Water distribution¶
Pressurised water distribution: reservoirs and tanks at fixed or slowly varying head, and junctions with demand, are nodes; full pipes, pumps and valves are edges. Water is conserved at every junction, and flow is driven by differences in hydraulic head.
Every pipe is full and its head loss is a monotone function of the head difference, so the whole
network is one KirchhoffLayer whose potential is hydraulic head. That is exactly what
distinguishes it from the free-surface sewer, whose normal flow is set by slope and
upstream inflow rather than by head difference.
Units. Head and elevation m, flow and demand m³/s, pressure head m (with a helper to kPa).
from noodl.apps.water import (
Junction, Reservoir, Tank, WaterPipe, Pump, Valve, WaterNetwork, WaterOptions,
build_model, water_steady, initial_state, twoloop,
pressure_head, to_kilopascal, link_table, tank_inflow,
)
Every type this application offers — what it does and how to call it — is listed in its catalogue. It is set up and run like every other model; see Using noodl.
A worked example¶
from noodl.apps.water import build_model, water_steady, twoloop
model, state, drivers = build_model(twoloop())
model.check(state, drivers).raise_for_errors(strict=True)
final = water_steady(model, state, drivers)
water = model.refs.water
heads = water.head.named(final[water.head]) # m, by node name
flows = water.flow.named(final[water.flow]) # m3/s, by link name
print(round(float(heads["J1"]), 6), round(float(flows["P1"]), 6))
To change an input, give it by name over the builder's own values. A demand is a
negative source (m³/s withdrawn; demand= does the sign for you), and junctions not named
keep their demand. On a model built with quality= the quality sink is the
withdrawal of the current hydraulic solution (a scaled or patterned water.sources, or the
delivered demand under pda=True), so a tracer entering at the reservoir stays at its
concentration at every demand level. A junction with a positive water.sources (an injection
of water) is no sink: the injected water carries no tracer of its own and dilutes what arrives
through the pipes, and any tracer injected with it is quality.sources (kg/s). On R → A → B
with the reservoir at 100, an injection of 0.005 m³/s and 0.1 kg/s at A and a withdrawal of
0.01 m³/s at B, both junctions read (0.005 × 100 + 0.1) / 0.01 = 60, and the tracer withdrawn
equals the tracer supplied. A STAGNANT node (no inflow and no outflow, e.g. a dead end whose delivered demand is zero under pda=True because the pressure is below the minimum) has no steady concentration without decay: water_steady raises UndefinedSteadyState naming the node and the cause, as the transport layer does for any stagnant node (a steady solve never invents a value). With a bulk decay (quality > 0) the node reads 0; or step the quality in time from an initial concentration. Quality advects through every hydraulic edge kind: pipes, pumps and valves. A pressure-driven model (pda=True)
refuses both demand= and a water.sources value, since its demands are node sources set
at build time:
from noodl.apps.water import initial_drivers
more = initial_drivers(model, demand={"J2": 0.012}) # m3/s withdrawn at J2
final = water_steady(model, state, more)
print(round(float(water.head.named(final[water.head])["J2"]), 6))
In the dictionaries the heads are "water.phi" (full node order) and the flows "water.q"
(link order); tank levels and pump status are the state keys "water.tank_level" and
"water.link_status", set by tank and pump name with initial_state(model, values=...).
See Using noodl.
twoloop() is a hand-built network: reservoir R1 at 50 m, six junctions with demands
5/8/6/10/7/9 L/s, eight Hazen-Williams pipes at \(C=130\) forming two independent loops. Its heads
are 49.267731, 48.877316, 48.430882, 48.213867, 48.117119 and 48.122936 m, the same as EPANET 2.2
gives for the equivalent twoloop_si.inp.
The same network can be read from an EPANET .inp file (see File formats):
from noodl.apps.water import read_epanet_inp, build_model, water_steady
net = read_epanet_inp("twoloop_si.inp")
model, state, drivers = build_model(net)
final = water_steady(model, state, drivers)
The objects¶
| Object | Fields |
|---|---|
Junction(name, elevation, demand=0.0, pattern=None) |
A demand node. |
Reservoir(name, head, pattern=None) |
Fixed-head boundary. |
Tank(name, elevation, init_level, min_level, max_level, diameter) |
Cylindrical tank. |
WaterPipe(name, u, v, length, diameter, roughness, minor_loss=0.0, status="OPEN") |
A full pipe. |
Pump(name, u, v, curve, power=None, speed=1.0) |
Edge oriented suction → discharge. |
Valve(name, u, v, diameter, kind, setting, minor_loss=0.0, curve=None) |
TCV, FCV, PBV or GPV (curve: a GPV's head-loss curve). |
WaterOptions(demand_model="DDA", minimum_pressure=0.0, required_pressure=0.1, pressure_exponent=0.5, ...) |
Carried from the file's [OPTIONS]. |
WaterNetwork collects them plus curves, patterns, controls and the timing options.
nodes() returns junctions, then reservoirs, then tanks; links() returns pipes, pumps, TCVs,
FCVs, PBVs, GPVs in that order; validate() refuses anything out of scope by name.
build_model¶
model, state, drivers = build_model(
net,
headloss=None, # 'H-W' (default) or 'D-W'; None takes the network's own
pda=None, # pressure-driven demand; None takes net.options
p_min=None, p_req=None, exponent=None,
quality=None, # bulk decay coefficient, 1/day; adds a quality TransportLayer
coupling="pingpong",
dt=None, # overrides net.hydraulic_timestep for the tank closure
friction="composite", # D-W friction law: 'composite' (default) or 'colebrook'
pipe_form=None, # 'flow' (resistance form; the default) or 'potential'
)
pipe_form is how the pipes enter the solve. In the resistance form ("flow", the default
except with friction="colebrook") each pipe is dp = h(q) (FlowControlledPipe) with its
flow an unknown, solved by Modified Nodal Analysis from EPANET's own first iterate (every pipe
at 1 ft/s). In the potential form ("potential") each pipe's flow is the inverted law
\(q = g(dp)\) on the nodal path. Both give the same solution to round-off; they differ in how
Newton gets there. The potential form's slope grows without bound as a pipe's flow goes to
zero, so on Net1's 24 h run the step where a control closes the pump and leaves pipe 10
dead-ended needs 136 Newton iterations; the resistance form's slope goes to EPANET's RQtol
floor instead, and no step needs more than 6. The whole 24 h run over eight demand
multipliers (benchmarks/water_eps.py) takes 2.5 times less time.
water_steady(model, state, drivers, **solve_kwargs) wraps model.steady with defaults
atol=rtol=1e-11, max_iter=200 (both empirically tuned, the iteration cap for the
potential form; caller overrides win), starts a cold start of the resistance form (every pipe
flow exactly zero) from model.initial_flows, and the pumps from their design flows (EPANET's
own first iterate), then checks
every converged pump's flow against its own q_max() and raises naming the pump and batch
instance if it is exceeded — rather than silently accepting an extrapolated point off the end of
the curve.
The physics¶
Hazen-Williams¶
The SI constant was re-derived from EPANET's own US constant 4.727 and matches wntr's hw_k to
1.04e-9 relative. With no minor loss the inversion is closed form; with one, it is a batched
monotone root solve bracketed by the pure Hazen-Williams flow as an upper bound.
Below dp_transition (default \(10^{-9}\) m) the law blends to a laminar linear form to keep the
Jacobian finite. That value is empirically chosen: at 1e-9 the 24-hour tank trajectory is
converged to 8.18e-5 m against EPANET, and Newton iteration counts run 50/93/136/179 at
1e-3/1e-6/1e-9/1e-12.
Darcy-Weisbach¶
CompositeDarcyWeisbach (friction="composite") is EPANET 2.2's own composite law:
Hagen-Poiseuille, then Dunlop's transition cubic, then Swamee-Jain. It is transcribed from its source
(hydcoeffs.c, DWpipecoeff and frictionFactor), in EPANET's internal feet and cfs:
with \(f\) Dunlop's cubic interpolation for \(2000 < Re < 4000\) and Swamee-Jain above. The
composite is \(C^1\): the cubic matches \(64/Re\) and Swamee-Jain in value and slope at its ends.
EPANET's constants are kept as EPANET has them, because they are part of what it computes:
\(g = 32.2\) ft/s² (not 32.174), the rounded minor-loss factor 0.02517, water at
\(\nu = 1.1 \times 10^{-5}\) ft²/s times [OPTIONS] VISCOSITY, and the flow-unit factor EPANET
divides a file's flows by (LPSperCFS = 28.317, GPMperCFS = 448.831, ...;
WaterOptions.flow_units carries the file's unit). EPANET has no low-flow linearisation for D-W:
the laminar branch is already linear through \(q = 0\), so the element needs no transition blend.
The flow is found by a safeguarded Newton inversion of the odd law \(h_L(q)\) with EPANET's own
analytic gradient, and differentiated by the implicit-function rule.
This is the default whenever the head loss is D-W, including every .inp read with
HEADLOSS D-W, because the application's reference is EPANET. friction="colebrook" selects
the framework's generic Duct instead (implicit Colebrook, a straight laminar line to its
Re = 2000 value, water at \(1.002\times10^{-3}/998.2\) m²/s, standard gravity); it is a different
law and does not reproduce EPANET (3.6e-4 on the two-loop heads, up to 81 % on a pipe's head
loss near Re = 2000).
Pumps¶
three_point_curve(q_design, h_design) reproduces EPANET's single-point construction — shutoff
head at 133% of design head, maximum flow at twice design flow — giving \(h_0 = 4H/3\),
\(r = (H/3)/q_d^2\) and forcing \(n = 2\) exactly. q_max = w (h_0/r)^{1/n} is the flow at zero
head gain.
Every pump enters the solve in its head-gain form, whatever the pipe_form, as EPANET and
wntr write it: FlowControlledPump, \(H_{\text{discharge}} - H_{\text{suction}} =
h_{\text{gain}}(q)\) with \(h_{\text{gain}} = w^2 h_0 - r w^{2-n} q^n\) and the pump's flow an
unknown. Two regularisations keep the gain strictly decreasing in \(q\), so every head difference
has exactly one flow:
- below the flow where the curve's slope falls to EPANET's
RQtol, the gain is the curve's tangent there (wntr'sget_pump_line_paramsconstruction), so the slope never vanishes; - below zero flow the pump is shut: the gain rises with EPANET's closed-link slope
CBIG(\(10^8\) ft/cfs). A pump held beyond its shut-off head, which EPANET closes, converges to EPANET's closed-link leak (excess head /CBIG, 3.4e-8 m³/s for 36.7 m), not to a reverse flow.
PumpCurve.flow still gives the inverted curve, \(q = w((h_0 + dp/w^2)/r)^{1/n}\), with a laminar
blend near shut-off, but the solve no longer uses it: its slope is infinite at shut-off and
exactly zero beyond it. A Newton iterate beyond shut-off then has no Jacobian entry between the
pump's two sides, and with a pressure-driven demand downstream only the demand's own small slope
ties those heads to a boundary; the next step throws them far outside the curve and the system
goes singular. That was measured on a reservoir, a pump and a pipe feeding a PDA junction
(singular from the fifth iterate on). In the head-gain form the pump's row ties its two heads
together on every iterate, and the same network converges quadratically, in 5 Newton
iterations (19 with pipe_form="potential").
Valves¶
A TCV is a settable minor loss and an FCV a fixed flow. A PBV (pressure breaker)
and a GPV (general purpose valve) follow EPANET 2.2's own coefficient routines
(hydcoeffs.c, pbvcoeff and gpvcoeff) as implicit laws, BreakerValve and
GeneralPurposeValve, whose flow is an unknown of the layer:
- PBV: the head drop from the valve's start node to its end node is its setting, whichever way the water flows. Where the open valve's minor loss \(m q^2\) exceeds the setting, or the setting is 0, it is an open valve instead, \(h = m q |q|\), with EPANET's minor-loss coefficient \(0.02517 K / d^4\) (ft, cfs) and its rounded flow-unit factor.
- GPV: \(h = \operatorname{sgn}(q)\,(h_0 + r |q|)\) on the segment of its head-loss curve that brackets \(|q|\) (the first or last segment, extended, outside the curve), with \(\operatorname{sgn}(0) = +1\).
A network with either valve is solved by Modified Nodal Analysis (sparse LU); every other network stays on the nodal path. PRV and PSV, which switch status on the absolute head at one end, are not modelled.
Tanks and controls¶
TankLevels is a Model closure declaring state_keys = ("water.tank_level",
"water.link_status"). Its __call__ applies controls and returns updated drivers; it does not
advance the level. advance(level, inflow, dt) does that by explicit Euler,
\(H \mathrel{+}= q\,\Delta t / A\). event_step(level, rate, dt) implements EPANET's adaptive
step-shortening to the next control crossing.
That shortening is not optional. A fixed one-hour step was measured to diverge from EPANET by 2.07 m on Net1, because the pump switches an hour late; with event shortening the same trajectory matches to 8.181e-5 m.
An extended-period run therefore looks like this:
for _ in range(steps):
drivers["water.sources"] = base_demand * pattern_factor(t)
solved = water_steady(model, state, drivers)
inflow = tank_inflow(model, solved)
dt_eff = model.tank_closure.event_step(level, rate, dt)
level = model.tank_closure.advance(level, inflow, dt_eff)
Tanks as storage. build_model(net, tank_mode="storage") makes every tank an interior
node with a TankStorage edge to a datum node "_datum" at head 0, its level stepped
implicitly inside the solve: backward Euler, \(q = A\,(H - H_{\text{prev}})/\Delta t\), or
trapezoidal. water_step(model, state, drivers, dt) takes one step (Model.step, with
water_steady's Newton defaults); tank_levels(model, state) reads the levels from the solved
heads; the level-triggered controls stay a closure (TankStorageControls), acting on the
step-start levels. The step is differentiable through time (the gradient of a level after
several steps matches central differences). It is not the EPANET comparison mode: the
implicit step differs from EPANET's explicit one by the step's own discretisation error. Both
converge to the same trajectory, at first order: on Net1, halving the storage step halves its
distance from a fine explicit run. Its event_step never shortens a step below 1 s (the
implicit step lands just short of a projected crossing, and a far shorter step stalls Newton),
and with steps as long as an hour a switch can land up to a step late, so sub-steps of a few
minutes are the useful range. Not with quality: the combination is refused with a
ValueError (a storage tank is an interior node joined to the datum by a storage edge, which
the quality layer does not model as a well-mixed volume); use the default tank_mode for
water quality, where a tank is a quality boundary whose concentration the caller sets.
A constant tracer is preserved (every junction stays at the source concentration) across both
pipe_forms, TCV, PBV and GPV valves, tanks and pda, tested in tests/apps/water.
Pressure-driven demand¶
EPANET's Wagner function (Manual eq. 13.6), with \(p = H - z\):
Implemented with a guarded kink so both where branches stay finite and differentiable.
PressureDrivenDemand refuses p_req <= p_min — a zero-span demand curve.
Verification¶
Against the real EPANET 2.2 engine through wntr 1.5.0, which bundles epanet22.dll. Note the
reference implementation's own floor: EpanetSimulator reads EPANET's binary output, whose
on-disk reals are float32, so roughly 1e-7 relative is EPANET's own precision, not noodl physics'.
| Check | Tolerance | Measured |
|---|---|---|
twoloop_si.inp heads and flows |
rel 1e-6 | 4.361e-7 heads, 8.090e-8 flows |
twoloop_si.inp, noodl physics' own nodal continuity |
1e-13 | 2.093e-14 |
| Net1 single period: heads, flows, pump head gain | 1e-6 / 1e-5 / 1e-6 | 7.058e-8, 2.868e-6, 1.189e-7 |
| Net1 24 h tank level with level-triggered pump controls | 2e-4 m | 8.181e-5 m worst of 25 steps |
Darcy-Weisbach, twoloop_si.inp heads and flows |
rel 1e-6 | 5.819e-8 heads, 6.482e-8 flows |
| Darcy-Weisbach, Net1 single period: heads, flows | 1e-6 / 1e-5 | 7.009e-8, 3.986e-6 |
Darcy-Weisbach, laminar + transitional + turbulent pipes (lowflow_dw_si.inp): heads, flows, per-pipe head loss |
1e-6 / 1e-6 / 2.65e-7 m | 3.5e-8, 9.4e-8, 9.6e-8 m |
Pressure-driven demand vs EPANET's DEMAND MODEL PDA |
1e-5 | 3.521e-7 heads, 2.184e-7 demands |
A pump feeding pressure-driven demand (two-loop with a pump, every junction pressure-limited), both pipe_forms |
1e-5 | 3.526e-7 heads, 2.354e-7 flows, 2.355e-7 demands |
| PBV (active and open) and GPV on the two-loop: heads, flows, the valve's head loss | 1e-6 / 1e-6 m³/s / 1e-5 | 5.5e-7, 2.6e-7 m³/s, 6.8e-6 |
| Head loss sums to zero around every cycle-basis loop | 1e-12 | exactly 0.0 |
| Autodiff vs central differences | 1e-6 × scale | 3.212e-6 / 2.505e-10 / 1.007e-6 / 2.753e-9 |
| TRACE water quality, single source | 1e-3 | 6.438e-12 percentage points |
| Golden regression | 1e-10 | 0.0 |
Four rows deserve comment.
Continuity is machine-precision exact for noodl physics (2.093e-14), while EPANET itself misses
continuity at Net1 node 13 by 4.5e-10 m³/s. The ~1e-7 residuals on twoloop_si.inp and the
Net1 single period are attributable to EPANET's float32 output path and its own mild continuity
violation, not to this solver.
Darcy-Weisbach reaches the same floor as Hazen-Williams. With EPANET's own composite law
(above) the D-W rows sit at the float32 output floor. The per-pipe loss bound is derived, not
fitted: EPANET stores its heads as float32 feet in its hydraulics file and reports them as
float32 metres, so each reported head can be off by half a spacing in each, and a loss by twice
that, \(2(0.3048 \cdot 2^{-22} + 2^{-24})\) m for heads between 1 and 2 m. Two EPANET details
matter at this level: the rounded flow-unit factor (with the exact one the two-loop heads miss
by 4.3e-7 instead of 5.8e-8) and, for the low-flow network, ACCURACY 1e-8 in the fixture,
since at EPANET's default 1e-3 its own loop flows stop 5e-7 short of converged.
The valve row compares flows absolutely. EPANET holds an active PBV's head drop with a
penalty (\(10^8\), CBIG), so its own flows are not continuous through the valve: P2 and the
valve are in series through a demand-free junction, yet EPANET reports them 1.6e-7 m³/s apart,
and the drop across the valve as 4.999997 m for a 5 m setting. noodl physics' flows agree to
round-off and its drop is the setting.
The loop head-loss row is a formulation identity, not a comparison — head loss summing to zero around every independent loop is a property of the cycle-space formulation, and it holds exactly.
Limitations¶
- Not modelled, refused by name: PRV and PSV valves; time-based controls and
[RULES]; variable-speed pumps; non-cylindrical tanks; emitters; leakage; energy and cost reports; Chezy-Manning head loss; pipe-status controls and check valves. A file using any of these raises an error naming the feature. - Pump curves are single- or three-point only, with the exponent fixed at \(n = 2\). Curves with four or more points, which EPANET connects piecewise-linearly, are refused; fit a three-point curve instead.
- The Colebrook option is not EPANET below Re = 4000. With
friction="colebrook"a laminar D-W pipe's head loss follows a straight line to the Colebrook value at Re = 2000, not Hagen-Poiseuille, and Dunlop's transition cubic is not used. The defaultfriction="composite"reproduces both. - Water-quality tracing is verified on a single source only. The TRACE check uses a network with one source, where the answer is 100 % everywhere it reaches; mixing of several traced sources has not been compared against EPANET.
- Tank area does not enter a single
water_steadycall. Only the tank's head (bottom + level) enters the steady solve, so gradients with respect to tank area come from a time-stepped run (TankLevels.advance), not from the steady state. - Tank level events are not batch-safe.
TankLevels.event_stepindexes tanks along the first tensor dimension, so a batched(B, n_tanks)rollout with level-triggered controls gives wrong results. Run batched tank simulations one instance at a time.
Install¶
Nothing beyond the base dependencies, plus optionally sparse. wntr — the EPANET reference
implementation — is test-only and deliberately not a runtime extra, because it pulls in a
measured 351 MB of mandatory dependencies.