epyt-flow 0.11.0__py3-none-any.whl → 0.13.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 (32) hide show
  1. epyt_flow/EPANET/EPANET-MSX/Src/msxtoolkit.c +1 -1
  2. epyt_flow/VERSION +1 -1
  3. epyt_flow/data/benchmarks/gecco_water_quality.py +2 -2
  4. epyt_flow/data/benchmarks/leakdb.py +40 -5
  5. epyt_flow/data/benchmarks/water_usage.py +4 -3
  6. epyt_flow/gym/__init__.py +0 -3
  7. epyt_flow/gym/scenario_control_env.py +5 -12
  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/simulation/backend/__init__.py +1 -0
  13. epyt_flow/simulation/backend/my_epyt.py +1056 -0
  14. epyt_flow/simulation/events/quality_events.py +3 -1
  15. epyt_flow/simulation/scada/scada_data.py +201 -12
  16. epyt_flow/simulation/scenario_simulator.py +179 -87
  17. epyt_flow/topology.py +8 -7
  18. epyt_flow/uncertainty/sensor_noise.py +2 -9
  19. epyt_flow/utils.py +30 -0
  20. epyt_flow/visualization/scenario_visualizer.py +159 -69
  21. epyt_flow/visualization/visualization_utils.py +144 -17
  22. {epyt_flow-0.11.0.dist-info → epyt_flow-0.13.0.dist-info}/METADATA +4 -4
  23. {epyt_flow-0.11.0.dist-info → epyt_flow-0.13.0.dist-info}/RECORD +26 -29
  24. {epyt_flow-0.11.0.dist-info → epyt_flow-0.13.0.dist-info}/WHEEL +1 -1
  25. epyt_flow/gym/control_gyms.py +0 -55
  26. epyt_flow/metrics.py +0 -471
  27. epyt_flow/models/__init__.py +0 -2
  28. epyt_flow/models/event_detector.py +0 -36
  29. epyt_flow/models/sensor_interpolation_detector.py +0 -123
  30. epyt_flow/simulation/scada/advanced_control.py +0 -138
  31. {epyt_flow-0.11.0.dist-info → epyt_flow-0.13.0.dist-info/licenses}/LICENSE +0 -0
  32. {epyt_flow-0.11.0.dist-info → epyt_flow-0.13.0.dist-info}/top_level.txt +0 -0
@@ -5,6 +5,7 @@ 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
11
  from typing import Generator, Union, Optional
@@ -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.
@@ -119,6 +119,7 @@ class ScenarioSimulator():
119
119
  self._sensor_reading_events = []
120
120
  self.__running_simulation = False
121
121
 
122
+ # Check availability of custom EPANET libraries
122
123
  custom_epanet_lib = None
123
124
  custom_epanetmsx_lib = None
124
125
  if sys.platform.startswith("linux") or sys.platform.startswith("darwin") :
@@ -135,48 +136,49 @@ class ScenarioSimulator():
135
136
  if os.path.isfile(os.path.join(path_to_custom_libs, libepanetmsx_name)):
136
137
  custom_epanetmsx_lib = os.path.join(path_to_custom_libs, libepanetmsx_name)
137
138
 
138
- with warnings.catch_warnings():
139
- # Treat all warnings as exceptions when trying to load .inp and .msx files
140
- 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)
141
145
 
142
- # Workaround for EPyT bug concerning parallel simulations (see EPyT issue #54):
143
- # 1. Create random tmp folder (make sure it is unique!)
144
- # 2. Copy .inp and .msx file there
145
- # 3. Use those copies when loading EPyT
146
- tmp_folder_path = os.path.join(get_temp_folder(), f"{random.randint(int(1e5), int(1e7))}{time.time()}")
147
- 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
148
151
 
149
- def __file_exists(file_in: str) -> bool:
150
- try:
151
- return pathlib.Path(file_in).is_file()
152
- except Exception:
153
- 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
154
159
 
155
- if not __file_exists(self.__f_inp_in):
156
- my_f_inp_in = self.__f_inp_in
157
- 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
158
163
  else:
159
- my_f_inp_in = os.path.join(tmp_folder_path, pathlib.Path(self.__f_inp_in).name)
160
- shutil.copyfile(self.__f_inp_in, my_f_inp_in)
161
- 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
162
168
 
