Storm water detention pond

This part of the documentation demonstrates how STOMPC is used to perform online control for storm water detention ponds.

Installing the tools

For this case, we need to have three tools available:

  • strategoutil, the tool as described in this documentation,

  • UPPAAL Stratego, the generic tool that will synthesize strategies, and

  • pySWMM the Python API for SWMM, the domain-specific tool that will perform detailed simulations of the pond.

Information on how to install strategoutil and UPPAAL Stratego can be found in the Installation guide.

pySWMM is available though pip:

pip install pyswmm

Preparing the UPPAAL Stratego model

In most cases, a UPPAAL Stratego model suitable for offline strategy synthesis is adjusted to be suitable for online control. That also has been the situation for this case. The model from this paper has been modified for this online model-predictive control setup. In this section, we do not discuss how to adjust a model suitable for offline control to one for online control, but we indicate what you have to do specifically for using STOMPC tool to perform this online control.

In the UPPAAL Stratego model, we need to insert placeholders at the variables that will have different values at the start of each MPC step, for example, the water level \(w\). These placeholders are strings with the format //TAG_<varname>, where <varname> is the name of the variable. So, for the clock variable \(w\) representing the water level, we will rewrite

clock w = 100; // water level in pond [cm]

into

clock w = //TAG_w; // water level in pond [cm]

Notice that after the tag there is still the semicolon ;, as only the placeholder will be replaced by the initial value of that variable. The UPPAAL Stratego GUI will now also start to give a syntax error on the next line, as it cannot find the closing semicolon.

After inserting all the placeholders in the UPPAAL Stratego model, we have to create a model configuration file. This file tells the STOMPC tool which variables it need to keep track of during MPC, and what their initial values are for the very first step. The model configuration file has to be a yaml file, but you can use a custom name. For this case, we have the following pond_experiment_config.yaml file:

w: 0.0

Finally, we have to specify the learning and other query parameters. This is also done in a separate yaml-file. Below you can find the content of the verifyta_config.yaml file for the storm water pond (with some arbitrarily numbers that ensure fast calculations). In Define experiment variables we will indicate in python which files contain the model and strategy configurations. This file contains pairs of the setting name and its value, where the setting name is the one used for the command line interface of UPPAAL Stratego. In case a certain parameter does not have a value, for example nosummary, you just leave the value field empty.

learning-method: 4
good-runs: 10
total-runs: 20
runs-pr-state: 5
eval-runs: 5
discretization: 0.5
filter: 2
nosummary:
silence-progress:

Specializing the SafeMPCSetup class from STOMPC

The STOMPC tool provides several classes that can be tailored for the case you want to use it for.

  • MPCsetup. This class is the primarily class an end-user should specialize for his or her case. It implements the basic MPC scheme. It assumes that UPPAAL Stratego will always success in synthesizing a safe and optimal strategy.

  • SafeMPCSetup. This class inherits from MPCsetup, yet it monitors and detects whether UPPAAL Stratego has successfully synthesized a strategy. If not, it will run UPPAAL Stratego with an alternative query, which has to be specified by the user, as it depends on the model what a safe query would be.

For the storm water detention pond, the primary goal is to synthesize a strategy that ensures no overflow (safety) while maximizing particle sedimentation (optimality). Nonetheless, it might be the case that overflow cannot be prevented by any strategy, thus UPPAAL Stratego will fail. Therefore, the SafeMPCSetup class should be specialized.

Below the specialized class MPCSetupPond is defined. As can be seen, we override three methods for the pond case: create_query_file, create_alternative_query_file, and perform_at_start_iteration.

import strategoutil as stompc
import weather_forecast_generation as weather
import datetime

