Skip to main content

Module simulation

Module simulation 

Source
Expand description

§hydra-engine — Simulation Sub-Specification

§1. Overview

This document is the simulation sub-specification for hydra-engine. It defines the control system (§4), time stepper (§5), accounting subsystem (§7), and session API (§8), and references the hydraulic (§3) and quality (§6) subsystem sub-specifications in hydraulics spec and quality spec. It also documents solver characteristics relative to EPANET (§9) and an EPANET comparison reference (§10).

The network data model consumed by all subsystems is defined in model spec. Hydraulic algorithm details are specified in hydraulics spec, and quality algorithm details are specified in quality spec. Throughout this document, bare references to §2 and its sub-sections (e.g., §2.1, §2.7) refer to ../model/spec.md. For the system-level description of physical scope, see README.md; for the unit system contract, see model spec.


§4. Control System

Controls modify link statuses and settings during the simulation. They are the mechanism by which operational logic (pump scheduling, pressure regulation, tank management) is represented. There are two tiers: simple controls evaluated once per hydraulic step, and rule-based controls evaluated at sub-step resolution.

§4.1 Simple Controls

Simple controls are evaluated once per hydraulic time step, before the hydraulic solve for that step begins. Their effect is therefore in force for the entire duration of the step.

Evaluation procedure (applied to each enabled simple control in index order):

  1. Determine whether the trigger condition is satisfied at time $t$:
Trigger typeCondition
TIMER$t = t_{\text{trigger}}$ (exact match in seconds)
TIMEOFDAY$(t + t_{\text{clock_start}}) \bmod 86400 = t_{\text{trigger}}$
HILEVEL$V(h_{\text{node}}) \geq V(H_{\text{threshold}}) - \lvert Q_{\text{net,tank}} \rvert$
LOWLEVEL$V(h_{\text{node}}) \leq V(H_{\text{threshold}}) + \lvert Q_{\text{net,tank}} \rvert$

Level controls compare volumes (not levels) to avoid ambiguity with non-cylindrical tanks. Both volumes are computed through the same $V(h)$ function: $V(h_{\text{node}})$ from the current head, $V(H_{\text{threshold}})$ from the control grade. This ensures floating-point consistency regardless of how the current level/volume was accumulated.

  1. If the trigger fires and the resulting action (status and/or setting) differs from the link’s current state, apply the action:
  • Action resolution: when a control specifies a numeric setting without an explicit status keyword, the effective status depends on link type:
  • Pump or pipe: setting $= 0$ → CLOSED; setting $> 0$ → OPEN.
  • Valve: status = ACTIVE (always, regardless of setting value).
  • When an explicit OPEN keyword is used on a pump, the setting defaults to $1.0$. For CLOSED on a pump, the setting defaults to $0.0$.
  • Update LinkStatus and LinkSetting.
  • For a PCV, recompute the minor-loss coefficient from the new setting.
  • For a pump transitioning from CLOSED → OPEN, reset the pump’s flow to its design-point initialisation value.
  1. If multiple simple controls target the same link and both fire in the same step, the last one in index order wins.

§4.2 Rule-Based Controls

Rule-based controls are evaluated at rule time steps that subdivide each hydraulic period. This allows the simulation to detect and respond to mid-step state changes (e.g., a tank crossing a level threshold part-way through a step).

§4.2.1 Rule Time Step

$\delta t_r$ is a user-settable parameter (rule_timestep; default: $\Delta t_h / 10$, clamped to at most $\Delta t_h$). Sub-step boundaries are aligned to even multiples of $\delta t_r$ measured from $t = 0$: the first sub-step within a hydraulic period is $\delta t_r - (t \bmod \delta t_r)$, which may be shorter than $\delta t_r$.

Procedure for a hydraulic period starting at $t$ with nominal duration $\Delta t_h$:

  1. Advance the clock by the next sub-step interval $\delta$; update tank levels by $\delta$ (§5.3).
  2. Evaluate all rules against the most recent hydraulic solution (the one computed at the start of this hydraulic period).
  3. For every rule whose premise is satisfied and whose action differs from the current link state: add the action to the pending action set. Mark all conflicting actions by priority.
  4. If any actions were collected: apply them (highest-priority wins; all non-conflicting actions are applied together), then terminate the rule sub-step loop.
  5. Otherwise: continue to the next sub-step.

The rule sub-step loop runs until either a rule fires or the full $\Delta t_h$ is consumed.

