The Flowsheet Debugger#
A recycle that will not close usually fails somewhere specific: a unit returns a NaN on the
first pass, one tear field oscillates while the others settle, a reactor’s own solve gives up.
fs.solve() and difflow.diagnostics tell you that a solve failed. The debugger lets you
watch it: it pauses a sequential-modular solve unit by unit and tear iteration by tear
iteration, shows every stream on the way, and stops on breakpoints — before a unit, at an
iteration, on a condition on the residual, or where the first NaN appears.
This notebook covers:
Stepping through a recycle solve and reading streams, units and the tear residual
Breakpoints on a unit, a condition and an event
Post mortem: finding the unit where a NaN is born
set tear: giving the next pass a different guessRecord and replay: a solve kept frame by frame, and a what-if that finds a second steady state
Driving it from a program:
DebugRunnerand the JSON protocol
At a terminal you would type the commands yourself at a difflow-dbg> prompt:
difflow debug plant.py
or, from a script or a notebook, with debug.attach(): fs.solve(). A notebook that runs
unattended cannot type, so each session below hands the same debugger its commands as a script.
The commands and their output are exactly what you would see at the prompt.
Setup#
import io
import sys
import warnings
import jax
import jax.numpy as jnp
import matplotlib.pyplot as plt
jax.config.update("jax_enable_x64", True)
from difflow import (CSTR, CSTRParams, Flowsheet, IdealThermo, Mixer, Splitter, Unit,
make_stream, mass_action_kinetics)
from difflow import debug
from difflow.database import get_species_data
from difflow.debug import DebugQuit, DebugSession
from difflow.debug.record import compare, state_at, summarize
from difflow.debug.repl import ReplFrontend
The test case is a small isomerization loop: n-hexane is converted to 2-methylpentane in a CSTR,
and 80 % of the reactor effluent is recycled to the mixer. The splitter outlet recycle is the
tear stream.
species = ["n_hexane", "2_methylpentane"]
thermo = IdealThermo({s: get_species_data(s) for s in species})
kin = mass_action_kinetics(
[{"reactants": {species[0]: 1.0}, "products": {species[1]: 1.0},
"rate_params": {"A": 0.05}}], species_order=species)
def isomerization_loop():
reactor = CSTR(CSTRParams(V=1.0, molar_density=10.0, **kin.params_kwargs()), thermo)
fs = Flowsheet(species_order=species)
fs.add_feed("fresh", make_stream({species[0]: 1.0, species[1]: 0.0}, T=340.0, P=101325.0))
fs.add_unit(Unit("mixer", Mixer(species, thermo), ["fresh", "recycle"], ["reactor_feed"]))
fs.add_unit(Unit("reactor", reactor, ["reactor_feed"], ["effluent"], params={"T_spec": 340.0}))
fs.add_unit(Unit("split", Splitter(species), ["effluent"], ["recycle", "product"],
params={"split_frac": 0.8}))
fs.add_recycle("recycle", "recycle") # tear the splitter outlet
return fs
fs = isomerization_loop()
plain = fs.solve(acceleration="wegstein")
print("converged:", fs.last_solve_converged, "in", fs.last_solve_iterations, "iterations")
print("product 2-methylpentane:", float(plain["product"]["F_2_methylpentane"]), "mol/s")
converged: True in 6 iterations
product 2-methylpentane: 0.3333333333193864 mol/s
A small helper runs one debugging session with a list of commands, printing to the notebook. When the commands run out, the session detaches and the solve finishes on its own, which is what pressing Ctrl-D at the prompt does.
def session(commands, **attach):
# A debugger that reads `commands` instead of a keyboard and prints here.
frontend = ReplFrontend(script=commands, stdin=io.StringIO(""), stdout=sys.stdout)
return DebugSession(frontend, **attach)
1. Stepping through a solve#
The first pause is before anything runs (solve_start). From there:
nextruns to the next unit;stepruns to the next tear iteration;whereshows the calculation order, with->at the current unit and*on tear streams;p STREAM,p UNITandp STREAM.FIELDprint what is known at this point;p tearbreaks the tear residual down by stream and field, largest first;p historylists the residual so far, with a reading of it: mid-solve, how fast it is falling (or creeping, rising, oscillating); at the end, the verdict on the solve.
with warnings.catch_warnings():
warnings.simplefilter("ignore")
with debug.attach(session([
"where",
"next", "next",
"p reactor_feed",
"p reactor",
"step",
"p tear",
"step", "step",
"p history",
])):
isomerization_loop().solve(acceleration="wegstein")
── difflow-dbg ── @solve_start phase=start
↳ start
difflow-dbg> where
solve_start phase=start iteration=-
mixer: (fresh, recycle*) -> (reactor_feed)
reactor: (reactor_feed) -> (effluent)
split: (effluent) -> (recycle, product)
(* = tear stream)
difflow-dbg> next
stepping to the next unit
── difflow-dbg ── @after_unit mixer phase=init
↳ step
difflow-dbg> next
stepping to the next unit
── difflow-dbg ── @after_unit reactor phase=init
↳ step
difflow-dbg> p reactor_feed
reactor_feed:
F_n_hexane 1.01
F_2_methylpentane 0.01
T 340
P 101325
difflow-dbg> p reactor
unit reactor (CSTR)
inlets reactor_feed
outlets effluent
V 1
T_damping 0.3
molar_density 10
outlet_volumetric_basis 0
T_spec 340
info:
Q -4.11153
molar_density 10
balance_residual 2.17691e-16
converged 1
H_mix 0
difflow-dbg> step
stepping to the next iteration
── difflow-dbg ── @iteration iter 0 phase=solve residual=4.252e-01
↳ step
difflow-dbg> p tear
stream field x g(x) |g-x|
recycle F_n_hexane 0.542211 0.967411 0.4252
recycle F_2_methylpentane 0.273789 0.485389 0.2116
recycle T 340 340 0
recycle P 101325 101325 0
difflow-dbg> step
stepping to the next iteration
── difflow-dbg ── @iteration iter 1 phase=solve residual=3.400e-01
↳ step
difflow-dbg> step
stepping to the next iteration
── difflow-dbg ── @iteration iter 2 phase=solve residual=6.525e-04
↳ step
difflow-dbg> p history
0 0.4252
1 0.340003
2 0.000652472
reads as: falling, about x0.039 per iteration
difflow-dbg> (EOF: detached)
The first two nexts land in the init phase: before the iteration starts, difflow runs every
unit once from the feeds to guess the tear. That pass is where a bad starting point is usually
born, so the debugger stops there too. Each pause names its phase: init, solve (the
iteration), probe (the error probe after convergence) and final.
At the first iteration pause, p tear shows that the residual is entirely the recycle’s flows:
x is the guess the pass ran on, g(x) what the loop sent back.
2. Breakpoints#
Command |
Stops |
|---|---|
|
before unit |
|
at tear iteration 4 (once, for |
|
at the first iteration where the condition holds |
|
|
|
(not a stop) print a target at every pause |
continue runs to the next breakpoint, a unit error, or the end.
with debug.attach(session([
"break if residual<1e-6 && iter>1",
"break on residual_rise",
"watch recycle.F_n_hexane",
"continue",
"info",
"break clear",
"break split",
"continue",
"p effluent",
"break clear",
"continue",
])):
isomerization_loop().solve(acceleration="wegstein")
── difflow-dbg ── @solve_start phase=start
↳ start
difflow-dbg> break if residual<1e-6 && iter>1
conditional breakpoint: residual<1e-6 && iter>1
difflow-dbg> break on residual_rise
breakpoint on residual_rise (the tear residual went up from one iteration to the next)
difflow-dbg> watch recycle.F_n_hexane
watching recycle.F_n_hexane
difflow-dbg> continue
continuing
── difflow-dbg ── @iteration iter 4 phase=solve residual=2.239e-07
↳ condition: residual<1e-6 && iter>1
[recycle.F_n_hexane] recycle.F_n_hexane = 2.66667
difflow-dbg> info
checkpoint iteration
phase solve
iteration 4
residual 2.23924e-07
method wegstein
reason condition: residual<1e-6 && iter>1
log_residual -6.6499
ratio 0.000945793
tears recycle
units 3
difflow-dbg> break clear
all breakpoints cleared
difflow-dbg> break split
breakpoint before unit split
difflow-dbg> continue
continuing
── difflow-dbg ── @before_unit split iter 5 phase=solve
↳ unit split
[recycle.F_n_hexane] recycle.F_n_hexane = 2.66667
difflow-dbg> p effluent
effluent:
F_n_hexane 3.33333
F_2_methylpentane 1.66667
T 340
P 101325
difflow-dbg> break clear
all breakpoints cleared
difflow-dbg> continue
continuing
── difflow-dbg ── @terminated iter 6 phase=end residual=2.874e-11 converged
↳ stop-at terminated
[recycle.F_n_hexane] recycle.F_n_hexane = 2.66667
difflow-dbg> (EOF: detached)
The condition stopped the solve at iteration 4. break split then stopped before the splitter on
the next pass (iteration 5), and the watch printed the recycle flow at every stop. The final
continue runs to terminated, where the debugger always stops once to show the verdict.
3. Post mortem: where is the NaN born?#
A NaN in one unit spreads to every unit downstream of it and then to the tear, so by the time the
solve reports a NaN residual, every stream is NaN and the cause is invisible.
attach(on_error=True) (difflow debug plant.py --on-error at a terminal) runs the solve freely
and stops once, at the first of:
a unit that raises;
a unit that turns finite inlets into a NaN or inf outlet (where the NaN is born, not every unit it passes through);
a solve that ends unconverged.
Here a heater has a planted bug: a correlation that takes the log of T - 400, which is fine
above 400 K and NaN below.
def heater_with_a_bad_correlation(stream):
T_out = stream["T"] + 10.0 * jnp.log(stream["T"] - 400.0) # NaN when T < 400 K
return make_stream({s: stream[f"F_{s}"] for s in species}, T_out, stream["P"])
def faulty_loop():
fs = isomerization_loop()
fs.units.insert(1, Unit("preheat", heater_with_a_bad_correlation, ["reactor_feed"], ["hot_feed"]))
fs.units[2].inlet_names = ["hot_feed"] # the reactor now takes the preheated feed
return fs
faulty = faulty_loop()
faulty.solve(acceleration="wegstein", on_nonconvergence="ignore")
print("converged:", faulty.last_solve_converged, "| residual:", faulty.last_solve_residual)
converged: True | residual: 2.8741897750705903e-11
The solve says only that the residual is NaN. Post mortem, the debugger says where:
try:
with debug.attach(session(["info", "p reactor_feed", "p hot_feed", "diagnose", "quit"],
on_error=True)):
faulty_loop().solve(acceleration="wegstein", on_nonconvergence="ignore")
except DebugQuit:
print("(stopped from the debugger)")
── difflow-dbg ── @after_unit preheat phase=init
↳ on-error: nonfinite
difflow-dbg> info
checkpoint after_unit
phase init
unit preheat
trigger nonfinite
reason on-error: nonfinite
tears -
units 4
difflow-dbg> p reactor_feed
reactor_feed:
F_n_hexane 1.01
F_2_methylpentane 0.01
T 340
P 101325
difflow-dbg> p hot_feed
hot_feed:
F_n_hexane 1.01
F_2_methylpentane 0.01
T nan
P 101325
difflow-dbg> diagnose
residual history reads as: no tear iteration yet
non-finite streams: hot_feed
difflow-dbg> quit
stopping the solve
(stopped from the debugger)
It stopped at preheat, during the init pass, with finite inlets (340 K) and a NaN outlet:
the unit, the phase and the inlet that triggered it, before any of it reached the tear. quit
raises DebugQuit rather than returning a partial answer.
4. set tear: choosing the next guess#
The debugger changes only one thing: the tear guess the next pass runs on. At an iteration
pause, set tear STREAM.FIELD VALUE replaces that iteration’s step; at an after_accel pause it
edits the accelerated guess. The accelerator keeps its history, because every pair in it is still
a real evaluation of the loop.
An edit changes the route to the fixed point, not the fixed point. A solve that converges
after one converges to the same answer, but it is no longer the plain solve, so the edit is
recorded on the flowsheet in last_solve_debug_edits.
edited = isomerization_loop()
with debug.attach(session([
"stop-at iteration",
"continue",
"p tear",
"set tear recycle.F_n_hexane 3.0",
"stop-at clear",
"continue",
])):
streams = edited.solve(acceleration="wegstein")
print()
print("edits recorded:", edited.last_solve_debug_edits)
print("same product as the plain solve:",
bool(jnp.isclose(streams["product"]["F_2_methylpentane"], plain["product"]["F_2_methylpentane"])))
── difflow-dbg ── @solve_start phase=start
↳ start
difflow-dbg> stop-at iteration
will stop at every iteration
difflow-dbg> continue
continuing
── difflow-dbg ── @iteration iter 0 phase=solve residual=4.252e-01
↳ stop-at iteration
difflow-dbg> p tear
stream field x g(x) |g-x|
recycle F_n_hexane 0.542211 0.967411 0.4252
recycle F_2_methylpentane 0.273789 0.485389 0.2116
recycle T 340 340 0
recycle P 101325 101325 0
difflow-dbg> set tear recycle.F_n_hexane 3.0
next pass: recycle.F_n_hexane = 3 (was 0.967411)
difflow-dbg> stop-at clear
stop-at back to the defaults: terminated, unit_error
difflow-dbg> continue
continuing
── difflow-dbg ── @terminated iter 16 phase=end residual=3.571e-09 converged
↳ stop-at terminated
✎ the debugger set 1 tear value(s) on the way
difflow-dbg> (EOF: detached)
edits recorded: [{'iteration': 0, 'checkpoint': 'iteration', 'stream': 'recycle', 'field': 'F_n_hexane', 'was': 0.9674108171984366, 'value': 3.0}]
same product as the plain solve: True
5. Record and replay#
debug.record(fs) solves once with nobody at the prompt, stepping through every checkpoint,
and keeps a compact trace. Each frame holds only what that step wrote (the feeds, a unit’s
outlets, the tear iterate), so the state at any frame is the frames up to it replayed
(state_at). summarize gives the trace without its frames: one row per tear iteration, with
the tear entry the residual is mostly made of.
trace = debug.record(isomerization_loop(), {"acceleration": "wegstein"})
summary = summarize(trace)
print(len(trace["frames"]), "frames;", summary["result"]["iterations"], "iterations;",
"history reads as:", summary["regime"])
for row in summary["iterations"]:
print(f" frame {row['frame']:3d} iteration {row['iteration']} residual {row['residual']:.3e}"
f" largest: {row['largest']}")
last = len(trace["frames"]) - 1
print("\nproduct at the last frame:", state_at(trace, last)["product"])
80 frames; 6 iterations; history reads as: converged
frame 13 iteration 0 residual 4.252e-01 largest: recycle.F_n_hexane |g-x|=0.425
frame 20 iteration 1 residual 3.400e-01 largest: recycle.F_n_hexane |g-x|=0.34
frame 28 iteration 2 residual 6.525e-04 largest: recycle.F_2_methylpentane |g-x|=0.000652
frame 36 iteration 3 residual 2.368e-04 largest: recycle.F_n_hexane |g-x|=0.000237
frame 44 iteration 4 residual 2.239e-07 largest: recycle.F_n_hexane |g-x|=2.24e-07
frame 52 iteration 5 residual 5.967e-08 largest: recycle.F_n_hexane |g-x|=5.97e-08
frame 60 iteration 6 residual 2.874e-11 largest: recycle.F_2_methylpentane |g-x|=2.87e-11
product at the last frame: {'F_n_hexane': 0.6666666666739908, 'F_2_methylpentane': 0.3333333333259532, 'T': 340.0, 'P': 101325.0}
What if the loop had started somewhere else?#
A what-if replays the recorded solve and sets one tear value when the replay reaches a chosen
frame. It is a replay, not a fresh solve from the edited value: everything before the frame is
the recording, and the accelerator still has its history there. diverged_at checks that: it
names the first frame before the edit where the replay differed from the recording, which would
mean it is not the same solve. compare puts the two runs side by side and reports whether they
reached the same answer.
That last question matters for loops with more than one steady state, like an autothermal reactor that can be lit or extinguished. The loop below is a stand-in with that property: its recycle map \(g(x) = 3x^2 - 2x^3\) has stable fixed points at \(x = 0\) and \(x = 1\), separated by an unstable one at \(x = 0.5\).
def two_state_loop(feed, tear):
x = tear["F_A"]
return make_stream({"A": 3 * x**2 - 2 * x**3}, feed["T"], feed["P"])
def bistable():
fs = Flowsheet(species_order=["A"])
fs.add_feed("feed", make_stream({"A": 1.0}, 300.0, 101325.0))
fs.add_unit(Unit("loop", two_state_loop, ["feed", "tear"], ["out"]))
fs.add_recycle("out", "tear")
return fs
kw = {"use_initialization": False, # start from the default guess, 0.01 mol/s
"acceleration": "wegstein"}
base = debug.record(bistable(), kw)
k = summarize(base)["iterations"][0]["frame"]
results = {}
for value in (0.3, 0.8):
replay = debug.record(bistable(), kw, baseline=base["frames"],
edits=[{"frame": k, "stream": "tear", "field": "F_A", "value": value}])
results[value] = (replay, compare(base, replay))
c = results[value][1]
print(f"tear set to {value}: diverged_at={c['diverged_at']}, converged={c['whatif']['converged']},"
f" same_answer={c['same_answer']}, largest difference: {c['largest_difference']}")
tear set to 0.3: diverged_at=None, converged=True, same_answer=True, largest difference: None
tear set to 0.8: diverged_at=None, converged=True, same_answer=False, largest difference: {'where': 'out.F_A', 'relative': 1.0}
fig, ax = plt.subplots(figsize=(6, 3.5))
for label, trace_ in [("recorded", base), ("what-if: tear = 0.3", results[0.3][0]),
("what-if: tear = 0.8", results[0.8][0])]:
residuals = [f["residual"] for f in trace_["frames"] if f["checkpoint"] == "iteration"]
answer = float(state_at(trace_, len(trace_["frames"]) - 1)["out"]["F_A"])
ax.semilogy(range(len(residuals)), [max(r, 1e-17) for r in residuals], "o-",
label=f"{label} -> out = {answer:.3g}")
ax.set_xlabel("tear iteration")
ax.set_ylabel("residual |g(x) - x|")
ax.legend()
fig.tight_layout()
Both what-ifs converge. Setting the tear to 0.3 lands back on the recorded answer (same_answer
is true), while 0.8 lands on the other steady state, and compare names the stream that differs.
A converged solve is not necessarily the answer.
The accelerator matters here too. Anderson acceleration (difflow’s default) treats the loop as a root-finding problem for \(g(x) - x = 0\), and a root finder is happy to land on an unstable steady state that plain substitution would run away from:
anderson = {"use_initialization": False, "acceleration": "anderson"}
base_a = debug.record(bistable(), anderson)
k_a = summarize(base_a)["iterations"][0]["frame"]
replay_a = debug.record(bistable(), anderson, baseline=base_a["frames"],
edits=[{"frame": k_a, "stream": "tear", "field": "F_A", "value": 0.4}])
print("Anderson from 0.4 converged:", replay_a["result"]["converged"],
"| answer:", float(state_at(replay_a, len(replay_a["frames"]) - 1)["out"]["F_A"]))
Anderson from 0.4 converged: True | answer: 0.5000000002917983
It converged, to \(x = 0.5\), the unstable middle state, which a real plant could not sit at. A what-if is a quick way to find out whether a loop has more than one answer, and which one a given starting point and solver reach.
6. Driving it from a program#
DebugRunner runs the solve on a worker thread and hands back one event per call, which is the
shape a server or an agent needs. The events are the same JSON objects that
difflow debug plant.py --json writes, one per line, after a hello that lists every checkpoint,
event, metric, command and capability.
from difflow.debug.jsonproto import hello
from difflow.debug.runner import DebugRunner
runner = DebugRunner(isomerization_loop(), {"acceleration": "wegstein"})
print(runner.start()["checkpoint"])
print(runner.command("break if residual<1e-4")["output"])
event = runner.command("continue")["next"]
print({k: event[k] for k in ("checkpoint", "iteration", "residual", "reason")})
print(runner.command("p recycle.F_n_hexane")["output"])
runner.close()
h = hello()
print("\nprotocol:", h["protocol"])
print("checkpoints:", h["checkpoints"])
print("events:", list(h["events"]))
solve_start
['conditional breakpoint: residual<1e-4']
{'checkpoint': 'iteration', 'iteration': 4, 'residual': 2.239241769785849e-07, 'reason': 'condition: residual<1e-4'}
['recycle.F_n_hexane = 2.66667']
protocol: difflow-dbg/1
checkpoints: ['solve_start', 'before_unit', 'after_unit', 'unit_error', 'iteration', 'after_accel', 'terminated']
events: ['nonfinite', 'inner_unconverged', 'residual_rise', 'unit_error']
An agent connected through difflow mcp gets the same debugger as tools: debug_start,
debug_command, debug_wait, debug_record, debug_whatif and the rest (see
agents).
Summary#
To |
Use |
|---|---|
Step a solve at a terminal |
|
… from a script or notebook |
|
Find where a NaN is born |
|
Stop before a unit, at an iteration, on a condition or an event |
|
See what the residual is made of |
|
Try a different next guess |
|
Keep a solve frame by frame |
|
Ask whether another start reaches the same answer |
a what-if: |
Drive it from a program |
|
What it can step: the eager tear iteration (acceleration="anderson", the default, or
"wegstein"). acceleration="none" iterates inside optimistix, and nothing fires under
jax.grad or jit. The full reference is the debugger documentation.