epyt-flow 0.7.3__py3-none-any.whl → 0.8.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/VERSION +1 -1
- epyt_flow/gym/scenario_control_env.py +129 -10
- epyt_flow/serialization.py +10 -3
- epyt_flow/simulation/events/__init__.py +1 -0
- epyt_flow/simulation/events/leakages.py +55 -12
- epyt_flow/simulation/events/quality_events.py +194 -0
- epyt_flow/simulation/events/system_event.py +5 -0
- epyt_flow/simulation/scada/scada_data.py +512 -64
- epyt_flow/simulation/scada/scada_data_export.py +7 -5
- epyt_flow/simulation/scenario_config.py +13 -2
- epyt_flow/simulation/scenario_simulator.py +275 -187
- epyt_flow/simulation/scenario_visualizer.py +1259 -13
- {epyt_flow-0.7.3.dist-info → epyt_flow-0.8.0.dist-info}/METADATA +31 -30
- {epyt_flow-0.7.3.dist-info → epyt_flow-0.8.0.dist-info}/RECORD +17 -16
- {epyt_flow-0.7.3.dist-info → epyt_flow-0.8.0.dist-info}/WHEEL +1 -1
- {epyt_flow-0.7.3.dist-info → epyt_flow-0.8.0.dist-info}/LICENSE +0 -0
- {epyt_flow-0.7.3.dist-info → epyt_flow-0.8.0.dist-info}/top_level.txt +0 -0
epyt_flow/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.8.0
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Module provides a base class for control environments.
|
|
3
3
|
"""
|
|
4
|
+
import os
|
|
5
|
+
import uuid
|
|
4
6
|
from abc import abstractmethod, ABC
|
|
5
7
|
from typing import Union
|
|
6
8
|
import warnings
|
|
7
9
|
import numpy as np
|
|
8
10
|
|
|
9
|
-
from ..simulation import ScenarioSimulator, ScenarioConfig, ScadaData
|
|
11
|
+
from ..simulation import ScenarioSimulator, ScenarioConfig, ScadaData, ToolkitConstants
|
|
12
|
+
from ..utils import get_temp_folder
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
class ScenarioControlEnv(ABC):
|
|
@@ -21,12 +24,32 @@ class ScenarioControlEnv(ABC):
|
|
|
21
24
|
If True, environment is automatically reset if terminated.
|
|
22
25
|
|
|
23
26
|
The default is False.
|
|
27
|
+
|
|
28
|
+
Attributes
|
|
29
|
+
----------
|
|
30
|
+
_scenario_sim : :class:`~epyt_flow.simulation.scenario_simulator.ScenarioSimulator`, protected
|
|
31
|
+
Scenario simulator of the control scenario.
|
|
32
|
+
_scenario_config : :class:`~epyt_flow.simulation.scenario_config.ScenarioConfig`
|
|
33
|
+
Scenario configuration.
|
|
34
|
+
_sim_generator : Generator[Union[:class:`~epyt_flow.simulation.scada.scada_data.ScadaData`, dict], bool, None], protected
|
|
35
|
+
Generator for running the step-wise simulation.
|
|
36
|
+
_hydraulic_scada_data : :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`, protected
|
|
37
|
+
SCADA data from the hydraulic simulation -- only used if EPANET-MSX is used in the control scenario.
|
|
24
38
|
"""
|
|
25
39
|
def __init__(self, scenario_config: ScenarioConfig, autoreset: bool = False, **kwds):
|
|
26
|
-
|
|
40
|
+
if not isinstance(scenario_config, ScenarioConfig):
|
|
41
|
+
raise TypeError("'scenario_config' must be an instance of " +
|
|
42
|
+
"'epyt_flow.simulation.ScenarioConfig' " +
|
|
43
|
+
"but not of '{type(scenario_config)}'")
|
|
44
|
+
if not isinstance(autoreset, bool):
|
|
45
|
+
raise TypeError("'autoreset' must be an instance of 'bool' " +
|
|
46
|
+
f"but not of '{type(autoreset)}'")
|
|
47
|
+
|
|
48
|
+
self._scenario_config = scenario_config
|
|
27
49
|
self._scenario_sim = None
|
|
28
50
|
self._sim_generator = None
|
|
29
51
|
self.__autoreset = autoreset
|
|
52
|
+
self._hydraulic_scada_data = None
|
|
30
53
|
|
|
31
54
|
super().__init__(**kwds)
|
|
32
55
|
|
|
@@ -54,15 +77,27 @@ class ScenarioControlEnv(ABC):
|
|
|
54
77
|
"""
|
|
55
78
|
try:
|
|
56
79
|
if self._sim_generator is not None:
|
|
57
|
-
self._sim_generator.send(True)
|
|
58
80
|
next(self._sim_generator)
|
|
81
|
+
self._sim_generator.send(True)
|
|
59
82
|
except StopIteration:
|
|
60
83
|
pass
|
|
61
84
|
|
|
62
85
|
if self._scenario_sim is not None:
|
|
63
86
|
self._scenario_sim.close()
|
|
64
87
|
|
|
65
|
-
def
|
|
88
|
+
def contains_events(self) -> bool:
|
|
89
|
+
"""
|
|
90
|
+
Check if the scenario contains any events.
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
`bool`
|
|
95
|
+
True is the scenario contains any events, False otherwise.
|
|
96
|
+
"""
|
|
97
|
+
return len(self._scenario_config.system_events) != 0 or \
|
|
98
|
+
len(self._scenario_config.sensor_reading_events) != 0
|
|
99
|
+
|
|
100
|
+
def reset(self) -> Union[tuple[ScadaData, bool], ScadaData]:
|
|
66
101
|
"""
|
|
67
102
|
Resets the environment (i.e. simulation).
|
|
68
103
|
|
|
@@ -75,20 +110,38 @@ class ScenarioControlEnv(ABC):
|
|
|
75
110
|
self._scenario_sim.close()
|
|
76
111
|
|
|
77
112
|
self._scenario_sim = ScenarioSimulator(
|
|
78
|
-
scenario_config=self.
|
|
79
|
-
|
|
113
|
+
scenario_config=self._scenario_config)
|
|
114
|
+
|
|
115
|
+
if self._scenario_sim.f_msx_in is not None:
|
|
116
|
+
# Run hydraulic simulation first
|
|
117
|
+
hyd_export = os.path.join(get_temp_folder(), f"epytflow_env_MSX_{uuid.uuid4()}.hyd")
|
|
118
|
+
sim = self._scenario_sim.run_hydraulic_simulation
|
|
119
|
+
self._hydraulic_scada_data = sim(hyd_export=hyd_export)
|
|
120
|
+
|
|
121
|
+
# Run advanced quality analysis (EPANET-MSX) on top of the computed hydraulics
|
|
122
|
+
gen = self._scenario_sim.run_advanced_quality_simulation_as_generator
|
|
123
|
+
self._sim_generator = gen(hyd_export, support_abort=True)
|
|
124
|
+
else:
|
|
125
|
+
gen = self._scenario_sim.run_hydraulic_simulation_as_generator
|
|
126
|
+
self._sim_generator = gen(support_abort=True)
|
|
80
127
|
|
|
81
128
|
return self._next_sim_itr()
|
|
82
129
|
|
|
83
|
-
def _next_sim_itr(self) -> ScadaData:
|
|
130
|
+
def _next_sim_itr(self) -> Union[tuple[ScadaData, bool], ScadaData]:
|
|
84
131
|
try:
|
|
85
132
|
next(self._sim_generator)
|
|
86
|
-
|
|
133
|
+
scada_data = self._sim_generator.send(False)
|
|
134
|
+
|
|
135
|
+
if self._scenario_sim.f_msx_in is not None:
|
|
136
|
+
cur_time = int(scada_data.sensor_readings_time[0])
|
|
137
|
+
cur_hyd_scada_data = self._hydraulic_scada_data.\
|
|
138
|
+
extract_time_window(cur_time, cur_time)
|
|
139
|
+
scada_data.join(cur_hyd_scada_data)
|
|
87
140
|
|
|
88
141
|
if self.autoreset is True:
|
|
89
|
-
return
|
|
142
|
+
return scada_data
|
|
90
143
|
else:
|
|
91
|
-
return
|
|
144
|
+
return scada_data, False
|
|
92
145
|
except StopIteration:
|
|
93
146
|
if self.__autoreset is True:
|
|
94
147
|
return self.reset()
|
|
@@ -112,6 +165,10 @@ class ScenarioControlEnv(ABC):
|
|
|
112
165
|
- EN_CLOSED = 0
|
|
113
166
|
- EN_OPEN = 1
|
|
114
167
|
"""
|
|
168
|
+
if self._scenario_sim.f_msx_in is not None:
|
|
169
|
+
raise RuntimeError("Can not execute actions affecting the hydraulics "+
|
|
170
|
+
"when running EPANET-MSX")
|
|
171
|
+
|
|
115
172
|
pump_idx = self._scenario_sim.epanet_api.getLinkPumpNameID().index(pump_id)
|
|
116
173
|
pump_link_idx = self._scenario_sim.epanet_api.getLinkPumpIndex(pump_idx + 1)
|
|
117
174
|
self._scenario_sim.epanet_api.setLinkStatus(pump_link_idx, status)
|
|
@@ -127,6 +184,10 @@ class ScenarioControlEnv(ABC):
|
|
|
127
184
|
speed : `float`
|
|
128
185
|
New pump speed.
|
|
129
186
|
"""
|
|
187
|
+
if self._scenario_sim.f_msx_in is not None:
|
|
188
|
+
raise RuntimeError("Can not execute actions affecting the hydraulics "+
|
|
189
|
+
"when running EPANET-MSX")
|
|
190
|
+
|
|
130
191
|
pump_idx = self._scenario_sim.epanet_api.getLinkPumpNameID().index(pump_id)
|
|
131
192
|
pattern_idx = self._scenario_sim.epanet_api.getLinkPumpPatternIndex(pump_idx + 1)
|
|
132
193
|
|
|
@@ -154,6 +215,10 @@ class ScenarioControlEnv(ABC):
|
|
|
154
215
|
- EN_CLOSED = 0
|
|
155
216
|
- EN_OPEN = 1
|
|
156
217
|
"""
|
|
218
|
+
if self._scenario_sim.f_msx_in is not None:
|
|
219
|
+
raise RuntimeError("Can not execute actions affecting the hydraulics "+
|
|
220
|
+
"when running EPANET-MSX")
|
|
221
|
+
|
|
157
222
|
valve_idx = self._scenario_sim.epanet_api.getLinkValveNameID().index(valve_id)
|
|
158
223
|
valve_link_idx = self._scenario_sim.epanet_api.getLinkValveIndex()[valve_idx]
|
|
159
224
|
self._scenario_sim.epanet_api.setLinkStatus(valve_link_idx, status)
|
|
@@ -173,11 +238,65 @@ class ScenarioControlEnv(ABC):
|
|
|
173
238
|
qual_value : `float`
|
|
174
239
|
New quality source value.
|
|
175
240
|
"""
|
|
241
|
+
if self._scenario_sim.f_msx_in is not None:
|
|
242
|
+
raise RuntimeError("Can not execute actions affecting the hydraulics "+
|
|
243
|
+
"when running EPANET-MSX")
|
|
244
|
+
|
|
176
245
|
node_idx = self._scenario_sim.epanet_api.getNodeIndex(node_id)
|
|
177
246
|
pattern_idx = self._scenario_sim.epanet_api.getPatternIndex(pattern_id)
|
|
178
247
|
self._scenario_sim.epanet_api.setNodeSourceQuality(node_idx, 1)
|
|
179
248
|
self._scenario_sim.epanet_api.setPattern(pattern_idx, np.array([qual_value]))
|
|
180
249
|
|
|
250
|
+
def set_node_species_source_value(self, species_id: str, node_id: str, source_type: int,
|
|
251
|
+
pattern_id: str, source_strength: float) -> None:
|
|
252
|
+
"""
|
|
253
|
+
Sets the species source at a particular node to a specific value -- i.e.
|
|
254
|
+
setting the species injection amount at a particular location.
|
|
255
|
+
|
|
256
|
+
Parameters
|
|
257
|
+
----------
|
|
258
|
+
species_id : `str`
|
|
259
|
+
ID of the species.
|
|
260
|
+
node_id : `str`
|
|
261
|
+
ID of the node.
|
|
262
|
+
source_type : `int`
|
|
263
|
+
Type of the external species injection source -- must be one of
|
|
264
|
+
the following EPANET toolkit constants:
|
|
265
|
+
|
|
266
|
+
- EN_CONCEN = 0
|
|
267
|
+
- EN_MASS = 1
|
|
268
|
+
- EN_SETPOINT = 2
|
|
269
|
+
- EN_FLOWPACED = 3
|
|
270
|
+
|
|
271
|
+
Description:
|
|
272
|
+
|
|
273
|
+
- E_CONCEN Sets the concentration of external inflow entering a node
|
|
274
|
+
- EN_MASS Injects a given mass/minute into a node
|
|
275
|
+
- EN_SETPOINT Sets the concentration leaving a node to a given value
|
|
276
|
+
- EN_FLOWPACED Adds a given value to the concentration leaving a node
|
|
277
|
+
pattern_id : `str`
|
|
278
|
+
ID of the source pattern.
|
|
279
|
+
source_strength : `float`
|
|
280
|
+
Amount of the injected species (source strength) --
|
|
281
|
+
i.e. interpreation of this number depends on `source_type`
|
|
282
|
+
"""
|
|
283
|
+
if self._scenario_sim.f_msx_in is None:
|
|
284
|
+
raise RuntimeError("You are not running EPANET-MSX")
|
|
285
|
+
|
|
286
|
+
source_type_ = "None"
|
|
287
|
+
if source_type == ToolkitConstants.EN_CONCEN:
|
|
288
|
+
source_type_ = "CONCEN"
|
|
289
|
+
elif source_type == ToolkitConstants.EN_MASS:
|
|
290
|
+
source_type_ = "MASS"
|
|
291
|
+
elif source_type == ToolkitConstants.EN_SETPOINT:
|
|
292
|
+
source_type_ = "SETPOINT"
|
|
293
|
+
elif source_type == ToolkitConstants.EN_FLOWPACED:
|
|
294
|
+
source_type_ = "FLOWPACED"
|
|
295
|
+
|
|
296
|
+
self._scenario_sim.epanet_api.setMSXPattern(pattern_id, [1])
|
|
297
|
+
self._scenario_sim.epanet_api.setMSXSources(node_id, species_id, source_type_,
|
|
298
|
+
source_strength, pattern_id)
|
|
299
|
+
|
|
181
300
|
@abstractmethod
|
|
182
301
|
def step(self, *actions) -> Union[tuple[ScadaData, float, bool], tuple[ScadaData, float]]:
|
|
183
302
|
"""
|
epyt_flow/serialization.py
CHANGED
|
@@ -47,6 +47,7 @@ NETWORK_TOPOLOGY_ID = 26
|
|
|
47
47
|
PUMP_STATE_EVENT_ID = 28
|
|
48
48
|
PUMP_SPEED_EVENT_ID = 29
|
|
49
49
|
VALVE_STATE_EVENT_ID = 30
|
|
50
|
+
SPECIESINJECTION_EVENT_ID = 31
|
|
50
51
|
|
|
51
52
|
|
|
52
53
|
def my_packb(data: Any) -> bytes:
|
|
@@ -452,9 +453,15 @@ def save_to_file(f_out: str, data: Any, use_compression: bool = True) -> None:
|
|
|
452
453
|
def __encode_bsr_array(array: scipy.sparse.bsr_array
|
|
453
454
|
) -> tuple[tuple[int, int], tuple[list[float], tuple[list[int], list[int]]]]:
|
|
454
455
|
shape = array.shape
|
|
455
|
-
data =
|
|
456
|
-
rows =
|
|
457
|
-
cols =
|
|
456
|
+
data = []
|
|
457
|
+
rows = []
|
|
458
|
+
cols = []
|
|
459
|
+
|
|
460
|
+
array_ = array.tocsr() # Bug workaround: BSR arrays do not implement __getitem__
|
|
461
|
+
for i, j in zip(*array_.nonzero()):
|
|
462
|
+
rows.append(int(i))
|
|
463
|
+
cols.append(int(j))
|
|
464
|
+
data.append(float(array_[i, j]))
|
|
458
465
|
|
|
459
466
|
return shape, (data, (rows, cols))
|
|
460
467
|
|
|
@@ -60,8 +60,8 @@ class Leakage(SystemEvent, JsonSerializable):
|
|
|
60
60
|
if area is None and diameter is None:
|
|
61
61
|
raise ValueError("Either 'diameter' or 'area' must be given")
|
|
62
62
|
if area is not None and diameter is not None:
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
raise ValueError("Either 'diameter' or 'area' must be given, " +
|
|
64
|
+
"but not both at the same time")
|
|
65
65
|
if diameter is not None:
|
|
66
66
|
if not isinstance(diameter, float):
|
|
67
67
|
raise TypeError("'diameter must be an instance of 'float' but " +
|
|
@@ -92,7 +92,7 @@ class Leakage(SystemEvent, JsonSerializable):
|
|
|
92
92
|
self.__area = area
|
|
93
93
|
self.__profile = profile
|
|
94
94
|
|
|
95
|
-
self.
|
|
95
|
+
self.__leaky_node_idx = None
|
|
96
96
|
self.__leak_emitter_coef = None
|
|
97
97
|
self.__time_pattern_idx = 0
|
|
98
98
|
|
|
@@ -174,7 +174,7 @@ class Leakage(SystemEvent, JsonSerializable):
|
|
|
174
174
|
def get_attributes(self) -> dict:
|
|
175
175
|
return super().get_attributes() | {"link_id": self.__link_id, "diameter": self.__diameter,
|
|
176
176
|
"area": self.__area, "profile": self.__profile,
|
|
177
|
-
"node_id": self.
|
|
177
|
+
"node_id": self.__node_id
|
|
178
178
|
if self.__link_id is None else None}
|
|
179
179
|
|
|
180
180
|
def __eq__(self, other) -> bool:
|
|
@@ -238,10 +238,16 @@ class Leakage(SystemEvent, JsonSerializable):
|
|
|
238
238
|
g = 32.17405 # feet/s^2
|
|
239
239
|
else:
|
|
240
240
|
raise ValueError("Leakages are only implemented for the following flow units:\n" +
|
|
241
|
-
" EN_CMH (cubic
|
|
241
|
+
" EN_CMH (cubic meter/hr)\n EN_CFS (foot/sec)")
|
|
242
242
|
|
|
243
243
|
return discharge_coef * area * np.sqrt(2. * g)
|
|
244
244
|
|
|
245
|
+
def _get_new_link_id(self) -> str:
|
|
246
|
+
return f"leak_pipe_{self.__link_id}"
|
|
247
|
+
|
|
248
|
+
def _get_new_node_id(self) -> str:
|
|
249
|
+
return f"leak_node_{self.__link_id}"
|
|
250
|
+
|
|
245
251
|
def init(self, epanet_api: epyt.epanet) -> None:
|
|
246
252
|
super().init(epanet_api)
|
|
247
253
|
|
|
@@ -250,35 +256,72 @@ class Leakage(SystemEvent, JsonSerializable):
|
|
|
250
256
|
if self.__link_id not in self._epanet_api.getLinkNameID():
|
|
251
257
|
raise ValueError(f"Unknown link/pipe '{self.__link_id}'")
|
|
252
258
|
|
|
253
|
-
new_link_id =
|
|
254
|
-
new_node_id =
|
|
259
|
+
new_link_id = self._get_new_link_id()
|
|
260
|
+
new_node_id = self._get_new_node_id()
|
|
255
261
|
|
|
256
262
|
all_nodes_id = self._epanet_api.getNodeNameID()
|
|
257
263
|
if new_node_id in all_nodes_id:
|
|
258
264
|
raise ValueError(f"There is already a leak at pipe {self.link_id}")
|
|
259
265
|
|
|
260
266
|
self._epanet_api.splitPipe(self.link_id, new_link_id, new_node_id)
|
|
261
|
-
self.
|
|
267
|
+
self.__leaky_node_idx = self._epanet_api.getNodeIndex(new_node_id)
|
|
262
268
|
else:
|
|
263
269
|
if self.__node_id not in self._epanet_api.getNodeNameID():
|
|
264
270
|
raise ValueError(f"Unknown node '{self.__node_id}'")
|
|
265
271
|
|
|
266
|
-
self.
|
|
272
|
+
self.__leaky_node_idx = self._epanet_api.getNodeIndex(self.__node_id)
|
|
267
273
|
|
|
268
|
-
self._epanet_api.setNodeEmitterCoeff(self.
|
|
274
|
+
self._epanet_api.setNodeEmitterCoeff(self.__leaky_node_idx, 0.)
|
|
269
275
|
|
|
270
276
|
# Compute leak emitter coefficient
|
|
271
277
|
self.__leak_emitter_coef = self.compute_leak_emitter_coefficient(
|
|
272
278
|
self.compute_leak_area(self.area))
|
|
273
279
|
|
|
280
|
+
def cleanup(self) -> None:
|
|
281
|
+
if self.__link_id is not None:
|
|
282
|
+
pipe_idx = self._epanet_api.getLinkIndex(self.__link_id)
|
|
283
|
+
link_prop = self._epanet_api.getLinksInfo()
|
|
284
|
+
link_diameter = link_prop.LinkDiameter[pipe_idx - 1]
|
|
285
|
+
link_length = link_prop.LinkLength[pipe_idx - 1] * 2.
|
|
286
|
+
link_roughness_coeff = link_prop.LinkRoughnessCoeff[pipe_idx - 1]
|
|
287
|
+
link_minor_loss_coeff = link_prop.LinkMinorLossCoeff[pipe_idx - 1]
|
|
288
|
+
link_initial_status = link_prop.LinkInitialStatus[pipe_idx - 1]
|
|
289
|
+
link_initial_setting = link_prop.LinkInitialSetting[pipe_idx - 1]
|
|
290
|
+
link_bulk_reaction_coeff = link_prop.LinkBulkReactionCoeff[pipe_idx - 1]
|
|
291
|
+
link_wall_reaction_coeff = link_prop.LinkWallReactionCoeff[pipe_idx - 1]
|
|
292
|
+
|
|
293
|
+
node_a_idx = int(self._epanet_api.getLinkNodesIndex(pipe_idx)[0])
|
|
294
|
+
node_b_idx = int(self._epanet_api.getLinkNodesIndex(self._get_new_link_id())[1])
|
|
295
|
+
|
|
296
|
+
self._epanet_api.deleteLink(self._get_new_link_id())
|
|
297
|
+
self._epanet_api.deleteLink(self.__link_id)
|
|
298
|
+
self._epanet_api.deleteNode(self._get_new_node_id())
|
|
299
|
+
|
|
300
|
+
self._epanet_api.addLinkPipe(self.__link_id,
|
|
301
|
+
self._epanet_api.getNodeNameID(node_a_idx),
|
|
302
|
+
self._epanet_api.getNodeNameID(node_b_idx))
|
|
303
|
+
link_idx = self._epanet_api.getLinkIndex(self.__link_id)
|
|
304
|
+
self._epanet_api.setLinkNodesIndex(link_idx,
|
|
305
|
+
node_a_idx, node_b_idx)
|
|
306
|
+
self._epanet_api.setLinkPipeData(link_idx, link_length, link_diameter,
|
|
307
|
+
link_roughness_coeff,
|
|
308
|
+
link_minor_loss_coeff)
|
|
309
|
+
if link_minor_loss_coeff != 0:
|
|
310
|
+
self._epanet_api.setLinklinkMinorLossCoeff(link_idx, link_minor_loss_coeff)
|
|
311
|
+
self._epanet_api.setLinkInitialStatus(link_idx, link_initial_status)
|
|
312
|
+
self._epanet_api.setLinkInitialSetting(link_idx, link_initial_setting)
|
|
313
|
+
self._epanet_api.setLinkBulkReactionCoeff(link_idx, link_bulk_reaction_coeff)
|
|
314
|
+
self._epanet_api.setLinkWallReactionCoeff(link_idx, link_wall_reaction_coeff)
|
|
315
|
+
self._epanet_api.setLinkTypePipe(link_idx)
|
|
316
|
+
|
|
274
317
|
def reset(self) -> None:
|
|
275
318
|
self.__time_pattern_idx = 0
|
|
276
319
|
|
|
277
320
|
def exit(self, cur_time) -> None:
|
|
278
|
-
self._epanet_api.setNodeEmitterCoeff(self.
|
|
321
|
+
self._epanet_api.setNodeEmitterCoeff(self.__leaky_node_idx, 0.)
|
|
279
322
|
|
|
280
323
|
def apply(self, cur_time: int) -> None:
|
|
281
|
-
self._epanet_api.setNodeEmitterCoeff(self.
|
|
324
|
+
self._epanet_api.setNodeEmitterCoeff(self.__leaky_node_idx,
|
|
282
325
|
self.__leak_emitter_coef *
|
|
283
326
|
self.__profile[self.__time_pattern_idx])
|
|
284
327
|
self.__time_pattern_idx += 1
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module provides a class for implementing species injection (e.g. contamination) events.
|
|
3
|
+
"""
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
import warnings
|
|
6
|
+
import math
|
|
7
|
+
import numpy as np
|
|
8
|
+
import epyt
|
|
9
|
+
from epyt.epanet import ToolkitConstants
|
|
10
|
+
|
|
11
|
+
from .system_event import SystemEvent
|
|
12
|
+
from ...serialization import serializable, JsonSerializable, \
|
|
13
|
+
SPECIESINJECTION_EVENT_ID
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@serializable(SPECIESINJECTION_EVENT_ID, ".epytflow_speciesinjection_event")
|
|
17
|
+
class SpeciesInjectionEvent(SystemEvent, JsonSerializable):
|
|
18
|
+
"""
|
|
19
|
+
Class implementing a (bulk) species injection event -- e.g. modeling a contamination event.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
species_id : `str`
|
|
24
|
+
ID of the bulk species that is going to be injected.
|
|
25
|
+
node_id : `str`
|
|
26
|
+
ID of the node at which the injection is palced.
|
|
27
|
+
profile : `numpy.ndarray`
|
|
28
|
+
Injection strength profile -- i.e. every entry corresponds to the strength of the injection
|
|
29
|
+
at a point in time. Pattern will repeat if it is shorter than the total injection time.
|
|
30
|
+
source_type : `int`
|
|
31
|
+
Type of the bulk species injection source -- must be one of
|
|
32
|
+
the following EPANET toolkit constants:
|
|
33
|
+
|
|
34
|
+
- EN_CONCEN = 0
|
|
35
|
+
- EN_MASS = 1
|
|
36
|
+
- EN_SETPOINT = 2
|
|
37
|
+
- EN_FLOWPACED = 3
|
|
38
|
+
|
|
39
|
+
Description:
|
|
40
|
+
|
|
41
|
+
- E_CONCEN Sets the concentration of external inflow entering a node
|
|
42
|
+
- EN_MASS Injects a given mass/minute into a node
|
|
43
|
+
- EN_SETPOINT Sets the concentration leaving a node to a given value
|
|
44
|
+
- EN_FLOWPACED Adds a given value to the concentration leaving a node
|
|
45
|
+
"""
|
|
46
|
+
def __init__(self, species_id: str, node_id: str, profile: np.ndarray, source_type: int,
|
|
47
|
+
**kwds):
|
|
48
|
+
if not isinstance(species_id, str):
|
|
49
|
+
raise TypeError("'species_id' must be an instance of 'str' but not of " +
|
|
50
|
+
f"'{type(species_id)}'")
|
|
51
|
+
if not isinstance(node_id, str):
|
|
52
|
+
raise TypeError("'node_id' must be an instance of 'str' but not of " +
|
|
53
|
+
f"'{type(node_id)}'")
|
|
54
|
+
if not isinstance(profile, np.ndarray):
|
|
55
|
+
raise TypeError("'profile' must be an instance of 'numpy.ndarray' but not of " +
|
|
56
|
+
f"'{type(profile)}'")
|
|
57
|
+
if not isinstance(source_type, int):
|
|
58
|
+
raise TypeError("'source_type' must be an instance of 'int' but not of " +
|
|
59
|
+
f"'{type(source_type)}'")
|
|
60
|
+
if not 0 <= source_type <= 3:
|
|
61
|
+
raise ValueError("'source_tye' must be in [0, 3]")
|
|
62
|
+
|
|
63
|
+
self.__species_id = species_id
|
|
64
|
+
self.__node_id = node_id
|
|
65
|
+
self.__profile = profile
|
|
66
|
+
self.__source_type = source_type
|
|
67
|
+
|
|
68
|
+
super().__init__(**kwds)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def species_id(self) -> str:
|
|
72
|
+
"""
|
|
73
|
+
Gets the ID of the bulk species that is going to be injected.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
`str`
|
|
78
|
+
Bulk species ID.
|
|
79
|
+
"""
|
|
80
|
+
return self.__species_id
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def node_id(self) -> str:
|
|
84
|
+
"""
|
|
85
|
+
Gets the ID of the node at which the injection is palced.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
`str`
|
|
90
|
+
Node ID.
|
|
91
|
+
"""
|
|
92
|
+
return self.__node_id
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def profile(self) -> np.ndarray:
|
|
96
|
+
"""
|
|
97
|
+
Gets the injection strength profile.
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
`numpy.ndarray`
|
|
102
|
+
Pattern of the injection.
|
|
103
|
+
"""
|
|
104
|
+
return deepcopy(self.__profile)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def source_type(self) -> int:
|
|
108
|
+
"""
|
|
109
|
+
Type of the bulk species injection source -- will be one of
|
|
110
|
+
the following EPANET toolkit constants:
|
|
111
|
+
|
|
112
|
+
- EN_CONCEN = 0
|
|
113
|
+
- EN_MASS = 1
|
|
114
|
+
- EN_SETPOINT = 2
|
|
115
|
+
- EN_FLOWPACED = 3
|
|
116
|
+
|
|
117
|
+
Returns
|
|
118
|
+
-------
|
|
119
|
+
`int`
|
|
120
|
+
Type of the injection source.
|
|
121
|
+
"""
|
|
122
|
+
return self.__source_type
|
|
123
|
+
|
|
124
|
+
def get_attributes(self) -> dict:
|
|
125
|
+
return super().get_attributes() | {"species_id": self.__species_id,
|
|
126
|
+
"node_id": self.__node_id, "profile": self.__profile,
|
|
127
|
+
"source_type": self.__source_type}
|
|
128
|
+
|
|
129
|
+
def __eq__(self, other) -> bool:
|
|
130
|
+
return super().__eq__(other) and self.__species_id == other.species_id and \
|
|
131
|
+
self.__node_id == other.node_id and np.all(self.__profile == other.profile) and \
|
|
132
|
+
self.__source_type == other.source_type
|
|
133
|
+
|
|
134
|
+
def __str__(self) -> str:
|
|
135
|
+
return f"{super().__str__()} species_id: {self.__species_id} " +\
|
|
136
|
+
f"node_id: {self.__node_id} profile: {self.__profile} source_type: {self.__source_type}"
|
|
137
|
+
|
|
138
|
+
def _get_pattern_id(self) -> str:
|
|
139
|
+
return f"{self.__species_id}_{self.__node_id}_{self.start_time}"
|
|
140
|
+
|
|
141
|
+
def init(self, epanet_api: epyt.epanet) -> None:
|
|
142
|
+
super().init(epanet_api)
|
|
143
|
+
|
|
144
|
+
# Check parameters
|
|
145
|
+
if self.__species_id not in self._epanet_api.getMSXSpeciesNameID():
|
|
146
|
+
raise ValueError(f"Unknown species '{self.__species_id}'")
|
|
147
|
+
if self.__node_id not in self._epanet_api.getNodeNameID():
|
|
148
|
+
raise ValueError(f"Unknown node '{self.__node_id}'")
|
|
149
|
+
|
|
150
|
+
# Create final injection strength pattern
|
|
151
|
+
total_sim_duration = self._epanet_api.getTimeSimulationDuration()
|
|
152
|
+
time_step = self._epanet_api.getTimeHydraulicStep()
|
|
153
|
+
|
|
154
|
+
pattern = np.zeros(math.ceil(total_sim_duration / time_step))
|
|
155
|
+
|
|
156
|
+
end_time = self.end_time if self.end_time is not None else total_sim_duration
|
|
157
|
+
injection_pattern_length = math.ceil((end_time - self.start_time) / time_step)
|
|
158
|
+
injection_time_start_idx = int(self.start_time / time_step)
|
|
159
|
+
|
|
160
|
+
injection_pattern = None
|
|
161
|
+
if len(self.__profile) == injection_pattern_length:
|
|
162
|
+
injection_pattern = self.profile
|
|
163
|
+
else:
|
|
164
|
+
injection_pattern = np.tile(self.profile,
|
|
165
|
+
math.ceil(injection_pattern_length / len(self.profile)))
|
|
166
|
+
|
|
167
|
+
pattern[injection_time_start_idx:
|
|
168
|
+
injection_time_start_idx + injection_pattern_length] = injection_pattern
|
|
169
|
+
|
|
170
|
+
# Create injection
|
|
171
|
+
source_type_ = "None"
|
|
172
|
+
if self.__source_type == ToolkitConstants.EN_CONCEN:
|
|
173
|
+
source_type_ = "CONCEN"
|
|
174
|
+
elif self.__source_type == ToolkitConstants.EN_MASS:
|
|
175
|
+
source_type_ = "MASS"
|
|
176
|
+
elif self.__source_type == ToolkitConstants.EN_SETPOINT:
|
|
177
|
+
source_type_ = "SETPOINT"
|
|
178
|
+
elif self.__source_type == ToolkitConstants.EN_FLOWPACED:
|
|
179
|
+
source_type_ = "FLOWPACED"
|
|
180
|
+
|
|
181
|
+
pattern_id = self._get_pattern_id()
|
|
182
|
+
if pattern_id in self._epanet_api.getMSXPatternsNameID():
|
|
183
|
+
raise ValueError("Duplicated injection event")
|
|
184
|
+
|
|
185
|
+
self._epanet_api.addMSXPattern(pattern_id, pattern)
|
|
186
|
+
self._epanet_api.setMSXSources(self.__node_id, self.__species_id, source_type_, 1,
|
|
187
|
+
pattern_id)
|
|
188
|
+
|
|
189
|
+
def cleanup(self) -> None:
|
|
190
|
+
warnings.warn("Can not undo SpeciedInjectionEvent -- " +
|
|
191
|
+
"EPANET-MSX does not support removing patterns")
|
|
192
|
+
|
|
193
|
+
def apply(self, cur_time: int) -> None:
|
|
194
|
+
pass
|
|
@@ -73,6 +73,11 @@ class SystemEvent(Event):
|
|
|
73
73
|
Current time (seconds since the start) in the simulation.
|
|
74
74
|
"""
|
|
75
75
|
|
|
76
|
+
def cleanup(self) -> None:
|
|
77
|
+
"""
|
|
78
|
+
Clean up any changes/modifications made by this event.
|
|
79
|
+
"""
|
|
80
|
+
|
|
76
81
|
@abstractmethod
|
|
77
82
|
def apply(self, cur_time: int) -> None:
|
|
78
83
|
"""
|