How rule firing interacts with hydraulic solving: when the loop terminates due to a rule firing after $\delta$ seconds, the hydraulic period is shortened to $\delta$ seconds. Tank levels have already been advanced by $\delta$; the rule actions are in force. The outer time stepper then begins the next hydraulic period — a fresh hydraulic solve at time $t + \delta$ with the new settings applied. There is no nested re-solve within the remainder of the original period; the step simply ends at the firing point.

If no rules fire, the step proceeds at its full $\Delta t_h$.

§4.2.2 Premise Evaluation

Each premise has the form:

$$(\text{object},\ \text{attribute},\ \text{op},\ \text{threshold})$$

Supported (object, attribute) combinations:

Object typeAttributes
JunctionPRESSURE, HEAD, DEMAND
TankPRESSURE, HEAD, LEVEL, FILLTIME, DRAINTIME
ReservoirHEAD
Pipe / ValveFLOW, STATUS, SETTING
PumpFLOW, STATUS, SETTING, POWER
SimulationTIME, CLOCKTIME

FILLTIME and DRAINTIME are computed from current tank state:

$$\text{FILLTIME} = \begin{cases} (V_{\max} - V) / Q_{\text{net}} & Q_{\text{net}} > 0 \ \infty & \text{otherwise} \end{cases}, \qquad \text{DRAINTIME} = \begin{cases} (V - V_{\min}) / (-Q_{\text{net}}) & Q_{\text{net}} < 0 \ \infty & \text{otherwise} \end{cases}$$

where $Q_{\text{net}}$ is the net inflow (positive = filling) and $V$, $V_{\min}$, $V_{\max}$ are the current, minimum, and maximum tank volumes respectively. These attributes are evaluated at the time the premise is checked during the rule sub-step.

Units: all premise threshold values are stored in the internal unit system (see data model spec §3). The input layer converts user-unit thresholds to internal units at load time so that premise evaluation operates entirely in internal units with no per-evaluation conversion.

Logical combination: consecutive premises within a rule are joined by AND or OR. AND binds more tightly than OR. A rule’s overall truth value is the evaluation of this expression.

§4.2.3 Action Application and Conflict Resolution

When a rule fires, its THEN actions are applied; when it does not fire (any premise false), its ELSE actions are applied (if any).

If two or more rules fire at the same sub-step and assign conflicting values to the same (link, attribute) pair, the rule with the numerically highest priority value wins. All non-conflicting actions are applied regardless.

Actions take effect immediately and persist until changed by a subsequent control event.


§5. Time Stepper

The time stepper is responsible for advancing the simulation clock, computing the duration of each hydraulic time step, updating tank levels, and coordinating the hydraulic and quality engines across the full simulation period.

§5.1 Extended-Period Loop

The top-level simulation loop is:

t ← 0
while t < duration:
apply pattern multipliers at time t
apply simple controls at time t
solve hydraulics (§3)
record output snapshot at time t // §8
Δt ← adaptive_timestep(t) // §5.2
evaluate rule-based controls over [t, t+Δt] // §4.2
update tank levels over Δt // §5.3
compute pump energy for this step (§7)
run quality sub-steps over [t, t+Δt] // §6
t ← t + Δt

The adaptive time step (§5.2) is computed after the hydraulic solve so that the current step’s flow field — not the previous step’s — is used to predict when tank-level-based controls will fire.

§5.2 Adaptive Time Step

The actual time step used is the minimum of six quantities — the first constraint that would be violated determines the step:

$$\Delta t = \min!\left(\Delta t_h,\ \Delta t_{\text{report}},\ \Delta t_{\text{tank}},\ \Delta t_{\text{pattern}},\ \Delta t_{\text{control}},\ t_{\text{duration}} - t\right)$$

TermDefinition
$\Delta t_h$User-specified nominal hydraulic time step
$\Delta t_{\text{report}}$Time remaining until the next reporting instant: $\lceil t / \Delta t_r \rceil \cdot \Delta t_r - t$
$\Delta t_{\text{tank}}$Minimum over all tanks of the time to reach a level limit at the current net flow rate: $\min_{\text{tanks}} \Delta V_{\text{available}} / \lvert Q_{\text{net}} \rvert$ (set to $\Delta t_h$ if $Q_{\text{net}} = 0$)
$\Delta t_{\text{pattern}}$Time remaining until the next pattern boundary: $\lceil (t + t_{\text{pstart}}) / \Delta t_p \rceil \cdot \Delta t_p - t - t_{\text{pstart}}$
$\Delta t_{\text{control}}$Shortest time until a simple control fires (§5.2.1)
$t_{\text{duration}} - t$Time remaining until end of simulation
§5.2.1 Control Time Step

