# 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=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=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:

```
from datetime import datetime
import matplotlib.dates as mdates
import matplotlib.pyplot as plt
import numpy as np
# Import Data
data_path = "../../../examples/mixed_integer/output/timeseries_export.csv"
results = np.recfromcsv(data_path, encoding=None)
# Get times as datetime objects
times = [datetime.strptime(x, "%Y-%m-%d %H:%M:%S") for x in results["time"]]
# Generate Plot
fig, 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(times, results["storage_level"], label="Storage", linewidth=2, color="b")
axarr[0].plot(times, results["sea_level"], label="Sea", linewidth=2, color="m")
axarr[0].plot(
times,
0.5 * np.ones_like(times),
label="Storage Max",
linewidth=2,
color="r",
linestyle="--",
)
# Lower Subplot
axarr[1].set_ylabel("Flow Rate [m³/s]")
# add dots to clarify where the decision variables are defined:
axarr[1].scatter(times, results["q_orifice"], linewidth=1, color="g")
axarr[1].scatter(times, results["q_pump"], linewidth=1, color="r")
# add horizontal lines to the left of these dots, to indicate that the value is attained over an entire timestep:
axarr[1].step(times, results["q_orifice"], linewidth=2, where='pre', label="Orifice", color="g")
axarr[1].step(times, results["q_pump"], linewidth=1, where='pre', label="Pump", color="r")
# Format bottom axis label
axarr[-1].xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
# Shrink margins
fig.tight_layout()
# Shrink each axis 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()
```

(Source code, svg, png)

## 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.