Skip to main content

swmmbuilder

Python package that loads a city's GIS sewer network (GeoJSON or any format geopandas reads), selects subnetworks of it, and writes them as SWMM .inp files for pyswmm.

from swmmbuilder import Network, maps, swmm

net = Network("london.geojson")                     # schema=LONDON_ON by default
sub = net.catchment("OUT")                          # every node draining to OUT
sub = net.catchment("J1", max_hops=2, outlet="OUT") # truncated, plus the path down to OUT
sub = net.subnetwork(["J1", "J2", "OUT"])           # given nodes and the pipes between them

sub.validate(outfalls=["OUT"])                      # TopologyReport: splits, cycles, dead ends
sub.to_inp("model.inp", outfalls=["OUT"])           # or {"OUT": "FIXED"} for other types
model = swmm.build_model(sub, ["OUT"], manning_n_by_material={"PVC": 0.011})  # without a file
sub.to_node_list("nodes.txt")                       # Subnetwork.from_node_list(net, "nodes.txt")
maps.to_leaflet(sub, "map.html")                    # interactive map; see Maps below

A Subnetwork's graph is a networkx.MultiDiGraph in the direction of flow, with one edge per pipe keyed by pipe ID, so parallel pipes are kept.

Install

What changed between versions is in CHANGELOG.md.

pip install -e .                 # from a clone of this repository
pip install -e ".[maps]"         # + web-tile basemaps and Leaflet maps (contextily, xyzservices, folium)
pip install -e ".[simulate]"     # + pyswmm, to run the .inp files

swmmbuilder only writes .inp files and never imports pyswmm, so pyswmm is optional. pyswmm depends on julian, an sdist-only package whose setup.py imports distutils, which fails to build on some setups (e.g. AttributeError: install_layout); swmmbuilder itself installs and works without it.

Node layers

The pipes name their end nodes, but say little about them. Node layers add what they are and what the SWMM nodes need; each is optional:

from swmmbuilder import Network, strip_leading_zeros

