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()
../_images/bab6645d1a1b717c990a1199aa5456317dfdb22d5f5e9eaad78602e8aa5f9824.png

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()
../_images/c2d61ead4c9b6d7c583c5333dcea0a4f2421bab2c5145e6e760f2ea2406508dc.png

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()
../_images/ccae190ed4bdac9897b35d63f065c2dbc61936a63aec324a6a119feedea09b24.png

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()
../_images/be31180e0e5709d407af97caa4a84df82b8784458a80618a95dbe99800f3c753.png

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()
../_images/309f6f346f20402a9efeeb7c341b86bd43e173926f3726970d838de66a8d15e7.png

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()
../_images/9c34ecf70f8d6a8792e97f467ff44ab9a2a954481794745a50f1cdfabac2557c.png
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()
../_images/b22470e7694f56de2ef6e7321e7bb6c50a0ac0f619febdfcf0afe7a3b1b3fa29.png

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()
../_images/ebc165d4e23c02c5293c18c46c01368e6754b6985aee66bf3185aef12d66c2c7.png