Skip to content

Latest commit

 

History

History
496 lines (342 loc) · 20.5 KB

README.md

File metadata and controls

496 lines (342 loc) · 20.5 KB

Optimization and Simulation of Lake Ontario - St. Lawrence River (LOSLR) Flow Regulation

Background

Outflows from Lake Ontario are regulated at the Moses-Saunders Dam, which is located downstream on the St. Lawrence River near Cornwall, Ontario and Massena, New York. The Moses-Saunders Dam spans the border of the United States and Canada and is jointly managed by the two countries by the International Lake Ontario - St. Lawrence River Board, at the direction of the Internation Joint Commission. The current flow regulation plan of the LOSLR system is Plan 2014, which is the first control policy in the system to use forecasts to guide release decisions. The repository contains a framework to identify alternative forecast-informed reservoir operating (FIRO) policies using a simulation and optimization approach.

Repository Overview

There are two major components to the workflow in this repository: the simulation and optimization of alternative outflow control policies and the visualization and data analysis on optimized alternatives. This repository contains code to:

  1. Optimize control policies
  2. Simulate plan prescribed outflows and water levels
  3. Assess policy performance for system objectives
  4. Explore results in an interactive dashboard
  5. Perform a time-varying sensitivity analysis (and other analyses) on policy alternatives

For more information on running the policy simulation and optimization framework, see Getting Started.


workflow

Disclaimer: While the Bv7 rule curve and flow limit functions have been verified using the Great Lakes Regulation and Routing Model, this repository is not intended to simulate official Plan 2014 prescribed outflows and simulate the resulting water levels.


Simulation-Optimization

There are seven major components required to simulate and optimize alternative control policies, which are described in more detail below:

  1. Configuration File
  2. Optimization Algorithm
  3. Input Data File
  4. Release Function
  5. Flow Limit Function
  6. Routing Function
  7. Objective Functions

This repository makes a key assumption that a control policy is made up of two modules: a release function and flow limits (or operational adjustments). The release function calculates a preliminary flow, which is then checked against a series of flow limits and modified if the preliminary flow falls outside of those flow limits. Flow limits are a major component of flow regulation in the Plan 2014 control policy (e.g. I-limit, L-limit, F-limit). See Annex B, Section B2 in the Plan 2014 Compendium Report for more details. The Bv7 ruleset is included in this repository; however, this repository does not contain code to simulate Board deviations decisions under the H14 criteria.

Configuration File

The optimization requires several hyperparameters, decision variables, and simulation modules. These fields are specified in a user-generated configuration file. Configuration files are written using the toml file format. A template and examples of a configuration file can be found here. The variables that must be specified in a configuration file are described below.

[experimentalDesign]

These parameters specify the input files and functions used to guide policy simulation. Each variable should be a str.

[experimentalDesign]

# file name of the release function (without .py extension)
releaseFunction = "ruleCurve" # type:str

# whether to impose the September Rule (R+) regime on the release function release ["on" or "off"]
septemberRule = "off" # type:str

# file name of the flow limit function (without .py extension)
limitType = "Bv7" # type:str

# file name of the routing function (without .py extension)
stlawRouting = "stlaw" # type:str

# folder name of the hydrologic trace that contains input data that is being optimized over
trace = "historic" # type:str

# path and file name of the input data that is being optimized over
inputFile = "1900_2020/12month_sqAR" # type:str
[optimizationParameters]

These are parameters needed to run the many-objective evolutionary algorithm, Borg. Each variable should be an int.

[optimizationParameters]

# number of decision variables to optimize
numDV = 10 # type: int

# number of objectives
numObj = 7 # type: int

# number of constraints
numCon = 0 # type: int

# number of function evaluations
nfe = 200000 # type: int

# initial population size
popSize = 100 # type: int

# frequency of function evaluations to report metrics
metFreq = 100 # type: int
[decisionVariables]

These parameters specify information about the decision varibles. Each variable type is specified below.

[decisionVariables]

# list of decision variables names - list of `str` of length of numDV
dvName = []