$\Delta t_{\text{control}}$ is the shortest predicted time until a simple control (§4.1) would fire and change a link’s status or setting. It is computed from the post-solve state so that the current flow field governs the prediction.

For each enabled simple control $c$:

  1. Level controls (HILEVEL / LOWLEVEL): if the control references a tank node $n$:
  • Let $h$ be the tank’s current head and $Q_{\text{net}}$ the net inflow (positive = filling).

  • If $\lvert Q_{\text{net}} \rvert \leq Q_{\text{zero}}$, skip (no flow, no crossing).

  • If $h < G_c$ and $c$ is HILEVEL and $Q_{\text{net}} > 0$ (tank filling toward the threshold), or $h > G_c$ and $c$ is LOWLEVEL and $Q_{\text{net}} < 0$ (tank draining toward the threshold):

    $$t_c = \operatorname{round}!\left(\frac{V(G_c) - V_{\text{current}}}{Q_{\text{net}}}\right)$$

    where $V(G_c)$ is the tank volume at head $G_c$ and $V_{\text{current}}$ is the current tank volume. The result is rounded to the nearest whole second.

  1. Timer controls: if $t_{\text{trigger}} > t$, then $t_c = t_{\text{trigger}} - t$.

  2. Time-of-day controls: $t_c$ is the time remaining until the next occurrence of $t_{\text{trigger}}$ in wall-clock time: $t_c = (t_{\text{trigger}} - (t + t_{\text{start}}) \bmod 86400 + 86400) \bmod 86400$. If $t_c = 0$, use $86400$.

  3. Applicability check: $t_c$ only shortens the time step if $t_c > 0$ and the control’s target status or setting differs from the link’s current status or setting. Controls that would not actually change anything are ignored.

$\Delta t_{\text{control}} = \min_c t_c$ over all applicable controls; $\Delta t_h$ if no control is applicable.

§5.3 Tank Level Update

After the time step $\Delta t$ is determined and any rule re-solves are complete, each tank’s level is updated:

$$V_{\text{new}} = V_{\text{old}} + Q_{\text{net}} \cdot \Delta t$$

where $Q_{\text{net}} = \sum_{k:\text{to}=\text{tank}} Q_k - \sum_{k:\text{from}=\text{tank}} Q_k$.

Level from volume:

  • Cylindrical tank: $h_{\text{new}} = h_{\text{old}} + \Delta V / A$ where $A = \pi D^2/4$.
  • Volume-curve tank: look up $V_{\text{new}}$ in the TANK_VOLUME curve to obtain $h_{\text{new}}$.

Boundary enforcement:

  • If $h_{\text{new}} < h_{\min}$: clamp to $h_{\min}$; treat tank as a fixed-grade node at its minimum head for the next hydraulic step (inflow is cut off).
  • If $h_{\text{new}} > h_{\max}$:
  • overflow = true: clamp to $h_{\max}$; treat as fixed-grade at maximum head. Surplus volume exits freely. The overflow volume $\Delta V_{\text{overflow}} = (V_{\text{new}} - V_{\max})$ is accumulated in the global flow-balance accounts (§7.2) as nodal outflow from the tank — it contributes to storage_change and is included in the volumetric balance ratio. An implementing system may expose per-tank overflow volume as a reportable output quantity (§8.2).
  • overflow = false: clamp to $h_{\max}$; treat as fixed-grade. No overflow volume is recorded or counted in the flow balance.

§5.4 Pattern and Demand Update

At the start of each hydraulic time step, before the hydraulic solve:

  1. Compute the elapsed period index $p = \lfloor (t + t_{\text{pstart}}) / \Delta t_p \rfloor$.
  2. For every junction demand category assigned to pattern $j$ of length $L_j$: apply multiplier $F_j[p \bmod L_j]$.
  3. For every reservoir with a head pattern: apply multiplier to base elevation.
  4. For every pump with a utilisation pattern: apply multiplier as the new speed setting $\omega$.
  5. For every quality source with a pattern: apply multiplier to the base source value.

§5.5 Simulation State at Step Boundaries

The only state that must persist across hydraulic step boundaries (i.e., state that cannot be recomputed from scratch at the next step) is:

State itemOwnerNotes
Tank levels / volumesEach tankDrives the next step’s boundary conditions
Link flowsAll linksUsed as the initial iterate for the next Newton-Raphson solve
Link statuses and settingsAll linksCarried forward; may be overwritten by controls
Accumulated pump energyEach pumpRunning totals for §7
Quality segment listsEach pipe and tankLarge; persists across all quality sub-steps
Mass / flow balance accumulatorsGlobalRunning totals for §7 and §8

§7. Accounting

The accounting subsystem accumulates energy statistics for each pump and global volumetric flow balance totals. It does not affect the simulation state — it is a pure observer updated after each hydraulic step.

§7.1 Pump Energy

After each hydraulic step of duration $\Delta t$, for each pump $p$ with flow $Q_p$ and head gain $\Delta H_p$:

Hydraulic power (in internal power units):

$$W_p = \rho g Q_p \Delta H_p$$

where $\rho$ is the fluid density and all quantities are in the chosen internal unit system.

Flow guard: if $Q_p \leq Q_0$ (the same negligibly small positive threshold as ../hydraulics/spec.md §3.10), use $Q_p = Q_0$ for all energy computations below. This avoids division by zero in the electrical-power and KwHrsPerFlow calculations.

Efficiency: if the pump has an efficiency curve, $\eta_p = \eta(Q_p / \omega_p)$ evaluated from the curve at the speed-adjusted flow; otherwise $\eta_p = \eta_{\text{default}}$. After evaluation (and after the Sarbu-Borza correction below, if applicable), clamp the efficiency to $[0.01, 1.0]$. The 1 % floor prevents division by zero in the electrical-power calculation.

Variable-speed efficiency correction (Sarbu-Borza formula): when the pump operates at a speed setting $\omega_p \neq 1.0$ and an efficiency curve is supplied, apply the following correction to the curve-evaluated efficiency $\eta_1$ (expressed as a percentage, 0–100):

$$\eta_{\omega} = 100 - \frac{100 - \eta_1}{\omega_p^{,0.1}}$$

Use $\eta_{\omega}$ (converted back to fraction) as $\eta_p$. At $\omega_p = 1.0$ the formula yields $\eta_{\omega} = \eta_1$ and no correction is applied. When no efficiency curve is supplied, the correction is not applied.

Electrical power:

$$W_{\text{elec},p} = W_p / \eta_p$$

Accumulated statistics per pump:

StatisticUpdate
kwh$+= W_{\text{elec},p} \cdot \Delta t \cdot k_{\text{unit}}$
kwh_per_flow$+= (W_{\text{elec},p} \cdot k_{\text{unit}} / Q_p) \cdot \Delta t$
time_online$+= \Delta t$ if $Q_p > 0$
max_kw$\max(W_{\text{elec},p} \cdot k_{\text{unit}})$
total_cost$+= W_{\text{elec},p} \cdot \Delta t \cdot k_{\text{unit}} \cdot \text{price}(t)$
efficiency_sum$+= \eta_p \cdot \Delta t$ if $Q_p > 0$

Note on kwh_per_flow: this statistic is a time-weighted harmonic mean of the energy intensity, accumulated as $\sum (P_i / Q_i) \cdot \Delta t_i$. It is not the ratio $\int P,dt ,/, \int Q,dt$ — the two differ when flow or efficiency varies across steps.

The following read-only statistic is derived at report time:

Reported statisticDefinition
avg_efficiency$= \mathtt{efficiency_sum} / \mathtt{time_online}$ (time-weighted average efficiency fraction while pump is running)

where $k_{\text{unit}}$ is the conversion factor from internal power units to kW:

Unit systemInternal power unit$k_{\text{unit}}$
SIW (= kg·m²/s³)$10^{-3}$
US customaryft·lb/s$\approx 1.356 \times 10^{-3}$

Example (SI): pump with $Q_p = 0.05$ m³/s, $\Delta H_p = 20$ m, $\rho = 1000$ kg/m³, $g = 9.81$ m/s², $\eta_p = 0.75$, $\Delta t = 3600$ s:

$$W_p = 1000 \times 9.81 \times 0.05 \times 20 = 9{,}810 ;\text{W}$$ $$W_{\text{elec},p} = 9810 / 0.75 = 13{,}080 ;\text{W}$$ $$\Delta,\text{kWh} = 13{,}080 \times 3600 \times 10^{-3} = 47{,}088 ;\text{kWh} / 3600 = 13.08 ;\text{kWh}$$

Example (US customary): same pump ($Q_p = 1.766$ ft³/s, $\Delta H_p = 65.6$ ft, $\rho = 1.940$ slug/ft³, $g = 32.174$ ft/s², same $\eta_p$ and $\Delta t$):

