
RTC-Tools documentation¶
This is the documentation for RTC-Tools, the Deltares toolbox for control and optimization of environmental systems.
Visit the RTC-Tools website for a general product description and information on available services.
This first chapter covers getting the software running on your computer. The subsequent two chapters describe the RTC-Tools Python API. The fourth and final chapter discusses several illustrative examples, including the use of goal programming for multi-objective optimization, as well as the use of forecast ensembles.
Contents¶
Getting Started¶
Installation¶
For most users, the easiest way to install RTC-Tools using the pip package manager.
Using the Pip Package Manager¶
Although not required, it is recommended to install RTC-Tools in a virtual environment. See the official Python tutorial for more information on how to set up and activate a virtual environment.
RTC-Tools, including its dependencies, can be installed using the pip package manager:
# Install RTC-Tools and Channel Flow using pip package manager
pip install rtc-tools rtc-tools-channel-flow
From Source¶
The latest RTC-Tools and Channel Flow source can be downloaded using git:
# Get RTC-Tools source
git clone https://gitlab.com/deltares/rtc-tools.git
# Get RTC-Tools's Modelica library
git clone https://gitlab.com/deltares/rtc-tools-channel-flow.git
Then you can install this latest version as follows:
pip install ./rtc-tools
pip install ./rtc-tools-channel-flow
Or if you would like to have an editable installation (e.g. as developer):
pip install -e ./rtc-tools
pip install -e ./rtc-tools-channel-flow
Downloading and running examples¶
To check whether the installation was succesful, the basic example can be used. If RTC-Tools was not installed from source, the examples need to be downloaded first:
# Download the examples to the current folder (.)
rtc-tools-download-examples .
# Navigate to the basic example
cd rtc-tools-examples/basic/src
# Run the example
python example.py
If the installation was succesful, you should see that the solver succeeds:

