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 CHANGED
@@ -1 +1 @@
1
- 0.7.3
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
- self.__scenario_config = scenario_config
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 reset(self) -> ScadaData:
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.__scenario_config)
79
- self._sim_generator = self._scenario_sim.run_simulation_as_generator(support_abort=True)
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
- r = self._sim_generator.send(False)
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 r
142
+ return scada_data
90
143
  else:
91
- return r, False
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
  """
@@ -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 = array.data.flatten().tolist()
456
- rows = array.nonzero()[0].tolist()
457
- cols = array.nonzero()[1].tolist()
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
 
@@ -1,5 +1,6 @@
1
1
  from .system_event import *
2
2
  from .leakages import *
3
+ from .quality_events import *
3
4
  from .sensor_reading_event import *
4
5
  from .sensor_faults import *
5
6
  from .sensor_reading_attack import *
@@ -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
- raise ValueError("Either 'diameter' or 'area' must be given, " +
64
- "but not both at the same time")
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.__leaky_node_id = None
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.__leaky_node_id
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 foot/sec)\n EN_CFS (cubic meter/hr)")
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 = f"leak_pipe_{self.link_id}"
254
- new_node_id = f"leak_node_{self.link_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.__leaky_node_id = self._epanet_api.getNodeIndex(new_node_id)
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.__leaky_node_id = self._epanet_api.getNodeIndex(self.__node_id)
272
+ self.__leaky_node_idx = self._epanet_api.getNodeIndex(self.__node_id)
267
273
 
268
- self._epanet_api.setNodeEmitterCoeff(self.__leaky_node_id, 0.)
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.__leaky_node_id, 0.)
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.__leaky_node_id,
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
  """