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.
Files changed (131) hide show
  1. epyt_flow/EPANET/EPANET/SRC_engines/AUTHORS +28 -0
  2. epyt_flow/EPANET/EPANET/SRC_engines/LICENSE +21 -0
  3. epyt_flow/EPANET/EPANET/SRC_engines/Readme_SRC_Engines.txt +18 -0
  4. epyt_flow/EPANET/EPANET/SRC_engines/enumstxt.h +134 -0
  5. epyt_flow/EPANET/EPANET/SRC_engines/epanet.c +5578 -0
  6. epyt_flow/EPANET/EPANET/SRC_engines/epanet2.c +865 -0
  7. epyt_flow/EPANET/EPANET/SRC_engines/epanet2.def +131 -0
  8. epyt_flow/EPANET/EPANET/SRC_engines/errors.dat +73 -0
  9. epyt_flow/EPANET/EPANET/SRC_engines/funcs.h +193 -0
  10. epyt_flow/EPANET/EPANET/SRC_engines/genmmd.c +1000 -0
  11. epyt_flow/EPANET/EPANET/SRC_engines/hash.c +177 -0
  12. epyt_flow/EPANET/EPANET/SRC_engines/hash.h +28 -0
  13. epyt_flow/EPANET/EPANET/SRC_engines/hydcoeffs.c +1151 -0
  14. epyt_flow/EPANET/EPANET/SRC_engines/hydraul.c +1117 -0
  15. epyt_flow/EPANET/EPANET/SRC_engines/hydsolver.c +720 -0
  16. epyt_flow/EPANET/EPANET/SRC_engines/hydstatus.c +476 -0
  17. epyt_flow/EPANET/EPANET/SRC_engines/include/epanet2.h +431 -0
  18. epyt_flow/EPANET/EPANET/SRC_engines/include/epanet2_2.h +1786 -0
  19. epyt_flow/EPANET/EPANET/SRC_engines/include/epanet2_enums.h +468 -0
  20. epyt_flow/EPANET/EPANET/SRC_engines/inpfile.c +810 -0
  21. epyt_flow/EPANET/EPANET/SRC_engines/input1.c +707 -0
  22. epyt_flow/EPANET/EPANET/SRC_engines/input2.c +864 -0
  23. epyt_flow/EPANET/EPANET/SRC_engines/input3.c +2170 -0
  24. epyt_flow/EPANET/EPANET/SRC_engines/main.c +93 -0
  25. epyt_flow/EPANET/EPANET/SRC_engines/mempool.c +142 -0
  26. epyt_flow/EPANET/EPANET/SRC_engines/mempool.h +24 -0
  27. epyt_flow/EPANET/EPANET/SRC_engines/output.c +852 -0
  28. epyt_flow/EPANET/EPANET/SRC_engines/project.c +1359 -0
  29. epyt_flow/EPANET/EPANET/SRC_engines/quality.c +685 -0
  30. epyt_flow/EPANET/EPANET/SRC_engines/qualreact.c +743 -0
  31. epyt_flow/EPANET/EPANET/SRC_engines/qualroute.c +694 -0
  32. epyt_flow/EPANET/EPANET/SRC_engines/report.c +1489 -0
  33. epyt_flow/EPANET/EPANET/SRC_engines/rules.c +1362 -0
  34. epyt_flow/EPANET/EPANET/SRC_engines/smatrix.c +871 -0
  35. epyt_flow/EPANET/EPANET/SRC_engines/text.h +497 -0
  36. epyt_flow/EPANET/EPANET/SRC_engines/types.h +874 -0
  37. epyt_flow/EPANET/EPANET-MSX/MSX_Updates.txt +53 -0
  38. epyt_flow/EPANET/EPANET-MSX/Src/dispersion.h +27 -0
  39. epyt_flow/EPANET/EPANET-MSX/Src/hash.c +107 -0
  40. epyt_flow/EPANET/EPANET-MSX/Src/hash.h +28 -0
  41. epyt_flow/EPANET/EPANET-MSX/Src/include/epanetmsx.h +102 -0
  42. epyt_flow/EPANET/EPANET-MSX/Src/include/epanetmsx_export.h +42 -0
  43. epyt_flow/EPANET/EPANET-MSX/Src/mathexpr.c +937 -0
  44. epyt_flow/EPANET/EPANET-MSX/Src/mathexpr.h +39 -0
  45. epyt_flow/EPANET/EPANET-MSX/Src/mempool.c +204 -0
  46. epyt_flow/EPANET/EPANET-MSX/Src/mempool.h +24 -0
  47. epyt_flow/EPANET/EPANET-MSX/Src/msxchem.c +1285 -0
  48. epyt_flow/EPANET/EPANET-MSX/Src/msxcompiler.c +368 -0
  49. epyt_flow/EPANET/EPANET-MSX/Src/msxdict.h +42 -0
  50. epyt_flow/EPANET/EPANET-MSX/Src/msxdispersion.c +586 -0
  51. epyt_flow/EPANET/EPANET-MSX/Src/msxerr.c +116 -0
  52. epyt_flow/EPANET/EPANET-MSX/Src/msxfile.c +260 -0
  53. epyt_flow/EPANET/EPANET-MSX/Src/msxfuncs.c +175 -0
  54. epyt_flow/EPANET/EPANET-MSX/Src/msxfuncs.h +35 -0
  55. epyt_flow/EPANET/EPANET-MSX/Src/msxinp.c +1504 -0
  56. epyt_flow/EPANET/EPANET-MSX/Src/msxout.c +401 -0
  57. epyt_flow/EPANET/EPANET-MSX/Src/msxproj.c +791 -0
  58. epyt_flow/EPANET/EPANET-MSX/Src/msxqual.c +2010 -0
  59. epyt_flow/EPANET/EPANET-MSX/Src/msxrpt.c +400 -0
  60. epyt_flow/EPANET/EPANET-MSX/Src/msxtank.c +422 -0
  61. epyt_flow/EPANET/EPANET-MSX/Src/msxtoolkit.c +1164 -0
  62. epyt_flow/EPANET/EPANET-MSX/Src/msxtypes.h +551 -0
  63. epyt_flow/EPANET/EPANET-MSX/Src/msxutils.c +524 -0
  64. epyt_flow/EPANET/EPANET-MSX/Src/msxutils.h +56 -0
  65. epyt_flow/EPANET/EPANET-MSX/Src/newton.c +158 -0
  66. epyt_flow/EPANET/EPANET-MSX/Src/newton.h +34 -0
  67. epyt_flow/EPANET/EPANET-MSX/Src/rk5.c +287 -0
  68. epyt_flow/EPANET/EPANET-MSX/Src/rk5.h +39 -0
  69. epyt_flow/EPANET/EPANET-MSX/Src/ros2.c +293 -0
  70. epyt_flow/EPANET/EPANET-MSX/Src/ros2.h +35 -0
  71. epyt_flow/EPANET/EPANET-MSX/Src/smatrix.c +816 -0
  72. epyt_flow/EPANET/EPANET-MSX/Src/smatrix.h +29 -0
  73. epyt_flow/EPANET/EPANET-MSX/readme.txt +14 -0
  74. epyt_flow/EPANET/compile.sh +4 -0
  75. epyt_flow/VERSION +1 -0
  76. epyt_flow/__init__.py +24 -0
  77. epyt_flow/data/__init__.py +0 -0
  78. epyt_flow/data/benchmarks/__init__.py +11 -0
  79. epyt_flow/data/benchmarks/batadal.py +257 -0
  80. epyt_flow/data/benchmarks/batadal_data.py +28 -0
  81. epyt_flow/data/benchmarks/battledim.py +473 -0
  82. epyt_flow/data/benchmarks/battledim_data.py +51 -0
  83. epyt_flow/data/benchmarks/gecco_water_quality.py +267 -0
  84. epyt_flow/data/benchmarks/leakdb.py +592 -0
  85. epyt_flow/data/benchmarks/leakdb_data.py +18923 -0
  86. epyt_flow/data/benchmarks/water_usage.py +123 -0
  87. epyt_flow/data/networks.py +650 -0
  88. epyt_flow/gym/__init__.py +4 -0
  89. epyt_flow/gym/control_gyms.py +47 -0
  90. epyt_flow/gym/scenario_control_env.py +101 -0
  91. epyt_flow/metrics.py +404 -0
  92. epyt_flow/models/__init__.py +2 -0
  93. epyt_flow/models/event_detector.py +31 -0
  94. epyt_flow/models/sensor_interpolation_detector.py +118 -0
  95. epyt_flow/rest_api/__init__.py +4 -0
  96. epyt_flow/rest_api/base_handler.py +70 -0
  97. epyt_flow/rest_api/res_manager.py +95 -0
  98. epyt_flow/rest_api/scada_data_handler.py +476 -0
  99. epyt_flow/rest_api/scenario_handler.py +352 -0
  100. epyt_flow/rest_api/server.py +106 -0
  101. epyt_flow/serialization.py +438 -0
  102. epyt_flow/simulation/__init__.py +5 -0
  103. epyt_flow/simulation/events/__init__.py +6 -0
  104. epyt_flow/simulation/events/actuator_events.py +259 -0
  105. epyt_flow/simulation/events/event.py +81 -0
  106. epyt_flow/simulation/events/leakages.py +404 -0
  107. epyt_flow/simulation/events/sensor_faults.py +267 -0
  108. epyt_flow/simulation/events/sensor_reading_attack.py +185 -0
  109. epyt_flow/simulation/events/sensor_reading_event.py +170 -0
  110. epyt_flow/simulation/events/system_event.py +88 -0
  111. epyt_flow/simulation/parallel_simulation.py +147 -0
  112. epyt_flow/simulation/scada/__init__.py +3 -0
  113. epyt_flow/simulation/scada/advanced_control.py +134 -0
  114. epyt_flow/simulation/scada/scada_data.py +1589 -0
  115. epyt_flow/simulation/scada/scada_data_export.py +255 -0
  116. epyt_flow/simulation/scenario_config.py +608 -0
  117. epyt_flow/simulation/scenario_simulator.py +1897 -0
  118. epyt_flow/simulation/scenario_visualizer.py +61 -0
  119. epyt_flow/simulation/sensor_config.py +1289 -0
  120. epyt_flow/topology.py +290 -0
  121. epyt_flow/uncertainty/__init__.py +3 -0
  122. epyt_flow/uncertainty/model_uncertainty.py +302 -0
  123. epyt_flow/uncertainty/sensor_noise.py +73 -0
  124. epyt_flow/uncertainty/uncertainties.py +555 -0
  125. epyt_flow/uncertainty/utils.py +206 -0
  126. epyt_flow/utils.py +306 -0
  127. epyt_flow-0.1.0.dist-info/LICENSE +21 -0
  128. epyt_flow-0.1.0.dist-info/METADATA +139 -0
  129. epyt_flow-0.1.0.dist-info/RECORD +131 -0
  130. epyt_flow-0.1.0.dist-info/WHEEL +5 -0
  131. 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})