163
- if self.__f_msx_in is not None:
164
- if not __file_exists(self.__f_msx_in):
165
- my_f_msx_in = self.__f_msx_in
166
- else:
167
- my_f_msx_in = os.path.join(tmp_folder_path, pathlib.Path(self.__f_msx_in).name)
168
- shutil.copyfile(self.__f_msx_in, my_f_msx_in)
169
- else:
170
- 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)
171
174
 
172
- self.epanet_api = epanet(my_f_inp_in, ph=self.__f_msx_in is None,
173
- customlib=custom_epanet_lib, loadfile=True,
174
- display_msg=epanet_verbose,
175
- 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)
176
177
 
177
- if self.__f_msx_in is not None:
178
- 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)
179
180
 
181
+ # Parse and initialize scenario
180
182
  self._simple_controls = self._parse_simple_control_rules()
181
183
  self._complex_controls = self._parse_complex_control_rules()
182
184
 
@@ -1050,6 +1052,11 @@ class ScenarioSimulator():
1050
1052
  if node_type == "TANK":
1051
1053
  node_tank_idx = node_tank_names.index(node_id) + 1
1052
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])
1053
1060
 
1054
1061
  nodes.append((node_id, node_info))
1055
1062
 
@@ -1138,7 +1145,14 @@ class ScenarioSimulator():
1138
1145
  `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
1139
1146
  The pattern -- i.e. multiplier factors over time.
1140
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
+
1141
1152
  pattern_idx = self.epanet_api.getPatternIndex(pattern_id)
1153
+ if pattern_idx == 0:
1154
+ raise ValueError(f"Unknown pattern '{pattern_id}'")
1155
+
1142
1156
  pattern_length = self.epanet_api.getPatternLengths(pattern_idx)
1143
1157
  return np.array([self.epanet_api.getPatternValue(pattern_idx, t+1)
1144
1158
  for t in range(pattern_length)])
@@ -1171,6 +1185,37 @@ class ScenarioSimulator():
1171
1185
  raise RuntimeError("Failed to add pattern! " +
1172
1186
  "Maybe pattern name contains invalid characters or is too long?")
1173
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
1218
+
1174
1219
  def get_node_demand_pattern(self, node_id: str) -> np.ndarray:
1175
1220
  """
1176
1221
  Returns the values of the demand pattern of a given node --
@@ -1186,6 +1231,12 @@ class ScenarioSimulator():
1186
1231
  `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
1187
1232
  The demand pattern -- i.e. multiplier factors over time.
1188
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
+
1189
1240
  node_idx = self.epanet_api.getNodeIndex(node_id)
1190
1241
  demand_category = self.epanet_api.getNodeDemandCategoriesNumber()[node_idx]
1191
1242
  demand_pattern_id = self.epanet_api.getNodeDemandPatternNameID()[demand_category][node_idx - 1]
@@ -1948,12 +1999,12 @@ class ScenarioSimulator():
1948
1999
  result = None
1949
2000
 
1950
2001
  gen = self.run_advanced_quality_simulation_as_generator
1951
- for scada_data in gen(hyd_file_in=hyd_file_in,
1952
- verbose=verbose,
1953
- return_as_dict=True,
1954
- frozen_sensor_config=frozen_sensor_config,
1955
- use_quality_time_step_as_reporting_time_step=
1956
- use_quality_time_step_as_reporting_time_step):
2002
+ for scada_data, _ in gen(hyd_file_in=hyd_file_in,
2003
+ verbose=verbose,
2004
+ return_as_dict=True,
2005
+ frozen_sensor_config=frozen_sensor_config,
2006
+ use_quality_time_step_as_reporting_time_step=
2007
+ use_quality_time_step_as_reporting_time_step):
1957
2008
  if result is None:
1958
2009
  result = {}
1959
2010
  for data_type, data in scada_data.items():
@@ -1981,7 +2032,7 @@ class ScenarioSimulator():
1981
2032
  return_as_dict: bool = False,
1982
2033
  frozen_sensor_config: bool = False,
1983
2034
  use_quality_time_step_as_reporting_time_step: bool = False,