$$W_p = 1.940 \times 32.174 \times 1.766 \times 65.6 \approx 7{,}229 ;\text{ft·lb/s}$$ $$W_{\text{elec},p} = 7229 / 0.75 \approx 9{,}639 ;\text{ft·lb/s}$$ $$\Delta,\text{kWh} = 9{,}639 \times 3600 \times 1.356 \times 10^{-3} \approx 13.08 ;\text{kWh} \checkmark$$

Energy cost $\text{price}(t)$ at time $t$ ($/kWh) is determined as follows:

  1. Base cost: use the pump’s own energy_price if it is set ($> 0$); otherwise use the global energy_price.
  2. Pattern modulation: if the pump has a price_pattern, multiply the base cost by that pattern’s multiplier at $t$; otherwise multiply by the global energy_price_pattern multiplier at $t$ (or 1.0 if no global pattern is set).

This means each pump’s effective energy tariff is independently time-varying: a pump-specific price_pattern fully overrides the global pattern modulation for that pump, while the base cost override is independent of the pattern override.

Global peak demand charge: throughout the simulation, maintain a running maximum of the total simultaneous electrical power draw across all pumps:

$$P_{\text{peak}} = \max_t \sum_p W_{\text{elec},p}(t)$$

At report time, the total peak demand cost is $\text{peak_demand_cost} = \mathtt{peak_demand_charge} \times P_{\text{peak}} \times k_{\text{unit}}$ (in the same currency as total_cost). If peak_demand_charge = 0, this cost is zero. $P_{\text{peak}}$ is updated after every hydraulic step.

§7.2 Volumetric Flow Balance

Integrated over the full simulation, for each hydraulic step $\Delta t$:

QuantityUpdate
total_inflow$+= \left(\sum_{\substack{\text{reservoirs} \ Q_{\text{net}} < 0}} \lvert Q_{\text{net}} \rvert + \sum_{\text{junctions with } D_i < 0} \lvert D_i \rvert\right) \cdot \Delta t$ (only reservoirs that are supplying the network — i.e., net flow out of the reservoir — count as inflow)
total_outflow$+= \left(\sum_{\text{junctions with } D_i \geq 0} D_i + \sum_{\text{junctions}} Q_{e,i} + \sum_{\text{junctions}} Q_{\text{leak},i} + \sum_{\substack{\text{reservoirs} \ Q_{\text{net}} \geq 0}} Q_{\text{net}}\right) \cdot \Delta t$ (absorbing reservoirs — net inflow into the reservoir — count as outflow)
demand_deficit$+= \sum_{\text{junctions}} \max(0, D_{\text{full},i} - D_i) \cdot \Delta t$ (PDA mode only; tracked for reporting but not included in the balance ratio)
storage_changefinal total tank volume − initial total tank volume

Balance ratio:

$$\rho_v = \frac{\text{total_outflow} + \max(0, +\Delta V_{\text{storage}})}{\text{total_inflow} + \max(0, -\Delta V_{\text{storage}})}$$

where $\Delta V_{\text{storage}}$ is positive when tanks fill overall (storage increases, which is output) and negative when tanks drain (which is input). Reservoirs are split directionally: those supplying the network contribute to total_inflow; those absorbing water contribute to total_outflow. The demand deficit is reported separately alongside the ratio but is not incorporated into it.

A value of $\rho_v \approx 1$ confirms global volume conservation.


§8. Session API

hydra-engine exposes a session API (§8.3) through which a caller can load a validated Network, drive the simulation, retrieve results, and serialize output. Model-file parsing is owned by hydra-engine’s I/O layer (../model/spec.md §4). hydra-engine performs no filesystem or network I/O; callers supply bytes and receive structured results.

§8.1 Input Contract

Model-file bytes are parsed by hydra-engine’s I/O layer (../model/spec.md §4), which performs format detection, parsing, unit conversion, and validation. The session receives a Network via load().

Alternatively, a caller may construct a Network programmatically and pass it to load(). In this case all numeric values must be in the internal unit system (../model/spec.md §3) and the caller is responsible for conversion.

§8.1.1 Data Model Completeness

The data model passed to load() must be capable of expressing every entity and property defined in ../model/spec.md §2. No property may be silently omitted; every required field must be present and valid. (G5)

§8.1.2 Post-Population Validation

