Layers and models¶
A layer is one physical process on one network. A Model holds several layers on one
network and steps them together.
This page covers the part of noodl physics least like other network solvers: there are four different ways an edge flow can be determined, and choosing the right one for your problem matters more than any other modelling decision you will make.
The four flow-determination modes¶
Most network tools support exactly one of these. noodl physics supports all four, and they compose on one graph.
| # | Mode | The flow comes from | Implemented by |
|---|---|---|---|
| 1 | Kirchhoff solve | Both Kirchhoff laws plus the constitutive laws on the edges, solved together. Newton's method on a nodal conservation residual: \(A_I\,g(A^\top\phi) = s_I\) (with the flows of implicit constitutive laws as extra unknowns: Modified Nodal Analysis) | KirchhoffLayer |
| 2 | Driver-prescribed | Handed in directly as a driver value | any TransportLayer with no Kirchhoff-layer owner |
| 3 | Closure-computed | Computed from other state by an arbitrary function | a closure writing "<layer>.q" |
| 4 | Clip-and-allocate | A request, clipped against arc capacity and receiver headroom — no potential variable anywhere | AllocatedFlowLayer |
Worked examples of each, from the applications:
- 1 — A building's airflow network. Pressures are unknown; flows follow from them.
- 2 — Not usually chosen alone; it is the escape hatch for a flow you measured or imposed.
- 3 — A street canyon. The along-canyon velocity is a closed-form function of the wind aloft
and the canyon geometry, so there is no potential to solve for;
StreetFlowscomputes every flow and writes it. The sewer's water side likewise: on a tree the cycle space is empty, so continuity alone fixes every pipe discharge in closed form. - 4 — A water-resources network. An arc requests a transfer; the receiving reservoir accepts what its remaining headroom allows; the rest is unmet. There is no head, no pressure, no potential — the rule is the physics.
Getting this wrong is expensive. Modelling a gravity sewer as a potential problem, or a water-allocation rule as a pressure network, produces a model that converges to a confidently wrong answer.
KirchhoffLayer¶
Solves for nodal potentials such that flow is conserved at every interior node.
from noodl.layers.kirchhoff import KirchhoffLayer
layer = KirchhoffLayer(
net,
name="air",
laws=[leak], # one ConstitutiveLaw per edge kind
drives=[stack], # additive potential-difference terms
boundary=["ambient"], # nodes whose potential is prescribed
linear_solver="auto", # 'auto' | 'cg' | 'gmres' | 'direct' | 'sparse_direct'
quantity="pressure", # metadata a Model reports
unit="Pa",
phasor=False, # True: complex sinusoidal steady state (see below)
)
phi, q = layer.solve(phi_boundary, drivers, sources=sources, differentiable=True)
quantity and unit are metadata only — nothing numerical reads them. They exist so that a
model composing several Kirchhoff layers over one network can tell a pressure layer from a
temperature one without matching on names. They are still checked for consistency: every key
of a layer is a Field carrying its unit (a transport layer's unit, source_unit,
capacity_unit and flow_unit give the units of its state, sources, capacity and flows), and
Model.check() warns (unit-mismatch) when a closure declares a different unit for a key it
reads (input_specs) or writes (output_units), and notes (unit-undeclared) a field with
no unit. Units are compared as strings after trivial respelling (m3 and m^3, 1 and -);
nothing is converted. See The closure contract.
Useful accessors: flows(phi, drivers) evaluates the branch law; flows_of_kind(q, kinds)
extracts one kind's block from the concatenated flow vector; residual(...) and jacobian(...)
expose the Newton pieces directly; kind_slice(kind) and law_for(kind) locate a kind.
diagnostics, when you pass a dict, is filled with newton_iterations, linear_iterations,
method, backend, converged and residual_norm. method is what you asked for; backend
is what actually ran. They are deliberately separate — see Solvers.
Modified Nodal Analysis: implicit, coupled and dynamic laws¶
A KirchhoffLayer whose constitutive laws are all potential-controlled, single-edge and
static is solved by nodal analysis, as above. A layer with any law that is implicit
(law_form = "implicit"), multi-port (ports > 1), dynamic or contextual, or a layer built with
phasor=True, is solved by Modified Nodal Analysis instead (layer.mna is True); the
declarations are described under
Implicit, coupled and dynamic constitutive laws.
The electrical application is built on it; the same machinery
serves any domain with ideal sources, flow-controlled laws or storage on its edges.
The system. The unknowns are the interior potentials and the flows of the implicit-law edges
(layer.implicit_edges, in layer edge order). The residual rows are the conservation balance at
the interior nodes, \(A_I q - s_I\) (plus node sources), where \(q\) is the law's flow on a
potential-controlled edge and the unknown on an implicit one, followed by every implicit law's
own residual. The Jacobian is assembled as a sparse, nonsymmetric matrix from each law's local
blocks (forward-mode derivatives, one per port, two on a phasor layer), so a multi-port law
couples the edges of its group exactly. solve still returns (phi, q), with q covering every edge.
flows on an MNA layer. flows(phi, drivers) is a function of the potentials only on a classic
layer. An implicit law's flow is not: a fan with a prescribed rise has a residual that does not
contain \(q\), and conservation at the nodes fixes its flow. So an MNA layer takes the flows as input,
flows(phi, drivers, q), with q as solve returned it (every edge). The implicit edges' columns
are returned as given and the potential-controlled edges' columns are evaluated from phi (steady
context), differentiably. At solve's own (phi, q) the result is q. Without q it raises
TypeError; a phasor layer has no such accessor.
Construction checks, each raising ValueError naming the layer and the kind or nodes:
- every kind's edge count is a multiple of its law's
ports; - every connected part of the layer's edges contains a boundary node (a ground): a part with none (a transformer secondary left floating) has undetermined potentials;
- a dynamic kind carries no drive (its step-start potential differences are taken without one);
linear_solver="cg"is refused (the system is not symmetric positive definite);- on a phasor layer, every law is
phasor_capableand there are no node sources.
A system that is structurally sound but singular (a loop of ideal potential sources, a cut set
of flow sources, a part reached only through flow-prescribed edges) raises a RuntimeError at
the solve naming the layer and its implicit kinds. At the initial point (mode="initial") every
storage law is held at its initial value and so acts as a source itself (a capacitance as a
potential source, an inductance as a flow source); the error then says so and suggests starting
from the operating point (mode="dc"). Under on_failure="return" that singularity shows only
as converged=False in diagnostics.
The nodal-only methods (flows, dflows, residual, jacobian, linear_init, adjoint)
assume every flow is a function of its own edge's potential difference and raise TypeError on
such a layer; read the flows from solve's q, and the balance from balance(phi, q, sources).
The solve keywords¶
| Keyword | Meaning |
|---|---|
mode |
None (default): "transient" when dt is given, "dc" otherwise. "dc": the steady operating point (no dt). "initial": the consistent initial point (each storage law at its initial value; no dt). A phasor layer is always "phasor". |
dt |
One implicit step of this length (s). |
t |
The evaluation time, the END of the step on a transient (a time-dependent source is evaluated there); None when untracked. |
history |
(phi, q) at the step start, full-node potentials and every edge's flow, as solve returns them. Required on a step of a layer with a dynamic law. |
integration |
"trapezoidal" (default) or "backward_euler". |
phi0, q0 |
Warm start of the interior potentials and of the implicit-edge flows; zeros otherwise. |
A layer solved by nodal analysis has static laws: it accepts t, integration, q0 and the
modes "dc" and "initial" and ignores them (phi0 is its own warm start), and refuses
mode="phasor", dt and history, since it takes no step. A phasor layer ignores
integration and refuses dt, history and any mode but "phasor". On the differentiable path (the default)
gradients reach history too, so a chain of steps back-propagates through time.
Linear solver. linear_solver="auto" on such a layer factorises: SciPy's sparse LU
("sparse_direct", per instance) whenever SciPy is importable, at any batch size, and batched
dense LU ("direct") only when it is not. A dense batched factorisation costs memory and work
growing with the square and cube of the system size per instance: a 64-frequency sweep of a
1604-unknown circuit took 6.1 s and 1.3 GB dense against 0.48 s with sparse LU. A tiny circuit
at a very large batch may be faster with an explicit method="direct". "auto" never picks
GMRES (the system is indefinite and badly scaled, with potentials and flows in one vector) and
refuses CG. An explicit "gmres", "direct" or "sparse_direct" is honoured. diagnostics["method"] is the
method requested and diagnostics["backend"] the one that ran.
Phasor layers¶
KirchhoffLayer(..., phasor=True) solves the sinusoidal steady state: phi, q, the
boundary potentials and the warm starts are complex (complex128). The frequency in Hz comes from
the driver layer.frequency_key ("<name>.frequency", one value per instance) and reaches the
laws as ctx.omega = 2 pi f. Each complex unknown is solved as its (real, imaginary) pair, so a
law that is not holomorphic in dp (a constant-power load, q = conj(S / dp)) is solved
exactly, and the real solver machinery serves unchanged. A load is an edge to ground (node
sources are refused). power_residual is then the complex power balance, real and imaginary
parts both zero at a solution: Tellegen's theorem for phasors.
The initial guess is zero. A law that is singular or flat at dp = 0, such as a constant-power
load, needs a warm start: pass phi0 (for example every interior potential at the nominal
source voltage).
On a Model¶
Model gives such a layer its history, time and warm start; see
Layers solved by Modified Nodal Analysis below.
TransportLayer¶
Advects one or more species (or heat) along the flows of its kinds, with storage at nodes:
from noodl.layers.transport import TransportLayer
layer = TransportLayer(
net, "thermal",
capacity=heat_capacities, # per ACTIVE INTERIOR node
flow_kind=("airpath", "door"), # one or several advecting kinds
boundary=["ambient"],
n_species=1,
conduction_kind="wall", # optional diffusive edges
conductance=ua,
scheme="exact", # 'exact' | 'implicit' | 'trapezoidal'
quantity="temperature", unit="K",
)
Three things catch people:
- The active interior. Nodes that no edge of this layer's kinds touches are inactive and
have no row at all — not a zero row, which would be singular.
capacitymust be indexed by the active interior, whichactive_interior(net, kinds, boundary)computes. A wall-mass node with conduction edges and no airpath edge is an unknown of a thermal layer and is not one of a species layer over the same network. sourcesis in full node order, not interior order — zeros on boundary and inactive nodes. This differs fromcapacityon purpose: sources are an application-level quantity, capacity a layer-level one.flow_kindmay be several kinds, andqis then their flows concatenated in that order. That is exactly whatKirchhoffLayer.flows_of_kind(q, layer.flow_kinds)produces, so heat advected by both"airpath"and"door"edges needs no new operator.
The three schemes: "exact" uses an augmented matrix exponential and controls its own error by
sub-stepping; "implicit" is implicit Euler; "trapezoidal" is the second-order variant. Use
"exact" for heat, "implicit" for species — the application defaults reflect this.
The exact scheme bases its sub-stepping on the operator's one-norm ||dt M||_1, computed from
the state-independent matrix before any arithmetic on the state; if the predicted work exceeds
the budget, the step is refused, raising an error that names the layer and advises the implicit
or trapezoidal schemes. A stiff operator (large ||dt M||_1, e.g. fast removal with a nonzero
forcing) costs the exact scheme thousands of matvecs per step by nature; scheme="implicit" is
the documented choice there, not a larger budget. All coefficients the layer owns—carrier,
transmission, kinetics, removal, and conductance—are differentiable under every scheme.
Boundary values linear in time. By default, the boundary values stay constant over a step.
In the exact scheme, step(..., x_boundary_end=...) instead makes them vary linearly over the
step, from x_boundary at its start to x_boundary_end at its end (a first-order hold). The
step is then exact for a boundary value that ramps linearly while the flows are held fixed. This
matters for a zone that flushes much faster than the step. With a boundary value held at the
step mean of a ramp, such a zone relaxes to that mean and ends about half a step behind the
boundary. With the linear boundary, it ends behind by its own flushing time, as it should. The
Modelica importer's scheme="midpoint" runner uses this. The implicit and trapezoidal schemes
do not accept the argument and raise ValueError if it is given. So does step_with_transfer, which returns the boundary transfer: the transfer is defined for a boundary held constant over the step.
x_boundary_rates=(start, end), given together with x_boundary_end, adds the boundary's rates
of change at both ends of the step. The boundary then follows the cubic Hermite through both
ends' values and rates instead of the straight line. A fast-flushing zone ends behind its
boundary by its flushing time times the boundary's rate at the step's end. On a curved boundary
the straight line gets that rate wrong: it uses the chord's slope, which is off by about half a
step times the curvature. The Hermite uses the end's own rate.
Changing storage. A transport layer's capacity may change over a step: a sewer pipe's wetted
volume, or a headspace volume as the water level changes. The step conserves the stored amount
V x in amount form: V_new x_new - V_old x_old = dt F(x_new) (implicit) or = dt/2 (F(x_new) + F(x_old))
(trapezoidal). In the trapezoidal form the old-time rate F(x_old) is evaluated with the OLD
capacity, which matters for removal and kinetics (they act on the amount V x), not for advection
or conduction (capacity-free amount rates). The argument capacity_prev (the storage at the step's start, when it differs from
the capacity argument at the step's end) implements this; a fixed-storage layer omits both and
recovers the classical form. The driver "<layer>.capacity" holds the storage at the step's end.
When a closure writes that driver, the step-start state must carry the key "<layer>.capacity",
the storage at the state's own time, which Model.initial_capacities(state, drivers) builds by
querying the closures. The "exact" scheme has no changing-capacity form and raises by name if
capacity_prev differs from capacity.
Dilution. dilution= (per species, None by default) adds dilution * net(q) / capacity
to the removal, net(q) the signed net inflow of the flow edges into each node. Where the flows
do not balance because the stored volume changes, dilution = carrier is the advective form
V dx/dt = sum_in carrier q (x_in - x) and dilution = 0 the conservative flux form; the
capacity is then a rate coefficient at the end of the step. dilution_mode="withdrawal" instead
reads only max(net(q), 0): a FIXED volume whose flow imbalance is a nodal sink or source of
the carrier. A surplus of inflow is a withdrawal that leaves at the node's own value; a deficit
is an injection of carrier with no content of its own, which the outflow carries away, and
whatever content it brings is the caller's sources, never a negative removal. The water
application's quality layer uses this mode. The sink goes through torch.where, so at
net(q) = 0 its derivative is the withdrawal side's. The default "signed" mode is unchanged.
Storage-free nodes. storage_free=[...] names interior nodes that hold no amount, such as a
zero-volume air-handler plenum. Their balance is algebraic in every step: what flows in flows out
within the step, so their value is the flow-weighted mix of what enters. A storage-free node with
nothing passing through it (no flow and no conduction) keeps its value, as ContamX holds the
plenum of a switched-off air handler. A source at such a node (a heating coil in a plenum) is
carried on by the flow through it within the step. At a node nothing passes through the source
has nowhere to go, and the step drops it (no airflow through a plenum: its coil delivers
nothing); step's diagnostics reports the number of nonzero source entries dropped under
"held_sources_dropped" (in Model.step, at diagnostics["layers"][name]). Their capacity, at construction or per step, may be 0
(a negative one is refused everywhere, and a zero one at any other node). Removal and kinetics act
on a stored amount, so they do not act on these nodes. Only scheme="implicit" and steady can
hold them: the exponential and trapezoidal steps have no algebraic form, so a layer with either
scheme refuses storage-free nodes at construction. A steady solve has no value for such a node
with nothing passing through it, and refuses it, naming the node. Whether such a node mixes or holds is a 0/1 choice on the flows and
carries no gradient: a held node's value has zero gradient with respect to the flows, although
any flow through it would switch it to mixing. layer.storage_free_mask is the (n_i,) boolean mask of these nodes.
Undefined steady states. A stored node that nothing leaves (no outflow, no conduction, no
removal) keeps everything that enters it, and so does a closed group of stored nodes whose
contents only circulate among themselves, never reaching a boundary outflow, a removal, a filter
loss or a reaction. Their steady state is undefined without a source and unbounded with one, and
the operator is singular. steady checks for this on every batch instance before it solves and
raises UndefinedSteadyState (a ValueError), naming the nodes; its layer and nodes
attributes say which. Step such a model through time instead, or give the nodes an exchange.
Linear solver. The "implicit" and "trapezoidal" schemes each need one linear solve per
step ("exact" has none). linear_solver names it: "auto" (the default), "gmres",
"gmres_jacobi", "gmres_ilu", "sparse_direct" or "direct", resolved fresh on every solve
(unlike KirchhoffLayer.linear_solver, which is fixed for the layer's lifetime). "auto"
picks "sparse_direct" (SciPy's sparse LU, one exact factor-and-solve, no outer iteration) at or
under a batch cap of 32 instances, and plain "gmres" above it — chosen on an interleaved
forward/backward benchmark across the whole family (gmres, gmres_jacobi, gmres_ilu,
sparse_direct), not on iteration counts alone (benchmarks/transport_solver_bench.py).
"auto" never picks
gmres_jacobi/gmres_ilu: the benchmark found gmres_ilu's per-instance ILU factorisation cost
(paid twice per differentiable step — once in the forward solve, once in the backward adjoint,
since preconditioning is not reused across the two) the clear loser at every batch size measured,
and the gmres-vs-gmres_jacobi ordering too close to the run-to-run spread to trust as a
tie-break. Two fall-backs, both silent (never a raise, since "auto" is a promise to choose a
backend that works): SciPy absent ("auto" would have factorised, but the optional noodl-physics[sparse]
extra is not installed) falls back to gmres and warns once per process; a grad-requiring solve
on the on_failure="return" early-return path (which runs outside the differentiable path's own
no_grad, unlike step's ordinary forward/backward) falls back to gmres silently, since an
explicit sparse_direct there would otherwise raise for a reason the caller did not ask for.
diagnostics["linear"]["backend"] reports which backend actually ran on every solve.
Reaction¶
Applied to a transport layer's state after its step (by default), operator-split:
from noodl.layers.reaction import FirstOrderDecay
model = Model(net, layers, reactions=[("species", FirstOrderDecay(k))])
FirstOrderDecay and Photostationary (Leighton NO/NO₂/O₃) ship built in; the sewer
application adds SulfideGeneration. Photostationary's default rate is evaluated at the
driver "temperature" (K), which it requires by name unless a constant k_no_o3 rate
overrides it.
Model(reaction_order="before_transport") applies the reactions to the step-start state
before the transport step instead, so the returned state is the one after transport. The
sequence of operations is the same; only the point where the state is sampled moves. The sewer
application uses it because SWMM reports its water quality after mixing.
Model.steady never applies reactions. This is deliberate — a steady state under a reaction
is a different fixed-point problem, not the transport steady state. The street and sewer
applications each provide their own street_steady / sewer_steady helper that iterates
transport and chemistry together to a joint fixed point. If you have a reaction and you call
Model.steady, you get the transport-only answer.
AllocatedFlowLayer¶
The fourth mode. Each edge carries a requested flow; the layer clips it against arc capacity and the receiver's remaining storage headroom, sharing proportionally when several edges compete for one node's headroom.
from noodl.layers.allocation import AllocatedFlowLayer
layer = AllocatedFlowLayer(
net, "cap", kind="link",
s_max=storage_ceilings, # per node, full node order; inf for unbounded
c_arc=arc_capacities, # per edge
preference=weights, # optional sharing weights, strictly positive
mode="hard", # 'hard' | 'smooth' | 'projection'
n_passes=5,
)
s_new, f = layer.step(s, drivers, dt, diagnostics=diag)
In the simplest case the realised flow is just \(f = \min(r,\; c_{\text{arc}},\; h)\). The three modes differ in gradient behaviour, which is the whole reason there is more than one — see Flow allocation for the full treatment.
An allocation layer is inherently discrete-time. A Model owning one refuses a steady pass and
refuses residuals() outright, because a clip-and-allocate rule has no steady meaning to report.
Model¶
from noodl.model import Model
model = Model(
net,
layers={"air": air_layer, "thermal": thermal_layer},
closures=[density],
reactions=[("species", decay)],
coupling="pingpong", # or "iterate"
iterate_tol={"thermal": 0.01}, # REQUIRED when coupling="iterate"; transport or Kirchhoff layers
iterate_max=20,
substeps={"species": 4},
)
state = model.step(state, drivers, dt=60.0)
state = model.steady(state, drivers)
iterate_tol may name a transport layer (converged on its state .x) or a Kirchhoff layer
(converged on the largest change of its potential .phi over its interior and boundary nodes, in
the potential's units, per instance). A Kirchhoff layer is named when a closure computes that
layer's element coefficients, or its boundary potentials, from its own previous solution, as the
sewer dynamic wave does with its conduit coefficients and outfall heads: for example
iterate_tol={"water": 1e-6} (the sewer's head layer) repeats the pass until no head moves by
more than 1 micrometre.
Its .phi and .q are relaxed with iterate_relaxation like a transport .x. Kirchhoff layers not
named in iterate_tol are not iterated.
iterate_flow_tol={"water": 1e-9} (optional, flow units) also judges such a layer's flows: a pass
converges only when its returned .q is within that of the .q it was fed. It matters when the
potential solve stops on its warm start (its residual already within atol), which hands the fed
potential back unchanged while the flows the closures read are still moving.
iterate_acceleration="anderson" (opt-in; default None) replaces the relaxation of those
Kirchhoff layers' .phi and .q by Anderson mixing (type II, Walker & Ni 2011) over the last
iterate_memory passes (default 5), and needs an iterate_flow_tol for each of them: the mixing
minimises the fixed-point residual of .phi and .q, each divided by its tolerance, and the
convergence test then also asks that residual of .phi to be within iterate_tol. Transport
states stay relaxed. Mixing changes only the sequence of fed states, never the fixed point, and the
fixed-point adjoint is unchanged. Two safeguards: an ill-conditioned history drops its oldest
passes, and a mixed step that would move any entry against its own residual is replaced by the
relaxed step, because the relaxed iteration only reaches fixed points that attract it and a secant
step can head for one that repels it (a closure may have both; the sewer's flat, near-dry conduits
do). The sewer dynamic wave builds with it and needs two to four times fewer passes.
Key conventions¶
State and driver keys are namespaced by layer name:
| Key | Meaning |
|---|---|
"<layer>.phi" |
Nodal potentials, full node order |
"<layer>.q" |
Branch flows, the layer's kind order |
"<layer>.x" |
Transport state, interior order, (n_i,) or (n_i, K) |
"<layer>.s" |
An allocation layer's per-node storage |
"<layer>.phi_boundary" |
Prescribed boundary potentials (driver) |
"<layer>.x_boundary" |
Prescribed boundary transport state (driver) |
"<layer>.sources" |
Nodal sources, full node order (driver, optional) |
"<layer>.capacity" |
Per-step capacity override (driver, optional) |
"<layer>.removal" |
Per-step first-order removal rate, 1/s, (n_i, K), replacing the layer's construction-time removal (driver, optional; not carried in the state, so a closure that sets it must write it every step) |
"<layer>.transmission" |
Per-step pass fraction of each flow edge, (b_flow,) or (b_flow, K) in the layer's flow order, replacing the layer's construction-time transmission (driver, optional; not carried in the state, so a closure that sets it must write it every step) |
"<layer>.requests" |
An allocation layer's per-edge request (driver, required) |
"<layer>.frequency" |
A phasor layer's frequency in Hz, one per instance (driver, required) |
The order within a pass¶
Closures → potential solves → allocation steps → transport steps → reactions (reactions before
the transport steps under reaction_order="before_transport").
The allocation step sits between the potential solves and the transport steps so that a
transport layer reading "<layer>.q" sees a freshly written flow, whichever kind of layer wrote
it.
Layers solved by Modified Nodal Analysis¶
A Kirchhoff layer whose constitutive laws include implicit, multi-port, dynamic or
context-reading ones (layer.mna) is solved with the flows of its implicit-law edges as
unknowns. Model hands such a layer what its laws need:
- the warm start of those flows from
"<layer>.q"in the state, when it is there; - the time the laws are evaluated at: the end of a step (
t + dtforstep(..., t=t)), thetofsteady(..., t=t), orNonewhen no time is given; - on a step of a layer with dynamic laws (
layer.dynamic: a capacitance, an inductance), the step-start"<layer>.phi"and"<layer>.q"as its history, anddt. Both keys are then required in the state (state_fromandchecksay so); give the first step a consistent initial point, e.g.layer.solve(..., mode="initial"). The history is always the step-start state, socoupling="iterate"re-steps every pass from the same start.
integration= ("trapezoidal" or "backward_euler") travels through **solve_kwargs; the
keywords the Model sets itself (phi0, q0, history, dt, t) are refused there by name.
steady solves such a layer at its operating point (a capacitance open, an inductance
shorted). A layer with contextual laws but no dynamic one (a time-dependent source and
resistors, no storage) takes no history: on a step it is solved at its operating point at the
step end, t + dt, with ctx.mode == "dc" and ctx.dt None, which is exact for a layer that
stores nothing. residuals reports layer.balance(phi, q, sources), the interior balance of the
state's own flows. A phasor layer (phasor=True) has complex "<layer>.phi" and
"<layer>.q" and reads the driver layer.frequency_key; the iterated coupling judges its
convergence on the modulus of the change and differentiates its fixed point in the real view.
Anderson mixing (iterate_acceleration="anderson") is refused on a phasor layer.
line_search="convex" (by keyword or through newton_defaults) is refused on such a layer: its
residual is not the gradient of a convex function. analytic_primal has no effect on it,
because its solve already assembles the analytic operator.
Closures¶
A closure is (state, drivers) -> driver updates. It is the escape hatch for anything that is a
function of solved state: air density from temperature, canyon velocities from wind, tank levels
from the previous step.
Closures may not write layer state keys. A closure may carry its own state across steps by
declaring state_keys, which Model copies from its return into the returned state — a sewer
manhole level, or a water tank level and its controlled links' status. Two closures claiming one
key is refused at construction, naming both, rather than resolved by whichever ran last.
Closures and the clock¶
A closure that only computes coefficients or forcing terms keeps the two-argument form closure(state, drivers).
A closure that integrates some state over the step (such as a storage sweep that advances manhole levels)
declares integrates = True and receives a third argument, ctx = StepContext(dt, t), where dt is
the integration interval and t the step's start time if tracked. Inside step(...) the context carries the
interval; inside steady(...) it refuses the closure by name because there is no interval to integrate over.
Queries (residuals, current_flows, and the sewer report) call closures with ctx = None, where an
integrating closure evaluates its outputs at the given state without advancing it. A closure declaring state_keys
must also declare integrates (True or False) so the model knows whether its carried state changes step to step;
omitting it is refused by name.
What a closure may declare¶
Everything a closure says about itself is an optional attribute, listed in
noodl.contracts.CLOSURE_ATTRIBUTES and read once, at construction, into a ClosureSpec
(integrates, state_keys, inputs, outputs, input_specs, key_labels, edge_kinds,
requires_coupling, reads_flows_of, species_columns). A malformed declaration is refused
at construction by name:
inputs,outputs,state_keysandedge_kindsmust be tuples of names; a bare string (outputs = "rho") is aTypeError;integratesmust beTrueorFalse;- a declared output may not be a layer's state key (
"<layer>.phi",.q,.x,.s), apart from"<layer>.q"of a transport layer no Kirchhoff layer owns; species_columns = {layer: {species: column}}, for a closure or reaction that addresses species by column index, must agree with the layer'sspecies_namesandn_species.
model.refs.ownership() lists, for every key, who writes it and who reads it (layers,
closures by their declarations, the caller); model.check reports closures that overwrite
each other's declared outputs (overwritten-output), declare nothing (undeclared-closure),
return keys they did not declare when probed (declared-vs-probed), or declare a unit that
differs from the field's (unit-mismatch, with unit-undeclared for a field with no unit).
The two couplings, and the trap in the default¶
coupling="pingpong" (the default) takes exactly one pass per step, with the state at
the start of the step. It is cheap, it is what a weakly coupled model wants, and its splitting
error is first order in dt.
coupling="iterate" — Hensen's "onion" — repeats that pass within the one step until the
transport states named in iterate_tol stop changing, at most iterate_max times. Successive
substitution with 0.5 relaxation, applied with a one-pass delay: pass 1 sees the step-start
state, pass 2 sees pass 1's transport states unrelaxed, and from pass 3 on each pass sees the
mean of the two before it. Every pass re-advances from the step-start state, so passes never
compound into passes × dt.
The trap. Calling .steady() at the defaults on a two-way coupled model solves the airflow
at the temperatures the closures saw — the initial ones — and never re-converges it against
the temperatures it produced. It does not warn you; it returns a plausible number. Whenever the
flow depends on what it carries, pass coupling="iterate" with an iterate_tol.
Three more things about iterate_tol:
- It is absolute, in each layer's own units. A species layer whose mass fractions are \(\sim10^{-3}\) and a thermal layer in kelvin cannot share a tolerance, which is why it is a per-layer mapping. A transport layer left out of it is stepped every pass but not tested.
- It has a floor: the potential solve's own residual, propagated through \(dT/dF\). A tolerance
tighter than that can never be met, and the run raises naming the layer, the change and the
tolerance. A tight
iterate_tolneeds a matchingatol/rtolon thestep/steadycall. - Convergence is decided per batch instance. A batch that fails raises naming the instances;
on_failure="return"with adiagnosticsdict returns instead of raising. - A pass is one iterate. A potential solve that does not converge within a pass hands back
its last Newton iterate and the iteration carries on from it (that pass is never judged
converged); only the iteration's own verdict raises. A solve that is differentiated still
raises (
on_failure="return_primal", the passes' default), and an expliciton_failureis passed through as given.
Introspection¶
model.residuals(state, drivers) # per-layer nodal balance residual
model.ports(state) # what a coupled or external model may prescribe and read
Ports reports boundary nodes, prescribable keys, boundary flows, and each transport layer's
flow driver key. Model.flow_layer_of[name] is None tells you whether a transport layer's flows
are yours to prescribe. This is the surface the coupling application
builds on.
Differentiability¶
Ping-pong is one pass of differentiable operations. Iterate runs its passes to convergence
without a graph, then re-runs the certified pass once more on the graph and attaches the
implicit adjoint of the interface equations — memory is one pass, not all of them, and the
gradient error is of the order of the primal residual rather than tied to the pass count. The
convergence decision is still made on detached copies, so it never appears in the graph. See
Differentiability and
Coupling for the full contract.
**solve_kwargs on step/steady reach the potential solves only (differentiable,
on_failure, method, Newton options); transport steps always raise on failure.