1984
- ) -> Generator[Union[ScadaData, dict], bool, None]:
2035
+ ) -> Generator[Union[tuple[ScadaData, bool], tuple[dict, bool]], bool, None]:
1985
2036
  """
1986
2037
  Runs an advanced quality analysis using EPANET-MSX.
1987
2038
 
@@ -2015,7 +2066,7 @@ class ScenarioSimulator():
2015
2066
  -------
2016
2067
  :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
2017
2068
  Generator containing the current EPANET-MSX simulation results as SCADA data
2018
- (i.e. species concentrations).
2069
+ (i.e. species concentrations) and a boolean indicating whether the simulation terminated or not.
2019
2070
  """
2020
2071
  if self.__running_simulation is True:
2021
2072
  raise RuntimeError("A simulation is already running.")
@@ -2034,6 +2085,8 @@ class ScenarioSimulator():
2034
2085
  reporting_time_step = self.epanet_api.getTimeReportingStep()
2035
2086
  hyd_time_step = self.epanet_api.getTimeHydraulicStep()
2036
2087
 
2088
+ network_topo = self.get_topology()
2089
+
2037
2090
  if use_quality_time_step_as_reporting_time_step is True:
2038
2091
  quality_time_step = self.epanet_api.getMSXTimeStep()
2039
2092
  reporting_time_step = quality_time_step
@@ -2122,13 +2175,16 @@ class ScenarioSimulator():
2122
2175
  data = {"bulk_species_node_concentration_raw": bulk_species_node_concentrations,
2123
2176
  "bulk_species_link_concentration_raw": bulk_species_link_concentrations,
2124
2177
  "surface_species_concentration_raw": surface_species_concentrations,
2125
- "sensor_readings_time": np.array([0])}
2178
+ "sensor_readings_time": np.array([0]),
2179
+ "warnings_code": np.array([0]) # TODO: Replace with MSX error
2180
+ }
2126
2181
  else:
2127
- data = ScadaData(network_topo=self.get_topology(), sensor_config=self._sensor_config,
2182
+ data = ScadaData(network_topo=network_topo, sensor_config=self._sensor_config,
2128
2183
  bulk_species_node_concentration_raw=bulk_species_node_concentrations,
2129
2184
  bulk_species_link_concentration_raw=bulk_species_link_concentrations,
2130
2185
  surface_species_concentration_raw=surface_species_concentrations,
2131
2186
  sensor_readings_time=np.array([0]),
2187
+ warnings_code=np.array([0]), # TODO: Replace with MSX error
2132
2188
  sensor_reading_events=self._sensor_reading_events,
2133
2189
  sensor_noise=self._sensor_noise,
2134
2190
  frozen_sensor_config=frozen_sensor_config)
@@ -2138,7 +2194,7 @@ class ScenarioSimulator():
2138
2194
  if abort is True:
2139
2195
  return None
2140
2196
 
2141
- yield data
2197
+ yield (data, False)
2142
2198
 
2143
2199
  # Run step-by-step simulation
2144
2200
  tleft = 1
@@ -2166,9 +2222,11 @@ class ScenarioSimulator():
2166
2222
  "bulk_species_link_concentration_raw":
2167
2223
  bulk_species_link_concentrations,
2168
2224
  "surface_species_concentration_raw": surface_species_concentrations,
2169
- "sensor_readings_time": np.array([total_time])}
2225
+ "sensor_readings_time": np.array([total_time]),
2226
+ "warnings_code": np.array([0]), # TODO: Replace with MSX error
2227
+ }
2170
2228
  else:
2171
- data = ScadaData(network_topo=self.get_topology(),
2229
+ data = ScadaData(network_topo=network_topo,
2172
2230
  sensor_config=self._sensor_config,
2173
2231
  bulk_species_node_concentration_raw=
2174
2232
  bulk_species_node_concentrations,
@@ -2177,6 +2235,7 @@ class ScenarioSimulator():
2177
2235
  surface_species_concentration_raw=
2178
2236
  surface_species_concentrations,
2179
2237
  sensor_readings_time=np.array([total_time]),
2238
+ warnings_code=np.array([0]), # TODO: Replace with MSX error
2180
2239
  sensor_reading_events=self._sensor_reading_events,
2181
2240
  sensor_noise=self._sensor_noise,
2182
2241
  frozen_sensor_config=frozen_sensor_config)
@@ -2186,7 +2245,7 @@ class ScenarioSimulator():
2186
2245
  if abort is not False:
2187
2246
  break
2188
2247
 
2189
- yield data
2248
+ yield (data, tleft <= 0)
2190
2249
 
2191
2250
  self.__running_simulation = False
2192
2251
 
@@ -2231,12 +2290,12 @@ class ScenarioSimulator():
2231
2290
 
2232
2291
  # Run simulation step-by-step
2233
2292
  gen = self.run_basic_quality_simulation_as_generator
2234
- for scada_data in gen(hyd_file_in=hyd_file_in,
2235
- verbose=verbose,
2236
- return_as_dict=True,
2237
- frozen_sensor_config=frozen_sensor_config,
2238
- use_quality_time_step_as_reporting_time_step=
2239
- use_quality_time_step_as_reporting_time_step):
2293
+ for scada_data, _ in gen(hyd_file_in=hyd_file_in,
2294
+ verbose=verbose,
2295
+ return_as_dict=True,
2296
+ frozen_sensor_config=frozen_sensor_config,
2297
+ use_quality_time_step_as_reporting_time_step=
2298
+ use_quality_time_step_as_reporting_time_step):
2240
2299
  if result is None:
2241
2300
  result = {}
2242
2301
  for data_type, data in scada_data.items():
@@ -2261,7 +2320,7 @@ class ScenarioSimulator():
2261
2320
  return_as_dict: bool = False,
2262
2321
  frozen_sensor_config: bool = False,
2263
2322
  use_quality_time_step_as_reporting_time_step: bool = False
2264
- ) -> Generator[Union[ScadaData, dict], bool, None]:
2323
+ ) -> Generator[Union[tuple[ScadaData, bool], tuple[dict, bool]], bool, None]:
2265
2324
  """
2266
2325
  Runs a basic quality analysis using EPANET.
2267
2326
 
@@ -2296,11 +2355,13 @@ class ScenarioSimulator():
2296
2355
  Returns
2297
2356
  -------
2298
2357
  :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
2299
- Generator with the current simulation results/states as SCADA data.
2358
+ Generator with the current simulation results/states as SCADA data and a
2359
+ boolean indicating whether the simulation terminated or not.
2300
2360
  """
2301
2361
  if self.__running_simulation is True:
2302
2362
  raise RuntimeError("A simulation is already running.")
2303
2363
 
2364
+ requested_total_time = self.epanet_api.getTimeSimulationDuration()
2304
2365
  requested_time_step = self.epanet_api.getTimeHydraulicStep()
2305
2366
  reporting_time_start = self.epanet_api.getTimeReportingStart()
2306
2367
  reporting_time_step = self.epanet_api.getTimeReportingStep()
@@ -2310,6 +2371,8 @@ class ScenarioSimulator():
2310
2371
  requested_time_step = quality_time_step
2311
2372
  reporting_time_step = quality_time_step
2312
2373
 
2374
+ network_topo = self.get_topology()
2375
+
2313
2376
  self.epanet_api.useHydraulicFile(hyd_file_in)
2314
2377
 
2315
2378
  self.epanet_api.openQualityAnalysis()
@@ -2338,10 +2401,11 @@ class ScenarioSimulator():
2338
2401
  pass
2339
2402
 
2340
2403
  # Compute current time step
2341
- t = self.epanet_api.runQualityAnalysis()
2404
+ t = self.epanet_api.api.ENrunQ()
2342
2405
  total_time = t
2343
2406
 
2344
2407
  # Fetch data
2408
+ error_code = self.epanet_api.get_last_error_code()
2345
2409
  quality_node_data = self.epanet_api.getNodeActualQuality().reshape(1, -1)
2346
2410
  quality_link_data = self.epanet_api.getLinkActualQuality().reshape(1, -1)
2347
2411
 
@@ -2350,13 +2414,15 @@ class ScenarioSimulator():
2350
2414
  if return_as_dict is True:
2351
2415
  data = {"node_quality_data_raw": quality_node_data,
2352
2416
  "link_quality_data_raw": quality_link_data,
2353
- "sensor_readings_time": np.array([total_time])}
2417
+ "sensor_readings_time": np.array([total_time]),
2418
+ "warnings_code": np.array([error_code])}
2354
2419
  else:
2355
- data = ScadaData(network_topo=self.get_topology(),
2420
+ data = ScadaData(network_topo=network_topo,
2356
2421
  sensor_config=self._sensor_config,
2357
2422
  node_quality_data_raw=quality_node_data,
2358
2423
  link_quality_data_raw=quality_link_data,
2359
2424
  sensor_readings_time=np.array([total_time]),
2425
+ warnings_code=np.array([error_code]),
2360
2426
  sensor_reading_events=self._sensor_reading_events,
2361
2427
  sensor_noise=self._sensor_noise,
2362
2428
  frozen_sensor_config=frozen_sensor_config)
@@ -2366,12 +2432,12 @@ class ScenarioSimulator():
2366
2432
  if abort is True:
2367
2433
  break
2368
2434
 
2369
- yield data
2435
+ yield (data, total_time < requested_total_time)
2370
2436
 
2371
2437
  # Next
2372
- tstep = self.epanet_api.stepQualityAnalysisTimeLeft()
2438
+ tstep = self.epanet_api.api.ENstepQ()
2373
2439
 
2374
- self.epanet_api.closeHydraulicAnalysis()
2440
+ self.epanet_api.closeQualityAnalysis()
2375
2441
 
2376
2442
  def run_hydraulic_simulation(self, hyd_export: str = None, verbose: bool = False,
2377
2443
  frozen_sensor_config: bool = False) -> ScadaData:
@@ -2413,10 +2479,10 @@ class ScenarioSimulator():
2413
2479
 
2414
2480
  # Run hydraulic simulation step-by-step
2415
2481
  gen = self.run_hydraulic_simulation_as_generator
2416
- for scada_data in gen(hyd_export=hyd_export,
2417
- verbose=verbose,
2418
- return_as_dict=True,
2419
- frozen_sensor_config=frozen_sensor_config):
2482
+ for scada_data, _ in gen(hyd_export=hyd_export,
2483
+ verbose=verbose,
2484
+ return_as_dict=True,
2485
+ frozen_sensor_config=frozen_sensor_config):
2420
2486
  if result is None:
2421
2487
  result = {}
2422
2488
  for data_type, data in scada_data.items():
@@ -2441,7 +2507,7 @@ class ScenarioSimulator():
2441
2507
  support_abort: bool = False,
2442
2508
  return_as_dict: bool = False,
2443
2509
  frozen_sensor_config: bool = False,
2444
- ) -> Generator[Union[ScadaData, dict], bool, None]:
2510
+ ) -> Generator[Union[tuple[ScadaData, bool], tuple[dict, bool]], bool, None]:
2445
2511
  """
2446
2512
  Runs the hydraulic simulation of this scenario (incl. basic quality if set) and
2447
2513
  provides the results as a generator.
@@ -2482,7 +2548,7 @@ class ScenarioSimulator():
2482
2548
  -------
2483
2549
  :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
2484
2550
  Generator with the current simulation results/states as SCADA data
2485
- (i.e. sensor readings).
2551
+ (i.e. sensor readings) and a boolean indicating whether the simulation terminated or not.
2486
2552
  """
2487
2553
  if self.__running_simulation is True:
2488
2554
  raise RuntimeError("A simulation is already running.")
@@ -2493,15 +2559,18 @@ class ScenarioSimulator():
2493
2559
 
2494
2560
  self.__running_simulation = True
2495
2561
 
2496
- self.epanet_api.openHydraulicAnalysis()
2497
- self.epanet_api.openQualityAnalysis()
2562
+ self.epanet_api.api.ENopenH()
2563
+ self.epanet_api.api.ENopenQ()
2498
2564
  self.epanet_api.initializeHydraulicAnalysis(ToolkitConstants.EN_SAVE)
2499
2565
  self.epanet_api.initializeQualityAnalysis(ToolkitConstants.EN_SAVE)
2500
2566
 
2567
+ requested_total_time = self.epanet_api.getTimeSimulationDuration()
2501
2568
  requested_time_step = self.epanet_api.getTimeHydraulicStep()