After the data model is fully populated — whether via file parsing or programmatic construction — the validation checks defined in ../model/spec.md §2.9 must be run. Any failure is a fatal error; the data model is considered invalid and the simulation must not proceed. The error must identify the offending object by its string ID and the condition violated.

§8.2 Result API

The following quantities are available from the session API at each reporting time step (every $\Delta t_{\text{report}}$ seconds, starting at report_start). The unit system in which values are delivered is an implementation decision (see ../model/spec.md §3).

§8.2.1 Reported Quantities

The “Dimension” column gives the physical quantity; the unit in which it is delivered is an implementation decision (../model/spec.md §3).

Per node:

QuantityDimensionSource subsystem
Hydraulic headlength§3
Gauge pressurelength (head above elevation)§3
Demand deliveredvolume/time§3
Quality (concentration / age / trace)mass/volume, time, or dimensionless§6

Per link:

QuantityDimensionSource subsystem
Flow ratevolume/time§3
Mean velocitylength/time§3
Unit head losslength/length§3
Friction factordimensionless§3 (Darcy-Weisbach only; else 0)
Qualitymass/volume, time, or dimensionless§6
Statusenum§3
Settingdimensionless (pump speed) or length (pressure setting)§3

Status annotations (output-only): the following status values are computed at reporting time and do not influence the hydraulic solve:

StatusApplies toMeaning
XFLOWPumpPump flow exceeds $\omega \times Q_{\max}$ at the current operating point
FILLINGTankTank has net inflow during the reported step
EMPTYINGTankTank has net outflow during the reported step
OVERFLOWINGTankTank is at maximum level with overflow = true and net inflow

$Q_{\max}$ is the theoretical maximum flow capacity of the pump at the current operating point, defined per curve type:

  • Power-function curve ($H = h_0 - r Q^n$): $Q_{\max} = (h_0 / r)^{1/n}$ — the zero-head flow of the fitted curve.
  • Custom curve (piecewise-linear): $Q_{\max} = Q_{\text{last}}$ — the highest flow data point on the head curve.
  • Constant-power pump: $Q_{\max} = \infty$ (XFLOW is never triggered).

Aggregate (once per simulation):

QuantitySource subsystem
Per-pump energy statistics (kWh, cost, efficiency, peak demand)§7.1
Mass balance ratio $\rho_m$../quality/spec.md §6.9
Volumetric flow balance ratio $\rho_v$§7.2

§8.3 Core API Contract

The core must expose the following logical operations through its public API. How the API is surfaced — as a native function-call interface, a foreign-function interface, a shared-memory protocol — is an implementation detail. The logical operations and their invariants are what this specification defines.

The simulation is modelled as a session with the following lifecycle:

// ── Parsing (owned by hydra-engine's I/O layer, not the session) ──
network = parse(bytes) // from hydra-engine I/O: format detection, conversion, validation
// → error on unrecognised format, parse failure, or validation failure

// ── Session lifecycle ──
session = create() // allocate empty project
load(session, network) // accept a parsed Network (or programmatically built)
// → validates data model; error on failure

run_hydraulics(session) // full hydraulic EPS in one call
-- or --
step_hydraulics(session) → Δt // one hydraulic step; returns actual step taken
// caller may modify model properties between steps

run_quality(session) // full quality EPS in one call (requires hydraulics done)
-- or --
step_quality(session) → Δt // one quality sub-cycle

// ── Result retrieval ──
get_node_result(session, node_id, quantity, time) → value
get_link_result(session, link_id, quantity, time) → value
get_pump_energy(session, pump_id) → EnergyStats
get_mass_balance(session) → MassBalance
get_flow_balance(session) → FlowBalance

// ── Output serialization ──
write_binary_output(session, writer, // serialize results to binary format (spec.md §4.3 output)
input_name, // input filename (metadata for prolog)
report_name, // report filename (metadata for prolog)
output_units) // flow-unit variant for result values in the output file
write_text_report(session) → string // serialize report to plain text

set_node_property(session, node_id, property, value) // modify between steps
set_link_property(session, link_id, property, value)

destroy(session) // release all resources

Invariants:

  • Multiple session objects may coexist in the same process. Sessions share no mutable state.
  • A session is not thread-safe with respect to itself — concurrent calls on the same session are not supported; the outcome is unspecified. Concurrent calls on different sessions are safe.
  • Property setters that change a value affecting the sparse matrix structure (e.g. adding a node or link) are only valid before run_hydraulics / step_hydraulics begins. Property setters that change only values (e.g. roughness, demand, pump speed) may be called between steps.
  • The unit system of values passed to and returned from the API is an implementation decision. The solver operates in the internal unit system (../model/spec.md §3); the API may expose internal units directly (requiring callers to convert) or may accept a unit selection and convert at the API boundary. Either approach is conforming, provided the solver itself never performs unit-dependent branching.

§8.4 Error Handling

Errors fall into three categories:

CategoryExamplesBehaviour
Fatal pre-simulationValidation failure (../model/spec.md §2.9), malformed data model, unknown object typeAbort; return structured error with offending object ID and condition
Fatal mid-simulationUnrecoverable solver singularity, out-of-memory in segment poolAbort current simulation; session remains valid for inspection of partial results
WarningNon-convergence (with ExtraIter bailout), negative pressure in DDA mode, pump XHEADSimulation continues; warning attached to the affected time step in the result

All errors and warnings must be accessible programmatically (not only as printed text) so that callers can handle them without parsing log output.


§9. Solver Characteristics and EPANET Comparison

Hydra has been exercised against eight real-world hydraulic networks totalling 12,500+ junctions and up to 2,000 demand periods. The following characteristics explain all observed differences between Hydra and EPANET 2.3.5 output. They are properties of Hydra’s solver — not bugs, and not deviations from a standard.

§9.1 Global Gradient Algorithm Numerical Path

System: EPANET and Hydra both implement the Global Gradient Algorithm (GGA), but starting from different initial flow estimates and applying convergence tolerances independently, they may converge to numerically distinct equilibrium points that differ by 1–10 ULPs in head/flow values.

Observed consequence: In heterogeneous networks with many demand nodes (e.g., D-Town 407 junctions), initial flow disparities at t=0 (0.05–0.11 CFS for individual pipes) cascade through subsequent hydraulic time steps and quality transport phases, resulting in downstream quality concentration drifts of 1–2 orders of magnitude when integrated over 100+ periods.

Impact:

  • D-Town: ~1,800 flow/head mismatches at t=0 leading to 29,244 cascading quality failures
  • KY8/KY9/KY10: 3–4 small failures per network at t=0

Verdict: Correct. These differences are inherent to the numerical path — floating-point arithmetic is not associative, and no amount of re-engineering the solver can guarantee byte-level agreement with EPANET’s specific convergence trajectory without essentially replicating EPANET’s C code line-for-line (including its precision choices, f32 truncations, and sparse matrix libraries). Hydra’s GGA convergence path is its own authoritative solution.

Note: The absolute differences are small (<0.1% of network head ranges) and physically sensible.

§9.2 Unbalanced-Stop Mode Not Implemented

System: EPANET has a configurable “unbalanced stop” mode that halts the EPS when node pressures throughout the network cannot be made non-negative. This is a numerical stability safeguard intended to prevent divergence.

Current state: Hydra’s time-stepping solver (§5) is designed to converge at each step and does not include an “unbalanced-stop” check. If a network solution becomes marginally infeasible (e.g. one or two nodes with slight negative pressure), Hydra continues to integrate, while EPANET would halt.

Observed consequence:

  • Richmond network: Hydra computes 49 periods of full convergence; EPANET halts after 28 periods due to unbalanced state. The last 21 periods in EPANET’s output file are empty or filled with earlier values; Hydra continues with physically valid equilibria.

Verdict: Correct, and favorable. Hydra’s solver does not need an emergency halt — it integrates through marginally infeasible states to physically valid equilibria. The EPANET unbalanced-stop halt is a legacy safeguard; Hydra’s more robust solver makes it unnecessary.

Future consideration: An unbalanced_stop option could be added to the session API (§8.3) and ../model/spec.md §2.4 (Options) for users who need to reproduce EPANET’s exact halt-on-infeasible behavior. Currently, no such option is defined or needed.

§9.3 Energy Statistics Differences

System: Both Hydra and EPANET accumulate pump electrical power and efficiency statistics according to §7. However, the specific values of per-pump utilization (%), average efficiency (%), and energy intensity (kW per unit flow) depend on the exact hydraulic flow dispatch each step.

Observed consequence:

  • BWSN2: Pump utilization values differ by 10–100% (e.g., Hydra reports 10.9% where EPANET reports 1.4% for pump[0]); efficiency calculations follow accordingly. The differences arise because Hydra’s GGA converges to slightly different flow magnitudes than EPANET.

Verdict: Correct. Energy statistics are derived from hydraulic results; if hydraulic flows differ, energy statistics will differ proportionally. This is the correct behavior given the upstream flow divergence.

Scope: These differences appear only on networks with significant control switching and multiple pump/valve interactions (e.g., BWSN2 with 40+ control events). Simple networks with stable demand patterns (Balerma, L-TOWN) show zero energy discrepancies.


§10. EPANET Comparison Reference

A historical test campaign across eight networks spanning 407–12,900 junctions [Balerma, BWSN2, D-Town, KY8, KY9, KY10, L-TOWN, Richmond] compared Hydra against EPANET 2.3.5 binary output. This data is retained as reference material; it is not an active correctness gate.

NetworkNodesLinksPeriodsDifferencesNotes
Balerma39944910✅ Full agreement
BWSN212,52714,83128/4918ℹ️ Energy/period-count (see §9.3, §9.2)
D-Town407459235,968ℹ️ Quality drift (see §9.1)
KY81,0461,13413ℹ️ GGA path (see §9.1)
KY91,0561,16214ℹ️ GGA path (see §9.1)
KY101,1001,20714ℹ️ GGA path (see §9.1)
L-TOWN3,3593,936730✅ Full agreement
Richmond2,8733,27624/488ℹ️ Period-stop behavior (see §9.2)

Summary:

  • 2/8 networks fully agree (Balerma, L-TOWN)
  • 6/8 networks have well-characterised differences attributable to §9.1–§9.3
  • All algorithmic bugs have been resolved (friction factor output, valve control status, pump efficiency defaults, AGE-mode timing)

Hydra and EPANET implement the same physics; on well-posed networks they naturally agree closely. Where they diverge, the difference is attributable to one of the three solver characteristics above. Hydra’s result is the authoritative output.


§11. Runtime Estimation API

hydra-engine provides a deterministic runtime estimator for hydraulic + quality execution cost. The estimator is advisory only and does not influence time-step selection, convergence behavior, or any simulation result.

§11.1 Inputs

The estimator consumes the following static network summary quantities:

  1. node count
  2. link count
  3. simulation duration
  4. hydraulic time step
  5. quality time step
  6. whether quality simulation is enabled

The estimator must not depend on mutable post-run state so the estimate remains stable before and after executing a simulation on the same network definition.

§11.2 Output

The estimator returns an effort category (Low, Medium, or High; see ../model/spec.md §5).

§11.3 Estimation Characteristics

The estimator should model cost as an increasing function of:

  1. hydraulic step count (duration / hydraulic time step)
  2. network size (nodes + links)
  3. topological complexity (for example, mesh density indicators)
  4. quality step count (duration / quality time step) when quality mode is enabled
  5. quality-mode overhead

The estimator is not required to be exact, but it must preserve monotonic ordering under typical workloads: larger and/or longer simulations should not systematically receive lower estimates than smaller and/or shorter ones.

Re-exports§

pub use engine::LinkQuantity;
pub use engine::NodeQuantity;
pub use engine::SimWarning;
pub use engine::WarningKind;
pub use crate::io::FlowBalance;
pub use crate::io::FlowBalanceSummary;
pub use crate::io::HydSnapshot;
pub use crate::io::MassBalance;
pub use crate::io::PumpEnergy;
pub use crate::io::WritableSimulation;

Structs§

LinkResult
Per-link result at a single timestep.
NodeResult
Per-node result at a single timestep.
ResultRanges
Global min/max across all timesteps for each display variable.
Simulation
A simulation session: owns network, solver context, results, and accounting.

Enums§

HydraulicError
Hydraulic solver error.
LinkProperty
Settable link properties via set_link_property.
NodeProperty
Settable node properties via set_node_property.
QualityError
Errors returned by the quality engine.
SessionError
Errors returned by the session API (§8.4).

Constants§

HYDRA_HYDRAULICS_VERSION
Semver version of the hydraulics engine, taken from Cargo.toml at compile time.
HYDRA_QUALITY_VERSION
Semver version of the quality engine, taken from Cargo.toml at compile time.
HYDRA_SIMULATION_VERSION
Semver version of the simulation engine, taken from Cargo.toml at compile time.

Functions§

classify_simulation_runtime_millis
Classify a predicted runtime (milliseconds) into effort buckets.
estimate_simulation_runtime
Estimate simulation runtime from a fully loaded network.
estimate_simulation_runtime_from_summary
Estimate simulation runtime from summary metadata.
estimate_simulation_runtime_millis_from_summary
Estimate simulation runtime in milliseconds from summary metadata.