Refinery Unit Operations#
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 aHeavyEndthe 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 aFlowsheet, 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 areDesalter,PreflashDrumandCrudeUnitWithPreheat.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 aFlowsheet.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_blocklinks the isomerate to a blend pool. The palette operations areIsomerizationReactorandIsomerizationUnit.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 aFlowsheettear 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 GAMSprocess.gms), shortcut DIB/depropanizer/debutanizer and the isobutane recycle as aFlowsheettear; alkylate toBlendPool,alky_blockfor 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), andh2_blockfor 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.jacobianuses onejax.jacfwd/jax.jacrevwhen 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 inz; beyond it, the least-squares line through the lastn_tailpoints. 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 belowT_max.The cuts run to
T_max(default 800 °C) onDEFAULT_HEAVY_CUT_WIDTHS. Everything above is one residue lump whoseTb,MWand optionallySGare 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 |
|---|---|---|---|
|
|
volume fraction |
columns |
|
|
mass fraction |
|
|
|
mass fraction |
the characterization’s own vectors |
|
|
share of the component’s S |
columns |
|
|
share of the component’s N |
columns |
|
|
-, g/cm³ |
n20 used for the estimate, density at 20 °C |
|
|
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:
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.d20comes from SG (API).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], withH = (1 - S - N)/(1 + C/H).A measured hydrogen content replaces either.
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 interceptRi = n20 - d20/2, the C/H weight ratio, andm = 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_vgcgives 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.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.
saturatesis split into paraffins and naphthenes in the estimate’s ratio.resinsandasphaltenescount 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/Handx_N = 0.52641 - 0.7494 x_P - 0.021811 m;MW > 200:
x_P = 1.9842 - 0.27722 Ri - 0.15643 C/Handx_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 VGCandx_N = 23.9825 - 23.333 Ri + 0.81517 VGC;MW > 200:
x_P = 2.5737 + 1.0133 Ri - 3.573 VGCandx_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)andw = (d - 0.8510) - 1.11 (n - 1.4750);%C_A = 430 v + 3660/Mfor v > 0, or670 v + 3660/Motherwise;%C_R = 820 w - 3 S + 10000/Mfor w > 0, or1440 w - 3 S + 10600/Motherwise;%C_N = %C_R - %C_Aand%C_P = 100 - %C_R;ring counts
R_A = 0.44 + 0.055 M v(0.080 for v < 0) andR_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)witht = 1e-3, then each row is normalized.Stream averages:
x_type = Σ φ_i x_type,i, with standard-volume fractionsφ_i ∝ m_i/SG_i, andH = Σ w_i H_i(likewise S, N and the classes), with mass fractionsw_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 ( |
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 |
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 |
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) |
|
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 |
|---|---|
|
rate of the distillate, a side product, |
|
reflux over overhead product, molar |
|
a main-column stage temperature (stage 0 is the condenser) |
|
the two pumparound freedoms |
|
liquid leaving the stage above the flash zone, as a fraction of crude |
|
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,volumeandbpd;yield_volumeandyield_massagainst the crude;sg,apiandmw;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, aStreamComposition; 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 ispreheat.T(°C), the furnace inlet temperature, except on aPreheatedCrudeUnit, 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_ratioandstage<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 |
|---|---|---|
|
bbl/d |
|
|
- |
volume fraction of the crude |
|
- |
mass fraction of the crude |
|
- |
|
|
API |
|
|
g/mol |
|
|
°C |
|
|
K |
TBP5(b) less TBP95(a) |
|
°C |
the effective cut point: the crude’s TBP at the cumulative yield through |
|
MW |
|
|
°C |
|
|
- |
|
|
MW |
|
|
kg/h |
|
|
- |
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.totalis 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 ofdifflow.units.heat_exchanger(and its F-factor whenshellsis given). An optionalbypasssends 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 keepswater_outof its volume as water. Salt removal is a fixedefficiency. 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 setvapor_fractionof 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=...)givesd(fired duty)/d(R_f)for every exchanger from one reverse-mode gradient.unit.cleaning_ranking(rate, T_tank, train=...)multiplies each sensitivity by itsR_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 |
|
|---|---|---|
|
°C |
|
|
m²K/kW |
the fouling resistance, so a delta vector reads per 1e-3 m²K/W |
|
m² |
|
|
- |
hot-stream fraction |
|
- |
wash water, standard-volume fraction of the crude |
|
bar |
or |
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 likeexp(-NTU). The LMTD ofdifflow.units.heat_exchangerfloors each terminal difference atMIN_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.
approachreports 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:
the distillate;
each side product, in the order the column declares them;
"residue";"water";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 fullCrudeUnitResult(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.correlationskeeps 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.VacuumColumnis 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.Assayandvacuum.characterizeare kept as a compatibility view. The oldAssay(Celsius, wt%) converts withto_assay().characterizeruns the shared characterization on a vacuum cut grid and returns the old(components, yields, light_ends, Kw)shape.vacuum.atmospheric_residueis 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 |
|
the mean of the TBP curve over the cut |
|
from a constant Watson K, fitted over all the cuts so the whole crude matches its bulk SG |
|
Twu (1984) |
|
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 |
|---|---|---|
|
a fraction set by the knobs (entrainment) |
none |
|
what the share routes leave |
none |
|
a softmax share of the rest |
one logit, with an equation |
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 |
|---|---|
|
|
|
|
|
|
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 defaultlvgo.T95 = 450 Cthen 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.T95spec.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):
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/dzis 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 alongzandTis algebraic in the gas-oil fraction (above).T_mix: adiabatic mixing at the riser base of regenerated catalyst atT_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 cokemetals_coke × (Ni+V)and contaminant H2metals_h2 × (Ni+V).Deactivation:
phi = exp(-alpha t_c)(time on stream, [F5]) orphi = 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 |
|---|---|---|---|
|
gas oil, gasoline, gas+coke |
GO→G, GO→C, G→C |
[F1] |
|
gas oil, gasoline, gas (C1-C4), coke |
GO→G, GO→gas, GO→coke, G→gas, G→coke |
[F2] |
|
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] |
|
– |
– |
[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 C3propylene_in_c3; olefin share of C4olefins_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’scorrelations].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 oil1 + cycle_oil_sulfur_slope × X), H2S takes the rest; nitrogenn_to_coketo coke, the rest to cycle oil; cycle-oil hydrogen by difference (reported ascycle_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 |
|
the spec; catalyst circulation follows from the heat balance |
Feed preheat |
|
|
Feed rate |
|
|
Regenerator air |
|
|
CO/CO2 |
|
|
Catalyst activity |
|
the parameter to track with |
Fractionator |
|
TBP cut points |
Kinetics |
|
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 |
|---|---|---|
|
|
fuel gas / amine |
|
|
alkylation (#310), polymer-grade propylene |
|
|
alkylation (#310): C4= olefins and isobutane |
|
|
|
|
|
diesel hydrotreating |
|
|
fuel oil |
|
|
|
|
|
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.jacfwdof conversion, gasoline yield, coke yield and regenerator T with respect to ROT, feed preheat and one VGO TBP point (450 °C, throughcharacterize) 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_5LUMPwas 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’sCompositionis 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, reachedmainafter this unit was built; routingc3/c4through 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. |
|
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 |
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 ( |
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 |
F12 |
ASTM D4737, Standard Test Method for Calculated Cetane Index by Four Variable Equation (edition unverified). |
LCO cetane index |
The plugin’s existing |
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) orNone(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) |
|---|---|---|
|
|
|
|
|
|
|
propane, butane |
|
|
isobutane, normal butane |
|
|
any two-product cut |
the cut placed by |
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.rvpand.tvp;reflux_ratio,boilup_ratio,condenser.duty,reboiler.duty,energy_balance;top.T,bottom.Tand everystage{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_oholds 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:
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.
Continuation of every target and efficiency to the user’s values.
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 returnsvalues, signedmargins(positive on spec) andon_spec. The vapour pressure is gauge, at 100 F, on the EOS.reid_vapor_pressure(flows, comps)andtrue_vapor_pressuregive 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>.Tand<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:
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 underjax.jit).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_changeis 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.Drops the water (and anything else in
drop=), reported asdropped_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 withsulfur_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_HCwith 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) raisesValueError, as theIsomerizationReactordoes; it is never dropped.The product separator is one equilibrium stage at
separator_T(a 1-trayGasPlantColumn). 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
GasPlantColumnsplitters: the gas plant’s Peng-Robinson MESH model on the sixteen species. The DIH has a side draw atdih_side_tray, at the ratedih_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_inof 160 C and above, the exotherm drives hydrocracking, which is itself exothermic and uses hydrogen. The bed then uses up its hydrogen.IsomerizationHydrogenWarningfires 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_recoveryreports this as 1 andinfo["stabilizer"]["spec_met"]as False.
Isomerization gotchas#
Forward mode only.
jax.gradthrough the reactor fails on the stage Newton’swhile_loop; usejax.jacfwdorjax.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_inin 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 sharedBlendCharacterization. The composition givesSG, 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 |
|---|---|---|---|
|
|
D86 10 % point of the stream (smoothed TBP, Riazi-Daubert TBP->D86) |
(unverified) |
|
ideal solubility of each cut’s n-paraffins, |
|
|
|
|
TBP 50 % point, SG |
(unverified) |
|
Abbott, Kaufmann & Domash (1971), API TDB 11A4.2, at 100 and 210 °F; ASTM D341 (Walther) line to |
TBP 50 % point, SG |
(unverified); |
|
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) |
|
(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 floorFREEZE_FLOOR_K(150 K).n_paraffin_share(default 0.5) is ILLUSTRATIVE.Smoke point: with
Tbin 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. WithTbin 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_cStisviscosity_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 |
|
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^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 |
|
by mass |
- |
Aromatics, olefins, benzene, PNA, smoke point |
|
by volume |
- |
Flash point |
|
Hu-Burns index, |
Hu & Burns (1970); identical to Wickey-Chittenden in °F |
Cloud, pour point |
|
|
Hu & Burns (1970) |
Freeze point |
|
|
omsQlibs Blending Quality Models Equations (2016), generic freeze index; its Ethyl index defaults to n = 12.5 |
CFPP |
|
|
no published index found; an assumption, override with |
Viscosity |
|
Refutas VBN |
Refutas, as given in Maples (2000); tested against a published worked example |
Distillation |
|
from the blend’s composition: smoothed TBP, then Riazi-Daubert TBP->D86 |
Riazi & Daubert (1986) |
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. Withweighted=Trueit returnsV * margininstead. A property is intensive, so it is 0/0 at an empty pool. The weighted form is the same constraint whereverV > 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)returnsmax(-margin, 0), or its softplust * logaddexp(-margin/t, 0). The smooth form is withint ln 2of the kink and its derivative at an active spec is exactly-1/2. The branchlessmax(u,0) + log1p(exp(-|u|))has the same value and a derivative of zero there; see the CVaR note indifflow.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: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);
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;paraffins are split into normal and iso by
iso_fraction, and C6 naphthenes into methylcyclopentane and cyclohexane bymcp_fraction. Neither split is in a Tb/SG characterization; the defaults (0.5, 0.6) are ILLUSTRATIVE and a PIONA replaces them;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 aFractionationResultproduct such as"heavy_naphtha") on its TREATED product grid: the same mapping asfrom_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 asfeed.sulfur_wppm, which the reformer’s trace-sulfur balance takes whenReformerParams.feed_sulfur_wppmisNone. See Hydrotreated naphtha to the splitter and the reformer.lean_naphtha()andrich_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 |
iso-paraffin |
naphthene |
aromatic |
|---|---|---|---|---|
6 |
n-hexane |
2-methylpentane |
cyclohexane |
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’sEOSFlash) at the separator temperature and pressure. The vapour is the flash’sV y; the liquid is the feed minus the vapour, so the component balance closes to round-off. PR runs with allk_ij = 0, hydrogen-hydrocarbon included, so the hydrogen dissolved in the liquid is PR’s unfitted prediction.RecycleSplitter: recycles enough of the vapour to carryH2_HCmol 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) reachedmain, 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 andc4_recoveryof the butanes to LPG, the rest to stabilized reformate.c4_recoverystands 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 ( |
Default |
|
|---|---|---|
|
773.15 K each |
reactor inlet temperatures; WAIT is their catalyst-weighted mean |
|
0.15, 0.30, 0.55 |
number of reactors = its length |
|
1.5 1/h, 700 kg/m3 |
catalyst mass = density x feed std volume per hour / LHSV (density ILLUSTRATIVE) |
|
12 bar, 3 bar |
reactors at |
|
311.15 K |
|
|
5 |
recycle H2 per naphtha hydrocarbon, mol/mol |
|
0.95 |
stabilizer butanes to LPG |
|
|
rate parameters, |
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-valueK = Psat(T)/P(Lee-Kesler, H2S critical constants from thechemicals1.5.2 PSRK table) against the separator’s own vapour and liquid flows;with a share
sof the vapour recycled, the steady state puts(1 - s) a G / (1 - s a)of an H2S makeGin the net gas and(1 - a) G / (1 - s a)in the separator liquid. The two add up toG, 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_ronreaches a RON target (95 on the lean feed) to 1e-3.The reformate enters a
BlendPoolas a property-modeBlendComponent.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.jacfwdof those 4x4 plus the eight finite-difference solves takes about eight minutes on one CPU core, mostly compilation of the traced recycle.Thermochemistry:
Hf,S0and Cp are the shared table’s (#339; unchanged by the move), pinned to their sources there;Hfagrees withdifflow.databasewithin 1 kJ/mol for the 16 species both hold;ln Kis 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
GibbsReactorcomparison 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 fromNaphthaFeed.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 |
Read from the |
Ideal-gas S0 (298.15 K, 1 bar) |
Yaws ideal-gas entropy table (as |
Read from |
Ideal-gas Cp |
TRC ideal-gas heat-capacity correlation (Thermodynamics Research Center), as tabulated in |
Fit within 1.4 % of the correlation; values at 298 K pinned. |
Tc, Pc, omega, Tb |
First-ranked source in |
Read from |
Liquid density at 60 F |
Perry’s Chemical Engineers’ Handbook, 8th ed., DIPPR-105 table (via |
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 blending |
Ethyl RT-70: Healy, Maassen & Peterson (1959), coefficients as in Maples (2000) |
As in the blend pool (see Blending rules). |
RVP |
|
As in the blend pool and |
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 |
|
Δ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 |
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 ism / 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>andF_<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_ijmatrix.kappais the 1976 quadratic up toomega = 0.491and 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 thethermopackage), H2S with every cut 0.0333 (ChemSep’s H2S/n-decane), every other pair zero. Illustrative; passkij=.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 throughdHvap(Tb). Gas constants: Tc, Pc and omega of H2, H2S and NH3 fromdifflow.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 inchemicals.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;Trcapped 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 (
zaboveTRACE = 1e-20of 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 getsx = 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 largeFlashResult.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’sconvergedincludes every flash (outputs["flash.residual"]), andcompressreturns 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
attributesandattribute_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 ofdifflow.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 ong(x, args) = xover the recycle-gas flows (and the compressor outlet temperature). It is a difflowFlowsheetrecycle in substance – a tear on the recycle gas, converged and implicitly differentiated – without theFlowsheetobject, 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 - Nby mass, so the cut’s mass from its atoms equalsF x MWexactly;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 |
|
2.0, 4.0, 3.0, 2.6, 3.95 |
-104.8, -261.2, -157.1, -83.4, -172.8 |
HDN, basic / non-basic |
|
4.0, 5.0 |
-238.5, -263.3 |
poly + 2 H2 <-> di |
|
2 |
dH(T): -114.8 at 25 °C, -124.8 at 350 °C |
di + 2 H2 <-> mono |
|
2 |
dH(T): -124.0, -132.1 |
mono + 3 H2 <-> naphthene |
|
3 |
dH(T): -206.1, -219.9 |
olefin + H2 -> paraffin |
|
1 |
-125.3 |
cracking leak: molecule + H2 -> lighter molecule + C1–C4 |
|
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 fallbackf 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)/RTfrom model-compound thermochemistry, with the heat capacities integrated from 298.15 K to the bed temperature:aromatic_ln_K(T), the shared table’sIdealGasSet.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 isaromatic_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 inT. Saturation is exothermic and loses moles of gas, so equilibrium recedes as temperature rises and aromatics pass through a minimum. Until #338Kused the 298 KdHanddSas constants; that made the benzene step’sK3.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 - 2per S,2 nu - 3per 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
ito the cut whose carbon number per molecule is nearest toi’s less the gas fragment’s (fixed at construction from the composition), splitting off one C1–C4 molecule in the proportionsCRACK_GAS_SPLIT(10/15/35/15/25 % C1/C2/C3/iC4/nC4, illustrative), with one H2.Deactivation is the
activitymultiplier a(t). It is a differentiable parameter, sodifflow.reconciliation.trackingcan 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 |
|
|---|---|---|
|
(613.15,) K |
bed inlet temperatures: the first only when |
|
(0.15,) |
quench into each later bed, fraction of the treat gas; |
|
(0.4, 0.6) |
catalyst split between beds |
|
50 bar |
reactor pressure (no bed pressure drop) |
|
1.0 h⁻¹ |
feed standard liquid volume per hour per catalyst volume |
|
800 kg/m³ |
loaded density |
|
300 Nm³/m³ |
treat-gas H2 to oil, including quench |
|
0.05 |
fraction of the scrubbed separator gas purged |
|
97 % H2, 3 % CH4 |
makeup-gas composition, |
|
0.85 |
charge-heater absorbed / fired duty (illustrative) |
|
|
charge-heater inlet temperature after a feed/effluent exchanger; |
|
0.99, 1.0 |
amine and wash-water removal fractions |
|
50 °C, 3 bar |
separator temperature; pressure drop round the loop (the compressor makes it up) |
|
0.75 |
isentropic efficiency |
|
230 °C, 7 bar, 1 kPa/stage |
stripper |
|
0.01 kg/kg, 250 °C |
stripping steam |
|
40 °C, 0.3 bar |
overhead drum |
|
6 |
|
|
|
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_dPbelow 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., viachemicals); 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_voland itsmono_/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 equals1 - feed.h2_water_rate/feed.rateplus 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) andnaphtha.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 bedbed<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.convergedrequires all three (flashes belowFLASH_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_streamrefuses species outside its grid, so a pool takesproduct_stream("wild_naphtha", gases=False), andoutputs["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.dutyis 0.heater_inlet_T=<K>: the oil and gas mixed at that temperature, after a feed/effluent exchanger.feed_effluent.dutyis that exchanger’s cold-side duty. Its hot side is not modelled (the reactor effluent goes to the separator athps_T), so a temperature cross is not checked: keepheater_inlet_Tbelow 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:wstands 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 anF_<product_char.names>stream, straight intoBlendComponent.from_stream(name, stream, fr.char);fr.ratesis kg/s per product.Cut points and width are traceable:
jax.gradof 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
HydrotreaterResultwith one outlet name or several (summed), taken with its dissolved gases;a
FractionationResultwith a product name;anything with
product_stream/product_char, such as a hydrocracker result;an explicit
F_stream together withchar=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_iat 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, sofeed.mass_flowequalsres.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_volandolefins_volafter saturation. The hydrotreater reports types on the rule it reads them in by, so an unreacted cut maps exactly asfrom_characterizationmaps 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’sS_ppm. The reformer takes it as the feed’s organic sulfur whenReformerParams.feed_sulfur_wppmisNone(#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: aGasComponentstable;stream: theF_<components.names>stream;mass_flow;dropped;below(T_cut): alight=set forsplitter.
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_dataconstants. Ammonia has none in the gas plant’s tables (no ideal-gas Cp or LHV), so it is refused, or left out withunsupported="drop"and reported indropped.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,Pcandomega, and a Watson-Nelson ideal-gas Cp from the grid’sTb,SGandMW(the correlationcharacterizeuses for its own cuts). OnlyMWandSGare changed by the hydrotreater:Tb,Tc,Pcandomegaare 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_characterizationdoes;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.jacfwdthrough 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
CrudeDistillationUnitas 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_tearagainst 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_blockdelta vectors passcheck_delta_vectors;the product enters
BlendPool("ulsd")throughBlendComponent.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=Falsedrops exactlynaphtha.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.jacfwdthroughtheta), 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.yieldpositive 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 returningconverged=Falsewith a warning instead of raising; and (release)NAPHTHA_HDT_PARAMSreaching 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 aromaticsln K(T)againsttc.ln_K(and its distance from the old constant-dH/dSform 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 |
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 |
|
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., |
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 |
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_mmoverride.No dedicated example notebook.
examples/40_refinery_flowsheet.ipynbruns 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
Flowsheetobject. The recycle is solved by the unit’s own tear (above); aFlowsheetwiring 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
StageColumnfractionator is not built.The charge heater has no feed/effluent exchanger hot side:
heater_inlet_Tis 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.trackingis 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 carriesF_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ω_SRKare 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 fromdifflow.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 |
|
per-olefin selectivity table ( |
Heavy ends |
|
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.1MON/K aboutT_ref = 283.15 K(about -0.55 per 10 °F);c_SV = -2MON 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 |
|
Lee & Kesler (1975) |
Non-key split |
|
Geddes (1958); Hengstebeck (1961) |
Minimum stages |
|
Fenske (1932) |
Minimum reflux |
|
Underwood (1948) |
Stages |
|
Gilliland (1940), Molokanov et al. (1972) form |
Duties |
CMO, saturated-liquid feed and products: |
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.
difflow’s
ShortcutColumnhad a sign error in its Hengstebeck-Geddes constants (now fixed). It usedA = log(d_LK/b_LK) - log(d_HK/b_HK)andC = 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).GeddesShortcutColumnremains as an alias, andtests/test_distillation.py::TestShortcutColumnNonKeyDistributionis the regression test.ShortcutColumnwith 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 asAlkylationUnitParams(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_minandN_mindiffer 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) |
|
8 |
Reactor temperature |
|
283.15 K |
Acid strength |
|
89 wt% |
Olefin space velocity |
|
0.3 1/h |
Olefin feed rate and composition |
the feed stream |
|
Key recoveries and reflux of each column |
|
table above |
Depropanizer slipstream |
|
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 |
|---|---|---|---|
|
bbl/d |
2709 |
2921 |
|
bbl/d |
4840 |
5234 |
|
- |
1.787 |
1.792 |
|
- |
1.746 |
1.746 |
|
- |
92.7 / 95.2 |
92.7 / 95.2 |
|
- |
0.704 |
0.700 |
|
psi |
2.39 |
2.66 |
|
°C |
92 / 106 / 120 |
79 / 99 / 120 |
|
bbl/d |
2969 / 1730 / 18 140 |
3356 / 2778 / 19 410 |
|
bbl/d |
102, 1083 |
437, 968 |
|
lb/bbl, 1000 lb/d |
35.7, 173 |
35.7, 187 |
|
MW |
4.0, 7.0 |
4.8, 8.0 |
|
MW |
29.9, 20.0 |
33.6, 22.8 |
|
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 |
|---|---|---|---|
|
1161.33660200 (MINLPLib primal bound for instance |
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 |
|
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 |
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 |
Reference optimum 1161.33660200 |
MINLPLib, instance |
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 |
COSTALD parameters V*, ω_SRK |
Hankinson & Thomson (1979), as tabulated in |
table number unverified; validated against GPA 2145 SGs (below) |
Standard SGs of light ends (check) |
GPA 2145, as in |
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 |
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 |
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 |
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) ( |
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 |
|
as in that module |
TBP → D86 |
Riazi & Daubert (1986), |
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), withKw_feedthe mass-average Watson K of the fresh feed’s cuts anddKw = +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’sDEFAULT_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 |
|
|---|---|---|
|
370 °C |
pretreat first-bed inlet |
|
(0.06,), (0.5, 0.5) |
quench to pretreat bed 2 (fraction of treat gas); catalyst split |
|
1.5 h⁻¹ |
on fresh feed |
|
380 °C |
inlet of every cracking bed when |
|
None |
or fixed quench fractions of the treat gas (a knife-edge: see below) |
|
(0.15, 0.18, 0.20, 0.22, 0.25) |
catalyst split, smaller beds first |
|
1.5 h⁻¹ |
on fresh feed (a recycle loads the same catalyst harder) |
|
150 bar |
uniform |
|
1500 Nm³/m³ |
treat gas H2 per fresh feed, quench included |
|
0.05, 99 % H2 / 1 % CH4 |
|
|
0.99, 1.0 |
amine / wash water |
|
50 °C, 8 bar, 0.75 |
|
|
0 |
fraction of UCO recycled |
|
85/165/260/370 °C, 15 K |
fractionator |
|
|
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)) andconversion.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_wppmand.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 identityoverall = 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")throughBlendComponent.from_streamwith the unit’s gravity, sulfur and cetane index;hcu_blockdelta vectors passingcheck_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_densityas 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 belowT_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: |
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 |
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 |
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 ( |
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_charfeedBlendComponent.from_stream.The fractionator is not a column (above), so no duties; jet smoke and freeze points are not computed.
Deactivation is the
activitymultiplier of either catalyst, a differentiable parameter thatdifflow.reconciliation.trackingcan track; not wired up or demonstrated.Not a difflow
Flowsheetobject: 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
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 |
|---|---|---|---|
|
sulfur atoms in asphaltene/resin molecules: the refractory residue sulfur |
S |
a share |
|
Ni + V atoms, as moles of a nominal metal of |
none (metals are outside a cut’s mass, as everywhere in the layout) |
|
|
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) |
|
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:
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 cutisends all of its atoms tom_i = n_C,i/n_C,jmolecules of the lighter cutj(i)whose carbon number is nearest half ofi’s (conversion_targets). Them_i - 1new 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.recyclehas 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 belowproduct_cut_Tgoes todistillate, and the rest toresidue. 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, andsolve(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, andconversion(of the cuts at or aboveT_conv);hydrogen:
h2.chemical,h2.chemical_nm3_m3,h2.chemical_wt, andh2s.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 |
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.depositis reported, andactivityis a differentiable multiplier thatdifflow.reconciliation.trackingcould 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:
VacuumColumnon 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’snet_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=...), orProducer.from_flows(name, {gas: mol/s}). A shareto_psaof each can go through the header’s PSA.PSA (
PSA(recovery, purity)): product H2 =R s E_H2; product impuritiesR s E_H2 (1 - y_P)/y_P, split like the feed’s; tail gas the rest, to fuel gas.s = 1, or withtarget_puritythe share for which the header purity equals the target, which is linear ins:s = (y* B - A) / (E_H2 (R - 1) - y* (R E_H2/y_P - E)), clipped to[0, 1](<h>.psa.target_erroris 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 deficitneed = sum_j d_j + min_purge - S_H2, in order, eachclip(need, 0, capacity).Consumers (
Consumer) take a makeup H2 flowd_j(theHydrotreater’sh2.makeupoutput: chemical consumption, solution loss and the purge’s H2) at the header’s composition, so their total makeup isd_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 ofy -> 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, andres.feasible["<h>.balanced"]isFalse.
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 |
|
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"isjax.jacfwdof the whole chain, and"rev"isjax.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, anddx_k/dx_0is 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: aNaphthaFeedis 32 numbers, while a wholeHydrotreaterResultis thousands.vectorize=Falsepushes the tangents (or cotangents) one at a time instead of in avmap. 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:
Make every unit forward-capable, then use one
jax.jacfwd. The hydrotreater can be built withHydrotreaterParams(reactor=ReactorOptions(adjoint="forward")). Its beds are then integrated withdiffrax.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.Keep the units as they are and use
method="chain". It gives the same Jacobian (tests/refinery/test_plant_chain.pychecks 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 ( |
fwd + rev |
EO MESH Newton on stop-gradient inputs, then one Newton step with the converged Jacobian |
– |
|
Vacuum unit ( |
fwd + rev |
stage-network Newton, implicit step reusing the converged Jacobian |
– |
|
Gas plant ( |
fwd + rev |
the vacuum machinery on |
– |
|
Gas compressor, amine treater |
fwd + rev |
closed-form, pure JAX |
– |
|
Hydrotreater ( |
rev |
beds by diffrax |
|
|
Residue desulfurizer ( |
rev |
checkpointed bed adjoints; quench mixing by implicit Newton |
|
|
Hydrocracker ( |
rev |
checkpointed beds and the UCO recycle’s Anderson fixed point with a GMRES adjoint (a |
none |
|
Catalytic reformer ( |
fwd |
|
none for the unit; |
|
FCC ( |
fwd + rev |
constant-step Tsit5 with |
– |
|
Isomerization ( |
fwd |
with a DIH, the recycle is converged by a Python Anderson loop and differentiated at its solution by a |
– |
|
Alkylation ( |
fwd |
DIB-overhead tear by the |
– |
|
Preheat train ( |
fwd + rev |
outer Newton with an implicit step |
– |
|
Blend pool, property estimates |
fwd + rev |
closed-form, pure JAX |
– |
|
Adapters ( |
fwd + rev |
sums, sigmoid splits, lumping; |
– |
|
Hydrogen network ( |
fwd + rev |
pure JAX; the swing clips and the PSA share are kinks |
– |
|
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), |
227 s |
2.4 s |
|
The same, |
132 s |
1.0 s |
|
NHT → fractionator → reformer → gasoline pool, 2 inputs × 5 outputs, |
487 s (435 s inside example 40, after |
34 s (31 s) |
7.1 GB (the whole process, prototype) |
The same, stages not jitted, |
671 s |
511 s |
8.6 GB |
The same, stages not jitted, |
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
Flowsheetwith a Python-level recycle. Under a transform it switches to its traced path, which is built afresh on every call. Withoutjit, 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. WithStage(..., 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
xis differentiated.Warm starts. Pass the reformer
tear_initial=from a converged solve, and useon_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_loopiterates 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.
Elements (H2, N2, O2): zero.
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).
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).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.
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.databaseis difflow core’s table, with its own users. Its waterHfis the liquid’s. No refinery module reads itsHf. 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_IGand the hydroprocessing separators’_CP_IG_GASESare 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 usedifflow.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 |
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 |
|
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 |
|---|---|---|---|---|
|
0 |
0 |
130.7 |
130.68 |
|
-45.558 |
-45.94 |
192.8 |
192.77 |
|
83.18 |
82.93 |
269.2 |
269.18 |
|
-122.08 |
-123.13 |
298.19 |
297.31 |
|
150.6 |
150.58 |
333.1 |
333.6 |
|
26 |
26.61 |
366.22 |
366.22 |
|
207.5 |
207.1 |
396.01 |
396.01 |
|
92.3 |
92.3 |
– (di step’s dS used) |
428.63 (estimate) |
|
-83.5 |
-83.47 |
368.1 |
368.32 |
|
-83.78 |
-83.85 |
229.2 |
229.45 |
|
-125.85 |
-125.65 |
304.4 |
304.4 |
|
166.3 |
166.3 |
212.76 |
– (rejected) |
|
29.9 |
29.79 |
360.6 |
361.24 |
|
181.4 |
182.09 |
391.24 |
391.24 |
|
7.9 |
7.9 |
397.86 |
399.08 |
|
200.7 |
200.7 |
244.95 |
– (rejected) |
|
-43.5 |
-41.67 |
383.84 |
383.84 |
|
-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, |
-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, |
-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 |
|---|---|---|---|---|---|---|---|---|
|
H2 |
0 |
element |
definition |
130.68 |
CODATA |
TRC fit (#309) |
298.15-1000, 0.5 % |
|
N2 |
0 |
element |
definition |
191.609 |
CODATA |
JANAF (Shomate) fit |
298.15-1500, 0.5 % |
|
O2 |
0 |
element |
definition |
205.152 |
CODATA |
JANAF (Shomate) fit |
298.15-1500, 1.1 % |
|
H2O |
-241.826 |
CODATA |
key value |
188.835 |
CODATA |
JANAF fit |
298.15-1500, 0.2 % |
|
CO |
-110.53 |
CODATA |
key value |
197.66 |
CODATA |
JANAF fit |
298.15-1500, 0.5 % |
|
CO2 |
-393.51 |
CODATA |
key value |
213.785 |
CODATA |
JANAF fit |
298.15-1500, 0.3 % |
|
O2S |
-296.81 |
CODATA |
key value |
248.223 |
CODATA |
JANAF fit |
298.15-1500, 0.2 % |
|
H2S |
-20.6 |
CODATA |
key value |
205.81 |
CODATA |
JANAF fit |
298.15-1000, 0.0 % |
|
H3N |
-45.94 |
CODATA |
key value |
192.77 |
CODATA |
JANAF fit |
298.15-1000, 0.3 % |
|
CH4 |
-74.52 |
API TDB |
cross-checked |
186.6 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.8 % |
|
C2H6 |
-83.85 |
API TDB |
cross-checked |
229.45 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.8 % |
|
C3H8 |
-104.69 |
API TDB |
cross-checked |
270.28 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.8 % |
|
C4H10 |
-134.99 |
API TDB |
cross-checked |
295.34 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.8 % |
|
C4H10 |
-125.65 |
API TDB |
cross-checked |
304.4 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.9 % |
|
C5H12 |
-153.7 |
API TDB |
cross-checked |
343.89 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.5 % |
|
C5H12 |
-146.71 |
API TDB |
cross-checked |
349.25 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.9 % |
|
C5H12 |
-168.07 |
API TDB |
cross-checked |
305.99 |
Yaws |
TRC fit |
298.15-1000, 0.2 % |
|
C6H14 |
-166.95 |
API TDB |
cross-checked |
388.74 |
Yaws |
TRC fit (#309) |
298.15-1000, 1.0 % |
|
C6H14 |
-174.69 |
API TDB |
cross-checked |
380.69 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.4 % |
|
C6H14 |
-172.06 |
API TDB |
cross-checked |
383.04 |
Yaws |
TRC fit |
298.15-1000, 0.5 % |
|
C6H14 |
-184.68 |
API TDB |
cross-checked |
358.22 |
Yaws |
TRC fit |
298.15-1000, 0.4 % |
|
C6H14 |
-176.8 |
API TDB |
cross-checked |
365.94 |
Yaws |
TRC fit |
298.15-1000, 0.1 % |
|
C7H16 |
-187.65 |
API TDB |
cross-checked |
428.23 |
Yaws |
TRC fit (#309) |
298.15-1000, 1.0 % |
|
C7H16 |
-194.5 |
CRC |
unverified |
420.52 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.5 % |
|
C7H16 |
-198.7 |
CRC |
unverified |
415.15 |
Yaws |
TRC fit |
298.15-1000, 0.0 % |
|
C7H16 |
-201.67 |
API TDB |
cross-checked |
397.38 |
Yaws |
TRC fit |
298.15-1000, 0.1 % |
|
C8H18 |
-208.82 |
API TDB |
cross-checked |
467.05 |
Yaws |
TRC fit (#309) |
298.15-1000, 1.0 % |
|
C8H18 |
-215.35 |
API TDB |
cross-checked |
459.34 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.7 % |
|
C8H18 |
-224.01 |
API TDB |
cross-checked |
423.11 |
Yaws |
TRC fit |
298.15-1000, 0.2 % |
|
C8H18 |
-217.32 |
API TDB |
cross-checked |
428.48 |
Yaws |
TRC fit |
298.15-1000, 0.3 % |
|
C8H18 |
-222.51 |
API TDB |
cross-checked |
442.57 |
Yaws |
TRC fit |
298.15-1000, 0.3 % |
|
C9H20 |
-228.86 |
API TDB |
cross-checked |
507.08 |
Yaws |
TRC fit (#309) |
298.15-1000, 1.0 % |
|
C9H20 |
-235.85 |
API TDB |
unverified |
499.16 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.7 % |
|
C9H20 |
-253.3 |
API TDB |
unverified |
461.93 |
Yaws |
TRC fit |
298.15-1000, 0.3 % |
|
C10H22 |
-249.53 |
API TDB |
cross-checked |
546.36 |
Yaws |
TRC fit (#309) |
298.15-1000, 1.0 % |
|
C10H22 |
-256.52 |
API TDB |
unverified |
539.32 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.6 % |
|
C12H26 |
-290.79 |
API TDB |
cross-checked |
625.21 |
Yaws |
TRC fit |
298.15-1000, 1.0 % |
|
C2H4 |
52.28 |
API TDB |
cross-checked |
219.18 |
Yaws |
TRC fit |
298.15-1000, 1.4 % |
|
C3H6 |
19.71 |
API TDB |
cross-checked |
266.71 |
Yaws |
TRC fit |
298.15-1000, 0.6 % |
|
C4H8 |
0.1 |
CRC |
cross-checked |
307.88 |
Yaws |
TRC fit |
298.15-1000, 0.5 % |
|
C4H8 |
-6.99 |
API TDB |
cross-checked |
301.17 |
Yaws |
TRC fit |
298.15-1000, 0.8 % |
|
C4H8 |
-11.17 |
API TDB |
cross-checked |
296.48 |
Yaws |
TRC fit |
298.15-1000, 0.6 % |
|
C4H8 |
-16.9 |
API TDB |
cross-checked |
293.12 |
Yaws |
TRC fit |
298.15-1000, 0.4 % |
|
C5H10 |
-20.92 |
API TDB |
cross-checked |
347.03 |
Yaws |
TRC fit |
298.15-1000, 0.8 % |
|
C5H10 |
-42.55 |
API TDB |
cross-checked |
338.65 |
Yaws |
TRC fit |
298.15-1000, 0.7 % |
|
C6H12 |
-41.67 |
API TDB |
unverified |
383.84 |
Yaws |
TRC fit |
298.15-1000, 0.8 % |
|
C6H12 |
-106.69 |
API TDB |
cross-checked |
339.9 |
Yaws |
TRC fit (#309) |
298.15-1000, 1.2 % |
|
C6H12 |
-123.13 |
API TDB |
cross-checked |
297.31 |
Yaws |
TRC fit (#309) |
298.15-1000, 1.4 % |
|
C7H14 |
-154.77 |
API TDB |
cross-checked |
343.5 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.6 % |
|
C8H16 |
-171.75 |
API TDB |
cross-checked |
382.99 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.6 % |
|
C9H18 |
-193.3 |
API TDB |
cross-checked |
419.97 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.6 % |
|
C10H20 |
-213.17 |
API TDB |
cross-checked |
459.59 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.7 % |
|
C10H18 |
-182.1 |
CRC |
unverified |
373.89 |
Yaws |
TRC fit |
298.15-1000, 2.0 % |
|
C6H6 |
82.93 |
API TDB |
cross-checked |
269.18 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.4 % |
|
C7H8 |
50.17 |
API TDB |
cross-checked |
321.08 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.8 % |
|
C8H10 |
29.79 |
API TDB |
cross-checked |
361.24 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.8 % |
|
C9H12 |
7.9 |
API TDB |
cross-checked |
399.08 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.8 % |
|
C10H14 |
-13.14 |
API TDB |
cross-checked |
440.28 |
Yaws |
TRC fit (#309) |
298.15-1000, 0.9 % |
|
C12H16 |
-16.7 |
CRC |
unverified |
429.36 |
Yaws |
Joback |
298.15-1000, estimate |
|
C10H12 |
26.61 |
API TDB |
cross-checked |
366.22 |
Yaws |
TRC fit |
298.15-1000, 0.5 % |
|
C10H8 |
150.58 |
API TDB |
cross-checked |
333.6 |
Yaws |
TRC fit |
298.15-1000, 0.6 % |
|
C12H10 |
182.09 |
API TDB |
cross-checked |
391.24 |
Yaws |
TRC fit |
298.15-1000, 0.4 % |
|
C14H14 |
116.57 |
estimate |
estimate |
– |
– |
TRC fit |
298.15-1000, 0.4 % |
|
C14H20 |
-81.1 |
estimate |
estimate |
– |
– |
Joback |
298.15-1000, estimate |
|
C14H10 |
207.1 |
API TDB |
cross-checked |
396.01 |
Yaws |
TRC fit |
298.15-1000, 0.4 % |
|
C14H14 |
92.3 |
NIST (unverified) |
unverified |
428.63 |
estimate |
TRC fit |
298.15-1000, 0.7 % |
|
C4H10S |
-83.47 |
API TDB |
cross-checked |
368.32 |
Yaws |
TRC fit |
298.15-1000, 0.8 % |
|
C4H4S |
114.9 |
CRC |
unverified |
278.81 |
Yaws |
TRC fit |
298.15-1000, 0.2 % |
|
C8H6S |
166.3 |
CRC |
unverified |
– |
– |
Joback |
298.15-1000, estimate |
|
C12H8S |
205.1 |
CRC |
unverified |
– |
– |
Joback |
298.15-1000, estimate |
|
C14H12S |
139.58 |
estimate |
estimate |
– |
– |
Joback |
298.15-1000, estimate |
|
C5H5N |
140.16 |
API TDB |
cross-checked |
282.5 |
Yaws |
TRC fit |
298.15-1000, 0.8 % |
|
C9H7N |
200.5 |
CRC |
unverified |
366.04 |
Yaws |
Joback |
298.15-1000, estimate |
|
C8H7N |
156.6 |
API TDB |
cross-checked |
328.44 |
Yaws |
Joback |
298.15-1000, estimate |
|
C12H9N |
200.7 |
CRC |
unverified |
– |
– |
Joback |
298.15-1000, estimate |
|
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 |
|
Watson K, Lee-Kesler Psat, Riedel latent heat at Tb |
to the printed digits |
|
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 |
|
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:
With the overflash spec relaxed and the HVGO draw held, so the wash bed stays wet.
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 |
|
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) |
|
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, whichdifflow.units.heat_exchangeralready 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.jacfwdof the furnace inlet temperature, the fired duty and the drum vapour with respect to E6’s area, E8’sR_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 |
|
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
TrayColumnwas 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 onSmoothVLE’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 |
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,LHSVandx_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_inon 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 |
|---|---|
|
one DWSIM |
|
a |
|
phase fractions, compositions, |
|
a hypothetical compound on given constants; |
|
DWSIM’s own distillation-curve characterization, its pseudo-component table |
|
DWSIM’s bulk C7+ characterization |
|
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_ONLYfor the cubics: liquid density from the EOS instead of Rackett with Peneloux;IDEAL_RAOULTfor 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, |
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 |
|
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_residualand 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’sH - T S. Two details differ from difflow: the Cp integrals are midpoint-rule quadratures (as in the harness’s enthalpies), andR = 8.314. For the hypos the emulation reproduces DWSIM’sint Cp dT,int Cp/T dTand 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
libIpopt39is 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
InitializeFromPreviousSolutionis 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 ( |
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) |
|
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 |
|
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 ( |
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 |
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 |
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.pywritesdwsim_gasplant_reference.json. It holds three rigorous columns, the wet-gas compressor train and two vapour pressures; the cases are indwsim_gasplant_case.py.tests/refinery/reference/dwsim_hps_generate.pywritesdwsim_hps_reference.json. It holds a solved hydrotreater’s reactor effluent flashed at HP-separator conditions; the case is indwsim_hps_case.py.tests/refinery/reference/dwsim_lightends.pyholds 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.
SolvingMethodNameis 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/Kvaluesare empty; the profiles are the column’sTf,Lf,Vf,xf,yfandKf. 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)), andtest_gasplant.py::test_solve_T_convergeschecks 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) |
|
3e-16 |
the same equations |
MW Riazi (1986) |
|
0.50-0.76 % high, reproduced to 1e-15 |
DWSIM writes |
Tc Lee-Kesler (1976) |
|
0.012 K |
constants rounded in DWSIM’s kelvin form |
Pc Lee-Kesler (1976) |
|
9.869x too high |
DWSIM bug: a pressure in bar multiplied by |
MW Lee-Kesler (1974) |
|
13-180 g/mol off (negative for the lightest cuts); with one sign flipped, 0.5 g/mol |
DWSIM bug: |
omega Lee-Kesler (1976) |
|
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:
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.
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.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.
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.
Ideal-gas Cp is Lee-Kesler’s from Watson K and omega; difflow’s is Watson-Nelson’s.
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
HeavyEndextends 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 – |
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 |
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_HVAPihas 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_CalcEnthalpyon the same phases gives the 42.30 / 38.34 MW above, the stream values 15.06 / 11.09 MW. The table usesDW_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, |
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.
CrudeUnitandCrudeDistillationUnittake the crude at the furnace inlet.PreheatedCrudeUnitandCrudeUnitWithPreheatadd 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 aFlowsheet(above).The preheat train has no hydraulics or geometry. Its exchangers are
U, area andR_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
TrayColumnon 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
GibbsReactoron the same thermochemistry; no published case study was found (validation).