2502
2569
  reporting_time_start = self.epanet_api.getTimeReportingStart()
2503
2570
  reporting_time_step = self.epanet_api.getTimeReportingStep()
2504
2571
 
2572
+ network_topo = self.get_topology()
2573
+
2505
2574
  if verbose is True:
2506
2575
  print("Running EPANET ...")
2507
2576
  n_iterations = math.ceil(self.epanet_api.getTimeSimulationDuration() /
@@ -2531,8 +2600,11 @@ class ScenarioSimulator():
2531
2600
  event.step(total_time + tstep)
2532
2601
 
2533
2602
  # Compute current time step
2534
- t = self.epanet_api.runHydraulicAnalysis()
2535
- self.epanet_api.runQualityAnalysis()
2603
+ t = self.epanet_api.api.ENrunH()
2604
+ error_code = self.epanet_api.get_last_error_code()
2605
+ self.epanet_api.api.ENrunQ()
2606
+ if error_code == 0:
2607
+ error_code = self.epanet_api.get_last_error_code()
2536
2608
  total_time = t
2537
2609
 
2538
2610
  # Fetch data
@@ -2551,7 +2623,7 @@ class ScenarioSimulator():
2551
2623
  link_valve_idx = self.epanet_api.getLinkValveIndex()
2552
2624
  valves_state_data = self.epanet_api.getLinkStatus(link_valve_idx).reshape(1, -1)
2553
2625
 
2554
- scada_data = ScadaData(network_topo=self.get_topology(),
2626
+ scada_data = ScadaData(network_topo=network_topo,
2555
2627
  sensor_config=self._sensor_config,
2556
2628
  pressure_data_raw=pressure_data,
2557
2629
  flow_data_raw=flow_data,
@@ -2564,6 +2636,7 @@ class ScenarioSimulator():
2564
2636
  pumps_energy_usage_data_raw=pumps_energy_usage_data,
2565
2637
  pumps_efficiency_data_raw=pumps_efficiency_data,
2566
2638
  sensor_readings_time=np.array([total_time]),
2639
+ warnings_code=np.array([error_code]),
2567
2640
  sensor_reading_events=self._sensor_reading_events,
2568
2641
  sensor_noise=self._sensor_noise,
2569
2642
  frozen_sensor_config=frozen_sensor_config)
@@ -2581,7 +2654,8 @@ class ScenarioSimulator():
2581
2654
  "tanks_volume_data_raw": tanks_volume_data,
2582
2655
  "pumps_energy_usage_data_raw": pumps_energy_usage_data,
2583
2656
  "pumps_efficiency_data_raw": pumps_efficiency_data,
2584
- "sensor_readings_time": np.array([total_time])}
2657
+ "sensor_readings_time": np.array([total_time]),
2658
+ "warnings_code": np.array([error_code])}
2585
2659
  else:
2586
2660
  data = scada_data
2587
2661
 
@@ -2590,18 +2664,18 @@ class ScenarioSimulator():
2590
2664
  if abort is True:
2591
2665
  break
2592
2666
 
2593
- yield data
2667
+ yield (data, total_time < requested_total_time)
2594
2668
 
2595
2669
  # Apply control modules
2596
2670
  for control in self._custom_controls:
2597
2671
  control.step(scada_data)
2598
2672
 
2599
2673
  # Next
2600
- tstep = self.epanet_api.nextHydraulicAnalysisStep()
2601
- self.epanet_api.nextQualityAnalysisStep()
2674
+ tstep = self.epanet_api.api.ENnextH()
2675
+ self.epanet_api.api.ENnextQ()
2602
2676
 
2603
- self.epanet_api.closeQualityAnalysis()
2604
- self.epanet_api.closeHydraulicAnalysis()
2677
+ self.epanet_api.api.ENcloseQ()
2678
+ self.epanet_api.api.ENcloseH()
2605
2679
 
2606
2680
  self.__running_simulation = False
2607
2681
 
@@ -2718,6 +2792,7 @@ class ScenarioSimulator():
2718
2792
 
2719
2793
  def set_general_parameters(self, demand_model: dict = None, simulation_duration: int = None,
2720
2794
  hydraulic_time_step: int = None, quality_time_step: int = None,
2795
+ advanced_quality_time_step: int = None,
2721
2796
  reporting_time_step: int = None, reporting_time_start: int = None,
2722
2797
  flow_units_id: int = None, quality_model: dict = None) -> None:
2723
2798
  """
@@ -2750,6 +2825,12 @@ class ScenarioSimulator():
2750
2825
  Quality time step -- i.e. the interval at which qualities are computed.
2751
2826
  Should be much smaller than the hydraulic time step!
2752
2827
 
2828
+ The default is None.
2829
+ advanced_quality_time_step : `ìnt`, optional
2830
+ Time step in the advanced quality simuliation -- i.e. EPANET-MSX simulation.
2831
+ This number specifies the interval at which all species concentrations are.
2832
+ Should be much smaller than the hydraulic time step!
2833
+
2753
2834
  The default is None.
2754
2835
  reporting_time_step : `int`, optional
2755
2836
  Report time step -- i.e. the interval at which hydraulics and quality states are
@@ -2862,6 +2943,14 @@ class ScenarioSimulator():
2862
2943
  "greater than the hydraulic time step")
2863
2944
  self.epanet_api.setTimeQualityStep(quality_time_step)
2864
2945
 
2946
+ if advanced_quality_time_step is not None:
2947
+ if not isinstance(advanced_quality_time_step, int) or \
2948
+ advanced_quality_time_step <= 0 or \
2949
+ advanced_quality_time_step > self.epanet_api.getTimeHydraulicStep():
2950
+ raise ValueError("'advanced_quality_time_step' must be a positive integer " +
2951
+ "that is not greater than the hydraulic time step")
2952
+ self.epanet_api.setMSXTimeStep(advanced_quality_time_step)
2953
+
2865
2954
  if quality_model is not None:
2866
2955
  if quality_model["type"] == "NONE":
2867
2956
  self.epanet_api.setQualityType("none")
@@ -3459,7 +3548,7 @@ class ScenarioSimulator():
3459
3548
  source_type: int, pattern_id: str = None,
3460
3549
  source_strength: int = 1.) -> None:
3461
3550
  """
3462
- Adds a new external (bulk or surface) species injection source at a particular node.
3551
+ Adds a new external bulk species injection source at a particular node.
3463
3552
 
3464
3553
  Only for EPANET-MSX scenarios.
3465
3554
 
@@ -3472,6 +3561,8 @@ class ScenarioSimulator():
3472
3561
  is placed.
3473
3562
  pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
3474
3563
  1d source pattern.
3564
+
3565
+ Note that the pattern time step is equivalent to the EPANET pattern time step.
3475
3566
  source_type : `int`,
3476
3567
  Type of the external (bulk or surface) species injection source -- must be one of
3477
3568
  the following EPANET toolkit constants:
@@ -3537,17 +3628,18 @@ class ScenarioSimulator():
3537
3628
  any(not isinstance(species_id, str) or not isinstance(node_initial_conc, list)
3538
3629
  for species_id, node_initial_conc in inital_conc.items()) or \
3539
3630
  any(not isinstance(node_initial_conc, tuple)
3540
- for node_initial_conc in inital_conc.values()) or \
3631
+ for node_initial_conc in list(itertools.chain(*inital_conc.values()))) or \
3541
3632
  any(not isinstance(node_id, str) or not isinstance(conc, float)
3542
- for node_id, conc in inital_conc.values()):
3633
+ for node_id, conc in list(itertools.chain(*inital_conc.values()))):
3543
3634
  raise TypeError("'inital_conc' must be an instance of " +
3544
3635
  "'dict[str, list[tuple[str, float]]'")
3636
+ inital_conc_values = list(itertools.chain(*inital_conc.values()))
3545
3637
  if any(species_id not in self.sensor_config.bulk_species
3546
3638
  for species_id in inital_conc.keys()):
3547
3639
  raise ValueError("Unknown bulk species in 'inital_conc'")
3548
- if any(node_id not in self.sensor_config.nodes for node_id, _ in inital_conc.values()):
3640
+ if any(node_id not in self.sensor_config.nodes for node_id, _ in inital_conc_values):
3549
3641
  raise ValueError("Unknown node ID in 'inital_conc'")