# list of lower bounds of decision variable ranges - list of `float` of length of numDV
lowerBounds = []

# list of upper bounds of decision variable ranges - list of `float` of length of numDV
upperBounds = []

# whether the decision variables are normalized before input to the simulation model ["True" or "False"]
normalized = ""

# if normalized is True, specify the normalization range
normalizedRange = [int, int]
[releaseFunction]

This sections contains specific inputs needed for the user specified release function. These inputs are completely dependent on the release function specified in experimentalDesign.

[releaseFunction]

releaseFunctionVariable1 = ""
releaseFunctionVariable2 = ""
[performanceIndicators]

These parameters specify information about the performance indicators (i.e. objective functions). Each variable type is specified below.

[performanceIndicators]

# file name of the objective function
objectiveFunction = "" # type: str
 
# aggregate metric to return to optimization algorithm
metricWeighting = "" # type: str

# list of performance indicator names - list of `str` of length numObj
piName = []

# list of thresholds of *meaningful* improvements/reductions in performance for each obejctive - list of `float` of length numObj
epsilonValue = []

# list of the direction of improvement for each objective - list of "min" or "max" of length numObj
direction = []

Optimization Algorithm

A many-objective evolutionary algorithm (MOEA) is used to optimize control policies for flow regulation. The optimization algorithm used in this repository is the Borg MOEA. Before any runs, you will need to download and compile Borg. A two-part tutorial on setup (with an example) is available here by the Reed Lab at Cornell University. Once you have compiled Borg, you can introduce new simulation and evaluation problems. You will need to move the borg.c, borg.py, and libborg.so to the directory with your wrapper script.

Input Data

Input hydrologic files are provided for the historic supply data from 1900 - 2020 (input/historic/hydro). The required inputs for policy simulation and optimization are described here.

Release Function

Release function scripts should contain functions: formatDecisionVariables to format decision variables, getReleaseFunctionInputs to extract the release function inputs from the dictionary of input data, and releaseFunction to prescribe a preliminary flow based on the inputs. Each function's inputs and outputs are described below.

formatDecisionVariables()
# format raw decision variables from optimization algorithm
def formatDecisionVariables(vars, **args):

    # INPUTS
    # vars: list of decision variable values returned from the Borg MOEA
    # args: dict of optional release function inputs from the configuration file in "releaseFunction" section

    # OUTPUTS
    # pars: dict with key value pairs of decision variable names and values
    
    # code to format decision variables values for `releaseFunction` ...

    return pars
getReleaseFunctionInputs()
# extracts timestep inputs for `releaseFunction`
def getReleaseFunctionInputs(data, t, **args):

    # INPUTS
    # data: dictionary of input time series from main simulation function
    # keys are variable names and values are np.arrays of the time series of the variable values
    # t: timestep being simulated in for loop
    # args: dict of optional release function inputs from the configuration file in "releaseFunction" section

    # OUTPUTS
    # x: dictionary with named key value pairs of hydrologic inputs at the
    # timestep of interest, calculated or formatted as needed

    # code to extract, calculate, or format needed inputs for `releaseFunction`....

    return x
releaseFunction()
# takes in output from formatDecisionVariables and getInputs, outputs release and flow regime
def releaseFunction(x, pars, **args):

    # INPUTS
    # x: dict that is output from `getReleaseFunctionInputs`
    # pars: dict that is output from `formatDecisionVariables`
    # args: dict of optional release function inputs from the configuration file in "releaseFunction" section

    # OUTPUTS
    # dictionary with named key value pairs:
    # "rfFlow": prescribed outflow
    # "rfRegime": regime that prescribed outflow follows
    # "pprFlow": preproj flow or np.nan
    # "rfOutput": output from release function (could be the same as ontFlow)

    # code for release function here....

    # return all the relevant outputs to save in dataframe
    outputs = dict()
    outputs["rfFlow"] = ontFlow
    outputs["rfRegime"] = ontRegime
    outputs["pprFlow"] = pprFlow # or np.nan
    outputs["rfOutput"] = rfOutput

    return outputs