Elsewhere in this documentation we refer to the folder containing the examples
as <examples directory>
. Depending on the method of installation this can
then either be:
\path\to\rtc-tools-examples
, when having downloaded the examples\path\to\source\of\rtc-tools\examples
, when having installed RTC-Tools from source
Copying Modelica libraries¶
Because the Modelica libraries are distributed as pip packages, their location
inside Python’s site-packages can be somewhat inconvient. To copy the Modelica
libraries to a more convenient location, you can use the rtc-tools-copy-libraries
command:
# Copy all Modelica libraries of RTC-Tools to the current folder (.)
rtc-tools-copy-libraries .
You should now have a folder Deltares
, containing amongst others a
package.mo
file, a ChannelFlow
folder and folders of any other RTC-
Tools extensions you installed.
Elsewhere in this documentation we refer to the library folder containing the
Deltares
folder as <library directory>
.
Getting OMEdit¶
RTC-Tools uses the Modelica language to describe the mathematics of the system we wish to optimize. There are several editors for Modelica models, but the OpenModelica Connection Editor, or OMEdit, is a free and open-source graphical connection editor that can be used to construct RTC-Tools models. To download it for windows, click here: https://www.openmodelica.org/download/download-windows
Once installed, you can start OMEdit by clicking:
Start -> All Programs -> OpenModelica -> OpenModelica Connection Editor
With OMEdit installed, you can start using it by following along with the basic example, Filling a Reservoir.
Running RTC-Tools¶
RTC-Tools is run from a command line shell. On Windows, both PowerShell
and cmd
can be used. On Linux/MacOS you could use the terminal application
with a shell of your liking.
Once you have started the shell and loaded the correct virtual environment (if
applicable), navigate to the src
directory of the case you wish to
optimize, e.g.:
cd \path\to\rtc-tools-examples\basic\src
Then, to run the case with RTC-Tools, run the src
python script, e.g.:
python example.py
You will see the progress of RTC-Tools in your shell. All your standard shell commands can be used in the RTC-Tools shell. For example, you can use:
python example.py > log.txt
to pipe RTC-Tools output to a log file.
Optimization¶
Contents:
Basics¶
-
class
rtctools.optimization.timeseries.
Timeseries
(times: numpy.ndarray, values: Union[numpy.ndarray, list, casadi.casadi.DM])¶ Bases:
object
Time series object, bundling time stamps with values.
-
__init__
(times: numpy.ndarray, values: Union[numpy.ndarray, list, casadi.casadi.DM])¶ Create a new time series object.
Parameters: - times – Iterable of time stamps.
- values – Iterable of values.
-
times
¶ Array of time stamps.
-
values
¶ Array of values.
-
-
class
rtctools.optimization.optimization_problem.
OptimizationProblem
(**kwargs)¶ Bases:
object
Base class for all optimization problems.
-
bounds
() → rtctools._internal.alias_tools.AliasDict¶ Returns variable bounds as a dictionary mapping variable names to a pair of bounds. A bound may be a constant, or a time series.
Returns: A dictionary of variable names and (upper, lower)
bound pairs. The bounds may be numbers orTimeseries
objects.Example:
def bounds(self): return {'x': (1.0, 2.0), 'y': (2.0, 3.0)}
-
constant_inputs
(ensemble_member: int) → rtctools._internal.alias_tools.AliasDict¶ Returns a dictionary of constant inputs.
Parameters: ensemble_member – The ensemble member index. Returns: A dictionary of constant input names and time series.
-
constraints
(ensemble_member: int) → List[Tuple[casadi.casadi.MX, Union[float, numpy.ndarray], Union[float, numpy.ndarray]]]¶ Returns a list of constraints for the given ensemble member.
Call
OptimizationProblem.state_at()
to return a symbol representing a model variable at a given time.Parameters: ensemble_member – The ensemble member index. Returns: A list of triples (f, m, M)
, with anMX
object representing the constraint functionf
, lower boundm
, and upper boundM
. The bounds must be numbers.Example:
def constraints(self, ensemble_member): t = 1.0 constraint1 = ( 2 * self.state_at('x', t, ensemble_member), 2.0, 4.0) constraint2 = ( self.state_at('x', t, ensemble_member) + self.state_at('y', t, ensemble_member), 2.0, 3.0) return [constraint1, constraint2]
-
control
(variable: str) → casadi.casadi.MX¶ Returns an
MX
symbol for the given control input, not bound to any time.Parameters: variable – Variable name. Returns: MX
symbol for given control input.Raises: KeyError
-
control_at
(variable: str, t: float, ensemble_member: int = 0, scaled: bool = False) → casadi.casadi.MX¶ Returns an
MX
symbol representing the given control input at the given time.Parameters: - variable – Variable name.
- t – Time.
- ensemble_member – The ensemble member index.
- scaled – True to return the scaled variable.
Returns: MX
symbol representing the control input at the given time.Raises: KeyError
-
delayed_feedback
() → List[Tuple[str, str, float]]¶ Returns the delayed feedback mappings. These are given as a list of triples \((x, y, \tau)\), to indicate that \(y = x(t - \tau)\).
Returns: A list of triples. Example:
def delayed_feedback(self): fb1 = ['x', 'y', 0.1] fb2 = ['x', 'z', 0.2] return [fb1, fb2]
-
der
(variable: str) → casadi.casadi.MX¶ Returns an
MX
symbol for the time derivative given state, not bound to any time.Parameters: variable – Variable name. Returns: MX
symbol for given state.Raises: KeyError
-
der_at
(variable: str, t: float, ensemble_member: int = 0) → casadi.casadi.MX¶ Returns an expression for the time derivative of the specified variable at time t.
Parameters: - variable – Variable name.
- t – Time.
- ensemble_member – The ensemble member index.
Returns: MX
object representing the derivative.Raises: KeyError
-
ensemble_member_probability
(ensemble_member: int) → float¶ The probability of an ensemble member occurring.
Parameters: ensemble_member – The ensemble member index. Returns: The probability of an ensemble member occurring. Raises: IndexError
-
ensemble_size
¶ The number of ensemble members.
-
get_timeseries
(variable: str, ensemble_member: int = 0) → rtctools.optimization.timeseries.Timeseries¶ Looks up a timeseries from the internal data store.
Parameters: - variable – Variable name.
- ensemble_member – The ensemble member index.
Returns: The requested time series.
Return type: Raises: KeyError
-
history
(ensemble_member: int) → rtctools._internal.alias_tools.AliasDict¶ Returns the state history. Uses the initial_state() method by default.
Parameters: ensemble_member – The ensemble member index. Returns: A dictionary of variable names and historical time series (up to and including t0).
-
initial_state
(ensemble_member: int) → rtctools._internal.alias_tools.AliasDict¶ The initial state.
The default implementation uses t0 data returned by the
history
method.Parameters: ensemble_member – The ensemble member index. Returns: A dictionary of variable names and initial state (t0) values.
-
initial_time
¶ The initial time in seconds.
-
integral
(variable: str, t0: float = None, tf: float = None, ensemble_member: int = 0) → casadi.casadi.MX¶ Returns an expression for the integral over the interval [t0, tf].
Parameters: - variable – Variable name.
- t0 – Left bound of interval. If equal to None, the initial time is used.
- tf – Right bound of interval. If equal to None, the final time is used.
- ensemble_member – The ensemble member index.
Returns: MX
object representing the integral.Raises: KeyError
-
interpolate
(t: Union[float, numpy.ndarray], ts: numpy.ndarray, fs: numpy.ndarray, f_left: float = nan, f_right: float = nan, mode: int = 0) → Union[float, numpy.ndarray]¶ Linear interpolation over time.
Parameters: - t (float or vector of floats) – Time at which to evaluate the interpolant.
- ts (numpy array) – Time stamps.
- fs – Function values at time stamps ts.
- f_left – Function value left of leftmost time stamp.
- f_right – Function value right of rightmost time stamp.
- mode – Interpolation mode.
Returns: The interpolated value.
-
lookup_tables
(ensemble_member: int) → rtctools._internal.alias_tools.AliasDict¶ Returns a dictionary of lookup tables.
Parameters: ensemble_member – The ensemble member index. Returns: A dictionary of variable names and lookup tables.
-
objective
(ensemble_member: int) → casadi.casadi.MX¶ The objective function for the given ensemble member.
Call
OptimizationProblem.state_at()
to return a symbol representing a model variable at a given time.Parameters: ensemble_member – The ensemble member index. Returns: An MX
object representing the objective function.Example:
def objective(self, ensemble_member): # Return value of state 'x' at final time: times = self.times() return self.state_at('x', times[-1], ensemble_member)
-
optimize
(preprocessing: bool = True, postprocessing: bool = True, log_solver_failure_as_error: bool = True) → bool¶ Perform one initialize-transcribe-solve-finalize cycle.
Parameters: - preprocessing – True to enable a call to
pre
preceding the optimization. - postprocessing – True to enable a call to
post
following the optimization.
Returns: True on success.
- preprocessing – True to enable a call to
-
parameters
(ensemble_member: int) → rtctools._internal.alias_tools.AliasDict¶ Returns a dictionary of parameters.
Parameters: ensemble_member – The ensemble member index. Returns: A dictionary of parameter names and values.
-
path_constraints
(ensemble_member: int) → List[Tuple[casadi.casadi.MX, Union[float, numpy.ndarray], Union[float, numpy.ndarray]]]¶ Returns a list of path constraints.
Path constraints apply to all times and ensemble members simultaneously. Call
OptimizationProblem.state()
to return a time- and ensemble-member-independent symbol representing a model variable.Parameters: ensemble_member – The ensemble member index. This index may only be used to supply member-dependent bounds. Returns: A list of triples (f, m, M)
, with anMX
object representing the path constraint functionf
, lower boundm
, and upper boundM
. The bounds may be numbers orTimeseries
objects.Example:
def path_constraints(self, ensemble_member): # 2 * x must lie between 2 and 4 for every time instance. path_constraint1 = (2 * self.state('x'), 2.0, 4.0) # x + y must lie between 2 and 3 for every time instance path_constraint2 = (self.state('x') + self.state('y'), 2.0, 3.0) return [path_constraint1, path_constraint2]
-
path_objective
(ensemble_member: int) → casadi.casadi.MX¶ Returns a path objective the given ensemble member.
Path objectives apply to all times and ensemble members simultaneously. Call
OptimizationProblem.state()
to return a time- and ensemble-member-independent symbol representing a model variable.Parameters: ensemble_member – The ensemble member index. This index is currently unused, and here for future use only. Returns: A MX
object representing the path objective.Example:
def path_objective(self, ensemble_member): # Minimize x(t) for all t return self.state('x')
-
post
() → None¶ Postprocessing logic is performed here.
-
pre
() → None¶ Preprocessing logic is performed here.
-
seed
(ensemble_member: int) → rtctools._internal.alias_tools.AliasDict¶ Seeding data. The optimization algorithm is seeded with the data returned by this method.
Parameters: ensemble_member – The ensemble member index. Returns: A dictionary of variable names and seed time series.
-
set_timeseries
(variable: str, timeseries: rtctools.optimization.timeseries.Timeseries, ensemble_member: int = 0, output: bool = True, check_consistency: bool = True) → None¶ Sets a timeseries in the internal data store.
Parameters: - variable – Variable name.
- timeseries (iterable of floats, or
Timeseries
) – Time series data. - ensemble_member – The ensemble member index.
- output – Whether to include this time series in output data files.
- check_consistency – Whether to check consistency between the time stamps on the new timeseries object and any existing time stamps.
-
solver_options
() → Dict[str, Union[str, int, float]]¶ Returns a dictionary of CasADi optimization problem solver options.
The default solver for continuous problems is Ipopt. The default solver for mixed integer problems is Bonmin.
Returns: A dictionary of solver options. See the CasADi and respective solver documentation for details.
-
solver_success
(solver_stats: Dict[str, Union[str, bool]], log_solver_failure_as_error: bool) → Tuple[bool, int]¶ Translates the returned solver statistics into a boolean and log level to indicate whether the solve was succesful, and how to log it.
Parameters: - solver_stats – Dictionary containing information about the solver status. See explanation below.
- log_solver_failure_as_error – Indicates whether a solve failure Should be logged as an error or info message.
solver_stats
typically consist of three fields:- return_status:
str
- secondary_return_status:
str
- success:
bool
By default we rely on CasADi’s interpretation of the return_status (and secondary status) to the success variable, with an exception for IPOPT (see below).
The logging level is typically
logging.INFO
for success, andlogging.ERROR
for failure. Only for IPOPT an exception is made for Not_Enough_Degrees_Of_Freedom, which returnslogging.WARNING
instead. For example, this can happen when too many goals are specified, and lower priority goals cannot improve further on the current result.Returns: A tuple indicating whether or not the solver has succeeded, and what level to log it with.
-
state
(variable: str) → casadi.casadi.MX¶ Returns an
MX
symbol for the given state, not bound to any time.Parameters: variable – Variable name. Returns: MX
symbol for given state.Raises: KeyError
-
state_at
(variable: str, t: float, ensemble_member: int = 0, scaled: bool = False) → casadi.casadi.MX¶ Returns an
MX
symbol representing the given variable at the given time.Parameters: - variable – Variable name.
- t – Time.
- ensemble_member – The ensemble member index.
- scaled – True to return the scaled variable.
Returns: MX
symbol representing the state at the given time.Raises: KeyError
-
states_in
(variable: str, t0: float = None, tf: float = None, ensemble_member: int = 0) → Iterator[casadi.casadi.MX]¶ Iterates over symbols for states in the interval [t0, tf].
Parameters: - variable – Variable name.
- t0 – Left bound of interval. If equal to None, the initial time is used.
- tf – Right bound of interval. If equal to None, the final time is used.
- ensemble_member – The ensemble member index.
Raises: KeyError
-
timeseries_at
(variable: str, t: float, ensemble_member: int = 0) → float¶ Return the value of a time series at the given time.
Parameters: - variable – Variable name.
- t – Time.
- ensemble_member – The ensemble member index.
Returns: The interpolated value of the time series.
Raises: KeyError
-
-
rtctools.util.
run_optimization_problem
(optimization_problem_class, base_folder='..', log_level=20, profile=False)¶ Sets up and solves an optimization problem.
This function makes the following assumptions:
- That the
base_folder
contains subfoldersinput
,output
, andmodel
, containing input data, output data, and the model, respectively. - When using
CSVLookupTableMixin
, that the base folder contains a subfolderlookup_tables
. - When using
ModelicaMixin
, that the base folder contains a subfoldermodel
. - When using
ModelicaMixin
, that the toplevel Modelica model name equals the class name.
Parameters: - optimization_problem_class – Optimization problem class to solve.
- base_folder – Base folder.
- log_level – The log level to use.
- profile – Whether or not to enable profiling.
Returns: OptimizationProblem
instance.- That the
Time discretization¶
-
class
rtctools.optimization.collocated_integrated_optimization_problem.
CollocatedIntegratedOptimizationProblem
(**kwargs)¶ Bases:
rtctools.optimization.optimization_problem.OptimizationProblem
Discretizes your model using a mixed collocation/integration scheme.
Collocation means that the discretized model equations are included as constraints between state variables in the optimization problem.
Note
To ensure that your optimization problem only has globally optimal solutions, any model equations that are collocated must be linear. By default, all model equations are collocated, and linearity of the model equations is verified. Working with non-linear models is possible, but discouraged.
Variables: check_collocation_linearity – If True
, check whether collocation constraints are linear. Default isTrue
.-
integrated_states
¶ A list of states that are integrated rather than collocated.
Warning
This is an experimental feature.
-
integrator_options
()¶ Configures the implicit function used for time step integration.
Returns: A dictionary of CasADi rootfinder
options. See the CasADi documentation for details.
-
interpolation_method
(variable=None)¶ Interpolation method for variable.
Parameters: variable – Variable name. Returns: Interpolation method for the given variable.
-
theta
¶ RTC-Tools discretizes differential equations of the form
\[\dot{x} = f(x, u)\]using the \(\theta\)-method
\[x_{i+1} = x_i + \Delta t \left[\theta f(x_{i+1}, u_{i+1}) + (1 - \theta) f(x_i, u_i)\right]\]The default is \(\theta = 1\), resulting in the implicit or backward Euler method. Note that in this case, the control input at the initial time step is not used.
Set \(\theta = 0\) to use the explicit or forward Euler method. Note that in this case, the control input at the final time step is not used.
Warning
This is an experimental feature for \(0 < \theta < 1\).
-
times
(variable=None)¶ List of time stamps for variable.
Parameters: variable – Variable name. Returns: A list of time stamps for the given variable.
-
Modelica models¶
To learn the basics of modelling with Modelica, please refer to the online book Modelica by Example.
-
class
rtctools.optimization.modelica_mixin.
ModelicaMixin
(**kwargs)¶ Bases:
rtctools.optimization.optimization_problem.OptimizationProblem
Adds a Modelica model to your optimization problem.
During preprocessing, the Modelica files located inside the
model
subfolder are loaded.Variables: modelica_library_folders – Folders in which any referenced Modelica libraries are to be found. Default is an empty list.
CSV I/O¶
-
class
rtctools.optimization.csv_mixin.
CSVMixin
(**kwargs)¶ Bases:
rtctools.optimization.optimization_problem.OptimizationProblem
Adds reading and writing of CSV timeseries and parameters to your optimization problem.
During preprocessing, files named
timeseries_import.csv
,initial_state.csv
, andparameters.csv
are read from theinput
subfolder.During postprocessing, a file named
timeseries_export.csv
is written to theoutput
subfolder.In ensemble mode, a file named
ensemble.csv
is read from theinput
folder. This file contains two columns. The first column gives the name of the ensemble member, and the second column its probability. Furthermore, the other XML files appear one level deeper inside the filesystem hierarchy, inside subfolders with the names of the ensemble members.Variables: - csv_delimiter – Column delimiter used in CSV files. Default is
,
. - csv_equidistant – Whether or not the timeseries data is equidistant. Default is
True
. - csv_ensemble_mode – Whether or not to use ensembles. Default is
False
. - csv_validate_timeseries – Check consistency of timeseries. Default is
True
.
-
max_timeseries_id
(variable: str) → str¶ Returns the name of the upper bound timeseries for the specified variable.
Parameters: variable – Variable name.
-
min_timeseries_id
(variable: str) → str¶ Returns the name of the lower bound timeseries for the specified variable.
Parameters: variable – Variable name.
- csv_delimiter – Column delimiter used in CSV files. Default is
Delft-FEWS I/O¶
-
class
rtctools.optimization.pi_mixin.
PIMixin
(**kwargs)¶ Bases:
rtctools.optimization.optimization_problem.OptimizationProblem
Adds Delft-FEWS Published Interface I/O to your optimization problem.
During preprocessing, files named
rtcDataConfig.xml
,timeseries_import.xml
,rtcParameterConfig.xml
, andrtcParameterConfig_Numerical.xml
are read from theinput
subfolder.rtcDataConfig.xml
maps tuples of FEWS identifiers, including location and parameter ID, to RTC-Tools time series identifiers.During postprocessing, a file named
timeseries_export.xml
is written to theoutput
subfolder.Variables: - pi_binary_timeseries – Whether to use PI binary timeseries format. Default is
False
. - pi_parameter_config_basenames – List of parameter config file basenames to read. Default is [
rtcParameterConfig
]. - pi_parameter_config_numerical_basename – Numerical config file basename to read. Default is
rtcParameterConfig_Numerical
. - pi_check_for_duplicate_parameters – Check if duplicate parameters are read. Default is
True
. - pi_validate_timeseries – Check consistency of timeseries. Default is
True
.
-
max_timeseries_id
(variable: str) → str¶ Returns the name of the upper bound timeseries for the specified variable.
Parameters: variable – Variable name
-
min_timeseries_id
(variable: str) → str¶ Returns the name of the lower bound timeseries for the specified variable.
Parameters: variable – Variable name
-
timeseries_export
¶ pi.Timeseries
object for holding the output data.
-
timeseries_import
¶ pi.Timeseries
object containing the input data.
-
timeseries_import_times
¶ List of time stamps for which input data is specified.
The time stamps are in seconds since t0, and may be negative.
- pi_binary_timeseries – Whether to use PI binary timeseries format. Default is
Bookkeeping of linearization parameters¶
-
class
rtctools.optimization.linearization_mixin.
LinearizationMixin
(**kwargs)¶ Bases:
rtctools.optimization.optimization_problem.OptimizationProblem
Adds linearized equation parameter bookkeeping to your optimization aproblem.
If your model contains linearized equations, this mixin will set the parameters of these equations based on the t0 value of an associated timeseries.
The mapping between linearization parameters and time series is provided in the
linearization_parameters
method.-
linearization_parameters
() → Dict[str, str]¶ Returns: A dictionary of parameter names mapping to time series identifiers.
-
Lookup tables¶
-
class
rtctools.optimization.csv_lookup_table_mixin.
LookupTable
(inputs: List[casadi.casadi.MX], function: casadi.casadi.Function, tck: Tuple = None)¶ Bases:
object
Lookup table.
-
__call__
(*args) → Union[float, rtctools.optimization.timeseries.Timeseries]¶ Evaluate the lookup table.
Parameters: args (Float, iterable of floats, or Timeseries
) – Input values.Returns: Lookup table evaluated at input values. Example use:
y = lookup_table(1.0) [y1, y2] = lookup_table([1.0, 2.0])
-
-
class
rtctools.optimization.csv_lookup_table_mixin.
CSVLookupTableMixin
(**kwargs)¶ Bases:
rtctools.optimization.optimization_problem.OptimizationProblem
Adds lookup tables to your optimization problem.
During preprocessing, the CSV files located inside the
lookup_tables
subfolder are read. In every CSV file, the first column contains the output of the lookup table. Subsequent columns contain the input variables.Cubic B-Splines are used to turn the data points into continuous lookup tables.
Optionally, a file
curvefit_options.ini
may be included inside thelookup_tables
folder. This file contains, grouped per lookup table, the following options:- monotonicity:
- is an integer, magnitude is ignored
- if positive, causes spline to be monotonically increasing
- if negative, causes spline to be monotonically decreasing
- if 0, leaves spline monotonicity unconstrained
- curvature:
- is an integer, magnitude is ignored
- if positive, causes spline curvature to be positive (convex)
- if negative, causes spline curvature to be negative (concave)
- if 0, leaves spline curvature unconstrained
Note
Currently only one-dimensional lookup tables are fully supported. Support for two- dimensional lookup tables is experimental.
Variables: - csv_delimiter – Column delimiter used in CSV files. Default is
,
. - csv_lookup_table_debug – Whether to generate plots of the spline fits. Default is
false
. - csv_lookup_table_debug_points – Number of evaluation points for plots. Default is
100
.
-
lookup_tables
(ensemble_member)¶ Returns a dictionary of lookup tables.
Parameters: ensemble_member – The ensemble member index. Returns: A dictionary of variable names and lookup tables.
Treatment of nonconvexities using homotopy¶
Using homotopy, a convex optimization problem can be continuously deformed into a non-convex problem.
-
class
rtctools.optimization.homotopy_mixin.
HomotopyMixin
(**kwargs)¶ Bases:
rtctools.optimization.optimization_problem.OptimizationProblem
Adds homotopy to your optimization problem. A homotopy is a continuous transformation between two optimization problems, parametrized by a single parameter \(\theta \in [0, 1]\).
Homotopy may be used to solve non-convex optimization problems, by starting with a convex approximation at \(\theta = 0.0\) and ending with the non-convex problem at \(\theta = 1.0\).
Note
It is advised to look for convex reformulations of your problem, before resorting to a use of the (potentially expensive) homotopy process.
-
homotopy_options
() → Dict[str, Union[str, float]]¶ Returns a dictionary of options controlling the homotopy process.
Option Type Default value delta_theta_0
float
1.0
delta_theta_min
float
0.01
homotopy_parameter
string
theta
The homotopy process is controlled by the homotopy parameter in the model, specified by the option
homotopy_parameter
. The homotopy parameter is initialized to0.0
, and increases to a value of1.0
with a dynamically changing step size. This step size is initialized with the value of the optiondelta_theta_0
. If this step size is too large, i.e., if the problem with the increased homotopy parameter fails to converge, the step size is halved. The process of halving terminates when the step size falls below the minimum value specified by the optiondelta_theta_min
.Returns: A dictionary of homotopy options.
-
Initial state estimation¶
-
class
rtctools.optimization.initial_state_estimation_mixin.
InitialStateEstimationMixin
(**kwargs)¶ Bases:
rtctools.optimization.goal_programming_mixin.GoalProgrammingMixin
Adds initial state estimation to your optimization problem using goal programming.
Before any other goals are evaluated, first, the deviation between initial state measurements and their respective model states is minimized in the least squares sense (1DVAR, priority -2). Secondly, the distance between pairs of states is minimized, again in the least squares sense, so that “smooth” initial guesses are provided for states without measurements (priority -1).
Note
There are types of problems where, in addition to minimizing differences between states and measurements, it is advisable to perform a steady-state initialization using additional initial-time model equations. For hydraulic models, for instance, it is often helpful to require that the time-derivative of the flow variables vanishes at the initial time.
-
initial_state_measurements
() → List[Union[Tuple[str, str], Tuple[str, str, float]]]¶ List of pairs
(state, measurement_id)
or triples(state, measurement_id, max_deviation)
, relating states to measurement time series IDs.The default maximum deviation is
1.0
.
-
initial_state_smoothing_pairs
() → List[Union[Tuple[str, str], Tuple[str, str, float]]]¶ List of pairs
(state1, state2)
or triples(state1, state2, max_deviation)
, relating states the distance of which is to be minimized.The default maximum deviation is
1.0
.
-
Multi-objective optimization¶
-
class
rtctools.optimization.goal_programming_mixin.
Goal
¶ Bases:
object
Base class for lexicographic goal programming goals.
A goal is defined by overriding the
function()
method.Variables: - function_range – Range of goal function. Required if a target is set.
- function_nominal – Nominal value of function. Used for scaling. Default is
1
. - target_min – Desired lower bound for goal function. Default is
numpy.nan
. - target_max – Desired upper bound for goal function. Default is
numpy.nan
. - priority – Integer priority of goal. Default is
1
. - weight – Optional weighting applied to the goal. Default is
1.0
. - order – Penalization order of goal violation. Default is
2
. - critical – If
True
, the algorithm will abort if this goal cannot be fully met. Default isFalse
. - relaxation – Amount of slack added to the hard constraints related to the goal.
Must be a nonnegative value. Default is
0.0
.
The target bounds indicate the range within the function should stay, if possible. Goals are, in that sense, soft, as opposed to standard hard constraints.
Four types of goals can be created:
Minimization goal if no target bounds are set:
\[\min f\]Lower bound goal if
target_min
is set:\[m \leq f\]Upper bound goal if
target_max
is set:\[f \leq M\]Combined lower and upper bound goal if
target_min
andtarget_max
are both set:\[m \leq f \leq M\]
Lower priority goals take precedence over higher priority goals.
Goals with the same priority are weighted off against each other in a single objective function.
- In goals where a target is set:
- The function range interval must be provided as this is used to introduce hard constrains on the value that the function can take. If one is unsure about which value the function can take, it is recommended to overestimate this interval. However, an overestimated interval will negatively influence how accurately the target bounds are met.
- The target provided must be contained in the function range.
- The function nominal is used to scale the constraints.
- If both a target_min and a target_max are set, the target maximum must be at least equal to minimum one.
- In minimization goals:
- The function range is not used and therefore cannot be set.
- The function nominal is used to scale the function value in the objective function. To ensure that all goals are given a similar importance, it is crucial to provide an accurate estimate of this parameter.
The goal violation value is taken to the order’th power in the objective function of the final optimization problem.
Relaxation is used to loosen the constraints that are set after the optimization of the goal’s priority. The unit of the relaxation is equal to that of the goal function.
Example definition of the point goal \(x(t) \geq 1.1\) for \(t=1.0\) at priority 1:
class MyGoal(Goal): def function(self, optimization_problem, ensemble_member): # State 'x' at time t = 1.0 t = 1.0 return optimization_problem.state_at('x', t, ensemble_member) function_range = (1.0, 2.0) target_min = 1.1 priority = 1
Example definition of the path goal \(x(t) \geq 1.1\) for all \(t\) at priority 2:
class MyPathGoal(Goal): def function(self, optimization_problem, ensemble_member): # State 'x' at any point in time return optimization_problem.state('x') function_range = (1.0, 2.0) target_min = 1.1 priority = 2
Note that for path goals, the ensemble member index is not passed to the call to
OptimizationProblem.state()
. This call returns a time-independent symbol that is also independent of the active ensemble member. Path goals are applied to all times and all ensemble members simultaneously.-
function
(optimization_problem: rtctools.optimization.optimization_problem.OptimizationProblem, ensemble_member: int) → casadi.casadi.MX¶ This method returns a CasADi
MX
object describing the goal function.Returns: A CasADi MX
object.
-
get_function_key
(optimization_problem: rtctools.optimization.optimization_problem.OptimizationProblem, ensemble_member: int) → str¶ Returns a key string uniquely identifying the goal function. This is used to eliminate linearly dependent constraints from the optimization problem.
-
has_target_bounds
¶ True
if the user goal has min/max bounds.
-
has_target_max
¶ True
if the user goal has max bounds.
-
has_target_min
¶ True
if the user goal has min bounds.
-
class
rtctools.optimization.goal_programming_mixin.
StateGoal
(optimization_problem)¶ Bases:
rtctools.optimization.goal_programming_mixin.Goal
Base class for lexicographic goal programming path goals that act on a single model state.
A state goal is defined by setting at least the
state
class variable.Variables: - state – State on which the goal acts. Required.
- target_min – Desired lower bound for goal function. Default is
numpy.nan
. - target_max – Desired upper bound for goal function. Default is
numpy.nan
. - priority – Integer priority of goal. Default is
1
. - weight – Optional weighting applied to the goal. Default is
1.0
. - order – Penalization order of goal violation. Default is
2
. - critical – If
True
, the algorithm will abort if this goal cannot be fully met. Default isFalse
.
Example definition of the goal \(x(t) \geq 1.1\) for all \(t\) at priority 2:
class MyStateGoal(StateGoal): state = 'x' target_min = 1.1 priority = 2
Contrary to ordinary
Goal
objects,PathGoal
objects need to be initialized with anOptimizationProblem
instance to allow extraction of state metadata, such as bounds and nominal values. Consequently, state goals must be instantiated as follows:my_state_goal = MyStateGoal(optimization_problem)
Note that
StateGoal
is a helper class. State goals can also be defined usingGoal
as direct base class, by implementing thefunction
method and providing thefunction_range
andfunction_nominal
class variables manually.-
__init__
(optimization_problem)¶ Initialize the state goal object.
Parameters: optimization_problem – OptimizationProblem
instance.
-
class
rtctools.optimization.goal_programming_mixin.
GoalProgrammingMixin
(**kwargs)¶ Bases:
rtctools.optimization.optimization_problem.OptimizationProblem
Adds lexicographic goal programming to your optimization problem.
-
goal_programming_options
() → Dict[str, Union[float, bool]]¶ Returns a dictionary of options controlling the goal programming process.
Option Type Default value violation_relaxation
float
0.0
constraint_relaxation
float
0.0
mu_reinit
bool
True
fix_minimized_values
bool
True/False
check_monotonicity
bool
True
equality_threshold
float
1e-8
interior_distance
float
1e-6
scale_by_problem_size
bool
False
keep_soft_constraints
bool
False
Before turning a soft constraint of the goal programming algorithm into a hard constraint, the violation variable (also known as epsilon) of each goal is relaxed with the
violation_relaxation
. Use of this option is normally not required.When turning a soft constraint of the goal programming algorithm into a hard constraint, the constraint is relaxed with
constraint_relaxation
. Use of this option is normally not required. Note that:- Minimization goals do not get
constraint_relaxation
applied whenfix_minimized_values
is True. - Because of the constraints it generates, when
keep_soft_constraints
is True, the optionfix_minimized_values
needs to be set to False for theconstraint_relaxation
to be applied at all.
A goal is considered to be violated if the violation, scaled between 0 and 1, is greater than the specified tolerance. Violated goals are fixed. Use of this option is normally not required.
When using the default solver (IPOPT), its barrier parameter
mu
is normally re-initialized a every iteration of the goal programming algorithm, unless mu_reinit is set toFalse
. Use of this option is normally not required.If
fix_minimized_values
is set toTrue
, goal functions will be set to equal their optimized values in optimization problems generated during subsequent priorities. Otherwise, only an upper bound will be set. Use of this option is normally not required. Note that a non-zero goal relaxation overrules this option; a non-zero relaxation will always result in only an upper bound being set. Also note that the use of this option may add non-convex constraints to the optimization problem. The default value for this parameter isTrue
for the default solvers IPOPT/BONMIN. If any other solver is used, the default value isFalse
.If
check_monotonicity
is set toTrue
, then it will be checked whether goals with the same function key form a monotonically decreasing sequence with regards to the target interval.The option
equality_threshold
controls when a two-sided inequality constraint is folded into an equality constraint.The option
interior_distance
controls the distance from the scaled target bounds, starting from which the function value is considered to lie in the interior of the target space.If
scale_by_problem_size
is set toTrue
, the objective (i.e. the sum of the violation variables) will be divided by the number of goals, and the path objective will be divided by the number of path goals and the number of time steps. This will make sure the objectives are always in the range [0, 1], at the cost of solving each goal/time step less accurately.The option
keep_soft_constraints
controls how the epsilon variables introduced in the target goals are dealt with in subsequent priorities. Ifkeep_soft_constraints
is set to False, each epsilon is replaced by its computed value and those are used to derive a new set of constraints. Ifkeep_soft_constraints
is set to True, the epsilons are kept as variables and the constraints are not modified. To ensure the goal programming philosophy, i.e., Pareto optimality, a single constraint is added to enforce that the objective function must always be at most the objective value. This method allows for a larger solution space, at the cost of having a (possibly) more complex optimization problem. Indeed, more variables are kept around throughout the optimization and any objective function is turned into a constraint for the subsequent priorities (while in the False option this was the case only for the function of minimization goals).Returns: A dictionary of goal programming options. - Minimization goals do not get
-
goals
() → List[rtctools.optimization.goal_programming_mixin.Goal]¶ User problem returns list of
Goal
objects.Returns: A list of goals.
-
path_goals
() → List[rtctools.optimization.goal_programming_mixin.Goal]¶ User problem returns list of path
Goal
objects.Returns: A list of path goals.
-
priority_completed
(priority: int) → None¶ Called after optimization for goals of certain priority is completed.
Parameters: priority – The priority level that was completed.
-
priority_started
(priority: int) → None¶ Called when optimization for goals of certain priority is started.
Parameters: priority – The priority level that was started.
-
Forecast uncertainty¶
-
class
rtctools.optimization.control_tree_mixin.
ControlTreeMixin
(**kwargs)¶ Bases:
rtctools.optimization.optimization_problem.OptimizationProblem
Adds a stochastic control tree to your optimization problem.
-
control_tree_options
() → Dict[str, Union[List[str], List[float], int]]¶ Returns a dictionary of options controlling the creation of a k-ary stochastic tree.
Option Type Default value forecast_variables
list
of stringsAll constant inputs branching_times
list
of floatsself.times()
k
int
2
A
k
-ary tree is generated, branching at every interior branching time. Ensemble members are clustered to paths through the tree based on average distance over all forecast variables.Returns: A dictionary of control tree generation options.
-
Simulation¶
Note
For a simulation example, see Simulation examples
Contents:
Basics¶
-
class
rtctools.simulation.simulation_problem.
SimulationProblem
(**kwargs)¶ Bases:
object
Implements the BMI Interface.
Base class for all Simulation problems. Loads the Modelica Model.
Variables: modelica_library_folders – Folders containing any referenced Modelica libraries. Default is an empty list. -
get_current_time
()¶ Return current time of simulation.
Returns: The current simulation time.
-
get_end_time
()¶ Return end time of experiment.
Returns: The end time of the experiment.
-
get_start_time
()¶ Return start time of experiment.
Returns: The start time of the experiment.
-
get_var
(name)¶ Return a numpy array from FMU.
Parameters: name – Variable name. Returns: The value of the variable.
-
get_var_count
()¶ Return the number of variables in the model.
Returns: The number of variables in the model.
-
get_var_name
(i)¶ Returns the name of a variable.
Parameters: i – Index in ordered dictionary returned by method get_variables. Returns: The name of the variable.
-
get_var_rank
(name)¶ Not implemented
-
get_var_shape
(name)¶ Not implemented
-
get_var_type
(name)¶ Return type, compatible with numpy.
Parameters: name – String variable name. Returns: The numpy-compatible type of the variable. Raises: KeyError
-
get_variables
()¶ Return all variables (both internal and user defined)
Returns: An ordered dictionary of all variables supported by the model.
-
initialize
(config_file=None)¶ Initialize state vector with default values
Parameters: config_file – Path to an initialization file.
-
post
()¶ Any postprocessing takes place here.
-
pre
()¶ Any preprocessing takes place here.
-
reset
()¶ Reset the FMU.
-
setup_experiment
(start, stop, dt)¶ Method for subclasses (PIMixin, CSVMixin, or user classes) to set timing information for a simulation run.
Parameters: - start – Start time for the simulation.
- stop – Final time for the simulation.
- dt – Time step size.
-
simulate
()¶ Run model from start_time to end_time.
-
update
(dt)¶ Performs one timestep.
The methods
setup_experiment
andinitialize
must have been called before.Parameters: dt – Time step size.
-
-
rtctools.util.
run_simulation_problem
(simulation_problem_class, base_folder='..', log_level=20)¶ Sets up and runs a simulation problem.
Parameters: - simulation_problem_class – Optimization problem class to solve.
- base_folder – Folder within which subfolders “input”, “output”, and “model” exist, containing input and output data, and the model, respectively.
- log_level – The log level to use.
Returns: SimulationProblem
instance.
CSV I/O¶
-
class
rtctools.simulation.csv_mixin.
CSVMixin
(**kwargs)¶ Bases:
rtctools.simulation.simulation_problem.SimulationProblem
Adds reading and writing of CSV timeseries and parameters to your simulation problem.
During preprocessing, files named
timeseries_import.csv
,initial_state.csv
, andparameters.csv
are read from theinput
subfolder.During postprocessing, a file named
timeseries_export.csv
is written to theoutput
subfolder.Variables: - csv_delimiter – Column delimiter used in CSV files. Default is
,
. - csv_validate_timeseries – Check consistency of timeseries. Default is
True
.
-
timeseries_at
(variable, t)¶ Return the value of a timeseries at the given time.
Parameters: - variable – Variable name.
- t – Time.
Returns: The interpolated value of the time series.
Raises: KeyError
- csv_delimiter – Column delimiter used in CSV files. Default is
Delft-FEWS I/O¶
-
class
rtctools.simulation.pi_mixin.
PIMixin
(**kwargs)¶ Bases:
rtctools.simulation.simulation_problem.SimulationProblem
Adds Delft-FEWS Published Interface I/O to your simulation problem.
During preprocessing, files named
rtcDataConfig.xml
,timeseries_import.xml
, and``rtcParameterConfig.xml`` are read from theinput
subfolder.rtcDataConfig.xml
maps tuples of FEWS identifiers, including location and parameter ID, to RTC-Tools time series identifiers.During postprocessing, a file named
timeseries_export.xml
is written to theoutput
subfolder.Variables: - pi_binary_timeseries – Whether to use PI binary timeseries format. Default is
False
. - pi_parameter_config_basenames – List of parameter config file basenames to read. Default is [
rtcParameterConfig
]. - pi_check_for_duplicate_parameters – Check if duplicate parameters are read. Default is
True
. - pi_validate_timeseries – Check consistency of timeseries. Default is
True
.
-
timeseries_at
(variable, t)¶ Return the value of a time series at the given time.
Parameters: - variable – Variable name.
- t – Time.
Returns: The interpolated value of the time series.
Raises: KeyError
- pi_binary_timeseries – Whether to use PI binary timeseries format. Default is
Examples¶
This section provides examples demonstrating key features of RTC-Tools.
Optimization examples¶
This section provides examples demonstrating key features of RTC-Tools optimization.
Filling a Reservoir¶