3550
- if any(conc < 0 for _, conc in inital_conc.values()):
3642
+ if any(conc < 0 for _, conc in inital_conc_values):
3551
3643
  raise ValueError("Initial node concentration can not be negative")
3552
3644
 
3553
3645
  for species_id, node_initial_conc in inital_conc.items():
epyt_flow/topology.py CHANGED
@@ -78,7 +78,7 @@ class NetworkTopology(nx.Graph, JsonSerializable):
78
78
  links: list[tuple[str, tuple[str, str], dict]],
79
79
  pumps: dict,
80
80
  valves: dict,
81
- units: int = None,
81
+ units: int,
82
82
  **kwds):
83
83
  super().__init__(name=f_inp, **kwds)
84
84
 
@@ -88,11 +88,6 @@ class NetworkTopology(nx.Graph, JsonSerializable):
88
88
  self.__valves = valves
89
89
  self.__units = units
90
90
 
91
- if units is None:
92
- warnings.warn("Loading a file that was created with an outdated version of EPyT-Flow" +
93
- " -- support of such old files will be removed in the next release!",
94
- DeprecationWarning)
95
-
96
91
  for node_id, node_info in nodes:
97
92
  node_elevation = node_info["elevation"]
98
93
  node_type = node_info["type"]
@@ -561,7 +556,8 @@ class NetworkTopology(nx.Graph, JsonSerializable):
561
556
 
562
557
  # Nodes
563
558
  node_data = {"id": [], "type": [], "elevation": [], "geometry": []}
564
- tank_data = {"id": [], "elevation": [], "diameter": [], "geometry": []}
559
+ tank_data = {"id": [], "volume": [], "max_level": [], "min_level": [], "mixing_fraction": [],
560
+ "elevation": [], "diameter": [], "geometry": []}
565
561
  reservoir_data = {"id": [], "elevation": [], "geometry": []}
566
562
  for node_id in self.get_all_nodes():
567
563
  node_info = self.get_node_info(node_id)
@@ -575,6 +571,11 @@ class NetworkTopology(nx.Graph, JsonSerializable):
575
571
  tank_data["id"].append(node_id)
576
572
  tank_data["elevation"].append(node_info["elevation"])
577
573
  tank_data["diameter"].append(node_info["diameter"])
574
+ tank_data["volume"].append(node_info["volume"])
575
+ tank_data["max_level"].append(node_info["max_level"])
576
+ tank_data["min_level"].append(node_info["min_level"])
577
+ tank_data["mixing_fraction"].append(node_info["mixing_fraction"])
578
+ #tank_data["mixing_model"].append(node_info["mixing_model"])
578
579
  tank_data["geometry"].append(Point(node_info["coord"]))
579
580
  elif node_info["type"] == "RESERVOIR":
580
581
  reservoir_data["id"].append(node_id)
@@ -3,7 +3,6 @@ Module provides a class for implementing sensor noise (e.g. uncertainty in senso
3
3
  """
4
4
  from typing import Optional, Callable
5
5
  from copy import deepcopy
6
- import warnings
7
6
  import numpy
8
7
  from numpy.random import default_rng
9
8
 
@@ -32,18 +31,12 @@ class SensorNoise(JsonSerializable):
32
31
 
33
32
  The default is None.
34
33
  """
35
- def __init__(self, uncertainty: Optional[Uncertainty] = None,
36
- global_uncertainty: Optional[Uncertainty] = None,
34
+ def __init__(self, global_uncertainty: Optional[Uncertainty] = None,
37
35
  local_uncertainties: Optional[dict[int, str, Uncertainty]] = None,
38
36
  seed: Optional[int] = None,
39
37
  **kwds):
40
- if uncertainty is not None:
41
- global_uncertainty = uncertainty
42
- warnings.warn("'uncertainty' is deprecated and will be removed in future releases. " +
43
- "Use 'global_uncertainty' instead")
44
-
45
38
  if not isinstance(global_uncertainty, Uncertainty):
46
- raise TypeError("'uncertainty' must be an instance of " +
39
+ raise TypeError("'global_uncertainty' must be an instance of " +
47
40
  "'epyt_flow.uncertainty.Uncertainty' but not of " +
48
41
  f"'{type(global_uncertainty)}'")
49
42
  if local_uncertainties is not None: