Streams and Flowsheets#
This document covers stream handling, flowsheet management, and recycle calculations in Difflow.
Table of Contents#
Stream Representation#
Location: difflow/streams.py
Stream Structure#
In Difflow, streams are represented as Python dictionaries containing:
Molar flows for each species (
F_species)Temperature (
Tin Kelvin)Pressure (
Pin Pascal)
# Example stream structure
stream = {
'F_methane': 10.0, # mol/s
'F_ethane': 5.0, # mol/s
'F_propane': 2.0, # mol/s
'T': 300.0, # K
'P': 500000.0 # Pa
}
Creating Streams#
from difflow.streams import make_stream
# Create a stream
stream = make_stream(
flows={'methane': 10.0, 'ethane': 5.0, 'propane': 2.0},
T=300.0,
P=500000.0
)
Parameters:
flows: Dictionary of species names to molar flow rates (mol/s)T: Temperature (K)P: Pressure (Pa)
Returns: Stream dictionary with F_ prefix added to species names
JAX Pytree Compatibility#
Streams are designed as JAX pytrees, making them compatible with:
jax.grad(): Automatic differentiationjax.jit(): Just-in-time compilationjax.vmap(): Vectorizationjax.lax.scan(): Efficient loops
import jax
import jax.numpy as jnp
# Streams work seamlessly with JAX transformations
@jax.jit
def process_stream(stream):
total = stream['F_methane'] + stream['F_ethane'] + stream['F_propane']
return total
# Gradient through stream operations: a first-order A -> B CSTR
from difflow.units.cstr import CSTR, CSTRParams
def rate_fn(C, T, rp):
return jnp.array([rp['k0'] * jnp.exp(-rp['Ea'] / 8.314 * (1.0 / T - 1.0 / 350.0)) * C['A']])
reactor = CSTR(CSTRParams(V=1.0, rate_fn=rate_fn, stoich=jnp.array([[-1.0], [1.0]]),
rate_params={'k0': 0.1, 'Ea': 50000.0},
species_order=['A', 'B'], molar_density=10.0))
def objective(inlet_T):
stream = make_stream({'A': 1.0, 'B': 0.0}, T=inlet_T, P=101325.0)
outlet, _ = reactor(stream, T_spec=inlet_T)
return outlet['F_B']
grad_fn = jax.grad(objective)
sensitivity = grad_fn(350.0)
Stream Operations#
Basic Stream Functions#
from difflow.streams import (
get_flows,
get_species,
get_flow_array,
total_flow,
mole_fractions,
combine_streams,
scale_stream
)
Get Molar Flows#
# Extract flows as dictionary (without F_ prefix)
flows = get_flows(stream)
# {'methane': 10.0, 'ethane': 5.0, 'propane': 2.0}
# Get list of species names
species = get_species(stream)
# ['methane', 'ethane', 'propane']
# Get flows as JAX array (in specified order)
flow_array = get_flow_array(stream, species_order=['methane', 'ethane', 'propane'])
# Array([10., 5., 2.])
Total Flow Rate#
total = total_flow(stream) # 17.0 mol/s
Equation: $\(F_{total} = \sum_i F_i\)$
Mole Fractions#
x = mole_fractions(stream)
# {'methane': 0.588, 'ethane': 0.294, 'propane': 0.118}
Equation: $\(x_i = \frac{F_i}{\sum_j F_j}\)$
Combining Streams#
# Combine multiple streams (adiabatic mixing)
stream1 = make_stream({'A': 1.0, 'B': 0.5}, T=350.0, P=101325.0)
stream2 = make_stream({'B': 0.3, 'C': 0.2}, T=360.0, P=101325.0)
combined = combine_streams(stream1, stream2)
Equations:
Mass balance: $\(F_{i,out} = \sum_k F_{i,k}\)$
Energy balance (simplified, equal Cp): $\(T_{out} = \frac{\sum_k F_k T_k}{\sum_k F_k}\)$
Pressure (minimum): $\(P_{out} = \min(P_1, P_2, ...)\)$
Scaling Streams#
# Scale all flows by a factor
scaled = scale_stream(stream, factor=0.5) # 50% of original flows
Equation: $\(F_{i,out} = \alpha \cdot F_{i,in}\)$
Temperature and pressure are preserved.
Flowsheet Management#
Location: difflow/flowsheet.py
Flowsheet Class#
The Flowsheet class manages sequential modular process simulation.
from difflow.flowsheet import Flowsheet, Unit
class Flowsheet:
def __init__(self):
self.feeds = {} # Feed streams
self.units = [] # Unit operations
self.recycles = [] # Recycle connections
self.streams = {} # Computed stream results
def add_feed(self, name: str, stream: dict): ...
def add_unit(self, unit: Unit): ...
def add_recycle(self, source: str, dest: str): ...
def solve(self, tol: float = 1e-6, max_iter: int = 100): ...
Unit Definition#
from dataclasses import dataclass
from typing import Callable, List, Dict, Any
@dataclass
class Unit:
name: str # Unique identifier
operation: Callable # Unit operation function
inlet_names: List[str] # Names of inlet streams
outlet_names: List[str] # Names of outlet streams
params: Dict[str, Any] # Operating parameters
Building a Flowsheet#
import jax.numpy as jnp
from difflow.flowsheet import Flowsheet, Unit
from difflow.streams import make_stream
from difflow.thermo import IdealThermo
from difflow.database import get_species_data
from difflow.kinetics import mass_action_kinetics
from difflow.units.cstr import CSTR, CSTRParams
from difflow.units.flash import Flash, FlashParams, Mixer, Splitter
# Isomerisation n-hexane -> 2-methylpentane
species = ['n_hexane', '2_methylpentane']
thermo = IdealThermo({s: get_species_data(s) for s in species})
# Declarative mass-action kinetics (data, so the flowsheet can be serialized later)
kin = mass_action_kinetics(
[{'reactants': {species[0]: 1.0}, 'products': {species[1]: 1.0},
'rate_params': {'A': 0.05}}], # k = 0.05 1/s
species_order=species,
)
reactor_params = CSTRParams(V=1.0, molar_density=10.0, **kin.params_kwargs())
feed = make_stream({species[0]: 1.0, species[1]: 0.0}, T=340.0, P=101325.0)
mixer = Mixer(species, thermo)
reactor = CSTR(reactor_params, thermo)
flash = Flash(FlashParams(species_order=species), thermo)
splitter = Splitter(species)
# Initialize flowsheet
fs = Flowsheet(species_order=species)
# Add feed streams
fs.add_feed('fresh_feed', feed)
# Add mixer (combines fresh feed and recycle)
fs.add_unit(Unit(
name='mixer',
operation=mixer,
inlet_names=['fresh_feed', 'recycle'],
outlet_names=['reactor_feed'],
params={}
))
# Add reactor
fs.add_unit(Unit(
name='reactor',
operation=reactor,
inlet_names=['reactor_feed'],
outlet_names=['reactor_effluent'],
params={'T_spec': 340.0}
))
# Add flash drum (Flash returns liquid, then vapor)
fs.add_unit(Unit(
name='flash',
operation=flash,
inlet_names=['reactor_effluent'],
outlet_names=['liquid', 'vapor_product'],
params={'T': 337.0}
))
# Add splitter for recycle
fs.add_unit(Unit(
name='splitter',
operation=splitter,
inlet_names=['liquid'],
outlet_names=['recycle', 'liquid_product'],
params={'split_frac': 0.8} # fraction to the first outlet
))
# Define recycle connection
fs.add_recycle(source='recycle', dest='recycle') # same name: the splitter outlet feeds the mixer inlet
# Solve flowsheet
results = fs.solve(tol=1e-6, max_iter=50)
Sequential Modular Solution#
The flowsheet solver uses sequential modular approach:
Tear streams: Identify recycle streams to “tear”
Initialize: Set initial guesses for tear streams (see Where the initial guess comes from)
Sequential calculation: Solve units in order
Update tear streams: Compare calculated vs assumed
Iterate: Repeat until convergence
┌─────────────────────────────────────────────────────┐
│ Flowsheet │
│ │
│ Fresh Feed ──┬──► Mixer ──► Reactor ──► Flash │
│ │ │ │ │
│ │ │ ┌────┴────┐ │
│ │ │ ▼ ▼ │
│ │ │ Vapor Liquid │
│ │ │ │ │
│ │ │ ┌──────┴──────┐ │
│ │ │ ▼ ▼ │
│ │ (Tear Stream) Product│
│ │ │ │
│ └──────────────┘ │
│ Recycle │
└─────────────────────────────────────────────────────┘
Recycle Calculations#
Choosing the Tear Streams#
Where a loop is torn is not a formality. The tear is the fixed point the solve iterates on, so its placement sets the spectral radius of the map — how fast the loop converges, and sometimes whether it converges at all.
By default that choice is yours and only yours: add_recycle(source, dest)
tears exactly where you said, and a loop closed in the topology but never
declared is not torn at all (the sequential pass hits an inlet nothing has
written yet and raises KeyError). tear_analysis() says what difflow
would have picked, without solving anything and without running a single
unit:
print(fs.tear_analysis())
For a variant of the flowsheet above in which the splitter also sends a stream
short straight back to the mixer, the report reads:
Tear analysis: 2 recycle loop(s)
loop: mixer -> reactor -> splitter -> flash -> mixer
loop: mixer -> reactor -> splitter -> mixer
declared tears: recycle
heuristic would: recycle, mixed
minimum would: mixed
NOT TORN: mixer -> reactor -> splitter -> mixer
inlets read before they are written: short
That flowsheet has two loops and one add_recycle. The second loop, the
splitter’s short stream back to the mixer, is closed in the topology and
torn nowhere — which is why the units cannot run in the order they were
added, and why solve() raises KeyError: 'short' rather than iterating
on it. The heuristic keeps the declared tear and adds one for the loop that
has none; minimum notes that tearing mixed alone would break both.
The returned TearAnalysis carries the same thing as data — cycles,
declared, heuristic, minimum, uncovered, missing_inputs,
out_of_order, and torn, which is True when the declared tears break
every loop that was found. The last three are the ones worth checking on a
flowsheet that will not run: missing_inputs names inlets nothing
supplies, and out_of_order names inlets read before they are written —
a unit order that needs a tear it does not have.
Tearing Automatically#
solve(tears="auto") fills the gap when no recycle has been declared:
auto_fs = Flowsheet(species)
auto_fs.add_feed("feed", feed)
auto_fs.add_unit(Unit("mixer", mixer, ["feed", "recycle"], ["mixed"]))
auto_fs.add_unit(Unit("reactor", reactor, ["mixed"], ["rx_out"], {"T_spec": 340.0}))
auto_fs.add_unit(Unit("splitter", splitter, ["rx_out"], ["product", "recycle"], {"split_frac": 0.7}))
streams = auto_fs.solve(tears="auto") # no add_recycle anywhere
auto_fs.last_solve_tear_streams # -> ['mixed'], what it chose
Two strategies, and tears= names them: "auto" ("heuristic") takes one
tear per loop, preferring a stream the user declared, then one lying on
several loops, then one leaving a mixing point — where the stream is the
sum of everything entering it, so a guess that is wrong in composition is
still right in order of magnitude. "minimum" instead covers every loop
with as few tears as it can (greedily: an exact minimum tear set is
NP-hard).
Tearing somewhere other than where the units happen to be listed means the
units run in a different order, and calculation_order works it out: tear
mixed in the loop above and the reactor runs first, the mixer last. The
fixed point is the same one.
Two things it deliberately does not do. A declared recycle always wins —
an automatic choice fills a gap rather than overruling a decision someone
made — and the choice is not kept: fs.recycles and fs.units are
exactly what you declared once the solve returns, so nothing about a later
solve() changes. Inferring tears silently would change the answer for
flowsheets that already run, which is why the default stays "declared".
The pieces are usable on their own, against any flowsheet:
from difflow import (
FlowsheetGraph, find_cycles, select_tear_streams, calculation_order,
)
graph = FlowsheetGraph.from_flowsheet(fs) # units, streams, edges
find_cycles(graph) # elementary cycles, as unit names
tears = select_tear_streams(fs, method="minimum")
calculation_order(graph, tears) # unit order once those are seeded
find_cycles enumerates elementary cycles, of which a densely recycled
graph can have exponentially many; max_cycles bounds the work and a
CycleEnumerationWarning says when it was hit.
Three methods iterate on the tear streams, selected with acceleration.
Convergence and Initialization covers all of this in
depth — where the initial guess comes from, what each method costs, and
what to reach for when a loop will not close. The short version:
Direct Substitution#
acceleration="none": fixed-point iteration on tear streams, run through
optimistix, taking a fraction damping of each step.
Where \(\mathbf{x}\) is the tear stream vector, \(f\) is the flowsheet calculation
and \(\alpha\) is damping (1.0 by default, i.e. plain substitution). Damping
leaves the fixed point alone and makes the iteration contractive where \(f\)
overshoots. This is also the path a traced solve falls back to, because it is
the only one without a Python branch on the residual.
Wegstein Acceleration#
acceleration="wegstein": estimates the tear map’s slope from the last two
iterates and extrapolates.
\(q\) is the weight on the old iterate, bounded to \(q \in [-5, 0]\) for stability. The bound is what makes this the method for an oscillating loop.
Anderson Acceleration#
acceleration="anderson" (the default): keeps a history of anderson_depth
iterates and solves a small least-squares problem for the combination that
minimises the residual — equivalent to GMRES on the fixed-point iteration,
and exact on an affine map.
Convergence Parameters#
results = fs.solve(
tol=1e-6, # Convergence tolerance
max_iter=100, # Maximum iterations
acceleration='wegstein', # 'none', 'wegstein' (default: 'anderson')
anderson_depth=5, # History depth, for acceleration='anderson'
damping=1.0, # Step fraction, for acceleration='none'
clip_negative_flows=True, # False for signed tear flows (e.g. gas networks);
# binds on the accelerated paths only
error_probe=2, # extra passes measuring the error tol does not
tol_basis='step', # 'error' to make tol mean the answer's accuracy
)
When the Solve Does Not Converge#
A tear iteration that runs out of iterations returns anyway, and what it returns is the last iterate: every stream present, every number plausible, and the material balance around the loop off by the tear residual. For a loop that is limit-cycling that error is not small.
solve() says so rather than leaving it to the caller to go looking:
streams = fs.solve() # ConvergenceWarning if it didn't
streams = fs.solve(on_nonconvergence="raise") # ConvergenceError instead
streams = fs.solve(on_nonconvergence="ignore")# silent; check last_solve_* yourself
The message names the tear streams, the final residual, the tolerance, the
iteration count and the method used — the same last_solve_* diagnostics the
report layer and the editor read, which stay available whichever option is
chosen:
Recycle solve did not converge: tear stream(s) recycle reached a residual of
3.2e-03 after 100 of 100 iterations, against a tolerance of 1.0e-08
(method: anderson). The returned streams are the last iterate, not a solution
-- material balances around the loop are off by the tear residual. ...
ConvergenceWarning and ConvergenceError are exported from difflow, so the
usual warnings.simplefilter("error", ConvergenceWarning) turns every
non-converged solve in a script into a failure.
What to do about it — a better tear guess, a different acceleration, the equation-oriented solver — is When a solve does not converge.
Under jax.grad or jit the residual is a tracer with no numeric value, so
there is nothing to judge: last_solve_converged is None and the solve stays
silent whatever on_nonconvergence says. That path is the optimistix fixed
point, which carries its own implicit-differentiation rule.
Implicit Differentiation#
The flowsheet solver uses implicit differentiation through the converged solution:
This is implemented using JAX custom VJP rules, enabling gradient computation through the entire flowsheet solve.
import jax
# Gradient through flowsheet
def flowsheet_objective(feed_flow):
original = fs.feeds['fresh_feed']
fs.feeds['fresh_feed'] = make_stream({species[0]: feed_flow, species[1]: 0.0},
T=340.0, P=101325.0)
try:
results = fs.solve()
finally:
fs.feeds['fresh_feed'] = original # do not leave a tracer in the flowsheet
return results['liquid_product'][f'F_{species[1]}']
grad_fn = jax.grad(flowsheet_objective)
sensitivity = grad_fn(1.0) # d(product B) / d(fresh feed flow)
Plugin System#
Location: difflow/plugins.py
Plugin Architecture#
Difflow uses a plugin system for extensibility:
from difflow.plugins import register_operation, UnitOperation
# Register a new operation (adds it to the global ``registry``)
@register_operation(
name='my_reactor',
category='reactors',
description='Custom reactor model'
)
class MyReactor:
def __call__(self, inlet, **params):
# Custom reactor logic
outlet, info = dict(inlet), {}
return outlet, info
Protocol Definitions#
from typing import Protocol
class UnitOperation(Protocol):
"""Protocol for single-inlet unit operations."""
def __call__(self, inlet: dict, **kwargs) -> tuple[dict, dict]:
"""
Process inlet stream.
Args:
inlet: Input stream dictionary
**kwargs: Operating parameters
Returns:
(outlet_stream, info_dict)
"""
...
class MultiInletOperation(Protocol):
"""Protocol for multi-inlet unit operations."""
def __call__(self, inlets: list[dict], **kwargs) -> tuple[dict, dict]:
...
Using the Registry#
from difflow.plugins import registry
# List available operations (name -> OperationInfo)
operations = registry.list_operations()
# Get operation by name
CSTR = registry.get('CSTR')
# List operations by category
reactors = registry.list_operations(category='reactors')
Loading Plugins#
Plugins are discovered via Python entry points:
# pyproject.toml
[project.entry-points."difflow.plugins"]
bio = "difflow_bio:register"
from difflow.plugins import load_plugins, discover_plugins
# Discover installed plugins
plugins = discover_plugins()
# Load all plugins
load_plugins()
Bio Plugin#
The difflow_bio package is automatically registered as a plugin:
# After loading plugins, bio operations are available:
from difflow.plugins import registry
bioreactor = registry.get('ContinuousBioreactor')
centrifuge = registry.get('DiscStackCentrifuge')
protein_a = registry.get('ProteinAChromatography')
Saving and Loading Flowsheets#
A flowsheet is otherwise only expressible as Python: the code that builds it is the model. difflow.serialize gives it a file format, so a flowsheet can be saved, diffed, sent to a service, or read by something that never imported the module that built it.
from difflow import serialize
serialize.save(fs, "plant.json")
fs2 = serialize.load("plant.json")
The round trip preserves the answer, not just the shape — a reloaded flowsheet solves to bit-identical results. to_json/from_json and to_dict/from_dict are available if you want the text or the data rather than a file.
The format records format_version (checked on read against SUPPORTED_VERSIONS) and the difflow version that wrote it (for provenance only). difflow writes version 2 and reads 1 and 2, so a file written before the editor existed keeps opening.
Version 2 added an optional top-level view block, which round-trips through Flowsheet.view and is read by nothing numeric:
fs.view = {"nodes": {"reactor": {"x": 260.0, "y": 40.0},
"feed:feed": {"x": 40.0, "y": 40.0}},
"code_context": "thermo = IdealThermo(...)"}
It is where the editor keeps canvas positions, its code-context snippet and its planning selection, so a flowsheet laid out by hand opens the way it was left. Node keys follow the same vocabulary as _apply_params: a unit is its bare name, a feed is feed:<stream>. Nothing validates the contents, and _apply_params copies the block through, so a swept or optimized flowsheet keeps its layout.
What it can and cannot write#
Round-tripping goes through the operation registry: a unit is written as the name it is registered under and rebuilt by looking that name up. An unregistered operation is refused, because nothing would know how to rebuild it.
Parameters are written when they are data — numbers, strings, arrays, lists, dicts, nested Params dataclasses, and NamedTuples such as SpeciesData. A parameter holding a callable is refused rather than dropped:
SerializationError: unit 'reactor' field 'rate_fn' holds a callable ('rate_fn'),
which cannot be written to a file. Build it from data instead ...
That is deliberate. A file that silently lost a reactor’s rate law would reload into a different model that still looked plausible.
Thermodynamics#
About half the core units need a thermo or eos object in the constructor, not just a Params. IdealThermo is written and rebuilt automatically. Anything else is refused on write, and can be supplied on load instead:
my_thermo = thermo # any property package, e.g. a CubicThermo
fs2 = serialize.load("plant.json", extras={"flash": {"thermo": my_thermo}})
extras also overrides a stored thermo, which is the way to reload a saved flowsheet against a different property package.
Units that build their own Params#
Most units are constructed as Unit(Params(...), thermo), but a substantial minority take their numbers as plain constructor arguments and build the Params themselves — Compressor(ratio), FlowSplit(w), GasPipe(beta), and most of the gas plugin. Those numbers are written under params, because that is where the built unit keeps them, and they are passed straight back to the constructor on load. Nothing extra is needed:
from difflow_gas import Compressor
gas_fs = Flowsheet(species_order=["CH4"])
gas_fs.add_unit(Unit("boost", Compressor(ratio=1.3), ["a"], ["b"]))
serialize.from_json(serialize.to_json(gas_fs)) # ratio comes back as 1.3
An argument that is not data — Mixer(species_order), a thermo — still travels under constructor, and the two channels compose: CompressorBoost(ratio, direction) carries ratio by the first road and direction by the second.
Only required constructor arguments are carried. An optional one left at its default is not written, so a unit that was built with a non-default optional argument comes back with the default; pass it on load with extras= when it matters.
Generating Python#
serialize gives a flowsheet a file format; codegen gives it a source form — the same model written as the code someone would have typed.
from difflow import codegen
print(codegen.to_python(fs))
codegen.save_script(fs, "plant.py")
The two together close the loop. A graphical editor you can only enter is worse than none: the moment you want something the palette does not offer, you have to be able to drop into Python and keep going. Build in a GUI, export a script, edit it, and read the result back through serialize.
The output is laid out to be read and edited — imports, then thermodynamics, then kinetics, then the flowsheet:
"""Flowsheet generated by difflow 0.1.0.
Edit freely --- this is ordinary difflow code.
"""
from difflow import CSTR, CSTRParams, Flowsheet, Unit, make_stream, mass_action_kinetics
kinetics_reactor = mass_action_kinetics(reactions=[...], species_order=['A', 'B'], reverse='error')
fs = Flowsheet(species_order=['A', 'B'], ...)
fs.add_feed('feed', make_stream({'A': 1.0}, T=350.0, P=101325.0))
fs.add_unit(Unit('reactor', CSTR(CSTRParams(**kinetics_reactor.params_kwargs(), V=1.0, ...)), ['feed'], ['out']))
A data-built rate law is hoisted into its own statement and splatted back rather than inlined, which keeps the reactor to one readable line and avoids repeating the arrays the factory derives anyway. Thermo is hoisted the same way, and written as a get_species_data expression when every species is in the database.
Running the generated script rebuilds a flowsheet that solves to bit-identical results.
codegen refuses exactly what serialize refuses, for the same reason: an unregistered operation, and a callable that does not record how it was built. A generated script that quietly dropped a rate law would still run, and would be a different model.
Operation Catalog#
The registry answers what units exist. difflow.catalog answers what you can do with one: how many streams go in and out, what parameters it takes, which are required, and which hold code rather than data.
from difflow import catalog, describe_operation
spec = describe_operation("Flash")
spec.ports.inlets # ['inlet']
spec.ports.n_outlets # 2
spec.required_parameters() # ['T']
spec.equations # LaTeX governing equations
spec.to_dict() # JSON-serializable, for a UI or code generator
catalog() returns a schema for every registered operation, optionally filtered:
reactors = catalog(category="reactors")
All of it is derived by introspection, not from a second hand-maintained table: parameters come from dataclasses.fields of the unit’s Params class, ports from the __call__ signature, and equations from the equations class attribute the units already carry. The catalog therefore cannot drift from the code, and an operation whose signature is unannotated is reported as unknown rather than guessed at — Splitter returns a bare tuple, so its n_outlets is None.
Where parameter descriptions come from#
Each parameter also carries the prose that documents it, so a schema can label a field rather than only name it:
for p in describe_operation("Heater").parameters:
print(p.name, "--", p.units, "--", p.description)
duty -- W -- Heat duty (W). Positive = heating.
T_out -- K -- Outlet temperature (K). Alternative to duty.
UA -- W/K -- Overall heat transfer coefficient × area (W/K). For rating.
T_utility -- K -- Utility temperature (K). For LMTD calculation.
Cp -- J/mol/K -- Constant heat capacity (J/mol·K). Ignored when the unit is built with a ``thermo``; when there is neither, the unit falls back to DEFAULT_CP and warns (DefaultCpWarning).
phase -- - -- Force one phase ('liquid'/'vapor') for the thermo enthalpy. None (default) uses the thermo's two-phase flash enthalpy where it has one, so latent heat is carried through a partial vaporization.
That text is not a second copy. difflow.docstrings reads it out of the Params class’s own Attributes: section, which is where the project already writes it, and out of the comments beside the fields for the 35 that are documented there instead — CSTRParams.eos and CSTRParams.outlet_volumetric_basis among them. Populating field(metadata={"description": ...}) on all 87 Params classes would have duplicated every description and then drifted from it.
The editor gets this for free: the inspector’s parameter list already rendered spec.description as help text under each field, and the palette already rendered spec.units beside its name — both written against a schema that carried neither. 470 of the 487 catalogued parameters gain help text in the inspector with no front-end change.
Units are not read from the prose. They come from the class’s parameter_units table (above), which names every numeric field and is guarded by a test; a parenthetical in a sentence is a weaker signal, since (0-1) and (default 10) sit in the same position as (Pa), and a formula in a description will happily offer -1/k as “per kelvin”.
Where a field needs to say something different from its docstring, its metadata still wins:
P: float = field(default=101325.0, metadata={"description": "..."})
Which operations are declarative#
is_declarative marks the operations whose parameters are all data, so a form or a JSON file could supply them:
[name for name, spec in catalog().items() if not spec.is_declarative]
The handful that are not are the reactors, and always because of the rate law — see Declarative Kinetics for building that from data instead.
Core units and the registry#
The core reactors, separators, columns and exchangers are registered when difflow is imported, so they appear in catalog() alongside the plugin units with no load_plugins() call needed.
One name is deliberately not the class name: difflow_gas registers a Compressor, and plugins load after the core, so the EOS-consistent difflow.Compressor is catalogued as EOSCompressor. Registering both under the bare name would silently drop one.
The Local Editor#
difflow.gui serves a single-page flowsheet editor on localhost, with the installed package doing the solving:
difflow # an empty canvas, in a browser
difflow gui plant.json # ...on a flowsheet
difflow gui plant.py # ...on one a script builds
difflow gui --port 9000 --no-browser
difflow gui --stay # ...and keep serving after the tab closes
The editor is what a bare difflow does, because it is the one thing here that has nothing to print and everything to show. python -m difflow.gui is the same command for an environment where the console script is not on PATH.
Starting from an example#
The Examples menu opens a flowsheet that already solves: a water/ethanol flash drum, then ethyl acetate made from acetic acid and ethanol (a reversible Fischer esterification, equilibrium-limited at \(K_{eq} = 4\)) in a CSTR followed by a flash, and the same process with the acid-rich flash liquid recycled to the reactor and a purge. The reaction’s stoichiometry and equilibrium constant are real; its forward rate constant is illustrative, and the flash uses Raoult’s law, which does not know about the ethyl acetate azeotropes. Each is an ordinary flowsheet file (src/difflow/gui/examples/*.json), code context included, so it is also a template for writing your own. Choosing one replaces the canvas and forgets the file the editor was opened on, so Save cannot write the example over it; export it with File > Flowsheet JSON to keep it. A new NN_name.json in that directory, with view.title and view.description set, joins the menu.
Seeing the solution on the canvas#
After a solve, every wire answers a hover with its stream: temperature, pressure, total flow, and a composition bar over a row per species with its mole fraction and flow. Hovering the wire’s label works too, which matters on short wires the label mostly covers, and so does hovering a port. A product (an outlet nothing reads) has no wire, only the ring on its unit’s edge, so its port is where to read it. Clicking a wire pins its card, and clicking the canvas lets it go.
Hovering a unit shows what it did, read from the streams around it: each inlet and outlet with its temperature and flow, each outlet’s share of what leaves (a flash’s vapor fraction, a splitter’s split), the change in every species from in to out (where a reactor’s conversion shows), and the unit’s numeric parameters. It does not show a unit’s internal results such as a heater’s duty, because the solve does not report them.
Two views in the View menu draw the solve onto the wires themselves. Wire width by flow (or W) makes each wire as wide as its molar flow, linearly, so a recycle carrying twice the feed looks it. Colour wires shades them on a single-hue scale by temperature, pressure, total flow or any species’ mole fraction; the legend in the corner picks the variable and states the range, which is the streams’ own range rather than one from zero. A variable that is the same on every stream says so instead of spreading round-off across the scale. Both views wait for a solve, and an edit that clears the solve clears them from the wires until the next one. A coloured wire drops the tint from a forward sensitivity run: one colour cannot say two things.
The script#
View > Script shows the whole flowsheet as the Python that File > Python script writes, syntax-highlighted and read-only. It regenerates as the flowsheet changes, so it is always the script of what is on the canvas; Copy puts it on the clipboard. It is read-only because it is generated: an edit made there would be gone at the next change. The Python the flowsheet keeps is the code context, which is also the script’s preamble, and its panel is a code editor too, with the same highlighting, Tab to indent, and Cmd-Enter (Ctrl-Enter) to apply.
Opening a script#
Most flowsheets here were written as Python, because the package was a library long before it was an editor. difflow gui plant.py opens one: the script is run, and the first Flowsheet it leaves at module level is what appears on the canvas. Running it is the only way to read it — a rate law is a function and a parameter is whatever arithmetic produced it, so there is nothing to parse — and the same act is what difflow report plant.py and difflow plan-export plant.py have always done. It carries the same trust: the file is executed, so open your own scripts with it and not a stranger’s.
The script runs under __name__ == "__difflow__", not "__main__". A script’s if __name__ == "__main__" block is the part that solves, sweeps or plots, and opening a flowsheet in an editor should not cost an hour of optimization first. Put the flowsheet at module level, which is where it already is.
A script is a way in only. difflow.codegen can write a flowsheet back out as Python, but writing it over the script it came from would delete everything in that file that is not the flowsheet — the comments, the sweep at the bottom, the __main__ block. So the editor never saves to the .py: it saves the JSON beside it, plant.py → plant.json, and the header shows the script you opened with the file it will write in the tooltip. Once that JSON exists, difflow gui plant.json opens the edited version and the script is left alone; naming the .py again re-runs the script, so whichever one you name is the one you get.
A script that raises, or that builds no flowsheet, is reported as one line naming the file and the cause rather than as a traceback through runpy:
difflow gui: plant.py raised while building the flowsheet: FileNotFoundError: assay.csv
or from Python, on a flowsheet you already have:
from difflow import gui
gui.serve(fs, path="plant.json")
Two pages are served, and they are at different stages.
/ is the canvas: the flowsheet as a node graph, laid out automatically, pan and zoom, feeds banked left and products right, recycle edges dashed and orange. Drag an operation off the palette to drop a unit; drag from a unit’s outlet port to another unit’s inlet to wire it; drag a box to move it; select and press Delete to remove. Every gesture is one request and then a redraw from the answer, so the picture cannot drift from the model.
Right-click a node for the things you would otherwise go looking for. On a unit the menu offers the documentation page for its operation, the assistant pointed at it, the code context, its name on the clipboard, and Delete; on a feed or product node — drawn from the topology rather than held by the flowsheet — only the last two have anything to act on, so only those are offered. Nothing in it is a new capability, and that is rather the point: the documentation link was in the palette, which is where you are not once the unit is on the canvas, and the code context is a menu away from the red node that is waiting for it. Arrow keys walk the menu and Escape closes it; near the edge of the window it flips rather than slides, so the item under the cursor stays the item under the cursor.
The header is three menus and one button. It had been fifteen controls in a row, which is what a toolbar becomes when every panel earns a button: the two you want on any given day sit in a hedge of the eleven you do not. So File writes the flowsheet out — save, reload, the four exports below, and quit — View lists the five drawers and the two drawing preferences with a tick beside whichever are on, Help is the links that leave the editor, and Solve stays a button because it is the verb the whole editor exists for. Nothing moved into a menu gained or lost a capability; the menus are only where the capabilities went, which is why the whole arrangement is one list in model/menubar.js with no behaviour in it and the header is a <nav> of three words.
They are the same component as the right-click menu, which is the part worth insisting on: one placement rule, one keyboard, one look. A menu hanging from a button flips about the button rather than about the point when it would run off the edge, so it stays attached to the thing that opened it; left and right walk the bar carrying the open menu with them, down opens, Escape closes and hands the focus back to the word it came from. Two accelerators are advertised on the rows that have them — ⌘S saves, ⌘↵ solves — because a control that moved into a menu should say how to reach it without one, and because the browser’s own answer to ⌘S, offering to save the page, has never once been what anyone wanted here.
Two things about wiring are worth knowing before you use it, because both are properties of difflow rather than of the editor. A stream name is the wiring: connecting an outlet to an inlet renames the inlet, it does not add an arc, so the downstream unit’s port is called whatever the upstream unit’s outlet is called. And a loop is a tear, never an arc: if the wire you draw would close a cycle, the editor records a recycle instead — the same thing add_recycle does — and the edge draws dashed with both stream names on it, because the two ends carry different names.
A stream can be renamed, and it is not a label edit. Select a feed or
product node and type a new name: the server moves the feed, both ends of
any recycle, every port that reads or writes it and the canvas node
together, because a name that moved in some places and not others splits
the flowsheet into two graphs that each look fine on their own. Two
renames are refused rather than performed — a name another stream already
has, which would join the two and is a connection wearing a rename’s
clothes, and a name that is not a Python identifier, which would survive
the edit and fail later in codegen or on reload, a long way from the
typing. gui.edit.rename is that checked door; rename_stream is the
unchecked mechanism underneath, and both stay, because connect uses the
unchecked one to rewire.
A mixer’s inlet count belongs to the flowsheet, not to the class. A
Mixer mixes however many streams it is handed, and so do Junction,
BusNode and AffineFlow — the four operations the catalog reports as
ports.variadic. So the inspector offers Add inlet on those and a −
per row, and until now the only way to get a third inlet on a mixer was
to edit the JSON by hand. A variadic unit also arrives from the palette
with two inlets rather than one, because one is what it means to not
be there: a mixer mixing a single stream is a piece of pipe.
A new port arrives unwired, which is what a unit dropped from the palette
does too. Removing one is refused rather than cascaded when something is
on it — a feed, an upstream unit, a recycle destination — because
deleting the port and deleting the feed behind it are two edits, and
undoing the first does not bring back the second: the composition,
temperature and pressure are gone. The refusal names what is in the way.
The front end draws the button from the catalog’s own variadic flag and
the server refuses against the same one, so the button cannot offer
something the server will turn down.
A port with nothing attached is a red dot, not a box. A feed node is drawn for a stream the flowsheet declares as a feed, and for nothing else; the one other stream node left is the destination of a recycle no unit reads, which exists because an edge whose target is not a node is dropped by the canvas without a word. Everything else that used to get a box — every unwired inlet, every outlet nobody consumes — is marked on the unit that owns the port instead.
The boxes were drawn from the topology, and the effect was that a unit dropped from the palette arrived already joined to a stub on every port. It looked wired. It was not, and there was nothing on the canvas to say which of those attachments was a stream someone had declared and which was a name the drawing had just invented. A red dot on the port says the one thing that was missing: nothing is connected here yet.
Declaring a feed therefore moves to the port. Select the unit and the inspector lists its inlets and outlets, marking the open ones; feed it on an open inlet declares a feed on that stream at the flowsheet’s own defaults, and the box appears with a form behind it. The numbers come after the declaration rather than before it, because they are the part you edit and re-edit.
Code context opens the Python the flowsheet carries (see below). Parameter editing, the docstring inspector and the results panel are being built onto the canvas; until they land, /classic is where you change a number.
/classic is the form editor, and it is the one that can currently change a model: a palette of every registered operation with its port arity, an SVG of the topology, editable parameter fields per unit, and a panel that switches between solved stream values and the generated Python. Solve re-solves the edited model; Save writes the JSON; Python shows what codegen.to_python would produce. It stays until the canvas covers what it does; each page links to the other in its header.
Everything on both pages is derived from catalog(), so plugin units appear with no extra work, and a field the catalog reports as holding code is listed as set in code rather than given a text box that could only reject what you type.
The palette says what each unit is waiting for. Over half the catalog cannot be dropped as it stands — a Flash wants a thermo, a CSTR wants a rate law, an AmineAbsorber wants the name of a solvent — and those entries are dimmed with the missing field names on the row: needs thermo, needs rate_fn, stoich, rate_params. The names are the point. “Unbuildable” tells you nothing you can act on, whereas rate_fn is the thing you go and write, and it is the same list the node carries if you drag the unit anyway. They stay visible because a palette that cannot show a CSTR is not a palette; a filter hides them for anyone who would rather it did.
What each row needs is answered against the code context as it stands, not against the class, so a row un-dims the moment its binding appears — one thermo = IdealThermo(...) clears the flag on every unit that was waiting for one, and the page refetches the catalog when you apply the snippet. A bare value counts too: solvent = "MEA" is matched by field name, which is why the message can honestly tell you to go and write one.
That the flag and what the adder does are the same computation (gui.edit.unmet) is the part worth insisting on. When they were two, the catalog answered a question about the class — could a form construct one — and the adder answered a question about this session. AbsorberParams.solvent is a required str: no callable, no constructor object, so the class read as buildable, and nothing on earth can invent a solvent name. The palette offered it, the drop failed, and the error came from the file-loading path, which offered “the file may have been written by a different version of difflow” as the diagnosis of a unit dropped one second earlier. A test now walks every operation and asserts the two agree.
A flag that is empty promises a clean answer, not a success. A class may still refuse on its own terms — Transformer rejects a unity tap with no phase shift, because that is a line and not a transformer — and nothing short of constructing one can know that in advance, so the class’s own sentence is quoted rather than replaced.
A unit that cannot be built yet is dropped anyway, in red. Refusing the drop threw away the only thing you actually said — I want a Flash, here — and left you to say it again after writing the thermo. So the drop lands as a pending node: dashed, red, carrying needs thermo on the box and the full sentence in its tooltip. It is a node rather than a toast because a toast scrolls away and the drop does not; the red box is both the record of what you asked for and the place to find out what it is waiting for.
It goes on the flowsheet, with its real ports, and can be wired at once. That is the whole design. Held off the flowsheet it had no ports, and a unit with no ports cannot be connected to anything: dropping a compressor and a reactor and trying to wire them simply did not work, for as long as the reactor had no rate law — which is to say until after the wiring was done. Wiring first and writing the rate law afterwards is the order people work in, so the unit exists from the moment it is dropped and difflow.incomplete.Incomplete stands in for the operation it does not have yet. Ports are laid out from the class’s declared arity, so they are the ports the finished unit will have.
What it will not do is run. Incomplete.__call__ raises IncompleteUnitError, and solve refuses before it starts, by name and saying what each unit is still waiting for — better than raising from the middle of a recycle loop, and much better than returning numbers out of a unit nobody finished. serialize round-trips it as what it is, under an incomplete key, so a file saved mid-build reopens mid-build rather than refusing to load.
The two edits that can turn an unmet need into a met one — applying the code context, naming the species — finish every unfinished unit afterwards, in place. One that builds has its stand-in replaced by the real operation and the red goes away; one that still cannot has its hint re-answered against the new bindings, so a node that needed species_order, thermo and got the species now says thermo. In place and not by dropping it again, because by now it may be wired: its ports carry the names of the streams it is joined to, and a fresh drop would arrive with default port names and quietly detach everything already connected. Deleting one is the ordinary delete, and renaming one is the ordinary rename.
And it will write the code for you. Selecting a pending node offers Write the code for me, which is POST /api/boilerplate and gui.boilerplate. What comes back is a starting point, not an answer, and the difference is marked in the text rather than smoothed over. A generated thermo names this flowsheet’s own species and reads their data out of the bundled database, which is very likely right. A generated rate law is one made-up reaction with made-up Arrhenius parameters, and says # INVENTED above it. A fallback species list says # GUESSED; a value derived from nothing but a declared annotation says # PLACEHOLDER. A snippet that quietly looked finished would be worse than no snippet, because the number it invented would end up in a flowsheet.
rate_fn, stoich and rate_params are written as a single mass_action_kinetics(...) call, because a stoichiometry that disagrees with its rate law is the one failure that call exists to remove. A need with no recipe falls back to a stub of the shape the docstring documents (Signature: rate_fn(C, T, rate_params)) or to a value of the declared type — which is how a plugin’s own constructor argument gets an entry without boilerplate.py knowing anything about it. The button appends to the code context rather than replacing it, skipping lines already there, so a second snippet cannot define SPECIES a second time with a different list.
The point is not to replace writing Python. It is to make the tedious parts quick — seeing the topology, changing one parameter and re-solving, checking what a unit expects — while leaving the door open in both directions: export a script, edit it, and read the result back through serialize. An editor you can only enter is worse than none.
It is stdlib only (http.server), binds to 127.0.0.1, and is meant for a single local user. It is a development tool, not a hardened service — do not expose it to a network.
Because the code context runs Python, the server answers mutating requests only from the page it served. Three checks, all in server.py: a token minted per process, put into the page as a <meta> tag and required in an X-Difflow-Token header; an Origin that must be this exact host and port; and a Host that must be a loopback name, which is what a DNS-rebinding request cannot produce. A request that fails any of them gets a 403 — the one case where a refusal is not a 200, because it is a failure of the request rather than an answer about the flowsheet. Reads are not guarded: the page fetches the catalog before it has done anything. A client outside the browser — curl, or npm run dev proxying to this server — has to send the token too.
The editor stops when its page does#
A local editor that outlives its tab is a small disaster: the port stays
held, the next difflow gui refuses to bind, and nothing on screen says
why. So the page and the server keep each other alive.
The page posts POST /api/ping every 15 seconds with an id minted for
that page load, and POST /api/bye on pagehide. The server keeps a
dict keyed by page id, not a count: a reload increments before it
decrements, and a tab that dies without a farewell never decrements at
all, so a counter goes wrong in both directions. A page that has not
been heard from in IDLE_GRACE_SECONDS (90) is dropped, and when the
last one goes the server shuts itself down and the port comes back. The
grace is six heartbeats rather than two because Chrome throttles a
background tab’s timers to roughly one firing a minute — a tab left in
the background is not a tab that has gone away.
Nothing expires before the first check-in, so a server started with
--no-browser, or one whose page is still loading, is never killed for
having no pages yet.
pagehide fires for the back/forward cache too, and that is not a
departure — the tab is coming back with its JavaScript intact. So the
farewell is suppressed when event.persisted is true, and pageshow
re-pings.
Quit is the last row of the File menu, and it asks twice: the row
arms it, a red Really quit? appears in the header for six seconds, and
clicking that posts POST /api/quit, after which the page draws a
stopped overlay so a dead tab does not look like a live one. The
second half is a button rather than a second trip into the menu because
the menu shuts behind the first click, and a question asked somewhere
nobody is looking is not asked at all. Either way the process prints why it
stopped — stopped: quit from the editor, or stopped: the editor page was closed.
--stay turns all of this off and serves until Ctrl-C, which is what a
long-running or embedded server wants. make_server never starts the
watcher at all: a test builds a server and drives it, and a server that
can vanish mid-test is not testable.
When the port is taken, the message says by whom. lsof first,
ss second, nothing third — none of which may exist, and none of which
is allowed to raise:
difflow gui: port 8756 on 127.0.0.1 is already in use.
It is held by pid 62689 (python3.12).
difflow gui plant.json
To stop it:
kill 62689
The kill line is offered only when the process is one this user can
signal (os.kill(pid, 0)), because telling someone to kill another
user’s process is telling them to run a command that will fail.
Where the book talks about a unit#
Every operation the palette offers carries a ? that opens this book at
the section describing it, and the inspector’s heading carries the same
link for the selected unit. The Help menu has Documentation and
Source on GitHub, read from the installed package’s Project-URL
metadata rather than typed into the front end — and greyed out, saying
so, on an install whose metadata names neither.
The mapping is derived, not maintained. difflow.gui.doclinks reads
static/docs-index.json — the same index the assistant retrieves
against, built from docs/ and rebuilt by CI — and for an operation
name prefers, in order: a section whose heading starts with the name,
on a unit-operations page, at the shallowest nesting, then the section
that mentions it most. A hand-kept table of 87 operations against 760
sections would be wrong within a release; this one cannot drift from the
prose, because it is computed from it.
It resolves 82 of the 87 registered operations. The five it does not are
units the book does not yet describe by name, and catalog() reports
docs_url: null for them rather than a link to something else —
#228
tracks writing the missing sections. A test asserts that every URL it
does emit points at a page _toc.yml actually builds, so a renamed
chapter fails a test rather than shipping a 404.
The code context#
Roughly half the catalog cannot be built from data alone. A Flash needs a thermo, a PengRobinson unit needs an eos, a reactor needs a rate law — objects, not numbers, and no form can supply one. So the flowsheet carries a snippet of Python in view["code_context"], the session execs it in a fresh namespace, and anything it defines can be referred to by name:
from difflow import IdealThermo, get_species_data
thermo = IdealThermo({n: get_species_data(n) for n in ['water', 'ethanol']})
With that in the panel, Flash becomes droppable; with a mass_action_kinetics(...) call in it, so does CSTR. The reference is stored as {"$ref": "thermo"} in the unit’s constructor block rather than inlined, and the same tag works anywhere a value goes:
serialize.save(fs, "plant.json", refs={"thermo": thermo})
serialize.load("plant.json", refs={"thermo": thermo})
The scan is by identity, and it runs after the primitive branch: two IdealThermos built the same way are two objects, and small ints and short strings are interned, so x = 1 in the context must not turn every 1.0 in the file into a reference. A $ref the namespace cannot resolve is a SerializationError naming the missing name, not a silent None.
codegen.to_python emits the snippet as the script’s preamble, ahead of everything that uses it, so what is exported is what ran. A snippet that fails to compile or raises is refused with the line number and not stored — the flowsheet keeps the last one that worked, so a half-typed line cannot unbuild the units already depending on it.
That the editor runs the Python a file carries is worth stating plainly: opening a flowsheet in the GUI executes its code context, exactly as running a script would. Treat a flowsheet JSON from someone else the way you would treat their .py file.
For tests and embedding#
make_server builds the server without starting it, which is what a test wants:
from difflow.gui import FlowsheetSession, make_server
server = make_server(FlowsheetSession(fs), port=0) # 0 => any free port
The routes are:
Route |
What it does |
|---|---|
|
every registered operation, its ports and its parameters |
|
the document, with |
|
|
|
replace the whole model (load a file) |
|
solve; write the JSON |
|
one unit’s |
|
add one from the palette; remove one |
|
wire or unwire, body |
|
the species list, and whether it can still be changed |
|
the declared feeds, and the inlets still waiting for one |
|
declare or change a feed, body |
|
positions only, no rebuild and no solve |
|
read the snippet and what it defines; set it, or say why it will not run |
|
one operation’s rendered docstring, equations, assumptions and references |
|
what a derivative can be taken with respect to, and of |
|
the flowsheet drawn as one SVG, at the canvas’s own layout |
|
one derivative sweep, forward or reverse |
A failed solve or a rejected edit comes back as {"ok": false, "error": ...} with a 200 rather than a traceback at the socket: it is an answer about the flowsheet, not a failure of the request, and a bad edit from the browser cannot take the server down. Only malformed JSON and an unrouted path get a 4xx.
The incremental routes exist because POST /api/flowsheet re-runs serialize.from_dict and re-instantiates every unit — wrong twice over for a canvas, since it costs a full reconstruction per keystroke and it drops any constructor object the file cannot carry. PATCH rebuilds only the unit you touched and hands its thermo back by identity, so a hand-built one survives an edit that JSON could not have round-tripped. A unit dropped from the palette takes species_order from the flowsheet and its constructor objects from the code context below; one whose thermo is nowhere to be found is parked as a pending node naming what is missing and where to define it, and built in place once it appears. Removing a unit also drops any recycle naming its streams, which would otherwise tear a stream nothing produces and fail the solve somewhere far from the edit.
difflow.gui is a package rather than one module: session.py holds the flowsheet and everything that can be done to it, server.py holds the wire encoding and the routes, and static/ holds the page as files on disk. FlowsheetSession needs no socket, so the interesting half — load, edit, solve, emit code — is usable and testable on its own:
from difflow.gui import FlowsheetSession
session = FlowsheetSession(path="plant.json")
session.solve()["converged"]
session.code()["source"]
GET /api/flowsheet fills in view.nodes when the document has none, from difflow.gui.layout.auto_layout — a longest-path column assignment with feeds banked left and dangling products right, shared with difflow.report.diagram so a report and the canvas agree on where a unit sits. Recycle edges are left out of the path length, which is what makes a recycle draw as an arrow going back rather than as another column. The positions are served, not adopted: opening a file does not give it a view, and coordinates reach disk only when the user saves. Stored positions sit on top of the automatic ones rather than replacing them, so moving one box does not send every other node back to the browser unplaced.
Anything else under static/ is served alongside the page, by an allow-list of the suffixes a front-end build emits (.html, .js, .css, .json, .svg, .map, .woff2, .ico); the page is re-read from disk on every request, so a rebuilt bundle appears on reload rather than on restart.
Building the canvas#
The canvas is Svelte plus @xyflow/svelte, and its source lives in src/difflow/gui/frontend/. The build output is committed to src/difflow/gui/static/, which is the whole point of the arrangement: pip install difflow never needs node, only changing the UI does.
make gui-build # npm ci && npm run build, into static/
make gui-test # the pure model functions, under bare node
make gui FLOWSHEET=plant.json
npm run build runs vite twice: once for the editor (app.js, app.css, index.html) and once for the frozen page difflow.publish inlines (publish.js, publish.css). Two builds rather than two entries, for the reason given under Publishing a Model.
A unit is drawn as its PFD symbol rather than as a labelled box: a distillation column as a tray stack, a heat exchanger as the circle with its zigzag, a pump as the circle with its volute, a CSTR as a vessel with an impeller. The symbols are hand-drawn SVG paths in src/difflow/gui/frontend/src/lib/nodes/symbols.js, mapped to operations by name first and by catalog category second, which is what lets a plugin’s own unit — one this front end has never heard of — still draw as equipment: an unknown reactor gets the reactor symbol from its category. tests/test_gui_symbols.py joins the map to difflow.catalog() from the Python side and asserts that every one of the catalog’s operations resolves to real equipment and none falls through to a plain block, so adding a unit operation and forgetting its symbol fails a test rather than shipping a rectangle. The same symbol is drawn on the palette row, so what is dragged looks like what lands.
Wires route orthogonally, and two keys toggle the rest: L names every port beside its handle, T swaps to a dark palette. The dark palette is opt-in by a data-theme attribute on the root rather than by prefers-color-scheme, so a report opened in a dark browser keeps the light palette it was designed and screenshotted with.
A port says what it is. Three marks, and they answer two different questions:
red dot |
an inlet nothing supplies |
the flowsheet cannot be solved; |
green dot |
a port something is joined to |
a wire, a declared feed, or a recycle |
grey ring |
an outlet nothing reads |
a product — the stream leaves, and there is nothing further to do |
The two dots are round and coloured because they are one question with two answers. The ring is neither colour and hollow, because it answers a different question: not is this wired? but where does this stream go?, and the answer — out — is what the last unit of a finished flowsheet is supposed to say. Marking it red said “unwired” about a flowsheet that was complete, and the only thing red should mean on this canvas is that solve will refuse.
Losing the red mark at that end loses no warning. Whenever an outlet should have gone somewhere, the unit waiting for it still shows a red inlet, which is the end that can actually be fixed.
All of it comes from openPorts in model/graph.js, the same walk over the document that the palette’s “waiting for” hints use: an inlet is open unless a unit produces that stream, a declared feed supplies it, or a recycle lands on it; an outlet is a product unless a unit reads it or a recycle carries it back. So green is not “a wire was drawn here” — it is “the flowsheet has an answer for this stream”, and a feed declared in the inspector turns an inlet green with no wire on the canvas at all.
The two ends are not symmetric, and the model is where that comes from. A feed is an object the flowsheet holds — Flowsheet.feeds, a Stream per name — because it is data nothing on the canvas can imply: a temperature, a pressure and a flow per species. A product is not an object at all. It is an outlet nothing happens to read, solve returns it in streams with everything else, and there is nothing to declare. Which is why one end has a form to fill in and the other has only a mark saying it is finished.
Feeds are declared from the panel of the unit that wants one: the feed it link beside an open inlet declares it at the flowsheet’s own defaults, and the feed box that then appears on the canvas is what you select to type the numbers into. It sits on the port rather than on the canvas because the canvas draws a box only for a feed that exists, so there is no node to click for a stream that has never been one.
The green half is also the acknowledgement for the gesture that earns it, and the lack of one hid a bug worth recording. @xyflow calls onconnect with the Connection itself — {source, target, sourceHandle, targetHandle} — and it calls it after adding the edge to the canvas. The handler destructured { connection } off that argument, read undefined, and refused the connection with that is not a connection, so the wire appeared, the server never heard about it, both dots stayed red, and the phantom edge vanished at the next redraw — which dragging a node triggers. Every unit test of the pure function passed, because the function was right and its one caller was wrong. connectionWire now accepts either shape, and edit.test.js asserts both.
Starting from an empty canvas#
difflow gui with no file opens on an empty flowsheet, and empty rather than absent: the session used to leave self.flowsheet = None, every edit route begins by refusing “no flowsheet loaded”, and nothing said so — so the palette filled in, the canvas drew its grid, and dragging a unit onto it did nothing at all. Which reads as a broken drag rather than as an editor with no flowsheet to edit.
So the flowsheet exists from the first frame, and the one thing it is missing is asked for by name. Every stream in difflow is an array indexed by species_order, so until that list exists nothing can be built — and the palette says so on every row (needs species_order). A drop before then is not lost: it parks as a red node that asks for the list and builds itself once it has it. The list can be typed into the header field or bound by the code context below (as species_order, or as SPECIES, which is what the starter snippet defines); whoever typed it in the header wins, and the code context does not overwrite it. It freezes once a unit indexes it: re-ordering under a built unit would turn a water flow into an ethanol flow with nothing on screen changing.
The other half of building from nothing is the feed, and it is the half that has no gesture. Dropping and wiring can describe a whole topology, but a feed is data — a temperature, a pressure and a flow per species — so a from-scratch flowsheet could be drawn and never solved, and solve answered KeyError: 'mixer_in': the name of the stream, which was on the canvas already, and no hint that a feed was the thing missing. An inlet with nothing on the other end is now selectable, and the inspector gives it a form. set_feed fills any field left out from the feed that is already there, so editing a temperature does not zero the flows, and from the flowsheet’s own default_flow/default_T/default_P when there is no feed yet — the same numbers Flowsheet.solve invents for a tear stream, so an untouched feed is not a new guess about the model. It refuses a stream a unit already produces (two sources for one stream, and the solver would silently use one of them), a stream nothing reads, an unknown species, a negative flow and a non-positive temperature or pressure, each by name. A blank box is a question and not a zero: zero is a real flow, and guessing which was meant would put a number nobody typed into the model. What remains unfed at solve time is reported as that, with what to do about it, and GET /api/feeds answers the same question ahead of time.
The inspector#
Selecting a unit shows what the code already knows about it. None of it is written twice: the unit classes have carried symbol, equations, assumptions, references, parameter_symbols, parameter_units and numerical_method since the report writer needed them, difflow.report.metadata.get_metadata reads them with docstring fallbacks, and difflow.catalog now goes through that same function. So ParameterSpec.units — declared from the start and always None, because no Params field uses field(metadata={"units": ...}) — fills in from the class’s own table for 147 of the 486 catalog parameters, and a field that does declare its own metadata still wins, being the more specific of the two.
GET /api/docs/<op> renders the class docstring with docutils when it is installed and returns the escaped source in a <pre> when it is not — an optional extra, never a hard dependency, and the panel says which of the two it is showing. Two accommodations make difflow’s own docstrings render: nothing rewrites the Google-style sections, because Args: followed by an indented block already is a reStructuredText definition list; and the Sphinx roles the codebase uses (:class:`~difflow.streams.Stream`) are registered as literal text, since bare docutils treats an unknown role as an error that swallows the line. Messages are suppressed rather than rendered: a red box in the inspector would be about difflow’s prose, not about the user’s flowsheet.
Equations are rendered with a bundled KaTeX, to MathML rather than to KaTeX’s own HTML. The HTML output is laid out against KaTeX’s fonts and looks wrong without them, so taking it would mean committing twenty .woff2 files and a stylesheet whose only job is positioning glyphs; MathML asks the browser to do that instead. All 214 equations in the catalog render.
Parameters are editable in place, through PATCH /api/unit/<name>. Not all of them: serialize writes a JAX array as {"$array": ...}, a rate law from mass_action_kinetics as {"$callable": ...}, and a code-context object as {"$ref": ...}, and none of those is something a text input can edit. Each is shown with where its value came from — mass_action_kinetics(...), array 2×1, thermo (code context) — in place of an input, which is the same fact the old editor put in a “set in code:” line under the form, moved to the field it belongs to. The constructor objects get their own section for the same reason: a Flash’s thermodynamics are not a Params field, and a panel that showed only parameters would not say where they came from.
Results, and the derivatives that come with them#
Solving fills a drawer under the canvas with three things, and the first two are what any flowsheet editor shows: a stream table, and the solve’s own diagnostics. The table’s columns are the union of the species across all streams, so a stream that never sees a component reads 0 rather than going missing, and a mole-fraction toggle divides by the total — leaving the cells of a zero-flow stream blank rather than printing NaN. The diagnostics are last_solve_converged, last_solve_method, last_solve_iterations, last_solve_residual, last_solve_tol and last_solve_tear_streams: Flowsheet has recorded how it solved, how far the tear residual came down, against what tolerance and on which streams for as long as the recycle solver has, and none of that was shown anywhere. It matters because a recycle that stopped at max_iter with a residual of 1e-3 returns numbers that look exactly like an answer. A solve that did not converge says so in red and still shows its numbers, because the residual and the tear list are exactly what one wants to see when it did not.
The third tab is the one no other flowsheet editor can offer, and it is the reason difflow is built on JAX. Ask for a derivative and the panel takes it — not by re-solving the flowsheet once per lever, but with a single AD pass:
Pin a lever — “if I change the reactor volume, what moves?” — and it is one
jax.jvp. One forward pass givesdy/dufor every quantity in every stream at once, so the cost does not grow with how much you want to look at.Pin an output — “what moves the purge’s benzene?” — and it is one
jax.value_and_grad. One reverse pass givesdy/dufor every lever at once, so the cost does not grow with how many knobs the flowsheet has.
That is difflow.planning’s choose_ad_mode rule surfaced in the UI: the direction of the pass follows from which end you pinned, and either question by finite differences would cost one solve per lever. The derivative goes through the recycle tear solve implicitly, so a flowsheet with a recycle is no more expensive to differentiate than one without.
Levers are found rather than declared. difflow.gui.sensitivity.levers walks the flowsheet and keeps every parameter whose current value is a real scalar — a Python int or float, or a 0-d array of float dtype. A rate_fn is a function, a stoich is an array, a species_order is a list and a thermo is an object, so all four fall out without a maintained exclusion list: they are ruled out by the same test the solver itself would apply. It falls out as very nearly the set the inspector greys out, arrived at independently and from the other direction. Feed streams are levers too, as total_flow, T and P; x_<species> is deliberately left out, because _apply_params rescales the other species to hold the total, which makes that derivative a composition swap rather than one knob. Every key the picker offers is a key Flowsheet._apply_params accepts, and therefore a key planning.Block.from_flowsheet’s u list takes — the GUI picker and the Python API name the same things.
Rankings are relative: d ln y / d ln u, which is the only comparison that means anything between a volume in m³ and a temperature in K. It is None, not infinity, when either base value is zero. The reverse view draws them as a tornado — plain SVG geometry computed in model/results.js, no plotting library, so the committed bundle stays at half a megabyte and publish.py’s self-contained page has nothing new to swallow.
Two smaller things make the panel honest. Edges carry their flow as a label once solved, and tint by relative sensitivity after a derivative — thickness by magnitude, colour by sign — through a CSS custom property, so Canvas.svelte’s stylesheet decides what “up” and “down” look like and the model layer never computes a colour. And any edit that is not a move drops the stored solve: results are about the flowsheet you solved, and a panel still showing the previous one is worse than an empty panel.
Getting the model out again#
An editor you can only enter is worse than none, so the File menu offers four files and none of them is a re-implementation of something difflow already writes:
File |
Where it comes from |
|---|---|
|
|
|
the served document, |
|
|
|
that same SVG rastered at 2x in the page, on a white ground |
The diagram is the part worth explaining. difflow.report.diagram already owned the only flowsheet-to-SVG drawer in the codebase, and the editor already shared its column algorithm through difflow.gui.layout.unit_columns — so rather than grow a second drawer, that one was parameterised: topology_svg(units, recycles, positions=None) is the drawing, flowsheet_svg(report) is the adapter a report uses, and flowsheet_diagram(flowsheet, positions) is the adapter the editor uses. A picture exported from the canvas and a picture in an HTML report are therefore the same picture, and positions is what makes the exported one a diagram of your flowsheet rather than of some flowsheet with the same topology: pass the canvas’s own coordinates and the boxes land where they were dragged. The two callers key their nodes differently — the canvas uses a unit’s bare name, because that is _apply_params’s vocabulary, while the report prefixes unit: — and _diagram_keys is the single place that translates. A position naming a node that no longer exists is ignored rather than raised on, because a stale layout is a normal thing to be holding.
The PNG goes through an Image and a <canvas>, with the SVG handed over as a data URL rather than a blob URL: a canvas that has drawn a blob-URL image counts as tainted and toBlob on it throws. Its size comes from the SVG’s own viewBox, since the drawer writes width="100%" — right for a document that flows, useless for a raster. Naming, the XML declaration and the raster size are pure functions in model/download.js and are tested under node; the Blob and the anchor click stay in the component, where nothing can be quietly wrong.
The one thing that can go wrong with committed build output is drift — source edited, bundle not rebuilt — so CI reruns gui-build and fails if static/ differs from the commit.
The functions that turn a serialized flowsheet into nodes and edges (frontend/src/lib/model/graph.js) and the ones that turn a canvas gesture into a request (model/edit.js) are plain JavaScript with no framework in them, and they are tested under bare node --test. That is where the testing effort goes on purpose: a wire attached to the wrong port, or a delete sent to the wrong endpoint, looks exactly like one that worked until the next reload. tests/test_gui.py runs those same files when node is on PATH and skips when it is not: node is a tool for building difflow, never a dependency of it.
One wrinkle worth knowing: JSON has no literal for the non-finite floats, and JSON.parse rejects the Infinity that Python’s json writes. This is the common case, not an exotic one — mass_action_kinetics puts inf in K_eq for every irreversible reaction — so those values travel as the strings "Infinity", "-Infinity" and "NaN", and are restored on the way back.
Delta vectors, for someone else’s planning model#
Planning is the panel that turns the open flowsheet into the object an LP planning system consumes: a Jacobian of chosen outputs against chosen levers, around the solved base case, with the bounds and the trust radius that say where it is valid. difflow.planning has computed that since it existed. What it never had was a way to say which levers, by pointing at them.
Check parameters to make them levers and stream quantities to make them outputs, press Linearize, and POST /api/linearize builds a Block.from_flowsheet over the live flowsheet, calls linearize_block, and returns a DeltaVectorSet. Lever keys are _apply_params’s own vocabulary — reactor.V, feed:feed.total_flow — which is why the same picker serves the sensitivity sweep and this: they are asking for derivatives of the same thing, one at a time versus all at once.
Four things about it are deliberate.
The selection is part of the document. It persists as view.planning = {"u", "y", "bounds", "radius"} alongside view.nodes, so a flowsheet reopens on the levers it was last linearized against. Choosing them is the work; recomputing the Jacobian is a second.
A blank bound is not an infinity. A lever with no bounds still needs a range for the trust region to mean anything, so an unbounded lever gets a symmetric window around its own base value. An infinite bound would make every scaled column zero and the health report would then flag the whole model as dead — a diagnostic failure caused by the diagnostic’s own input.
The finite-difference check is a button, not a default. check_delta_vectors compares the AD Jacobian against central differences, which costs 2 n_u extra solves against the one the Jacobian took. It is the check that decides whether a Jacobian should leave the building — a clip, a minimum or a where sitting on an active constraint gives an AD derivative that is perfectly correct and completely unlike the flowsheet’s actual response — so the panel offers it prominently and states the number either way, but does not make everyone pay for it on every press.
Health findings travel with the coefficients. check_delta_health runs on every linearization and its findings ship inside the export, because a dead lever or a recycle loop gain near one is a fact about the numbers, and the person pricing against them downstream never sees this panel. The panel also renders an exactly-zero cell as 0 rather than 6.781e-21: a structurally dead column is the one thing in that table a reader must not miss, and scientific notation hides it among the merely small.
Downloads are the JSON manifest and the CSV tables, and they are fetched from the server rather than rebuilt in the page — difflow.planning.export’s own writers produce them, so what a planning system reads is byte-for-byte what difflow plan-export writes. The name is <flowsheet>_delta_vectors.json, never <flowsheet>.json: a download landing next to the document under that name is a flowsheet overwritten by a Jacobian.
.lp and .mps are not offered here, and their absence is not an omission. Those are renderings of an assembled LP, and this panel poses none — no prices, no constraints, therefore no shadow prices. Run DeltaBasePlanner or difflow plan-export for those; the JSON this writes is what such a run consumes.
What the assistant is told#
The editor can put a small language model next to the canvas, and the model is the least interesting half of that. A 3B model knows nothing about difflow; the useful thing it can do is read. So the work is in the brief — and the brief is assembled from things difflow already computed and never showed anyone.
GET /api/context (or session.context(...) in Python, with no server at all) returns one of four:
|
What it contains |
|---|---|
|
the catalog schema for the operation, its equations and assumptions, its docstring, and this node’s own parameter values with their units |
|
the units and how they are wired, the feeds, the recycles, the species, and the Python |
|
|
|
a |
Three decisions in there are worth stating.
The brief is the product, not the answer. Every pack comes back whole — sections, assembled prompt, token count, and a note for anything the budget dropped — and the panel shows it. When the local model is weak that is still the useful output: read it, or paste it into a stronger assistant. It is a report about the flowsheet that difflow could not previously produce.
Retrieval is lexical and build-time. docs/ is 23 files and 720 KB, fixed at release. difflow.gui.docs_index splits it at headings — outside fenced code, since every chapter is full of Python and # Build the flowsheet in a fence is a comment, not a section — and writes static/docs-index.json. That file is committed and CI rebuilds it and fails on a difference, exactly like the JS bundle: an index that has silently stopped matching the prose it indexes is worse than no index. It lives under static/ because docs/ is not in the wheel and static/ is, and it is built by a stdlib-only script so the CI job needs nothing installed.
Scoring is TF-IDF with length normalisation, and it runs in Python rather than in the browser. Where a dot product happens is not architecture; running it here makes it testable without a headless browser and keeps a 667 KB file off the wire on every question. What actually sharpens it is that each pack searches on the question plus its own subject — the operation name, the units in the flowsheet, the solver that ran. A user’s question is four words long and two of them are “why” and “this”; the pack is what knows what “this” is.
And where the question goes is stated, not assumed. Three things can answer a brief, chosen in the panel:
Provider |
Where the brief goes |
|---|---|
No model (the default) |
nowhere. The brief is assembled and shown; copy it into an assistant of your choosing |
In this browser |
a small model over WebGPU, via WebLLM. The weights come from the MLC CDN once (1–2 GB) and are cached by the browser; nothing you type leaves the machine |
Local server |
an OpenAI-compatible base URL you give — Ollama, llama.cpp, vLLM |
Anthropic |
|
The footer names the active one in a sentence, in the accent colour when the answer is that the brief leaves the machine. The default sends nothing anywhere, and the WebLLM runtime is bundled rather than fetched from a CDN at run time — this page carries the CSRF token for a server that can exec Python, so it must not import third-party code over the network. That costs about 6 MB of committed build output, loaded only if someone selects that provider.
The budget is a real constraint. The default runtime is a 3B model with a 4096-token window, and a brief that overflows it is truncated at the end — where the question is. So a pack is fitted to context.BUDGET tokens, dropping whole low-priority sections rather than truncating any of them (half a parameter table is a table with parameters missing from it), never dropping the subject, and recording what went. A thin answer then has a visible cause.
The console, where the flowsheet is a Python object again#
The assistant answers a question in English. Console is the same question asked in Python, and it is the panel that admits the editor can never have a button for everything. It is a REPL running in the difflow process — every cell is exec’d in a namespace that is rebound before each run:
Name |
What it is |
|---|---|
|
the flowsheet on the canvas. Not a copy |
|
the last solve’s streams, or |
|
the last linearization, or |
|
the session itself — |
|
imported for you |
plus whatever the code context defines, so a thermo object built there is a name at the prompt.
That fs is live is the whole point. A REPL over a deepcopy would answer questions about a model nobody is looking at. Edit a parameter in a cell and the server refingerprints the document, notices, drops the stale solve, and tells the page to redraw — the canvas follows the prompt. It is also what makes the panel difflow’s rather than any editor’s: jax.grad at the prompt differentiates through the recycle tear solve of the flowsheet you are looking at.
>>> import jax
>>> f = lambda V: fs._apply_params({'reactor.V': V}).solve()['liq']['F_ethanol']
>>> float(jax.grad(f)(1.0))
0.249960277574744
Four things about it are deliberate.
A traceback is an answer. console_run returns ok: True with error set, which is the one place difflow’s {"ok": false, "error": ...} convention is on purpose not followed: the cell raising is the normal outcome someone typing at a prompt asked about, and a panel that reported it as a failed request would be reporting on the wrong thing. The traceback is trimmed to start at the user’s own frame — the server’s stack above it is noise — and the offending line is shown, because cells are registered with linecache under <console:n> so Python can quote source that was never a file.
The namespace is not the code context’s. view["code_context"] is part of the model: it is serialized, re-evaluated on load, and it is what $ref tags resolve against. If a cell’s assignments landed there, typing k = 0.5 would silently change what the saved file means. The console reads those bindings and writes to a layer of its own, and names lists what you defined — never the names that were injected for you.
Figures are a hook. v1 emits text, but the wire format ({"kind", "mime", "data"}) and the browser’s renderer both already understand an image, so plots are a callable and nothing else:
from difflow.gui.console import Console, image
def figures(namespace):
import io, matplotlib.pyplot as plt
out = []
for num in plt.get_fignums():
buf = io.BytesIO()
plt.figure(num).savefig(buf, format="png", dpi=110)
out.append(image(buf.getvalue()))
plt.close("all")
return out
Console(display_hooks=[figures])
A hook that raises is reported where its output would have been, rather than taking the cell’s result down with it. And the page has an explicit branch for a kind it does not know, so a server emitting something newer degrades to a legible placeholder instead of an empty box.
It is not a sandbox, and says so. The server already execs browser-supplied Python for the code context; a console adds no capability that endpoint did not have, and the CSRF token plus the Origin/Host check are the whole protection either way. What it does add is a way to hang the single-threaded server with a cell that loops forever — there is no safe way to interrupt a running thread in Python, so the panel states that rather than offering a stop button that would not work.
Publishing a Model#
difflow.publish turns a flowsheet into a self-contained HTML page that anyone can open with nothing installed — no Python, no server, no network. It is the form a model needs for a paper’s supplementary material or a project page.
from difflow import publish, SweepAxis
axes = [
SweepAxis("reactor.V", 0.5, 5.0, n=5, label="Reactor volume", units="m³"),
SweepAxis("feed:fresh_feed.F_n_hexane", 0.5, 1.5, n=3, label="Feed rate", units="mol/s"),
]
outputs = {"conversion": lambda streams: 1 - (streams["liquid_product"]["F_n_hexane"]
+ streams["vapor_product"]["F_n_hexane"])
/ streams["fresh_feed"]["F_n_hexane"]}
publish(
fs,
axes=axes,
outputs=outputs,
path="model.html",
title="Reactor sizing",
)
Axis keys are whatever _apply_params accepts — "<unit>.<param>" and "feed:<stream>.<field>" — so a feed rate or a feed temperature is an axis like any other.
The page carries the flowsheet itself, sliders for each axis, the outputs, and their sensitivities. This works because the solve is pre-computed: sweep evaluates the flowsheet on the grid with jax.vmap, takes gradients with jax.grad, and bakes the results into the page, which interpolates between them.
The page is the editor, frozen#
A published page is the editor’s own front end with nothing editable and nothing to solve: the same @xyflow canvas, the same graph model, the same palette. Click a unit and it says what the unit is — description, parameters with units, ports, equations, assumptions, references — read from difflow.catalog, which reads the class. A published model therefore cannot describe a unit differently from the editor, and cannot go stale against the code it was published from.
That replaces what a published page used to be, which was a set of sliders belonging to nothing visible. Pass topology=False to publish to get that back.
Three details follow from self-contained, and they are the reason this is a second Vite build rather than a second entry point of the first:
A build with two entries shares chunks between them, and a chunk is a file the page would have to fetch.
vite.publish.config.jsusesbuild.libwithformats: ['iife'], sopublish.jsis one filepublish.pycan inline verbatim.KaTeX is deliberately left out. A quarter megabyte of typesetting is a poor trade for a file that has to open in ten years, so equations are shown as their LaTeX source.
The topology on the page is not
serialize.to_dict(). That is a document meant to be loaded back, and it refuses a flowsheet holding arate_fnor a thermo object — which is most interesting flowsheets, and no reason to publish a page without a picture.publish._topologybuilds a description meant for looking at: same node keys as the editor, every value reduced to something printable, and anything that cannot be shown named by its type (<function>,<array (2, 1)>) rather than dropped, because a parameter that vanishes reads as one the unit does not have.
test_is_self_contained is now two tests, because the bundle makes the old one impossible to satisfy: Svelte and @xyflow ship documentation URLs inside their error messages, and every SVG carries the http://www.w3.org/2000/svg namespace, which is a name that looks like an address. So one test forbids the constructs that actually load something (<script src, <link , url(http, @import url(), and the other walks every URL in the file against an allowlist — which is the stricter of the two, since a new URL has to be looked at before it can be added.
That is a deliberate trade, and its limits should be stated plainly. JAX has no WebAssembly build, so a browser cannot run the real solver; the published page is an interpolation of a grid, not a live model. It is exact at the grid points and only as good as the grid between them, and it can only vary what the axes name. When you need the real thing, use the local editor above, or the generated script.
The arithmetic the page runs — interpolation between solved points, and the exact derivatives recorded alongside them — lives in frontend/src/lib/model/sweep.js and is tested under bare node --test with everything else in model/. It used to be a string of JavaScript inside a Python template, where nothing could reach it.
sweep is available on its own when you want the grid as data rather than as a page:
from difflow import sweep
result = sweep(fs, axes, outputs)
result.values["conversion"] # shape (5, 3)
result.gradients["conversion"] # d(conversion)/d(axis), same shape per axis
Best Practices#
Stream Naming Conventions#
# Clear, descriptive names
'fresh_feed' # Feed streams
'reactor_effluent' # Unit outputs
'flash_vapor' # Phase-specific
'recycle_to_mixer' # Recycle streams
'product_A' # Product streams
Flowsheet Organization#
# Good: Logical unit ordering
fs.add_unit(mixer) # 1. Combine feeds
fs.add_unit(preheater) # 2. Preheat
fs.add_unit(reactor) # 3. React
fs.add_unit(cooler) # 4. Cool
fs.add_unit(separator) # 5. Separate
fs.add_unit(splitter) # 6. Split recycle
# Define recycles last
fs.add_recycle('recycle', 'mixer')
Debugging Convergence Issues#
# Check individual units against the solved streams
streams = fs.solve()
for unit in fs.units:
inlet = streams[unit.inlet_names[0]]
outlet = streams[unit.outlet_names[0]]
print(f"{unit.name}: {inlet['T']:.1f} K -> {outlet['T']:.1f} K")
# What the last recycle solve actually did
print(fs.last_solve_method) # 'anderson', 'wegstein', 'none', ...
print(fs.last_solve_iterations) # == max_iter if it ran out
print(fs.last_solve_residual, fs.last_solve_tol)
print(fs.last_solve_converged) # False is a finding; None means "not judged"
The full decision tree is in Convergence and Initialization.
Memory Efficiency#
# Use JIT compilation for repeated evaluations
@jax.jit
def evaluate_flowsheet(feed_flow):
original = fs.feeds['fresh_feed']
fs.feeds['fresh_feed'] = make_stream({species[0]: feed_flow, species[1]: 0.0},
T=340.0, P=101325.0)
try:
streams = fs.solve()
finally:
fs.feeds['fresh_feed'] = original
return streams['liquid_product'][f'F_{species[1]}']
# Vectorize over multiple cases
cases = jnp.array([0.8, 1.0, 1.2])
results = jax.vmap(evaluate_flowsheet)(cases)