class MPCSetupPond(stompc.SafeMPCSetup):
    def create_query_file(self, horizon, period, final):
        """
        Create the query file for each step of the pond model.
        Current content will be overwritten.

        Overrides SafeMPCsetup.create_query_file().
        """
        with open(self.queryfile, "w") as f:
            line1 = f"strategy opt = minE (c) [<={horizon}*{period}]: " \
                    f"<> (t=={final} && o <= 0)\n"
            f.write(line1)
            f.write("\n")
            line2 = f"simulate 1 [<={period}+1] {{" \
                    f"{self.controller.get_var_names_as_string()} }} under opt\n"
            f.write(line2)

    def create_alternative_query_file(self, horizon, period, final):
        """
        Create an alternative query file in case the original
        query could not be satisfied by Stratego, i.e., it could
        not find a strategy. Current content will be overwritten.

        Overrides SafeMPCsetup.create_alternative_query_file().
        """
        with open(self.queryfile, "w") as f:
            line1 = f"strategy opt = minE (w) [<={horizon}*{period}]: <> (t=={final})\n"
            f.write(line1.format(horizon, period, final))
            f.write("\n")
            line2 = f"simulate 1 [<={period}+1] " \
                    f"{{ {self.controller.get_var_names_as_string()} }} under opt\n"
            f.write(line2)

    def perform_at_start_iteration(self, controlperiod, horizon, duration, step, **kwargs):
        """
        Performs some customizable preprocessing steps at the
        start of each MPC iteration.

        Overrides SafeMPCsetup.perform_at_start_iteration().
        """
        current_date = kwargs["start_date"] + datetime.timedelta(hours=step)
        weather.create_weather_forecast(kwargs["historical_rain_data_path"],
                                        kwargs["weather_forecast_path"],
                                        current_date,
                                        horizon * controlperiod,
                                        kwargs["uncertainty"])

In method create_query_file we specify the strategy synthesis query. For the pond case, we have this defined with line1. It states that we want to synthesize a strategy that we call opt that minimizes the expected value of clock variable \(c\) (representing the cost in the model) where all runs have a maximum duration of the number of periods (denoted by horizon) and UPPAAL Stratego time units per period (denoted by period) such that eventually the time variable \(t\) reaches its final value and accumulated overflow duration \(o\) is zero or less.

Furthermore, we have a simulate query in this method. Only the first period is simulated to obtain the first control action of the synthesized strategy opt.

The second method create_alternative_query_file specifies the query in case there is overflow and UPPAAL Stratego fails to synthesize a safe strategy. We have almost the same strategy synthesis query, except we removed the requirement that no overflow can occur (\(o \leq 0\)) and we want to minimize the water level \(w\) instead of the cost \(c\).

Finally, at the start of each MPC iteration, we need to create a weather forecast. These are generated from historical rain data and, similarly to real weather forecasts, these change over time. Therefore, we create new ones each iteration. A separate custom library contains methods to generate weather forecasts.

Define experiment variables

We can now define and set all the experiment variables. These include, for example, file paths to the UPPAAL Stratego and SWMM models.

import yaml

if __name__ == "__main__":
    # SWMM files.
    swmm_inputfile = "swmm_simulation.inp"
    rain_data_file = "swmm_5061.dat"

    # Other variables of swimm.
    orifice_id = "OR1"
    basin_id = "SU1"
    time_step = 60 * 60 # duration of SWMM simulation step in seconds.
    swmm_results = "swmm_results_online.csv"

    # Now we specify the Uppaal files.
    model_template_path = "pond_experiment.xml"
    query_file_path = "pond_experiment_query.q"
    model_config_path = "pond_experiment_config.yaml"
    learning_config_path = "verifyta_config.yaml"
    weather_forecast_path = "weather_forecast.csv"
    output_file_path = "stratego_result.txt"
    verifyta_command = "verifyta-stratego-9"

    # Define MPC model variables.
    action_variable = "Open"  # Name of the control variable.
    debug = True  # Whether to run in debug mode.
    period = 60  # Control period in Stratego time units (minutes).
    horizon = 12  # How many periods to compute strategy for.
    uncertainty = 0.1  # The uncertainty in the weather forecast generation.

