Making Art with EPA SWMM#
After a hard day of H&H modeling, some of us may need to express ourselves with the other side of our brain. Let’s play around with a model and make some useless but interesting visualizations. We’ll need a few hidden features in swmmio: the swmmio.utils.functions.rotate_model()
and swmmio.utils.spatial.centroid_and_bbox_from_coords()
functions.
We’ll start with the jersey
example model.
import numpy as np
import pandas as pd
import swmmio
from swmmio.examples import jersey
from swmmio.utils.functions import rotate_model
from swmmio.utils.spatial import centroid_and_bbox_from_coords
ax = jersey.links.geodataframe.plot(linewidth=jersey.links.dataframe['Geom1'], capstyle='round')
jersey.nodes.geodataframe.plot('MaxDepth', ax=ax, markersize='InvertElev', zorder=2)
# hide the axes since we're making art
ax.set_axis_off()

But what if it was a Spirograph?#
What happens if we spin the model around a bunch and replot it on the same figure? Would it be cool?
# first we'll get the centroid and the bounding box of the model
# this is useful for rotating the model about its centroid
(xc, yc), [x1, y1, x2, y2] = centroid_and_bbox_from_coords(jersey.inp.coordinates)
# start the plot as usual
ax = jersey.links.geodataframe.plot(linewidth=jersey.links.dataframe['Geom1'], capstyle='round')
jersey.nodes.geodataframe.plot('MaxDepth', ax=ax, markersize='InvertElev', zorder=2)
# spin that thing around
for rads in np.arange(0, 3*3.14, 0.1):
jersey = rotate_model(jersey,rads=rads, origin=(xc, yc))
jersey.links.geodataframe.plot(linewidth=1, capstyle='round', ax=ax, alpha=0.5)
jersey.nodes.geodataframe.plot('MaxDepth', ax=ax, markersize='InvertElev', zorder=2, alpha=0.5)
ax.set_axis_off()

Pretty cool. But what if we wasted more compute by making a 5x more stamps of the model. And what if we shifted the origin about which we rotate the model before each iteration?
# refresh the model
jersey = swmmio.Model(jersey.inp.path)
# start the plot as usual
ax = jersey.links.geodataframe.plot(linewidth=jersey.links.dataframe['Geom1'], capstyle='round')
# jersey.nodes.geodataframe.plot('MaxDepth', ax=ax, markersize='InvertElev', zorder=2)
# spin that thing around
for rads in np.arange(0, 6*3.14, 0.05):
jersey = rotate_model(jersey, rads=rads, origin=(xc+rads*50, yc))
jersey.links.geodataframe.plot(linewidth=jersey.links.dataframe['Geom1'], capstyle='round', ax=ax, alpha=0.1)
ax.set_axis_off()

Now we are talking.
Fractals?#
Let’s see if we can take this a bit further by generating fractals from our SWMM model. We’ll need a couple more functions for shifting and shrinking the model. We’ll also need to determine the top-of-shed nodes - this is where we can repeat the fractal pattern.
Let’s start by defining a function to scale the model coordinates:
def scale_model_coords(model, scale_factor, center_point):
"""
Apply a scaling factor to all coordinates in the model, such that the coordinates
appear to shrink or expand about a given center point.
Parameters
----------
model : swmmio.Model
The SWMM model whose coordinates are to be scaled.
scale_factor : float
The factor by which to scale the coordinates.
center_point : tuple
The (x, y) point about which to scale the coordinates.
Returns
-------
swmmio.Model
The model with scaled coordinates.
"""
def scale_coordinates(coords, scale_factor, center_point):
coords = np.array(coords)
center_point = np.array(center_point)
scaled_coords = center_point + scale_factor * (coords - center_point)
return scaled_coords.tolist()
model.inp.coordinates = pd.DataFrame(
data=scale_coordinates(model.inp.coordinates.values, scale_factor, center_point),
columns=model.inp.coordinates.columns,
index=model.inp.coordinates.index
)
model.inp.vertices = pd.DataFrame(
data=scale_coordinates(model.inp.vertices.values, scale_factor, center_point),
columns=model.inp.vertices.columns,
index=model.inp.vertices.index
)
model.inp.polygons = pd.DataFrame(
data=scale_coordinates(model.inp.polygons.values, scale_factor, center_point),
columns=model.inp.polygons.columns,
index=model.inp.polygons.index
)
return model
# let's see if that worked
jersey = swmmio.Model(jersey.inp.path)
(xc, yc), [x1, y1, x2, y2] = centroid_and_bbox_from_coords(jersey.inp.coordinates)
# start the plot as usual
ax = jersey.links.geodataframe.plot(color='green', capstyle='round')
# scale the model a bunch of times and plot it
for scale in np.arange(0.05, 1, 0.1):
jersey = swmmio.Model(jersey.inp.path)
jersey = scale_model_coords(jersey, scale, (xc, yc))
jersey.links.geodataframe.plot(ax=ax, alpha=0.5)
ax.set_axis_off()