Examples for the Plan 2014 rule curve and the ANN policy appoximator release functions can found here. A blank template of a release function can be found here.

Flow Limits

Flow limit function scripts should contain functions: getPlanLimitsInputs to extract the limit function inputs from the dictionary of input data and planLimits to check the preliminary flow against the flow limits and modify if needed. Each function's inputs and outputs are described below.

getPlanLimitsInputs()
# extracts timestep inputs for `planLimits`
def getPlanLimitsInputs(data, t):
    
    # INPUTS
    # data: dictionary of input time series from main simulation function
    # keys are variable names and values are np.arrays of the time series of 
    # the variable values
    # t: timestep from simulation for loop

    # OUTPUTS
    # x: dictionary with named key value pairs of hydrologic inputs at the
    # timestep of interest

    # code to extract, calculate, or format needed inputs for `planLimits`....

    return x
planLimits()
# function to check (and modify) preliminary flow from release function 
def planLimits(
    qm,
    prelimLevel,
    prelimFlow,
    prelimRegime,
    x,
    septemberRule,
    ):

    # INPUTS
    # qm: quarter-month from simulation for loop
    # prelimLevel: release function calculated preliminary water level
    # prelimFlow: release function calculated preliminary flow
    # prelimRegime: release function calculated preliminary regime
    # x: dict that is output from `getPlanLimitsInputs`
    # septemberRule: "off" or the septemberRule function

    # OUTPUTS
    # dictionary with named key value pairs
    # "ontFlow": checked outflow (could be release function or a limit flow)
    # "ontRegime": regime that outflow follows (could be "RF" or other)

    # code to check against flow limits here....

    return {"ontFlow": ontFlow, "ontRegime": ontRegime}

Examples for the Bv7 or Phase 2 updated Bv7 flow limit functions can found here. A blank template of a flow limit function can be found here.

Routing Scheme

Routing function scripts should contain functions: getStLawrenceRoutingInputs to extract the routing function inputs from the dictionary of input data and stLawrenceRouting to route the outflow through the system and determine water levels along Lake Ontario and the St. Lawrence River. Each function's inputs and outputs are described below.

getStLawrenceRoutingInputs()
# extracts timestep inputs for `stLawrenceRouting`
def getStLawrenceRoutingInputs(data, t):
    
    # INPUTS
    # data: dictionary of input time series from main simulation function
    # keys are variable names and values are np.arrays of the time series of 
    # the variable values
    # t: timestep from simulation for loop

    # OUTPUTS
    # x: dictionary with named key value pairs of hydrologic inputs at the
    # timestep of interest

    # code to extract, calculate, or format needed inputs for `stLawrenceRouting`....

    return x
stLawrenceRouting()
# routes final outflow through system to calculate levels/flows along the St. Lawrence River
def stLawrenceRouting(ontLevel, ontFlow, x):

    # INPUTS
    # ontLevel: water level calculated with observed NTS and final release
    # ontFlow: final outflow for timestep
    # x: dictionary output from `getStLawrenceRoutingInputs`

    # OUTPUTS
    # dictionary with named key value pairs for relevant locations along the st. lawrence river (see below for all locations)

    # code to calculate st. lawrence levels and flows ....

    # save timestep in dictionary
    levels = dict()
    levels["stlouisFlow"] = stlouisFlow
    levels["kingstonLevel"] = kingstonLevel_rounded
    levels["alexbayLevel"] = alexbayLevel
    levels["brockvilleLevel"] = brockvilleLevel
    levels["ogdensburgLevel"] = ogdensburgLevel
    levels["cardinalLevel"] = cardinalLevel
    levels["iroquoishwLevel"] = iroquoishwLevel
    levels["iroquoistwLevel"] = iroquoistwLevel
    levels["morrisburgLevel"] = morrisburgLevel
    levels["longsaultLevel"] = longsaultLevel
    levels["saundershwLevel"] = saundershwLevel
    levels["saunderstwLevel"] = saunderstwLevel
    levels["cornwallLevel"] = cornwallLevel
    levels["summerstownLevel"] = summerstownLevel
    levels["lerybeauharnoisLevel"] = lerybeauharnoisLevel
    levels["ptclaireLevel"] = ptclaireLevel
    levels["jetty1Level"] = jetty1Level
    levels["stlambertLevel"] = stlambertLevel
    levels["varennesLevel"] = varennesLevel
    levels["sorelLevel"] = sorelLevel
    levels["lacstpierreLevel"] = lacstpierreLevel
    levels["maskinongeLevel"] = maskinongeLevel
    levels["troisrivieresLevel"] = troisrivieresLevel
    levels["batiscanLevel"] = batiscanLevel

    return levels