Overview¶
The purpose of this example is to understand the technical setup of an RTC-Tools model, how to run the model, and how to interpret the results.
The scenario is the following: A reservoir operator is trying to fill a reservoir. They are given a six-day forecast of inflows given in 12-hour increments. The operator wants to save as much of the inflows as possible, but does not want to end up with too much water at the end of the six days. They have chosen to use RTC-Tools to calculate how much water to release and when to release it.
If you installed using source, the library and examples directory are available in the git repositories. If you installed using pip directly, you first need to download/copy the examples and libraries to a convenient location. See Downloading and running examples and Copying Modelica libraries for detailed instructions.
The folder <examples directory>\basic
contains a complete RTC-Tools
optimization problem. An RTC-Tools directory has the following structure:
input
: This folder contains the model input data. These are several files in comma separated value format,csv
.model
: This folder contains the Modelica model. The Modelica model contains the physics of the RTC-Tools model.output
: The folder where the output is saved in the filetimeseries_export.csv
.src
: This folder contains a Python file. This file contains the configuration of the model and is used to run the model .
The Model¶
The first step is to develop a physical model of the system. The model can be viewed and edited using the OpenModelica Connection Editor (OMEdit) program. For how to download and start up OMEdit, see Getting OMEdit.
- Load the Deltares library into OMEdit
- Using the menu bar: File -> Open Model/Library File(s)
- Select
<library directory>\Deltares\package.mo
- Load the example model into OMEdit
- Using the menu bar: File -> Open Model/Library File(s)
- Select
<examples directory>\basic\model\Example.mo
Once loaded, we have an OpenModelica Connection Editor window that looks like this:

The model Example.mo
represents a simple system with the following
elements:
- a reservoir, modeled as storage element
Deltares.ChannelFlow.SimpleRouting.Storage.Storage
, - an inflow boundary condition
Deltares.ChannelFlow.SimpleRouting.BoundaryConditions.Inflow
, - an outfall boundary condition
Deltares.ChannelFlow.SimpleRouting.BoundaryConditions.Terminal
, - connectors (black lines) connecting the elements.
You can use the mouse-over feature help to identify the predefined models from the Deltares library. You can also drag the elements around- the connectors will move with the elements. Adding new elements is easy- just drag them in from the Deltares Library on the sidebar. Connecting the elements is just as easy- click and drag between the ports on the elements.
In text mode, the Modelica model looks as follows (with annotation statements removed):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | model Example
Deltares.ChannelFlow.SimpleRouting.BoundaryConditions.Inflow inflow;
Deltares.ChannelFlow.SimpleRouting.Storage.Storage storage(V(nominal=4e5, min=2e5, max=6e5));
Deltares.ChannelFlow.SimpleRouting.BoundaryConditions.Terminal outfall;
input Modelica.SIunits.VolumeFlowRate Q_in(fixed = true);
input Modelica.SIunits.VolumeFlowRate Q_release(fixed = false, min = 0.0, max = 6.5);
output Modelica.SIunits.Volume V_storage;
equation
connect(inflow.QOut, storage.QIn);
connect(storage.QOut, outfall.QIn);
storage.Q_release = Q_release;
inflow.Q = Q_in;
V_storage = storage.V;
end Example;
|
The three water system elements (storage, inflow, and outfall) appear under
the model Example
statement. The equation
part connects these three
elements with the help of connections. Note that storage
extends the partial
model QSISO
which contains the connectors QIn
and QOut
.
With QSISO
, storage
can be connected on two sides. The storage
element also has a variable Q_release
, which is the decision variable the
operator controls.
OpenModelica Connection Editor will automatically generate the element and
connector entries in the text text file. Defining inputs and outputs requires
editing the text file directly. Relationships between the inputs and outputs and
the library elements must also be defined in the equation
section.
In addition to elements, the input variables Q_in
and Q_release
are also
defined. Q_in
is determined by the forecast and the operator cannot control
it, so we set Q_in(fixed = true)
. The actual values of Q_in
are stored
in timeseries_import.csv
. In the equation
section, equations are defined
to relate the inputs to the appropriate water system elements.
Because we want to view the water volume in the storage element in the output
file, we also define an output variable V_storage
.
The Optimization Problem¶
The python script is created and edited in a text editor. In general, the python script consists of the following blocks:
- Import of packages
- Definition of the optimization problem class
- Constructor
- Objective function
- Definition of constraints
- Any additional configuration
- A run statement
Importing Packages¶
Packages are imported using from ... import ...
at the top of the file. In
our script, we import the classes we want the class to inherit, the
package run_optimization_problem
form the rtctools.util
package, and
any extra packages we want to use. For this example, the import block looks
like:
1 2 3 4 5 | from rtctools.optimization.collocated_integrated_optimization_problem \
import CollocatedIntegratedOptimizationProblem
from rtctools.optimization.csv_mixin import CSVMixin
from rtctools.optimization.modelica_mixin import ModelicaMixin
from rtctools.util import run_optimization_problem
|
Optimization Problem¶
The next step is to define the optimization problem class. We construct the class by declaring the class and inheriting the desired parent classes. The parent classes each perform different tasks related to importing and exporting data and solving the optimization problem. Each imported class makes a set of methods available to the our optimization class.
8 | class Example(CSVMixin, ModelicaMixin, CollocatedIntegratedOptimizationProblem):
|
Next, we define an objective function. This is a class method that returns the value that needs to be minimized.
12 13 14 15 16 | def objective(self, ensemble_member):
# Minimize water pumped. The total water pumped is the integral of the
# water pumped from the starting time until the stoping time. In
# practice, self.integral() is a summation of all the discrete states.
return self.integral('Q_release', ensemble_member)
|
Constraints can be declared by declaring the path_constraints()
method. Path
constraints are constraints that are applied every timestep. To set a constraint
at an individual timestep, we could define it inside the constraints()
method.
Other parent classes also declare this method, so we call the super()
method
so that we don’t overwrite their behaviour.
18 19 20 21 22 23 | def path_constraints(self, ensemble_member):
# Call super() class to not overwrite default behaviour
constraints = super().path_constraints(ensemble_member)
# Constrain the volume of storage between 380000 and 420000 m^3
constraints.append((self.state('storage.V'), 380000, 420000))
return constraints
|
Run the Optimization Problem¶
To make our script run, at the bottom of our file we just have to call
the run_optimization_problem()
method we imported on the optimization
problem class we just created.
27 | run_optimization_problem(Example)
|
The Whole Script¶
All together, the whole example script is as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | from rtctools.optimization.collocated_integrated_optimization_problem \
import CollocatedIntegratedOptimizationProblem
from rtctools.optimization.csv_mixin import CSVMixin
from rtctools.optimization.modelica_mixin import ModelicaMixin
from rtctools.util import run_optimization_problem
class Example(CSVMixin, ModelicaMixin, CollocatedIntegratedOptimizationProblem):
"""
A basic example for introducing users to RTC-Tools 2
"""
def objective(self, ensemble_member):
# Minimize water pumped. The total water pumped is the integral of the
# water pumped from the starting time until the stoping time. In
# practice, self.integral() is a summation of all the discrete states.
return self.integral('Q_release', ensemble_member)
def path_constraints(self, ensemble_member):
# Call super() class to not overwrite default behaviour
constraints = super().path_constraints(ensemble_member)
# Constrain the volume of storage between 380000 and 420000 m^3
constraints.append((self.state('storage.V'), 380000, 420000))
return constraints
# Run
run_optimization_problem(Example)
|
Running RTC-Tools¶
To run this basic example in RTC-Tools, navigate to the basic example src
directory in the RTC-Tools shell and run the example using python
example.py
. For more details about using RTC-Tools, see
Running RTC-Tools.
Extracting Results¶
The results from the run are found in output\timeseries_export.csv
. Any
CSV-reading software can import it, but this is what the results look like when
plotted in Microsoft Excel:

This plot shows that the operator is able to keep the water level within the bounds over the entire time horizon and end with a full reservoir.
Feel free to experiment with this example. See what happens if you change the
max of Q_release
(in the Modelica file) or if you make the objective
function negative (in the python script).
Mixed Integer Optimization: Pumps and Orifices¶

