The Flowsheet Debugger#
A recycle that will not close usually fails somewhere specific: one unit
returns a NaN on the first pass, a tear field oscillates while the others
settle, a reactor’s own Newton solve quietly gives up. The solve’s summary
(last_solve_converged, the residual history, difflow.diagnostics) tells
you that it failed. The debugger lets you watch it fail: pause the
sequential-modular solve unit by unit and tear iteration by tear iteration,
look at every stream on the way, and stop on the thing you are hunting.
It is not pdb. pdb stops on lines of Python; this stops on the
flowsheet’s own structure — before unit R1, at iteration 12, when the
residual rises, where the first NaN is born — and its commands speak
streams, units and tears.
Table of Contents#
Quick start#
From the shell, on a script that builds a flowsheet or on a saved flowsheet:
difflow debug plant.py # stop before the first pass
difflow debug plant.py --on-error # run; stop only if something fails
difflow debug plant.json --acceleration wegstein
difflow debug plant.py --script warmup.dbg # run these commands first
difflow debug plant.py --json # JSON protocol on stdin/stdout
A script runs with the debugger already attached, so the first solve it makes is the one you step through, with its own arguments. From Python or a notebook:
from difflow import debug
with debug.attach(): # a prompt on this terminal
fs.solve(acceleration="wegstein")
with debug.attach(on_error=True): # stop only post mortem
fs.solve()
A short session:
── difflow-dbg ── @solve_start phase=start
↳ start
difflow-dbg> where
M1: (feed, recycle*) -> (mixed)
R1: (mixed) -> (reacted)
S1: (reacted) -> (recycle_out, product)
difflow-dbg> break if residual<1e-4
difflow-dbg> watch recycle.F_A
difflow-dbg> continue
── difflow-dbg ── @iteration iter 4 phase=solve residual=1.110e-16
↳ condition: residual<1e-4
[recycle.F_A] recycle.F_A = 0.5625
difflow-dbg> p tear
stream field x g(x) |g-x|
recycle F_B 0.9375 0.9375 1.11022e-16
...
difflow-dbg> p history
difflow-dbg> continue
The prompt and everything it prints go to stderr, so a script’s own output
is left alone. Tab completes commands, unit names and stream names, and
history is kept in ~/.difflow_dbg_history.
Where it stops#
Checkpoint |
Fires |
What is fresh |
|---|---|---|
|
once, before anything runs |
the feeds |
|
before each unit call |
the unit’s inlets |
|
after each unit call |
its outlets and its |
|
a unit raised (the solve re-raises after) |
the inlets that broke it, the exception |
|
the tear map was evaluated |
|
|
Wegstein/Anderson computed the next guess |
|
|
the solve is about to return or raise |
the verdict, every stream |
Unit checkpoints carry a phase: init (the pass from the feeds that
guesses the tears), solve (the tear iteration), probe (the
error-probe passes after convergence, see
convergence), final (the pass from the converged
tears) and sequential (a flowsheet with no recycle).
Stepping:
Command |
Runs to |
|---|---|
|
the next |
|
the next unit ( |
|
the next checkpoint of any kind |
|
the next breakpoint, a unit error, or the end |
|
iteration |
|
(not a step) always stop at that checkpoint |
|
the end, without stopping again |
|
nowhere: the solve raises |
Commands#
Command |
Shows |
|---|---|
|
where the solve is: checkpoint, phase, iteration, unit, residual |
|
the calculation order, |
|
every field of a stream |
|
one value |
|
the unit’s ports, parameters and its last |
|
every tear entry with |
|
the residual history, and what |
|
the accelerated next guess, at |
|
a metric |
|
print a target at every stop ( |
|
the history’s regime, the worst tear entries, non-finite streams; at |
|
the current streams, history and tear table as JSON |
|
the guess the next pass runs on (see below) |
|
a Python prompt with |
Breakpoints#
break 12 # at iteration 12 (tbreak 12: once)
break R1 # before unit R1, every pass (or: break unit R1)
break if residual>1e3 && iter>5
break on residual_rise # an event
break # list them
break del R1 # delete one; break clear: all
Conditions are metric OP value joined by &&, over iter,
residual, log_residual and ratio (residual over the previous
residual: the observed contraction; a ratio near 1 is a loop gain near 1).
Events:
Event |
When |
|---|---|
|
a unit turned finite inlets into a NaN or inf outlet — where the NaN is born, not every unit it passes through — or the tear residual is not finite |
|
a unit’s |
|
the tear residual went up |
|
a unit raised |
Post mortem: on_error#
--on-error (debug.attach(on_error=True)) runs the solve freely and
stops once, at the first of: a unit that raises, a unit that produces a
NaN from finite inlets, or a solve that ends unconverged. A clean solve
never stops. It is the quickest way to answer “which unit broke it?”,
including when it broke during the tear-guessing pass before the iteration
began.
Editing the tear: set tear#
The one thing the debugger changes is the guess the next pass runs on:
difflow-dbg> stop-at iteration
difflow-dbg> continue
── difflow-dbg ── @iteration iter 3 phase=solve residual=4.120e-01
difflow-dbg> p tear
difflow-dbg> set tear recycle.F_A 0.5
next pass: recycle.F_A = 0.5 (was 0.4909)
difflow-dbg> continue
At an after_accel pause it edits the accelerated guess. At an
iteration pause it replaces that iteration’s step: the edit starts from
g(x) (what plain substitution would run next), and the convergence test
for that iteration is skipped, because the next pass is not the one that
was tested. p next shows the guess as edited. Wegstein’s secant pairs
and Anderson’s history are kept: every pair in them is still a real
evaluation of the tear map.
An edit changes the route to the fixed point, not the fixed point: a
solve that converges after one converges to the same answer. It is still
not the plain solve, so it says so. Every edit is listed in the
terminated event’s edits and in Flowsheet.last_solve_debug_edits
(empty for a solve nobody edited), and the REPL’s last banner counts
them. Unit parameters are not editable: that would be a different
flowsheet, not a different route.
Recording a solve, and replaying it#
debug.record(fs, solve_kwargs) solves once with nobody at the prompt,
stepping through every checkpoint, and returns a difflow-trace/1 dict:
the unit order, the tears, one frame per checkpoint holding only what that
checkpoint wrote (the feeds, a unit’s outlets, the tear iterate, the next
guess), and the solve’s verdict. Replaying the frames up to i gives
every stream at frame i (difflow.debug.record.state_at). A solve that
raises is recorded up to the raise, with the error in result, rather than
raised. summarize(trace) is the same thing without the frames: one row per
tear iteration (its frame, its residual, the tear entry the residual is
mostly made of), the events, and how the history reads.
from difflow import debug
from difflow.debug.record import compare, summarize
trace = debug.record(fs, {"acceleration": "wegstein"})
summarize(trace)["iterations"]
What if#
What would the solve have done if the tear had been X at iteration 3? The faithful answer is a replay: solve again from the start with the same options and set the tear when the replay reaches that frame, so that everything before it is the recording and the accelerator still has the history it had there. A fresh solve seeded with the edited value is not the same thing — it forgets Anderson’s or Wegstein’s history and takes a different path.
k = summarize(trace)["iterations"][3]["frame"]
replay = debug.record(fs, {"acceleration": "wegstein"},
edits=[{"frame": k, "stream": "recycle", "field": "F_A", "value": 0.5}],
baseline=trace["frames"])
compare(trace, replay) # residuals side by side, same_answer
Two checks keep it honest. Every frame before the edit is checked against
the recording, and result["diverged_at"] names the first that differs:
then the replay is not the same solve, and its answer is about something
else. And compare says whether the two runs ended at the same answer
(same_answer, with the stream that differs most): two converged runs
that disagree are two steady states.
Behind a server: the session’s debugger#
difflow.workspace.FlowsheetSession — the session engine the agent
tools run on — carries the debugger for front ends that cannot block on
a prompt. The live debugger is a long poll: debug_start,
debug_command, debug_wait, debug_pause and debug_stop each return
as soon as the solve pauses or ends, or after a wait (20 s by default)
with state: "running", and the caller asks debug_wait again. The solve
runs on a copy of the flowsheet, so nothing done to the session while it
is paused reaches it. debug_record and debug_whatif keep the last
recording and replay it, refusing a recording of a flowsheet that has
changed since (the layout aside). All of it answers in the JSON protocol’s
events.
The difflow book’s app (the browser editor) puts its Debug and Trace panels on exactly these calls.
Driving it from a program#
difflow debug --json speaks difflow-dbg/1: newline-delimited JSON,
one object per line. The first line is a hello listing every checkpoint,
event, metric, command and capability, so a client feature-detects off it
rather than off a version. Then a pause at each stop, and a result for
each command, echoing its id as request_id. A command is a bare string
("continue") or an object ({"cmd": "print", "args": ["recycle"], "id": 3}).
Inside Python, difflow.debug.runner.DebugRunner runs the solve on a
worker thread and hands back the same events one call at a time — the
shape a server, an editor or an agent needs:
from difflow.debug.runner import DebugRunner
runner = DebugRunner(fs, {"acceleration": "wegstein"})
runner.start() # {'event': 'pause', 'checkpoint': 'solve_start', ...}
runner.command("break on nonfinite") # {'event': 'result', 'ok': True, ...}
runner.command("continue")["next"] # the next pause, or {'event': 'finished', ...}
runner.close()
An agent connected over MCP (difflow mcp) gets the same debugger as
tools: debug_start, debug_command, debug_wait, debug_pause,
debug_state, debug_stop, and debug_guide for the contract (see
agents). They are the session’s debugger calls above, on the
agent’s session. Record and replay are there too:
debug_record (a summary, not every frame), debug_trace_frame (one frame
in full) and debug_whatif (the replay, compared with the recording,
including whether it reached the same answer). In Python the summary and the
comparison are difflow.debug.record.summarize, state_at and compare.
What it does not change#
Detached, a solve is exactly what it was: each checkpoint costs one
thread-local read. Attached, the debugger only reads unless you set tear: the same units run in the same order on the same values, and the
answer is the same. quit
raises DebugQuit instead of returning a partial answer, because a
stopped solve has none.
Only the outermost solve is debugged. A flowsheet solved inside a unit of
the one being debugged stays silent, and by default only the first solve
on the thread is debugged, so a script that sweeps does not stop you once
per point (--all-solves changes that).
Limits#
What it can step is the eager tear iteration:
acceleration="anderson"(the default) and"wegstein".acceleration="none"iterates inside optimistix, where there is no Python loop to stop in; the debugger sees that solve’s start and end only.Nothing fires under
jax.gradorjit. A traced solve is traced once, not run, and a tracer is not a number anyone can inspect.A unit is one step. A unit’s own inner solve (a CSTR’s Newton iteration, a flash’s Rachford-Rice) runs inside JAX and is not stepped; what it reports in its
infodict is shown, andinner_unconvergedstops on it.It edits tear values only (
set tear), never parameters, and has no rewind yet.