Examples for the routing function using SLON flows can found here. A blank template of a routing function can be found here.

Objective Functions

Objective functions are simulated over the user-specified time period. Each objective is aggregated by the net annual average value, and that metric is returned to Borg to drive the optimization. More information on the objective function formulations and required inputs can be found here.


Data Visualization and Post Analysis

  1. Dashboard
  2. Candidate Policy Selection
  3. Policy Simulation
  4. Secondary Analyses

Dashboard

Results from the optimization and simulation of candidate plans for objective functions and a posteriori performance indicators are displayed in an interactive dashboard (based in an R Shiny app). First, compile the results by running the following command and specifying the following:

python output/postScripts/dataFormat.py ${loc} ${folderName} ${nseeds}
  1. ${loc} : the absolute path of the user's home directory (i.e., where the code repository is located)
  2. ${folderName} : the name name of the folder that contains the raw Borg results (in the output/ directory)
  3. ${nseeds} : the number of random seeds to use in the optimization

You can then run the dashboard by navigating to the dashboard/ directory, activating the conda environment, and launching r:

conda activate r-shiny      # activate shiny specific conda environment
cd dashboard/               # navigate to folder where dashboard script is stored
r                           # launch r
shiny::runApp()             # launch app

More detail on running the dashboard is available here.

Candidate Policy Selection

Select from dashboard...

Policy Re-Simulation

Borg returns the decision variables values of policy that fall on the Pareto Frontier. However, the time series of water levels and objective performance is not returned. To run the simulation model and return the time series of hydrologic attributes and performance indicators, run the policySimulation.py script.

Secondary Analyses

This repository contains code to performa a time-varying sensitivity analysis on candidate policies. Users can add additional analysis scripts to the output/ folder and call them in the postAnalysis.sh script to run.


Getting Started

There are two scripts that drive the policy simulation and optimization: optimizationWrapper.py and optimizationSimulation.py. The optimizationWrapper.py script calls and interacts with the Borg MOEA. The wrapper script reads the user-generated configuration file and sets up the optimization experiment. optimizationWrapper.py then calls optimizationSimulation.py to simulate the time series of outflows, water levels, and system performance that result from the decision variables returned by Borg in each function evaluation.

You can run Borg on your local machine from the command line or on a HPC (see example bash or SLURM script). Both scripts call the optimizationWrapper.py script, which requires three user-specified arguments:

python optimizationWrapper.py ${loc} ${config} ${S}
  1. ${loc} : the absolute path of the user's home directory (i.e., where the code repository is located)
  2. ${config} : the relative path to the configuration file from the home directory
  3. ${S} : the random seed to use in the optimization

To ensure convergence on the Pareto Frontier and avoid falling into local minima/maxima, it is advisable to run multiple seeds per experiment. The runOptimization_Local.sh and runOptimization_HPC.sh shell scripts are setup to take in the number of seeds to run per experiment rather than the random seed. To run the shell script locally (or submit a SLURM job on a HPC), users must specify three arguments:

./runOptimization_Local.sh ${loc} ${config} ${nseeds}
  1. ${loc} : the absolute path of the user's home directory (i.e., where the code repository is located)
  2. ${config} : the relative path to the configuration file from the home directory
  3. ${nseeds} : the number of random seeds to use in the optimization