Cascading Channels: Modeling Channel Hydraulics

../../_images/pont-du-gard-1742029_1280.jpg

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:

../../_images/cascading_channels_omedit.png

In text mode, the Modelica model looks as follows (with annotation statements removed):

 1model Example
 2  // Model Elements
 3  Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Discharge Inflow;
 4  Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Level DrinkingWaterPlant(H = 10.);
 5  Deltares.ChannelFlow.Hydraulic.BoundaryConditions.Level Level;
 6  Deltares.ChannelFlow.Hydraulic.Branches.HomotopicLinear LowerChannel(
 7    H(each max = 1.0),
 8    H_b_down = -2.0,
 9    H_b_up = -1.5,
10    friction_coefficient = 10.,
11    length = 2000.,
12    theta = theta,
13    uniform_nominal_depth = 1.75,
14    width_down = 10.,
15    width_up = 10.,
16    semi_implicit_step_size = step_size,
17    Q_nominal = 1.0
18  );
19  Deltares.ChannelFlow.Hydraulic.Branches.HomotopicLinear MiddleChannel(
20    H(each max = 1.5),
21    H_b_down = -1.5,
22    H_b_up = -1.0,
23    friction_coefficient = 10.,
24    length = 2000.,
25    theta = theta,
26    uniform_nominal_depth = 1.75,
27    width_down = 10.,
28    width_up = 10.,
29    semi_implicit_step_size = step_size,
30    Q_nominal = 1.0
31  );
32  Deltares.ChannelFlow.Hydraulic.Branches.HomotopicLinear UpperChannel(
33    H(each max = 2.0),
34    H_b_down = -1.0,
35    H_b_up = -0.5,
36    friction_coefficient = 10.,
37    length = 2000.,
38    theta = theta,
39    uniform_nominal_depth = 1.75,
40    width_down = 10.,
41    width_up = 10.,
42    semi_implicit_step_size = step_size,
43    Q_nominal = 1.0
44  );
45  Deltares.ChannelFlow.Hydraulic.Structures.Pump DrinkingWaterExtractionPump;
46  Deltares.ChannelFlow.Hydraulic.Structures.Pump LowerControlStructure;
47  Deltares.ChannelFlow.Hydraulic.Structures.Pump UpperControlStructure;
48  // Parameters
49  parameter Real theta;
50  parameter Real step_size;
51  // Inputs
52  input Real Inflow_Q(fixed = true) = Inflow.Q;
53  input Real UpperControlStructure_Q(fixed = false, min = 0., max = 4.) = UpperControlStructure.Q;
54  input Real LowerControlStructure_Q(fixed = false, min = 0., max = 4.) = LowerControlStructure.Q;
55  input Real DrinkingWaterExtractionPump_Q(fixed = false, min = 0., max = 2.) = DrinkingWaterExtractionPump.Q;
56initial equation
57  // Steady state initialization
58  der(LowerChannel.Q) = 0;
59  der(MiddleChannel.Q) = 0;
60  der(UpperChannel.Q) = 0;
61equation
62  connect(DrinkingWaterExtractionPump.HQDown, DrinkingWaterPlant.HQ);
63  connect(Inflow.HQ, UpperChannel.HQUp);
64  connect(LowerChannel.HQDown, Level.HQ);
65  connect(LowerControlStructure.HQDown, LowerChannel.HQUp);
66  connect(MiddleChannel.HQDown, DrinkingWaterExtractionPump.HQUp);
67  connect(MiddleChannel.HQDown, LowerControlStructure.HQUp);
68  connect(UpperChannel.HQDown, UpperControlStructure.HQUp);
69  connect(UpperControlStructure.HQDown, MiddleChannel.HQUp);
70end Example;

Important

Modellers should take care to set proper values for the initial derivatives, in order to avoid spurious waves at the start of the optimization run. In this example we assume a steady state initial condition for all states except those acted upon by the PID controllers.

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 parameters() method

    • Implementation of path_goals() method

  • A run statement

Goals

In this model, we define two generic StateGoal subclasses:

13class RangeGoal(StateGoal):
14    def __init__(self, opt_prob, state, priority):
15        self.state = state
16        self.target_min = opt_prob.get_timeseries(state + "_min")
17        self.target_max = opt_prob.get_timeseries(state + "_max")
18        self.violation_timeseries_id = state + "_target_violation"
19        self.function_value_timeseries_id = state
20        self.priority = priority
21        super().__init__(opt_prob)
24class TargetGoal(StateGoal):
25    def __init__(self, opt_prob, state, priority):
26        self.state = state
27        self.target_min = opt_prob.get_timeseries(state + "_target")
28        self.target_max = self.target_min
29        self.violation_timeseries_id = state + "_target_violation"
30        self.function_value_timeseries_id = state
31        self.priority = priority
32        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.

35class Example(
36    HomotopyMixin,
37    GoalProgrammingMixin,
38    CSVMixin,
39    ModelicaMixin,
40    CollocatedIntegratedOptimizationProblem,
41):

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    def pre(self):
46        super().pre()
47        # Generate handy tuples to iterate over
48        self.channel_node_indices = tuple(range(1, self.channel_n_level_nodes + 1))
49        self.channel_level_nodes = tuple(
50            "{}.H[{}]".format(c, n)
51            for c, n in itertools.product(self.channels, self.channel_node_indices)
52        )
53        # Expand channel water level goals to all nodes
54        for channel in self.channels:
55            channel_max = self.get_timeseries(channel + "_max")
56            channel_min = self.get_timeseries(channel + "_min")
57            for i in self.channel_node_indices:
58                self.set_timeseries("{}.H[{}]_max".format(channel, i), channel_max)
59                self.set_timeseries("{}.H[{}]_min".format(channel, i), channel_min)
60        # Make input series appear in output csv
61        self.set_timeseries("Inflow_Q", self.get_timeseries("Inflow_Q"))
62        self.set_timeseries(
63            "DrinkingWaterExtractionPump_Q_target",
64            self.get_timeseries("DrinkingWaterExtractionPump_Q_target"),
65        )

Next, we implement the parameters() method. This method passes parameter values down to the model. The model uses the step size parameter to perform a semi-implicit discretization of the hydraulic equations. We set the step_size parameter value to match the time step size in the input time series.

67    def parameters(self, ensemble_member):
68        p = super().parameters(ensemble_member)
69        times = self.times()
70        p["step_size"] = times[1] - times[0]
71        return p

Finally, 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.

73    def path_goals(self):
74        g = super().path_goals()
75
76        # Add RangeGoal on water level states with a priority of 1
77        for node in self.channel_level_nodes:
78            g.append(RangeGoal(self, node, 1))
79
80        # Add TargetGoal on Extraction Pump with a priority of 2
81        g.append(TargetGoal(self, "DrinkingWaterExtractionPump_Q", 2))
82
83        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.

90run_optimization_problem(Example)

The Whole Script

All together, the whole example script is as follows:

 1import itertools
 2
 3from rtctools.optimization.collocated_integrated_optimization_problem import (
 4    CollocatedIntegratedOptimizationProblem,
 5)
 6from rtctools.optimization.csv_mixin import CSVMixin
 7from rtctools.optimization.goal_programming_mixin import GoalProgrammingMixin, StateGoal
 8from rtctools.optimization.homotopy_mixin import HomotopyMixin
 9from rtctools.optimization.modelica_mixin import ModelicaMixin
10from rtctools.util import run_optimization_problem
11
12
13class RangeGoal(StateGoal):
14    def __init__(self, opt_prob, state, priority):
15        self.state = state
16        self.target_min = opt_prob.get_timeseries(state + "_min")
17        self.target_max = opt_prob.get_timeseries(state + "_max")
18        self.violation_timeseries_id = state + "_target_violation"
19        self.function_value_timeseries_id = state
20        self.priority = priority
21        super().__init__(opt_prob)
22
23
24class TargetGoal(StateGoal):
25    def __init__(self, opt_prob, state, priority):
26        self.state = state
27        self.target_min = opt_prob.get_timeseries(state + "_target")
28        self.target_max = self.target_min
29        self.violation_timeseries_id = state + "_target_violation"
30        self.function_value_timeseries_id = state
31        self.priority = priority
32        super().__init__(opt_prob)
33
34
35class Example(
36    HomotopyMixin,
37    GoalProgrammingMixin,
38    CSVMixin,
39    ModelicaMixin,
40    CollocatedIntegratedOptimizationProblem,
41):
42    channels = "LowerChannel", "MiddleChannel", "UpperChannel"
43    channel_n_level_nodes = 2
44
45    def pre(self):
46        super().pre()
47        # Generate handy tuples to iterate over
48        self.channel_node_indices = tuple(range(1, self.channel_n_level_nodes + 1))
49        self.channel_level_nodes = tuple(
50            "{}.H[{}]".format(c, n)
51            for c, n in itertools.product(self.channels, self.channel_node_indices)
52        )
53        # Expand channel water level goals to all nodes
54        for channel in self.channels:
55            channel_max = self.get_timeseries(channel + "_max")
56            channel_min = self.get_timeseries(channel + "_min")
57            for i in self.channel_node_indices:
58                self.set_timeseries("{}.H[{}]_max".format(channel, i), channel_max)
59                self.set_timeseries("{}.H[{}]_min".format(channel, i), channel_min)
60        # Make input series appear in output csv
61        self.set_timeseries("Inflow_Q", self.get_timeseries("Inflow_Q"))
62        self.set_timeseries(
63            "DrinkingWaterExtractionPump_Q_target",
64            self.get_timeseries("DrinkingWaterExtractionPump_Q_target"),
65        )
66
67    def parameters(self, ensemble_member):
68        p = super().parameters(ensemble_member)
69        times = self.times()
70        p["step_size"] = times[1] - times[0]
71        return p
72
73    def path_goals(self):
74        g = super().path_goals()
75
76        # Add RangeGoal on water level states with a priority of 1
77        for node in self.channel_level_nodes:
78            g.append(RangeGoal(self, node, 1))
79
80        # Add TargetGoal on Extraction Pump with a priority of 2
81        g.append(TargetGoal(self, "DrinkingWaterExtractionPump_Q", 2))
82
83        return g
84
85    def post(self):
86        super().post()
87
88
89# Run
90run_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:

(Source code, svg, png)

../../_images/cascading_channels_results.svg