import shutil
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from ribasim import Allocation, Model, Node, Solver
from ribasim.nodes import (
basin,
continuous_control,
discrete_control,
flow_boundary,
level_boundary,
level_demand,
linear_resistance,
manning_resistance,
outlet,
pid_control,
pump,
tabulated_rating_curve,
user_demand,
)from shapely.geometry import Point
Examples
1 Basic model with static forcing
= Path("data")
datadir =True) shutil.rmtree(datadir, ignore_errors
= Model(starttime="2020-01-01", endtime="2021-01-01", crs="EPSG:4326") model
Setup the basins:
= pd.date_range(model.starttime, model.endtime)
time = time.day_of_year.to_numpy()
day_of_year = 24 * 60 * 60
seconds_per_day = (
evaporation -1.0 * np.cos(day_of_year / 365.0 * 2 * np.pi) + 1.0) * 0.0025 / seconds_per_day
(
)= np.random.default_rng(seed=0)
rng = (
precipitation =-1.0, sigma=1.7, size=time.size) * 0.001 / seconds_per_day
rng.lognormal(mean
)
# Convert steady forcing to m/s
# 2 mm/d precipitation, 1 mm/d evaporation
= [
basin_data =[0.01, 1000.0], level=[0.0, 1.0]),
basin.Profile(area
basin.Time(=pd.date_range(model.starttime, model.endtime),
time=0.0,
drainage=evaporation,
potential_evaporation=0.0,
infiltration=precipitation,
precipitation
),=[1.4]),
basin.State(level
]
= model.basin.add(Node(1, Point(0.0, 0.0)), basin_data)
basin1 = model.basin.add(Node(3, Point(2.0, 0.0)), basin_data)
basin3 = model.basin.add(Node(6, Point(3.0, 2.0)), basin_data)
basin6 = model.basin.add(Node(9, Point(5.0, 0.0)), basin_data) basin9
Setup linear resistance:
= model.linear_resistance.add(
linear_resistance10 10, Point(6.0, 0.0)),
Node(=[5e3])],
[linear_resistance.Static(resistance
)= model.linear_resistance.add(
linear_resistance12 12, Point(2.0, 1.0)),
Node(=[3600.0 * 24.0 / 100.0])],
[linear_resistance.Static(resistance )
Setup Manning resistance:
= model.manning_resistance.add(
manning_resistance2 2, Point(1.0, 0.0)),
Node(
[
manning_resistance.Static(=[900], manning_n=[0.04], profile_width=[6.0], profile_slope=[3.0]
length
)
], )
Set up rating curve nodes:
= 10 / 86400 # 10 m³/day
q = model.tabulated_rating_curve.add(
tabulated_rating_curve4 8, Point(3.0, -1.0)),
Node(
[
tabulated_rating_curve.Static(=[0.0, 1.0],
level=[0.0, 0.6 * q],
flow_rate
)
],
)= model.tabulated_rating_curve.add(
tabulated_rating_curve5 5, Point(3.0, 1.0)),
Node(
[
tabulated_rating_curve.Static(=[0.0, 1.0],
level=[0.0, 0.3 * q],
flow_rate
)
],
)= model.tabulated_rating_curve.add(
tabulated_rating_curve8 4, Point(4.0, 0.0)),
Node(
[
tabulated_rating_curve.Static(=[0.0, 1.0],
level=[0.0, 0.1 * q],
flow_rate
)
], )
Setup pump:
= model.pump.add(Node(7, Point(4.0, 1.0)), [pump.Static(flow_rate=[0.5 / 3600])]) pump7
Setup level boundary:
= model.level_boundary.add(
level_boundary11 11, Point(2.0, 2.0)), [level_boundary.Static(level=[0.5])]
Node(
)= model.level_boundary.add(
level_boundary17 17, Point(6.0, 1.0)), [level_boundary.Static(level=[1.5])]
Node( )
Setup flow boundary:
= model.flow_boundary.add(
flow_boundary15 15, Point(3.0, 3.0)), [flow_boundary.Static(flow_rate=[1e-4])]
Node(
)= model.flow_boundary.add(
flow_boundary16 16, Point(0.0, 1.0)), [flow_boundary.Static(flow_rate=[1e-4])]
Node( )
Setup terminal:
= model.terminal.add(Node(14, Point(3.0, -2.0))) terminal14
Setup the edges:
model.edge.add(basin1, manning_resistance2)
model.edge.add(manning_resistance2, basin3)
model.edge.add(
basin3,
tabulated_rating_curve8,
)
model.edge.add(
basin3,
tabulated_rating_curve5,
)
model.edge.add(
basin3,
tabulated_rating_curve4,
)
model.edge.add(tabulated_rating_curve5, basin6)
model.edge.add(tabulated_rating_curve8, basin9)
model.edge.add(
tabulated_rating_curve4,
terminal14,
)
model.edge.add(basin6, pump7)
model.edge.add(pump7, basin9)
model.edge.add(basin9, linear_resistance10)
model.edge.add(level_boundary11, linear_resistance12)
model.edge.add(linear_resistance12, basin3)
model.edge.add(flow_boundary15, basin6)
model.edge.add(flow_boundary16, basin1) model.edge.add(linear_resistance10, level_boundary17)
Let’s take a look at the model:
model.plot()
Write the model to a TOML and GeoPackage:
= datadir / "basic/ribasim.toml"
toml_path model.write(toml_path)
PosixPath('data/basic/ribasim.toml')
1.1 Running a model
Now run the model. You can open a terminal and run it from there. For example, to run the basic model, input:
ribasim basic/ribasim.toml
After running the model, read back the results:
= pd.read_feather(
df_basin / "basic/results/basin.arrow", dtype_backend="pyarrow"
datadir
)= df_basin.pivot_table(
df_basin_wide ="time", columns="node_id", values=["storage", "level"]
index
)= df_basin_wide["level"].plot()
ax "level [m]"); ax.set_ylabel(
= pd.read_feather(datadir / "basic/results/flow.arrow", dtype_backend="pyarrow")
df_flow "edge"] = list(zip(df_flow.from_node_id, df_flow.to_node_id))
df_flow["flow_m3d"] = df_flow.flow_rate * 86400
df_flow[= df_flow.pivot_table(index="time", columns="edge", values="flow_m3d").plot()
ax =(1.3, 1), title="Edge")
ax.legend(bbox_to_anchor"flow [m³day⁻¹]"); ax.set_ylabel(
2 Model with discrete control
The model constructed below consists of a single basin which slowly drains trough a TabulatedRatingCurve
, but is held within a range by two connected pumps. These two pumps together behave like a reversible pump. When pumping can be done in only one direction, and the other direction is only possible under gravity, use an Outlet for that direction.
Setup the basins:
= Model(
model ="2020-01-01",
starttime="2021-01-01",
endtime="EPSG:4326",
crs=Solver(abstol=1e-6, reltol=1e-5),
solver )
model.basin.add(1, Point(0.0, 0.0)),
Node(
[=[1000.0, 1000.0], level=[0.0, 1.0]),
basin.Profile(area=[20.0]),
basin.State(level=["2020-01-01", "2020-07-01"], precipitation=[0.0, 3e-6]),
basin.Time(time
], )
NodeData(node_id=1, node_type='Basin', geometry=<POINT (0 0)>)
Setup the discrete control:
model.discrete_control.add(7, Point(1.0, 0.0)),
Node(
[
discrete_control.Variable(=1,
compound_variable_id=1,
listen_node_id=["level"],
variable
),
discrete_control.Condition(=1,
compound_variable_id# min, max
=[5.0, 15.0],
greater_than
),
discrete_control.Logic(=["FF", "TF", "TT"],
truth_state=["in", "none", "out"],
control_state
),
], )
NodeData(node_id=7, node_type='DiscreteControl', geometry=<POINT (1 0)>)
The above control logic can be summarized as follows:
- If the level is above the maximum, activate the control state “out”;
- If the level is below the minimum, active the control state “in”;
- Otherwise activate the control state “none”.
Setup the pump:
model.pump.add(2, Point(1.0, 1.0)),
Node(=["none", "in", "out"], flow_rate=[0.0, 2e-3, 0.0])],
[pump.Static(control_state
)
model.pump.add(3, Point(1.0, -1.0)),
Node(=["none", "in", "out"], flow_rate=[0.0, 0.0, 2e-3])],
[pump.Static(control_state )
NodeData(node_id=3, node_type='Pump', geometry=<POINT (1 -1)>)
The pump data defines the following:
Control state | Pump #2 flow rate (m/s) | Pump #3 flow rate (m/s) |
---|---|---|
“none” | 0.0 | 0.0 |
“in” | 2e-3 | 0.0 |
“out” | 0.0 | 2e-3 |
Setup the level boundary:
model.level_boundary.add(4, Point(2.0, 0.0)), [level_boundary.Static(level=[10.0])]
Node( )
NodeData(node_id=4, node_type='LevelBoundary', geometry=<POINT (2 0)>)
Setup the rating curve:
model.tabulated_rating_curve.add(5, Point(-1.0, 0.0)),
Node(=[2.0, 15.0], flow_rate=[0.0, 2e-3])],
[tabulated_rating_curve.Static(level )
NodeData(node_id=5, node_type='TabulatedRatingCurve', geometry=<POINT (-1 0)>)
Setup the terminal:
6, Point(-2.0, 0.0))) model.terminal.add(Node(
NodeData(node_id=6, node_type='Terminal', geometry=<POINT (-2 0)>)
Setup edges:
1], model.pump[3])
model.edge.add(model.basin[3], model.level_boundary[4])
model.edge.add(model.pump[4], model.pump[2])
model.edge.add(model.level_boundary[2], model.basin[1])
model.edge.add(model.pump[1], model.tabulated_rating_curve[5])
model.edge.add(model.basin[5], model.terminal[6])
model.edge.add(model.tabulated_rating_curve[7], model.pump[2])
model.edge.add(model.discrete_control[7], model.pump[3]) model.edge.add(model.discrete_control[
Let’s take a look at the model:
model.plot()
Listen edges are plotted with a dashed line since they are not present in the “Edge / static” schema but only in the “Control / condition” schema.
= Path("data")
datadir / "level_range/ribasim.toml") model.write(datadir
PosixPath('data/level_range/ribasim.toml')
Now run the model (for running instructions see here). After running the model, read back the results:
= pd.read_feather(
df_basin / "level_range/results/basin.arrow", dtype_backend="pyarrow"
datadir
)= df_basin.pivot_table(
df_basin_wide ="time", columns="node_id", values=["storage", "level"]
index
)
= df_basin_wide["level"].plot()
ax
= model.discrete_control.condition.df.greater_than
greater_than
ax.hlines(
greater_than,0],
df_basin.time[max(),
df_basin.time.=1,
lw="--",
ls="k",
color
)
"min", "max"])
ax.set_yticks(greater_than, ["level")
ax.set_ylabel( plt.show()
We see that in January the level of the basin is too high and thus water is pumped out until the maximum level of the desired range is reached. Then until May water flows out of the basin freely through the tabulated rating curve until the minimum level is reached. From this point until the start of July water is pumped into the basin in short bursts to stay within the desired range. At the start of July rain starts falling on the basin, which causes the basin level to rise until the maximum level. From this point onward water is pumped out of the basin in short bursts to stay within the desired range.
3 Model with PID control
Set up the model:
= Model(starttime="2020-01-01", endtime="2020-12-01", crs="EPSG:4326") model
Setup the basins:
model.basin.add(2, Point(1.0, 0.0)),
Node(=[1000.0, 1000.0], level=[0.0, 1.0]), basin.State(level=[6.0])],
[basin.Profile(area )
NodeData(node_id=2, node_type='Basin', geometry=<POINT (1 0)>)
Setup the pump:
model.pump.add(3, Point(2.0, 0.5)),
Node(=[0.0])], # Will be overwritten by PID controller
[pump.Static(flow_rate )
NodeData(node_id=3, node_type='Pump', geometry=<POINT (2 0.5)>)
Setup the outlet:
model.outlet.add(6, Point(2.0, -0.5)),
Node(=[0.0])], # Will be overwritten by PID controller
[outlet.Static(flow_rate )
NodeData(node_id=6, node_type='Outlet', geometry=<POINT (2 -0.5)>)
Setup flow boundary:
model.flow_boundary.add(1, Point(0.0, 0.0)),
Node(=[1e-3])],
[flow_boundary.Static(flow_rate )
NodeData(node_id=1, node_type='FlowBoundary', geometry=<POINT (0 0)>)
Setup level boundary:
model.level_boundary.add(4, Point(3.0, 0.0)),
Node(=[5.0])],
[level_boundary.Static(level )
NodeData(node_id=4, node_type='LevelBoundary', geometry=<POINT (3 0)>)
Setup PID control:
for node, proportional, integral in [
5, Point(1.5, 1.0)), -1e-3, -1e-7),
(Node(7, Point(1.5, -1.0)), 1e-3, 1e-7),
(Node(
]:= [
pid_control_data
pid_control.Time(=[
time"2020-01-01",
"2020-05-01",
"2020-07-01",
"2020-12-01",
],=2,
listen_node_id=[5.0, 5.0, 7.5, 7.5],
target=proportional,
proportional=integral,
integral=0.0,
derivative
)
] model.pid_control.add(node, pid_control_data)
Note that the coefficients for the pump and the outlet are equal in magnitude but opposite in sign. This way the pump and the outlet equally work towards the same goal, while having opposite effects on the controlled basin due to their connectivity to this basin.
Setup the edges:
1], model.basin[2])
model.edge.add(model.flow_boundary[2], model.pump[3])
model.edge.add(model.basin[3], model.level_boundary[4])
model.edge.add(model.pump[4], model.outlet[6])
model.edge.add(model.level_boundary[6], model.basin[2])
model.edge.add(model.outlet[5], model.pump[3])
model.edge.add(model.pid_control[7], model.outlet[6]) model.edge.add(model.pid_control[
Let’s take a look at the model:
model.plot()
Write the model to a TOML and GeoPackage:
= Path("data")
datadir / "pid_control/ribasim.toml") model.write(datadir
PosixPath('data/pid_control/ribasim.toml')
Now run the model (for running instructions see here). After running the model, read back the results:
from matplotlib.dates import date2num
= pd.read_feather(
df_basin / "pid_control/results/basin.arrow", dtype_backend="pyarrow"
datadir
)= df_basin.pivot_table(
df_basin_wide ="time", columns="node_id", values=["storage", "level"]
index
)= df_basin_wide["level"].plot()
ax "level [m]")
ax.set_ylabel(
# Plot target level
= model.pid_control.time.df.target.to_numpy()[:4]
level_demands = date2num(model.pid_control.time.df.time)[:4]
times ="k", ls=":", label="target level")
ax.plot(times, level_demands, colorpass
4 Model with allocation (user demand)
Setup a model:
= Model(starttime="2020-01-01", endtime="2020-01-20", crs="EPSG:4326") model
Setup the basins:
= [
basin_data =[300_000.0, 300_000.0], level=[0.0, 1.0]),
basin.Profile(area=[1.0]),
basin.State(level
]
model.basin.add(2, Point(1.0, 0.0), subnetwork_id=1),
Node(
basin_data,
)
model.basin.add(5, Point(3.0, 0.0), subnetwork_id=1),
Node(
basin_data, )
NodeData(node_id=5, node_type='Basin', geometry=<POINT (3 0)>)
Setup the flow boundary:
model.flow_boundary.add(1, Point(0.0, 0.0), subnetwork_id=1), [flow_boundary.Static(flow_rate=[2.0])]
Node( )
NodeData(node_id=1, node_type='FlowBoundary', geometry=<POINT (0 0)>)
Setup the linear resistance:
model.linear_resistance.add(4, Point(2.0, 0.0), subnetwork_id=1),
Node(=[0.06])],
[linear_resistance.Static(resistance )
NodeData(node_id=4, node_type='LinearResistance', geometry=<POINT (2 0)>)
Setup the tabulated rating curve:
model.tabulated_rating_curve.add(7, Point(4.0, 0.0), subnetwork_id=1),
Node(=[0.0, 0.5, 1.0], flow_rate=[0.0, 0.0, 2.0])],
[tabulated_rating_curve.Static(level )
NodeData(node_id=7, node_type='TabulatedRatingCurve', geometry=<POINT (4 0)>)
Setup the terminal:
8, Point(5.0, 0.0), subnetwork_id=1)) model.terminal.add(Node(
NodeData(node_id=8, node_type='Terminal', geometry=<POINT (5 0)>)
Setup the users:
model.user_demand.add(6, Point(3.0, 1.0), subnetwork_id=1),
Node(
[
user_demand.Static(=[1.5], return_factor=[0.0], min_level=[-1.0], priority=[1]
demand
)
],
)
model.user_demand.add(3, Point(1.0, 1.0), subnetwork_id=1),
Node(
[
user_demand.Time(=[0.0, 1.0, 1.2, 1.2],
demand=[0.0, 0.0, 0.0, 0.0],
return_factor=[-1.0, -1.0, -1.0, -1.0],
min_level=[1, 1, 2, 2],
priority=2 * ["2020-01-01", "2020-01-20"],
time
)
], )
NodeData(node_id=3, node_type='UserDemand', geometry=<POINT (1 1)>)
Setup the allocation:
= Allocation(use_allocation=True, timestep=86400) model.allocation
Setup the edges:
1], model.basin[2])
model.edge.add(model.flow_boundary[2], model.user_demand[3])
model.edge.add(model.basin[2], model.linear_resistance[4])
model.edge.add(model.basin[4], model.basin[5])
model.edge.add(model.linear_resistance[5], model.user_demand[6])
model.edge.add(model.basin[5], model.tabulated_rating_curve[7])
model.edge.add(model.basin[3], model.basin[2])
model.edge.add(model.user_demand[6], model.basin[5])
model.edge.add(model.user_demand[7], model.terminal[8]) model.edge.add(model.tabulated_rating_curve[
Let’s take a look at the model:
model.plot()
Write the model to a TOML and GeoPackage:
= Path("data")
datadir / "allocation_example/ribasim.toml") model.write(datadir
PosixPath('data/allocation_example/ribasim.toml')
Now run the model (for running instructions see here). After running the model, read back the results:
import matplotlib.ticker as plticker
= pd.read_feather(
df_allocation / "allocation_example/results/allocation.arrow", dtype_backend="pyarrow"
datadir
)= df_allocation.pivot_table(
df_allocation_wide ="time",
index=["node_type", "node_id", "priority"],
columns=["demand", "allocated", "realized"],
values
)= df_allocation_wide.loc[:, (df_allocation_wide != 0).any(axis=0)]
df_allocation_wide
= plt.subplots(1, 3, figsize=(8, 5))
fig, axs
"demand"].plot(ax=axs[0], ls=":")
df_allocation_wide["allocated"].plot(ax=axs[1], ls="--")
df_allocation_wide[1, level="priority", axis=1)["realized"].plot(
df_allocation_wide.xs(=axs[2], color=["C0", "C2", "C3"]
ax
)
fig.tight_layout()= plticker.MultipleLocator(2)
loc
0].set_ylabel("level [m]")
axs[
for ax, title in zip(axs, ["Demand", "Allocated", "Abstracted"]):
ax.set_title(title)0.0, 1.6)
ax.set_ylim( ax.xaxis.set_major_locator(loc)
Some things to note about this plot:
The realized flow at the start time is not correct, as there is no realized flow yet. The given value is how much of its total demand a user can abstract in the physical layer.
No flow was allocated to UserDemand 13 so that is not plotted.
Abstraction is accumulated over all priorities per user.
= pd.read_feather(
df_basin / "allocation_example/results/basin.arrow", dtype_backend="pyarrow"
datadir
)= df_basin.pivot_table(
df_basin_wide ="time", columns="node_id", values=["storage", "level"]
index
)
= df_basin_wide["level"].plot()
ax "Basin levels")
ax.set_title("level [m]") ax.set_ylabel(
Text(0, 0.5, 'level [m]')
5 Model with allocation (basin supply/demand)
Setup a model:
= Model(starttime="2020-01-01", endtime="2020-02-01", crs="EPSG:4326") model
Setup the basins:
= [
basin_data =[1e3, 1e3], level=[0.0, 1.0]),
basin.Profile(area=[0.5]),
basin.State(level
]
model.basin.add(2, Point(1.0, 0.0), subnetwork_id=2),
Node(
[*basin_data,
basin.Time(=["2020-01-01", "2020-01-16"],
time=[0.0, 0.0],
drainage=[0.0, 0.0],
potential_evaporation=[0.0, 0.0],
infiltration=[1e-6, 0.0],
precipitation
),
],
)
model.basin.add(5, Point(2.0, -1.0), subnetwork_id=2),
Node(
[*basin_data,
basin.Static(=[0.0],
drainage=[0.0],
potential_evaporation=[0.0],
infiltration=[0.0],
precipitation
),
], )
NodeData(node_id=5, node_type='Basin', geometry=<POINT (2 -1)>)
Setup the flow boundary:
model.flow_boundary.add(1, Point(0.0, 0.0), subnetwork_id=2), [flow_boundary.Static(flow_rate=[1e-3])]
Node( )
NodeData(node_id=1, node_type='FlowBoundary', geometry=<POINT (0 0)>)
Setup level demand:
model.level_demand.add(4, Point(1.0, -1.0), subnetwork_id=2),
Node(=[1], min_level=[1.0], max_level=[1.5])],
[level_demand.Static(priority )
NodeData(node_id=4, node_type='LevelDemand', geometry=<POINT (1 -1)>)
Setup the users:
model.user_demand.add(3, Point(2.0, 0.0), subnetwork_id=2),
Node(
[
user_demand.Static(=[2], demand=[1.5e-3], return_factor=[0.2], min_level=[0.2]
priority
)
], )
NodeData(node_id=3, node_type='UserDemand', geometry=<POINT (2 0)>)
Setup the allocation:
= Allocation(use_allocation=True, timestep=1e5) model.allocation
Setup the edges:
1], model.basin[2])
model.edge.add(model.flow_boundary[2], model.user_demand[3])
model.edge.add(model.basin[4], model.basin[2])
model.edge.add(model.level_demand[3], model.basin[5])
model.edge.add(model.user_demand[4], model.basin[5]) model.edge.add(model.level_demand[
Let’s take a look at the model:
model.plot()
Write the model to a TOML and GeoPackage:
/ "level_demand/ribasim.toml") model.write(datadir
PosixPath('data/level_demand/ribasim.toml')
Now run the model (for running instructions see here). After running the model, read back the results:
= pd.read_feather(
df_basin / "level_demand/results/basin.arrow", dtype_backend="pyarrow"
datadir
)= df_basin[df_basin.node_id == 2]
df_basin = df_basin.pivot_table(
df_basin_wide ="time", columns="node_id", values=["storage", "level"]
index
)= df_basin_wide["level"].plot(ylabel="level [m]") ax
In the plot above, the line denotes the level of Basin #2 over time. The Basin level is a piecewise linear function of time, with several stages explained below.
Constants:
- \(d\): UserDemand #3 demand,
- \(\phi\): Basin #2 precipitation rate,
- \(q\): LevelBoundary flow.
Stages:
- In the first stage the Basin takes precedence so the UserDemand doesn’t abstract, hence the net change of Basin #2 is \(q + \phi\);
- In the second stage (and following stages) the Basin no longer has a positive demand, since precipitation provides enough water to get the Basin to its target level. The FlowBoundary flow gets fully allocated to the UserDemand, hence the net change of Basin #2 is \(\phi\);
- In the third stage the Basin enters its surplus stage, even though initially the level is below the maximum level. This is because the simulation anticipates that the current precipitation is going to bring the Basin level over its maximum level. The net change of Basin #2 is now \(q + \phi - d\);
- At the start of the fourth stage the precipitation stops, and so the UserDemand partly uses surplus water from the Basin to fulfill its demand. The net change of Basin #2 becomes \(q - d\).
- In the final stage the Basin is in a dynamical equilibrium, since the Basin has no supply so the user abstracts precisely the flow from the LevelBoundary.
6 Guidance of modelling a cascade of polder basins
Situation description: This example shows how to make a model for a given practical water system, which consists of a cascade of level control polder basins with inlet and outlet to the main systems. Note that alternative model layouts are feasible for the same water system, each having its positive items and drawbacks.
The polder system is composed of a sequence of level controlled polder basins with weirs inbetween each basin and an inlet and outlet to main system
= Model(starttime="2020-01-01", endtime="2021-01-01", crs="EPSG:28992") model
All the polder basins are exposed to time varying forcings (precipitation, evaporation, drainage, infiltration) to mimic situations of water excess and water shortage.
In case of water excess, a pump in the most downstream polder will need to pump the surplus water to the main water system. In case of water shortage, an inlet at the most upstream polder will need to bring water into the cascase of polders. The main water system acts as a water source.
Model approach: All polder basins as well as the main water system are modelled with basin nodes. To let the system experience all 4 excess/shortage situation, forcing time series are made in a way that is adapting to them. Overall, assume that in one year, the system will experience precipitation (situation 1) in winter and early spring, precipitation shortage (situation 2) from late spring until early autumn. During situation 2, polder basin 4 will experience additional seepage (compoensating its shortage), and later polder basin 3 will also receive more seepage.
Setting up the basins:
= pd.date_range(model.starttime, model.endtime)
time = time.day_of_year.to_numpy()
day_of_year
= np.zeros(day_of_year.size)
precipitation 0:90] = 1.72e-8
precipitation[330:366] = 1.72e-8
precipitation[
= np.zeros(day_of_year.size)
evaporation 130:270] = 2.87e-8
evaporation[
= np.zeros(day_of_year.size)
drainage 120:270] = 0.4 * 2.87e-8
drainage[= drainage.copy()
drainage_3 210:240] = 17 * 2.87e-8
drainage_3[= drainage.copy()
drainage_4 160:240] = 13 * 2.87e-8
drainage_4[
= np.zeros(day_of_year.size)
infiltration 0:90] = 5e-8
infiltration[
= basin.Profile(area=[100, 100], level=[0.0, 3.0])
polder_profile
= [
basin_time
basin.Time(=pd.date_range(model.starttime, model.endtime),
time=drainage,
drainage=evaporation,
potential_evaporation=0.0,
infiltration=precipitation,
precipitation
),
]
= [
basin_time4
basin.Time(=pd.date_range(model.starttime, model.endtime),
time=drainage_4,
drainage=evaporation,
potential_evaporation=0.0,
infiltration=precipitation,
precipitation
),
]= [
basin_time3
basin.Time(=pd.date_range(model.starttime, model.endtime),
time=drainage_3,
drainage=evaporation,
potential_evaporation=0.0,
infiltration=precipitation,
precipitation
),
]
model.basin.add(1, Point(2.0, 0.0)),
Node(
[=[2.5]),
basin.State(level=[1000, 1000], level=[0.0, 3.0]),
basin.Profile(area
basin.Time(=pd.date_range(model.starttime, model.endtime),
time=0.0,
drainage=0.0,
potential_evaporation=0.0,
infiltration=0.0,
precipitation
),
],
)
model.basin.add(4, Point(0.0, -2.0)),
Node(=[1.5]), polder_profile, *basin_time],
[basin.State(level
)
model.basin.add(6, Point(0.0, -4.0)),
Node(=[1.0]), polder_profile, *basin_time],
[basin.State(level
)
model.basin.add(8, Point(2.0, -4.0)),
Node(=[1.5]), polder_profile, *basin_time3],
[basin.State(level
)
model.basin.add(10, Point(4.0, -4.0)),
Node(=[1.3]), polder_profile, *basin_time4],
[basin.State(level
)
model.basin.add(12, Point(4.0, -2.0)),
Node(=[0.1]), polder_profile, *basin_time],
[basin.State(level )
NodeData(node_id=12, node_type='Basin', geometry=<POINT (4 -2)>)
After all the basins are defined the connecting component inbetween the basins needs to be determined. For polder basin 5 (node 12), the water level needs to be maintain at 0.0 meter. This means that either there should be no water in this basin, or the basin bottom is lower than the reference level, and the water level should be maintained at the reference level.
Since the water level of the main system is at 2.5 meter above the reference level a pump is needed to remove the water from polder basin 5.
Setup the pumps:
model.pump.add(13, Point(4.0, -1.0)),
Node(=[0.5 / 3600])],
[pump.Static(flow_rate )
NodeData(node_id=13, node_type='Pump', geometry=<POINT (4 -1)>)
According to the description of situation 1 and 2, the water in one polder basin needs to be able to flow to the downstream basin if the current basin has too much water (i.e. the water level is above the setpoint) or if the downstream basin is below setpoint and needs more water. This could be modelled with an uncontrolled TabulatedRatingCurve node with Q=0 at the setpoint level (and Q rising when the level rises above setpoint) , or with an Outlet node where the min_upstream_level
is specified at or just below the setpoint. In this example, we’ve chosen for the Outlet where we specify the minimum upstream level 5 cm below the setpoint. For example: the Outlet of polder basin 1 (node 4) is specified with a minimum upstream level of 1.95 meter.
Setup the outlets:
# Set up outlet
model.outlet.add(2, Point(0.0, -1.0)),
Node(=[2 * 0.5 / 3600], min_upstream_level=[0.0])],
[outlet.Static(flow_rate
)
model.outlet.add(5, Point(0.0, -3.0)),
Node(=[0.5 / 3600], min_upstream_level=[1.95])],
[outlet.Static(flow_rate
)
model.outlet.add(7, Point(1.0, -4.0)),
Node(=[0.5 / 3600], min_upstream_level=[1.45])],
[outlet.Static(flow_rate
)
model.outlet.add(9, Point(3.0, -4.0)),
Node(=[0.5 / 3600], min_upstream_level=[0.95])],
[outlet.Static(flow_rate
)
model.outlet.add(11, Point(4.0, -3.0)),
Node(=[0.5 / 3600], min_upstream_level=[0.45])],
[outlet.Static(flow_rate )
NodeData(node_id=11, node_type='Outlet', geometry=<POINT (4 -3)>)
When using Outlets as connecting nodes, the flow over the Outlet needs to be controlled to maintain the water level at the setpoint. For this purpose we introduce local PidControllers, where the targets of the PidControllers are set to the setpoints. Disadvantage of this local control approach is the delay that is introduced to transport the ‘basin X has a shortage’ message upstream through the cascade to the inlet. Current functionality does not offer the capability for PidControl to take multiple observations into account when controlling the inlet. Combining multiple observations in one control is feasible with DiscreteControl. This could be an alternative approach to controlling the inlet for the cascading water system.
Setup the PID control:
= {
pid_control_data "proportional": [0.05],
"integral": [0.00],
"derivative": [0.0],
}
model.pid_control.add(3, Point(-1.0, -1.0)),
Node(=[4], target=[2.0], **pid_control_data)],
[pid_control.Static(listen_node_id
)
model.pid_control.add(14, Point(-1.0, -3.0)),
Node(=[6], target=[1.5], **pid_control_data)],
[pid_control.Static(listen_node_id
)
model.pid_control.add(15, Point(1.0, -3.0)),
Node(=[8], target=[1.0], **pid_control_data)],
[pid_control.Static(listen_node_id
)
model.pid_control.add(16, Point(3.0, -3.0)),
Node(=[10], target=[0.5], **pid_control_data)],
[pid_control.Static(listen_node_id )
NodeData(node_id=16, node_type='PidControl', geometry=<POINT (3 -3)>)
Setup the edges:
1], model.outlet[2])
model.edge.add(model.basin[3], model.outlet[2])
model.edge.add(model.pid_control[2], model.basin[4])
model.edge.add(model.outlet[4], model.outlet[5])
model.edge.add(model.basin[5], model.basin[6])
model.edge.add(model.outlet[6], model.outlet[7])
model.edge.add(model.basin[7], model.basin[8])
model.edge.add(model.outlet[8], model.outlet[9])
model.edge.add(model.basin[9], model.basin[10])
model.edge.add(model.outlet[10], model.outlet[11])
model.edge.add(model.basin[11], model.basin[12])
model.edge.add(model.outlet[12], model.pump[13])
model.edge.add(model.basin[13], model.basin[1])
model.edge.add(model.pump[14], model.outlet[5])
model.edge.add(model.pid_control[15], model.outlet[7])
model.edge.add(model.pid_control[16], model.outlet[9]) model.edge.add(model.pid_control[
To plot the model
model.plot()
Write the model to a TOML file and run it in the Julia.
= Path("data")
datadir / "local_pidcontrolled_cascade/ribasim.toml") model.write(datadir
PosixPath('data/local_pidcontrolled_cascade/ribasim.toml')
Now run the model (for running instructions see here). After running the model, read back the result to plot the flow of each polder basin.
= datadir / "local_pidcontrolled_cascade/results/flow.arrow"
datadir_flow = pd.read_feather(datadir_flow, dtype_backend="pyarrow")
df_flow "edge"] = list(zip(df_flow.from_node_id, df_flow.to_node_id))
df_flow["flow_m3d"] = df_flow.flow_rate * 86400
df_flow[
= df_flow.pivot_table(index="time", columns="edge", values="flow_m3d") df_pivot
Below graphs show the flow exchanged with the mainsystem (i.e. the inlet and the pump), and the flow of weirs inbetween the polder basins.
= df_pivot.loc[:, [(1, 2), (13, 1)]]
df_input = df_input.plot(ylim=[-1.0, 20.0])
ax "flow [m³day⁻¹]")
ax.set_ylabel(= df_pivot.loc[:, [(4, 5), (6, 7), (8, 9), (10, 11)]]
df_weirs = df_weirs.plot(ylim=[-1.0, 15.0])
ax "flow [m³day⁻¹]"); ax.set_ylabel(
Below graph shows the vertical flux on each basin.
= datadir / "local_pidcontrolled_cascade/results/basin.arrow"
datadir_basin = pd.read_feather(datadir_basin, dtype_backend="pyarrow")
df_basin "vertical_flux"] = (
df_basin["precipitation"]
df_basin[- df_basin["evaporation"]
+ df_basin["drainage"]
+ df_basin["infiltration"]
)= df_basin.pivot_table(
df_basin_wide ="time", columns="node_id", values=["storage", "level", "vertical_flux"]
index
)"vertical_flux"] *= 86400
df_basin_wide[= df_basin_wide["vertical_flux"].plot()
ax "vertical flux [m³day⁻¹]"); ax.set_ylabel(
In the following graph, the water level of basins are shown. The five polder basins are given starting levels that are different from their setpoints. It can be observed that in the beginning, the water level are changing and approaching to the set points. Later when the water levels are stable, they will not be affected by the forcing.
= df_basin_wide["level"].plot()
ax "level [m]"); ax.set_ylabel(
7 Model with continuous control
= Model(starttime="2020-01-01", endtime="2021-01-01", crs="EPSG:28992") model
Set up the transient level boundary:
model.level_boundary.add(1, Point(0, 0)),
Node(
[
level_boundary.Time(=pd.date_range(
time="2020-01-01", end="2021-01-01", periods=100, unit="ms"
start
),=6.0 + np.sin(np.linspace(0, 6 * np.pi, 100)),
level
)
], )
NodeData(node_id=1, node_type='LevelBoundary', geometry=<POINT (0 0)>)
Set up the linear resistance:
model.linear_resistance.add(2, Point(1, 0)), [linear_resistance.Static(resistance=[10.0])]
Node( )
NodeData(node_id=2, node_type='LinearResistance', geometry=<POINT (1 0)>)
Set up the basin:
model.basin.add(3, Point(2, 0)),
Node(
[=10000.0, level=[0.0, 1.0]),
basin.Profile(area=[10.0]),
basin.State(level
], )
NodeData(node_id=3, node_type='Basin', geometry=<POINT (2 0)>)
Set up the outlets:
4, Point(3, 1)), [outlet.Static(flow_rate=[1.0])])
model.outlet.add(Node(5, Point(3, -1)), [outlet.Static(flow_rate=[1.0])]) model.outlet.add(Node(
NodeData(node_id=5, node_type='Outlet', geometry=<POINT (3 -1)>)
Set up the terminals:
6, Point(4, 1)))
model.terminal.add(Node(7, Point(4, -1))) model.terminal.add(Node(
NodeData(node_id=7, node_type='Terminal', geometry=<POINT (4 -1)>)
Set up the continuous control:
model.continuous_control.add(8, Point(2, 1)),
Node(
[
continuous_control.Variable(=[2],
listen_node_id="flow_rate",
variable
),
continuous_control.Function(input=[0.0, 1.0],
=[0.0, 0.6],
output="flow_rate",
controlled_variable
),
],
)
model.continuous_control.add(9, Point(2, -1)),
Node(
[
continuous_control.Variable(=[2],
listen_node_id="flow_rate",
variable
),
continuous_control.Function(input=[0.0, 1.0],
=[0.0, 0.4],
output="flow_rate",
controlled_variable
),
], )
NodeData(node_id=9, node_type='ContinuousControl', geometry=<POINT (2 -1)>)
This defines:
- A
ContinuousControl
node with ID 1, which listens to the flow rate of theLinearResistance
node with ID 1, puts that trough the function \(f(x) = \max(0, 0.6x)\), and assigns the result to the flow rate of the node thisContinuousControl
node is controlling, which is defined by a (control) edge; - A
ContinuousControl
node with ID 2, which listens to the flow rate of theLinearResistance
node with ID 1, puts that through the function \(f(x) = \max(0, 0.4x)\), and assigns the result to the flow rate of the node thisContinuousControl
node is controlling, which is defined by a (control) edge.
1], model.linear_resistance[2])
model.edge.add(model.level_boundary[2], model.basin[3])
model.edge.add(model.linear_resistance[3], model.outlet[4])
model.edge.add(model.basin[3], model.outlet[5])
model.edge.add(model.basin[4], model.terminal[6])
model.edge.add(model.outlet[5], model.terminal[7])
model.edge.add(model.outlet[
# Define which node is controlled by each continuous control node
8], model.outlet[4])
model.edge.add(model.continuous_control[9], model.outlet[5]) model.edge.add(model.continuous_control[
Let’s take a look at the model:
model.plot()
With this setup we want to split the flow coming into the basin into a 60% - 40% ratio.
Write the model to a TOML and GeoPackage:
= datadir / "outlet_continuous_control/ribasim.toml"
toml_path model.write(toml_path)
PosixPath('data/outlet_continuous_control/ribasim.toml')
Now run the model (for running instructions see here). After running the model, read back the results:
= pd.read_feather(
df_flow / "outlet_continuous_control/results/flow.arrow", dtype_backend="pyarrow"
datadir
)= plt.subplots()
fig, ax
def plot_edge_flow(from_node_type, from_node_id, to_node_type, to_node_id):
= df_flow[
df_flow_filtered "from_node_id"] == from_node_id)
(df_flow[& (df_flow["to_node_id"] == to_node_id)
]
df_flow_filtered.plot(="time",
x="flow_rate",
y=ax,
ax=f"{from_node_type} #{from_node_id} → {to_node_type} #{to_node_id}",
label
)
"LinearResistance", 1, "Basin", 1)
plot_edge_flow("Basin", 1, "Outlet", 1)
plot_edge_flow("Basin", 1, "Outlet", 2)
plot_edge_flow("flow [m³s⁻¹]"); ax.set_ylabel(