epyt-flow 0.10.0__py3-none-any.whl → 0.12.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 (42) hide show
  1. epyt_flow/VERSION +1 -1
  2. epyt_flow/data/benchmarks/gecco_water_quality.py +2 -2
  3. epyt_flow/data/benchmarks/leakdb.py +40 -5
  4. epyt_flow/data/benchmarks/water_usage.py +4 -3
  5. epyt_flow/data/networks.py +27 -14
  6. epyt_flow/gym/__init__.py +0 -3
  7. epyt_flow/gym/scenario_control_env.py +11 -13
  8. epyt_flow/rest_api/scenario/control_handlers.py +118 -0
  9. epyt_flow/rest_api/scenario/event_handlers.py +114 -1
  10. epyt_flow/rest_api/scenario/handlers.py +33 -0
  11. epyt_flow/rest_api/server.py +14 -2
  12. epyt_flow/serialization.py +1 -0
  13. epyt_flow/simulation/__init__.py +0 -1
  14. epyt_flow/simulation/backend/__init__.py +1 -0
  15. epyt_flow/simulation/backend/my_epyt.py +1056 -0
  16. epyt_flow/simulation/events/actuator_events.py +7 -1
  17. epyt_flow/simulation/events/quality_events.py +3 -1
  18. epyt_flow/simulation/scada/scada_data.py +716 -5
  19. epyt_flow/simulation/scenario_config.py +1 -40
  20. epyt_flow/simulation/scenario_simulator.py +645 -119
  21. epyt_flow/simulation/sensor_config.py +18 -2
  22. epyt_flow/topology.py +24 -7
  23. epyt_flow/uncertainty/model_uncertainty.py +80 -62
  24. epyt_flow/uncertainty/sensor_noise.py +15 -4
  25. epyt_flow/uncertainty/uncertainties.py +71 -18
  26. epyt_flow/uncertainty/utils.py +40 -13
  27. epyt_flow/utils.py +45 -1
  28. epyt_flow/visualization/__init__.py +2 -0
  29. epyt_flow/visualization/scenario_visualizer.py +1240 -0
  30. epyt_flow/visualization/visualization_utils.py +738 -0
  31. {epyt_flow-0.10.0.dist-info → epyt_flow-0.12.0.dist-info}/METADATA +15 -4
  32. {epyt_flow-0.10.0.dist-info → epyt_flow-0.12.0.dist-info}/RECORD +35 -36
  33. {epyt_flow-0.10.0.dist-info → epyt_flow-0.12.0.dist-info}/WHEEL +1 -1
  34. epyt_flow/gym/control_gyms.py +0 -47
  35. epyt_flow/metrics.py +0 -466
  36. epyt_flow/models/__init__.py +0 -2
  37. epyt_flow/models/event_detector.py +0 -31
  38. epyt_flow/models/sensor_interpolation_detector.py +0 -118
  39. epyt_flow/simulation/scada/advanced_control.py +0 -138
  40. epyt_flow/simulation/scenario_visualizer.py +0 -1307
  41. {epyt_flow-0.10.0.dist-info → epyt_flow-0.12.0.dist-info/licenses}/LICENSE +0 -0
  42. {epyt_flow-0.10.0.dist-info → epyt_flow-0.12.0.dist-info}/top_level.txt +0 -0
@@ -5,9 +5,10 @@ import sys
5
5
  import os
6
6
  import pathlib
7
7
  import time
8
+ import itertools
8
9
  from datetime import timedelta
9
10
  from datetime import datetime
10
- from typing import Generator, Union
11
+ from typing import Generator, Union, Optional
11
12
  from copy import deepcopy
12
13
  import shutil
13
14
  import warnings
@@ -15,9 +16,8 @@ import random
15
16
  import math
16
17
  import uuid
17
18
  import numpy as np
18
- from epyt import epanet
19
- from epyt.epanet import ToolkitConstants
20
19
  from tqdm import tqdm
20
+ from epyt.epanet import ToolkitConstants
21
21
 
22
22
  from .scenario_config import ScenarioConfig
23
23
  from .sensor_config import SensorConfig, areaunit_to_id, massunit_to_id, qualityunit_to_id, \
@@ -62,7 +62,7 @@ class ScenarioSimulator():
62
62
 
63
63
  Attributes
64
64
  ----------
65
- epanet_api : `epyt.epanet`
65
+ epanet_api : :class:`~epyt_flow.simulation.backend.my_epyt.EPyT`
66
66
  API to EPANET and EPANET-MSX.
67
67
  _model_uncertainty : :class:`~epyt_flow.uncertainty.model_uncertainty.ModelUncertainty`, protected
68
68
  Model uncertainty.
@@ -112,7 +112,6 @@ class ScenarioSimulator():
112
112
  self._model_uncertainty = ModelUncertainty()
113
113
  self._sensor_noise = None
114
114
  self._sensor_config = None
115
- self._advanced_controls = []
116
115
  self._custom_controls = []
117
116
  self._simple_controls = []
118
117
  self._complex_controls = []
@@ -120,6 +119,7 @@ class ScenarioSimulator():
120
119
  self._sensor_reading_events = []
121
120
  self.__running_simulation = False
122
121
 
122
+ # Check availability of custom EPANET libraries
123
123
  custom_epanet_lib = None
124
124
  custom_epanetmsx_lib = None
125
125
  if sys.platform.startswith("linux") or sys.platform.startswith("darwin") :
@@ -136,48 +136,49 @@ class ScenarioSimulator():
136
136
  if os.path.isfile(os.path.join(path_to_custom_libs, libepanetmsx_name)):
137
137
  custom_epanetmsx_lib = os.path.join(path_to_custom_libs, libepanetmsx_name)
138
138
 
139
- with warnings.catch_warnings():
140
- # Treat all warnings as exceptions when trying to load .inp and .msx files
141
- warnings.simplefilter('error')
139
+ # Workaround for EPyT bug concerning parallel simulations (see EPyT issue #54):
140
+ # 1. Create random tmp folder (make sure it is unique!)
141
+ # 2. Copy .inp and .msx file there
142
+ # 3. Use those copies when loading EPyT
143
+ tmp_folder_path = os.path.join(get_temp_folder(), f"{random.randint(int(1e5), int(1e7))}{time.time()}")
144
+ pathlib.Path(tmp_folder_path).mkdir(parents=True, exist_ok=False)
142
145
 
143
- # Workaround for EPyT bug concerning parallel simulations (see EPyT issue #54):
144
- # 1. Create random tmp folder (make sure it is unique!)
145
- # 2. Copy .inp and .msx file there
146
- # 3. Use those copies when loading EPyT
147
- tmp_folder_path = os.path.join(get_temp_folder(), f"{random.randint(int(1e5), int(1e7))}{time.time()}")
148
- pathlib.Path(tmp_folder_path).mkdir(parents=True, exist_ok=False)
146
+ def __file_exists(file_in: str) -> bool:
147
+ try:
148
+ return pathlib.Path(file_in).is_file()
149
+ except Exception:
150
+ return False
149
151
 
150
- def __file_exists(file_in: str) -> bool:
151
- try:
152
- return pathlib.Path(file_in).is_file()
153
- except Exception:
154
- return False
152
+ if not __file_exists(self.__f_inp_in):
153
+ my_f_inp_in = self.__f_inp_in
154
+ self.__my_f_inp_in = None
155
+ else:
156
+ my_f_inp_in = os.path.join(tmp_folder_path, pathlib.Path(self.__f_inp_in).name)
157
+ shutil.copyfile(self.__f_inp_in, my_f_inp_in)
158
+ self.__my_f_inp_in = my_f_inp_in
155
159
 
156
- if not __file_exists(self.__f_inp_in):
157
- my_f_inp_in = self.__f_inp_in
158
- self.__my_f_inp_in = None
160
+ if self.__f_msx_in is not None:
161
+ if not __file_exists(self.__f_msx_in):
162
+ my_f_msx_in = self.__f_msx_in
159
163
  else:
160
- my_f_inp_in = os.path.join(tmp_folder_path, pathlib.Path(self.__f_inp_in).name)
161
- shutil.copyfile(self.__f_inp_in, my_f_inp_in)
162
- self.__my_f_inp_in = my_f_inp_in
164
+ my_f_msx_in = os.path.join(tmp_folder_path, pathlib.Path(self.__f_msx_in).name)
165
+ shutil.copyfile(self.__f_msx_in, my_f_msx_in)
166
+ else:
167
+ my_f_msx_in = None
163
168
 
164
- if self.__f_msx_in is not None:
165
- if not __file_exists(self.__f_msx_in):
166
- my_f_msx_in = self.__f_msx_in
167
- else:
168
- my_f_msx_in = os.path.join(tmp_folder_path, pathlib.Path(self.__f_msx_in).name)
169
- shutil.copyfile(self.__f_msx_in, my_f_msx_in)
170
- else:
171
- my_f_msx_in = None
169
+ from .backend import EPyT # Workaround: Sphinx autodoc "importlib.import_module TypeError: __mro_entries__"
170
+ self.epanet_api = EPyT(my_f_inp_in, ph=self.__f_msx_in is None,
171
+ customlib=custom_epanet_lib, loadfile=True,
172
+ display_msg=epanet_verbose,
173
+ display_warnings=False)
172
174
 
173
- self.epanet_api = epanet(my_f_inp_in, ph=self.__f_msx_in is None,
174
- customlib=custom_epanet_lib, loadfile=True,
175
- display_msg=epanet_verbose,
176
- display_warnings=False)
175
+ if self.__f_msx_in is not None:
176
+ self.epanet_api.loadMSXFile(my_f_msx_in, customMSXlib=custom_epanetmsx_lib)
177
177
 
178
- if self.__f_msx_in is not None:
179
- self.epanet_api.loadMSXFile(my_f_msx_in, customMSXlib=custom_epanetmsx_lib)
178
+ # Do not raise exceptions in the case of EPANET warnings and errors
179
+ self.epanet_api.set_error_handling(False)
180
180
 
181
+ # Parse and initialize scenario
181
182
  self._simple_controls = self._parse_simple_control_rules()
182
183
  self._complex_controls = self._parse_complex_control_rules()
183
184
 
@@ -190,9 +191,6 @@ class ScenarioSimulator():
190
191
  self._sensor_noise = scenario_config.sensor_noise
191
192
  self._sensor_config = scenario_config.sensor_config
192
193
 
193
- if scenario_config.advanced_controls is not None:
194
- for control in scenario_config.advanced_controls:
195
- self.add_advanced_control(control)
196
194
  for control in scenario_config.custom_controls:
197
195
  self.add_custom_control(control)
198
196
  for control in scenario_config.simple_controls:
@@ -342,22 +340,6 @@ class ScenarioSimulator():
342
340
 
343
341
  self._sensor_config = sensor_config
344
342
 
345
- @property
346
- def advanced_controls(self) -> list:
347
- """
348
- Returns all advanced control modules.
349
-
350
- Returns
351
- -------
352
- list[:class:`~epyt_flow.simulation.scada.advanced_control.AdvancedControlModule`]
353
- All advanced control modules.
354
- """
355
- warnings.warn("'AdvancedControlModule' is deprecated and will be removed in a " +
356
- "future release -- use 'CustomControlModule' instead")
357
- self._adapt_to_network_changes()
358
-
359
- return deepcopy(self._advanced_controls)
360
-
361
343
  @property
362
344
  def custom_controls(self) -> list[CustomControlModule]:
363
345
  """
@@ -990,7 +972,6 @@ class ScenarioSimulator():
990
972
  return ScenarioConfig(f_inp_in=self.__f_inp_in, f_msx_in=self.__f_msx_in,
991
973
  general_params=general_params, sensor_config=self.sensor_config,
992
974
  memory_consumption_estimate=self.estimate_memory_consumption(),
993
- advanced_controls=None if len(self._advanced_controls) == 0 else self.advanced_controls,
994
975
  custom_controls=self.custom_controls,
995
976
  simple_controls=self.simple_controls,
996
977
  complex_controls=self.complex_controls,
@@ -1041,6 +1022,7 @@ class ScenarioSimulator():
1041
1022
  nodes_type = [self.epanet_api.TYPENODE[i] for i in self.epanet_api.getNodeTypeIndex()]
1042
1023
  nodes_coord = [self.epanet_api.api.ENgetcoord(node_idx)
1043
1024
  for node_idx in self.epanet_api.getNodeIndex()]
1025
+ nodes_comments = self.epanet_api.getNodeComment()
1044
1026
  node_tank_names = self.epanet_api.getNodeTankNameID()
1045
1027
 
1046
1028
  links_id = self.epanet_api.getLinkNameID()
@@ -1060,21 +1042,29 @@ class ScenarioSimulator():
1060
1042
 
1061
1043
  # Build graph describing the topology
1062
1044
  nodes = []
1063
- for node_id, node_elevation, node_type, node_coord in zip(nodes_id, nodes_elevation,
1064
- nodes_type, nodes_coord):
1045
+ for node_id, node_elevation, node_type, \
1046
+ node_coord, node_comment in zip(nodes_id, nodes_elevation, nodes_type, nodes_coord,
1047
+ nodes_comments):
1065
1048
  node_info = {"elevation": node_elevation,
1066
1049
  "coord": node_coord,
1050
+ "comment": node_comment,
1067
1051
  "type": node_type}
1068
1052
  if node_type == "TANK":
1069
1053
  node_tank_idx = node_tank_names.index(node_id) + 1
1070
1054
  node_info["diameter"] = float(self.epanet_api.getNodeTankDiameter(node_tank_idx))
1055
+ node_info["volume"] = float(self.epanet_api.getNodeTankVolume(node_tank_idx))
1056
+ node_info["max_level"] = float(self.epanet_api.getNodeTankMaximumWaterLevel(node_tank_idx))
1057
+ node_info["min_level"] = float(self.epanet_api.getNodeTankMinimumWaterLevel(node_tank_idx))
1058
+ node_info["mixing_fraction"] = float(self.epanet_api.getNodeTankMixingFraction(node_tank_idx))
1059
+ #node_info["mixing_model"] = int(self.epanet_api.getNodeTankMixingModelCode(node_tank_idx)[0])
1071
1060
 
1072
1061
  nodes.append((node_id, node_info))
1073
1062
 
1074
1063
  links = []
1075
- for link_id, link_type, link, diameter, length, roughness_coeff, bulk_coeff, wall_coeff, loss_coeff \
1076
- in zip(links_id, links_type, links_data, links_diameter, links_length,
1077
- links_roughness_coeff, links_bulk_coeff, links_wall_coeff, links_loss_coeff):
1064
+ for link_id, link_type, link, diameter, length, roughness_coeff, bulk_coeff, \
1065
+ wall_coeff, loss_coeff in zip(links_id, links_type, links_data, links_diameter,
1066
+ links_length, links_roughness_coeff, links_bulk_coeff,
1067
+ links_wall_coeff, links_loss_coeff):
1078
1068
  links.append((link_id, list(link),
1079
1069
  {"type": link_type, "diameter": diameter, "length": length,
1080
1070
  "roughness_coeff": roughness_coeff,
@@ -1110,7 +1100,7 @@ class ScenarioSimulator():
1110
1100
 
1111
1101
  The default is None.
1112
1102
  """
1113
- from .scenario_visualizer import ScenarioVisualizer
1103
+ from ..visualization import ScenarioVisualizer
1114
1104
  ScenarioVisualizer(self).show_plot(export_to_file)
1115
1105
 