Note
This example focuses on how to incorporate mixed integer components into a hydraulic model, and assumes basic exposure to RTC-Tools. To start with basics, see Filling a Reservoir.
Note
By default, if you define any integer or boolean variables in the model, RTC-Tools
will switch from IPOPT to BONMIN. You can modify solver options by overriding
the
solver_options()
method. Refer to CasADi’s nlpsol interface for a list of supported solvers.
The Model¶
For this example, the model represents a typical setup for the dewatering of lowland areas. Water is routed from the hinterland (modeled as discharge boundary condition, right side) through a canal (modeled as storage element) towards the sea (modeled as water level boundary condition on the left side). Keeping the lowland area dry requires that enough water is discharged to the sea. If the sea water level is lower than the water level in the canal, the water can be discharged to the sea via gradient flow through the orifice (or a weir). If the sea water level is higher than in the canal, water must be pumped.
To discharge water via gradient flow is free, while pumping costs money. The control task is to keep the water level in the canal below a given flood warning level at minimum costs. The expected result is that the model computes a control pattern that makes use of gradient flow whenever possible and activates the pump only when necessary.
The model can be viewed and edited using the OpenModelica Connection Editor
program. First load the Deltares library into OpenModelica Connection Editor,
and then load the example model, located at
<examples directory>\mixed_integer\model\Example.mo
. The model Example.mo
represents a simple water system with the following elements:
- a canal segment, modeled as storage element
Deltares.ChannelFlow.Hydraulic.Storage.Linear
, - a discharge boundary condition
Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Discharge
, - a water level boundary condition
Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Level
, - a pump
Deltares.ChannelFlow.Hydraulic.Structures.Pump
- an orifice modeled as a pump
Deltares.ChannelFlow.Hydraulic.Structures.Pump

In text mode, the Modelica model looks as follows (with annotation statements removed):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | model Example
// Declare Model Elements
Deltares.ChannelFlow.Hydraulic.Storage.Linear storage(A=1.0e6, H_b=0.0, HQ.H(min=0.0, max=0.5));
Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Discharge discharge;
Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Level level;
Deltares.ChannelFlow.Hydraulic.Structures.Pump pump;
Deltares.ChannelFlow.Hydraulic.Structures.Pump orifice;
// Define Input/Output Variables and set them equal to model variables
input Modelica.SIunits.VolumeFlowRate Q_pump(fixed=false, min=0.0, max=7.0) = pump.Q;
input Boolean is_downhill;
input Modelica.SIunits.VolumeFlowRate Q_in(fixed=true) = discharge.Q;
input Modelica.SIunits.Position H_sea(fixed=true) = level.H;
input Modelica.SIunits.VolumeFlowRate Q_orifice(fixed=false, min=0.0, max=10.0) = orifice.Q;
output Modelica.SIunits.Position storage_level = storage.HQ.H;
output Modelica.SIunits.Position sea_level = level.H;
equation
// Connect Model Elements
connect(orifice.HQDown, level.HQ);
connect(storage.HQ, orifice.HQUp);
connect(storage.HQ, pump.HQUp);
connect(discharge.HQ, storage.HQ);
connect(pump.HQDown, level.HQ);
end Example;
|
The five water system elements (storage, discharge boundary condition, water
level boundary condition, pump, and orifice) appear under the model Example
statement. The equation
part connects these five elements with the help of
connections. Note that Pump
extends the partial model HQTwoPort
which
inherits from the connector HQPort
. With HQTwoPort
, Pump
can be
connected on two sides. level
represents a model boundary condition (model
is meant in a hydraulic sense here), so it can be connected to one other
element only. It extends the HQOnePort
which again inherits from the
connector HQPort
.
In addition to elements, the input variables Q_in
, H_sea
, Q_pump
,
and Q_orifice
are also defined. Because we want to view the water levels in
the storage element in the output file, we also define output
variables storage_level
and sea_level
. It is usually easiest to set input
and output variables equal to their corresponding model variable in the same line.
To maintain the linearity of the model, we input the Boolean is_downhill
as
a way to keep track of whether water can flow by gravity to the sea. This
variable is not used directly in the hydraulics, but we use it later in the
constraints in the python file.
The Optimization Problem¶
The python script consists of the following blocks:
- Import of packages
- Definition of the optimization problem class
- Constructor
- Objective function
- Definition of constraints
- Additional configuration of the solver
- A run statement
Importing Packages¶
For this example, the import block is as follows:
1 2 3 4 5 6 | import numpy as np
from rtctools.optimization.collocated_integrated_optimization_problem \
import CollocatedIntegratedOptimizationProblem
from rtctools.optimization.csv_mixin import CSVMixin
from rtctools.optimization.modelica_mixin import ModelicaMixin
|
Note that we are also importing inf
from numpy
. We will use this later
in the constraints.
Optimization Problem¶
Next, we construct the class by declaring it and inheriting the desired parent classes.
10 | class Example(CSVMixin, ModelicaMixin, CollocatedIntegratedOptimizationProblem):
|
Now we define an objective function. This is a class method that returns the value that needs to be minimized. Here we specify that we want to minimize the volume pumped:
18 19 20 21 22 | def objective(self, ensemble_member):
# Minimize water pumped. The total water pumped is the integral of the
# water pumped from the starting time until the stoping time. In
# practice, self.integral() is a summation of all the discrete states.
return self.integral('Q_pump', ensemble_member)
|
Constraints can be declared by declaring the path_constraints()
method.
Path constraints are constraints that are applied every timestep. To set a
constraint at an individual timestep, define it inside the constraints
method.
The orifice BooleanSubmergedOrifice
requires special constraints to be set
in order to work. They are implemented below in the path_constraints()
method. their parent classes also declare this method, so we call the
super()
method so that we don’t overwrite their behaviour.
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 | def path_constraints(self, ensemble_member):
# Call super to get default constraints
constraints = super().path_constraints(ensemble_member)
M = 2 # The so-called "big-M"
# Release through orifice downhill only. This constraint enforces the
# fact that water only flows downhill.
constraints.append(
(self.state('Q_orifice') + (1 - self.state('is_downhill')) * 10,
0.0, 10.0))
# Make sure is_downhill is true only when the sea is lower than the
# water level in the storage.
constraints.append((self.state('H_sea') - self.state('storage.HQ.H') -
(1 - self.state('is_downhill')) * M, -np.inf, 0.0))
constraints.append((self.state('H_sea') - self.state('storage.HQ.H') +
self.state('is_downhill') * M, 0.0, np.inf))
# Orifice flow constraint. Uses the equation:
# Q(HUp, HDown, d) = width * C * d * (2 * g * (HUp - HDown)) ^ 0.5
# Note that this equation is only valid for orifices that are submerged
# units: description:
w = 3.0 # m width of orifice
d = 0.8 # m hight of orifice
C = 1.0 # none orifice constant
g = 9.8 # m/s^2 gravitational acceleration
constraints.append(
(((self.state('Q_orifice') / (w * C * d)) ** 2) / (2 * g) +
self.state('orifice.HQDown.H') - self.state('orifice.HQUp.H') -
M * (1 - self.state('is_downhill')),
-np.inf, 0.0))
return constraints
|
Finally, we want to apply some additional configuration, reducing the amount of information the solver outputs:
61 62 63 64 65 66 | def solver_options(self):
options = super().solver_options()
# Restrict solver output
solver = options['solver']
options[solver]['print_level'] = 1
return options
|
Run the Optimization Problem¶
To make our script run, at the bottom of our file we just have to call
the run_optimization_problem()
method we imported on the optimization
problem class we just created.
70 | run_optimization_problem(Example)
|
The Whole Script¶
All together, the whole example script is as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | import numpy as np
from rtctools.optimization.collocated_integrated_optimization_problem \
import CollocatedIntegratedOptimizationProblem
from rtctools.optimization.csv_mixin import CSVMixin
from rtctools.optimization.modelica_mixin import ModelicaMixin
from rtctools.util import run_optimization_problem
class Example(CSVMixin, ModelicaMixin, CollocatedIntegratedOptimizationProblem):
"""
This class is the optimization problem for the Example. Within this class,
the objective, constraints and other options are defined.
"""
# This is a method that returns an expression for the objective function.
# RTC-Tools always minimizes the objective.
def objective(self, ensemble_member):
# Minimize water pumped. The total water pumped is the integral of the
# water pumped from the starting time until the stoping time. In
# practice, self.integral() is a summation of all the discrete states.
return self.integral('Q_pump', ensemble_member)
# A path constraint is a constraint where the values in the constraint are a
# Timeseries rather than a single number.
def path_constraints(self, ensemble_member):
# Call super to get default constraints
constraints = super().path_constraints(ensemble_member)
M = 2 # The so-called "big-M"
# Release through orifice downhill only. This constraint enforces the
# fact that water only flows downhill.
constraints.append(
(self.state('Q_orifice') + (1 - self.state('is_downhill')) * 10,
0.0, 10.0))
# Make sure is_downhill is true only when the sea is lower than the
# water level in the storage.
constraints.append((self.state('H_sea') - self.state('storage.HQ.H') -
(1 - self.state('is_downhill')) * M, -np.inf, 0.0))
constraints.append((self.state('H_sea') - self.state('storage.HQ.H') +
self.state('is_downhill') * M, 0.0, np.inf))
# Orifice flow constraint. Uses the equation:
# Q(HUp, HDown, d) = width * C * d * (2 * g * (HUp - HDown)) ^ 0.5
# Note that this equation is only valid for orifices that are submerged
# units: description:
w = 3.0 # m width of orifice
d = 0.8 # m hight of orifice
C = 1.0 # none orifice constant
g = 9.8 # m/s^2 gravitational acceleration
constraints.append(
(((self.state('Q_orifice') / (w * C * d)) ** 2) / (2 * g) +
self.state('orifice.HQDown.H') - self.state('orifice.HQUp.H') -
M * (1 - self.state('is_downhill')),
-np.inf, 0.0))
return constraints
# Any solver options can be set here
def solver_options(self):
options = super().solver_options()
# Restrict solver output
solver = options['solver']
options[solver]['print_level'] = 1
return options
# Run
run_optimization_problem(Example)
|
Running the Optimization Problem¶
Note
An explaination of bonmin behaviour and output goes here.
Extracting Results¶
The results from the run are found in output/timeseries_export.csv
. Any
CSV-reading software can import it, but this is how results can be plotted using
the python library matplotlib:
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from datetime import datetime
data_path = '../../../examples/mixed_integer/output/timeseries_export.csv'
delimiter = ','
# Import Data
ncols = len(np.genfromtxt(data_path, max_rows=1, delimiter=delimiter))
datefunc = lambda x: datetime.strptime(x, '%Y-%m-%d %H:%M:%S')
results = np.genfromtxt(data_path, converters={0: datefunc}, delimiter=delimiter,
dtype='object' + ',float' * (ncols - 1), names=True, encoding=None)[1:]
# Generate Plot
f, axarr = plt.subplots(2, sharex=True)
axarr[0].set_title('Water Level and Discharge')
# Upper subplot
axarr[0].set_ylabel('Water Level [m]')
axarr[0].plot(results['time'], results['storage_level'], label='Storage',
linewidth=2, color='b')
axarr[0].plot(results['time'], results['sea_level'], label='Sea',
linewidth=2, color='m')
axarr[0].plot(results['time'], 0.5 * np.ones_like(results['time']), label='Storage Max',
linewidth=2, color='r', linestyle='--')
# Lower Subplot
axarr[1].set_ylabel('Flow Rate [m³/s]')
axarr[1].plot(results['time'], results['Q_orifice'], label='Orifice',
linewidth=2, color='g')
axarr[1].plot(results['time'], results['Q_pump'], label='Pump',
linewidth=2, color='r')
axarr[1].xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
f.autofmt_xdate()
# Shrink each axis by 20% and put a legend to the right of the axis
for i in range(len(axarr)):
box = axarr[i].get_position()
axarr[i].set_position([box.x0, box.y0, box.width * 0.8, box.height])
axarr[i].legend(loc='center left', bbox_to_anchor=(1, 0.5), frameon=False)
plt.autoscale(enable=True, axis='x', tight=True)
# Output Plot
plt.show()

Observations¶
Note that in the results plotted above, the pump runs with a constantly varying throughput. To smooth out the flow through the pump, consider using goal programming to apply a path goal minimizing the derivative of the pump at each timestep. For an example, see the third goal in Declaring Goals.
Goal Programming: Defining Multiple Objectives¶
Note
This example focuses on how to implement multi-objective optimization in RTC-Tools using Goal Programming. It assumes basic exposure to RTC-Tools. If you are a first-time user of RTC-Tools, see Filling a Reservoir.
Goal programming is a way to satisfy (sometimes conflicting) goals by ranking the goals by priority. The optimization algorithm will attempt to optimize each goal one at a time, starting with the goal with the highest priority and moving down through the list. Even if a goal cannot be satisfied, the goal programming algorithm will move on when it has found the best possible answer. Goals can be roughly divided into two types:
- As long as we satisfy the goal, we do not care by how much. If we cannot satisfy a goal, any lower priority goals are not allowed to increase the amount by which we exceed (which is equivalent to not allowing any change at all to the exceedance).
- We try to achieve as low a value as possible. Any lower priority goals are not allowed to result in an increase of this value (which is equivalent to not allowing any change at all).
In this example, we will be specifying two goals, on for each type. The higher priority goal will be to maintain the water level of the storage element between two levels. The lower priority goal will be to minimize the total volume pumped.
The Model¶
Note
This example uses the same hydraulic model as the MILP example. For a detailed explanation of the hydraulic model, including how to to formulate mixed integers in your model, see Mixed Integer Optimization: Pumps and Orifices.
For this example, the model represents a typical setup for the dewatering of lowland areas. Water is routed from the hinterland (modeled as discharge boundary condition, right side) through a canal (modeled as storage element) towards the sea (modeled as water level boundary condition on the left side). Keeping the lowland area dry requires that enough water is discharged to the sea. If the sea water level is lower than the water level in the canal, the water can be discharged to the sea via gradient flow through the orifice (or a weir). If the sea water level is higher than in the canal, water must be pumped.
In OpenModelica Connection Editor, the model looks like this:

In text mode, the Modelica model looks as follows (with annotation statements removed):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | model Example
// Declare Model Elements
Deltares.ChannelFlow.Hydraulic.Storage.Linear storage(A=1.0e6, H_b=0.0, HQ.H(min=0.0, max=0.5));
Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Discharge discharge;
Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Level level;
Deltares.ChannelFlow.Hydraulic.Structures.Pump pump;
Deltares.ChannelFlow.Hydraulic.Structures.Pump orifice;
// Define Input/Output Variables and set them equal to model variables
input Modelica.SIunits.VolumeFlowRate Q_pump(fixed=false, min=0.0, max=7.0) = pump.Q;
input Boolean is_downhill;
input Modelica.SIunits.VolumeFlowRate Q_in(fixed=true) = discharge.Q;
input Modelica.SIunits.Position H_sea(fixed=true) = level.H;
input Modelica.SIunits.VolumeFlowRate Q_orifice(fixed=false, min=0.0, max=10.0) = orifice.Q;
output Modelica.SIunits.Position storage_level = storage.HQ.H;
output Modelica.SIunits.Position sea_level = level.H;
equation
// Connect Model Elements
connect(orifice.HQDown, level.HQ);
connect(storage.HQ, orifice.HQUp);
connect(storage.HQ, pump.HQUp);
connect(discharge.HQ, storage.HQ);
connect(pump.HQDown, level.HQ);
end Example;
|
The Optimization Problem¶
When using goal programming, the python script consists of the following blocks:
- Import of packages
- Declaration of Goals
- Declaration of the optimization problem class
- Constructor
- Declaration of constraint methods
- Specification of Goals
- Declaration of a
priority_completed()
method - Declaration of a
pre()
method - Declaration of a
post()
method - Additional configuration of the solver
- A run statement
Importing Packages¶
For this example, the import block is as follows:
1 2 3 4 5 6 7 8 | import numpy as np
from rtctools.optimization.collocated_integrated_optimization_problem \
import CollocatedIntegratedOptimizationProblem
from rtctools.optimization.csv_mixin import CSVMixin
from rtctools.optimization.goal_programming_mixin \
import Goal, GoalProgrammingMixin, StateGoal
from rtctools.optimization.modelica_mixin import ModelicaMixin
|
Declaring Goals¶
Goals are defined as classes that inherit the Goal
parent class. The
components of goals can be found in Multi-objective optimization. In
this example, we demonstrate three ways to define a goal in RTC-Tools.
First, we have a high priority goal to keep the water level within a minimum and
maximum. Since we are applying this goal to a specific state (model variable) in
our model at every time step, we can inherit a special helper class to define
this goal, called a StateGoal
:
12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | class WaterLevelRangeGoal(StateGoal):
# Applying a state goal to every time step is easily done by defining a goal
# that inherits StateGoal. StateGoal is a helper class that uses the state
# to determine the function, function range, and function nominal
# automatically.
state = 'storage.HQ.H'
# One goal can introduce a single or two constraints (min and/or max). Our
# target water level range is 0.43 - 0.44. We might not always be able to
# realize this, but we want to try.
target_min = 0.43
target_max = 0.44
# Because we want to satisfy our water level target first, this has a
# higher priority (=lower number).
priority = 1
|
We also want to save energy, so we define a goal to minimize the integral of
Q_pump
. This goal has a lower priority than the water level range goal.
This goal does not use a helper class:
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | class MinimizeQpumpGoal(Goal):
# This goal does not use a helper class, so we have to define the function
# method, range and nominal explicitly. We do not specify a target_min or
# target_max in this class, so the goal programming mixin will try to
# minimize the expression returned by the function method.
def function(self, optimization_problem, ensemble_member):
return optimization_problem.integral('Q_pump')
# The nominal is used to scale the value returned by
# the function method so that the value is on the order of 1.
function_nominal = 100.0
# The lower the number returned by this function, the higher the priority.
priority = 2
# The penalty variable is taken to the order'th power.
order = 1
|
We add a third goal minimizing the changes in``Q_pump``, and give it the least priority. This goal smooths out the operation of the pump so that it changes state as few times as possible. To get an idea of what the pump would have done without this goal, see Mixed Integer: Observations. The order of this goal must be 2, so that it penalizes both positive and negative derivatives. Order of 2 is the default, but we include it here explicitly for the sake of clarity.
46 47 48 49 50 51 52 53 54 55 56 | class MinimizeChangeInQpumpGoal(Goal):
# To reduce pump power cycles, we add a third goal to minimize changes in
# Q_pump. This will be passed into the optimization problem as a path goal
# because it is an an individual goal that should be applied at every time
# step.
def function(self, optimization_problem, ensemble_member):
return optimization_problem.der('Q_pump')
function_nominal = 5.0
priority = 3
# Default order is 2, but we want to be explicit
order = 2
|
Optimization Problem¶
Next, we construct the class by declaring it and inheriting the desired parent classes.
59 60 | class Example(GoalProgrammingMixin, CSVMixin, ModelicaMixin,
CollocatedIntegratedOptimizationProblem):
|
Constraints can be declared by declaring the path_constraints()
method.
Path constraints are constraints that are applied every timestep. To set a
constraint at an individual timestep, define it inside the constraints()
method.
The “orifice” requires special constraints to be set in order to work. They are
implemented below in the path_constraints()
method. Other parent classes
also declare this method, so we call the super()
method so that we don’t
overwrite their behaviour.
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 | def path_constraints(self, ensemble_member):
# We want to add a few hard constraints to our problem. The goal
# programming mixin however also generates constraints (and objectives)
# from on our goals, so we have to call super() here.
constraints = super().path_constraints(ensemble_member)
# Release through orifice downhill only. This constraint enforces the
# fact that water only flows downhill
constraints.append((self.state('Q_orifice') +
(1 - self.state('is_downhill')) * 10, 0.0, 10.0))
# Make sure is_downhill is true only when the sea is lower than the
# water level in the storage.
M = 2 # The so-called "big-M"
constraints.append((self.state('H_sea') - self.state('storage.HQ.H') -
(1 - self.state('is_downhill')) * M, -np.inf, 0.0))
constraints.append((self.state('H_sea') - self.state('storage.HQ.H') +
self.state('is_downhill') * M, 0.0, np.inf))
# Orifice flow constraint. Uses the equation:
# Q(HUp, HDown, d) = width * C * d * (2 * g * (HUp - HDown)) ^ 0.5
# Note that this equation is only valid for orifices that are submerged
# units: description:
w = 3.0 # m width of orifice
d = 0.8 # m hight of orifice
C = 1.0 # none orifice constant
g = 9.8 # m/s^2 gravitational acceleration
constraints.append(
(((self.state('Q_orifice') / (w * C * d)) ** 2) / (2 * g) +
self.state('orifice.HQDown.H') - self.state('orifice.HQUp.H') -
M * (1 - self.state('is_downhill')),
-np.inf, 0.0))
return constraints
|
Now we pass in the goals. There are path goals and normal goals, so we have to pass them in using separate methods. A path goal is a specific kind of goal that applies to a particular variable at an individual time step, but that we want to set for all the timesteps.
Non-path goals are more general goals that are not iteratively applied at every
timestep. We use the goals()
method to pass a list of these goals to the
optimizer.
99 100 | def goals(self):
return [MinimizeQpumpGoal()]
|
For the goals that want to apply our goals to every timestep, so we use the
path_goals()
method. This is a method that returns a list of the path goals
we defined above. Note that with path goals, each timestep is implemented as an
independant goal- if we cannot satisfy our min/max on time step A, it will not
affect our desire to satisfy the goal at time step B. Goals that inherit
StateGoal
are always path goals and must always be initialized with the
parameter self
.
102 103 104 105 | def path_goals(self):
# Sorting goals on priority is done in the goal programming mixin. We
# do not have to worry about order here.
return [WaterLevelRangeGoal(self), MinimizeChangeInQpumpGoal()]
|
If all we cared about were the results, we could end our class declaration here. However, it is usually helpful to track how the solution changes after optimizing each priority level. To track these changes, we need to add three methods.
The method pre()
is already defined in RTC-Tools, but we would like to add
a line to it to create a variable for storing intermediate results. To do this,
we declare a new pre()
method, call super().pre()
to ensure
that the original method runs unmodified, and add in a variable declaration to
store our list of intermediate results:
107 108 109 110 111 112 | def pre(self):
# Call super() class to not overwrite default behaviour
super().pre()
# We keep track of our intermediate results, so that we can print some
# information about the progress of goals at the end of our run.
self.intermediate_results = []
|
Next, we define the priority_completed()
method to inspect and summarize the
results. These are appended to our intermediate results variable after each
priority is completed.
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | def priority_completed(self, priority):
# We want to show that the results of our highest priority goal (water
# level) are remembered. The other information we want to see is how our
# lower priority goal (Q_pump) progresses. We can write some code that
# sumerizes the results and stores it.
# A little bit of tolerance when checking for acceptance, because
# strictly speaking 0.4299... is smaller than 0.43.
_min = 0.43 - 1e-4
_max = 0.44 + 1e-4
results = self.extract_results()
n_level_satisfied = sum(
1 for x in results['storage.HQ.H'] if _min <= x <= _max)
q_pump_integral = sum(results['Q_pump'])
q_pump_sum_changes = np.sum(np.diff(results['Q_pump'])**2)
self.intermediate_results.append(
(priority, n_level_satisfied, q_pump_integral, q_pump_sum_changes))
|
We want some way to output our intermediate results. This is accomplished using
the post()
method. Again, we nedd to call the super()
method to avoid
overwiting the internal method.
133 134 135 136 137 138 139 140 141 142 | def post(self):
# Call super() class to not overwrite default behaviour
super().post()
for priority, n_level_satisfied, q_pump_integral, q_pump_sum_changes \
in self.intermediate_results:
print('\nAfter finishing goals of priority {}:'.format(priority))
print('Level goal satisfied at {} of {} time steps'.format(
n_level_satisfied, len(self.times())))
print('Integral of Q_pump = {:.2f}'.format(q_pump_integral))
print('Sum of squares of changes in Q_pump: {:.2f}'.format(q_pump_sum_changes))
|
Finally, we want to apply some additional configuration, reducing the amount of information the solver outputs:
145 146 147 148 149 | def solver_options(self):
options = super().solver_options()
solver = options['solver']
options[solver]['print_level'] = 1
return options
|
Run the Optimization Problem¶
To make our script run, at the bottom of our file we just have to call
the run_optimization_problem()
method we imported on the optimization
problem class we just created.
153 | run_optimization_problem(Example)
|
The Whole Script¶
All together, the whole example script is as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 | import numpy as np
from rtctools.optimization.collocated_integrated_optimization_problem \
import CollocatedIntegratedOptimizationProblem
from rtctools.optimization.csv_mixin import CSVMixin
from rtctools.optimization.goal_programming_mixin \
import Goal, GoalProgrammingMixin, StateGoal
from rtctools.optimization.modelica_mixin import ModelicaMixin
from rtctools.util import run_optimization_problem
class WaterLevelRangeGoal(StateGoal):
# Applying a state goal to every time step is easily done by defining a goal
# that inherits StateGoal. StateGoal is a helper class that uses the state
# to determine the function, function range, and function nominal
# automatically.
state = 'storage.HQ.H'
# One goal can introduce a single or two constraints (min and/or max). Our
# target water level range is 0.43 - 0.44. We might not always be able to
# realize this, but we want to try.
target_min = 0.43
target_max = 0.44
# Because we want to satisfy our water level target first, this has a
# higher priority (=lower number).
priority = 1
class MinimizeQpumpGoal(Goal):
# This goal does not use a helper class, so we have to define the function
# method, range and nominal explicitly. We do not specify a target_min or
# target_max in this class, so the goal programming mixin will try to
# minimize the expression returned by the function method.
def function(self, optimization_problem, ensemble_member):
return optimization_problem.integral('Q_pump')
# The nominal is used to scale the value returned by
# the function method so that the value is on the order of 1.
function_nominal = 100.0
# The lower the number returned by this function, the higher the priority.
priority = 2
# The penalty variable is taken to the order'th power.
order = 1
class MinimizeChangeInQpumpGoal(Goal):
# To reduce pump power cycles, we add a third goal to minimize changes in
# Q_pump. This will be passed into the optimization problem as a path goal
# because it is an an individual goal that should be applied at every time
# step.
def function(self, optimization_problem, ensemble_member):
return optimization_problem.der('Q_pump')
function_nominal = 5.0
priority = 3
# Default order is 2, but we want to be explicit
order = 2
class Example(GoalProgrammingMixin, CSVMixin, ModelicaMixin,
CollocatedIntegratedOptimizationProblem):
"""
An introductory example to goal programming in RCT-Tools
"""
def path_constraints(self, ensemble_member):
# We want to add a few hard constraints to our problem. The goal
# programming mixin however also generates constraints (and objectives)
# from on our goals, so we have to call super() here.
constraints = super().path_constraints(ensemble_member)
# Release through orifice downhill only. This constraint enforces the
# fact that water only flows downhill
constraints.append((self.state('Q_orifice') +
(1 - self.state('is_downhill')) * 10, 0.0, 10.0))
# Make sure is_downhill is true only when the sea is lower than the
# water level in the storage.
M = 2 # The so-called "big-M"
constraints.append((self.state('H_sea') - self.state('storage.HQ.H') -
(1 - self.state('is_downhill')) * M, -np.inf, 0.0))
constraints.append((self.state('H_sea') - self.state('storage.HQ.H') +
self.state('is_downhill') * M, 0.0, np.inf))
# Orifice flow constraint. Uses the equation:
# Q(HUp, HDown, d) = width * C * d * (2 * g * (HUp - HDown)) ^ 0.5
# Note that this equation is only valid for orifices that are submerged
# units: description:
w = 3.0 # m width of orifice
d = 0.8 # m hight of orifice
C = 1.0 # none orifice constant
g = 9.8 # m/s^2 gravitational acceleration
constraints.append(
(((self.state('Q_orifice') / (w * C * d)) ** 2) / (2 * g) +
self.state('orifice.HQDown.H') - self.state('orifice.HQUp.H') -
M * (1 - self.state('is_downhill')),
-np.inf, 0.0))
return constraints
def goals(self):
return [MinimizeQpumpGoal()]
def path_goals(self):
# Sorting goals on priority is done in the goal programming mixin. We
# do not have to worry about order here.
return [WaterLevelRangeGoal(self), MinimizeChangeInQpumpGoal()]
def pre(self):
# Call super() class to not overwrite default behaviour
super().pre()
# We keep track of our intermediate results, so that we can print some
# information about the progress of goals at the end of our run.
self.intermediate_results = []
def priority_completed(self, priority):
# We want to show that the results of our highest priority goal (water
# level) are remembered. The other information we want to see is how our
# lower priority goal (Q_pump) progresses. We can write some code that
# sumerizes the results and stores it.
# A little bit of tolerance when checking for acceptance, because
# strictly speaking 0.4299... is smaller than 0.43.
_min = 0.43 - 1e-4
_max = 0.44 + 1e-4
results = self.extract_results()
n_level_satisfied = sum(
1 for x in results['storage.HQ.H'] if _min <= x <= _max)
q_pump_integral = sum(results['Q_pump'])
q_pump_sum_changes = np.sum(np.diff(results['Q_pump'])**2)
self.intermediate_results.append(
(priority, n_level_satisfied, q_pump_integral, q_pump_sum_changes))
def post(self):
# Call super() class to not overwrite default behaviour
super().post()
for priority, n_level_satisfied, q_pump_integral, q_pump_sum_changes \
in self.intermediate_results:
print('\nAfter finishing goals of priority {}:'.format(priority))
print('Level goal satisfied at {} of {} time steps'.format(
n_level_satisfied, len(self.times())))
print('Integral of Q_pump = {:.2f}'.format(q_pump_integral))
print('Sum of squares of changes in Q_pump: {:.2f}'.format(q_pump_sum_changes))
# Any solver options can be set here
def solver_options(self):
options = super().solver_options()
solver = options['solver']
options[solver]['print_level'] = 1
return options
# Run
run_optimization_problem(Example)
|
Running the Optimization Problem¶
Following the execution of the optimization problem, the post()
method
should print out the following lines:
After finishing goals of priority 1:
Level goal satisfied at 19 of 21 time steps
Integral of Q_pump = 74.18
Sum of Changes in Q_pump: 7.83
After finishing goals of priority 2:
Level goal satisfied at 19 of 21 time steps
Integral of Q_pump = 60.10
Sum of Changes in Q_pump: 11.70
After finishing goals of priority 3:
Level goal satisfied at 19 of 21 time steps
Integral of Q_pump = 60.10
Sum of Changes in Q_pump: 10.07
As the output indicates, while optimizing for the priority 1 goal, no attempt
was made to minimize the integral of Q_pump
. The only objective was to
minimize the number of states in violation of the water level goal.
After optimizing for the priority 2 goal, the solver was able to find a solution
that reduced the integral of Q_pump
without increasing the number of
timesteps where the water level exceeded the limit. However, this solution
induced additional variation into the operation of Q_pump
After optimizing the priority 3 goal, the integral of Q_pump
is the same and
the level goal has not improved. Without hurting any higher priority goals,
RTC-Tools was able to smooth out the operation of the pump.
Extracting Results¶
The results from the run are found in output/timeseries_export.csv
. Any
CSV-reading software can import it, but this is how results can be plotted using
the python library matplotlib:
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from datetime import datetime
data_path = '../../../examples/goal_programming/output/timeseries_export.csv'
delimiter = ','
# Import Data
ncols = len(np.genfromtxt(data_path, max_rows=1, delimiter=delimiter))
datefunc = lambda x: datetime.strptime(x, '%Y-%m-%d %H:%M:%S')
results = np.genfromtxt(data_path, converters={0: datefunc}, delimiter=delimiter,
dtype='object' + ',float' * (ncols - 1), names=True, encoding=None)[1:]
# Generate Plot
n_subplots = 3
f, axarr = plt.subplots(n_subplots, sharex=True, figsize=(8, 3 * n_subplots))
axarr[0].set_title('Water Level and Discharge')
# Upper subplot
axarr[0].set_ylabel('Water Level [m]')
axarr[0].plot(results['time'], results['storage_level'], label='Storage',
linewidth=2, color='b')
axarr[0].plot(results['time'], results['sea_level'], label='Sea',
linewidth=2, color='m')
# Middle subplot
axarr[1].set_ylabel('Water Level [m]')
axarr[1].plot(results['time'], results['storage_level'], label='Storage',
linewidth=2, color='b')
axarr[1].plot(results['time'], 0.44 * np.ones_like(results['time']), label='Storage Max',
linewidth=2, color='r', linestyle='--')
axarr[1].plot(results['time'], 0.43 * np.ones_like(results['time']), label='Storage Min',
linewidth=2, color='g', linestyle='--')
# Lower Subplot
axarr[2].set_ylabel('Flow Rate [m³/s]')
axarr[2].plot(results['time'], results['Q_orifice'], label='Orifice',
linewidth=2, color='g')
axarr[2].plot(results['time'], results['Q_pump'], label='Pump',
linewidth=2, color='r')
axarr[2].xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
f.autofmt_xdate()
# Shrink each axis by 20% and put a legend to the right of the axis
for i in range(n_subplots):
box = axarr[i].get_position()
axarr[i].set_position([box.x0, box.y0, box.width * 0.8, box.height])
axarr[i].legend(loc='center left', bbox_to_anchor=(1, 0.5), frameon=False)
plt.autoscale(enable=True, axis='x', tight=True)
# Output Plot
plt.show()

Using Lookup Tables¶
Note
This example focuses on how to implement non-linear storage elements in RTC-Tools using lookup tables. It assumes basic exposure to RTC-Tools. If you are a first-time user of RTC-Tools, see Filling a Reservoir.
This example also uses goal programming in the formulation. If you are unfamiliar with goal programming, please see Goal Programming: Defining Multiple Objectives.
The Model¶
Note
This example uses the same hydraulic model as the basic example. For a detalied explaination of the hydraulic model, see Filling a Reservoir.
In OpenModelica Connection Editor, the model looks like this:

In text mode, the Modelica model is as follows (with annotation statements removed):
1 2 3 4 5 6 7 8 9 10 11 12 | model Example
Deltares.ChannelFlow.SimpleRouting.BoundaryConditions.Inflow inflow;
Deltares.ChannelFlow.SimpleRouting.Storage.Storage storage(V(nominal=4e5, min=2e5, max=6e5));
Deltares.ChannelFlow.SimpleRouting.BoundaryConditions.Terminal outfall;
input Modelica.SIunits.VolumeFlowRate Q_in(fixed = true);
input Modelica.SIunits.VolumeFlowRate Q_release(fixed = false, min = 0.0, max = 10.0);
equation
connect(inflow.QOut, storage.QIn);
connect(storage.QOut, outfall.QIn);
storage.Q_release = Q_release;
inflow.Q = Q_in;
end Example;
|
The Optimization Problem¶
The python script consists of the following blocks:
- Import of packages
- Declaration of Goals
- Declaration of the optimization problem class
- Constructor
- Declaration of a
pre()
method - Specification of Goals
- Declaration of a
priority_completed()
method - Declaration of a
post()
method - Additional configuration of the solver
- A run statement
Importing Packages¶
For this example, the import block is as follows:
1 2 3 4 5 6 7 8 9 | import numpy as np
from rtctools.optimization.collocated_integrated_optimization_problem \
import CollocatedIntegratedOptimizationProblem
from rtctools.optimization.csv_lookup_table_mixin import CSVLookupTableMixin
from rtctools.optimization.csv_mixin import CSVMixin
from rtctools.optimization.goal_programming_mixin \
import GoalProgrammingMixin, StateGoal
from rtctools.optimization.modelica_mixin import ModelicaMixin
|
Declaring Goals¶
Goals are defined as classes that inherit the Goal
parent class. The
components of goals can be found in Multi-objective optimization. In
this example, we use the helper goal class, StateGoal
.
First, we have a high priority goal to keep the water volume within a minimum and maximum. We use a water volume goal instead of a water level goal when the volume-storage relation of the storage element is non-linear. The volume of water in the storage element behaves linearly, while the water level does not.
However, goals are usually defined in the form of water level goals. We will
convert the water level goals into volume goals within the optimization
problem class, so we define the __init__()
method so we can pass the
values of the goals in later. We call the super()
method to avoid
overwriting the __init__()
method of the parent class.
13 14 15 16 17 18 19 20 21 22 23 24 25 | class WaterVolumeRangeGoal(StateGoal):
# We want to add a water volume range goal to our optimization. However, at
# the time of defining this goal we still do not know what the value of the
# min and max are. We add an __init__() method so that the values of these
# goals can be defined when the optimization problem class instantiates
# this goal.
def __init__(self, optimization_problem):
# Assign V_min and V_max the the target range
self.target_min = optimization_problem.get_timeseries('V_min')
self.target_max = optimization_problem.get_timeseries('V_max')
super().__init__(optimization_problem)
state = 'storage.V'
priority = 1
|
We also want to save energy, so we define a goal to minimize Q_release
. This
goal has a lower priority.
28 29 30 31 32 33 34 | class MinimizeQreleaseGoal(StateGoal):
# GoalProgrammingMixin will try to minimize the following state:
state = 'Q_release'
# The lower the number returned by this function, the higher the priority.
priority = 2
# The penalty variable is taken to the order'th power.
order = 1
|
Optimization Problem¶
Next, we construct the class by declaring it and inheriting the desired parent classes.
37 38 | class Example(GoalProgrammingMixin, CSVLookupTableMixin, CSVMixin,
ModelicaMixin, CollocatedIntegratedOptimizationProblem):
|
The method pre()
is already defined in RTC-Tools, but we would like to add
a line to it to create a variable for storing intermediate results. To do this,
we declare a new pre()
method, call super().pre()
to ensure
that the original method runs unmodified, and add in a variable declaration to
store our list of intermediate results.
We also want to convert our water level rane goal into a water volume range
goal. We can access the spline function describing the water level-storage
relation using the lookup_table()
method. We cache the functions for
convenience. The lookup_storage_V()
method can convert timeseries objects,
and we save the water volume goal bounds as timeseries.
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | def pre(self):
super().pre()
# Empty list for storing intermediate_results
self.intermediate_results = []
# Cache lookup tables for convenience and legibility
_lookup_tables = self.lookup_tables(ensemble_member=0)
self.lookup_storage_V = _lookup_tables['storage_V']
# Non-varrying goals can be implemented as a timeseries like this:
self.set_timeseries('H_min', np.ones_like(self.times()) * 0.44, output=False)
# Q_in is a varying input and is defined in timeseries_import.csv
# However, if we set it again here, it will be added to the output file
self.set_timeseries('Q_in', self.get_timeseries('Q_in'))
# Convert our water level constraints into volume constraints
self.set_timeseries('V_max',
self.lookup_storage_V(self.get_timeseries('H_max')))
self.set_timeseries('V_min',
self.lookup_storage_V(self.get_timeseries('H_min')))
|
Notice that H_max was not defined in pre(). This is because it was defined as a timeseries import. We access timeseries using get_timeseries() and store them using set_timeseries(). Once a timeseries is set, we can access it later. In addition, all timeseries that are set are automatically included in the output file. You can find more information on timeseries here Basics.
Now we pass in the goals. We want to apply our goals to every timestep, so we
use the path_goals()
method. This is a method that returns a list of the
goals we defined above. The WaterVolumeRangeGoal
needs to be instantiated
with the new water volume timeseries we just defined.
66 67 68 69 70 | def path_goals(self):
g = []
g.append(WaterVolumeRangeGoal(self))
g.append(MinimizeQreleaseGoal(self))
return g
|
If all we cared about were the results, we could end our class declaration here. However, it is usually helpful to track how the solution changes after optimizing each priority level. To track these changes, we need to add three methods.
We define the priority_completed()
method to inspect and summerize the
results. These are appended to our intermediate results variable after each
priority is completed.
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 | def priority_completed(self, priority):
results = self.extract_results()
self.set_timeseries('storage_V', results['storage.V'])
_max = self.get_timeseries('V_max').values
_min = self.get_timeseries('V_min').values
storage_V = self.get_timeseries('storage_V').values
# A little bit of tolerance when checking for acceptance.
tol = 10
_max += tol
_min -= tol
n_level_satisfied = sum(
np.logical_and(_min <= storage_V, storage_V <= _max))
q_release_integral = sum(results['Q_release'])
self.intermediate_results.append(
(priority, n_level_satisfied, q_release_integral))
|
We output our intermediate results using the post()
method. Again, we nedd
to call the super()
method to avoid overwiting the internal method.
93 94 95 96 97 98 99 100 | def post(self):
# Call super() class to not overwrite default behaviour
super().post()
for priority, n_level_satisfied, q_release_integral in self.intermediate_results:
print("\nAfter finishing goals of priority {}:".format(priority))
print("Volume goal satisfied at {} of {} time steps".format(
n_level_satisfied, len(self.times())))
print("Integral of Q_release = {:.2f}".format(q_release_integral))
|
Finally, we want to apply some additional configuration, reducing the amount of information the solver outputs:
103 104 105 106 107 | def solver_options(self):
options = super().solver_options()
solver = options['solver']
options[solver]['print_level'] = 1
return options
|
Run the Optimization Problem¶
To make our script run, at the bottom of our file we just have to call
the run_optimization_problem()
method we imported on the optimization
problem class we just created.
111 | run_optimization_problem(Example)
|
The Whole Script¶
All together, the whole example script is as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | import numpy as np
from rtctools.optimization.collocated_integrated_optimization_problem \
import CollocatedIntegratedOptimizationProblem
from rtctools.optimization.csv_lookup_table_mixin import CSVLookupTableMixin
from rtctools.optimization.csv_mixin import CSVMixin
from rtctools.optimization.goal_programming_mixin \
import GoalProgrammingMixin, StateGoal
from rtctools.optimization.modelica_mixin import ModelicaMixin
from rtctools.util import run_optimization_problem
class WaterVolumeRangeGoal(StateGoal):
# We want to add a water volume range goal to our optimization. However, at
# the time of defining this goal we still do not know what the value of the
# min and max are. We add an __init__() method so that the values of these
# goals can be defined when the optimization problem class instantiates
# this goal.
def __init__(self, optimization_problem):
# Assign V_min and V_max the the target range
self.target_min = optimization_problem.get_timeseries('V_min')
self.target_max = optimization_problem.get_timeseries('V_max')
super().__init__(optimization_problem)
state = 'storage.V'
priority = 1
class MinimizeQreleaseGoal(StateGoal):
# GoalProgrammingMixin will try to minimize the following state:
state = 'Q_release'
# The lower the number returned by this function, the higher the priority.
priority = 2
# The penalty variable is taken to the order'th power.
order = 1
class Example(GoalProgrammingMixin, CSVLookupTableMixin, CSVMixin,
ModelicaMixin, CollocatedIntegratedOptimizationProblem):
"""
An extention of the goal programming example that shows how to incorporate
non-linear storage elements in the model.
"""
def pre(self):
super().pre()
# Empty list for storing intermediate_results
self.intermediate_results = []
# Cache lookup tables for convenience and legibility
_lookup_tables = self.lookup_tables(ensemble_member=0)
self.lookup_storage_V = _lookup_tables['storage_V']
# Non-varrying goals can be implemented as a timeseries like this:
self.set_timeseries('H_min', np.ones_like(self.times()) * 0.44, output=False)
# Q_in is a varying input and is defined in timeseries_import.csv
# However, if we set it again here, it will be added to the output file
self.set_timeseries('Q_in', self.get_timeseries('Q_in'))
# Convert our water level constraints into volume constraints
self.set_timeseries('V_max',
self.lookup_storage_V(self.get_timeseries('H_max')))
self.set_timeseries('V_min',
self.lookup_storage_V(self.get_timeseries('H_min')))
def path_goals(self):
g = []
g.append(WaterVolumeRangeGoal(self))
g.append(MinimizeQreleaseGoal(self))
return g
# We want to print some information about our goal programming problem. We
# store the useful numbers temporarily, and print information at the end of
# our run (see post() method below).
def priority_completed(self, priority):
results = self.extract_results()
self.set_timeseries('storage_V', results['storage.V'])
_max = self.get_timeseries('V_max').values
_min = self.get_timeseries('V_min').values
storage_V = self.get_timeseries('storage_V').values
# A little bit of tolerance when checking for acceptance.
tol = 10
_max += tol
_min -= tol
n_level_satisfied = sum(
np.logical_and(_min <= storage_V, storage_V <= _max))
q_release_integral = sum(results['Q_release'])
self.intermediate_results.append(
(priority, n_level_satisfied, q_release_integral))
def post(self):
# Call super() class to not overwrite default behaviour
super().post()
for priority, n_level_satisfied, q_release_integral in self.intermediate_results:
print("\nAfter finishing goals of priority {}:".format(priority))
print("Volume goal satisfied at {} of {} time steps".format(
n_level_satisfied, len(self.times())))
print("Integral of Q_release = {:.2f}".format(q_release_integral))
# Any solver options can be set here
def solver_options(self):
options = super().solver_options()
solver = options['solver']
options[solver]['print_level'] = 1
return options
# Run
run_optimization_problem(Example)
|
Running the Optimization Problem¶
Following the execution of the optimization problem, the post()
method
should print out the following lines:
After finishing goals of priority 1:
Volume goal satisfied at 12 of 12 time steps
Integral of Q_release = 42.69
After finishing goals of priority 2:
Volume goal satisfied at 12 of 12 time steps
Integral of Q_release = 42.58
As the output indicates, while optimizing for the priority 1 goal, no attempt
was made to minimize the integral of Q_release
. The only objective was to
minimize the number of states in violation of the water level goal.
After optimizing for the priority 2 goal, the solver was able to find a solution
that reduced the integral of Q_release
without increasing the number of
timesteps where the water level exceded the limit.
Extracting Results¶
The results from the run are found in output/timeseries_export.csv
. Any
CSV-reading software can import it, but this is how results can be plotted using
the python library matplotlib:
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from datetime import datetime
data_path = '../../../examples/lookup_table/output/timeseries_export.csv'
delimiter = ','
# Import Data
ncols = len(np.genfromtxt(data_path, max_rows=1, delimiter=delimiter))
datefunc = lambda x: datetime.strptime(x, '%Y-%m-%d %H:%M:%S')
results = np.genfromtxt(data_path, converters={0: datefunc}, delimiter=delimiter,
dtype='object' + ',float' * (ncols - 1), names=True, encoding=None)[1:]
# Generate Plot
n_subplots = 2
f, axarr = plt.subplots(n_subplots, sharex=True, figsize=(8, 3 * n_subplots))
axarr[0].set_title('Water Volume and Discharge')
f.autofmt_xdate()
# Upper subplot
axarr[0].set_ylabel('Water Volume [m³]')
axarr[0].ticklabel_format(style='sci', axis='y', scilimits=(0, 0))
axarr[0].plot(results['time'], results['storage_V'], label='Storage',
linewidth=2, color='b')
axarr[0].plot(results['time'], results['V_max'], label='Storage Max',
linewidth=2, color='r', linestyle='--')
axarr[0].plot(results['time'], results['V_min'], label='Storage Min',
linewidth=2, color='g', linestyle='--')
# Lower Subplot
axarr[1].set_ylabel('Flow Rate [m³/s]')
axarr[1].plot(results['time'], results['Q_in'], label='Inflow',
linewidth=2, color='g')
axarr[1].plot(results['time'], results['Q_release'], label='Release',
linewidth=2, color='r')
# Shrink each axis by 20% and put a legend to the right of the axis
for i in range(n_subplots):
box = axarr[i].get_position()
axarr[i].set_position([box.x0, box.y0, box.width * 0.8, box.height])
axarr[i].legend(loc='center left', bbox_to_anchor=(1, 0.5), frameon=False)
plt.autoscale(enable=True, axis='x', tight=True)
# Output Plot
plt.show()

Using an Ensemble Forecast¶
Note
This example is an extension of Using Lookup Tables. It assumes prior knowledge of goal programming and the lookup tables components of RTC-Tools. If you are a first-time user of RTC-Tools, see Filling a Reservoir.
Then biggest change to RTC-Tools when using an ensemble is the structure of the
directory. The folder <examples directory>\ensemble
contains a complete RTC-Tools ensemble optimization problem. An RTC-Tools
ensemble directory has the following structure:
model
: This folder contains the Modelica model. The Modelica model contains the physics of the RTC-Tools model.src
: This folder contains a Python file. This file contains the configuration of the model and is used to run the model.input
: This folder contains the model input data pertaining to each ensemble member:ensemble.csv
: a file where the names and probabilities of the ensemble members are definedforecast1
- the file
timeseries_import.csv
- the file
initial_state.csv
- the file
forecast2
timeseries_import.csv
initial_state.csv
- …
output
: The folder where the output is saved:forecast1
timeseries_export.csv
forecast2
timeseries_export.csv
- …
The Model¶
Note
This example uses the same hydraulic model as the basic example. For a detailed explanation of the hydraulic model, see Filling a Reservoir.
In OpenModelica Connection Editor, the model looks like this:

In text mode, the Modelica model is as follows (with annotation statements removed):
1 2 3 4 5 6 7 8 9 10 11 12 | model Example
Deltares.ChannelFlow.SimpleRouting.BoundaryConditions.Inflow inflow;
Deltares.ChannelFlow.SimpleRouting.Storage.Storage storage(V(nominal=4e5, min=2e5, max=6e5));
Deltares.ChannelFlow.SimpleRouting.BoundaryConditions.Terminal outfall;
input Modelica.SIunits.VolumeFlowRate Q_in(fixed = true);
input Modelica.SIunits.VolumeFlowRate Q_release(fixed = false, min = 0.0, max = 6.0);
equation
connect(inflow.QOut, storage.QIn);
connect(storage.QOut, outfall.QIn);
storage.Q_release = Q_release;
inflow.Q = Q_in;
end Example;
|
The Optimization Problem¶
The python script consists of the following blocks:
- Import of packages
- Declaration of Goals
- Declaration of the optimization problem class
- Constructor
- Set
csv_ensemble_mode = True
- Declaration of a
pre()
method - Specification of Goals
- Declaration of a
priority_completed()
method - Declaration of a
post()
method - Additional configuration of the solver
- A run statement
Importing Packages¶
For this example, the import block is as follows:
1 2 3 4 5 6 7 8 9 10 | import numpy as np
from rtctools.optimization.collocated_integrated_optimization_problem \
import CollocatedIntegratedOptimizationProblem
from rtctools.optimization.control_tree_mixin import ControlTreeMixin
from rtctools.optimization.csv_lookup_table_mixin import CSVLookupTableMixin
from rtctools.optimization.csv_mixin import CSVMixin
from rtctools.optimization.goal_programming_mixin \
import GoalProgrammingMixin, StateGoal
from rtctools.optimization.modelica_mixin import ModelicaMixin
|
Declaring Goals¶
First, we have a high priority goal to keep the water volume within a minimum and maximum.
14 15 16 17 18 19 20 21 | class WaterVolumeRangeGoal(StateGoal):
def __init__(self, optimization_problem):
# Assign V_min and V_max the the target range
self.target_min = optimization_problem.get_timeseries('V_min')
self.target_max = optimization_problem.get_timeseries('V_max')
super().__init__(optimization_problem)
state = 'storage.V'
priority = 1
|
We also want to save energy, so we define a goal to minimize Q_release
. This
goal has a lower priority.
24 25 26 27 28 29 30 | class MinimizeQreleaseGoal(StateGoal):
# GoalProgrammingMixin will try to minimize the following state
state = 'Q_release'
# The lower the number returned by this function, the higher the priority.
priority = 2
# The penalty variable is taken to the order'th power.
order = 1
|
Optimization Problem¶
Next, we construct the class by declaring it and inheriting the desired parent classes.
33 34 | class Example(GoalProgrammingMixin, CSVMixin, CSVLookupTableMixin, ModelicaMixin,
ControlTreeMixin, CollocatedIntegratedOptimizationProblem):
|
We turn on ensemble mode by setting csv_ensemble_mode = True
:
49 50 | ensemble_member: [] for ensemble_member in range(self.ensemble_size)}
|
The method pre()
is already defined in RTC-Tools, but we would like to add
a line to it to create a variable for storing intermediate results. To do
this, we declare a new pre()
method, call super().pre()
to ensure that the original method runs unmodified, and add in a variable
declaration to store our list of intermediate results. This variable is a
dict, reflecting the need to store results from multiple ensemble.
Because the timeseries we set will be the same for both ensemble members, we also make sure that the timeseries we set are set for both ensemble members using for loops.
42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 | def pre(self):
# Do the standard preprocessing
super().pre()
# Create a dict of empty lists for storing intermediate results from
# each ensemble
self.intermediate_results = {
ensemble_member: [] for ensemble_member in range(self.ensemble_size)}
# Cache lookup tables for convenience and code legibility
_lookup_tables = self.lookup_tables(ensemble_member=0)
self.lookup_storage_V = _lookup_tables['storage_V']
# Non-varying goals can be implemented as a timeseries
for e_m in range(self.ensemble_size):
self.set_timeseries('H_min', np.ones_like(self.times()) * 0.44,
ensemble_member=e_m)
self.set_timeseries('H_max', np.ones_like(self.times()) * 0.46,
ensemble_member=e_m)
# Q_in is a varying input and is defined in each timeseries_import.csv
# However, if we set it again here, it will be added to the output files
self.set_timeseries('Q_in',
self.get_timeseries('Q_in', ensemble_member=e_m),
ensemble_member=e_m)
# Convert our water level goals into volume goals
self.set_timeseries('V_max',
self.lookup_storage_V(self.get_timeseries('H_max')),
ensemble_member=e_m)
self.set_timeseries('V_min',
self.lookup_storage_V(self.get_timeseries('H_min')),
ensemble_member=e_m)
|
Now we pass in the goals:
76 77 78 79 80 | def path_goals(self):
g = []
g.append(WaterVolumeRangeGoal(self))
g.append(MinimizeQreleaseGoal(self))
return g
|
In order to better demonstrate the way that ensembles are handled in RTC-
Tools, we modify the control_tree_options()
method. The default setting
allows the control tree to split at every time, but we override this method
and force it to split at a single timestep. See Observations at
the bottom of the page for more information.
82 83 84 85 86 87 88 | def control_tree_options(self):
# We want to modify the control tree options, so we override the default
# control_tree_options method. We call super() to get the default options
options = super().control_tree_options()
# Change the branching_times list to only contain the fifth timestep
options['branching_times'] = [self.times()[5]]
return options
|
We define the priority_completed()
method. We ensure that it stores the
results from both ensemble members.
90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 | def priority_completed(self, priority):
# We want to print some information about our goal programming problem.
# We store the useful numbers temporarily, and print information at the
# end of our run.
for e_m in range(self.ensemble_size):
results = self.extract_results(e_m)
self.set_timeseries('V_storage', results['storage.V'], ensemble_member=e_m)
_max = self.get_timeseries('V_max', ensemble_member=e_m).values
_min = self.get_timeseries('V_min', ensemble_member=e_m).values
V_storage = self.get_timeseries('V_storage', ensemble_member=e_m).values
# A little bit of tolerance when checking for acceptance. This
# tolerance must be set greater than the tolerance of the solver.
tol = 10
_max += tol
_min -= tol
n_level_satisfied = sum(
np.logical_and(_min <= V_storage, V_storage <= _max))
q_release_integral = sum(results['Q_release'])
self.intermediate_results[e_m].append((priority, n_level_satisfied,
q_release_integral))
|
We output our intermediate results using the post()
method:
113 114 115 116 117 118 119 120 121 122 | def post(self):
super().post()
for e_m in range(self.ensemble_size):
print('\n\nResults for Ensemble Member {}:'.format(e_m))
for priority, n_level_satisfied, q_release_integral in \
self.intermediate_results[e_m]:
print("\nAfter finishing goals of priority {}:".format(priority))
print("Level goal satisfied at {} of {} time steps".format(
n_level_satisfied, len(self.times())))
print("Integral of Q_release = {:.2f}".format(q_release_integral))
|
Finally, we want to apply some additional configuration, reducing the amount of information the solver outputs:
125 126 127 128 129 130 131 132 133 | def solver_options(self):
options = super().solver_options()
# When mumps_scaling is not zero, errors occur. RTC-Tools does its own
# scaling, so mumps scaling is not critical. Proprietary HSL solvers
# do not exhibit this error.
solver = options['solver']
options[solver]['mumps_scaling'] = 0
options[solver]['print_level'] = 1
return options
|
Run the Optimization Problem¶
To make our script run, at the bottom of our file we just have to call
the run_optimization_problem()
method we imported on the optimization
problem class we just created.
137 | run_optimization_problem(Example)
|
The Whole Script¶
All together, the whole example script is as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | import numpy as np
from rtctools.optimization.collocated_integrated_optimization_problem \
import CollocatedIntegratedOptimizationProblem
from rtctools.optimization.control_tree_mixin import ControlTreeMixin
from rtctools.optimization.csv_lookup_table_mixin import CSVLookupTableMixin
from rtctools.optimization.csv_mixin import CSVMixin
from rtctools.optimization.goal_programming_mixin \
import GoalProgrammingMixin, StateGoal
from rtctools.optimization.modelica_mixin import ModelicaMixin
from rtctools.util import run_optimization_problem
class WaterVolumeRangeGoal(StateGoal):
def __init__(self, optimization_problem):
# Assign V_min and V_max the the target range
self.target_min = optimization_problem.get_timeseries('V_min')
self.target_max = optimization_problem.get_timeseries('V_max')
super().__init__(optimization_problem)
state = 'storage.V'
priority = 1
class MinimizeQreleaseGoal(StateGoal):
# GoalProgrammingMixin will try to minimize the following state
state = 'Q_release'
# The lower the number returned by this function, the higher the priority.
priority = 2
# The penalty variable is taken to the order'th power.
order = 1
class Example(GoalProgrammingMixin, CSVMixin, CSVLookupTableMixin, ModelicaMixin,
ControlTreeMixin, CollocatedIntegratedOptimizationProblem):
"""
An extention of the goal programming and lookuptable examples that
demonstrates how to work with ensembles.
"""
# Overide default csv_ensemble_mode = False from CSVMixin before calling pre()
csv_ensemble_mode = True
def pre(self):
# Do the standard preprocessing
super().pre()
# Create a dict of empty lists for storing intermediate results from
# each ensemble
self.intermediate_results = {
ensemble_member: [] for ensemble_member in range(self.ensemble_size)}
# Cache lookup tables for convenience and code legibility
_lookup_tables = self.lookup_tables(ensemble_member=0)
self.lookup_storage_V = _lookup_tables['storage_V']
# Non-varying goals can be implemented as a timeseries
for e_m in range(self.ensemble_size):
self.set_timeseries('H_min', np.ones_like(self.times()) * 0.44,
ensemble_member=e_m)
self.set_timeseries('H_max', np.ones_like(self.times()) * 0.46,
ensemble_member=e_m)
# Q_in is a varying input and is defined in each timeseries_import.csv
# However, if we set it again here, it will be added to the output files
self.set_timeseries('Q_in',
self.get_timeseries('Q_in', ensemble_member=e_m),
ensemble_member=e_m)
# Convert our water level goals into volume goals
self.set_timeseries('V_max',
self.lookup_storage_V(self.get_timeseries('H_max')),
ensemble_member=e_m)
self.set_timeseries('V_min',
self.lookup_storage_V(self.get_timeseries('H_min')),
ensemble_member=e_m)
def path_goals(self):
g = []
g.append(WaterVolumeRangeGoal(self))
g.append(MinimizeQreleaseGoal(self))
return g
def control_tree_options(self):
# We want to modify the control tree options, so we override the default
# control_tree_options method. We call super() to get the default options
options = super().control_tree_options()
# Change the branching_times list to only contain the fifth timestep
options['branching_times'] = [self.times()[5]]
return options
def priority_completed(self, priority):
# We want to print some information about our goal programming problem.
# We store the useful numbers temporarily, and print information at the
# end of our run.
for e_m in range(self.ensemble_size):
results = self.extract_results(e_m)
self.set_timeseries('V_storage', results['storage.V'], ensemble_member=e_m)
_max = self.get_timeseries('V_max', ensemble_member=e_m).values
_min = self.get_timeseries('V_min', ensemble_member=e_m).values
V_storage = self.get_timeseries('V_storage', ensemble_member=e_m).values
# A little bit of tolerance when checking for acceptance. This
# tolerance must be set greater than the tolerance of the solver.
tol = 10
_max += tol
_min -= tol
n_level_satisfied = sum(
np.logical_and(_min <= V_storage, V_storage <= _max))
q_release_integral = sum(results['Q_release'])
self.intermediate_results[e_m].append((priority, n_level_satisfied,
q_release_integral))
def post(self):
super().post()
for e_m in range(self.ensemble_size):
print('\n\nResults for Ensemble Member {}:'.format(e_m))
for priority, n_level_satisfied, q_release_integral in \
self.intermediate_results[e_m]:
print("\nAfter finishing goals of priority {}:".format(priority))
print("Level goal satisfied at {} of {} time steps".format(
n_level_satisfied, len(self.times())))
print("Integral of Q_release = {:.2f}".format(q_release_integral))
# Any solver options can be set here
def solver_options(self):
options = super().solver_options()
# When mumps_scaling is not zero, errors occur. RTC-Tools does its own
# scaling, so mumps scaling is not critical. Proprietary HSL solvers
# do not exhibit this error.
solver = options['solver']
options[solver]['mumps_scaling'] = 0
options[solver]['print_level'] = 1
return options
# Run
run_optimization_problem(Example)
|
Running the Optimization Problem¶
Following the execution of the optimization problem, the post()
method
should print out the following lines:
Results for Ensemble Member 0:
After finishing goals of priority 1:
Level goal satisfied at 10 of 12 time steps
Integral of Q_release = 17.34
After finishing goals of priority 2:
Level goal satisfied at 9 of 12 time steps
Integral of Q_release = 17.12
Results for Ensemble Member 1:
After finishing goals of priority 1:
Level goal satisfied at 10 of 12 time steps
Integral of Q_release = 20.82
After finishing goals of priority 2:
Level goal satisfied at 9 of 12 time steps
Integral of Q_release = 20.60
This is the same output as the output for Mixed Integer Optimization: Pumps and Orifices, except now the output for each ensemble is printed.
Extracting Results¶
The results from the run are found in output/forecast1/timeseries_export.csv
and output/forecast2/timeseries_export.csv
. Any CSV-reading software can
import it, but this is how results can be plotted using the python library
matplotlib:
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from datetime import datetime
from pylab import get_cmap
forecast_names = ['forecast1', 'forecast2']
# Import Data
def get_results(forecast_name):
output_dir = '../../../examples/ensemble/output/'
data_path = output_dir + forecast_name + '/timeseries_export.csv'
delimiter = ','
ncols = len(np.genfromtxt(data_path, max_rows=1, delimiter=delimiter))
datefunc = lambda x: datetime.strptime(x, '%Y-%m-%d %H:%M:%S')
return np.genfromtxt(data_path, converters={0: datefunc}, delimiter=delimiter,
dtype='object' + ',float' * (ncols - 1), names=True, encoding=None)
# Generate Plot
n_subplots = 2
f, axarr = plt.subplots(n_subplots, sharex=True, figsize=(8, 4 * n_subplots))
axarr[0].set_title('Water Volume and Discharge')
cmaps = ['Blues', 'Greens']
shades = [0.5, 0.8]
f.autofmt_xdate()
# Upper Subplot
axarr[0].set_ylabel('Water Volume in Storage [m³]')
axarr[0].ticklabel_format(style='sci', axis='y', scilimits=(0, 0))
# Lower Subplot
axarr[1].set_ylabel('Flow Rate [m³/s]')
# Plot Ensemble Members
for idx, forecast in enumerate(forecast_names):
# Upper Subplot
results = get_results(forecast)
if idx == 0:
axarr[0].plot(results['time'], results['V_max'], label='Max',
linewidth=2, color='r', linestyle='--')
axarr[0].plot(results['time'], results['V_min'], label='Min',
linewidth=2, color='g', linestyle='--')
axarr[0].plot(results['time'], results['V_storage'], label=forecast + ':Volume',
linewidth=2, color=get_cmap(cmaps[idx])(shades[1]))
# Lower Subplot
axarr[1].plot(results['time'], results['Q_in'], label='{}:Inflow'.format(forecast),
linewidth=2, color=get_cmap(cmaps[idx])(shades[0]))
axarr[1].plot(results['time'], results['Q_release'], label='{}:Release'.format(forecast),
linewidth=2, color=get_cmap(cmaps[idx])(shades[1]))
# Shrink each axis by 30% and put a legend to the right of the axis
for i in range(len(axarr)):
box = axarr[i].get_position()
axarr[i].set_position([box.x0, box.y0, box.width * 0.7, box.height])
axarr[i].legend(loc='center left', bbox_to_anchor=(1, 0.5), frameon=False)
plt.autoscale(enable=True, axis='x', tight=True)
# Output Plot
plt.show()

Observations¶
Note that in the results plotted above, the control tree follows a single path and does not branch until it arrives at the first branching time. Up until the branching point, RTC-Tools is making decisions that enhance the flexibility of the system so that it can respond as ideally as possible to whichever future emerges. In the case of two forecasts, this means taking a path that falls between the two possible futures. This will cause the water level to diverge from the ideal levels as time progresses. While this appears to be suboptimal, it is preferable to simply gambling on one of the forecasts coming true and ignoring the other. Once the branching time is reached, RTC-Tools is allowed to optimize for each individual branch separately. Immediately, RTC-Tools applies the corrective control needed to get the water levels into the acceptable range. If the operator simply picks a forecast to use and guesses wrong, the corrective control will have to be much more drastic and potentially catastrophic.
Cascading Channels: Modeling Channel Hydraulics¶

Note
This is a more advanced example that implements multi-objective optimization in RTC-Tools. It also capitalizes on the homotopy techniques available in RTC-Tools. If you are a first-time user of RTC-Tools, see Filling a Reservoir.
Goal programming is a way to satisfy (sometimes conflicting) goals by ranking the goals by priority. In this example, we specify two goals. The higher priority goal will be to maintain the water levels in the channels within a desired band. The lower priority goal will be to extract water to meet a forecasted drinking water demand.
The Model¶
For this example, water is flowing through a multilevel channel system. The model has three channel sections. There is an extraction pump at the downstream end of the middle channel. The algorithm will first attempt to maintain water levels in the channels within the desired water level band. Using the remaining flexibility in the model, the algorithm will attempt to meet the diurnal demand pattern as best as it can with the extraction pump.
In OpenModelica Connection Editor, the model looks like this:

In text mode, the Modelica model looks as follows (with annotation statements removed):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | model Example
// Model Elements
Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Discharge Inflow;
Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Level DrinkingWaterPlant(H = 10.);
Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Level Level(H = 0.);
Deltares.ChannelFlow.Hydraulic.Branches.HomotopicLinear LowerChannel(H(each max = 1.0), H_b_down = -2.0, H_b_up = -1.5, friction_coefficient = 10., length = 2000., theta = theta, uniform_nominal_depth = 1.75, width_down = 10., width_up = 10.);
Deltares.ChannelFlow.Hydraulic.Branches.HomotopicLinear MiddleChannel(H(each max = 1.5), H_b_down = -1.5, H_b_up = -1.0, friction_coefficient = 10., length = 2000., theta = theta, uniform_nominal_depth = 1.75, width_down = 10., width_up = 10.);
Deltares.ChannelFlow.Hydraulic.Branches.HomotopicLinear UpperChannel(H(each max = 2.0), H_b_down = -1.0, H_b_up = -0.5, friction_coefficient = 10., length = 2000., theta = theta, uniform_nominal_depth = 1.75, width_down = 10., width_up = 10.);
Deltares.ChannelFlow.Hydraulic.Structures.Pump DrinkingWaterExtractionPump;
Deltares.ChannelFlow.Hydraulic.Structures.Pump LowerControlStructure;
Deltares.ChannelFlow.Hydraulic.Structures.Pump UpperControlStructure;
// Parameters
parameter Real theta;
// Inputs
input Real Inflow_Q(fixed = true) = Inflow.Q;
input Real UpperControlStructure_Q(fixed = false, min = 0., max = 4.) = UpperControlStructure.Q;
input Real LowerControlStructure_Q(fixed = false, min = 0., max = 4.) = LowerControlStructure.Q;
input Real DrinkingWaterExtractionPump_Q(fixed = false, min = 0., max = 2.) = DrinkingWaterExtractionPump.Q;
equation
connect(DrinkingWaterExtractionPump.HQDown, DrinkingWaterPlant.HQ);
connect(Inflow.HQ, UpperChannel.HQUp);
connect(LowerChannel.HQDown, Level.HQ);
connect(LowerControlStructure.HQDown, LowerChannel.HQUp);
connect(MiddleChannel.HQDown, DrinkingWaterExtractionPump.HQUp);
connect(MiddleChannel.HQDown, LowerControlStructure.HQUp);
connect(UpperChannel.HQDown, UpperControlStructure.HQUp);
connect(UpperControlStructure.HQDown, MiddleChannel.HQUp);
end Example;
|
The Optimization Problem¶
The python script consists of the following blocks:
- Import of packages
- Declaration of Goals
- Declaration of the optimization problem class
- Constructor
- Implementation of
pre()
method - Implementation of
path_goals()
method
- A run statement
Goals¶
In this model, we define two generic StateGoal subclasses:
13 14 15 16 17 18 19 20 21 | class RangeGoal(StateGoal):
def __init__(self, opt_prob, state, priority):
self.state = state
self.target_min = opt_prob.get_timeseries(state + "_min")
self.target_max = opt_prob.get_timeseries(state + "_max")
self.violation_timeseries_id = state + "_target_violation"
self.function_value_timeseries_id = state
self.priority = priority
super().__init__(opt_prob)
|
24 25 26 27 28 29 30 31 32 | class TargetGoal(StateGoal):
def __init__(self, opt_prob, state, priority):
self.state = state
self.target_min = opt_prob.get_timeseries(state + "_target")
self.target_max = self.target_min
self.violation_timeseries_id = state + "_target_violation"
self.function_value_timeseries_id = state
self.priority = priority
super().__init__(opt_prob)
|
These goals are actually really similar. The only difference is that the
TargetGoal
uses the same timeseries for its target_max
and
target_min
attributes. This goal will try to minimize the difference between
the target and the goal’s state. This is in contrast to the RangeGoal
, which
has a separate min and max that define an acceptable range of values.
You can read more about the components of goals in the documentation: Multi-objective optimization.
Optimization Problem¶
We construct the class by declaring it and inheriting the desired parent classes.
35 36 37 38 39 40 41 | class Example(
HomotopyMixin,
GoalProgrammingMixin,
CSVMixin,
ModelicaMixin,
CollocatedIntegratedOptimizationProblem,
):
|
In our new class, we implement the pre()
method. This method is a good place
to do some preprocessing of the data to make sure it is all there when the model
runs.
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 | def pre(self):
super().pre()
# Generate handy tuples to iterate over
self.channel_node_indices = tuple(range(1, self.channel_n_level_nodes + 1))
self.channel_level_nodes = tuple(
"{}.H[{}]".format(c, n)
for c, n in itertools.product(self.channels, self.channel_node_indices)
)
# Expand channel water level goals to all nodes
for channel in self.channels:
channel_max = self.get_timeseries(channel + "_max")
channel_min = self.get_timeseries(channel + "_min")
for i in self.channel_node_indices:
self.set_timeseries("{}.H[{}]_max".format(channel, i), channel_max)
self.set_timeseries("{}.H[{}]_min".format(channel, i), channel_min)
# Make input series appear in output csv
self.set_timeseries("Inflow_Q", self.get_timeseries("Inflow_Q"))
self.set_timeseries(
"DrinkingWaterExtractionPump_Q_target",
self.get_timeseries("DrinkingWaterExtractionPump_Q_target"),
)
|
Next, we instantiate the goals. The highest priority goal in this example will be to keep the water levels within a desired range. We apply this goal iteratively over all the water level states, and give them a priority of 1. The second goal is to track a target extraction flow rate with the extraction pump. We give this goal a priority of 2.
67 68 69 70 71 72 73 74 75 76 77 | def path_goals(self):
g = super().path_goals()
# Add RangeGoal on water level states with a priority of 1
for node in self.channel_level_nodes:
g.append(RangeGoal(self, node, 1))
# Add TargetGoal on Extraction Pump with a priority of 2
g.append(TargetGoal(self, "DrinkingWaterExtractionPump_Q", 2))
return g
|
We want to apply these goals to every timestep, so we use the path_goals()
method. This is a method that returns a list of the path goals we defined above.
Note that with path goals, each timestep is implemented as an independent goal—
if we cannot satisfy our min/max on time step A, it will not affect our desire
to satisfy the goal at time step B. Goals that inherit StateGoal
are always
path goals.
Run the Optimization Problem¶
To make our script run, at the bottom of our file we just have to call
the run_optimization_problem()
method we imported on the optimization
problem class we just created.
84 | run_optimization_problem(Example)
|
The Whole Script¶
All together, the whole example script is as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 | import itertools
from rtctools.optimization.collocated_integrated_optimization_problem import (
CollocatedIntegratedOptimizationProblem
)
from rtctools.optimization.csv_mixin import CSVMixin
from rtctools.optimization.goal_programming_mixin import GoalProgrammingMixin, StateGoal
from rtctools.optimization.homotopy_mixin import HomotopyMixin
from rtctools.optimization.modelica_mixin import ModelicaMixin
from rtctools.util import run_optimization_problem
class RangeGoal(StateGoal):
def __init__(self, opt_prob, state, priority):
self.state = state
self.target_min = opt_prob.get_timeseries(state + "_min")
self.target_max = opt_prob.get_timeseries(state + "_max")
self.violation_timeseries_id = state + "_target_violation"
self.function_value_timeseries_id = state
self.priority = priority
super().__init__(opt_prob)
class TargetGoal(StateGoal):
def __init__(self, opt_prob, state, priority):
self.state = state
self.target_min = opt_prob.get_timeseries(state + "_target")
self.target_max = self.target_min
self.violation_timeseries_id = state + "_target_violation"
self.function_value_timeseries_id = state
self.priority = priority
super().__init__(opt_prob)
class Example(
HomotopyMixin,
GoalProgrammingMixin,
CSVMixin,
ModelicaMixin,
CollocatedIntegratedOptimizationProblem,
):
channels = "LowerChannel", "MiddleChannel", "UpperChannel"
channel_n_level_nodes = 2
def pre(self):
super().pre()
# Generate handy tuples to iterate over
self.channel_node_indices = tuple(range(1, self.channel_n_level_nodes + 1))
self.channel_level_nodes = tuple(
"{}.H[{}]".format(c, n)
for c, n in itertools.product(self.channels, self.channel_node_indices)
)
# Expand channel water level goals to all nodes
for channel in self.channels:
channel_max = self.get_timeseries(channel + "_max")
channel_min = self.get_timeseries(channel + "_min")
for i in self.channel_node_indices:
self.set_timeseries("{}.H[{}]_max".format(channel, i), channel_max)
self.set_timeseries("{}.H[{}]_min".format(channel, i), channel_min)
# Make input series appear in output csv
self.set_timeseries("Inflow_Q", self.get_timeseries("Inflow_Q"))
self.set_timeseries(
"DrinkingWaterExtractionPump_Q_target",
self.get_timeseries("DrinkingWaterExtractionPump_Q_target"),
)
def path_goals(self):
g = super().path_goals()
# Add RangeGoal on water level states with a priority of 1
for node in self.channel_level_nodes:
g.append(RangeGoal(self, node, 1))
# Add TargetGoal on Extraction Pump with a priority of 2
g.append(TargetGoal(self, "DrinkingWaterExtractionPump_Q", 2))
return g
def post(self):
super().post()
# Run
run_optimization_problem(Example)
|
Extracting Results¶
The results from the run are found in output/timeseries_export.csv
. Any
CSV-reading software can import it, but this is how results can be plotted using
the python library matplotlib:

Simulation examples¶
This section provides examples demonstrating key features of RTC-Tools simulation.
Tracking a Setpoint¶

Overview¶
The purpose of this example is to understand the technical setup of an RTC- Tools simulation model, how to run the model, and how to access the results.
The scenario is the following: A reservoir operator is trying to keep the reservoir’s volume close to a given target volume. They are given a six-day forecast of inflows given in 12-hour increments. To keep things simple, we ignore the waterlevel-storage relation of the reservoir and head-discharge relationships in this example. To make things interesting, the reservoir operator is only able to release water at a few discrete flow rates, and only change the discrete flow rate every 12 hours. They have chosen to use the RTC- Tools simulator to see if a simple proportional controller will be able to keep the system close enough to the target water volume.
The folder <examples directory>\simulation
contains a complete RTC-Tools simulation problem. An RTC-Tools
directory has the following structure:
input
: This folder contains the model input data. These are several files in comma separated value format,csv
.model
: This folder contains the Modelica model. The Modelica model contains the physics of the RTC-Tools model.output
: The folder where the output is saved in the filetimeseries_export.csv
.src
: This folder contains a Python file. This file contains the configuration of the model and is used to run the model .
The Model¶
The first step is to develop a physical model of the system. The model can be viewed and edited using the OpenModelica Connection Editor (OMEdit) program. For how to download and start up OMEdit, see Getting OMEdit.
Make sure to load the Deltares library before loading the example:
- Load the Deltares library into OMEdit
- Using the menu bar: File -> Open Model/Library File(s)
- Select
<library directory>\Deltares\package.mo
- Load the example model into OMEdit
- Using the menu bar: File -> Open Model/Library File(s)
- Select
<examples directory\simulation\model\Example.mo
Once loaded, we have an OpenModelica Connection Editor window that looks like this:

The model Example.mo
represents a simple system with the following
elements:
- a reservoir, modeled as storage element
Deltares.ChannelFlow.SimpleRouting.Storage.Storage
, - an inflow boundary condition
Deltares.ChannelFlow.SimpleRouting.BoundaryConditions.Inflow
, - an outfall boundary condition
Deltares.ChannelFlow.SimpleRouting.BoundaryConditions.Terminal
, - connectors (black lines) connecting the elements.
You can use the mouse-over feature help to identify the predefined models from the Deltares library. You can also drag the elements around- the connectors will move with the elements. Adding new elements is easy- just drag them in from the Deltares Library on the sidebar. Connecting the elements is just as easy- click and drag between the ports on the elements.
In text mode, the Modelica model looks as follows (with annotation statements removed):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | model Example
// Elements
Deltares.ChannelFlow.SimpleRouting.BoundaryConditions.Inflow inflow(Q = Q_in);
Deltares.ChannelFlow.SimpleRouting.Storage.Storage storage(Q_release = P_control, V(start=storage_V_init, fixed=true, nominal=4e5));
Deltares.ChannelFlow.SimpleRouting.BoundaryConditions.Terminal outfall;
// Initial States
parameter Modelica.SIunits.Volume storage_V_init;
// Inputs
input Modelica.SIunits.VolumeFlowRate P_control(fixed = true);
input Modelica.SIunits.VolumeFlowRate Q_in(fixed = true);
input Modelica.SIunits.VolumeFlowRate storage_V_target(fixed = true);
// Outputs
output Modelica.SIunits.Volume storage_V = storage.V;
output Modelica.SIunits.VolumeFlowRate Q_release = P_control;
equation
connect(inflow.QOut, storage.QIn);
connect(storage.QOut, outfall.QIn);
end Example;
|
The three water system elements (storage, inflow, and outfall) appear under
the model Example
statement. The equation
part connects these three
elements with the help of connections. Note that storage
extends the partial
model QSISO
which contains the connectors QIn
and QOut
.
With QSISO
, storage
can be connected on two sides. The storage
element also has a variable Q_release
, which is the decision variable the
operator controls.
OpenModelica Connection Editor will automatically generate the element and
connector entries in the text text file. Defining inputs and outputs requires
editing the text file directly and assigning the inputs to the appropriate
element variables. For example, inflow(Q = Q_in)
sets the Q
variable
of the inflow
element equal to Q_in
.
In addition to elements, the input variables Q_in
and P_control
are
also defined. Q_in
is determined by the forecast and the operator cannot
control it, so we set Q_in(fixed = true)
. The actual values of Q_in
are stored in timeseries_import.csv
. P_control
is not defined anywhere
in the model or inputs- we will dynamically assign its value every timestep in
the python script, \src\example.py
.
Because we want to view the water volume in the storage element in the output
file, we also define an output variable storage_V
and set it equal to the
corresponding state variable storage.V
. Dito for Q_release = P_control
.
The Simulation Problem¶
The python script is created and edited in a text editor. In general, the python script consists of the following blocks:
- Import of packages
- Definition of the simulation problem class
- Any additional configuration (e.g. overriding methods)
- A run statement
Importing Packages¶
Packages are imported using from ... import ...
at the top of the file. In
our script, we import the classes we want the class to inherit, the
package run_simulation_problem
form the rtctools.util
package, and
any extra packages we want to use. For this example, the import block looks
like:
1 2 3 4 5 6 7 8 | import logging
from rtctools.simulation.csv_mixin import CSVMixin
from rtctools.simulation.simulation_problem import SimulationProblem
from rtctools.util import run_simulation_problem
logger = logging.getLogger("rtctools")
|
Simulation Problem¶
The next step is to define the simulation problem class. We construct the class by declaring the class and inheriting the desired parent classes. The parent classes each perform different tasks related to importing and exporting data and running the simulation problem. Each imported class makes a set of methods available to the our simulation class.
10 | class Example(CSVMixin, SimulationProblem):
|
The next, we override any methods where we want to specify non-default
behaviour. In our simulation problem, we want to define a proportional
controller. In its simplest form, we load the current values of the volume and
target volume variables, calculate their difference, and set P_control
to be
as close as possible to eliminating that difference during the upcoming timestep.
24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | def update(self, dt):
# Get the time step
if dt < 0:
dt = self.get_time_step()
# Get relevant model variables
volume = self.get_var('storage.V')
target = self.get_var('storage_V_target')
# Calucate error in storage.V
error = target - volume
# Calculate the desired control
control = -error / dt
# Get the closest feasible setting.
bounded_control = min(max(control, self.min_release), self.max_release)
# Set the control variable as the control for the next step of the simulation
self.set_var('P_control', bounded_control)
# Call the super class so that everything else continues as normal
super().update(dt)
|
Run the Simulation Problem¶
To make our script run, at the bottom of our file we just have to call
the run_simulation_problem()
method we imported on the simulation
problem class we just created.
51 | run_simulation_problem(Example, log_level=logging.DEBUG)
|
The Whole Script¶
All together, the whole example script is as follows:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | import logging
from rtctools.simulation.csv_mixin import CSVMixin
from rtctools.simulation.simulation_problem import SimulationProblem
from rtctools.util import run_simulation_problem
logger = logging.getLogger("rtctools")
class Example(CSVMixin, SimulationProblem):
"""
A basic example for introducing users to RTC-Tools 2 Simulation
"""
def initialize(self):
self.set_var('P_control', 0.0)
super().initialize()
# Min and Max flow rate that the storage is capable of releasing
min_release, max_release = 0.0, 8.0 # m^3/s
# Here is an example of overriding the update() method to show how control
# can be build into the python script
def update(self, dt):
# Get the time step
if dt < 0:
dt = self.get_time_step()
# Get relevant model variables
volume = self.get_var('storage.V')
target = self.get_var('storage_V_target')
# Calucate error in storage.V
error = target - volume
# Calculate the desired control
control = -error / dt
# Get the closest feasible setting.
bounded_control = min(max(control, self.min_release), self.max_release)
# Set the control variable as the control for the next step of the simulation
self.set_var('P_control', bounded_control)
# Call the super class so that everything else continues as normal
super().update(dt)
# Run
run_simulation_problem(Example, log_level=logging.DEBUG)
|
Running RTC-Tools¶
To run this basic example in RTC-Tools, navigate to the basic example src
directory in the RTC-Tools shell and run the example using python
example.py
. For more details about using RTC-Tools, see
Running RTC-Tools.
Extracting Results¶
The results from the run are found in output\timeseries_export.csv
. Any
CSV-reading software can import it. Here we used matplotlib to generate this plot.

Observations¶
This plot shows that the operator is not able to keep the water level within the bounds over the entire time horizon. They may need to increase the controller timestep, use a more complete PID controller, or use model predictive control such as the RTC-Tools optimization library to get the results they want.