Nice. Now let’s define a function to shift the model
def shift_model_coords(model, new_center_point, anchor_point=None):
"""
Shift all coordinates in the model to a new center point, optionally anchoring
the shift relative to another point.
Parameters
----------
model : swmmio.Model
The SWMM model whose coordinates are to be shifted.
new_center_point : tuple
The (x, y) point to which the coordinates will be shifted.
anchor_point : tuple, optional
The (x, y) point relative to which the shift will be anchored. If not provided,
the current center point of the model's coordinates will be used.
Returns
-------
swmmio.Model
The model with shifted coordinates.
"""
def shift_coordinates(coords, shift_vector):
coords = np.array(coords)
shifted_coords = coords + shift_vector
return shifted_coords.tolist()
# Calculate the current center point or use the anchor point
if anchor_point is None:
current_center_point = model.inp.coordinates.mean().values
else:
current_center_point = np.array(anchor_point)
# Calculate the shift vector
shift_vector = np.array(new_center_point) - current_center_point
# Apply the shift to all coordinates
model.inp.coordinates = pd.DataFrame(
data=shift_coordinates(model.inp.coordinates.values, shift_vector),
columns=model.inp.coordinates.columns,
index=model.inp.coordinates.index
)
model.inp.vertices = pd.DataFrame(
data=shift_coordinates(model.inp.vertices.values, shift_vector),
columns=model.inp.vertices.columns,
index=model.inp.vertices.index
)
model.inp.polygons = pd.DataFrame(
data=shift_coordinates(model.inp.polygons.values, shift_vector),
columns=model.inp.polygons.columns,
index=model.inp.polygons.index
)
return model
jersey = swmmio.Model(jersey.inp.path)
(xc, yc), [x1, y1, x2, y2] = centroid_and_bbox_from_coords(jersey.inp.coordinates)
# start the plot as usual
ax = jersey.links.geodataframe.plot(color='green', linewidth=jersey.links.geodataframe['Geom1'], capstyle='round')
for outfall, _ in jersey.inp.outfalls.iterrows():
jersey = shift_model_coords(jersey, (jersey.inp.coordinates.loc[outfall].X, jersey.inp.coordinates.loc[outfall].Y), anchor_point=(x1, y2))
jersey.links.geodataframe.plot(ax=ax, alpha=0.5)
ax.set_axis_off()

Perfect. Now we can shift, scale, and rotate our model. All we need to do now is recursively iterator over the top-of-shed nodes and perform these actions.
We’ll use the swmmio.Model.network()
method to access the model as a graph. Then we’ll locate the top-of-shed nodes according to each node’s in_degree.
import networkx as nx
jersey = swmmio.Model(jersey.inp.path)
G = jersey.network
# find nodes with zero in-degree
zero_indegree = [n for n in G.nodes() if G.in_degree(n) == 0]
# start the plot as usual
ax = jersey.links.geodataframe.plot(color='green', linewidth=jersey.links.geodataframe['Geom1'], capstyle='round')
for node, row in jersey.inp.coordinates.loc[zero_indegree].iterrows():
jersey = swmmio.Model(jersey.inp.path)
anchor_point = jersey.inp.coordinates.loc[jersey.inp.outfalls.index[0]][['X', 'Y']].values
jersey = scale_model_coords(jersey, 0.3, center_point=anchor_point)
jersey = shift_model_coords(jersey, (row.X, row.Y), anchor_point=anchor_point)
jersey.links.geodataframe.plot(ax=ax, alpha=0.5)
ax.set_axis_off()

def plot_recursive(jersey, ax, nodes, depth, max_depth, scaler=0.8):
if depth > max_depth:
return
for node, row in jersey.inp.coordinates.loc[nodes].iterrows():
# Reload the model to avoid modifying the original
model_copy = swmmio.Model(jersey.inp.path)
anchor_point = model_copy.inp.coordinates.loc[model_copy.inp.outfalls.index[0]][['X', 'Y']].values
# Scale and shift the model
model_copy = scale_model_coords(model_copy, 0.3*scaler, center_point=anchor_point)
model_copy = shift_model_coords(model_copy, (row.X, row.Y), anchor_point=anchor_point)
# Plot the model
model_copy.links.geodataframe.plot(ax=ax, alpha=0.5)
# Find nodes with zero in-degree in the current model
zero_indegree = [n for n in model_copy.network.nodes() if model_copy.network.in_degree(n) == 0]
# Recur with increased depth
plot_recursive(model_copy, ax, zero_indegree, depth + 1, max_depth, scaler=scaler*scaler)
jersey = swmmio.Model(jersey.inp.path)
# Start the plot as usual
ax = jersey.links.geodataframe.plot(color='green', linewidth=jersey.links.geodataframe['Geom1'], capstyle='round', figsize=(10, 10))
# Find nodes with zero in-degree
G = jersey.network
zero_indegree = [n for n in G.nodes() if G.in_degree(n) == 0]
# Run the recursive plotting function with a maximum depth of 3
plot_recursive(jersey, ax, zero_indegree, depth=1, max_depth=3)
ax.set_axis_off()

Lovely. Now, to wrap up, let’s run this logic on another SWMM model and see how it looks
from swmmio.examples import walnut
walnut = swmmio.Model(walnut.inp.path)
# Start the plot as usual
ax = walnut.links.geodataframe.plot(color='green', linewidth=walnut.links.geodataframe['Geom1'], capstyle='round', figsize=(20, 10))
# Find nodes with zero in-degree
G = walnut.network
zero_indegree = [n for n in G.nodes() if G.in_degree(n) == 0]
# Run the recursive plotting function with a maximum depth of 3
plot_recursive(walnut, ax, zero_indegree, depth=1, max_depth=3)
ax.set_axis_off()