net = Network(
    "Sewer_Pipes.geojson",
    maintenance_holes="Maintenance_Holes.geojson",
    pump_stations="Pumping_Stations.geojson",
    outfalls="Sewer_Outfalls.geojson",
    other_nodes="Sewer_Other_Nodes.geojson",  # caps, tees, chambers, ...
    include_overflows=False,                   # dry-weather network: drop overflow pipes
    normalize_ids=strip_leading_zeros,         # 'OK0329' (older exports, node lists) -> 'OK329'
    snap_tolerance=0.5,                        # merge node IDs within 0.5 m ...
    max_invert_difference=1.0,                 # ... unless their inverts differ by more than 1 m
)
net.nodes            # GeoDataFrame: node_type, name, invert, rim_elevation, depth, ...
net.snap_report      # merged pairs: both IDs, distance, both inverts, kept ID or why not merged
sub = net.catchment("WT6011")
sub.validate()       # dead ends classified as capped / outfall / pump_station / unexplained
sub.to_inp("model.inp")  # outfalls default to the outfall nodes of the subnetwork
  • Node types. net.nodes has one row per node of the graph, indexed by node ID, with its point; the same values are node attributes of the graph. A node takes the type of its layer (maintenance_hole, pump_station, outfall, or the other-nodes layer's own type such as cap, tee or chamber); a node in no layer takes the pipes' inventory type (UpstreamInventoryType/DownstreamInventoryType in London), if the schema maps it.
  • Pipe status and structure type. status={'built'} (the default) keeps built pipes only; London's ConstructedStatus also has abandoned, proposed and removed. Older exports without it use the Abandoned flag, and keep_abandoned=True still works. structure_type is gravity, overflow or force_main; include_overflows=False drops overflow pipes, which carry no dry-weather flow and make spurious splits and dead ends. Network.removed counts what was dropped by flow type, status and structure type.
  • Outfalls from data. With no outfalls, validate, to_inp and swmm.build_model use the outfall-type nodes of the subnetwork (Subnetwork.outfalls); an explicit list still works.
  • Coincident nodes. The same structure sometimes has two IDs at one point, which disconnects the network there. net.snap_nodes(tolerance=0.5, max_invert_difference=None) (or snap_tolerance= on loading) merges node IDs whose points are within the tolerance, preferring the ID in the maintenance-hole layer. Pairs whose inverts differ by more than max_invert_difference (pipes crossing rather than joining) and pairs already joined by a pipe are not merged. The merged IDs are kept in net.aliases, and every method that takes node IDs accepts either one. Geographic coordinates are projected to the local UTM zone for this.
  • ID normalisation. normalize_ids is applied to the node IDs of the pipes, the node layers, and those passed to catchment, subnetwork and Subnetwork.from_node_list.
  • Validation. TopologyReport also has dead_end_types, force_mains, and pipe_count_mismatches: maintenance holes whose recorded pipes_in/pipes_out differ from the network's graph. For pumping stations it lists stations_without_force_main, orphan_force_mains (force mains that start at no pump station) and force_main_ends (force-main chains that join another force main or end at a dead end).
  • Maintenance-hole attributes. rim_elevation is the layer's (London: LidElevation, or HeadwallElevation for outfalls), else invert + depth; rims outside the schema's rim_range (London: 150–350 m) are treated as missing. The .inp writer uses rim − invert as a junction's maximum depth where it is known, and max_depth elsewhere. has_drop, overflow, weir, pipes_in, pipes_out, flow_monitor, access and verified are passed through for downstream use, e.g. choosing accessible or flow-monitored sampling sites.

Maps

swmmbuilder.maps draws values computed elsewhere (concentrations, flows, each upstream area's share of a pipe's load, ...) on the network: pandas Series or DataFrames indexed by pipe ID, node ID or group name. Static plots use matplotlib and draw in a metric CRS (the network's CRS if projected, else its UTM zone; see maps.plot_crs). Each one styles the axes as a map (no ticks or frame, a scale bar; maps.map_axes, or style=False) and returns a DrawnLayer with its artist and its key.

import matplotlib.pyplot as plt

fig, ax = plt.subplots()
zones = maps.node_group_polygons(sub, {"Z1": ["A1", "A2"], "Z2": ["B1", "B2"], "rest": [...]})
colours = maps.category_colors(zones.index)       # one palette for zones and ribbons
groups = maps.plot_groups(ax, zones, colors=colours, label="Zone", legend=False)
pipes = maps.plot_pipes(ax, sub, concentration, norm="log", width=flow, legend=False)
ribbons = maps.plot_ribbons(ax, sub, loads, colours, title="Upstream zone", legend=False)
site = maps.plot_markers(ax, sub, ["OUT"], labels={"OUT": "Treatment plant"}, legend=False)
maps.add_legends(ax, groups, pipes, ribbons)      # stacked to the right of the map
maps.add_basemap(ax, sub)                         # web tiles; or layer="roads.gpkg" for offline

maps.to_leaflet(sub, "map.html", [maps.PipeLayer(concentration, name="Concentration"),
                                  maps.GroupLayer(zones), maps.MarkerLayer(["OUT"])])
maps.to_geojson(sub, "pipes.geojson", values=concentration)
sub.to_geodataframe("pipes", values=concentration)  # or "nodes" (rows of net.nodes)
  • Pipes. plot_pipes colours pipes by value (missing values in grey) and, with width=, scales line widths by the square root of the value by default (width_scale="linear" or "log"), drawing thick pipes last. context=True draws them lighter and thinner, as context beneath zones or ribbons.
  • Colour scales. cmap, norm, vmin, vmax and missing_color can be given together as a maps.ColourScale (colour= on plot_pipes and plot_groups, or on PipeLayer and GroupLayer), e.g. so that several plots share one scale: scale = maps.ColourScale(norm="log", vmin=1e-3, vmax=10), then maps.plot_pipes(ax, sub, before, colour=scale) and maps.PipeLayer(after, colour=scale).
  • Ribbons. plot_ribbons splits each pipe into strands, one per category, as wide as its share. By default the ribbon width is proportional to the pipe's total load, with the largest 2.5 % of the network's extent wide (max_width= in metres to override); ribbons under 5 % of that (min_width=) leave their pipes as thin grey context. Pipes are joined into one ribbon along main stems (the widest ribbon through each junction) until the width or a share visibly changes (merge_tol=), and the joins are mitred, so a trunk reads as one band.
  • Zones. node_group_polygons makes Voronoi tiles (or concave hulls) clipped to the network footprint, the area near the pipes, so zones cover the serviced area. Its default distance is twice the median pipe length, at most 5 % of the extent (maps.footprint_distance; footprint= in metres to override, False for the convex hull). plot_groups fills them lightly by category (with a legend) or by value (with a colour bar), and can hatch sets of zones.
  • Keys. With legend=True (the default) each plot adds its colour bar or legend to a column to the right of the map, below those already there. To compose several layers, pass legend=False and call maps.add_legends(ax, *layers) once.
  • Units. Pipe line widths and marker sizes are in points: they stay the same when you zoom. Ribbon widths, footprints and buffers are in metres: they scale with the map. Their defaults are relative to the size of the network drawn, so they suit a street and a city alike.

python examples/maps_gallery.py renders every static plot on two Toronto test catchments, of 31 pipes (about 560 m across) and 2,692 pipes (about 9 km across), so the defaults can be checked at both scales; --pipes and --outlet render another network with the London schema. CI uploads the images as the maps-gallery artifact. Web-tile basemaps and to_leaflet need the optional maps extra (pip install "swmmbuilder[maps]": contextily, xyzservices, folium). generate_gmap (Google Maps) is deprecated in favour of to_leaflet.

Diagnostics

Problems are reported in four ways, by what you can do about them (see swmmbuilder.diagnostics):

Channel When Examples
ValueError the output would be wrong, or SWMM would refuse it; every problem found by one check is listed in one error gaps that stop a pipe being written, invalid pumps, flow splits under KINWAVE
warnings.warn the model is written as asked, but suspect data or an option you should act on affects it DataWarning: adverse slopes, a pipe below its node, a rim not above the invert, unknown shape codes; DesignWarning: force_main_checks, a pattern whose mean is not 1; OptionWarning: force mains left unpumped, height_correction keys that name no node
logger.warning input dropped or repaired on loading, or problems that do not change the hydraulics pipes with no ID or length, nodes left out of [COORDINATES], colours repeating in a plot
logger.info an audit trail values filled by the gap rules or derived by design rules, pipes removed by the filters

The warning categories derive from swmmbuilder.SwmmBuilderWarning (a UserWarning), so they can be filtered by kind, e.g. warnings.filterwarnings("ignore", category=swmmbuilder.DataWarning) or turned into errors with "error". The log goes to the swmmbuilder logger; nothing is printed. To see it:

import logging
logging.basicConfig(level=logging.INFO)

GIS schema

Network(path, schema=...) reads the GeoJSON and converts it to canonical columns (pipe_id, from_node, to_node, length, us_invert, ds_invert, drop_invert, shape, diameter, height, width, flow_type, material, status, abandoned, structure_type, us_node_type, ds_node_type, in m), and the node layers to canonical node columns (node_id, node_type, name, invert, rim_elevation, depth, has_drop, overflow, weir, pipes_in, pipes_out, flow_monitor, access, verified); everything after loading reads only those (see swmmbuilder/schema.py). These schemas ship with the package, as TOML files in swmmbuilder/schemas/:

  • LONDON_ON (the default): London, ON's GIS export. The City's GeoJSON is in WGS 84; it reprojects to UTM zone 17N (EPSG:2958, NAD83(CSRS)), so that missing lengths can be computed and the .inp coordinates are in metres.

  • TORONTO_ON: the Sewer Gravity Mains dataset of the City of Toronto Open Data Portal. It reprojects to MTM zone 10 (EPSG:2952), so the .inp coordinates are in metres. Toronto publishes only a diameter, so only circular pipes get a cross-section; the other shapes are reported on loading. Flow types other than SAN, Storm and Combined (CSO, SCSO, FD, EO) pass through unchanged, so e.g. flow_types={"sanitary", "combined"} keeps sanitary and combined sewers.

  • Six more Ontario cities' open data, each reprojected to its UTM zone 17N CRS:

    • WINDSOR_ON: the SanitarySewer, StormSewer and CombinedSewer layers.
    • KITCHENER_ON: Sanitary Pipes. CATEGORY is the structure type.
    • WATERLOO_ON: Sanitary Sewer Gravity Mains. All pipes are taken as circular, and an invert of 111.111 means none.
    • PETERBOROUGH_ON: Sanitary (and Storm) Sewer Pipes. Only circular pipes get a cross-section.
    • BARRIE_ON: Sanitary Pipes. TYPE is the structure type.
    • SARNIA_ON: Sanitary and Storm Sewers. All pipes are taken as circular; non-circular sizes ("W x H") are rejected if written.

    Each TOML file's header gives the dataset's URL and its quirks. The layers are ArcGIS services that return at most 1,000–2,000 pipes per query, so download them from the portal (GeoJSON) or page through the REST query.

from swmmbuilder import TORONTO_ON, Network

network = Network("sewer-gravity-main-4326.geojson", schema=TORONTO_ON)

For another city, write a schema file and pass its path as schema= (or build a Schema in Python). The top-level keys are the parameters of Schema; columns maps canonical columns to source columns, and shapes and flow_types map source codes (or set default_shape, e.g. "CIRCULAR", where there is no shape column):

name = "My city"
null_values = ["None"]   # values that mean "missing" in any mapped column
size_unit = "mm"         # m, cm, mm, ft or in; also length_unit and invert_unit
crs = "EPSG:32617"       # optional: reproject to this (projected) CRS on loading (data with
                         # no CRS are taken to be in it already)

[columns]
pipe_id = "PIPE"
from_node = "US_MH"
to_node = "DS_MH"
length = "LEN"
us_invert = "US_INV"
ds_invert = "DS_INV"
shape = "SHAPE"
diameter = "DIA"
height = "HT"
width = "WD"
flow_type = "SYSTEM"

[shapes]
CIRC = "CIRCULAR"
BOX = "RECT_CLOSED"

[flow_types]
Sanitary = "sanitary"
Storm = "storm"
network = Network("my_city.geojson", schema="my_city.toml")

Pipe statuses, pipe structure types and node types are mapped from source codes by the [statuses], [structure_types] and [node_types] tables, and the node layers' columns by [node_columns], where a list names alternatives (the first one present in a layer is used); see swmmbuilder/schemas/london_on.toml. The Toronto preset does not map node layers yet.

Only pipe_id, from_node and to_node are required; the pipes must name their upstream and downstream nodes. Missing lengths (all of them, if length is not mapped) are computed from the geometry, which must then be in a projected CRS (set crs). Shape codes that are not in shapes give a warning on loading, and those pipes are rejected if they are written to an .inp. flow_types= filters on canonical flow types ({'sanitary'} by default); source codes such as 'SAN' are translated too.

SWMM template

.inp files are written by swmmbuilder.swmm. build_model(subnetwork, ...) returns a SwmmModel: plain records of the junctions, outfalls, conduits, cross-sections, coordinates and vertices, with node inverts, conduit offsets and Manning's n already decided, so the model can be inspected or tested without a file. write_inp(subnetwork, path, ...) checks the template and the topology, builds the model and renders it into the template in one write; Subnetwork.to_inp calls it.

The default template is the one shipped in the package, swmmbuilder/templates/template.inp (examples/pyswmm_template/template.inp is a copy kept in sync by a test). It uses SI units, FLOW_UNITS LPS, because the GIS lengths, inverts and sizes are in metres. A custom template (template_path=) must use CMS, LPS or MLD, and may only contain the {nodes}, {outfalls}, {storage}, {conduits}, {pumps}, {xsections}, {curves}, {dwf}, {patterns}, {coordinates} and {vertices} placeholders; anything else (including US units) raises ValueError. Node [COORDINATES] and pipe [VERTICES] are written in the units of the GIS geometry.

Pumps

Pumping stations and force mains can be identified from the node layers and the pipes' structure type (see above), but the GIS data have no pump curves, so pumps are passed to to_inp by pipe ID. Each pumped pipe is written to [PUMPS] (from its upstream to its downstream node) instead of [CONDUITS], with its curve in [CURVES]; it needs no cross-section and is not reported as adverse. Wet wells can be written as [STORAGE] nodes of constant plan area instead of junctions:

sub.to_inp(
    "model.inp",
    outfalls=["OUT"],
    pumps={1010: dict(curve=[(0, 6.0), (2, 4.0), (4, 0.0)], curve_type="PUMP3",
                      startup=0.5, shutoff=0.1)},
    wet_wells={"D1": dict(max_depth=3.0, area=4.0)},
)

curve is a list of (x, y) points, with y the flow in the template's FLOW_UNITS (L/s with the shipped template) and x set by curve_type: PUMP1 wet well volume (m³; the inlet must be a wet well), PUMP2/PUMP4 inlet depth (m), PUMP3/PUMP5 head (m). curve="*" is an ideal pump. status (ON/OFF), startup and shutoff (inlet depths, m) are optional. Pump and WetWell objects can be passed instead of dicts.

An ideal pump must be the only outlet link of its inlet node (SWMM refuses it otherwise, with ERROR 134), so a station with an overflow needs a pump curve.

Pumping stations with force mains

pumps= replaces a pipe with a pump, which suits a short lift. For a pumping station with a force main, use pump_stations=, keyed by the station node, so that the force main keeps its length and volume (and so its travel time):

from swmmbuilder import LONDON_ON, PumpStation

sub.to_inp(
    "model.inp",
    pump_stations={"SL6001": PumpStation(
        pump=dict(curve=[(0.0, 120.0)], curve_type="PUMP2", startup=1.5, shutoff=0.5),
        wet_well=dict(max_depth=4.0, area=12.0),
    )},
    force_main_roughness=LONDON_ON.design["force_main_roughness"],
)

For each station the writer adds a discharge node <node>_D at the station, with the invert of the force main's upstream end, and a pump <node>_P from the station node (a wet well if one is given, else a junction) to it. The force-main pipes leaving the station (StructureType FM in London), followed downstream through every node whose only outlet is a force main (Subnetwork.force_main_chain), start at <node>_D and stay conduits with their GIS lengths and a FORCE_MAIN cross-section; their interior nodes and <node>_D get force_main_surcharge_depth (default 100 m) so that the main can run pressurised under DYNWAVE. Where two stations' force mains join, the shared main downstream is a force main of both; a chain stops at the next pump station, whose own pump starts its force main. Other outlets of the station, such as overflows, stay conduits (include_overflows=False drops them). A pump-station node with force mains that is not in pump_stations gets a warning: its force mains are written as ordinary conduits.

  • Pump operation. There is no built-in default pump. An ideal pump (curve="*") approximates a variable-speed station that delivers a near-uniform flow; a curve with startup/shutoff depths (e.g. PUMP2) a fixed-speed station whose on/off cycling pulses the force-main flow. A station's curve needs its curve_type. default_pump= gives the pump of the stations given without one; London's preset, an ideal pump for its variable-speed stations (s. 4.3.5), is LONDON_ON.design["default_pump"]. A startup depth must be above the shutoff depth and below the wet well's max depth, or the writer refuses.

  • Twin force mains. force_mains= picks the force mains to pump into (the others are written as ordinary conduits, with a warning). With more than one, discharge="shared" writes one pump feeding them all and discharge="separate" one pump each (<node>_P1, <node>_D1, ...); without it the writer refuses.

  • Roughness. force_main_roughness is required and is read by SWMM according to the template's FORCE_MAIN_EQUATION (Hazen–Williams C for H-W, the shipped template; roughness height in mm for D-W). It is a constant, {"by": "diameter", "table": [(upper_mm, value), ...]}, {"by": "material", "table": {material: value}} or a callable pipe -> value; a dict may declare "equation", and a template with another equation is refused (a constant or a callable has no declared unit, so it is not checked). force_main_roughness_by_pipe={pipe_id: value} overrides the rule. Schemas carry design-rule presets, never applied unless passed: LONDON_ON.design["force_main_roughness"] is London's C by diameter (Design Specifications & Requirements Manual, s. 4.8).

  • Wet-well defaults (opt-in). When a station's wet well is not known, wet_well_defaults={"fill_time": minutes, "high_level_below_inlet": m, "low_level_above_pump": m} derives one from the station's design_flow (design average flow, in the template's FLOW_UNITS: L/s with the shipped template; the GIS data do not carry it, so it is given per station) and the invert of its inlet sewer (the lowest gravity pipe entering it, not a force main or overflow). The station node invert is the wet-well floor, taken as the pump level: the shutoff depth is low_level_above_pump (or, with "low_level_suction_diameters": n in the rule and a station's suction_diameter in mm, n suction diameters if that is higher), the startup depth is the inlet invert minus high_level_below_inlet, the plan area holds design_flow for fill_time between the two, and the well rises to the rim if known, else to the crown of the inlet sewer. On/off depths are derived only for a pump with a curve and no startup/shutoff. Explicit WetWell (or wet_wells) and pump values always win. Every derived value is logged and listed in model.derived (model.derived_report() as a table), so nothing derived looks like data. London's preset (s. 4.3.10: 30 min fill, high water 300 mm below the inlet, low water 300 mm, or two suction diameters, above the pump) is LONDON_ON.design["wet_well_defaults"]:

    model = sub.to_inp(
        "model.inp",
        pump_stations={"SL6001": PumpStation(
            pump=dict(curve=[(0.0, 120.0)], curve_type="PUMP2"), design_flow=40.0,
        )},
        force_main_roughness=LONDON_ON.design["force_main_roughness"],
        wet_well_defaults=LONDON_ON.design["wet_well_defaults"],
    )
    print(model.derived_report())
    
  • Force-main checks (warnings). force_main_checks={"min_diameter": mm, "velocity": (min, max)} warns of force mains narrower than min_diameter and of those whose velocity, the pumped flow over the full pipe area, is outside velocity (m/s). The pumped flow is the pump curve's largest flow, or the station's design_flow for an ideal pump or a head-flow curve (PUMP3, PUMP5, whose largest flow is at zero head; not checked without one), summed over the stations feeding a shared main after a join; a shared pump feeding several force mains is not checked, as the split is unknown. London's preset (100 mm, 0.6–3.0 m/s) is LONDON_ON.design["force_main_checks"].

  • A pipe in both pumps and a station's force main, a station with no force-main outlet, and a wet well given both in wet_wells and a station are errors. A schema's design-rule presets are checked when it is made, so a typo in a TOML preset is caught on loading.

Dry-weather flow

dwf={node: base_flow} loads nodes with a dry-weather flow in the template's FLOW_UNITS (L/s with the shipped template), written to [DWF]. This also covers point loads at nodes with no upstream pipes, such as a pumping station fed by a system that is not in the data. Time patterns go in patterns={name: multipliers} ([PATTERNS]); the type comes from the number of multipliers (24 → HOURLY, 7 → DAILY, 12 → MONTHLY) or is given as (type, multipliers), e.g. ("WEEKEND", [...]). dwf_patterns applies them: one name (or list of names) for every loaded node, or {node: [names]}, up to four per node, one of each type.

from swmmbuilder import dwf_from_population

dwf = dwf_from_population({"A1": 1200, "B1": 800}, per_capita=250, unit="L/d")  # -> L/s
sub.to_inp(
    "model.inp",
    outfalls=["OUT"],
    dwf={**dwf, "D1": 3.5},
    patterns={"diurnal": [0.5, 0.4, 0.35, ..., 0.8]},  # 24 hourly multipliers, mean 1
    dwf_patterns="diurnal",
)

Nodes must be in the subnetwork and not outfalls, flows non-negative and finite, and patterns defined, of the right length and positive; otherwise ValueError is raised before anything is written. An hourly pattern whose mean is not 1 gives a warning, since it scales the base flows. The total base flow and the number of loaded nodes are logged, for comparison with the plant's measured dry-weather flow. How population is allocated to nodes is left to the study. dwf_from_population converts a per-capita flow (unit L/d, m3/d, L/s or m3/s) to the template's flow_units (default LPS).

Conduit geometry

Cross-sections come from the schema's shape codes. For London (PipeShape, sizes in mm, written in m): R/Round → CIRCULAR(Diameter), REC → RECT_CLOSED(Height, Width), EGG → EGG(Height), ARC → ARCH, ELH/ELV → HORIZ_ELLIPSE/VERT_ELLIPSE (Height, Width). A round pipe with no diameter whose height equals its width takes that value as its diameter (logged and flagged; see below). An unknown or missing shape or size raises ValueError, before anything is written.

A node's invert is the lowest invert of the subnetwork pipes connected to it, including the drop-structure invert (DropStructureInvert in London) of pipes entering it. Each conduit's inlet/outlet offset is its GIS pipe invert minus the node invert (LINK_OFFSETS DEPTH), so conduits keep their GIS inverts; any that slope uphill are reported with a warning. height_correction={node: invert} overrides a node's invert, and the offsets are measured from it.

Data gaps

sub.data_gaps() lists every gap in a subnetwork's data at once: pipes with an unknown or missing shape code (unknown_shape) or a missing size (no_size), pipe ends with no invert (no_us_invert, no_ds_invert; written at the node invert), and nodes with no invert from any pipe (no_node_invert). validate() reports their counts in data_gaps, and to_inp refuses a subnetwork with any unknown_shape, no_size or no_node_invert gap left, listing all of them.

Gaps can be filled by rules that the user chooses; none is applied by default. Every filled value is logged and flagged in the report's filled_from column (with value, source and the order of the fills), so nothing filled looks like City data. The same options go to data_gaps (to see what would be filled) and to_inp (whose returned model keeps the report as model.data_gaps):

from swmmbuilder import LONDON_ON

fills = dict(
    size_overrides={145041: 0.25, 207149: dict(height=0.6)},  # values from the City, in m
    shape_codes={"": "CIRCULAR"},       # missing and blank shape codes are ""
    size_fill="downstream",             # copy from the downstream pipe of the same shape,
                                        # else the largest upstream one ("upstream": reversed)
    invert_fill="interpolate",          # by length between the nearest known inverts
    headwater_slope=0.005,              # no known invert upstream: downstream invert + slope
    force_main_discharge=LONDON_ON.design["force_main_discharge"],
)
report = sub.data_gaps(**fills)
sub.to_inp("model.inp", **fills)

force_main_discharge sets the missing downstream invert of a force main's last pipe from the receiving node's invert (known, or filled first by invert_fill/headwater_slope), plus height_above_receiving_invert (default: max_height_above_receiving_invert); a callable (pipe, receiving_invert) -> invert can be passed instead. Design values like this sit in the schema's optional [design] table, apart from the column mapping, and are used only when passed: London's (london_on.toml) is the Design Specifications & Requirements Manual's limit of 200 mm above the receiving maintenance hole's flow line (Ch. 4, s. 4.8).

Tests

pip install -e ".[test]"
pytest

The tests use a small synthetic network (tests/data/fixture_network.geojson, regenerated by tests/data/make_fixture.py) that contains deliberate edge cases. The same script writes a second network in London's newer export schema with its four node layers (tests/data/fixture_nodes_*.geojson), used by tests/test_nodes.py. Known bugs are encoded as strict xfail tests that name their issue; a fix should turn its tests green and remove the marker.

tests/test_toronto.py also loads a 31-pipe sanitary catchment from the Toronto dataset (tests/data/toronto_catchment.geojson, fetched by tests/data/make_toronto_fixture.py; contains information licensed under the Open Government Licence - Toronto). tests/test_ontario.py does the same for Barrie, Kitchener, Peterborough, Sarnia, Waterloo and Windsor, with a 14–46 pipe sanitary catchment each (tests/data/<city>_catchment.geojson, fetched by tests/data/make_ontario_fixtures.py, whose docstring gives each city's licence).

On macOS, swmm-toolkit's wheel ships libomp.dylib and libswmm-output.dylib with invalid code signatures, and import pyswmm is killed (exit 137). Re-sign them ad hoc:

codesign --force --sign - "$(python -c 'import swmm.toolkit, os; print(os.path.dirname(swmm.toolkit.__file__))')"/{libomp,libswmm-output}.dylib

API reference

The API reference is generated from the docstrings with MkDocs (mkdocs.yml, docs/):

pip install -e ".[docs]"
mkdocs serve            # http://127.0.0.1:8000, rebuilt on save; or mkdocs build --strict

CI

.github/workflows/ci.yml runs ruff (ruff check and ruff format --check, rules in pyproject.toml) and the test suite on Python 3.11–3.13. The CI gate job is the single check to require in branch protection.

Metadata

Release files for swmmbuilder 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for swmmbuilder 0.1.0
File Size Uploaded Provenance Actions
swmmbuilder-0.1.0.tar.gz 178.6 kB With provenance Details for swmmbuilder-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for swmmbuilder 0.1.0
File Interpreter ABI Platform Actions
swmmbuilder-0.1.0-py3-none-any.whl Python 3 none any Details for swmmbuilder-0.1.0-py3-none-any.whl

Total release size: 293.9 kB

Release files / swmmbuilder-0.1.0.tar.gz

Download URL swmmbuilder-0.1.0.tar.gz
Size 178.6 kB
Tags Source
SHA-256 checksum
How to use checksums
273ccc87228f53b3c73ea91aa9781aa9c92db1da7f0c5bae092484de63df18dd
BLAKE2b-256 checksum
How to use checksums
cf0b30dede1826c61d47c3fd63cc3ac9ea22ec402d0008c0215a67f6703e3b7b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.

Transparency log

Release files / swmmbuilder-0.1.0-py3-none-any.whl

Download URL swmmbuilder-0.1.0-py3-none-any.whl
Size 115.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e41a1ba6c15e58cf5e56e2774615613ba09dc28983126a98e77a99f75e208e75
BLAKE2b-256 checksum
How to use checksums
51b5b77df0ad631ca8a03153ad9121b89ef50e59eca3e1c00308c5f2355fe6c5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.

Transparency log

Security for swmmbuilder 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for swmmbuilder-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 3, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page