epyt-flow 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- epyt_flow/EPANET/EPANET/SRC_engines/AUTHORS +28 -0
- epyt_flow/EPANET/EPANET/SRC_engines/LICENSE +21 -0
- epyt_flow/EPANET/EPANET/SRC_engines/Readme_SRC_Engines.txt +18 -0
- epyt_flow/EPANET/EPANET/SRC_engines/enumstxt.h +134 -0
- epyt_flow/EPANET/EPANET/SRC_engines/epanet.c +5578 -0
- epyt_flow/EPANET/EPANET/SRC_engines/epanet2.c +865 -0
- epyt_flow/EPANET/EPANET/SRC_engines/epanet2.def +131 -0
- epyt_flow/EPANET/EPANET/SRC_engines/errors.dat +73 -0
- epyt_flow/EPANET/EPANET/SRC_engines/funcs.h +193 -0
- epyt_flow/EPANET/EPANET/SRC_engines/genmmd.c +1000 -0
- epyt_flow/EPANET/EPANET/SRC_engines/hash.c +177 -0
- epyt_flow/EPANET/EPANET/SRC_engines/hash.h +28 -0
- epyt_flow/EPANET/EPANET/SRC_engines/hydcoeffs.c +1151 -0
- epyt_flow/EPANET/EPANET/SRC_engines/hydraul.c +1117 -0
- epyt_flow/EPANET/EPANET/SRC_engines/hydsolver.c +720 -0
- epyt_flow/EPANET/EPANET/SRC_engines/hydstatus.c +476 -0
- epyt_flow/EPANET/EPANET/SRC_engines/include/epanet2.h +431 -0
- epyt_flow/EPANET/EPANET/SRC_engines/include/epanet2_2.h +1786 -0
- epyt_flow/EPANET/EPANET/SRC_engines/include/epanet2_enums.h +468 -0
- epyt_flow/EPANET/EPANET/SRC_engines/inpfile.c +810 -0
- epyt_flow/EPANET/EPANET/SRC_engines/input1.c +707 -0
- epyt_flow/EPANET/EPANET/SRC_engines/input2.c +864 -0
- epyt_flow/EPANET/EPANET/SRC_engines/input3.c +2170 -0
- epyt_flow/EPANET/EPANET/SRC_engines/main.c +93 -0
- epyt_flow/EPANET/EPANET/SRC_engines/mempool.c +142 -0
- epyt_flow/EPANET/EPANET/SRC_engines/mempool.h +24 -0
- epyt_flow/EPANET/EPANET/SRC_engines/output.c +852 -0
- epyt_flow/EPANET/EPANET/SRC_engines/project.c +1359 -0
- epyt_flow/EPANET/EPANET/SRC_engines/quality.c +685 -0
- epyt_flow/EPANET/EPANET/SRC_engines/qualreact.c +743 -0
- epyt_flow/EPANET/EPANET/SRC_engines/qualroute.c +694 -0
- epyt_flow/EPANET/EPANET/SRC_engines/report.c +1489 -0
- epyt_flow/EPANET/EPANET/SRC_engines/rules.c +1362 -0
- epyt_flow/EPANET/EPANET/SRC_engines/smatrix.c +871 -0
- epyt_flow/EPANET/EPANET/SRC_engines/text.h +497 -0
- epyt_flow/EPANET/EPANET/SRC_engines/types.h +874 -0
- epyt_flow/EPANET/EPANET-MSX/MSX_Updates.txt +53 -0
- epyt_flow/EPANET/EPANET-MSX/Src/dispersion.h +27 -0
- epyt_flow/EPANET/EPANET-MSX/Src/hash.c +107 -0
- epyt_flow/EPANET/EPANET-MSX/Src/hash.h +28 -0
- epyt_flow/EPANET/EPANET-MSX/Src/include/epanetmsx.h +102 -0
- epyt_flow/EPANET/EPANET-MSX/Src/include/epanetmsx_export.h +42 -0
- epyt_flow/EPANET/EPANET-MSX/Src/mathexpr.c +937 -0
- epyt_flow/EPANET/EPANET-MSX/Src/mathexpr.h +39 -0
- epyt_flow/EPANET/EPANET-MSX/Src/mempool.c +204 -0
- epyt_flow/EPANET/EPANET-MSX/Src/mempool.h +24 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxchem.c +1285 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxcompiler.c +368 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxdict.h +42 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxdispersion.c +586 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxerr.c +116 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxfile.c +260 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxfuncs.c +175 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxfuncs.h +35 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxinp.c +1504 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxout.c +401 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxproj.c +791 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxqual.c +2010 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxrpt.c +400 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxtank.c +422 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxtoolkit.c +1164 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxtypes.h +551 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxutils.c +524 -0
- epyt_flow/EPANET/EPANET-MSX/Src/msxutils.h +56 -0
- epyt_flow/EPANET/EPANET-MSX/Src/newton.c +158 -0
- epyt_flow/EPANET/EPANET-MSX/Src/newton.h +34 -0
- epyt_flow/EPANET/EPANET-MSX/Src/rk5.c +287 -0
- epyt_flow/EPANET/EPANET-MSX/Src/rk5.h +39 -0
- epyt_flow/EPANET/EPANET-MSX/Src/ros2.c +293 -0
- epyt_flow/EPANET/EPANET-MSX/Src/ros2.h +35 -0
- epyt_flow/EPANET/EPANET-MSX/Src/smatrix.c +816 -0
- epyt_flow/EPANET/EPANET-MSX/Src/smatrix.h +29 -0
- epyt_flow/EPANET/EPANET-MSX/readme.txt +14 -0
- epyt_flow/EPANET/compile.sh +4 -0
- epyt_flow/VERSION +1 -0
- epyt_flow/__init__.py +24 -0
- epyt_flow/data/__init__.py +0 -0
- epyt_flow/data/benchmarks/__init__.py +11 -0
- epyt_flow/data/benchmarks/batadal.py +257 -0
- epyt_flow/data/benchmarks/batadal_data.py +28 -0
- epyt_flow/data/benchmarks/battledim.py +473 -0
- epyt_flow/data/benchmarks/battledim_data.py +51 -0
- epyt_flow/data/benchmarks/gecco_water_quality.py +267 -0
- epyt_flow/data/benchmarks/leakdb.py +592 -0
- epyt_flow/data/benchmarks/leakdb_data.py +18923 -0
- epyt_flow/data/benchmarks/water_usage.py +123 -0
- epyt_flow/data/networks.py +650 -0
- epyt_flow/gym/__init__.py +4 -0
- epyt_flow/gym/control_gyms.py +47 -0
- epyt_flow/gym/scenario_control_env.py +101 -0
- epyt_flow/metrics.py +404 -0
- epyt_flow/models/__init__.py +2 -0
- epyt_flow/models/event_detector.py +31 -0
- epyt_flow/models/sensor_interpolation_detector.py +118 -0
- epyt_flow/rest_api/__init__.py +4 -0
- epyt_flow/rest_api/base_handler.py +70 -0
- epyt_flow/rest_api/res_manager.py +95 -0
- epyt_flow/rest_api/scada_data_handler.py +476 -0
- epyt_flow/rest_api/scenario_handler.py +352 -0
- epyt_flow/rest_api/server.py +106 -0
- epyt_flow/serialization.py +438 -0
- epyt_flow/simulation/__init__.py +5 -0
- epyt_flow/simulation/events/__init__.py +6 -0
- epyt_flow/simulation/events/actuator_events.py +259 -0
- epyt_flow/simulation/events/event.py +81 -0
- epyt_flow/simulation/events/leakages.py +404 -0
- epyt_flow/simulation/events/sensor_faults.py +267 -0
- epyt_flow/simulation/events/sensor_reading_attack.py +185 -0
- epyt_flow/simulation/events/sensor_reading_event.py +170 -0
- epyt_flow/simulation/events/system_event.py +88 -0
- epyt_flow/simulation/parallel_simulation.py +147 -0
- epyt_flow/simulation/scada/__init__.py +3 -0
- epyt_flow/simulation/scada/advanced_control.py +134 -0
- epyt_flow/simulation/scada/scada_data.py +1589 -0
- epyt_flow/simulation/scada/scada_data_export.py +255 -0
- epyt_flow/simulation/scenario_config.py +608 -0
- epyt_flow/simulation/scenario_simulator.py +1897 -0
- epyt_flow/simulation/scenario_visualizer.py +61 -0
- epyt_flow/simulation/sensor_config.py +1289 -0
- epyt_flow/topology.py +290 -0
- epyt_flow/uncertainty/__init__.py +3 -0
- epyt_flow/uncertainty/model_uncertainty.py +302 -0
- epyt_flow/uncertainty/sensor_noise.py +73 -0
- epyt_flow/uncertainty/uncertainties.py +555 -0
- epyt_flow/uncertainty/utils.py +206 -0
- epyt_flow/utils.py +306 -0
- epyt_flow-0.1.0.dist-info/LICENSE +21 -0
- epyt_flow-0.1.0.dist-info/METADATA +139 -0
- epyt_flow-0.1.0.dist-info/RECORD +131 -0
- epyt_flow-0.1.0.dist-info/WHEEL +5 -0
- epyt_flow-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1897 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module provides a class for scenario simulations.
|
|
3
|
+
"""
|
|
4
|
+
import sys
|
|
5
|
+
import os
|
|
6
|
+
import pathlib
|
|
7
|
+
from typing import Generator, Union
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
import shutil
|
|
10
|
+
import warnings
|
|
11
|
+
import random
|
|
12
|
+
import math
|
|
13
|
+
import uuid
|
|
14
|
+
import numpy as np
|
|
15
|
+
from epyt import epanet
|
|
16
|
+
from epyt.epanet import ToolkitConstants
|
|
17
|
+
from tqdm import tqdm
|
|
18
|
+
|
|
19
|
+
from .scenario_config import ScenarioConfig
|
|
20
|
+
from .sensor_config import SensorConfig, SENSOR_TYPE_LINK_FLOW, SENSOR_TYPE_LINK_QUALITY, \
|
|
21
|
+
SENSOR_TYPE_NODE_DEMAND, SENSOR_TYPE_NODE_PRESSURE, SENSOR_TYPE_NODE_QUALITY, \
|
|
22
|
+
SENSOR_TYPE_PUMP_STATE, SENSOR_TYPE_TANK_VOLUME, SENSOR_TYPE_VALVE_STATE, \
|
|
23
|
+
SENSOR_TYPE_NODE_BULK_SPECIES, SENSOR_TYPE_LINK_BULK_SPECIES, SENSOR_TYPE_SURFACE_SPECIES
|
|
24
|
+
from ..uncertainty import ModelUncertainty, SensorNoise
|
|
25
|
+
from .events import SystemEvent, Leakage, ActuatorEvent, SensorFault, SensorReadingAttack, \
|
|
26
|
+
SensorReadingEvent
|
|
27
|
+
from .scada import ScadaData, AdvancedControlModule
|
|
28
|
+
from ..topology import NetworkTopology
|
|
29
|
+
from ..utils import get_temp_folder
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ScenarioSimulator():
|
|
33
|
+
"""
|
|
34
|
+
Class for running a simulation of a water distribution network scenario.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
f_inp_in : `str`
|
|
39
|
+
Path to the .inp file.
|
|
40
|
+
|
|
41
|
+
If this is None, then 'scenario_config' must be set with a valid configuration.
|
|
42
|
+
f_msx_in : `str`, option
|
|
43
|
+
Path to the .msx file -- optional, only necessary if EPANET-MSX is used.
|
|
44
|
+
|
|
45
|
+
The default is None.
|
|
46
|
+
scenario_config : :class:`~epyt_flow.simulation.scenario_config.ScenarioConfig`
|
|
47
|
+
Configuration of the scenario -- i.e. a description of the scenario to be simulated.
|
|
48
|
+
|
|
49
|
+
If this is None, then 'f_inp_in' must be set with a valid path to the .inp file
|
|
50
|
+
that is to be simulated.
|
|
51
|
+
|
|
52
|
+
Attributes
|
|
53
|
+
----------
|
|
54
|
+
epanet_api : `epyt.epanet`
|
|
55
|
+
API to EPANET and EPANET-MSX.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, f_inp_in: str = None, f_msx_in: str = None,
|
|
59
|
+
scenario_config: ScenarioConfig = None):
|
|
60
|
+
if f_msx_in is not None and f_inp_in is None:
|
|
61
|
+
raise ValueError("'f_inp_in' must be set if 'f_msx_in' is set.")
|
|
62
|
+
if f_inp_in is None and scenario_config is None:
|
|
63
|
+
raise ValueError("Either 'f_inp_in' or 'scenario_config' must be set.")
|
|
64
|
+
if f_inp_in is not None:
|
|
65
|
+
if not isinstance(f_inp_in, str):
|
|
66
|
+
raise TypeError("'f_inp_in' must be an instance of 'str' but not of " +
|
|
67
|
+
f"'{type(f_inp_in)}'")
|
|
68
|
+
if f_msx_in is not None:
|
|
69
|
+
if not isinstance(f_msx_in, str):
|
|
70
|
+
raise TypeError("'f_msx_in' must be an instance of 'str' but not of " +
|
|
71
|
+
f"'{type(f_msx_in)}'")
|
|
72
|
+
if scenario_config is not None:
|
|
73
|
+
if not isinstance(scenario_config, ScenarioConfig):
|
|
74
|
+
raise TypeError("'scenario_config' must be an instance of " +
|
|
75
|
+
"'epyt_flow.simulation.ScenarioConfig' but not of " +
|
|
76
|
+
f"'{type(scenario_config)}'")
|
|
77
|
+
|
|
78
|
+
self.__f_inp_in = f_inp_in if scenario_config is None else scenario_config.f_inp_in
|
|
79
|
+
self.__f_msx_in = f_msx_in if scenario_config is None else scenario_config.f_msx_in
|
|
80
|
+
self.__model_uncertainty = ModelUncertainty()
|
|
81
|
+
self.__sensor_noise = None
|
|
82
|
+
self.__sensor_config = None
|
|
83
|
+
self.__controls = []
|
|
84
|
+
self.__system_events = []
|
|
85
|
+
self.__sensor_reading_events = []
|
|
86
|
+
|
|
87
|
+
custom_epanet_lib = None
|
|
88
|
+
custom_epanetmsx_lib = None
|
|
89
|
+
if sys.platform.startswith("linux"):
|
|
90
|
+
path_to_custom_libs = os.path.join(pathlib.Path(__file__).parent.resolve(),
|
|
91
|
+
"..", "customlibs")
|
|
92
|
+
|
|
93
|
+
if os.path.isfile(os.path.join(path_to_custom_libs, "libepanet2_2.so")):
|
|
94
|
+
custom_epanet_lib = os.path.join(path_to_custom_libs, "libepanet2_2.so")
|
|
95
|
+
if os.path.isfile(os.path.join(path_to_custom_libs, "libepanetmsx2_2_0.so")):
|
|
96
|
+
custom_epanetmsx_lib = os.path.join(path_to_custom_libs, "libepanetmsx2_2_0.so")
|
|
97
|
+
|
|
98
|
+
self.epanet_api = epanet(self.__f_inp_in, ph=self.__f_msx_in is None,
|
|
99
|
+
customlib=custom_epanet_lib)
|
|
100
|
+
|
|
101
|
+
bulk_species = []
|
|
102
|
+
surface_species = []
|
|
103
|
+
if self.__f_msx_in is not None:
|
|
104
|
+
self.epanet_api.loadMSXFile(self.__f_msx_in, customMSXlib=custom_epanetmsx_lib)
|
|
105
|
+
|
|
106
|
+
for species_id, species_type in zip(self.epanet_api.getMSXSpeciesNameID(),
|
|
107
|
+
self.epanet_api.getMSXSpeciesType()):
|
|
108
|
+
if species_type == "BULK":
|
|
109
|
+
bulk_species.append(species_id)
|
|
110
|
+
elif species_type == "WALL":
|
|
111
|
+
surface_species.append(species_id)
|
|
112
|
+
|
|
113
|
+
self.__sensor_config = SensorConfig(nodes=self.epanet_api.getNodeNameID(),
|
|
114
|
+
links=self.epanet_api.getLinkNameID(),
|
|
115
|
+
valves=self.epanet_api.getLinkValveNameID(),
|
|
116
|
+
pumps=self.epanet_api.getLinkPumpNameID(),
|
|
117
|
+
tanks=self.epanet_api.getNodeTankNameID(),
|
|
118
|
+
bulk_species=bulk_species,
|
|
119
|
+
surface_species=surface_species)
|
|
120
|
+
if scenario_config is not None:
|
|
121
|
+
if scenario_config.general_params is not None:
|
|
122
|
+
self.set_general_parameters(**scenario_config.general_params)
|
|
123
|
+
|
|
124
|
+
self.__model_uncertainty = scenario_config.model_uncertainty
|
|
125
|
+
self.__sensor_noise = scenario_config.sensor_noise
|
|
126
|
+
self.__sensor_config = scenario_config.sensor_config
|
|
127
|
+
|
|
128
|
+
for control in scenario_config.controls:
|
|
129
|
+
self.add_control(control)
|
|
130
|
+
for event in scenario_config.system_events:
|
|
131
|
+
self.add_system_event(event)
|
|
132
|
+
for event in scenario_config.sensor_reading_events:
|
|
133
|
+
self.add_sensor_reading_event(event)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def f_inp_in(self) -> str:
|
|
137
|
+
"""
|
|
138
|
+
Gets the path to the .inp file.
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
`str`
|
|
143
|
+
Path to the .inp file.
|
|
144
|
+
"""
|
|
145
|
+
self.__adapt_to_network_changes()
|
|
146
|
+
|
|
147
|
+
return self.__f_inp_in
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def f_msx_in(self) -> str:
|
|
151
|
+
"""
|
|
152
|
+
Gets the path to the .msx file.
|
|
153
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
`str`
|
|
157
|
+
Path to the .msx file.
|
|
158
|
+
"""
|
|
159
|
+
self.__adapt_to_network_changes()
|
|
160
|
+
|
|
161
|
+
return self.__f_msx_in
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def model_uncertainty(self) -> ModelUncertainty:
|
|
165
|
+
"""
|
|
166
|
+
Gets the model uncertainty specification.
|
|
167
|
+
|
|
168
|
+
Returns
|
|
169
|
+
-------
|
|
170
|
+
:class:`~epyt_flow.uncertainty.model_uncertainty.ModelUncertainty`
|
|
171
|
+
Model uncertainty.
|
|
172
|
+
"""
|
|
173
|
+
self.__adapt_to_network_changes()
|
|
174
|
+
|
|
175
|
+
return deepcopy(self.__model_uncertainty)
|
|
176
|
+
|
|
177
|
+
@model_uncertainty.setter
|
|
178
|
+
def model_uncertainty(self, model_uncertainty: ModelUncertainty) -> None:
|
|
179
|
+
self.__adapt_to_network_changes()
|
|
180
|
+
|
|
181
|
+
self.set_model_uncertainty(model_uncertainty)
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def sensor_noise(self) -> SensorNoise:
|
|
185
|
+
"""
|
|
186
|
+
Gets the sensor noise/uncertainty.
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
:class:`~epyt_flow.uncertainty.sensor_noise.SensorNoise`
|
|
191
|
+
Sensor noise.
|
|
192
|
+
"""
|
|
193
|
+
self.__adapt_to_network_changes()
|
|
194
|
+
|
|
195
|
+
return deepcopy(self.__sensor_noise)
|
|
196
|
+
|
|
197
|
+
@sensor_noise.setter
|
|
198
|
+
def sensor_noise(self, sensor_noise: SensorNoise) -> None:
|
|
199
|
+
self.__adapt_to_network_changes()
|
|
200
|
+
|
|
201
|
+
self.set_sensor_noise(sensor_noise)
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def sensor_config(self) -> SensorConfig:
|
|
205
|
+
"""
|
|
206
|
+
Gets the sensor configuration.
|
|
207
|
+
|
|
208
|
+
Returns
|
|
209
|
+
-------
|
|
210
|
+
:class:`~epyt_flow.simulation.sensor_config.SensorConfig`
|
|
211
|
+
Sensor configuration.
|
|
212
|
+
"""
|
|
213
|
+
self.__adapt_to_network_changes()
|
|
214
|
+
|
|
215
|
+
return deepcopy(self.__sensor_config)
|
|
216
|
+
|
|
217
|
+
@sensor_config.setter
|
|
218
|
+
def sensor_config(self, sensor_config: SensorConfig) -> None:
|
|
219
|
+
if not isinstance(sensor_config, SensorConfig):
|
|
220
|
+
raise TypeError("'sensor_config' must be an instance of " +
|
|
221
|
+
"'epyt_flow.simulation.SensorConfig' but not of " +
|
|
222
|
+
f"'{type(sensor_config)}'")
|
|
223
|
+
|
|
224
|
+
sensor_config.validate(self.epanet_api)
|
|
225
|
+
|
|
226
|
+
self.__sensor_config = sensor_config
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def controls(self) -> list[AdvancedControlModule]:
|
|
230
|
+
"""
|
|
231
|
+
Gets all control modules.
|
|
232
|
+
|
|
233
|
+
Returns
|
|
234
|
+
-------
|
|
235
|
+
list[:class:`~epyt_flow.simulation.scada.advanced_control.AdvancedControlModule`]
|
|
236
|
+
All control modules.
|
|
237
|
+
"""
|
|
238
|
+
self.__adapt_to_network_changes()
|
|
239
|
+
|
|
240
|
+
return deepcopy(self.__controls)
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def leakages(self) -> list[Leakage]:
|
|
244
|
+
"""
|
|
245
|
+
Gets all leakages.
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
list[:class:`~epyt_flow.simulation.events.leakages.Leakage`]
|
|
250
|
+
All leakages.
|
|
251
|
+
"""
|
|
252
|
+
self.__adapt_to_network_changes()
|
|
253
|
+
|
|
254
|
+
return deepcopy(list(filter(lambda e: isinstance(e, Leakage), self.__system_events)))
|
|
255
|
+
|
|
256
|
+
def actuator_events(self) -> list[ActuatorEvent]:
|
|
257
|
+
"""
|
|
258
|
+
Gets all actuator events.
|
|
259
|
+
|
|
260
|
+
Returns
|
|
261
|
+
-------
|
|
262
|
+
list[:class:`~epyt_flow.simulation.events.actuator_event.ActuatorEvent`]
|
|
263
|
+
All actuator events.
|
|
264
|
+
"""
|
|
265
|
+
self.__adapt_to_network_changes()
|
|
266
|
+
|
|
267
|
+
return deepcopy(list(filter(lambda e: isinstance(e, ActuatorEvent), self.__system_events)))
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def system_events(self) -> list[SystemEvent]:
|
|
271
|
+
"""
|
|
272
|
+
Gets all system events (e.g. leakages, etc.).
|
|
273
|
+
|
|
274
|
+
Returns
|
|
275
|
+
-------
|
|
276
|
+
list[:class:`~epyt_flow.simulation.events.system_event.SystemEvent`]
|
|
277
|
+
All system events.
|
|
278
|
+
"""
|
|
279
|
+
self.__adapt_to_network_changes()
|
|
280
|
+
|
|
281
|
+
return deepcopy(self.__system_events)
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def sensor_faults(self) -> list[SensorFault]:
|
|
285
|
+
"""
|
|
286
|
+
Gets all sensor faults.
|
|
287
|
+
|
|
288
|
+
Returns
|
|
289
|
+
-------
|
|
290
|
+
list[:class:`~epyt_flow.simulation.events.sensor_faults.SensorFault`]
|
|
291
|
+
All sensor faults.
|
|
292
|
+
"""
|
|
293
|
+
self.__adapt_to_network_changes()
|
|
294
|
+
|
|
295
|
+
return deepcopy(list(filter(lambda e: isinstance(e, SensorFault),
|
|
296
|
+
self.__sensor_reading_events)))
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def sensor_reading_events(self) -> list[SensorReadingEvent]:
|
|
300
|
+
"""
|
|
301
|
+
Gets all sensor reading events (e.g. sensor faults, etc.).
|
|
302
|
+
|
|
303
|
+
Returns
|
|
304
|
+
-------
|
|
305
|
+
list[:class:`~epyt_flow.simulation.events.sensor_reading_event.SensorReadingEvent`]
|
|
306
|
+
All sensor reading events.
|
|
307
|
+
"""
|
|
308
|
+
self.__adapt_to_network_changes()
|
|
309
|
+
|
|
310
|
+
return deepcopy(self.__sensor_reading_events)
|
|
311
|
+
|
|
312
|
+
def __adapt_to_network_changes(self):
|
|
313
|
+
nodes = self.epanet_api.getNodeNameID()
|
|
314
|
+
links = self.epanet_api.getLinkNameID()
|
|
315
|
+
valves = self.epanet_api.getLinkValveNameID()
|
|
316
|
+
pumps = self.epanet_api.getLinkPumpNameID()
|
|
317
|
+
tanks = self.epanet_api.getNodeTankNameID()
|
|
318
|
+
bulk_species = []
|
|
319
|
+
surface_species = []
|
|
320
|
+
|
|
321
|
+
if self.__f_msx_in is not None:
|
|
322
|
+
for species_id, species_type in zip(self.epanet_api.getMSXSpeciesNameID(),
|
|
323
|
+
self.epanet_api.getMSXSpeciesType()):
|
|
324
|
+
if species_type == "BULK":
|
|
325
|
+
bulk_species.append(species_id)
|
|
326
|
+
elif species_type == "WALL":
|
|
327
|
+
surface_species.append(species_id)
|
|
328
|
+
|
|
329
|
+
node_id_to_idx = {node_id: self.epanet_api.getNodeIndex(node_id) - 1 for node_id in nodes}
|
|
330
|
+
link_id_to_idx = {link_id: self.epanet_api.getLinkIndex(link_id) - 1 for link_id in links}
|
|
331
|
+
valve_id_to_idx = None # {valve_id: self.epanet_api.getLinkValveIndex(valve_id) for valve_id in valves}
|
|
332
|
+
pump_id_to_idx = None # {pump_id: self.epanet_api.getLinkPumpIndex(pump_id) - 1 for pump_id in pumps}
|
|
333
|
+
tank_id_to_idx = None #{tank_id: self.epanet_api.getNodeTankIndex(tank_id) - 1 for tank_id in tanks}
|
|
334
|
+
bulkspecies_id_to_idx = None
|
|
335
|
+
surfacespecies_id_to_idx = None
|
|
336
|
+
|
|
337
|
+
if nodes != self.__sensor_config.nodes or links != self.__sensor_config.links or \
|
|
338
|
+
valves != self.__sensor_config.valves or pumps != self.__sensor_config.pumps or \
|
|
339
|
+
tanks != self.__sensor_config.tanks or \
|
|
340
|
+
bulk_species != self.__sensor_config.bulk_species or \
|
|
341
|
+
surface_species != self.__sensor_config.surface_species:
|
|
342
|
+
# Adapt sensor configuration if anything in the network topology changed
|
|
343
|
+
new_sensor_config = SensorConfig(nodes=nodes, links=links, valves=valves, pumps=pumps,
|
|
344
|
+
tanks=tanks, bulk_species=bulk_species,
|
|
345
|
+
surface_species=surface_species,
|
|
346
|
+
node_id_to_idx=node_id_to_idx,
|
|
347
|
+
link_id_to_idx=link_id_to_idx,
|
|
348
|
+
valve_id_to_idx=valve_id_to_idx,
|
|
349
|
+
pump_id_to_idx=pump_id_to_idx,
|
|
350
|
+
tank_id_to_idx=tank_id_to_idx,
|
|
351
|
+
bulkspecies_id_to_idx=bulkspecies_id_to_idx,
|
|
352
|
+
surfacespecies_id_to_idx=surfacespecies_id_to_idx)
|
|
353
|
+
new_sensor_config.pressure_sensors = self.__sensor_config.pressure_sensors
|
|
354
|
+
new_sensor_config.flow_sensors = self.__sensor_config.flow_sensors
|
|
355
|
+
new_sensor_config.demand_sensors = self.__sensor_config.demand_sensors
|
|
356
|
+
new_sensor_config.quality_node_sensors = self.__sensor_config.quality_node_sensors
|
|
357
|
+
new_sensor_config.quality_link_sensors = self.__sensor_config.quality_link_sensors
|
|
358
|
+
new_sensor_config.pump_state_sensors = self.__sensor_config.pump_state_sensors
|
|
359
|
+
new_sensor_config.valve_state_sensors = self.__sensor_config.valve_state_sensors
|
|
360
|
+
new_sensor_config.tank_volume_sensors = self.__sensor_config.tank_volume_sensors
|
|
361
|
+
new_sensor_config.bulk_species_node_sensors = self.__sensor_config.bulk_species_node_sensors
|
|
362
|
+
new_sensor_config.bulk_species_link_sensors = self.__sensor_config.bulk_species_link_sensors
|
|
363
|
+
new_sensor_config.surface_species_sensors = self.__sensor_config.surface_species_sensors
|
|
364
|
+
|
|
365
|
+
self.__sensor_config = new_sensor_config
|
|
366
|
+
|
|
367
|
+
def close(self):
|
|
368
|
+
"""
|
|
369
|
+
Closes & unloads all resources and libraries.
|
|
370
|
+
|
|
371
|
+
Call this function after the simulation is done -- do not call this function before!
|
|
372
|
+
"""
|
|
373
|
+
if self.__f_msx_in is not None:
|
|
374
|
+
self.epanet_api.unloadMSX()
|
|
375
|
+
|
|
376
|
+
self.epanet_api.unload()
|
|
377
|
+
|
|
378
|
+
def __enter__(self):
|
|
379
|
+
return self
|
|
380
|
+
|
|
381
|
+
def __exit__(self, *args):
|
|
382
|
+
self.close()
|
|
383
|
+
|
|
384
|
+
def get_flow_units(self) -> int:
|
|
385
|
+
"""
|
|
386
|
+
Gets the flow units.
|
|
387
|
+
|
|
388
|
+
Will be one of the following EPANET toolkit constants:
|
|
389
|
+
|
|
390
|
+
- EN_CFS = 0
|
|
391
|
+
- EN_GPM = 1
|
|
392
|
+
- EN_MGD = 2
|
|
393
|
+
- EN_IMGD = 3
|
|
394
|
+
- EN_AFD = 4
|
|
395
|
+
- EN_LPS = 5
|
|
396
|
+
- EN_LPM = 6
|
|
397
|
+
- EN_MLD = 7
|
|
398
|
+
- EN_CMH = 8
|
|
399
|
+
- EN_CMD = 9
|
|
400
|
+
|
|
401
|
+
Returns
|
|
402
|
+
-------
|
|
403
|
+
`int`
|
|
404
|
+
Flow units.
|
|
405
|
+
"""
|
|
406
|
+
return self.epanet_api.api.ENgetflowunits()
|
|
407
|
+
|
|
408
|
+
def get_hydraulic_time_step(self) -> int:
|
|
409
|
+
"""
|
|
410
|
+
Gets the hydraulic time step -- i.e. time step in the hydraulic simulation.
|
|
411
|
+
|
|
412
|
+
Returns
|
|
413
|
+
-------
|
|
414
|
+
`int`
|
|
415
|
+
Hydraulic time step in seconds.
|
|
416
|
+
"""
|
|
417
|
+
return self.epanet_api.getTimeHydraulicStep()
|
|
418
|
+
|
|
419
|
+
def get_quality_time_step(self) -> int:
|
|
420
|
+
"""
|
|
421
|
+
Gets the quality time step -- i.e. time step in the simple quality simulation.
|
|
422
|
+
|
|
423
|
+
Returns
|
|
424
|
+
-------
|
|
425
|
+
`int`
|
|
426
|
+
Quality time step in seconds.
|
|
427
|
+
"""
|
|
428
|
+
return self.epanet_api.getTimeQualityStep()
|
|
429
|
+
|
|
430
|
+
def get_simulation_duration(self) -> int:
|
|
431
|
+
"""
|
|
432
|
+
Gets the simulation duration -- i.e. time length to be simulated.
|
|
433
|
+
|
|
434
|
+
Returns
|
|
435
|
+
-------
|
|
436
|
+
`int`
|
|
437
|
+
Simulation duration in seconds.
|
|
438
|
+
"""
|
|
439
|
+
return self.epanet_api.getTimeSimulationDuration()
|
|
440
|
+
|
|
441
|
+
def get_demand_model(self) -> dict:
|
|
442
|
+
"""
|
|
443
|
+
Gets the demand model and its parameters.
|
|
444
|
+
|
|
445
|
+
Returns
|
|
446
|
+
-------
|
|
447
|
+
`dict`
|
|
448
|
+
Demand model.
|
|
449
|
+
"""
|
|
450
|
+
demand_info = self.epanet_api.getDemandModel()
|
|
451
|
+
|
|
452
|
+
return {"type": "PDA" if demand_info.DemandModelCode == 1 else "DDA",
|
|
453
|
+
"pressure_min": demand_info.DemandModelPmin,
|
|
454
|
+
"pressure_required": demand_info.DemandModelPreq,
|
|
455
|
+
"pressure_exponent": demand_info.DemandModelPexp}
|
|
456
|
+
|
|
457
|
+
def get_quality_model(self) -> dict:
|
|
458
|
+
"""
|
|
459
|
+
Gets the quality model and its parameters.
|
|
460
|
+
|
|
461
|
+
Note that this quality model refers to the basic quality analysis
|
|
462
|
+
as implemented in EPANET.
|
|
463
|
+
|
|
464
|
+
Returns
|
|
465
|
+
-------
|
|
466
|
+
`dict`
|
|
467
|
+
Quality model.
|
|
468
|
+
"""
|
|
469
|
+
qual_info = self.epanet_api.getQualityInfo()
|
|
470
|
+
|
|
471
|
+
return {"code": qual_info.QualityCode,
|
|
472
|
+
"type": qual_info.QualityType,
|
|
473
|
+
"chemical_name": qual_info.QualityChemName,
|
|
474
|
+
"units": qual_info.QualityChemUnits,
|
|
475
|
+
"trace_node_id": qual_info.TraceNode}
|
|
476
|
+
|
|
477
|
+
def get_scenario_config(self) -> ScenarioConfig:
|
|
478
|
+
"""
|
|
479
|
+
Gets the configuration of this scenario -- i.e. all information & elements
|
|
480
|
+
that completely describe this scenario.
|
|
481
|
+
|
|
482
|
+
Returns
|
|
483
|
+
-------
|
|
484
|
+
:class:`~epyt_flow.simulation.scenario_config.ScenarioConfig`
|
|
485
|
+
Complete scenario specification.
|
|
486
|
+
"""
|
|
487
|
+
self.__adapt_to_network_changes()
|
|
488
|
+
|
|
489
|
+
general_params = {"hydraulic_time_step": self.get_hydraulic_time_step(),
|
|
490
|
+
"quality_time_step": self.get_quality_time_step(),
|
|
491
|
+
"simulation_duration": self.get_simulation_duration(),
|
|
492
|
+
"flow_units": self.get_flow_units(),
|
|
493
|
+
"quality_model": self.get_quality_model(),
|
|
494
|
+
"demand_model": self.get_demand_model()}
|
|
495
|
+
|
|
496
|
+
return ScenarioConfig(f_inp_in=self.__f_inp_in, f_msx_in=self.__f_msx_in,
|
|
497
|
+
general_params=general_params, sensor_config=self.sensor_config,
|
|
498
|
+
memory_consumption_estimate=self.estimate_memory_consumption(),
|
|
499
|
+
controls=self.controls, sensor_noise=self.sensor_noise,
|
|
500
|
+
model_uncertainty=self.model_uncertainty,
|
|
501
|
+
system_events=self.system_events,
|
|
502
|
+
sensor_reading_events=self.sensor_reading_events)
|
|
503
|
+
|
|
504
|
+
def estimate_memory_consumption(self) -> float:
|
|
505
|
+
"""
|
|
506
|
+
Estimates the memory consumption of the simulation -- i.e. the amount of memory that is
|
|
507
|
+
needed on the hard disk as well as in RAM.
|
|
508
|
+
|
|
509
|
+
Returns
|
|
510
|
+
-------
|
|
511
|
+
`float`
|
|
512
|
+
Estimated memory consumption in MB.
|
|
513
|
+
"""
|
|
514
|
+
self.__adapt_to_network_changes()
|
|
515
|
+
|
|
516
|
+
n_time_steps = int(self.epanet_api.getTimeSimulationDuration() /
|
|
517
|
+
self.epanet_api.getTimeReportingStep())
|
|
518
|
+
n_quantities = self.epanet_api.getNodeCount() * 3 + self.epanet_api.getNodeTankCount() + \
|
|
519
|
+
self.epanet_api.getLinkValveCount() + self.epanet_api.getLinkPumpCount() + \
|
|
520
|
+
self.epanet_api.getLinkCount() * 2
|
|
521
|
+
n_bytes_per_quantity = 64
|
|
522
|
+
|
|
523
|
+
return n_time_steps * n_quantities * n_bytes_per_quantity * .000001
|
|
524
|
+
|
|
525
|
+
def get_topology(self) -> NetworkTopology:
|
|
526
|
+
"""
|
|
527
|
+
Gets the topology (incl. information such as elevations, pipe diameters, etc.) of this WDN.
|
|
528
|
+
|
|
529
|
+
Returns
|
|
530
|
+
-------
|
|
531
|
+
`epyt_flow.topology.NetworkTopology`
|
|
532
|
+
Topology of this WDN as a graph.
|
|
533
|
+
"""
|
|
534
|
+
self.__adapt_to_network_changes()
|
|
535
|
+
|
|
536
|
+
# Collect information about the topology of the water distribution network
|
|
537
|
+
nodes_id = self.epanet_api.getNodeNameID()
|
|
538
|
+
nodes_elevation = self.epanet_api.getNodeElevations()
|
|
539
|
+
nodes_type = [self.epanet_api.TYPENODE[i] for i in self.epanet_api.getNodeTypeIndex()]
|
|
540
|
+
|
|
541
|
+
links_id = self.epanet_api.getLinkNameID()
|
|
542
|
+
links_data = self.epanet_api.getNodesConnectingLinksID()
|
|
543
|
+
links_diameter = self.epanet_api.getLinkDiameter()
|
|
544
|
+
links_length = self.epanet_api.getLinkLength()
|
|
545
|
+
links_roughness_coeff = self.epanet_api.getLinkRoughnessCoeff()
|
|
546
|
+
links_bulk_coeff = self.epanet_api.getLinkBulkReactionCoeff()
|
|
547
|
+
links_wall_coeff = self.epanet_api.getLinkWallReactionCoeff()
|
|
548
|
+
links_loss_coeff = self.epanet_api.getLinkMinorLossCoeff()
|
|
549
|
+
|
|
550
|
+
# Build graph describing the topology
|
|
551
|
+
nodes = []
|
|
552
|
+
for node, node_elevation, node_type in zip(nodes_id, nodes_elevation, nodes_type):
|
|
553
|
+
nodes.append((node, {"elevation": node_elevation, "type": node_type}))
|
|
554
|
+
|
|
555
|
+
links = []
|
|
556
|
+
for link_id, link, diameter, length, roughness_coeff, bulk_coeff, wall_coeff, loss_coeff \
|
|
557
|
+
in zip(links_id, links_data, links_diameter, links_length, links_roughness_coeff,
|
|
558
|
+
links_bulk_coeff, links_wall_coeff, links_loss_coeff):
|
|
559
|
+
links.append((link_id, link, {"diameter": diameter, "length": length,
|
|
560
|
+
"roughness_coeff": roughness_coeff,
|
|
561
|
+
"bulk_coeff": bulk_coeff, "wall_coeff": wall_coeff,
|
|
562
|
+
"loss_coeff": loss_coeff}))
|
|
563
|
+
|
|
564
|
+
return NetworkTopology(f_inp=self.f_inp_in, nodes=nodes, links=links)
|
|
565
|
+
|
|
566
|
+
def randomize_demands(self) -> None:
|
|
567
|
+
"""
|
|
568
|
+
Randomizes all demand patterns.
|
|
569
|
+
"""
|
|
570
|
+
self.__adapt_to_network_changes()
|
|
571
|
+
|
|
572
|
+
# Get all demand patterns
|
|
573
|
+
demand_patterns_idx = self.epanet_api.getNodeDemandPatternIndex()
|
|
574
|
+
demand_patterns_id = np.unique([idx for _, idx in demand_patterns_idx.items()])
|
|
575
|
+
|
|
576
|
+
# Process each pattern separately
|
|
577
|
+
for pattern_id in demand_patterns_id:
|
|
578
|
+
if pattern_id == 0:
|
|
579
|
+
continue
|
|
580
|
+
|
|
581
|
+
pattern_length = self.epanet_api.getPatternLengths(pattern_id)
|
|
582
|
+
pattern = []
|
|
583
|
+
for t in range(pattern_length): # Get pattern
|
|
584
|
+
pattern.append(self.epanet_api.getPatternValue(pattern_id, t + 1))
|
|
585
|
+
|
|
586
|
+
random.shuffle(pattern) # Shuffle pattern
|
|
587
|
+
|
|
588
|
+
for t in range(pattern_length): # Set shuffled/randomized pattern
|
|
589
|
+
self.epanet_api.setPatternValue(pattern_id, t + 1, pattern[t])
|
|
590
|
+
|
|
591
|
+
def set_node_demand_pattern(self, node_id: str, base_demand: float, demand_pattern_id: str,
|
|
592
|
+
demand_pattern: np.ndarray) -> None:
|
|
593
|
+
"""
|
|
594
|
+
Sets the demand pattern (incl. base demand) at a given node.
|
|
595
|
+
|
|
596
|
+
Parameters
|
|
597
|
+
----------
|
|
598
|
+
node_id : `str`
|
|
599
|
+
ID of the node for which the demand pattern is set.
|
|
600
|
+
base_demand : `float`
|
|
601
|
+
Base demand.
|
|
602
|
+
demand_pattern_id : `str`
|
|
603
|
+
ID of the new demand pattern.
|
|
604
|
+
demand_pattern : `numpy.ndarray`
|
|
605
|
+
Demand pattern over time. Final demand over time = base_demand * demand_pattern
|
|
606
|
+
"""
|
|
607
|
+
self.__adapt_to_network_changes()
|
|
608
|
+
|
|
609
|
+
if node_id not in self.__sensor_config.nodes:
|
|
610
|
+
raise ValueError(f"Unknown node '{node_id}'")
|
|
611
|
+
if not isinstance(base_demand, float):
|
|
612
|
+
raise TypeError("'base_demand' must be an instance of 'float' " +
|
|
613
|
+
f"but not if '{type(base_demand)}'")
|
|
614
|
+
if not isinstance(demand_pattern_id, str):
|
|
615
|
+
raise TypeError("'demand_pattern_id' must be an instance of 'str' " +
|
|
616
|
+
f"but not of '{type(demand_pattern_id)}'")
|
|
617
|
+
if not isinstance(demand_pattern, np.ndarray):
|
|
618
|
+
raise TypeError("'demand_pattern' must be an instance of 'numpy.ndarray' " +
|
|
619
|
+
f"but not of '{type(demand_pattern)}'")
|
|
620
|
+
if len(demand_pattern.shape) > 1:
|
|
621
|
+
raise ValueError(f"Inconsistent demand pattern shape '{demand_pattern.shape}' " +
|
|
622
|
+
"detected. Expected a one dimensional array!")
|
|
623
|
+
|
|
624
|
+
node_idx = self.epanet_api.getNodeIndex(node_id)
|
|
625
|
+
self.epanet_api.addPattern(demand_pattern_id, demand_pattern)
|
|
626
|
+
self.epanet_api.setNodeJunctionData(node_idx, self.epanet_api.getNodeElevations(node_idx),
|
|
627
|
+
base_demand, demand_pattern_id)
|
|
628
|
+
|
|
629
|
+
def add_control(self, control: AdvancedControlModule) -> None:
|
|
630
|
+
"""
|
|
631
|
+
Adds a control module to the scenario simulation.
|
|
632
|
+
|
|
633
|
+
Parameters
|
|
634
|
+
----------
|
|
635
|
+
control : :class:`~epyt_flow.simulation.scada.advanced_control.AdvancedControlModule`
|
|
636
|
+
Control module.
|
|
637
|
+
"""
|
|
638
|
+
self.__adapt_to_network_changes()
|
|
639
|
+
|
|
640
|
+
if not isinstance(control, AdvancedControlModule):
|
|
641
|
+
raise TypeError("'control' must be an instance of " +
|
|
642
|
+
"'epyt_flow.simulation.scada.AdvancedControlModule' not of " +
|
|
643
|
+
f"'{type(control)}'")
|
|
644
|
+
|
|
645
|
+
self.__controls.append(control)
|
|
646
|
+
|
|
647
|
+
def add_leakage(self, leakage_event: Leakage) -> None:
|
|
648
|
+
"""
|
|
649
|
+
Adds a leakage to the scenario simulation.
|
|
650
|
+
|
|
651
|
+
Parameters
|
|
652
|
+
----------
|
|
653
|
+
event : :class:`~epyt_flow.simulation.events.leakages.Leakage`
|
|
654
|
+
Leakage.
|
|
655
|
+
"""
|
|
656
|
+
self.__adapt_to_network_changes()
|
|
657
|
+
|
|
658
|
+
if not isinstance(leakage_event, Leakage):
|
|
659
|
+
raise TypeError("'leakage_event' must be an instance of " +
|
|
660
|
+
"'epyt_flow.simulation.events.Leakage' not of " +
|
|
661
|
+
f"'{type(leakage_event)}'")
|
|
662
|
+
|
|
663
|
+
self.add_system_event(leakage_event)
|
|
664
|
+
|
|
665
|
+
def add_actuator_event(self, event: ActuatorEvent) -> None:
|
|
666
|
+
"""
|
|
667
|
+
Adds an actuator event to the scenario simulation.
|
|
668
|
+
|
|
669
|
+
Parameters
|
|
670
|
+
----------
|
|
671
|
+
event : :class:`~epyt_flow.simulation.events.actuator_events.ActuatorEvent`
|
|
672
|
+
Actuator event.
|
|
673
|
+
"""
|
|
674
|
+
self.__adapt_to_network_changes()
|
|
675
|
+
|
|
676
|
+
if not isinstance(event, ActuatorEvent):
|
|
677
|
+
raise TypeError("'event' must be an instance of " +
|
|
678
|
+
f"'epyt_flow.simulation.events.ActuatorEvent' not of '{type(event)}'")
|
|
679
|
+
|
|
680
|
+
self.add_system_event(event)
|
|
681
|
+
|
|
682
|
+
def add_system_event(self, event: SystemEvent) -> None:
|
|
683
|
+
"""
|
|
684
|
+
Adds a system event to the scenario simulation -- i.e. an event directly
|
|
685
|
+
affecting the EPANET simulation.
|
|
686
|
+
|
|
687
|
+
Parameters
|
|
688
|
+
----------
|
|
689
|
+
event : :class:`~epyt_flow.simulation.events.system_event.SystemEvent`
|
|
690
|
+
System event.
|
|
691
|
+
"""
|
|
692
|
+
self.__adapt_to_network_changes()
|
|
693
|
+
|
|
694
|
+
if not isinstance(event, SystemEvent):
|
|
695
|
+
raise TypeError("'event' must be an instance of " +
|
|
696
|
+
f"'epyt_flow.simulation.events.SystemEvent' not of '{type(event)}'")
|
|
697
|
+
|
|
698
|
+
event.init(self.epanet_api)
|
|
699
|
+
|
|
700
|
+
self.__system_events.append(event)
|
|
701
|
+
|
|
702
|
+
def add_sensor_fault(self, sensor_fault_event: SensorFault) -> None:
|
|
703
|
+
"""
|
|
704
|
+
Adds a sensor fault to the scenario simulation.
|
|
705
|
+
|
|
706
|
+
Parameters
|
|
707
|
+
----------
|
|
708
|
+
sensor_fault_event : :class:`~epyt_flow.simulation.events.sensor_faults.SensorFault`
|
|
709
|
+
Sensor fault specifications.
|
|
710
|
+
"""
|
|
711
|
+
self.__adapt_to_network_changes()
|
|
712
|
+
|
|
713
|
+
sensor_fault_event.validate(self.__sensor_config)
|
|
714
|
+
|
|
715
|
+
if not isinstance(sensor_fault_event, SensorFault):
|
|
716
|
+
raise TypeError("'sensor_fault_event' must be an instance of " +
|
|
717
|
+
"'epyt_flow.simulation.events.SensorFault' not of " +
|
|
718
|
+
f"'{type(sensor_fault_event)}'")
|
|
719
|
+
|
|
720
|
+
self.__sensor_reading_events.append(sensor_fault_event)
|
|
721
|
+
|
|
722
|
+
def add_sensor_reading_attack(self, sensor_reading_attack: SensorReadingAttack) -> None:
|
|
723
|
+
"""
|
|
724
|
+
Adds a sensor reading attack to the scenario simulation.
|
|
725
|
+
|
|
726
|
+
Parameters
|
|
727
|
+
----------
|
|
728
|
+
sensor_reading_attack : :class:`~epyt_flow.simulation.events.sensor_reading_attack.SensorReadingAttack`
|
|
729
|
+
Sensor fault specifications.
|
|
730
|
+
"""
|
|
731
|
+
self.__adapt_to_network_changes()
|
|
732
|
+
|
|
733
|
+
sensor_reading_attack.validate(self.__sensor_config)
|
|
734
|
+
|
|
735
|
+
if not isinstance(sensor_reading_attack, SensorReadingAttack):
|
|
736
|
+
raise TypeError("'sensor_reading_attack' must be an instance of " +
|
|
737
|
+
"'epyt_flow.simulation.events.SensorReadingAttack' not of " +
|
|
738
|
+
f"'{type(sensor_reading_attack)}'")
|
|
739
|
+
|
|
740
|
+
self.__sensor_reading_events.append(sensor_reading_attack)
|
|
741
|
+
|
|
742
|
+
def add_sensor_reading_event(self, event: SensorReadingEvent) -> None:
|
|
743
|
+
"""
|
|
744
|
+
Adds a sensor reading event to the scenario simulation.
|
|
745
|
+
|
|
746
|
+
Parameters
|
|
747
|
+
----------
|
|
748
|
+
event : :class:`~epyt_flow.simulation.events.sensor_reading_event.SensorReadingEvent`
|
|
749
|
+
Sensor reading event.
|
|
750
|
+
"""
|
|
751
|
+
self.__adapt_to_network_changes()
|
|
752
|
+
|
|
753
|
+
event.validate(self.__sensor_config)
|
|
754
|
+
|
|
755
|
+
if not isinstance(event, SensorReadingEvent):
|
|
756
|
+
raise TypeError("'event' must be an instance of " +
|
|
757
|
+
"'epyt_flow.simulation.events.SensorReadingEvent' not of " +
|
|
758
|
+
f"'{type(event)}'")
|
|
759
|
+
|
|
760
|
+
self.__sensor_reading_events.append(event)
|
|
761
|
+
|
|
762
|
+
def set_sensors(self, sensor_type: int, sensor_locations: Union[list[str], dict]) -> None:
|
|
763
|
+
"""
|
|
764
|
+
Specifies all sensors of a given type (e.g. pressure sensor, flow sensor, etc.)
|
|
765
|
+
|
|
766
|
+
Parameters
|
|
767
|
+
----------
|
|
768
|
+
sensor_type : `int`
|
|
769
|
+
Sensor type. Must be one of the following:
|
|
770
|
+
- SENSOR_TYPE_NODE_PRESSURE = 1
|
|
771
|
+
- SENSOR_TYPE_NODE_QUALITY = 2
|
|
772
|
+
- SENSOR_TYPE_NODE_DEMAND = 3
|
|
773
|
+
- SENSOR_TYPE_LINK_FLOW = 4
|
|
774
|
+
- SENSOR_TYPE_LINK_QUALITY = 5
|
|
775
|
+
- SENSOR_TYPE_VALVE_STATE = 6
|
|
776
|
+
- SENSOR_TYPE_PUMP_STATE = 7
|
|
777
|
+
- SENSOR_TYPE_TANK_VOLUME = 8
|
|
778
|
+
- SENSOR_TYPE_BULK_SPECIES = 9
|
|
779
|
+
- SENSOR_TYPE_SURFACE_SPECIES = 10
|
|
780
|
+
sensor_locations : `list[str]` or `dict`
|
|
781
|
+
Locations (IDs) of sensors either as a list or as a dict in the case of
|
|
782
|
+
bulk and surface species.
|
|
783
|
+
"""
|
|
784
|
+
self.__adapt_to_network_changes()
|
|
785
|
+
|
|
786
|
+
if sensor_type == SENSOR_TYPE_NODE_PRESSURE:
|
|
787
|
+
self.__sensor_config.pressure_sensors = sensor_locations
|
|
788
|
+
elif sensor_type == SENSOR_TYPE_LINK_FLOW:
|
|
789
|
+
self.__sensor_config.flow_sensors = sensor_locations
|
|
790
|
+
elif sensor_type == SENSOR_TYPE_NODE_DEMAND:
|
|
791
|
+
self.__sensor_config.demand_sensors = sensor_locations
|
|
792
|
+
elif sensor_type == SENSOR_TYPE_NODE_QUALITY:
|
|
793
|
+
self.__sensor_config.quality_node_sensors = sensor_locations
|
|
794
|
+
elif sensor_type == SENSOR_TYPE_LINK_QUALITY:
|
|
795
|
+
self.__sensor_config.quality_link_sensors = sensor_locations
|
|
796
|
+
elif sensor_type == SENSOR_TYPE_VALVE_STATE:
|
|
797
|
+
self.__sensor_config.valve_state_sensors = sensor_locations
|
|
798
|
+
elif sensor_type == SENSOR_TYPE_PUMP_STATE:
|
|
799
|
+
self.__sensor_config.pump_state_sensors = sensor_locations
|
|
800
|
+
elif sensor_type == SENSOR_TYPE_TANK_VOLUME:
|
|
801
|
+
self.__sensor_config.tank_volume_sensors = sensor_locations
|
|
802
|
+
elif sensor_type == SENSOR_TYPE_NODE_BULK_SPECIES:
|
|
803
|
+
self.__sensor_config.bulk_species_node_sensors = sensor_locations
|
|
804
|
+
elif sensor_type == SENSOR_TYPE_LINK_BULK_SPECIES:
|
|
805
|
+
self.__sensor_config.bulk_species_link_sensors = sensor_locations
|
|
806
|
+
elif sensor_type == SENSOR_TYPE_SURFACE_SPECIES:
|
|
807
|
+
self.__sensor_config.surface_species_sensors = sensor_locations
|
|
808
|
+
else:
|
|
809
|
+
raise ValueError(f"Unknown sensor type '{sensor_type}'")
|
|
810
|
+
|
|
811
|
+
self.__sensor_config.validate(self.epanet_api)
|
|
812
|
+
|
|
813
|
+
def set_pressure_sensors(self, sensor_locations: list[str]) -> None:
|
|
814
|
+
"""
|
|
815
|
+
Sets the pressure sensors -- i.e. measuring pressure at some nodes in the network.
|
|
816
|
+
|
|
817
|
+
Parameters
|
|
818
|
+
----------
|
|
819
|
+
sensor_locations : `list[str]`
|
|
820
|
+
Locations (IDs) of sensors.
|
|
821
|
+
"""
|
|
822
|
+
self.set_sensors(SENSOR_TYPE_NODE_PRESSURE, sensor_locations)
|
|
823
|
+
|
|
824
|
+
def set_flow_sensors(self, sensor_locations: list[str]) -> None:
|
|
825
|
+
"""
|
|
826
|
+
Sets the flow sensors -- i.e. measuring flows at some links/pipes in the network.
|
|
827
|
+
|
|
828
|
+
Parameters
|
|
829
|
+
----------
|
|
830
|
+
sensor_locations : `list[str]`
|
|
831
|
+
Locations (IDs) of sensors.
|
|
832
|
+
"""
|
|
833
|
+
self.set_sensors(SENSOR_TYPE_LINK_FLOW, sensor_locations)
|
|
834
|
+
|
|
835
|
+
def set_demand_sensors(self, sensor_locations: list[str]) -> None:
|
|
836
|
+
"""
|
|
837
|
+
Sets the demand sensors -- i.e. measuring demands at some nodes in the network.
|
|
838
|
+
|
|
839
|
+
Parameters
|
|
840
|
+
----------
|
|
841
|
+
sensor_locations : `list[str]`
|
|
842
|
+
Locations (IDs) of sensors.
|
|
843
|
+
"""
|
|
844
|
+
self.set_sensors(SENSOR_TYPE_NODE_DEMAND, sensor_locations)
|
|
845
|
+
|
|
846
|
+
def set_node_quality_sensors(self, sensor_locations: list[str]) -> None:
|
|
847
|
+
"""
|
|
848
|
+
Sets the node quality sensors -- i.e. measuring the water quality
|
|
849
|
+
(e.g. age, chlorine concentration, etc.) at some nodes in the network.
|
|
850
|
+
|
|
851
|
+
Parameters
|
|
852
|
+
----------
|
|
853
|
+
sensor_locations : `list[str]`
|
|
854
|
+
Locations (IDs) of sensors.
|
|
855
|
+
"""
|
|
856
|
+
self.set_sensors(SENSOR_TYPE_NODE_QUALITY, sensor_locations)
|
|
857
|
+
|
|
858
|
+
def set_link_quality_sensors(self, sensor_locations: list[str]) -> None:
|
|
859
|
+
"""
|
|
860
|
+
Sets the link quality sensors -- i.e. measuring the water quality
|
|
861
|
+
(e.g. age, chlorine concentration, etc.) at some links/pipes in the network.
|
|
862
|
+
|
|
863
|
+
Parameters
|
|
864
|
+
----------
|
|
865
|
+
sensor_locations : `list[str]`
|
|
866
|
+
Locations (IDs) of sensors.
|
|
867
|
+
"""
|
|
868
|
+
self.set_sensors(SENSOR_TYPE_LINK_QUALITY, sensor_locations)
|
|
869
|
+
|
|
870
|
+
def set_valve_sensors(self, sensor_locations: list[str]) -> None:
|
|
871
|
+
"""
|
|
872
|
+
Sets the valve state sensors -- i.e. retrieving the state of some valves in the network.
|
|
873
|
+
|
|
874
|
+
Parameters
|
|
875
|
+
----------
|
|
876
|
+
sensor_locations : `list[str]`
|
|
877
|
+
Locations (IDs) of sensors.
|
|
878
|
+
"""
|
|
879
|
+
self.set_sensors(SENSOR_TYPE_VALVE_STATE, sensor_locations)
|
|
880
|
+
|
|
881
|
+
def set_pump_sensors(self, sensor_locations: list[str]) -> None:
|
|
882
|
+
"""
|
|
883
|
+
Sets the pump state sensors -- i.e. retrieving the state of some pumps in the network.
|
|
884
|
+
|
|
885
|
+
Parameters
|
|
886
|
+
----------
|
|
887
|
+
sensor_locations : `list[str]`
|
|
888
|
+
Locations (IDs) of sensors.
|
|
889
|
+
"""
|
|
890
|
+
self.set_sensors(SENSOR_TYPE_PUMP_STATE, sensor_locations)
|
|
891
|
+
|
|
892
|
+
def set_tank_sensors(self, sensor_locations: list[str]) -> None:
|
|
893
|
+
"""
|
|
894
|
+
Sets the tank volume sensors -- i.e. measuring water volumes in some tanks in the network.
|
|
895
|
+
|
|
896
|
+
Parameters
|
|
897
|
+
----------
|
|
898
|
+
sensor_locations : `list[str]`
|
|
899
|
+
Locations (IDs) of sensors.
|
|
900
|
+
"""
|
|
901
|
+
self.set_sensors(SENSOR_TYPE_TANK_VOLUME, sensor_locations)
|
|
902
|
+
|
|
903
|
+
def set_bulk_species_node_sensors(self, sensor_info: dict) -> None:
|
|
904
|
+
"""
|
|
905
|
+
Sets the bulk species node sensors -- i.e. measuring bulk species concentrations
|
|
906
|
+
at nodes in the network.
|
|
907
|
+
|
|
908
|
+
Parameters
|
|
909
|
+
----------
|
|
910
|
+
sensor_info : `dict`
|
|
911
|
+
Bulk species sensors -- keys: bulk species IDs, values: node IDs.
|
|
912
|
+
"""
|
|
913
|
+
self.set_sensors(SENSOR_TYPE_NODE_BULK_SPECIES, sensor_info)
|
|
914
|
+
|
|
915
|
+
def set_bulk_species_link_sensors(self, sensor_info: dict) -> None:
|
|
916
|
+
"""
|
|
917
|
+
Sets the bulk species link/pipe sensors -- i.e. measuring bulk species concentrations
|
|
918
|
+
at links/pipes in the network.
|
|
919
|
+
|
|
920
|
+
Parameters
|
|
921
|
+
----------
|
|
922
|
+
sensor_info : `dict`
|
|
923
|
+
Bulk species sensors -- keys: bulk species IDs, values: node IDs.
|
|
924
|
+
"""
|
|
925
|
+
self.set_sensors(SENSOR_TYPE_LINK_BULK_SPECIES, sensor_info)
|
|
926
|
+
|
|
927
|
+
def set_surface_species_sensors(self, sensor_info: dict) -> None:
|
|
928
|
+
"""
|
|
929
|
+
Sets the surface species sensors -- i.e. measuring surface species concentrations
|
|
930
|
+
at nodes in the network.
|
|
931
|
+
|
|
932
|
+
Parameters
|
|
933
|
+
----------
|
|
934
|
+
sensor_info : `dict`
|
|
935
|
+
Surface species sensors -- keys: surface species IDs, values: link/pipe IDs.
|
|
936
|
+
"""
|
|
937
|
+
self.set_sensors(SENSOR_TYPE_SURFACE_SPECIES, sensor_info)
|
|
938
|
+
|
|
939
|
+
def __prepare_simulation(self) -> None:
|
|
940
|
+
self.__adapt_to_network_changes()
|
|
941
|
+
|
|
942
|
+
if self.__model_uncertainty is not None:
|
|
943
|
+
self.__model_uncertainty.apply(self.epanet_api)
|
|
944
|
+
|
|
945
|
+
for event in self.__system_events:
|
|
946
|
+
event.reset()
|
|
947
|
+
|
|
948
|
+
if self.__controls is not None:
|
|
949
|
+
for c in self.__controls:
|
|
950
|
+
c.init(self.epanet_api)
|
|
951
|
+
|
|
952
|
+
def run_advanced_quality_simulation(self, hyd_file_in: str, verbose: bool = False,
|
|
953
|
+
frozen_sensor_config: bool = False) -> ScadaData:
|
|
954
|
+
"""
|
|
955
|
+
Runs an advanced quality analysis using EPANET-MSX.
|
|
956
|
+
|
|
957
|
+
Parameters
|
|
958
|
+
----------
|
|
959
|
+
hyd_file_in : `str`
|
|
960
|
+
Path to an EPANET .hyd file for storing the simulated hydraulics --
|
|
961
|
+
the quality analysis is computed using those hydraulics.
|
|
962
|
+
verbose : `bool`, optional
|
|
963
|
+
If True, method will be verbose (e.g. showing a progress bar).
|
|
964
|
+
|
|
965
|
+
The default is False.
|
|
966
|
+
frozen_sensor_config : `bool`, optional
|
|
967
|
+
If True, the sensor config can not be changed and only the required sensor nodes/links
|
|
968
|
+
will be stored -- this usually leads to a significant reduction in memory consumption.
|
|
969
|
+
|
|
970
|
+
The default is False.
|
|
971
|
+
|
|
972
|
+
Returns
|
|
973
|
+
-------
|
|
974
|
+
:class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
|
|
975
|
+
Quality simulation results as SCADA data.
|
|
976
|
+
"""
|
|
977
|
+
result = None
|
|
978
|
+
|
|
979
|
+
gen = self.run_advanced_quality_simulation_as_generator
|
|
980
|
+
for scada_data in gen(hyd_file_in=hyd_file_in,
|
|
981
|
+
verbose=verbose,
|
|
982
|
+
frozen_sensor_config=frozen_sensor_config):
|
|
983
|
+
if result is None:
|
|
984
|
+
result = scada_data
|
|
985
|
+
else:
|
|
986
|
+
result.concatenate(scada_data)
|
|
987
|
+
|
|
988
|
+
return result
|
|
989
|
+
|
|
990
|
+
def run_advanced_quality_simulation_as_generator(self, hyd_file_in: str, verbose: bool = False,
|
|
991
|
+
support_abort: bool = False,
|
|
992
|
+
return_as_dict: bool = False,
|
|
993
|
+
frozen_sensor_config: bool = False
|
|
994
|
+
) -> Generator[Union[ScadaData, dict],
|
|
995
|
+
bool, None]:
|
|
996
|
+
"""
|
|
997
|
+
Runs an advanced quality analysis using EPANET-MSX.
|
|
998
|
+
|
|
999
|
+
Parameters
|
|
1000
|
+
----------
|
|
1001
|
+
hyd_file_in : `str`
|
|
1002
|
+
Path to an EPANET .hyd file for storing the simulated hydraulics --
|
|
1003
|
+
the quality analysis is computed using those hydraulics.
|
|
1004
|
+
verbose : `bool`
|
|
1005
|
+
If True, method will be verbose (e.g. showing a progress bar).
|
|
1006
|
+
return_as_dict : `bool`, optional
|
|
1007
|
+
If True, simulation results/states are returned as a dictionary instead of a
|
|
1008
|
+
:class:`~epyt_flow.simulation.scada.scada_data.ScadaData` instance.
|
|
1009
|
+
|
|
1010
|
+
The default is False.
|
|
1011
|
+
frozen_sensor_config : `bool`, optional
|
|
1012
|
+
If True, the sensor config can not be changed and only the required sensor nodes/links
|
|
1013
|
+
will be stored -- this usually leads to a significant reduction in memory consumption.
|
|
1014
|
+
|
|
1015
|
+
The default is False.
|
|
1016
|
+
|
|
1017
|
+
Returns
|
|
1018
|
+
-------
|
|
1019
|
+
:class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
|
|
1020
|
+
Generator containing the current EPANET-MSX simulation results as SCADA data
|
|
1021
|
+
(i.e. species concentrations).
|
|
1022
|
+
"""
|
|
1023
|
+
# Load pre-computed hydraulics
|
|
1024
|
+
self.epanet_api.useMSXHydraulicFile(hyd_file_in)
|
|
1025
|
+
|
|
1026
|
+
# Initialize simulation
|
|
1027
|
+
n_nodes = self.epanet_api.getNodeCount()
|
|
1028
|
+
n_links = self.epanet_api.getLinkCount()
|
|
1029
|
+
|
|
1030
|
+
reporting_time_start = self.epanet_api.getTimeReportingStart()
|
|
1031
|
+
reporting_time_step = self.epanet_api.getTimeReportingStep()
|
|
1032
|
+
hyd_time_step = self.epanet_api.getTimeHydraulicStep()
|
|
1033
|
+
|
|
1034
|
+
self.epanet_api.initializeMSXQualityAnalysis(ToolkitConstants.EN_NOSAVE)
|
|
1035
|
+
|
|
1036
|
+
bulk_species_idx = self.epanet_api.getMSXSpeciesIndex(self.__sensor_config.bulk_species)
|
|
1037
|
+
surface_species_idx = self.epanet_api.getMSXSpeciesIndex(
|
|
1038
|
+
self.__sensor_config.surface_species)
|
|
1039
|
+
|
|
1040
|
+
if verbose is True:
|
|
1041
|
+
print("Running EPANET-MSX ...")
|
|
1042
|
+
n_iterations = math.ceil(self.epanet_api.getTimeSimulationDuration() /
|
|
1043
|
+
hyd_time_step)
|
|
1044
|
+
progress_bar = iter(tqdm(range(n_iterations + 1), desc="Time steps"))
|
|
1045
|
+
|
|
1046
|
+
def __get_concentrations(init_qual=False):
|
|
1047
|
+
if init_qual is True:
|
|
1048
|
+
msx_get_cur_value = self.epanet_api.msx.MSXgetinitqual
|
|
1049
|
+
else:
|
|
1050
|
+
msx_get_cur_value = self.epanet_api.getMSXSpeciesConcentration
|
|
1051
|
+
|
|
1052
|
+
# Bulk species
|
|
1053
|
+
bulk_species_node_concentrations = []
|
|
1054
|
+
bulk_species_link_concentrations = []
|
|
1055
|
+
for species_idx in bulk_species_idx:
|
|
1056
|
+
cur_species_concentrations = []
|
|
1057
|
+
for node_idx in range(1, n_nodes+1):
|
|
1058
|
+
concen = msx_get_cur_value(0, node_idx, species_idx)
|
|
1059
|
+
cur_species_concentrations.append(concen)
|
|
1060
|
+
bulk_species_node_concentrations.append(cur_species_concentrations)
|
|
1061
|
+
|
|
1062
|
+
cur_species_concentrations = []
|
|
1063
|
+
for link_idx in range(1, n_links+1):
|
|
1064
|
+
concen = msx_get_cur_value(1, link_idx, species_idx)
|
|
1065
|
+
cur_species_concentrations.append(concen)
|
|
1066
|
+
bulk_species_link_concentrations.append(cur_species_concentrations)
|
|
1067
|
+
|
|
1068
|
+
if len(bulk_species_node_concentrations) == 0:
|
|
1069
|
+
bulk_species_node_concentrations = None
|
|
1070
|
+
else:
|
|
1071
|
+
bulk_species_node_concentrations = np.array(bulk_species_node_concentrations).\
|
|
1072
|
+
reshape((1, len(bulk_species_idx), n_nodes))
|
|
1073
|
+
|
|
1074
|
+
if len(bulk_species_link_concentrations) == 0:
|
|
1075
|
+
bulk_species_link_concentrations = None
|
|
1076
|
+
else:
|
|
1077
|
+
bulk_species_link_concentrations = np.array(bulk_species_link_concentrations).\
|
|
1078
|
+
reshape((1, len(bulk_species_idx), n_links))
|
|
1079
|
+
|
|
1080
|
+
# Surface species
|
|
1081
|
+
surface_species_concentrations = []
|
|
1082
|
+
for species_idx in surface_species_idx:
|
|
1083
|
+
cur_species_concentrations = []
|
|
1084
|
+
|
|
1085
|
+
for link_idx in range(1, n_links+1):
|
|
1086
|
+
concen = msx_get_cur_value(1, link_idx, species_idx)
|
|
1087
|
+
cur_species_concentrations.append(concen)
|
|
1088
|
+
|
|
1089
|
+
surface_species_concentrations.append(cur_species_concentrations)
|
|
1090
|
+
|
|
1091
|
+
if len(surface_species_concentrations) == 0:
|
|
1092
|
+
surface_species_concentrations = None
|
|
1093
|
+
else:
|
|
1094
|
+
surface_species_concentrations = np.array(surface_species_concentrations).\
|
|
1095
|
+
reshape((1, len(surface_species_idx), n_links))
|
|
1096
|
+
|
|
1097
|
+
return bulk_species_node_concentrations, bulk_species_link_concentrations, \
|
|
1098
|
+
surface_species_concentrations
|
|
1099
|
+
|
|
1100
|
+
# Initial concentrations:
|
|
1101
|
+
bulk_species_node_concentrations, bulk_species_link_concentrations, \
|
|
1102
|
+
surface_species_concentrations = __get_concentrations(init_qual=True)
|
|
1103
|
+
|
|
1104
|
+
if verbose is True:
|
|
1105
|
+
next(progress_bar)
|
|
1106
|
+
|
|
1107
|
+
if reporting_time_start == 0:
|
|
1108
|
+
if return_as_dict is True:
|
|
1109
|
+
yield {"bulk_species_node_concentration_raw": bulk_species_node_concentrations,
|
|
1110
|
+
"bulk_species_link_concentration_raw": bulk_species_link_concentrations,
|
|
1111
|
+
"surface_species_concentration_raw": surface_species_concentrations,
|
|
1112
|
+
"sensor_readings_time": np.array([total_time])}
|
|
1113
|
+
else:
|
|
1114
|
+
yield ScadaData(sensor_config=self.__sensor_config,
|
|
1115
|
+
bulk_species_node_concentration_raw=bulk_species_node_concentrations,
|
|
1116
|
+
bulk_species_link_concentration_raw=bulk_species_link_concentrations,
|
|
1117
|
+
surface_species_concentration_raw=surface_species_concentrations,
|
|
1118
|
+
sensor_readings_time=np.array([0]),
|
|
1119
|
+
sensor_reading_events=self.__sensor_reading_events,
|
|
1120
|
+
sensor_noise=self.__sensor_noise,
|
|
1121
|
+
frozen_sensor_config=frozen_sensor_config)
|
|
1122
|
+
|
|
1123
|
+
# Run step-by-step simulation
|
|
1124
|
+
tleft = 1
|
|
1125
|
+
while tleft > 0:
|
|
1126
|
+
if support_abort is True: # Can the simulation be aborted? If so, handle it.
|
|
1127
|
+
abort = yield
|
|
1128
|
+
if abort is not False:
|
|
1129
|
+
break
|
|
1130
|
+
|
|
1131
|
+
# Compute current time step
|
|
1132
|
+
total_time, tleft = self.epanet_api.stepMSXQualityAnalysisTimeLeft()
|
|
1133
|
+
|
|
1134
|
+
# Fetch data at regular time intervals
|
|
1135
|
+
if total_time % hyd_time_step == 0:
|
|
1136
|
+
bulk_species_node_concentrations, bulk_species_link_concentrations, \
|
|
1137
|
+
surface_species_concentrations = __get_concentrations()
|
|
1138
|
+
|
|
1139
|
+
if verbose is True:
|
|
1140
|
+
next(progress_bar)
|
|
1141
|
+
|
|
1142
|
+
# Report results in a regular time interval only!
|
|
1143
|
+
if total_time % reporting_time_step == 0 and total_time >= reporting_time_start:
|
|
1144
|
+
if return_as_dict is True:
|
|
1145
|
+
yield {"bulk_species_node_concentration_raw":
|
|
1146
|
+
bulk_species_node_concentrations,
|
|
1147
|
+
"bulk_species_link_concentration_raw":
|
|
1148
|
+
bulk_species_link_concentrations,
|
|
1149
|
+
"surface_species_concentration_raw": surface_species_concentrations,
|
|
1150
|
+
"sensor_readings_time": np.array([total_time])}
|
|
1151
|
+
else:
|
|
1152
|
+
yield ScadaData(sensor_config=self.__sensor_config,
|
|
1153
|
+
bulk_species_node_concentration_raw=
|
|
1154
|
+
bulk_species_node_concentrations,
|
|
1155
|
+
bulk_species_link_concentration_raw=
|
|
1156
|
+
bulk_species_link_concentrations,
|
|
1157
|
+
surface_species_concentration_raw=
|
|
1158
|
+
surface_species_concentrations,
|
|
1159
|
+
sensor_readings_time=np.array([total_time]),
|
|
1160
|
+
sensor_reading_events=self.__sensor_reading_events,
|
|
1161
|
+
sensor_noise=self.__sensor_noise,
|
|
1162
|
+
frozen_sensor_config=frozen_sensor_config)
|
|
1163
|
+
|
|
1164
|
+
def run_basic_quality_simulation(self, hyd_file_in: str, verbose: bool = False,
|
|
1165
|
+
frozen_sensor_config: bool = False) -> ScadaData:
|
|
1166
|
+
"""
|
|
1167
|
+
Runs a basic quality analysis using EPANET.
|
|
1168
|
+
|
|
1169
|
+
Parameters
|
|
1170
|
+
----------
|
|
1171
|
+
hyd_file_in : `str`
|
|
1172
|
+
Path to an EPANET .hyd file for storing the simulated hydraulics --
|
|
1173
|
+
the quality analysis is computed using those hydraulics.
|
|
1174
|
+
verbose : `bool`, optional
|
|
1175
|
+
If True, method will be verbose (e.g. showing a progress bar).
|
|
1176
|
+
|
|
1177
|
+
The default is False.
|
|
1178
|
+
frozen_sensor_config : `bool`, optional
|
|
1179
|
+
If True, the sensor config can not be changed and only the required sensor nodes/links
|
|
1180
|
+
will be stored -- this usually leads to a significant reduction in memory consumption.
|
|
1181
|
+
|
|
1182
|
+
The default is False.
|
|
1183
|
+
|
|
1184
|
+
Returns
|
|
1185
|
+
-------
|
|
1186
|
+
:class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
|
|
1187
|
+
Quality simulation results as SCADA data.
|
|
1188
|
+
"""
|
|
1189
|
+
result = None
|
|
1190
|
+
|
|
1191
|
+
# Run simulation step-by-step
|
|
1192
|
+
gen = self.run_basic_quality_simulation_as_generator
|
|
1193
|
+
for scada_data in gen(hyd_file_in=hyd_file_in,
|
|
1194
|
+
verbose=verbose,
|
|
1195
|
+
return_as_dict=True,
|
|
1196
|
+
frozen_sensor_config=frozen_sensor_config):
|
|
1197
|
+
if result is None:
|
|
1198
|
+
result = {}
|
|
1199
|
+
for data_type, data in scada_data.items():
|
|
1200
|
+
result[data_type] = [data]
|
|
1201
|
+
else:
|
|
1202
|
+
for data_type, data in scada_data.items():
|
|
1203
|
+
result[data_type].append(data)
|
|
1204
|
+
|
|
1205
|
+
# Build ScadaData instance
|
|
1206
|
+
for data_type in result:
|
|
1207
|
+
result[data_type] = np.concatenate(result[data_type], axis=0)
|
|
1208
|
+
|
|
1209
|
+
return ScadaData(**result,
|
|
1210
|
+
sensor_config=self.__sensor_config,
|
|
1211
|
+
sensor_reading_events=self.__sensor_reading_events,
|
|
1212
|
+
sensor_noise=self.__sensor_noise,
|
|
1213
|
+
frozen_sensor_config=frozen_sensor_config)
|
|
1214
|
+
|
|
1215
|
+
def run_basic_quality_simulation_as_generator(self, hyd_file_in: str, verbose: bool = False,
|
|
1216
|
+
support_abort: bool = False,
|
|
1217
|
+
return_as_dict: bool = False,
|
|
1218
|
+
frozen_sensor_config: bool = False,
|
|
1219
|
+
) -> Generator[Union[ScadaData, dict],
|
|
1220
|
+
bool, None]:
|
|
1221
|
+
"""
|
|
1222
|
+
Runs a basic quality analysis using EPANET.
|
|
1223
|
+
|
|
1224
|
+
Parameters
|
|
1225
|
+
----------
|
|
1226
|
+
hyd_file_in : `str`
|
|
1227
|
+
Path to an EPANET .hyd file for storing the simulated hydraulics --
|
|
1228
|
+
the quality analysis is computed using those hydraulics.
|
|
1229
|
+
verbose : `bool`, optional
|
|
1230
|
+
If True, method will be verbose (e.g. showing a progress bar).
|
|
1231
|
+
|
|
1232
|
+
The default is False.
|
|
1233
|
+
return_as_dict : `bool`, optional
|
|
1234
|
+
If True, simulation results/states are returned as a dictionary instead of a
|
|
1235
|
+
:class:`~epyt_flow.simulation.scada.scada_data.ScadaData` instance.
|
|
1236
|
+
|
|
1237
|
+
The default is False.
|
|
1238
|
+
frozen_sensor_config : `bool`, optional
|
|
1239
|
+
If True, the sensor config can not be changed and only the required sensor nodes/links
|
|
1240
|
+
will be stored -- this usually leads to a significant reduction in memory consumption.
|
|
1241
|
+
|
|
1242
|
+
The default is False.
|
|
1243
|
+
|
|
1244
|
+
Returns
|
|
1245
|
+
-------
|
|
1246
|
+
:class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
|
|
1247
|
+
Generator with the current simulation results/states as SCADA data.
|
|
1248
|
+
"""
|
|
1249
|
+
requested_time_step = self.epanet_api.getTimeHydraulicStep()
|
|
1250
|
+
reporting_time_start = self.epanet_api.getTimeReportingStart()
|
|
1251
|
+
reporting_time_step = self.epanet_api.getTimeReportingStep()
|
|
1252
|
+
|
|
1253
|
+
self.epanet_api.useHydraulicFile(hyd_file_in)
|
|
1254
|
+
|
|
1255
|
+
self.epanet_api.openQualityAnalysis()
|
|
1256
|
+
self.epanet_api.initializeQualityAnalysis(ToolkitConstants.EN_NOSAVE)
|
|
1257
|
+
|
|
1258
|
+
if verbose is True:
|
|
1259
|
+
print("Running basic quality analysis using EPANET ...")
|
|
1260
|
+
n_iterations = math.ceil(self.epanet_api.getTimeSimulationDuration() /
|
|
1261
|
+
requested_time_step)
|
|
1262
|
+
progress_bar = iter(tqdm(range(n_iterations + 1), desc="Time steps"))
|
|
1263
|
+
|
|
1264
|
+
# Run simulation step by step
|
|
1265
|
+
total_time = 0
|
|
1266
|
+
tstep = 1
|
|
1267
|
+
first_itr = True
|
|
1268
|
+
while tstep > 0:
|
|
1269
|
+
if support_abort is True: # Can the simulation be aborted? If so, handle it.
|
|
1270
|
+
abort = yield
|
|
1271
|
+
if abort is not False:
|
|
1272
|
+
break
|
|
1273
|
+
|
|
1274
|
+
if first_itr is True: # Fix current time in the first iteration
|
|
1275
|
+
tstep = 0
|
|
1276
|
+
first_itr = False
|
|
1277
|
+
|
|
1278
|
+
if verbose is True:
|
|
1279
|
+
if (total_time + tstep) % requested_time_step == 0:
|
|
1280
|
+
next(progress_bar)
|
|
1281
|
+
|
|
1282
|
+
# Compute current time step
|
|
1283
|
+
t = self.epanet_api.runQualityAnalysis()
|
|
1284
|
+
total_time = t
|
|
1285
|
+
|
|
1286
|
+
# Fetch data
|
|
1287
|
+
quality_node_data = None
|
|
1288
|
+
quality_link_data = None
|
|
1289
|
+
|
|
1290
|
+
quality_node_data = self.epanet_api.getNodeActualQuality().reshape(1, -1)
|
|
1291
|
+
quality_link_data = self.epanet_api.getLinkActualQuality().reshape(1, -1)
|
|
1292
|
+
|
|
1293
|
+
# Yield results in a regular time interval only!
|
|
1294
|
+
if total_time % reporting_time_step == 0 and total_time >= reporting_time_start:
|
|
1295
|
+
if return_as_dict is True:
|
|
1296
|
+
yield {"node_quality_data_raw": quality_node_data,
|
|
1297
|
+
"link_quality_data_raw": quality_link_data,
|
|
1298
|
+
"sensor_readings_time": np.array([total_time])}
|
|
1299
|
+
else:
|
|
1300
|
+
yield ScadaData(sensor_config=self.__sensor_config,
|
|
1301
|
+
node_quality_data_raw=quality_node_data,
|
|
1302
|
+
link_quality_data_raw=quality_link_data,
|
|
1303
|
+
sensor_readings_time=np.array([total_time]),
|
|
1304
|
+
sensor_reading_events=self.__sensor_reading_events,
|
|
1305
|
+
sensor_noise=self.__sensor_noise,
|
|
1306
|
+
frozen_sensor_config=frozen_sensor_config)
|
|
1307
|
+
|
|
1308
|
+
# Next
|
|
1309
|
+
tstep = self.epanet_api.nextQualityAnalysisStep()
|
|
1310
|
+
|
|
1311
|
+
self.epanet_api.closeHydraulicAnalysis()
|
|
1312
|
+
|
|
1313
|
+
def run_simulation(self, hyd_export: str = None, verbose: bool = False,
|
|
1314
|
+
frozen_sensor_config: bool = False) -> ScadaData:
|
|
1315
|
+
"""
|
|
1316
|
+
Runs the simulation of this scenario.
|
|
1317
|
+
|
|
1318
|
+
Parameters
|
|
1319
|
+
----------
|
|
1320
|
+
hyd_export : `str`, optional
|
|
1321
|
+
Path to an EPANET .hyd file for storing the simulated hydraulics -- these hydraulics
|
|
1322
|
+
can be used later for an advanced quality analysis using EPANET-MSX.
|
|
1323
|
+
|
|
1324
|
+
If None, the simulated hydraulics will NOT be exported to an EPANET .hyd file.
|
|
1325
|
+
|
|
1326
|
+
The default is None.
|
|
1327
|
+
verbose : `bool`, optional
|
|
1328
|
+
If True, method will be verbose (e.g. showing a progress bar).
|
|
1329
|
+
|
|
1330
|
+
The default is False.
|
|
1331
|
+
frozen_sensor_config : `bool`, optional
|
|
1332
|
+
If True, the sensor config can not be changed and only the required sensor nodes/links
|
|
1333
|
+
will be stored -- this usually leads to a significant reduction in memory consumption.
|
|
1334
|
+
|
|
1335
|
+
The default is False.
|
|
1336
|
+
|
|
1337
|
+
Returns
|
|
1338
|
+
-------
|
|
1339
|
+
:class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
|
|
1340
|
+
Simulation results as SCADA data (i.e. sensor readings).
|
|
1341
|
+
"""
|
|
1342
|
+
self.__adapt_to_network_changes()
|
|
1343
|
+
|
|
1344
|
+
result = None
|
|
1345
|
+
|
|
1346
|
+
hyd_export_old = hyd_export
|
|
1347
|
+
if self.__f_msx_in is not None:
|
|
1348
|
+
hyd_export = os.path.join(get_temp_folder(), f"epytflow_MSX_{uuid.uuid4()}.hyd")
|
|
1349
|
+
|
|
1350
|
+
# Run hydraulic simulation step-by-step
|
|
1351
|
+
gen = self.run_simulation_as_generator
|
|
1352
|
+
for scada_data in gen(hyd_export=hyd_export,
|
|
1353
|
+
verbose=verbose,
|
|
1354
|
+
return_as_dict=True,
|
|
1355
|
+
frozen_sensor_config=frozen_sensor_config):
|
|
1356
|
+
if result is None:
|
|
1357
|
+
result = {}
|
|
1358
|
+
for data_type, data in scada_data.items():
|
|
1359
|
+
result[data_type] = [data]
|
|
1360
|
+
else:
|
|
1361
|
+
for data_type, data in scada_data.items():
|
|
1362
|
+
result[data_type].append(data)
|
|
1363
|
+
|
|
1364
|
+
for data_type in result:
|
|
1365
|
+
result[data_type] = np.concatenate(result[data_type], axis=0)
|
|
1366
|
+
|
|
1367
|
+
result = ScadaData(**result,
|
|
1368
|
+
sensor_config=self.__sensor_config,
|
|
1369
|
+
sensor_reading_events=self.__sensor_reading_events,
|
|
1370
|
+
sensor_noise=self.__sensor_noise,
|
|
1371
|
+
frozen_sensor_config=frozen_sensor_config)
|
|
1372
|
+
|
|
1373
|
+
# If necessary, run advanced quality simulation utilizing the computed hydraulics
|
|
1374
|
+
if self.f_msx_in is not None:
|
|
1375
|
+
gen = self.run_advanced_quality_simulation
|
|
1376
|
+
result_msx = gen(hyd_file_in=hyd_export,
|
|
1377
|
+
verbose=verbose,
|
|
1378
|
+
frozen_sensor_config=frozen_sensor_config)
|
|
1379
|
+
result.join(result_msx)
|
|
1380
|
+
|
|
1381
|
+
if hyd_export_old is not None:
|
|
1382
|
+
shutil.copyfile(hyd_export, hyd_export_old)
|
|
1383
|
+
|
|
1384
|
+
os.remove(hyd_export)
|
|
1385
|
+
|
|
1386
|
+
return result
|
|
1387
|
+
|
|
1388
|
+
def run_simulation_as_generator(self, hyd_export: str = None, verbose: bool = False,
|
|
1389
|
+
support_abort: bool = False,
|
|
1390
|
+
return_as_dict: bool = False,
|
|
1391
|
+
frozen_sensor_config: bool = False,
|
|
1392
|
+
) -> Generator[Union[ScadaData, dict], bool, None]:
|
|
1393
|
+
"""
|
|
1394
|
+
Runs the simulation of this scenario and provides the results as a generator.
|
|
1395
|
+
|
|
1396
|
+
Parameters
|
|
1397
|
+
----------
|
|
1398
|
+
hyd_export : `str`, optional
|
|
1399
|
+
Path to an EPANET .hyd file for storing the simulated hydraulics -- these hydraulics
|
|
1400
|
+
can be used later for an advanced quality analysis using EPANET-MSX.
|
|
1401
|
+
|
|
1402
|
+
If None, the simulated hydraulics will NOT be exported to an EPANET .hyd file.
|
|
1403
|
+
|
|
1404
|
+
The default is None.
|
|
1405
|
+
verbose : `bool`, optional
|
|
1406
|
+
If True, method will be verbose (e.g. showing a progress bar).
|
|
1407
|
+
|
|
1408
|
+
The default is False.
|
|
1409
|
+
support_abort : `bool`, optional
|
|
1410
|
+
If True, the simulation can be aborted after every time step -- i.e. the generator
|
|
1411
|
+
takes a boolean as an input (send) to indicate whether the simulation
|
|
1412
|
+
is to be aborted or not.
|
|
1413
|
+
|
|
1414
|
+
The default is False.
|
|
1415
|
+
return_as_dict : `bool`, optional
|
|
1416
|
+
If True, simulation results/states are returned as a dictionary instead of a
|
|
1417
|
+
:class:`~epyt_flow.simulation.scada.scada_data.ScadaData` instance.
|
|
1418
|
+
|
|
1419
|
+
The default is False.
|
|
1420
|
+
frozen_sensor_config : `bool`, optional
|
|
1421
|
+
If True, the sensor config can not be changed and only the required sensor nodes/links
|
|
1422
|
+
will be stored -- this usually leads to a significant reduction in memory consumption.
|
|
1423
|
+
|
|
1424
|
+
The default is False.
|
|
1425
|
+
|
|
1426
|
+
Returns
|
|
1427
|
+
-------
|
|
1428
|
+
:class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
|
|
1429
|
+
Generator with the current simulation results/states as SCADA data
|
|
1430
|
+
(i.e. sensor readings).
|
|
1431
|
+
"""
|
|
1432
|
+
self.__adapt_to_network_changes()
|
|
1433
|
+
|
|
1434
|
+
self.__prepare_simulation()
|
|
1435
|
+
|
|
1436
|
+
self.epanet_api.openHydraulicAnalysis()
|
|
1437
|
+
self.epanet_api.openQualityAnalysis()
|
|
1438
|
+
self.epanet_api.initializeHydraulicAnalysis(ToolkitConstants.EN_SAVE)
|
|
1439
|
+
self.epanet_api.initializeQualityAnalysis(ToolkitConstants.EN_SAVE)
|
|
1440
|
+
|
|
1441
|
+
requested_time_step = self.epanet_api.getTimeHydraulicStep()
|
|
1442
|
+
reporting_time_start = self.epanet_api.getTimeReportingStart()
|
|
1443
|
+
reporting_time_step = self.epanet_api.getTimeReportingStep()
|
|
1444
|
+
|
|
1445
|
+
if verbose is True:
|
|
1446
|
+
print("Running EPANET ...")
|
|
1447
|
+
n_iterations = math.ceil(self.epanet_api.getTimeSimulationDuration() /
|
|
1448
|
+
requested_time_step)
|
|
1449
|
+
progress_bar = iter(tqdm(range(n_iterations + 1), desc="Time steps"))
|
|
1450
|
+
|
|
1451
|
+
try:
|
|
1452
|
+
# Run simulation step by step
|
|
1453
|
+
total_time = 0
|
|
1454
|
+
tstep = 1
|
|
1455
|
+
first_itr = True
|
|
1456
|
+
while tstep > 0:
|
|
1457
|
+
if support_abort is True: # Can the simulation be aborted? If so, handle it.
|
|
1458
|
+
abort = yield
|
|
1459
|
+
if abort is not False:
|
|
1460
|
+
break
|
|
1461
|
+
|
|
1462
|
+
if first_itr is True: # Fix current time in the first iteration
|
|
1463
|
+
tstep = 0
|
|
1464
|
+
first_itr = False
|
|
1465
|
+
|
|
1466
|
+
if verbose is True:
|
|
1467
|
+
if (total_time + tstep) % requested_time_step == 0:
|
|
1468
|
+
next(progress_bar)
|
|
1469
|
+
|
|
1470
|
+
# Apply system events in a regular time interval only!
|
|
1471
|
+
if (total_time + tstep) % requested_time_step == 0:
|
|
1472
|
+
for event in self.__system_events:
|
|
1473
|
+
event.step(total_time + tstep)
|
|
1474
|
+
|
|
1475
|
+
# Compute current time step
|
|
1476
|
+
t = self.epanet_api.runHydraulicAnalysis()
|
|
1477
|
+
self.epanet_api.runQualityAnalysis()
|
|
1478
|
+
total_time = t
|
|
1479
|
+
|
|
1480
|
+
# Fetch data
|
|
1481
|
+
pressure_data = None
|
|
1482
|
+
flow_data = None
|
|
1483
|
+
demand_data = None
|
|
1484
|
+
quality_node_data = None
|
|
1485
|
+
quality_link_data = None
|
|
1486
|
+
pumps_state_data = None
|
|
1487
|
+
valves_state_data = None
|
|
1488
|
+
|
|
1489
|
+
pressure_data = self.epanet_api.getNodePressure().reshape(1, -1)
|
|
1490
|
+
flow_data = self.epanet_api.getLinkFlows().reshape(1, -1)
|
|
1491
|
+
demand_data = self.epanet_api.getNodeActualDemand().reshape(1,
|
|
1492
|
+
-1) # TODO: Does not go back after emitter coefficient is changed back to zero
|
|
1493
|
+
quality_node_data = self.epanet_api.getNodeActualQuality().reshape(1, -1)
|
|
1494
|
+
quality_link_data = self.epanet_api.getLinkActualQuality().reshape(1, -1)
|
|
1495
|
+
pumps_state_data = self.epanet_api.getLinkPumpState().reshape(1, -1)
|
|
1496
|
+
tanks_volume_data = self.epanet_api.getNodeTankVolume().reshape(1, -1)
|
|
1497
|
+
|
|
1498
|
+
pump_idx = self.epanet_api.getLinkPumpIndex()
|
|
1499
|
+
pump_energy_usage_data = self.epanet_api.getLinkEnergy(pump_idx).reshape(1, -1)
|
|
1500
|
+
pump_efficiency_data = self.epanet_api.getLinkPumpEfficiency().reshape(1, -1)
|
|
1501
|
+
|
|
1502
|
+
link_valve_idx = self.epanet_api.getLinkValveIndex()
|
|
1503
|
+
valves_state_data = self.epanet_api.getLinkStatus(link_valve_idx).reshape(1, -1)
|
|
1504
|
+
|
|
1505
|
+
scada_data = ScadaData(sensor_config=self.__sensor_config,
|
|
1506
|
+
pressure_data_raw=pressure_data,
|
|
1507
|
+
flow_data_raw=flow_data,
|
|
1508
|
+
demand_data_raw=demand_data,
|
|
1509
|
+
node_quality_data_raw=quality_node_data,
|
|
1510
|
+
link_quality_data_raw=quality_link_data,
|
|
1511
|
+
pumps_state_data_raw=pumps_state_data,
|
|
1512
|
+
valves_state_data_raw=valves_state_data,
|
|
1513
|
+
tanks_volume_data_raw=tanks_volume_data,
|
|
1514
|
+
pump_energy_usage_data=pump_energy_usage_data,
|
|
1515
|
+
pump_efficiency_data=pump_efficiency_data,
|
|
1516
|
+
sensor_readings_time=np.array([total_time]),
|
|
1517
|
+
sensor_reading_events=self.__sensor_reading_events,
|
|
1518
|
+
sensor_noise=self.__sensor_noise,
|
|
1519
|
+
frozen_sensor_config=frozen_sensor_config)
|
|
1520
|
+
|
|
1521
|
+
# Yield results in a regular time interval only!
|
|
1522
|
+
if total_time % reporting_time_step == 0 and total_time >= reporting_time_start:
|
|
1523
|
+
if return_as_dict is True:
|
|
1524
|
+
yield {"pressure_data_raw": pressure_data,
|
|
1525
|
+
"flow_data_raw": flow_data,
|
|
1526
|
+
"demand_data_raw": demand_data,
|
|
1527
|
+
"node_quality_data_raw": quality_node_data,
|
|
1528
|
+
"link_quality_data_raw": quality_link_data,
|
|
1529
|
+
"pumps_state_data_raw": pumps_state_data,
|
|
1530
|
+
"valves_state_data_raw": valves_state_data,
|
|
1531
|
+
"tanks_volume_data_raw": tanks_volume_data,
|
|
1532
|
+
"pump_energy_usage_data": pump_energy_usage_data,
|
|
1533
|
+
"pump_efficiency_data": pump_efficiency_data,
|
|
1534
|
+
"sensor_readings_time": np.array([total_time])}
|
|
1535
|
+
else:
|
|
1536
|
+
yield scada_data
|
|
1537
|
+
|
|
1538
|
+
# Apply control modules
|
|
1539
|
+
for control in self.__controls:
|
|
1540
|
+
control.step(scada_data)
|
|
1541
|
+
|
|
1542
|
+
# Next
|
|
1543
|
+
tstep = self.epanet_api.nextHydraulicAnalysisStep()
|
|
1544
|
+
self.epanet_api.nextQualityAnalysisStep()
|
|
1545
|
+
|
|
1546
|
+
self.epanet_api.closeQualityAnalysis()
|
|
1547
|
+
self.epanet_api.closeHydraulicAnalysis()
|
|
1548
|
+
|
|
1549
|
+
if hyd_export is not None:
|
|
1550
|
+
self.epanet_api.saveHydraulicFile(hyd_export)
|
|
1551
|
+
except Exception as ex:
|
|
1552
|
+
raise ex
|
|
1553
|
+
|
|
1554
|
+
def set_model_uncertainty(self, model_uncertainty: ModelUncertainty) -> None:
|
|
1555
|
+
"""
|
|
1556
|
+
Specifies the model uncertainties.
|
|
1557
|
+
|
|
1558
|
+
Parameters
|
|
1559
|
+
----------
|
|
1560
|
+
model_uncertainty : :class:`~epyt_flow.uncertainties.model_uncertainty.ModelUncertainty`
|
|
1561
|
+
Model uncertainty specifications.
|
|
1562
|
+
"""
|
|
1563
|
+
self.__adapt_to_network_changes()
|
|
1564
|
+
|
|
1565
|
+
if not isinstance(model_uncertainty, ModelUncertainty):
|
|
1566
|
+
raise TypeError("'model_uncertainty' must be an instance of " +
|
|
1567
|
+
"'epyt_flow.uncertainties.ModelUncertainty' but not of " +
|
|
1568
|
+
f"'{type(model_uncertainty)}'")
|
|
1569
|
+
|
|
1570
|
+
self.__model_uncertainty = model_uncertainty
|
|
1571
|
+
|
|
1572
|
+
def set_sensor_noise(self, sensor_noise: SensorNoise) -> None:
|
|
1573
|
+
"""
|
|
1574
|
+
Specifies the sensor noise -- i.e. uncertainties of sensor readings.
|
|
1575
|
+
|
|
1576
|
+
Parameters
|
|
1577
|
+
----------
|
|
1578
|
+
sensor_noise : :class:`~epyt_flow.uncertainties.sensor_noise.SensorNoise`
|
|
1579
|
+
Sensor noise specification.
|
|
1580
|
+
"""
|
|
1581
|
+
self.__adapt_to_network_changes()
|
|
1582
|
+
|
|
1583
|
+
if not isinstance(sensor_noise, SensorNoise):
|
|
1584
|
+
raise TypeError("'sensor_noise' must be an instance of " +
|
|
1585
|
+
"'epyt_flow.uncertainties.SensorNoise' but not of " +
|
|
1586
|
+
f"'{type(sensor_noise)}'")
|
|
1587
|
+
|
|
1588
|
+
self.__sensor_noise = sensor_noise
|
|
1589
|
+
|
|
1590
|
+
def set_general_parameters(self, demand_model: dict = None, simulation_duration: int = None,
|
|
1591
|
+
hydraulic_time_step: int = None, quality_time_step: int = None,
|
|
1592
|
+
reporting_time_step: int = None, reporting_time_start: int = None,
|
|
1593
|
+
flow_units: int = None, quality_model: dict = None) -> None:
|
|
1594
|
+
"""
|
|
1595
|
+
Sets some general parameters.
|
|
1596
|
+
|
|
1597
|
+
Note that all these parameters can be stated in the .inp file as well.
|
|
1598
|
+
|
|
1599
|
+
You only have to specify the parameters that are to be changed -- all others
|
|
1600
|
+
can be left as None and will not be changed.
|
|
1601
|
+
|
|
1602
|
+
Parameters
|
|
1603
|
+
----------
|
|
1604
|
+
demand_model : `dict`, optional
|
|
1605
|
+
Specifies the demand model (e.g. pressure-driven or demand-driven) -- the dictionary
|
|
1606
|
+
must contain the "type", the minimal pressure ("pressure_min"),
|
|
1607
|
+
the required pressure ("pressure_required"), and the
|
|
1608
|
+
pressure exponent ("pressure_exponent").
|
|
1609
|
+
|
|
1610
|
+
The default is None.
|
|
1611
|
+
|
|
1612
|
+
simulation_duration : `int`, optional
|
|
1613
|
+
Number of seconds to be simulated.
|
|
1614
|
+
|
|
1615
|
+
The default is None.
|
|
1616
|
+
hydraulic_time_step : `int`, optional
|
|
1617
|
+
Hydraulic time step -- i.e. the interval at which hydraulics are computed.
|
|
1618
|
+
|
|
1619
|
+
The default is None.
|
|
1620
|
+
quality_time_step : `int`, optional
|
|
1621
|
+
Quality time step -- i.e. the interval at which qualities are computed.
|
|
1622
|
+
Should be much smaller than the hydraulic time step!
|
|
1623
|
+
|
|
1624
|
+
The default is None.
|
|
1625
|
+
reporting_time_step : `int`, optional
|
|
1626
|
+
Report time step -- i.e. the interval at which hydraulics and quality states are
|
|
1627
|
+
reported.
|
|
1628
|
+
|
|
1629
|
+
Must be a multiple of `hydraulic_time_step`.
|
|
1630
|
+
|
|
1631
|
+
If None, it will be set equal to `hydraulic_time_step`
|
|
1632
|
+
|
|
1633
|
+
The default is None.
|
|
1634
|
+
reporting_time_start : `int`, optional
|
|
1635
|
+
Start time (in seconds) at which reporting of hydraulic and quality states starts.
|
|
1636
|
+
|
|
1637
|
+
The default is None.
|
|
1638
|
+
flow_units : `int`, optional
|
|
1639
|
+
Specifies the flow units -- i.e. all flows will be reported in these units.
|
|
1640
|
+
If None, the units from the .inp file will be used.
|
|
1641
|
+
|
|
1642
|
+
Must be one of the following EPANET toolkit constants:
|
|
1643
|
+
|
|
1644
|
+
- EN_CFS = 0
|
|
1645
|
+
- EN_GPM = 1
|
|
1646
|
+
- EN_MGD = 2
|
|
1647
|
+
- EN_IMGD = 3
|
|
1648
|
+
- EN_AFD = 4
|
|
1649
|
+
- EN_LPS = 5
|
|
1650
|
+
- EN_LPM = 6
|
|
1651
|
+
- EN_MLD = 7
|
|
1652
|
+
- EN_CMH = 8
|
|
1653
|
+
- EN_CMD = 9
|
|
1654
|
+
|
|
1655
|
+
The default is None.
|
|
1656
|
+
quality_model : `dict`, optional
|
|
1657
|
+
Specifies the quality model -- the dictionary must contain,
|
|
1658
|
+
"type", "chemical_name", "chemical_units", and "trace_node_id", of the
|
|
1659
|
+
requested quality model.
|
|
1660
|
+
|
|
1661
|
+
The default is None.
|
|
1662
|
+
"""
|
|
1663
|
+
self.__adapt_to_network_changes()
|
|
1664
|
+
|
|
1665
|
+
if demand_model is not None:
|
|
1666
|
+
self.epanet_api.setDemandModel(demand_model["type"], demand_model["pressure_min"],
|
|
1667
|
+
demand_model["pressure_required"],
|
|
1668
|
+
demand_model["pressure_exponent"])
|
|
1669
|
+
|
|
1670
|
+
if simulation_duration is not None:
|
|
1671
|
+
if not isinstance(simulation_duration, int) or simulation_duration <= 0:
|
|
1672
|
+
raise ValueError("'simulation_duration' must be a positive integer specifying " +
|
|
1673
|
+
"the number of seconds to simulate")
|
|
1674
|
+
self.epanet_api.setTimeSimulationDuration(simulation_duration) # TODO: Changing the simulation
|
|
1675
|
+
# duration from .inp file seems to break EPANET-MSX
|
|
1676
|
+
|
|
1677
|
+
if hydraulic_time_step is not None:
|
|
1678
|
+
if not isinstance(hydraulic_time_step, int) or hydraulic_time_step <= 0:
|
|
1679
|
+
raise ValueError("'hydraulic_time_step' must be a positive integer specifying " +
|
|
1680
|
+
"the time steps of the hydraulic simulation")
|
|
1681
|
+
if len(self.__system_events) != 0:
|
|
1682
|
+
raise RuntimeError("Hydraulic time step cannot be changed after system events " +
|
|
1683
|
+
"such as leakages have been added to the scenario")
|
|
1684
|
+
self.epanet_api.setTimeHydraulicStep(hydraulic_time_step)
|
|
1685
|
+
if reporting_time_step is None:
|
|
1686
|
+
warnings.warn("No report time steps specified -- using 'hydraulic_time_step'")
|
|
1687
|
+
self.epanet_api.setTimeReportingStep(hydraulic_time_step)
|
|
1688
|
+
|
|
1689
|
+
if reporting_time_step is not None:
|
|
1690
|
+
hydraulic_time_step = self.epanet_api.getTimeHydraulicStep()
|
|
1691
|
+
if not isinstance(reporting_time_step, int) or \
|
|
1692
|
+
reporting_time_step % hydraulic_time_step != 0:
|
|
1693
|
+
raise ValueError("'reporting_time_step' must be a positive integer " +
|
|
1694
|
+
"and a multiple of 'hydraulic_time_step'")
|
|
1695
|
+
self.epanet_api.setTimeReportingStep(reporting_time_step)
|
|
1696
|
+
|
|
1697
|
+
if reporting_time_start is not None:
|
|
1698
|
+
if not isinstance(reporting_time_start, int) or reporting_time_start <= 0:
|
|
1699
|
+
raise ValueError("'reporting_time_start' must be a positive integer specifying " +
|
|
1700
|
+
"the time at which reporting starts")
|
|
1701
|
+
self.epanet_api.setTimeReportingStart(reporting_time_start)
|
|
1702
|
+
|
|
1703
|
+
if quality_time_step is not None:
|
|
1704
|
+
if not isinstance(quality_time_step, int) or quality_time_step <= 0 or \
|
|
1705
|
+
quality_time_step > self.epanet_api.getTimeHydraulicStep():
|
|
1706
|
+
raise ValueError("'quality_time_step' must be a positive integer that is not " +
|
|
1707
|
+
"greater than the hydraulic time step")
|
|
1708
|
+
self.epanet_api.setTimeQualityStep(quality_time_step)
|
|
1709
|
+
|
|
1710
|
+
if flow_units is not None:
|
|
1711
|
+
if flow_units == ToolkitConstants.EN_CFS:
|
|
1712
|
+
self.epanet_api.setFlowUnitsCFS()
|
|
1713
|
+
elif flow_units == ToolkitConstants.EN_GPM:
|
|
1714
|
+
self.epanet_api.setFlowUnitsGPM()
|
|
1715
|
+
elif flow_units == ToolkitConstants.EN_MGD:
|
|
1716
|
+
self.epanet_api.setFlowUnitsMGD()
|
|
1717
|
+
elif flow_units == ToolkitConstants.EN_IMGD:
|
|
1718
|
+
self.epanet_api.setFlowUnitsIMGD()
|
|
1719
|
+
elif flow_units == ToolkitConstants.EN_AFD:
|
|
1720
|
+
self.epanet_api.setFlowUnitsAFD()
|
|
1721
|
+
elif flow_units == ToolkitConstants.EN_LPS:
|
|
1722
|
+
self.epanet_api.setFlowUnitsLPS()
|
|
1723
|
+
elif flow_units == ToolkitConstants.EN_LPM:
|
|
1724
|
+
self.epanet_api.setFlowUnitsLPM()
|
|
1725
|
+
elif flow_units == ToolkitConstants.EN_MLD:
|
|
1726
|
+
self.epanet_api.setFlowUnitsMLD()
|
|
1727
|
+
elif flow_units == ToolkitConstants.EN_CMH:
|
|
1728
|
+
self.epanet_api.setFlowUnitsCMH()
|
|
1729
|
+
elif flow_units == ToolkitConstants.EN_CMD:
|
|
1730
|
+
self.epanet_api.setFlowUnitsCMD()
|
|
1731
|
+
else:
|
|
1732
|
+
raise ValueError(f"Unknown flow units '{flow_units}'")
|
|
1733
|
+
|
|
1734
|
+
if quality_model is not None:
|
|
1735
|
+
if quality_model["type"] == "NONE":
|
|
1736
|
+
self.epanet_api.setQualityType("none")
|
|
1737
|
+
elif quality_model["type"] == "AGE":
|
|
1738
|
+
self.epanet_api.setQualityType("age")
|
|
1739
|
+
elif quality_model["type"] == "CHEM":
|
|
1740
|
+
self.epanet_api.setQualityType("chem", quality_model["chemical_name"],
|
|
1741
|
+
quality_model["units"])
|
|
1742
|
+
elif quality_model["type"] == "TRACE":
|
|
1743
|
+
self.epanet_api.setQualityType("trace", quality_model["trace_node_id"])
|
|
1744
|
+
else:
|
|
1745
|
+
raise ValueError(f"Unknown quality type: {quality_model['type']}")
|
|
1746
|
+
|
|
1747
|
+
def get_events_active_time_points(self) -> list[int]:
|
|
1748
|
+
"""
|
|
1749
|
+
Gets a list of time points (i.e. seconds since simulation start) at which
|
|
1750
|
+
at least one event (system or sensor readinge event) is active.
|
|
1751
|
+
|
|
1752
|
+
Returns
|
|
1753
|
+
-------
|
|
1754
|
+
`list[int]`
|
|
1755
|
+
List of time points at which at least one event is active.
|
|
1756
|
+
"""
|
|
1757
|
+
events_times = []
|
|
1758
|
+
|
|
1759
|
+
hyd_time_step = self.epanet_api.getTimeHydraulicStep()
|
|
1760
|
+
|
|
1761
|
+
def __process_event(event) -> None:
|
|
1762
|
+
cur_time = event.start_time
|
|
1763
|
+
while cur_time < event.end_time:
|
|
1764
|
+
events_times.append(cur_time)
|
|
1765
|
+
cur_time += hyd_time_step
|
|
1766
|
+
|
|
1767
|
+
for event in self.__sensor_reading_events:
|
|
1768
|
+
__process_event(event)
|
|
1769
|
+
|
|
1770
|
+
for event in self.__system_events:
|
|
1771
|
+
__process_event(event)
|
|
1772
|
+
|
|
1773
|
+
return list(set(events_times))
|
|
1774
|
+
|
|
1775
|
+
def __warn_if_quality_set(self):
|
|
1776
|
+
qual_info = self.epanet_api.getQualityInfo()
|
|
1777
|
+
if qual_info.QualityCode != ToolkitConstants.EN_NONE:
|
|
1778
|
+
warnings.warn("You are overriding current quality settings " +
|
|
1779
|
+
f"'{qual_info.QualityType}'")
|
|
1780
|
+
|
|
1781
|
+
def enable_waterage_analysis(self) -> None:
|
|
1782
|
+
"""
|
|
1783
|
+
Sets water age analysis -- i.e. estimates the water age (in hours) at
|
|
1784
|
+
all places in the network.
|
|
1785
|
+
"""
|
|
1786
|
+
self.__adapt_to_network_changes()
|
|
1787
|
+
|
|
1788
|
+
self.__warn_if_quality_set()
|
|
1789
|
+
self.set_general_parameters(quality_model={"type": "AGE"})
|
|
1790
|
+
|
|
1791
|
+
def enable_chemical_analysis(self, chemical_name: str = "Chlorine",
|
|
1792
|
+
chemical_units: str = "mg/L") -> None:
|
|
1793
|
+
"""
|
|
1794
|
+
Sets chemical analysis.
|
|
1795
|
+
|
|
1796
|
+
ATTENTION: Do not forget to inject this chemical into the WDN.
|
|
1797
|
+
|
|
1798
|
+
Parameters
|
|
1799
|
+
----------
|
|
1800
|
+
chemical_name : `str`, optional
|
|
1801
|
+
Name of the chemical being analyzed.
|
|
1802
|
+
|
|
1803
|
+
The default is "Chlorine".
|
|
1804
|
+
chemical_units : `str`, optional
|
|
1805
|
+
Units that the chemical is measured in.
|
|
1806
|
+
Either "mg/L" or "ug/L".
|
|
1807
|
+
|
|
1808
|
+
The default is "mg/L".
|
|
1809
|
+
"""
|
|
1810
|
+
self.__adapt_to_network_changes()
|
|
1811
|
+
|
|
1812
|
+
self.__warn_if_quality_set()
|
|
1813
|
+
self.set_general_parameters(quality_model={"type": "CHEM", "chemical_name": chemical_name,
|
|
1814
|
+
"units": chemical_units})
|
|
1815
|
+
|
|
1816
|
+
def add_quality_source(self, node_id: str, pattern: np.ndarray, source_type: int,
|
|
1817
|
+
pattern_id: str = None, source_strength: int = 1.) -> None:
|
|
1818
|
+
"""
|
|
1819
|
+
Adds a new external water quality source at a particular node.
|
|
1820
|
+
|
|
1821
|
+
Parameters
|
|
1822
|
+
----------
|
|
1823
|
+
node_id : `str`
|
|
1824
|
+
ID of the node at which this external water quality source is placed.
|
|
1825
|
+
pattern : `numpy.ndarray`
|
|
1826
|
+
1d source pattern.
|
|
1827
|
+
source_type : `int`,
|
|
1828
|
+
Types of the external water quality source -- must be of the following
|
|
1829
|
+
EPANET toolkit constants:
|
|
1830
|
+
|
|
1831
|
+
- EN_CONCEN = 0
|
|
1832
|
+
- EN_MASS = 1
|
|
1833
|
+
- EN_SETPOINT = 2
|
|
1834
|
+
- EN_FLOWPACED = 3
|
|
1835
|
+
|
|
1836
|
+
Description:
|
|
1837
|
+
|
|
1838
|
+
- E_CONCEN Sets the concentration of external inflow entering a node
|
|
1839
|
+
- EN_MASS Injects a given mass/minute into a node
|
|
1840
|
+
- EN_SETPOINT Sets the concentration leaving a node to a given value
|
|
1841
|
+
- EN_FLOWPACED Adds a given value to the concentration leaving a node
|
|
1842
|
+
pattern_id : `str`, optional
|
|
1843
|
+
ID of the source pattern.
|
|
1844
|
+
|
|
1845
|
+
If None, a pattern_id will be generated automatically -- be aware that this
|
|
1846
|
+
could conflict with existing pattern IDs (in this case, an exception is raised).
|
|
1847
|
+
|
|
1848
|
+
The default is None.
|
|
1849
|
+
source_strength : `int`, optional
|
|
1850
|
+
Quality source strength -- i.e. quality-source = source_strength * pattern.
|
|
1851
|
+
|
|
1852
|
+
The default is 1.
|
|
1853
|
+
"""
|
|
1854
|
+
self.__adapt_to_network_changes()
|
|
1855
|
+
|
|
1856
|
+
if self.epanet_api.getQualityInfo().QualityCode != ToolkitConstants.EN_CHEM:
|
|
1857
|
+
raise RuntimeError("Chemical analysis is not enabled -- " +
|
|
1858
|
+
"call 'enable_chemical_analysis()' before calling this function.")
|
|
1859
|
+
if node_id not in self.__sensor_config.nodes:
|
|
1860
|
+
raise ValueError(f"Unknown node '{node_id}'")
|
|
1861
|
+
if not isinstance(pattern, np.ndarray):
|
|
1862
|
+
raise TypeError("'pattern' must be an instance of 'numpy.ndarray' " +
|
|
1863
|
+
f"but not of '{type(pattern)}'")
|
|
1864
|
+
if not isinstance(source_type, int) or not 0 <= source_type <= 3:
|
|
1865
|
+
raise ValueError("Invalid type of water quality source")
|
|
1866
|
+
|
|
1867
|
+
if pattern_id is None:
|
|
1868
|
+
pattern_id = f"quality_source_pattern_node={node_id}"
|
|
1869
|
+
if pattern_id in self.epanet_api.getPatternNameID():
|
|
1870
|
+
raise ValueError("Invalid 'pattern_id' -- " +
|
|
1871
|
+
f"there already exists a pattern with ID '{pattern_id}'")
|
|
1872
|
+
|
|
1873
|
+
node_idx = self.epanet_api.getNodeIndex(node_id)
|
|
1874
|
+
pattern_idx = self.epanet_api.addPattern(pattern_id, pattern)
|
|
1875
|
+
|
|
1876
|
+
self.epanet_api.api.ENsetnodevalue(node_idx, ToolkitConstants.EN_SOURCETYPE, source_type)
|
|
1877
|
+
self.epanet_api.setNodeSourceQuality(node_idx, source_strength)
|
|
1878
|
+
self.epanet_api.setNodeSourcePatternIndex(node_idx, pattern_idx)
|
|
1879
|
+
|
|
1880
|
+
def enable_sourcetracing_analysis(self, trace_node_id: str) -> None:
|
|
1881
|
+
"""
|
|
1882
|
+
Set source tracing analysis -- i.e. tracks the percentage of flow from a given node
|
|
1883
|
+
reaching all other nodes over time.
|
|
1884
|
+
|
|
1885
|
+
Parameters
|
|
1886
|
+
----------
|
|
1887
|
+
trace_node_id : `str`
|
|
1888
|
+
ID of the node traced in the source tracing analysis.
|
|
1889
|
+
"""
|
|
1890
|
+
self.__adapt_to_network_changes()
|
|
1891
|
+
|
|
1892
|
+
if trace_node_id not in self.__sensor_config.nodes:
|
|
1893
|
+
raise ValueError(f"Invalid node ID '{trace_node_id}'")
|
|
1894
|
+
|
|
1895
|
+
self.__warn_if_quality_set()
|
|
1896
|
+
self.set_general_parameters(quality_model={"type": "TRACE",
|
|
1897
|
+
"trace_node_id": trace_node_id})
|