Welcome to RTC-Tools Hydraulic Structures’s documentation!

Contents:

Getting Started

Installation

This package requires RTC-Tools 2 to be installed, including the ChannelFlow library.

Installation of the RTC-Tools Hydraulic Structures library then consists of the following steps:

# 1. Download the source code
https://gitlab.com/deltares/rtc-tools-hydraulic-structures.git

# 2. Move into the downloaded directory
cd rtc-tools-hydraulic structures

# 3. Install the Python modules
python -m pip install .

The Modelica library is not installed automatically, and needs to be copied manually. The location of RTC-Tools’s Modelica library root is typically something like C:\RTCTools2\mo on Windows. Copy the modelica/Deltares folder to this location.

Running an example

To make sure that everything is set-up correctly, you can run one of the example cases in examples/:

cd /path/to/rtc-tools-hydraulic-structures/examples/simple-pumping-station/src

python example.py

You will see the progress of RTC-Tools in your shell. If all is well, you should see something like the following output.

_images/example-output.png

Contribute

You can contribute to this code through Pull Request on GitLab. Please, make sure that your code is coming with unit tests to ensure full coverage and continuous integration in the API.

Support

Raise any issue on GitLab such that we can address your problem.

Python API

Pumping Station Mixin

class rtctools_hydraulic_structures.pumping_station_mixin.Pump(optimization_problem, symbol)[source]

Bases: rtctools_hydraulic_structures.util._ObjectParameterWrapper

Python Pump object as an interface to the Pump object in the model.

discharge()[source]

Get the state corresponding to the pump discharge.

Returns:MX expression of the pump discharge.
head()[source]

Get the state corresponding to the pump head. This depends on the head_option that was specified by the user.

Returns:MX expression of the pump head.
class rtctools_hydraulic_structures.pumping_station_mixin.Resistance(optimization_problem, symbol)[source]

Bases: rtctools_hydraulic_structures.util._ObjectParameterWrapper

Python Resistance object as an interface to the Resistance object in the model.

discharge()[source]

Get the state corresponding to the discharge through the resistance.

Returns:MX expression of the discharge.
head_loss()[source]

Get the state corresponding to the head loss over the resistance.

Returns:MX expression of the head loss.
class rtctools_hydraulic_structures.pumping_station_mixin.PumpingStation(optimization_problem, symbol, pump_symbols=None, **kwargs)[source]

Bases: rtctools_hydraulic_structures.util._ObjectParameterWrapper

Python PumpingStation object as an interface to the PumpingStation object in the model.

__init__(optimization_problem, symbol, pump_symbols=None, **kwargs)[source]

Initialize the pumping station object.

Parameters:
  • optimization_problemOptimizationProblem instance.
  • symbol – Symbol name of the pumping station in the model.
  • pump_symbols – Symbol names of the pumps in the pumping station.
pumps()[source]

Get a list of Pump objects that are part of this pumping station in the model.

Returns:List of Pump objects.
resistances()[source]

Get a list of Resistance objects that are part of this pumping station in the model.

Returns:List of Resistance objects.
class rtctools_hydraulic_structures.pumping_station_mixin.PumpingStationMixin(*args, **kwargs)[source]

Bases: rtctools.optimization.optimization_problem.OptimizationProblem

Adds handling of PumpingStation objects in your model to your optimization problem.

Relevant parameters and variables are read from the model, and from this data a set of constraints and objectives are automatically generated to minimize cost.

pumping_stations()[source]

User problem returns list of PumpingStation objects.

Returns:A list of pumping stations.
rtctools_hydraulic_structures.pumping_station_mixin.plot_operating_points(optimization_problem, output_folder, plot_expanded_working_area=True)[source]

Plot the working area of each pump with its operating points.

Weir Mixin

class rtctools_hydraulic_structures.weir_mixin.Weir(optimization_problem, name)[source]

Bases: rtctools_hydraulic_structures.util._ObjectParameterWrapper

Python Weir object as an interface to the Weir object in the model.

In the optimization, the weir flow is implemented as constraints. It means that the optimization calculated a flow (not weir height!), that is forced by the constraints to be a physically possible weir flow.

discharge()[source]

Get the state corresponding to the weir discharge.

Returns:MX expression of the weir discharge.
class rtctools_hydraulic_structures.weir_mixin.WeirMixin(*args, **kwargs)[source]

Bases: rtctools.optimization.optimization_problem.OptimizationProblem

Adds handling of Weir objects in your model to your optimization problem.

weirs()[source]

User problem returns list of Weir objects.

Returns:A list of weirs.

Modelica API

Pumping Station

The Modelica library Deltares.HydraulicStructures.PumpingStation is an extension to the Deltares.ChannelFlow.Hydraulic library, which is part of the ChannelFlow library. It consists of the following components:

Pump
A pump model with a QHP (discharge, head, power) relationship, to be used for optimization of e.g. costs. It extends Deltares.ChannelFlow.Hydraulic.Structures.Pump
Resistance
Quadratic resistance.
PumpingStation
Encapsulating class for Pump and Resistance objects.

Note

Pump and Resistance objects should always be placed inside a PumpingStation object.

Pump

class Pump : Deltares::ChannelFlow::Hydraulic::Structures::Pump

Represents a single pump object. Because the power of the pump is seldom a linear function of Q and H, this class is wrapped by the Python API’s Pump which turns the power equation specified by power_coefficients into a set of inequality constraints:

\[\begin{split}\begin{aligned} \ P &\ge C_{1,1} + C_{1,2} Q + \ldots \\[5pt] \lim_{Q \to 0} P &= 0 \ \end{aligned}\end{split}\]

With minimization of pumping costs (i.e. power), the optimization results will satisfy the first inequality constraint as if it was an equality constraint.