1116
1106
  def randomize_demands(self) -> None:
@@ -1155,7 +1145,14 @@ class ScenarioSimulator():
1155
1145
  `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
1156
1146
  The pattern -- i.e. multiplier factors over time.
1157
1147
  """
1148
+ if not isinstance(pattern_id, str):
1149
+ raise TypeError("'pattern_id' must be an instance of 'str' " +
1150
+ f"but not of '{type(pattern_id)}'")
1151
+
1158
1152
  pattern_idx = self.epanet_api.getPatternIndex(pattern_id)
1153
+ if pattern_idx == 0:
1154
+ raise ValueError(f"Unknown pattern '{pattern_id}'")
1155
+
1159
1156
  pattern_length = self.epanet_api.getPatternLengths(pattern_idx)
1160
1157
  return np.array([self.epanet_api.getPatternValue(pattern_idx, t+1)
1161
1158
  for t in range(pattern_length)])
@@ -1183,7 +1180,41 @@ class ScenarioSimulator():
1183
1180
  raise ValueError(f"Inconsistent pattern shape '{pattern.shape}' " +
1184
1181
  "detected. Expected a one dimensional array!")
1185
1182
 
1186
- self.epanet_api.addPattern(pattern_id, pattern)
1183
+ pattern_idx = self.epanet_api.addPattern(pattern_id, pattern)
1184
+ if pattern_idx == 0:
1185
+ raise RuntimeError("Failed to add pattern! " +
1186
+ "Maybe pattern name contains invalid characters or is too long?")
1187
+
1188
+ def get_node_base_demand(self, node_id: str) -> float:
1189
+ """
1190
+ Returns the base demand of a given node. None, if there does not exist any base demand.
1191
+
1192
+ Note that base demands are summed up in the case of different demand categories.
1193
+
1194
+ Parameters
1195
+ ----------
1196
+ node_id : `str`
1197
+ ID of the node.
1198
+
1199
+ Returns
1200
+ -------
1201
+ `float`
1202
+ Base demand.
1203
+ """
1204
+ if node_id not in self._sensor_config.nodes:
1205
+ raise ValueError(f"Unknown node '{node_id}'")
1206
+
1207
+ node_idx = self.epanet_api.getNodeIndex(node_id)
1208
+ n_demand_categories = self.epanet_api.getNodeDemandCategoriesNumber(node_idx)
1209
+
1210
+ if n_demand_categories == 0:
1211
+ return None
1212
+ else:
1213
+ base_demand = 0
1214
+ for demand_category in range(n_demand_categories):
1215
+ base_demand += self.epanet_api.getNodeBaseDemands(node_idx)[demand_category + 1]
1216
+
1217
+ return base_demand
1187
1218
 
1188
1219
  def get_node_demand_pattern(self, node_id: str) -> np.ndarray:
1189
1220
  """
@@ -1200,6 +1231,12 @@ class ScenarioSimulator():
1200
1231
  `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
1201
1232
  The demand pattern -- i.e. multiplier factors over time.
1202
1233
  """
1234
+ if not isinstance(node_id, str):
1235
+ raise TypeError("'node_id' must be an instance of 'str' " +
1236
+ f"but not of '{type(node_id)}'")
1237
+ if node_id not in self._sensor_config.nodes:
1238
+ raise ValueError(f"Unknown node '{node_id}'")
1239
+
1203
1240
  node_idx = self.epanet_api.getNodeIndex(node_id)
1204
1241
  demand_category = self.epanet_api.getNodeDemandCategoriesNumber()[node_idx]
1205
1242
  demand_pattern_id = self.epanet_api.getNodeDemandPatternNameID()[demand_category][node_idx - 1]
@@ -1257,25 +1294,6 @@ class ScenarioSimulator():
1257
1294
  self.epanet_api.setNodeJunctionData(node_idx, self.epanet_api.getNodeElevations(node_idx),
1258
1295
  base_demand, demand_pattern_id)
1259
1296
 
1260
- def add_advanced_control(self, control) -> None:
1261
- """
1262
- Adds an advanced control module to the scenario simulation.
1263
-
1264
- Parameters
1265
- ----------
1266
- control : :class:`~epyt_flow.simulation.scada.advanced_control.AdvancedControlModule`
1267
- Advanced control module.
1268
- """
1269
- self._adapt_to_network_changes()
1270
-
1271
- from .scada.advanced_control import AdvancedControlModule
1272
- if not isinstance(control, AdvancedControlModule):
1273
- raise TypeError("'control' must be an instance of " +
1274
- "'epyt_flow.simulation.scada.AdvancedControlModule' not of " +
1275
- f"'{type(control)}'")
1276
-
1277
- self._advanced_controls.append(control)
1278
-
1279
1297
  def add_custom_control(self, control: CustomControlModule) -> None:
1280
1298
  """
1281
1299
  Adds a custom control module to the scenario simulation.
@@ -1934,9 +1952,6 @@ class ScenarioSimulator():
1934
1952
  for event in self._system_events:
1935
1953
  event.reset()
1936
1954
 
1937
- if self._advanced_controls is not None:
1938
- for c in self._advanced_controls:
1939
- c.init(self.epanet_api)
1940
1955
  if self._custom_controls is not None:
1941
1956
  for control in self._custom_controls:
1942
1957
  control.init(self.epanet_api)
@@ -2006,6 +2021,7 @@ class ScenarioSimulator():
2006
2021
  result[data_type] = None
2007
2022
 
2008
2023
  return ScadaData(**result,
2024
+ network_topo=self.get_topology(),
2009
2025
  sensor_config=self._sensor_config,
2010
2026
  sensor_reading_events=self._sensor_reading_events,
2011
2027
  sensor_noise=self._sensor_noise,
@@ -2069,6 +2085,8 @@ class ScenarioSimulator():
2069
2085
  reporting_time_step = self.epanet_api.getTimeReportingStep()
2070
2086
  hyd_time_step = self.epanet_api.getTimeHydraulicStep()
2071
2087
 
2088
+ network_topo = self.get_topology()
2089
+
2072
2090
  if use_quality_time_step_as_reporting_time_step is True:
2073
2091
  quality_time_step = self.epanet_api.getMSXTimeStep()
2074
2092
  reporting_time_step = quality_time_step
@@ -2159,7 +2177,7 @@ class ScenarioSimulator():
2159
2177
  "surface_species_concentration_raw": surface_species_concentrations,
2160
2178
  "sensor_readings_time": np.array([0])}
2161
2179
  else:
2162
- data = ScadaData(sensor_config=self._sensor_config,
2180
+ data = ScadaData(network_topo=network_topo, sensor_config=self._sensor_config,
2163
2181
  bulk_species_node_concentration_raw=bulk_species_node_concentrations,
2164
2182
  bulk_species_link_concentration_raw=bulk_species_link_concentrations,
2165
2183
  surface_species_concentration_raw=surface_species_concentrations,
@@ -2203,7 +2221,8 @@ class ScenarioSimulator():
2203
2221
  "surface_species_concentration_raw": surface_species_concentrations,
2204
2222
  "sensor_readings_time": np.array([total_time])}
2205
2223
  else:
2206
- data = ScadaData(sensor_config=self._sensor_config,
2224
+ data = ScadaData(network_topo=network_topo,
2225
+ sensor_config=self._sensor_config,
2207
2226
  bulk_species_node_concentration_raw=
2208
2227
  bulk_species_node_concentrations,
2209
2228
  bulk_species_link_concentration_raw=
@@ -2284,6 +2303,7 @@ class ScenarioSimulator():
2284
2303
  result[data_type] = np.concatenate(result[data_type], axis=0)
2285
2304
 
2286
2305
  return ScadaData(**result,
2306
+ network_topo=self.get_topology(),
2287
2307
  sensor_config=self._sensor_config,
2288
2308
  sensor_reading_events=self._sensor_reading_events,
2289
2309
  sensor_noise=self._sensor_noise,
@@ -2343,6 +2363,8 @@ class ScenarioSimulator():
2343
2363
  requested_time_step = quality_time_step
2344
2364
  reporting_time_step = quality_time_step
2345
2365
 
2366
+ network_topo = self.get_topology()
2367
+
2346
2368
  self.epanet_api.useHydraulicFile(hyd_file_in)
2347
2369
 
2348
2370
  self.epanet_api.openQualityAnalysis()
@@ -2371,10 +2393,11 @@ class ScenarioSimulator():
2371
2393
  pass
2372
2394
 
2373
2395
  # Compute current time step
2374
- t = self.epanet_api.runQualityAnalysis()
2396
+ t = self.epanet_api.api.ENrunQ()
2375
2397
  total_time = t
2376
2398
 
2377
2399
  # Fetch data
2400
+ error_code = self.epanet_api.get_last_error_code()
2378
2401
  quality_node_data = self.epanet_api.getNodeActualQuality().reshape(1, -1)
2379
2402
  quality_link_data = self.epanet_api.getLinkActualQuality().reshape(1, -1)
2380
2403
 
@@ -2383,12 +2406,15 @@ class ScenarioSimulator():
2383
2406
  if return_as_dict is True:
2384
2407
  data = {"node_quality_data_raw": quality_node_data,
2385
2408
  "link_quality_data_raw": quality_link_data,
2386
- "sensor_readings_time": np.array([total_time])}
2409
+ "sensor_readings_time": np.array([total_time]),
2410
+ "warnings_code": np.array([error_code])}
2387
2411
  else:
2388
- data = ScadaData(sensor_config=self._sensor_config,
2412
+ data = ScadaData(network_topo=network_topo,
2413
+ sensor_config=self._sensor_config,
2389
2414
  node_quality_data_raw=quality_node_data,
2390
2415
  link_quality_data_raw=quality_link_data,
2391
2416
  sensor_readings_time=np.array([total_time]),
2417
+ warnings_code=np.array([error_code]),
2392
2418
  sensor_reading_events=self._sensor_reading_events,
2393
2419
  sensor_noise=self._sensor_noise,
2394
2420
  frozen_sensor_config=frozen_sensor_config)
@@ -2401,9 +2427,9 @@ class ScenarioSimulator():
2401
2427
  yield data
2402
2428
 
2403
2429
  # Next
2404
- tstep = self.epanet_api.stepQualityAnalysisTimeLeft()
2430
+ tstep = self.epanet_api.api.ENstepQ()
2405
2431
 
2406
- self.epanet_api.closeHydraulicAnalysis()
2432
+ self.epanet_api.closeQualityAnalysis()
2407
2433
 
2408
2434
  def run_hydraulic_simulation(self, hyd_export: str = None, verbose: bool = False,
2409
2435
  frozen_sensor_config: bool = False) -> ScadaData:
@@ -2461,6 +2487,7 @@ class ScenarioSimulator():
2461
2487
  result[data_type] = np.concatenate(result[data_type], axis=0)
2462
2488
 
2463
2489
  result = ScadaData(**result,
2490
+ network_topo=self.get_topology(),
2464
2491
  sensor_config=self._sensor_config,
2465
2492
  sensor_reading_events=self._sensor_reading_events,
2466
2493
  sensor_noise=self._sensor_noise,
@@ -2524,8 +2551,8 @@ class ScenarioSimulator():
2524
2551
 
2525
2552
  self.__running_simulation = True
2526
2553
 
2527
- self.epanet_api.openHydraulicAnalysis()
2528
- self.epanet_api.openQualityAnalysis()
2554
+ self.epanet_api.api.ENopenH()
2555
+ self.epanet_api.api.ENopenQ()
2529
2556
  self.epanet_api.initializeHydraulicAnalysis(ToolkitConstants.EN_SAVE)
2530
2557
  self.epanet_api.initializeQualityAnalysis(ToolkitConstants.EN_SAVE)
2531
2558
 
@@ -2533,6 +2560,8 @@ class ScenarioSimulator():
2533
2560
  reporting_time_start = self.epanet_api.getTimeReportingStart()
2534
2561
  reporting_time_step = self.epanet_api.getTimeReportingStep()
2535
2562
 
2563
+ network_topo = self.get_topology()
2564
+
2536
2565
  if verbose is True:
2537
2566
  print("Running EPANET ...")
2538
2567
  n_iterations = math.ceil(self.epanet_api.getTimeSimulationDuration() /
@@ -2562,8 +2591,11 @@ class ScenarioSimulator():
2562
2591
  event.step(total_time + tstep)
2563
2592
 
2564
2593
  # Compute current time step
2565
- t = self.epanet_api.runHydraulicAnalysis()
2566
- self.epanet_api.runQualityAnalysis()
2594
+ t = self.epanet_api.api.ENrunH()
2595
+ error_code = self.epanet_api.get_last_error_code()
2596
+ self.epanet_api.api.ENrunQ()
2597
+ if error_code == 0:
2598
+ error_code = self.epanet_api.get_last_error_code()
2567
2599
  total_time = t
2568
2600
 
2569
2601
  # Fetch data
@@ -2582,7 +2614,8 @@ class ScenarioSimulator():
2582
2614
  link_valve_idx = self.epanet_api.getLinkValveIndex()
2583
2615
  valves_state_data = self.epanet_api.getLinkStatus(link_valve_idx).reshape(1, -1)
2584
2616
 
2585
- scada_data = ScadaData(sensor_config=self._sensor_config,
2617
+ scada_data = ScadaData(network_topo=network_topo,
2618
+ sensor_config=self._sensor_config,
2586
2619
  pressure_data_raw=pressure_data,
2587
2620
  flow_data_raw=flow_data,
2588
2621
  demand_data_raw=demand_data,
@@ -2594,6 +2627,7 @@ class ScenarioSimulator():
2594
2627
  pumps_energy_usage_data_raw=pumps_energy_usage_data,
2595
2628
  pumps_efficiency_data_raw=pumps_efficiency_data,
2596
2629
  sensor_readings_time=np.array([total_time]),
2630
+ warnings_code=np.array([error_code]),
2597
2631
  sensor_reading_events=self._sensor_reading_events,
2598
2632
  sensor_noise=self._sensor_noise,
2599
2633
  frozen_sensor_config=frozen_sensor_config)
@@ -2611,7 +2645,8 @@ class ScenarioSimulator():
2611
2645
  "tanks_volume_data_raw": tanks_volume_data,
2612
2646
  "pumps_energy_usage_data_raw": pumps_energy_usage_data,
2613
2647
  "pumps_efficiency_data_raw": pumps_efficiency_data,
2614
- "sensor_readings_time": np.array([total_time])}
2648
+ "sensor_readings_time": np.array([total_time]),
2649
+ "warnings_code": np.array([error_code])}
2615
2650
  else:
2616
2651
  data = scada_data
2617
2652
 
@@ -2623,17 +2658,15 @@ class ScenarioSimulator():
2623
2658
  yield data
2624
2659
 
2625
2660
  # Apply control modules
2626
- for control in self._advanced_controls:
2627
- control.step(scada_data)
2628
2661
  for control in self._custom_controls:
2629
2662
  control.step(scada_data)
2630
2663
 
2631
2664
  # Next
2632
- tstep = self.epanet_api.nextHydraulicAnalysisStep()
2633
- self.epanet_api.nextQualityAnalysisStep()
2665
+ tstep = self.epanet_api.api.ENnextH()
2666
+ self.epanet_api.api.ENnextQ()
2634
2667
 
2635
- self.epanet_api.closeQualityAnalysis()
2636
- self.epanet_api.closeHydraulicAnalysis()
2668
+ self.epanet_api.api.ENcloseQ()
2669
+ self.epanet_api.api.ENcloseH()
2637
2670
 
2638
2671
  self.__running_simulation = False
2639
2672
 
@@ -2643,16 +2676,6 @@ class ScenarioSimulator():
2643
2676
  self.__running_simulation = False
2644
2677
  raise ex
2645
2678
 
2646
- def run_simulation_as_generator(self, hyd_export: str = None, verbose: bool = False,
2647
- support_abort: bool = False,
2648
- return_as_dict: bool = False,
2649
- frozen_sensor_config: bool = False,
2650
- ) -> Generator[Union[ScadaData, dict], bool, None]:
2651
- warnings.warn("'run_simulation_as_generator' is deprecated and will be removed in " +
2652
- "future releases -- use 'run_hydraulic_simulation_as_generator' instead")
2653
- return self.run_hydraulic_simulation_as_generator(hyd_export, verbose, support_abort,
2654
- return_as_dict, frozen_sensor_config)
2655
-
2656
2679
  def run_simulation(self, hyd_export: str = None, verbose: bool = False,
2657
2680
  frozen_sensor_config: bool = False) -> ScadaData:
2658
2681
  """
@@ -2760,6 +2783,7 @@ class ScenarioSimulator():
2760
2783
 
2761
2784
  def set_general_parameters(self, demand_model: dict = None, simulation_duration: int = None,
2762
2785
  hydraulic_time_step: int = None, quality_time_step: int = None,
2786
+ advanced_quality_time_step: int = None,
2763
2787
  reporting_time_step: int = None, reporting_time_start: int = None,
2764
2788
  flow_units_id: int = None, quality_model: dict = None) -> None:
2765
2789
  """
@@ -2792,6 +2816,12 @@ class ScenarioSimulator():
2792
2816
  Quality time step -- i.e. the interval at which qualities are computed.
2793
2817
  Should be much smaller than the hydraulic time step!
2794
2818
 
2819
+ The default is None.
2820
+ advanced_quality_time_step : `ìnt`, optional
2821
+ Time step in the advanced quality simuliation -- i.e. EPANET-MSX simulation.
2822
+ This number specifies the interval at which all species concentrations are.
2823
+ Should be much smaller than the hydraulic time step!
2824
+
2795
2825
  The default is None.
2796
2826
  reporting_time_step : `int`, optional
2797
2827
  Report time step -- i.e. the interval at which hydraulics and quality states are
@@ -2904,6 +2934,14 @@ class ScenarioSimulator():
2904
2934
  "greater than the hydraulic time step")
2905
2935
  self.epanet_api.setTimeQualityStep(quality_time_step)
2906
2936
 
2937
+ if advanced_quality_time_step is not None:
2938
+ if not isinstance(advanced_quality_time_step, int) or \
2939
+ advanced_quality_time_step <= 0 or \
2940
+ advanced_quality_time_step > self.epanet_api.getTimeHydraulicStep():
2941
+ raise ValueError("'advanced_quality_time_step' must be a positive integer " +
2942
+ "that is not greater than the hydraulic time step")
2943
+ self.epanet_api.setMSXTimeStep(advanced_quality_time_step)
2944
+
2907
2945
  if quality_model is not None:
2908
2946
  if quality_model["type"] == "NONE":
2909
2947
  self.epanet_api.setQualityType("none")
@@ -2945,6 +2983,202 @@ class ScenarioSimulator():
2945
2983
 
2946
2984
  return list(set(events_times))
2947
2985
 
2986
+ def set_pump_energy_price_pattern(self, pump_id: str, pattern: np.ndarray,
2987
+ pattern_id: Optional[str] = None) -> None:
2988
+ """
2989
+ Specifies/sets the energy price pattern of a given pump.
2990
+
2991
+ Overwrites any existing (energy price) patterns of the given pump.
2992
+
2993
+ Parameters
2994
+ ----------
2995
+ pump_id : `str`
2996
+ ID of the pump.
2997
+ pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
2998
+ Pattern of multipliers.
2999
+ pattern_id : `str`, optional
3000
+ ID of the pattern.
3001
+ If not specified, 'energy_price_{pump_id}' will be used as the pattern ID.
3002
+
3003
+ The default is None.
3004
+ """
3005
+ if not isinstance(pump_id, str):
3006
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
3007
+ if pump_id not in self._sensor_config.pumps:
3008
+ raise ValueError(f"Unknown pump '{pump_id}'")
3009
+ if not isinstance(pattern, np.ndarray):
3010
+ raise TypeError("'pattern' must be an instance of 'numpy.ndarray' " +
3011
+ f"but no of '{type(pattern)}'")
3012
+ if len(pattern.shape) > 1:
3013
+ raise ValueError("'pattern' must be 1-dimensional")
3014
+ if pattern_id is not None:
3015
+ if not isinstance(pattern_id, str):
3016
+ raise TypeError("'pattern_id' must be an instance of 'str' " +
3017
+ f"but not of '{type(pattern_id)}'")
3018
+ else:
3019
+ pattern_id = f"energy_price_{pump_id}"
3020
+
3021
+ pattern_idx = self.epanet_api.getPatternIndex(pattern_id)
3022
+ if pattern_idx != 0:
3023
+ warnings.warn(f"Overwriting existing pattern '{pattern_id}'")
3024
+
3025
+ pump_idx = self.epanet_api.getLinkIndex(pump_id)
3026
+ pattern_idx = self.epanet_api.getLinkPumpEPat(pump_idx)
3027
+ if pattern_idx != 0:
3028
+ warnings.warn(f"Overwriting existing energy price pattern of pump '{pump_id}'")
3029
+
3030
+ self.add_pattern(pattern_id, pattern)
3031
+ pattern_idx = self.epanet_api.getPatternIndex(pattern_id)
3032
+ self.epanet_api.setLinkPumpEPat(pattern_idx)
3033
+
3034
+ def get_pump_energy_price_pattern(self, pump_id: str) -> np.ndarray:
3035
+ """
3036
+ Returns the energy price pattern of a given pump.
3037
+
3038
+ Parameters
3039
+ ----------
3040
+ pump_id : `str`
3041
+ ID of the pump.
3042
+
3043
+ Returns
3044
+ -------
3045
+ `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
3046
+ Energy price pattern. None, if none exists.
3047
+ """
3048
+ if not isinstance(pump_id, str):
3049
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
3050
+ if pump_id not in self._sensor_config.pumps:
3051
+ raise ValueError(f"Unknown pump '{pump_id}'")
3052
+
3053
+ pump_idx = self.epanet_api.getLinkIndex(pump_id)
3054
+ pattern_idx = self.epanet_api.getLinkPumpEPat(pump_idx)
3055
+ if pattern_idx == 0:
3056
+ return None
3057
+ else:
3058
+ pattern_length = self.epanet_api.getPatternLengths(pattern_idx)
3059
+ return np.array([self.epanet_api.getPatternValue(pattern_idx, t+1)
3060
+ for t in range(pattern_length)])
3061
+
3062
+ def get_pump_energy_price(self, pump_id: str) -> float:
3063
+ """
3064
+ Returns the energy price of a given pump.
3065
+
3066
+ Parameters
3067
+ ----------
3068
+ pump_id : `str`
3069
+ ID of the pump.
3070
+
3071
+ Returns
3072
+ -------
3073
+ `float`
3074
+ Energy price.
3075
+ """
3076
+ if not isinstance(pump_id, str):
3077
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
3078
+ if pump_id not in self._sensor_config.pumps:
3079
+ raise ValueError(f"Unknown pump '{pump_id}'")
3080
+
3081
+ pump_idx = self.epanet_api.getLinkIndex(pump_id)
3082
+ return self.epanet_api.getLinkPumpECost(pump_idx)
3083
+
3084
+ def set_pump_energy_price(self, pump_id, price: float) -> None:
3085
+ """
3086
+ Sets the energy price of a given pump.
3087
+
3088
+ Parameters
3089
+ ----------
3090
+ pump_id : `str`
3091
+ ID of the pump.
3092
+ price : `float`
3093
+ Energy price.
3094
+ """
3095
+ if not isinstance(pump_id, str):
3096
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
3097
+ if pump_id not in self._sensor_config.pumps:
3098
+ raise ValueError(f"Unknown pump '{pump_id}'")
3099
+ if not isinstance(price, float):
3100
+ raise TypeError(f"'price' must be an instance of 'float' but not of '{type(price)}'")
3101
+ if price <= 0:
3102
+ raise ValueError("'price' must be positive")
3103
+
3104
+ pump_idx = self._sensor_config.pumps.index(pump_id) + 1
3105
+ pumps_energy_price = self.epanet_api.getLinkPumpECost()
3106
+ pumps_energy_price[pump_idx - 1] = price
3107
+
3108
+ self.epanet_api.setLinkPumpECost(pumps_energy_price)
3109
+
3110
+ def set_initial_link_status(self, link_id: str, status: int) -> None:
3111
+ """
3112
+ Sets the initial status (open or closed) of a given link.
3113
+
3114
+ Parameters
3115
+ ----------
3116
+ link_id : `str`
3117
+ ID of the link.
3118
+ status : `int`
3119
+ Initial status of the link. Must be one of the following EPANET constants:
3120
+
3121
+ - EN_CLOSED = 0
3122
+ - EN_OPEN = 1
3123
+ """
3124
+ if not isinstance(link_id, str):
3125
+ raise TypeError(f"'link_id' must be an instance of 'str' but not of '{type(link_id)}'")
3126
+ if link_id not in self._sensor_config.pumps:
3127
+ raise ValueError("Invalid link ID '{link_id}'")
3128
+ if not isinstance(status, int):
3129
+ raise TypeError(f"'status' must be an instance of 'int' but not of '{type(status)}'")
3130
+ if status not in [ActuatorConstants.EN_CLOSED, ActuatorConstants.EN_OPEN]:
3131
+ raise ValueError(f"Invalid link status '{status}'")
3132
+
3133
+ link_idx = self.epanet_api.getLinkIndex(link_id)
3134
+ self.epanet_api.setLinkInitialStatus(link_idx, status)
3135
+
3136
+ def set_initial_pump_speed(self, pump_id: str, speed: float) -> None:
3137
+ """
3138
+ Sets the initial pump speed of a given pump.
3139
+
3140
+ Parameters
3141
+ ----------
3142
+ pump_id : `str`
3143
+ ID of the pump.
3144
+ speed : `float`
3145
+ Initial speed of the pump.
3146
+ """
3147
+ if not isinstance(pump_id, str):
3148
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
3149
+ if pump_id not in self._sensor_config.pumps:
3150
+ raise ValueError("Invalid pump ID '{tank_id}'")
3151
+ if not isinstance(speed, float):
3152
+ raise TypeError(f"'speed' must be an instance of 'int' but not of '{type(speed)}'")
3153
+ if speed < 0:
3154
+ raise ValueError("'speed' can not be negative")
3155
+
3156
+ pump_idx = self.epanet_api.getLinkIndex(pump_id)
3157
+ self.epanet_api.setLinkInitialSetting(pump_idx, speed)
3158
+
3159
+ def set_initial_tank_level(self, tank_id, level: int) -> None:
3160
+ """
3161
+ Sets the initial water level of a given tank.
3162
+
3163
+ Parameters
3164
+ ----------
3165
+ tank_id : `str`
3166
+ ID of the tank.
3167
+ level : `int`
3168
+ Initial water level in the tank.
3169
+ """
3170
+ if not isinstance(tank_id, str):
3171
+ raise TypeError(f"'tank_id' must be an instance of 'str' but not of '{type(tank_id)}'")
3172
+ if tank_id not in self._sensor_config.tanks:
3173
+ raise ValueError("Invalid tank ID '{tank_id}'")
3174
+ if not isinstance(level, int):
3175
+ raise TypeError(f"'level' must be an instance of 'int' but not of '{type(level)}'")
3176
+ if level < 0:
3177
+ raise ValueError("'level' can not be negative")
3178
+
3179
+ tank_idx = self.epanet_api.getNodeIndex(tank_id)
3180
+ self.epanet_api.setNodeTankInitialLevel(tank_idx, level)
3181
+
2948
3182
  def __warn_if_quality_set(self):
2949
3183
  qual_info = self.epanet_api.getQualityInfo()
2950
3184
  if qual_info.QualityCode != ToolkitConstants.EN_NONE:
@@ -3057,7 +3291,7 @@ class ScenarioSimulator():
3057
3291
  if pattern is None and pattern_id is None:
3058
3292
  raise ValueError("'pattern_id' and 'pattern' can not be None at the same time")
3059
3293
  if pattern_id is None:
3060
- pattern_id = f"quality_source_pattern_node={node_id}"
3294
+ pattern_id = f"qual_src_pat_{node_id}"
3061
3295
 
3062
3296
  node_idx = self.epanet_api.getNodeIndex(node_id)
3063
3297
 
@@ -3065,11 +3299,220 @@ class ScenarioSimulator():
3065
3299
  pattern_idx = self.epanet_api.getPatternIndex(pattern_id)
3066
3300
  else:
3067
3301
  pattern_idx = self.epanet_api.addPattern(pattern_id, pattern)
3302
+ if pattern_idx == 0:
3303
+ raise RuntimeError("Failed to add/get pattern! " +
3304
+ "Maybe pattern name contains invalid characters or is too long?")
3068
3305
 
3069
3306
  self.epanet_api.api.ENsetnodevalue(node_idx, ToolkitConstants.EN_SOURCETYPE, source_type)
3070
3307
  self.epanet_api.setNodeSourceQuality(node_idx, source_strength)
3071
3308
  self.epanet_api.setNodeSourcePatternIndex(node_idx, pattern_idx)
3072
3309
 
3310
+ def set_initial_node_quality(self, node_id: str, initial_quality: float) -> None:
3311
+ """
3312
+ Specifies the initial quality at a given node.
3313
+ Quality represents concentration for chemicals, hours for water age,
3314
+ or percent for source tracing.
3315
+
3316
+ Parameters
3317
+ ----------
3318
+ node_id : `str`
3319
+ ID of the node.
3320
+ initial_quality : `float`
3321
+ Initial node quality.
3322
+ """
3323
+ self.set_quality_parameters(initial_quality={node_id, initial_quality})
3324
+
3325
+ def set_quality_parameters(self, initial_quality: Optional[dict[str, float]] = None,
3326
+ order_wall: Optional[int] = None, order_tank: Optional[int] = None,
3327
+ order_bulk: Optional[int] = None,
3328
+ global_wall_reaction_coefficient: Optional[float] = None,
3329
+ global_bulk_reaction_coefficient: Optional[float] = None,
3330
+ local_wall_reaction_coefficient: Optional[dict[str, float]] = None,
3331
+ local_bulk_reaction_coefficient: Optional[dict[str, float]] = None,
3332
+ local_tank_reaction_coefficient: Optional[dict[str, float]] = None,
3333
+ limiting_potential: Optional[float] = None) -> None:
3334
+ """
3335
+ Specifies some parameters of the EPANET quality analysis.
3336
+ Note that those parameters are only relevant for EPANET but not for EPANET-MSX.
3337
+
3338
+ Parameters
3339
+ ----------
3340
+ initial_quality : `dict[str, float]`, optional
3341
+ Specifies the initial quality (value in the dictionary) at nodes
3342
+ (key in the dictionary).
3343
+ Quality represents concentration for chemicals, hours for water age,
3344
+ or percent for source tracing.
3345
+
3346
+ The default is None.
3347
+ order_wall : `int`, optional
3348
+ Specifies the order of reactions occurring in the bulk fluid at pipe walls.
3349
+ Value for wall reactions must be either 0 or 1.
3350
+ If not specified, the default reaction order is 1.0.
3351
+
3352
+ The default is None.
3353
+ order_bulk : `int`, optional
3354
+ Specifies the order of reactions occurring in the bulk fluid in tanks.
3355
+ Value must be either 0 or 1.
3356
+ If not specified, the default reaction order is 1.0.
3357
+
3358
+ The default is None.
3359
+ global_wall_reaction_coefficient : `float`, optional
3360
+ Specifies the global value for all pipe wall reaction coefficients (pipes and tanks).
3361
+ If not specified, the default value is zero.
3362
+
3363
+ The default is None.
3364
+ global_bulk_reaction_coefficient : `float`, optional
3365
+ Specifies the global value for all bulk reaction coefficients (pipes and tanks).
3366
+ If not specified, the default value is zero.
3367
+
3368
+ The default is None.
3369
+ local_wall_reaction_coefficient : `dict[str, float]`, optional
3370
+ Overrides the global reaction coefficients for specific pipes (key in dictionary).
3371
+
3372
+ The default is None.
3373
+ local_bulk_reaction_coefficient : `dict[str, float]`, optional
3374
+ Overrides the global reaction coefficients for specific pipes (key in dictionary).
3375
+
3376
+ The default is None.
3377
+ local_tank_reaction_coefficient : `dict[str, float]`, optional
3378
+ Overrides the global reaction coefficients for specific tanks (key in dictionary).
3379
+
3380
+ The default is None.
3381
+ limiting_potential : `float`, optional
3382
+ Specifies that reaction rates are proportional to the difference between the
3383
+ current concentration and some (specified) limiting potential value.
3384
+
3385
+ The default is None.
3386
+ """
3387
+ if initial_quality is not None:
3388
+ if not isinstance(initial_quality, dict):
3389
+ raise TypeError("'initial_quality' must be an instance of 'dict[str, float]' " +
3390
+ f"but not of '{type(initial_quality)}'")
3391
+ if any(not isinstance(key, str) or not isinstance(value, float)
3392
+ for key, value in initial_quality):
3393
+ raise TypeError("'initial_quality' must be an instance of 'dict[str, float]'")
3394
+ for node_id, node_init_qual in initial_quality:
3395
+ if node_id not in self._sensor_config.nodes:
3396
+ raise ValueError(f"Invalid node ID '{node_id}'")
3397
+ if node_init_qual < 0:
3398
+ raise ValueError(f"{node_id}: Initial node quality can not be negative")
3399
+
3400
+ init_qual = self.epanet_api.getNodeInitialQuality()
3401
+ for node_id, node_init_qual in initial_quality:
3402
+ node_idx = self.epanet_api.getNodeIndex(node_id) - 1
3403
+ init_qual[node_idx] = node_init_qual
3404
+
3405
+ self.epanet_api.setNodeInitialQuality(init_qual)
3406
+
3407
+ if order_wall is not None:
3408
+ if not isinstance(order_wall, int):
3409
+ raise TypeError("'order_wall' must be an instance of 'int' " +
3410
+ f"but not of '{type(order_wall)}'")
3411
+ if order_wall not in [0, 1]:
3412
+ raise ValueError(f"Invalid value '{order_wall}' for order_wall")
3413
+
3414
+ self.epanet_api.setOptionsPipeWallReactionOrder(order_wall)
3415
+
3416
+ if order_bulk is not None:
3417
+ if not isinstance(order_bulk, int):
3418
+ raise TypeError("'order_bulk' must be an instance of 'int' " +
3419
+ f"but not of '{type(order_bulk)}'")
3420
+ if order_bulk not in [0, 1]:
3421
+ raise ValueError(f"Invalid value '{order_bulk}' for order_bulk")
3422
+
3423
+ self.epanet_api.setOptionsPipeBulkReactionOrder(order_bulk)
3424
+
3425
+ if order_tank is not None:
3426
+ if not isinstance(order_tank, int):
3427
+ raise TypeError("'order_tank' must be an instance of 'int' " +
3428
+ f"but not of '{type(order_tank)}'")
3429
+ if order_tank not in [0, 1]:
3430
+ raise ValueError(f"Invalid value '{order_tank}' for order_wall")
3431
+
3432
+ self.epanet_api.setOptionsTankBulkReactionOrder(order_tank)
3433
+
3434
+ if global_wall_reaction_coefficient is not None:
3435
+ if not isinstance(global_wall_reaction_coefficient, float):
3436
+ raise TypeError("'global_wall_reaction_coefficient' must be an instance of " +
3437
+ f"'float' but not of '{type(global_wall_reaction_coefficient)}'")
3438
+
3439
+ wall_reaction_coeff = self.epanet_api.getLinkWallReactionCoeff()
3440
+ for i in range(len(wall_reaction_coeff)):
3441
+ wall_reaction_coeff[i] = global_wall_reaction_coefficient
3442
+
3443
+ self.epanet_api.setLinkWallReactionCoeff(wall_reaction_coeff)
3444
+
3445
+ if global_bulk_reaction_coefficient is not None:
3446
+ if not isinstance(global_bulk_reaction_coefficient, float):
3447
+ raise TypeError("'global_bulk_reaction_coefficient' must be an instance of " +
3448
+ f"'float' but not of '{type(global_bulk_reaction_coefficient)}'")
3449
+
3450
+ bulk_reaction_coeff = self.epanet_api.getLinkBulkReactionCoeff()
3451
+ for i in range(len(bulk_reaction_coeff)):
3452
+ bulk_reaction_coeff[i] = global_bulk_reaction_coefficient
3453
+
3454
+ self.epanet_api.setLinkBulkReactionCoeff(bulk_reaction_coeff)
3455
+
3456
+ if local_wall_reaction_coefficient is not None:
3457
+ if not isinstance(local_wall_reaction_coefficient, dict):
3458
+ raise TypeError("'local_wall_reaction_coefficient' must be an instance " +
3459
+ "of 'dict[str, float]' but not of " +
3460
+ f"'{type(local_wall_reaction_coefficient)}'")
3461
+ if any(not isinstance(key, str) or not isinstance(value, float)
3462
+ for key, value in local_wall_reaction_coefficient):
3463
+ raise TypeError("'local_wall_reaction_coefficient' must be an instance " +
3464
+ "of 'dict[str, float]'")
3465
+ for link_id, _ in local_wall_reaction_coefficient:
3466
+ if link_id not in self._sensor_config.links:
3467
+ raise ValueError(f"Invalid link ID '{link_id}'")
3468
+
3469
+ for link_id, link_reaction_coeff in local_wall_reaction_coefficient:
3470
+ link_idx = self.epanet_api.getLinkIndex(link_id)
3471
+ self.epanet_api.setLinkWallReactionCoeff(link_idx, link_reaction_coeff)
3472
+
3473
+ if local_bulk_reaction_coefficient is not None:
3474
+ if not isinstance(local_bulk_reaction_coefficient, dict):
3475
+ raise TypeError("'local_bulk_reaction_coefficient' must be an instance " +
3476
+ "of 'dict[str, float]' but not of " +
3477
+ f"'{type(local_bulk_reaction_coefficient)}'")
3478
+ if any(not isinstance(key, str) or not isinstance(value, float)
3479
+ for key, value in local_bulk_reaction_coefficient):
3480
+ raise TypeError("'local_bulk_reaction_coefficient' must be an instance " +
3481
+ "of 'dict[str, float]'")
3482
+ for link_id, _ in local_bulk_reaction_coefficient:
3483
+ if link_id not in self._sensor_config.links:
3484
+ raise ValueError(f"Invalid link ID '{link_id}'")
3485
+
3486
+ for link_id, link_reaction_coeff in local_bulk_reaction_coefficient:
3487
+ link_idx = self.epanet_api.getLinkIndex(link_id)
3488
+ self.epanet_api.setLinkBulkReactionCoeff(link_idx, link_reaction_coeff)
3489
+
3490
+ if local_tank_reaction_coefficient is not None:
3491
+ if not isinstance(local_tank_reaction_coefficient, dict):
3492
+ raise TypeError("'local_tank_reaction_coefficient' must be an instance " +
3493
+ "of 'dict[str, float]' but not of " +
3494
+ f"'{type(local_tank_reaction_coefficient)}'")
3495
+ if any(not isinstance(key, str) or not isinstance(value, float)
3496
+ for key, value in local_tank_reaction_coefficient):
3497
+ raise TypeError("'local_tank_reaction_coefficient' must be an instance " +
3498
+ "of 'dict[str, float]'")
3499
+ for tank_id, _ in local_tank_reaction_coefficient:
3500
+ if tank_id not in self._sensor_config.tanks:
3501
+ raise ValueError(f"Invalid tank ID '{tank_id}'")
3502
+
3503
+ for tank_id, tank_reaction_coeff in local_tank_reaction_coefficient:
3504
+ tank_idx = self.epanet_api.getNodeTankIndex(tank_id)
3505
+ self.epanet_api.setNodeTankBulkReactionCoeff(tank_idx, tank_reaction_coeff)
3506
+
3507
+ if limiting_potential is not None:
3508
+ if not isinstance(limiting_potential, float):
3509
+ raise TypeError("'limiting_potential' must be an instance of 'float' " +
3510
+ f"but not of '{type(limiting_potential)}'")
3511
+ if limiting_potential < 0:
3512
+ raise ValueError("'limiting_potential' can not be negative")
3513
+
3514
+ self.epanet_api.setOptionsLimitingConcentration(limiting_potential)
3515
+
3073
3516
  def enable_sourcetracing_analysis(self, trace_node_id: str) -> None:
3074
3517
  """
3075
3518
  Set source tracing analysis -- i.e. tracks the percentage of flow from a given node
@@ -3096,7 +3539,9 @@ class ScenarioSimulator():
3096
3539
  source_type: int, pattern_id: str = None,
3097
3540
  source_strength: int = 1.) -> None:
3098
3541
  """
3099
- Adds a new external (bulk or surface) species injection source at a particular node.
3542
+ Adds a new external bulk species injection source at a particular node.
3543
+
3544
+ Only for EPANET-MSX scenarios.
3100
3545
 
3101
3546
  Parameters
3102
3547
  ----------
@@ -3107,6 +3552,8 @@ class ScenarioSimulator():
3107
3552
  is placed.
3108
3553
  pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
3109
3554
  1d source pattern.
3555
+
3556
+ Note that the pattern time step is equivalent to the EPANET pattern time step.
3110
3557
  source_type : `int`,
3111
3558
  Type of the external (bulk or surface) species injection source -- must be one of
3112
3559
  the following EPANET toolkit constants:
@@ -3153,3 +3600,82 @@ class ScenarioSimulator():
3153
3600
  self.epanet_api.addMSXPattern(pattern_id, pattern)
3154
3601
  self.epanet_api.setMSXSources(node_id, species_id, source_type_, source_strength,
3155
3602
  pattern_id)
3603
+
3604
+ def set_bulk_species_node_initial_concentrations(self,
3605
+ inital_conc: dict[str, list[tuple[str, float]]]
3606
+ ) -> None:
3607
+ """
3608
+ Species the initial bulk species concentration at nodes.
3609
+
3610
+ Only for EPANET-MSX scenarios.
3611
+
3612
+ Parameters
3613
+ ----------
3614
+ inital_conc : `dict[str, list[tuple[str, float]]]`
3615
+ Initial concentration of species (key) at nodes -- i.e.
3616
+ value: list of node ID and initial concentration.
3617
+ """
3618
+ if not isinstance(inital_conc, dict) or \
3619
+ any(not isinstance(species_id, str) or not isinstance(node_initial_conc, list)
3620
+ for species_id, node_initial_conc in inital_conc.items()) or \
3621
+ any(not isinstance(node_initial_conc, tuple)
3622
+ for node_initial_conc in list(itertools.chain(*inital_conc.values()))) or \
3623
+ any(not isinstance(node_id, str) or not isinstance(conc, float)
3624
+ for node_id, conc in list(itertools.chain(*inital_conc.values()))):
3625
+ raise TypeError("'inital_conc' must be an instance of " +
3626
+ "'dict[str, list[tuple[str, float]]'")
3627
+ inital_conc_values = list(itertools.chain(*inital_conc.values()))
3628
+ if any(species_id not in self.sensor_config.bulk_species
3629
+ for species_id in inital_conc.keys()):
3630
+ raise ValueError("Unknown bulk species in 'inital_conc'")
3631
+ if any(node_id not in self.sensor_config.nodes for node_id, _ in inital_conc_values):
3632
+ raise ValueError("Unknown node ID in 'inital_conc'")
3633
+ if any(conc < 0 for _, conc in inital_conc_values):
3634
+ raise ValueError("Initial node concentration can not be negative")
3635
+
3636
+ for species_id, node_initial_conc in inital_conc.items():
3637
+ species_idx, = self.epanet_api.getMSXSpeciesIndex([species_id])
3638
+
3639
+ for node_id, initial_conc in node_initial_conc:
3640
+ node_idx = self.epanet_api.getNodeIndex(node_id)
3641
+ self.epanet_api.msx.MSXsetinitqual(ToolkitConstants.MSX_NODE, node_idx, species_idx,
3642
+ initial_conc)
3643
+
3644
+ def set_species_link_initial_concentrations(self,
3645
+ inital_conc: dict[str, list[tuple[str, float]]]
3646
+ ) -> None:
3647
+ """
3648
+ Species the initial (bulk or surface) species concentration at links.
3649
+
3650
+ Only for EPANET-MSX scenarios.
3651
+
3652
+ Parameters
3653
+ ----------
3654
+ inital_conc : `dict[str, list[tuple[str, float]]]`
3655
+ Initial concentration of species (key) at links -- i.e.
3656
+ value: list of link ID and initial concentration.
3657
+ """
3658
+ if not isinstance(inital_conc, dict) or \
3659
+ any(not isinstance(species_id, str) or not isinstance(link_initial_conc, list)
3660
+ for species_id, link_initial_conc in inital_conc.items()) or \
3661
+ any(not isinstance(link_initial_conc, tuple)
3662
+ for link_initial_conc in inital_conc.values()) or \
3663
+ any(not isinstance(link_id, str) or not isinstance(conc, float)
3664
+ for link_id, conc in inital_conc.values()):
3665
+ raise TypeError("'inital_conc' must be an instance of " +
3666
+ "'dict[str, list[tuple[str, float]]'")
3667
+ if any(species_id not in self.sensor_config.bulk_species
3668
+ for species_id in inital_conc.keys()):
3669
+ raise ValueError("Unknown bulk species in 'inital_conc'")
3670
+ if any(link_id not in self.sensor_config.links for link_id, _ in inital_conc.values()):
3671
+ raise ValueError("Unknown link ID in 'inital_conc'")
3672
+ if any(conc < 0 for _, conc in inital_conc.values()):
3673
+ raise ValueError("Initial link concentration can not be negative")
3674
+
3675
+ for species_id, link_initial_conc in inital_conc.items():
3676
+ species_idx, = self.epanet_api.getMSXSpeciesIndex([species_id])
3677
+
3678
+ for link_id, initial_conc in link_initial_conc:
3679
+ link_idx = self.epanet_api.getLinkIndex(link_id)
3680
+ self.epanet_api.msx.MSXsetinitqual(ToolkitConstants.MSX_LINK, link_idx, species_idx,
3681
+ initial_conc)