Runtime forcings and scenario workflows
Runtime forcing is a value supplied by the host application while a project is
open or running. Use it for a measured inflow, forecast precipitation, an
operator action, or a scenario input that should not require rewriting the
.inp file. swmmrs retains supported forcing values in the owning
Simulation and reapplies them when SWMM initialization resets native fields.
All values use the model's configured units. Check simulation.flow_units and
simulation.unit_system before passing telemetry or forecast data into a
model.
The familiar PySWMM control loop
The usual PySWMM real-time-control pattern also works in swmmrs: enter a
context manager, advance to a decision cadence, read results, and write a
control. The object access is direct from the simulation rather than through
separate Nodes and Links collections.
from datetime import timedelta
from swmmrs import Simulation
with Simulation("model.inp", "model.rpt", "model.out") as simulation:
basin = simulation.nodes["BASIN"]
gate = simulation.links["OUTLET_GATE"]
simulation.step_advance(timedelta(minutes=5))
for current_time in simulation:
if basin.depth >= 4.0:
gate.target_setting = 1.0
elif basin.depth <= 2.0:
gate.target_setting = 0.0
simulation.end()
simulation.report()
The first iterator advancement starts the model. Each loop body observes the
state after its preceding routing advance, so a decision affects the next
routing operation. step_advance() defaults to strict=True, returning control
at exact cadence boundaries and potentially shortening the final routing step.
step_advance(..., strict=False) or a caller-owned stride(..., strict=False)
loop preserves ordinary routing steps but can return after the requested target.
The control loop is deliberately explicit. PySWMM offers callback registration;
swmmrs uses the loop body as the scheduler. This keeps the lifecycle and the
point at which host code changes a model visible. swmmrs also exposes typed
SimulationState values and permits explicit start(), step(), end(), and
close() when a host needs manual lifecycle control. See the
PySWMM Simulation reference
for its callback-oriented API and Run a model for the swmmrs
lifecycle.
Supported runtime inputs
| Need | Property | Semantics and write state |
|---|---|---|
| Open or close a regulator; set pump speed | simulation.links["ID"].target_setting |
Active-run target setting. Valid only in RUNNING; regulator values clamp to [0, 1], while pump speed is nonnegative. |
| Add measured or forecast flow at a node | simulation.nodes["ID"].external_inflow |
Persistent additive external inflow in project flow units. Valid in OPEN, RUNNING, and ENDED. |
| Supply catchment precipitation | simulation.subcatchments["ID"].external_rainfall or .external_snowfall |
Persistent additive rainfall or snowfall in project rainfall units. Valid in OPEN, RUNNING, and ENDED. |
| Make the host application a rain gage's precipitation source | simulation.rain_gages["ID"].use_external_precipitation(rate) |
Named persistent source switch in project rainfall units. Selects the API source, marks the gage used, and unlinks any co-gage. |
| Mask a rain gage's current source | simulation.rain_gages["ID"].rainfall_override |
Persistent highest-priority rainfall override. Assign None to clear it and resume the underlying source. |
| Force boundary stage | simulation.nodes["OUTFALL"].fixed_stage |
Persistent fixed outfall stage in project length units. |
| Supply quality mass or buildup | node.external_pollutant_mass_flux["TSS"] = value, link.external_pollutant_mass_flux.update(...), or subcatchment.external_pollutant_buildup_increment[...] = value |
Persistent live mappings keyed by canonical pollutant ID. Item assignment is sparse; update() is one atomic multi-pollutant mutation. |
| Override current node or link quality | node.override_pollutant_concentrations({...}) or link.override_pollutant_concentrations({...}) |
Atomic nonnegative concentration overrides consumed by the next routing-quality step. Valid only in RUNNING. |
| Scale a subcatchment's gage precipitation | subcatchment.set_precipitation_scale_factors(rainfall=..., snowfall=...) |
Named atomic update of both positive multipliers. Valid in OPEN, RUNNING, and ENDED. |
target_setting is a live control, not a persistent forcing source. Set it
when the control decision changes and set it again in every restarted run that
needs a non-default target. The other entries are retained until replaced; set
them to 0.0 to remove an inflow, external rainfall, snowfall, or quality
source. Rain-gage forcing has distinct clearing behavior described below.
Persistent forcings are discarded on close() and are not carried into a
later open() generation. Checkpoint Resume and Fork copy the source owner's
forcing; Enhanced Hotstart through load_checkpoint_state() keeps the already
open receiver's forcing instead.
Subcatchment external_rainfall is additive: reported catchment rainfall can
include both gage precipitation and the external amount. Rain-gage
external_precipitation_rate and rainfall_override instead determine the
gage's effective precipitation, with the override taking precedence.
total_precip is the read-only current effective rain-plus-snow result.
Forcing rain-gage precipitation
Rain gages expose two persistent forcing mechanisms because swmmrs preserves
both OWASWMM's external API precipitation source and the canonical expanded-EPA
rainfall override. Both values are rates, not one-step pulses: the last assigned
value applies to every subsequent routing step and repeated start() on the same
open project generation until it is replaced, cleared where supported, or
discarded by close().
Use use_external_precipitation() when the host owns the complete precipitation
stream:
Calling it changes the gage source to the API, marks the gage as used, and
unlinks any co-gage. Call it only when the external measurement or forecast
changes. A value of 0.0 keeps the API selected with a dry rate; it does not
restore the former file or time-series source. The read-only
external_precipitation_rate property is None before this source is selected.
Use rainfall_override when the gage already has a valid source that should
remain configured but be masked for a period:
gage.rainfall_override = 1.5 # persists and masks every source
gage.rainfall_override = 0.0 # persists and forces dry precipitation
gage.rainfall_override = None # clear and resume the underlying source
The override has higher priority than external_precipitation_rate and does
not change the gage's data_source. Setting an override unlinks any co-gage;
clearing it does not reconstruct that relationship. Therefore, use the
external rate for an authoritative telemetry or forecast feed and the override
for a reversible value mask over an already valid direct gage source.
Read total_precip, rainfall, and snowfall for current effective results.
SWMM partitions the selected gage precipitation into rain or snow from the
model temperature and snow settings, so total_precip need not equal
rainfall.
Pollutant forcing and one-step overrides
Current concentration and load properties are immutable result mappings.
pollut_quality therefore has no setter. To override one or more node or link
concentrations for exactly the next routing-quality step, use the named atomic
operation:
node.override_pollutant_concentrations({"TSS": 100.0, "Lead": 0.25})
link.override_pollutant_concentrations([("TSS", 80.0), ("Lead", 0.10)])
The complete payload is resolved and validated before any override is queued.
Pollutant IDs are case-insensitive, duplicate aliases are rejected, values must
be finite and nonnegative, and the operation is valid only in RUNNING.
Omitted pollutants preserve any override already queued for that same step.
The next quality-routing advance consumes the values; an ordinary end/restart
also clears unconsumed overrides.
Persistent pollutant sources use dense live mappings in configured pollutant order:
flux = node.external_pollutant_mass_flux
flux["TSS"] = 2.5
flux.update({"TSS": 3.0, "Lead": 0.1}) # one atomic sparse write
del flux["Lead"] # reset one value to 0.0
flux.clear() # atomically reset every value to 0.0
The same protocol applies to link.external_pollutant_mass_flux and
subcatchment.external_pollutant_buildup_increment. Deletion and clear()
reset values but do not remove configured pollutant keys. Whole-property
assignment is unsupported; the mapping itself is the mutation interface.
Failed multi-key writes leave every value and the owner lifecycle unchanged.
Persistent mappings remain applied across reruns and are discarded on close.
Nonzero link.external_pollutant_mass_flux values are supported only for real
conduits. Pumps, orifices, weirs, outlets, and dummy conduits have no conduit
quality reactor, so a candidate containing any nonzero value is rejected
atomically; clear() and other all-zero replacements remain valid. When a
conduit ends a quality-routing step dry, positive forcing is counted as link
load and external inflow and deposited into final dry storage without producing
an aqueous concentration. That deposited mass is a terminal continuity sink, it
is not remobilized if the conduit later rewets. Negative forcing on a dry
conduit is a no-op because there is no aqueous mass to remove. Wet-conduit
positive and negative forcing retains the ordinary mixing and bounded-removal
behavior.
When any node type ends a quality-routing step dry with negligible hydraulic
inflow, positive node.external_pollutant_mass_flux is counted as external
inflow and deposited into final dry storage without producing an aqueous
concentration. As with dry conduits, this is a terminal continuity sink and is
not remobilized if the node later rewets. Negative forcing on a dry node is a
no-op, while wet-node mixing and bounded removal are unchanged.
Persistence and timing
Persistent forcing is intentionally held between routing calls and across
start() calls on the same open generation. It is appropriate to set an
initial value before starting, then replace it only when the host has a new
measurement or forecast.
from datetime import timedelta
from swmmrs import Simulation
with Simulation("model.inp", "model.rpt", "model.out") as simulation:
inflow = simulation.nodes["MONITORED_INLET"]
gage = simulation.rain_gages["FORECAST_GAGE"]
# These values affect the first routing advance.
inflow.external_inflow = latest_measured_flow()
gage.use_external_precipitation(forecast_rainfall(simulation.start_time))
simulation.step_advance(timedelta(minutes=5))
for current_time in simulation:
# These replacements persist through the next callback and beyond.
inflow.external_inflow = latest_measured_flow()
gage.use_external_precipitation(forecast_rainfall(current_time))
simulation.end()
simulation.report()
A persistent write is valid in OPEN, RUNNING, and ENDED, but not in
COMPLETE, FAILED, or CLOSED. Writing after end() changes ENDED back
to OPEN and resets the completed run's clock and report-period bookkeeping.
That makes the next start() a clean run with the retained forcing. End a
manually or iterator-advanced run before changing a forcing after natural completion.
Hydraulic real-time control
A hydraulic RTC loop normally has four parts:
- Set a decision cadence that is short enough for the controlled asset.
- Read the measured model state after each advance.
- Apply a bounded, deterministic control decision.
- Feed new telemetry or forecasts through persistent inputs only when they change.
Use target_setting for a gate, regulator, or pump response. Avoid writing it
unconditionally if the target has not changed; the model already retains the
current target. Use a small pure function for the controller so its behavior is
testable outside SWMM.
from datetime import timedelta
from swmmrs import Simulation
def gate_target(depth: float, previous: float) -> float:
if depth >= 4.0:
return 1.0
if depth <= 2.0:
return 0.0
return previous
with Simulation("model.inp", "model.rpt", "model.out") as simulation:
storage = simulation.nodes["STORAGE"]
gate = simulation.links["GATE"]
target = 0.0
simulation.step_advance(timedelta(minutes=5))
for _ in simulation:
target = gate_target(storage.depth, target)
if gate.target_setting != target:
gate.target_setting = target
simulation.end()
simulation.report()
This hysteresis prevents the gate from repeatedly toggling when depth hovers near one threshold. It is host policy, not a replacement for SWMM control rules already encoded in the input file. Pick one source of authority for an asset: host RTC and input-file rules can both change the same link, making the result dependent on their execution order and difficult to audit.
Calibration runs
Calibration is usually a sequence of independent candidate runs: set static parameters before a run, advance at the observation cadence, accumulate an objective in memory, then start the next candidate from a clean state.
Configuration properties such as a subcatchment width are not live controls.
They are valid in OPEN and ENDED, but not RUNNING. A configuration write
from ENDED returns the project to OPEN, which makes a single owner useful
for serial candidate evaluation. This example skips binary results because the
objective is computed in memory.
from datetime import timedelta
from swmmrs import Simulation
def score_candidates(candidates: list[float]) -> list[tuple[float, float]]:
scores = []
with Simulation("model.inp", "calibration.rpt") as simulation:
subcatchment = simulation.subcatchments["S1"]
monitor = simulation.nodes["J1"]
simulation.step_advance(timedelta(minutes=5))
for width in candidates:
subcatchment.width = width
simulation.start(save_results=False)
squared_error = 0.0
sample_count = 0
while (current_time := simulation.stride(timedelta(minutes=5))) is not None:
residual = monitor.depth - observed_depth_at(current_time)
squared_error += residual * residual
sample_count += 1
simulation.end()
scores.append((width, squared_error / sample_count))
return scores
Collect values during the run, not from stale live views after close(). Keep
the objective calculation deterministic: align observation timestamps to the
chosen callback cadence, define missing-data treatment once, and avoid changing
both forcing and physical parameters in the same candidate unless that is the
experiment.
Sensitivity analyses
For sensitivity analysis, prefer one isolated Simulation per candidate.
This produces independent initial state, forcing storage, report files, and
native ownership. It also lets the standard library run candidates in parallel
without sharing a mutable owner.
from concurrent.futures import ThreadPoolExecutor
from datetime import timedelta
from pathlib import Path
from swmmrs import Simulation
def peak_depth(
candidate: tuple[int, float], run_directory: Path
) -> tuple[float, float]:
candidate_index, width = candidate
report_path = run_directory / f"candidate-{candidate_index}.rpt"
with Simulation("model.inp", report_path) as simulation:
subcatchment = simulation.subcatchments["S1"]
monitor = simulation.nodes["J1"]
subcatchment.width = width
simulation.start(save_results=False)
peak = 0.0
while simulation.stride(timedelta(minutes=5)) is not None:
peak = max(peak, monitor.depth)
simulation.end()
return width, peak
run_directory = Path("sensitivity-runs")
run_directory.mkdir(exist_ok=True)
candidates = [200.0, 250.0, 300.0, 350.0]
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(
executor.map(
lambda candidate: peak_depth(candidate, run_directory),
enumerate(candidates),
)
)
Every concurrent owner needs a distinct report path; omit output_path when
binary output is unnecessary and swmmrs uses scratch output. Do not share one
Simulation, its live views, or its artifact paths between workers. Budget
Dynamic Wave THREADS across all candidates. For example, four concurrent
models with THREADS 2 can use up to eight caller-inclusive solver threads.
Gotchas
- Runtime values must be finite. Rainfall, snowfall, external precipitation
rates, and pollutant concentrations that require nonnegative values reject
negatives. Finite mass fluxes whose native unit conversion would overflow are
also rejected before mutation; use
ValidationErroras an input-data boundary, not as flow control. target_settingis invalid beforestart()and after natural completion. Call it only fromRUNNINGcontrol code.- A manually or iterator-advanced model becomes
COMPLETEat natural exhaustion. Callend()before changing a persistent forcing or starting the next candidate. report()is available only for a run started withsave_results=True. Calibration and sensitivity loops commonly useFalseand retain their metrics in memory instead.- Simulation and input timestamps are timezone-naive. Convert external telemetry to the model's clock before indexing a forecast or observation.
close()invalidates every live view. Reacquire objects afteropen()and never copy or pickle aSimulationto distribute a workload.