Real power_coefficients[:, :]

The power coefficients describe the relationship between the discharge, head and power. For example, one can consider a fit of the pump power of the general form:

\[P = C_{1,1} + C_{1,2} Q + C_{2,1} H + C_{2,2} Q H + C_{1,3} Q^2 + \ldots\]

The power coefficients matrix corresponds to the coefficients C in the equation above. To guarantee that optimization finds a good and stable solution, we require the coefficients of this polynomial to be chosen such that the polynomial is convex over the entire domain.

Note

Strictly speaking it would only have to be convex over the (automatically) extended working area domain, the size of which is not always known before run-time.

Real working_area[:, :, :]

The working area array describes the polynomials bounding the convex set of allowed Q-H coordinates. These polynomials typically arise from one of the following properties:

  • Q-H curve at minimum pump speed
  • Q-H curve at maximum pump speed
  • Minimum required efficiency (e.g. 50%)
  • Minimum and/or maximum input power constraint
  • Cavitation constraints
  • NPSH constraints

The first coordinate of the array is the polynomial number. For example, working_area[1, :, :] would describe the first working area polynomial. The order of Q and H coefficients is the same as in power_coefficients.

Real working_area_direction[:]

The polynomials in working_area describe the polynomials, but do not yet indicate what side of this polynomial the Q-H combination has to be on. So for each of the polynomials in the working area we have to specify whether the expression should evaluate to a positive expression (=1), or a negative expression (=-1).

Note

It may become unnecessary to specify this in the future, if it is possible to figure out a way to determine this automatically based on the polynomials and their crossing points.

Integer head_option = 0

What head to use for the pump head. This can be one of the following three options:

-1
The upstream head
0
The differential head (i.e. downstream head minus upstream head)
1
The downstream head.
Modelica::SIunits::Duration minimum_on = 0.0

The minimum amount of time in seconds a pump needs to be on before allowed to switch off again. This applies to all pumps in this pumping station.

Note

Only multiples of the (equidistant) time step are allowed.

Modelica::SIunits::Duration minimum_off = 0.0

The minimum amount of time in seconds a pump needs to be off before allowed to switch on again. This applies to all pumps in this pumping station.

Note

Only multiples of the (equidistant) time step are allowed.

Modelica::SIunits::Energy start_up_energy = 0.0

The energy needed to start a pump. This will be multiplied with the energy price to calculate the costs.

Real start_up_cost = 0.0

Costs in e.g. EUR or kg CO2 associated with a pump start up. Many pump switches could for example mean the pump life is shortened, or that more maintenance is needed. These could then be expressed in monetary value, and associated with pump start up.

Important

Make sure that the units of this value are of the same units as start_up_energy times the energy price.

Modelica::SIunits::Energy shut_down_energy = 0.0

Energy needed to shut down a pump. See equivalent parameter for pump start start_up_energy for more information.

Real shut_down_cost = 0.0

Cost associated with a pump shutdown. See equivalent parameter for pump start start_up_cost for more information.

Resistance

class Resistance : Deltares::ChannelFlow::Internal::HQTwoPort

Represents a single quadratic resistance object relating the head loss to the discharge:

\[dH = C \cdot Q^2\]

Because a non-linear equality constraint is not allowed in convex optimization, this class is wrapped by the Python API’s Resistance which turns it into two inequality constraints:

\[\begin{split}\begin{aligned} \ dH &\ge C \cdot Q^2 \\[5pt] \lim_{Q \to 0} dH &= 0 \ \end{aligned}\end{split}\]

With minimization of pumping costs (i.e. power), the optimization results will satisfy the first inequality constraint as if it was an equality constraint, provided the power relationship of every pump is monotonically increasing with H.

Note

Only positive flow is allowed (read: enforced).

Real C = 0.0

PumpingStation

class PumpingStation : Deltares::ChannelFlow::Internal::HQTwoPort

Represents a pumping station object, containing one or more Pump or Resistance objects.

Integer n_pumps

The number of pumps contained in the pumping station. This is necessary to enforce the right size of e.g. the pump_switching_matrix.

Integer pump_switching_matrix[n_pumps, n_pumps] = -999

Together with pump_switching_constraints describes which pumps are allowed to be on at the same time. The default value of -999 will make Python fill it with the default matrix. This default matrix implies that the second pump can only be on when the first pump is on, that the third pump can only be on when the second pump is on, etc.

In matrix multiplication form

\[b[:,1] \le A \cdot x \le b[:,2]\]

with \(A\) the pump_switching_matrix, \(b\) the pump_switching_constraints, and \(x\) the vector of pump statuses:

\[\begin{split}x = \left[\begin{array}{cc}S_1 \\ S_2 \\ S_3 \\ \vdots \end{array}\right]\end{split}\]

where \(S_1\) is the status of pump 1 (on = 1, off = 0).

So the default matrix, where a pump being on requires all lower numbered pumps to be on as well, can be expressed as follows:

\[\begin{split}A = \left[\begin{array}{ccc}0 & 0 & 0\\1 & -1 & 0\\1 & 1 & -2\end{array}\right]\end{split}\]

with pump_switching_constraints equal to:

\[\begin{split}b = \left[\begin{array}{cc}-\infty & \infty\\0 & \infty\\0 & \infty\end{array}\right]\end{split}\]

To allow all pumps to switch independently from each other, it is sufficient to set the coefficient matrix to all zeros (e.g. pump_switching_matrix = fill(0, n_pumps, n_pumps)). For rows in the matrix not containing any non- zero values, the accompanying constraints are not applied.

Note

Only square matrices allowed, i.e. a single constraint per pump.

Integer pump_switching_constraints[n_pumps, 2]

See discussion in pump_switching_matrix.

Weir

class Weir : Deltares::ChannelFlow::Internal::HQTwoPort

Represents a general movable-crest weir object described by the conventional weir equation (see e.g. Swamee, Prabhata K. “Generalized rectangular weir equations.” Journal of Hydraulic Engineering 114.8 (1988): 945-949.):

\[Q = \frac{2}{3} C B \sqrt{2g} \left( H - H_w \right)^{1.5}\]

where Q is the discharge of the weir, C is the weir discharge coefficient (very well approximated by 0.61), B is the width of the weir, g is the acceleration of gravity, H is the water level, and \(H_w\) is the level of the movable weir crest. The equation assumes critical flow over the weir crest.

Modelica::SIunits::Length width

The physical width of the weir.

Modelica::SIunits::VolumeFlowRate q_min

The minimal possible discharge on this weir. It can be known from the physical characteristics of the system. The linear approximation works the best if this is set as tight as possible. It is allowed to set it to zero.

Modelica::SIunits::VolumeFlowRate q_max

The maximum physically possible flow over the weir. It should be set as tight as possible

Modelica::SIunits::Length hw_min

The minimal possible crest elevation.

Modelica::SIunits::Length hw_max

The maximum possible crest elevation.

Real weir_coef = 0.61

The discharge coefficient of the weir. Typically the default value of 0.61.

Examples

Pumping Station

Basic Pumping Station

_images/Woudagemaal.jpg

Note

This example focuses on how to implement optimization for pumping stations in RTC-Tools using the Hydraulic Structures library. It assumes basic exposure to RTC-Tools. If you are a first-time user of RTC-Tools, please refer to the RTC-Tools documentation.

The purpose of this example is to understand the technical setup of a model with the Hydraulic Structures Pumping Station object, how to run the model, and how to interpret the results.

The scenario is the following: A pumping station with a single pump is trying to keep an upstream polder in an allowable water level range. Downstream of the pumping station is a sea with a (large) tidal range, but the sea level never drops below the polder level. The price on the energy market fluctuates, and the goal of the operator is to keep the polder water level in the allowable range while minimizing the pumping costs.

The folder examples/pumping_station/basic contains the complete RTC-Tools optimization problem.

The Model

For this example, the model represents a typical setup for a polder pumping station in lowland areas. The inflow from precipitation and seepage is modeled as a discharge (left side), with the total surface area / volume of storage in the polder modeled as a linear storage. The downstream water level is assumed to not be (directly) influenced by the pumping station, and therefore modeled as a boundary condition.

Operating the pumps to discharge the water in the polder consumes power, which varies based on the head difference and total flow. In general, the lower the head difference or discharge, the lower the power needed to pump water.

The expected result is therefore that the model computes a control pattern that makes use of these tidal and energy fluctuations, pumping water when the sea water level is low and/or energy is cheap. It is also expected that as little water as necessary is pumped, i.e. the storage available in the polder is used to its fullest. Concretely speaking this means that the water level at the last time step will be very close (or equal) to the maximum water level.

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/pumping_station/basic/model/Example.mo. The model Example.mo represents a simple water system with the following elements:

  • the polder canals, 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 pumping station MyPumpingStation extending Deltares.HydraulicStructures.PumpingStation.PumpingStation
_images/simple-pumping-station-omedit.png

Note it is a nested model. In other words, we have defined our own MyPumpingStation model, which is in itself part of the Example model. You can add classes (e.g. models) to an existing model in the OpenModelica Editor by right clicking your current model (e.g. Example) –> New Modelica Class. Make sure to extend the Deltares.HydraulicStructures.PumpingStation.PumpingStation class.

If we navigate into our nested MyPumpingStation model, we have the following elements:

  • our single pump Deltares.HydraulicStructures.PumpingStation.Pump,
  • a resistance Deltares.HydraulicStructures.PumpingStation.Resistance,
_images/simple-pumping-station-mypumpingstation-medit.png

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
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
model Example

  model MyPumpingStation
    extends Deltares.HydraulicStructures.PumpingStation.PumpingStation(
      n_pumps=1
    );

    Deltares.HydraulicStructures.PumpingStation.Pump pump1(
      power_coefficients = {{3522.8, -27946.3, 54484.8},
                            {1665.43, 5827.81,     0.0},
                            {208.251,     0.0,     0.0}},

      working_area = {{{ -5.326999,  54.050758,  0.000000},
                       { -1.0,        0.0,       0.0}},
                      {{  0.000426,  -0.001241,  2.564056},
                       { -1.0,        0.0,       0.0}},
                      {{  2.577975,  -5.203480,  0.000000},
                       { -1.0,        0.0,       0.0}},
                      {{ 13.219650,  -3.097600, -7.551339},
                       { -1.0,        0.0,       0.0}}},

      working_area_direction = {1, -1, -1, 1},

      minimum_on=3.0*3600
    );
    Deltares.HydraulicStructures.PumpingStation.Resistance resistance1(C=1.0);
  equation
    connect(HQUp, resistance1.HQUp);
    connect(resistance1.HQDown, pump1.HQUp);
    connect(pump1.HQDown, HQDown);
  end MyPumpingStation;

 // Elements in model flow chart
  Deltares.ChannelFlow.Hydraulic.Storage.Linear storage(
    A = 149000,
    H_b = -1.0,
    HQ.H(min = -0.7, max = 0.2),
    V(nominal = 1E5)
  );
  Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Level sea;
  Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Discharge inflow;
  MyPumpingStation pumpingstation1;

  // Input variables
  input Modelica.SIunits.VolumeFlowRate Q_in(fixed = true);
  input Modelica.SIunits.Position H_ext(fixed=true);

  // Energy price is typically of units EUR/kWh (when optimizing for energy
  // usage), but one can also choose for e.g. ton CO2/kWh to get the lowest
  // CO2 output.
  input Real energy_price(fixed=true);

  // NOTE: Because we cannot flag each pump's .Q as "input", we need an extra
  // variable to do this. Format is expected to be the fully specified name,
  // with all dots replaced with underscores.
  input Real pumpingstation1_pump1_Q;
  // TODO: Move bounds to the mixin.
  input Real pumpingstation1_resistance1_dH(min=0.0, max=10.0);

  // Output variables
  output Modelica.SIunits.Position storage_level;
  output Modelica.SIunits.Position sea_level;
equation
  connect(pumpingstation1.HQUp, storage.HQ);
  connect(pumpingstation1.HQDown, sea.HQ);
  connect(inflow.HQ, storage.HQ);
  // Mapping of variables
  inflow.Q = Q_in;
  sea.H = H_ext;
  pumpingstation1.pump1.Q = pumpingstation1_pump1_Q;
  pumpingstation1.resistance1.dH = pumpingstation1_resistance1_dH;
  storage_level = storage.HQ.H;
  sea_level = H_ext;
end Example;

The attributes of pump1 are explained in detail in Pump.

In addition to the elements, two input variables pumpingstation1_pump1_Q and pumpingstation1_resistance1_dH are also defined, with a set of equations matching them to their dot-equivalent (e.g. pumpingstation1.pump1.Q).

Important

Because nested input symbols cannot be detected, it is necessary for the user to manually map this symbol to an equivalent one with dots replaced with underscores.

The Optimization Problem

The python script consists of the following blocks:

  • Import of packages
  • Definition of water level goal
  • Definition of the optimization problem class
    • Constructor
    • Passing a list of pumping stations
    • 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 os
import sys

from rtctools.optimization.collocated_integrated_optimization_problem import CollocatedIntegratedOptimizationProblem
from rtctools.optimization.goal_programming_mixin import GoalProgrammingMixin, Goal, StateGoal
from rtctools.optimization.modelica_mixin import ModelicaMixin
from rtctools.optimization.pi_mixin import PIMixin
from rtctools.util import run_optimization_problem
from rtctools_hydraulic_structures.pumping_station_mixin import \

Note that we are importing both PumpingStationMixin and PumpingStation from rtctools_hydraulic_structures.pumping_station_mixin.

Water Level Goal

Next we define our water level range goal. It reads the desired upper and lower water levels from the optimization problem class. For more information about how this goal maps to an objective and constraints, we refer to the documentation of StateGoal.

13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class WaterLevelRangeGoal(StateGoal):
    """
    Goal that tries to keep the water level minum and maximum water level,
    the values of which are read from the optimization problem.
    """

    state = 'storage.HQ.H'

    priority = 1

    def __init__(self, optimization_problem):
        self.target_min = optimization_problem.wl_min
        self.target_max = optimization_problem.wl_max

        _range = self.target_max - self.target_min
        self.function_range = (self.target_min - _range, self.target_max + _range)

        super(WaterLevelRangeGoal, self).__init__(optimization_problem)
Optimization Problem

Then we construct the optimization problem class by declaring it and inheriting the desired parent classes.

33
34
class Example(PumpingStationMixin, GoalProgrammingMixin, PIMixin, ModelicaMixin,
              CollocatedIntegratedOptimizationProblem):

Now we define our pumping station objects, and store them in a local instance variable. We refer to this instance variable from the abstract method pumping_stations() we have to override.

48
49
50
51
52
53

        # Here we define a list of pumping stations, each consisting of a list
        # of pumps. In our case, there is only one pumping station containing
        # a single pump.
        self.__pumping_stations = [PumpingStation(self, 'pumpingstation1',
                                                 pump_symbols=['pumpingstation1.pump1'])]
55
56
57
58
59
    def pumping_stations(self):
        # This is the method that we must implement. It has to return a list of
        # PumpingStation objects, which we already initialized in the __init__
        # function. So here we just return that list.
        return self.__pumping_stations

Then we append our water level range goal to the list of path goals from our parents classes:

61
62
63
64
    def path_goals(self):
        goals = super(Example, self).path_goals()
        goals.append(WaterLevelRangeGoal(self))
        return goals

Note

The PumpingStationMixin sets a minimization goal for the costs, with priority equal to 999. There is no need to specify a minimization goal of costs yourself.

Finally, we want to apply some additional configuration, reducing the amount of information the solver outputs:

66
67
68
69
    def solver_options(self):
        options = super(Example, self).solver_options()
        options['print_level'] = 2
        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.

155
run_optimization_problem(Example, base_folder='..')
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
154
155
import os
import sys

from rtctools.optimization.collocated_integrated_optimization_problem import CollocatedIntegratedOptimizationProblem
from rtctools.optimization.goal_programming_mixin import GoalProgrammingMixin, Goal, StateGoal
from rtctools.optimization.modelica_mixin import ModelicaMixin
from rtctools.optimization.pi_mixin import PIMixin
from rtctools.util import run_optimization_problem
from rtctools_hydraulic_structures.pumping_station_mixin import \
    PumpingStationMixin, PumpingStation, plot_operating_points


class WaterLevelRangeGoal(StateGoal):
    """
    Goal that tries to keep the water level minum and maximum water level,
    the values of which are read from the optimization problem.
    """

    state = 'storage.HQ.H'

    priority = 1

    def __init__(self, optimization_problem):
        self.target_min = optimization_problem.wl_min
        self.target_max = optimization_problem.wl_max

        _range = self.target_max - self.target_min
        self.function_range = (self.target_min - _range, self.target_max + _range)

        super(WaterLevelRangeGoal, self).__init__(optimization_problem)


class Example(PumpingStationMixin, GoalProgrammingMixin, PIMixin, ModelicaMixin,
              CollocatedIntegratedOptimizationProblem):
    """
    An example showing the basic usage of the PumpingStationMixin. It consists of two goals:
    1. Keep water level in the acceptable range.
    2. Minimize power usage for doing so.
    """

    # Set the target minimum and maximum water levels.
    wl_min, wl_max = (-0.5, 0)

    def __init__(self, *args, **kwargs):
        super(Example, self).__init__(*args, **kwargs)

        self.__output_folder = kwargs['output_folder']  # So we can write our pictures to it

        # Here we define a list of pumping stations, each consisting of a list
        # of pumps. In our case, there is only one pumping station containing
        # a single pump.
        self.__pumping_stations = [PumpingStation(self, 'pumpingstation1',
                                                 pump_symbols=['pumpingstation1.pump1'])]

    def pumping_stations(self):
        # This is the method that we must implement. It has to return a list of
        # PumpingStation objects, which we already initialized in the __init__
        # function. So here we just return that list.
        return self.__pumping_stations

    def path_goals(self):
        goals = super(Example, self).path_goals()
        goals.append(WaterLevelRangeGoal(self))
        return goals

    def solver_options(self):
        options = super(Example, self).solver_options()
        options['print_level'] = 2
        return options

    def post(self):
        super(Example, self).post()

        results = self.extract_results()

        # TODO: Currently we use hardcoded references to pump1. It would be
        # prettier if we could generalize this so we can handle an arbitrary
        # number of pumps. It would also be prettier to replace hardcoded
        # references to e.g. pumpingstation1.pump1__power with something like
        # pumpingstation1.pump.power(), if at all possible.

        # Calculate the total amount of energy used. Note that QHP fit was
        # made to power in W, and that our timestep is 1 hour.
        powers = results['pumpingstation1.pump1__power'][1:]
        total_power = sum(powers)/1000
        print("Total power = {} kWh".format(total_power))

        # Make plots
        import matplotlib.dates as mdates
        import matplotlib.pyplot as plt
        import numpy as np

        plt.style.use('ggplot')

        def unite_legends(axes):
            h, l = [], []
            for ax in axes:
                tmp = ax.get_legend_handles_labels()
                h.extend(tmp[0])
                l.extend(tmp[1])
            return h, l

        # Plot #1: Data over time. X-axis is always time.
        f, axarr = plt.subplots(4, sharex=True)
        # TODO: Do not use private API of PIMixin
        times = self._timeseries_import.times

        axarr[0].set_ylabel('Water level\n[m]')
        axarr[0].plot(times, results['storage_level'], label='Polder',
                      linewidth=2, color='b')
        axarr[0].plot(times, self.wl_max * np.ones_like(times), label='Polder Max',
                      linewidth=2, color='r', linestyle='--')
        axarr[0].plot(times, self.wl_min * np.ones_like(times), label='Polder Min',
                      linewidth=2, color='g', linestyle='--')
        ymin, ymax = axarr[0].get_ylim()
        axarr[0].set_ylim(ymin - 0.1, ymax + 0.1)

        axarr[1].set_ylabel('Water level\n[m]')
        axarr[1].plot(times, self.get_timeseries('H_ext', 0).values, label='Sea',
                      linewidth=2, color='b')
        ymin, ymax = axarr[1].get_ylim()
        axarr[1].set_ylim(ymin - 0.5, ymax + 0.5)

        axarr[2].set_ylabel('Energy price\n[EUR/kWh]')
        axarr[2].step(times, self.get_timeseries('energy_price', 0).values, label='Energy price',
                      linewidth=2, color='b')
        ymin, ymax = axarr[2].get_ylim()
        axarr[2].set_ylim(-0.1, ymax + 0.1)

        axarr[3].set_ylabel('Discharge\n[$\mathdefault{m^3\!/s}$]')
        axarr[3].step(times, results['pumpingstation1.pump1.Q'], label='Pump',
                      linewidth=2, color='b')
        axarr[3].plot(times, self.get_timeseries('Q_in', 0).values, label='Inflow',
                      linewidth=2, color='g')
        ymin, ymax = axarr[3].get_ylim()

        axarr[3].set_ylim(-0.05 * (ymax - ymin), ymax * 1.1)
        axarr[3].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)

        # Output Plot
        f.set_size_inches(8, 9)
        plt.savefig(os.path.join(self._output_folder, 'overall_results.png'), bbox_inches='tight', pad_inches=0.1)

        # Plot the working area with the operating points of the pump.
        plot_operating_points(self, self._output_folder)

# Run
run_optimization_problem(Example, base_folder='..')
Results

The results from the run are found in output/timeseries_export.xml. Any PI-XML-reading software can import it.

The post() method in our Example class also generates some pictures to help understand what is going on.

First we have an overview of the relevant boundary conditions and control variables.

_images/overall_results.png

As expressed in the introduction of this example problem, we indeed see that the available buffer in the polder is used to its fullest. The water level at the final time step is (almost) equal to the maximum water level.

Furthermore, we see that the pump only discharges water when the water level is low. It is interesting to see that the optimal solution for costs means pumping at the lowest water level, even though the energy price is twice as high.

Two Pumps

Property of Wetterskip Fryslân

Note

This example focuses on how to put multiple pumps in a hydraulic model, and assumes basic exposure to RTC-Tools and the PumpingStationMixin. To start with basics of pump modeling, see Basic Pumping Station.

The purpose of this example is to understand the technical setup of a model with multiple pumps.

The scenario of this example is equal to that of Basic Pumping Station, but with two pumps available instead of one. The folder examples/pumping_station/two_pumps contains the complete RTC- Tools optimization problem. The discussion below will focus on the differences from the Basic Pumping Station.

The Model

The pumping station object MyPumpingStation looks as follows in its diagram representation in OpenModelica:

_images/two-pumps-mypumpingstation-medit.png

When modeling multiple pumps of the same type, it makes sense to define a model, which can then be instantiated into multiple objects. In the file Example.mo this can be seen in the submodel MyPump of MyPumpingStation:

 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
    model MyPump
      extends Deltares.HydraulicStructures.PumpingStation.Pump(
        power_coefficients = {{3522.8, -27946.3, 54484.8},
                              {1665.43, 5827.81,     0.0},
                              {208.251,     0.0,     0.0}},

        working_area = {{{ -5.326999,  54.050758,  0.000000},
                         { -1.0,        0.0,       0.0}},
                        {{  0.000426,  -0.001241,  2.564056},
                         { -1.0,        0.0,       0.0}},
                        {{  2.577975,  -5.203480,  0.000000},
                         { -1.0,        0.0,       0.0}},
                        {{ 13.219650,  -3.097600, -7.551339},
                         { -1.0,        0.0,       0.0}}},

        working_area_direction = {1, -1, -1, 1},

        minimum_on=3.0*3600);
    end MyPump;

The data of this pump is exactly equal to that used in basic-pumping- station, but is not instantiated yet. To instantiate two pumps using this data, we define two components pump1 and pump2:

28
29
    MyPump pump1;
    MyPump pump2;

Lastly, it is important not to forget to set the right number of pumps on the pumping station object:

3
4
5
6
  model MyPumpingStation
    extends Deltares.HydraulicStructures.PumpingStation.PumpingStation(
      n_pumps=2
    );
The Optimization Problem

When using multiple pumps it is important to specify the right order of pumps. This order should match the order of pumps in the pump_switching_matrix.

48
49
50
51
52
53
54

        # Here we define a list of pumping stations, each consisting of a list
        # of pumps. In our case, there is only one pumping station containing
        # a single pump.
        self.__pumping_stations = [PumpingStation(self, 'pumpingstation1',
                                                 pump_symbols=['pumpingstation1.pump1',
                                                               'pumpingstation1.pump2'])]

Weir

Basic Weir

https://beeldbank.rws.nl, Rijkswaterstaat / Bart van Eyck

Note

This example focuses on how to implement a controllable weir in RTC-Tools using the Hydraulic Structures library. It assumes basic exposure to RTC- Tools. If you are a first-time user of RTC-Tools, please refer to the RTC-Tools documentation.

The weir structure is valid for two flow conditions:

  • Free (critical) flow
  • No flow

Warning

Submerged flow is not supported.

Modeling
Building a model with a weir

In this example we are considering a system of two branches and a controllable weir in between. On the upstream side is a prescribed input flow, and on the downstream side is a prescribed output flow. The weir should move in such way that the water level in both branches is kept within the desired limits.

To build this model, we need the following blocks:
  • upstream and downstream discharge boundary conditions
  • two branches
  • a weir

By putting the blocks from the Modelica editor, the code is automatically generated (Note: this code snippet excludes the lines about the annotation and location):

 6
 7
 8
 9
10
11
  output Modelica.SIunits.Volume branch_2_water_level;
  Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Discharge Upstream;
  Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Discharge Downstream;
  Deltares.ChannelFlow.Hydraulic.Reservoir.Linear Branch1(A = 50, H_b = 0, H(nominal=1, min=0, max=100));
  Deltares.ChannelFlow.Hydraulic.Reservoir.Linear Branch2(A = 100, H_b = 0, H(nominal=1, min=0, max=100));
  Deltares.HydraulicStructures.Weir.Weir weir1(hw_max = 3, hw_min = 1.7, q_max = 1, q_min = 0, width = 10);

For the weir block, the dimensions of the weir should be set. It can be done either by double clicking to the block, or in the text editor. A controllable weir is represented with a weir block. This block has discharge and water level as input, and also as output. When a block is placed, the following parameters can be given: - width: the width of the crest in meters - hw_min: the minimum crest height - hw_max: the maximum crest height - q_min: the minimum expected discharge - q_max: the maximum expected discharge

The last two values should be estimated in such way that the discharge will not be able to go outside these bounds. However, for some linearization purposes, they should be as tight as possible. The values set by the text editor look like the line above.

The input variables are the upstream and downstream (known) discharges. The control variable - the variable that the algorithm changes until it achieves the desired results - is the discharge between the two branches. In practice, the weir height is the variable that we are interested in, but as it depends on the discharge between the two branches and the upstream water level, it will only be calculated in post processing. The input variables for the model are:

2
3
4
  input Modelica.SIunits.VolumeFlowRate upstream_q_ext(fixed = true);
  input Modelica.SIunits.VolumeFlowRate downstream_q_ext(fixed = true);
  input Modelica.SIunits.VolumeFlowRate WeirFlow1(fixed = false, nominal=1, min=0, max=2.5);

Important

The min, max and nominal the values should always be meaningful. For nominal, set the value that the variable most likely takes.

As output, we are interested in the water level in the two branches:

5
6
  output Modelica.SIunits.Volume branch_1_water_level;
  output Modelica.SIunits.Volume branch_2_water_level;

Now we have to define the equations. We have to set the boundary conditions. First the discharge should be read from the external files:

21
22
  Upstream.Q = upstream_q_ext;
  Downstream.Q = downstream_q_ext;

And then the water level should be defined equal to the water level in the branch:

17
18
  Branch1.HQDown.H=Branch1.H;
  Branch2.HQDown.H=Branch2.H;

As we use reservoirs for branches, the variables we do not need should be zero:

19
20
  Branch1.Q_turbine=0;
  Branch2.Q_turbine=0;

Finally the outputs are set:

24
25
  branch_1_water_level = Branch1.H;
  branch_2_water_level = Branch2.H;

and the control variable as well:

23
  WeirFlow1 = weir1.Q;

The whole model file looks like this:

 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
model WeirExample
  input Modelica.SIunits.VolumeFlowRate upstream_q_ext(fixed = true);
  input Modelica.SIunits.VolumeFlowRate downstream_q_ext(fixed = true);
  input Modelica.SIunits.VolumeFlowRate WeirFlow1(fixed = false, nominal=1, min=0, max=2.5);
  output Modelica.SIunits.Volume branch_1_water_level;
  output Modelica.SIunits.Volume branch_2_water_level;
  Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Discharge Upstream;
  Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Discharge Downstream;
  Deltares.ChannelFlow.Hydraulic.Reservoir.Linear Branch1(A = 50, H_b = 0, H(nominal=1, min=0, max=100));
  Deltares.ChannelFlow.Hydraulic.Reservoir.Linear Branch2(A = 100, H_b = 0, H(nominal=1, min=0, max=100));
  Deltares.HydraulicStructures.Weir.Weir weir1(hw_max = 3, hw_min = 1.7, q_max = 1, q_min = 0, width = 10);
equation
  connect(weir1.HQDown, Branch2.HQUp);
  connect(Branch1.HQDown, weir1.HQUp);
  connect(Branch2.HQDown, Downstream.HQ);
  connect(Upstream.HQ, Branch1.HQUp);
  Branch1.HQDown.H=Branch1.H;
  Branch2.HQDown.H=Branch2.H;
  Branch1.Q_turbine=0;
  Branch2.Q_turbine=0;
  Upstream.Q = upstream_q_ext;
  Downstream.Q = downstream_q_ext;
  WeirFlow1 = weir1.Q;
  branch_1_water_level = Branch1.H;
  branch_2_water_level = Branch2.H;
end WeirExample;
Optimization

In this example, we would like to achieve that the water levels in the branches stay in the prescribed limits. The easiest way to achieve this objective is through goal programming. We will define two goals, one goal for each branch. The goal is that the water level should be higher than the given minimum and lower than the given maximum. Any solution satisfying these criteria is equally attractive for us. In practice, in goal programming the goal violation value is taken to the order’th power in the objective function (see: Goal Programming). In our example, we use the file WeirExample.py. We define a class, and apart from the usual classes that we import for optimization problems, we also have to import the class WeirMixin:

37
38
39
40
class WeirExample(WeirMixin, GoalProgrammingMixin, CSVMixin, ModelicaMixin, CollocatedIntegratedOptimizationProblem):

    def __init__(self, *args, **kwargs):
        super(WeirExample, self).__init__(*args, **kwargs)

Now we have to define the weirs: in quotation marks should be the same name as used for the Modelica model. Now there is only one weir, and the definition looks like:

41
        self.__weirs = [Weir(self, 'weir1')]

In case of more weirs, the names can be separated with a comma, for example:

self._weirs = [Weir('weir1'), Weir('weir2')]

Lastly we have to override the abstract method the returns the list of weirs:

44
45
    def weirs(self):
        return self.__weirs
Adding goals

In this example there are two branches connected with a weir. On the upstream side is a prescribed input flow, and on the downstream side is a prescribed output flow. The weir should move in such way, that the water level in both branches kept within the desired limits. We can add a water level goal for the upstream branch:

13
14
15
16
17
18
19
20
21
22
class WLRangeGoal_01(StateGoal):

    def __init__(self, optimization_problem):
        self.state = 'Branch1.H'
        self.priority = 1

        self.target_min = optimization_problem.get_timeseries('h_min_branch1')
        self.target_max = optimization_problem.get_timeseries('h_max_branch1')

        super(WLRangeGoal_01, self).__init__(optimization_problem)

A similar goal can be added to the downstream branch.

Setting the solver

As it is a mixed integer problem, it is handy to set some options to control the solver. In this example, we set the allowable_gap to 0.005. It is used to specify the value of absolute gap under which the algorithm stops. This is bigger than the default. This gives lower expectations for the acceptable solution, and in this way, the time of iteration is less. This value might be different for every problem and might be adjusted a trial-and-error basis. For more information, see the documentation of the BONMIN solver User’s Manual)

The solver setting is the following:

47
48
49
50
51
    def solver_options(self):
        options = super(WeirExample, self).solver_options()
        options['allowable_gap'] = 0.005
        options['print_level'] = 2
        return options
Input data

In order to run the optimization, we need to give the boundary conditions and the water level bounds. This data is given as time-series in the file timeseries_import.csv.

The whole python file

The optimization file looks like:

  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
154
155
156
from rtctools.optimization.collocated_integrated_optimization_problem \
    import CollocatedIntegratedOptimizationProblem
from rtctools.optimization.goal_programming_mixin import GoalProgrammingMixin, StateGoal
from rtctools.optimization.modelica_mixin import ModelicaMixin
from rtctools.optimization.csv_mixin import CSVMixin
from rtctools.util import run_optimization_problem
from rtctools_hydraulic_structures.weir_mixin import WeirMixin, Weir, plot_operating_points

# There are two water level targets, with different priority.
# The water level should stay in the required range during all the simulation


class WLRangeGoal_01(StateGoal):

    def __init__(self, optimization_problem):
        self.state = 'Branch1.H'
        self.priority = 1

        self.target_min = optimization_problem.get_timeseries('h_min_branch1')
        self.target_max = optimization_problem.get_timeseries('h_max_branch1')

        super(WLRangeGoal_01, self).__init__(optimization_problem)


class WLRangeGoal_02(StateGoal):

    def __init__(self, optimization_problem):
        self.state = 'Branch2.H'
        self.priority = 2

        self.target_min = optimization_problem.get_timeseries('h_min_branch2')
        self.target_max = optimization_problem.get_timeseries('h_max_branch2')

        super(WLRangeGoal_02, self).__init__(optimization_problem)


class WeirExample(WeirMixin, GoalProgrammingMixin, CSVMixin, ModelicaMixin, CollocatedIntegratedOptimizationProblem):

    def __init__(self, *args, **kwargs):
        super(WeirExample, self).__init__(*args, **kwargs)
        self.__weirs = [Weir(self, 'weir1')]
        self.__output_folder = kwargs['output_folder']  # So we can write our pictures to it

    def weirs(self):
        return self.__weirs

    def solver_options(self):
        options = super(WeirExample, self).solver_options()
        options['allowable_gap'] = 0.005
        options['print_level'] = 2
        return options

    def path_goals(self):
        goals = super(WeirExample, self).path_goals()
        goals.append(WLRangeGoal_01(self))
        goals.append(WLRangeGoal_02(self))
        return goals

    def post(self):
        super(WeirExample, self).post()
        results = self.extract_results()

        # Make plots
        import matplotlib.dates as mdates
        import matplotlib.pyplot as plt
        import numpy as np
        import os

        plt.style.use('ggplot')

        def unite_legends(axes):
            h, l = [], []
            for ax in axes:
                tmp = ax.get_legend_handles_labels()
                h.extend(tmp[0])
                l.extend(tmp[1])
            return h, l

        # Plot #1: Data over time. X-axis is always time.
        f, axarr = plt.subplots(4, sharex=True)

        # TODO: Do not use private API of CSVMixin
        times = self._timeseries_times
        weir = self.weirs()[0]

        axarr[0].set_ylabel('Water level\n[m]')
        axarr[0].plot(times, results['branch_1_water_level'], label='Upstream',
                      linewidth=2, color='b')
        axarr[0].plot(times, self.get_timeseries('h_min_branch1').values, label='Upstream Max',
                      linewidth=2, color='r', linestyle='--')
        axarr[0].plot(times, self.get_timeseries('h_max_branch1').values, label='Upstream Min',
                      linewidth=2, color='g', linestyle='--')
        ymin, ymax = axarr[0].get_ylim()
        axarr[0].set_ylim(ymin - 0.1, ymax + 0.1)

        axarr[1].set_ylabel('Water level\n[m]')
        axarr[1].plot(times, results['branch_2_water_level'], label='Downstream',
                      linewidth=2, color='b')
        axarr[1].plot(times, self.get_timeseries('h_max_branch2').values, label='Downstream Max',
                      linewidth=2, color='r', linestyle='--')
        axarr[1].plot(times, self.get_timeseries('h_min_branch2').values, label='Downstream Min',
                      linewidth=2, color='g', linestyle='--')
        ymin, ymax = axarr[1].get_ylim()
        axarr[1].set_ylim(ymin - 0.1, ymax + 0.1)

        axarr[2].set_ylabel('Discharge\n[$\mathdefault{m^3\!/s}$]')
        # We need the first point for plotting, but its value does not
        # necessarily make sense as it is not included in the optimization.
        weir_flow = results['WeirFlow1']
        weir_height = results["weir1_height"]
        minimum_water_level_above_weir = (weir_flow-weir.q_nom)/weir.slope + weir.h_nom

        minimum_weir_height = minimum_water_level_above_weir - ((weir_flow/weir.c_weir)**(2.0/3.0))

        minimum_weir_height[0] = minimum_weir_height[1]

        weir_flow = results['WeirFlow1']
        weir_flow[0] = weir_flow[1]

        axarr[2].step(times, weir_flow, label='Weir',
                      linewidth=2, color='b')
        axarr[2].step(times, self.get_timeseries('upstream_q_ext').values, label='Inflow',
                      linewidth=2, color='r', linestyle='--')
        axarr[2].step(times, -1 * self.get_timeseries('downstream_q_ext').values, label='Outflow',
                      linewidth=2, color='g', linestyle='--')
        ymin, ymax = axarr[2].get_ylim()
        axarr[2].set_ylim(-0.1, ymax + 0.1)

        weir_height = results["weir1_height"]
        weir_height[0] = weir_height[1]

        axarr[3].set_ylabel('Weir height\n[m]')
        axarr[3].step(times, weir_height, label='Weir',
                      linewidth=2, color='b')
        ymin, ymax = axarr[3].get_ylim()
        ymargin = 0.1 * (ymax - ymin)
        axarr[3].set_ylim(ymin - ymargin, ymax + ymargin)
        axarr[3].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)

        # Output Plot
        f.set_size_inches(8, 9)
        plt.savefig(os.path.join(self.__output_folder, 'overall_results.png'),
                    bbox_inches='tight', pad_inches=0.1)

        plot_operating_points(self, self._output_folder, results)


if __name__ == "__main__":
    run_optimization_problem(WeirExample)
Results

After successful optimization the results are printed in the time series export file. After running this example, the following results are expected:

_images/Results.png

The file found in the example folder includes some visualization routines.

Interpretation of the results

The results of this simulation are summarized in the following figure:

_images/overall_results1.png

In this example, the input flow increases after 6 minutes, while the downstream flow is kept constant. While the inflow drops after 6 minutes, the result is not seen in the upstream branch, because the weir moves up to compensate it. After the weir moved up, the water level drops in the downstream branch.

Indices and tables