After this we load the two configuration files:

# Get model and learning config dictionaries from files.
with open(model_config_path, "r") as yamlfile:
    model_cfg_dict = yaml.safe_load(yamlfile)
with open(learning_config_path, "r") as yamlfile:
    learning_cfg_dict = yaml.safe_load(yamlfile)

Finally, we can create the MPC object from our MPCSetupPond class:

# Construct the MPC object.
controller = MPCSetupPond(model_template_path, output_file_path, queryfile=query_file_path,
                          model_cfg_dict=model_cfg_dict, learning_args=learning_cfg_dict,
                          verifyta_command=verifyta_command, external_simulator=False,
                          action_variable=action_variable, debug=debug)

Combining strategy synthesis and simulation

Finally, we need to actually define how STOMPC should combine UPPAAL Stratego and SWMM together. Because SWMM is a stateful simulator from which we cannot extract the full state through the pySWMM API, we cannot use the default SafeMPCSetup.run method to perform MPC. Therefore, we will ‘pause’ the SWMM simulator after each step and let SafeMPCSetup perform a single MPC step instead.

The method below will start and run the SWMM simulation, and after each step ask for the next control setting.

from pyswmm import Simulation, Nodes, Links
import csv

def swmm_control(swmm_inputfile, orifice_id, basin_id, time_step, swmm_results,
                 controller, period, horizon, rain_data_file, weather_forecast_path,
                 uncertainty):
    # Arrays for storing simulation results before writing it to file.
    time_series = []
    water_depth = []
    orifice_settings = []

    with Simulation(swmm_inputfile) as sim:
        # Get the pond and orifice objects from the simulation.
        pond = Nodes(sim)[basin_id]
        orifice = Links(sim)[orifice_id]

        sim.step_advance(time_step)
        current_time = sim.start_time

        # Ask for the first control setting.
        orifice.target_setting = get_control_strategy(pond.depth, current_time, controller,
                                                      period, horizon, rain_data_file,
                                                      weather_forecast_path, uncertainty)

        # Get the initial data points.
        orifice_settings.append(orifice.target_setting)
        time_series.append(sim.start_time)
        water_depth.append(pond.depth)

        for step in sim:
            current_time = sim.current_time
            time_series.append(current_time)
            water_depth.append(pond.depth)

            # Get and set the control parameter for the next period.
            orifice.target_setting = get_control_strategy(pond.depth, current_time,
                                                          controller, period, horizon,
                                                          rain_data_file,
                                                          weather_forecast_path, uncertainty)
            orifice_settings.append(orifice.target_setting)

    # Write results to file.
    with open(swmm_results, "w") as f:
        writer = csv.writer(f)
        for i, j, k in zip(time_series, water_depth, orifice_settings):
            i = i.strftime('%Y-%m-%d %H:%M')
            writer.writerow([i, j, k])

The method get_control_strategy that gets the next control setting is defined below. It first updates the state of the controller by updating the value of the water level \(w\) as obtained by the SWMM simulation. Subsequently, it performs the run_single method that performs a single MPC step. This method returns the control setting for the next period.

def get_control_strategy(current_water_level, current_time, controller, period, horizon,
                         rain_data_file, weather_forecast_path, uncertainty):
    # The 100 is due to conversion from m to cm.
    controller.controller.update_state({'w':current_water_level * 100})
    control_setting = controller.run_single(period, horizon, start_date=current_time,
                                            historical_rain_data_path=rain_data_file,
                                            weather_forecast_path=weather_forecast_path,
                                            uncertainty=uncertainty)

    return control_setting

Finally, we have to start everything in our main block. We do this by simply calling swmm_control with the necessary inputs.

swmm_control(swmm_inputfile, orifice_id, basin_id, time_step, swmm_results, controller,
             period, horizon, rain_data_file, weather_forecast_path, uncertainty)