epyt-flow 0.11.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 (30) 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/gym/__init__.py +0 -3
  6. epyt_flow/gym/scenario_control_env.py +3 -10
  7. epyt_flow/rest_api/scenario/control_handlers.py +118 -0
  8. epyt_flow/rest_api/scenario/event_handlers.py +114 -1
  9. epyt_flow/rest_api/scenario/handlers.py +33 -0
  10. epyt_flow/rest_api/server.py +14 -2
  11. epyt_flow/simulation/backend/__init__.py +1 -0
  12. epyt_flow/simulation/backend/my_epyt.py +1056 -0
  13. epyt_flow/simulation/events/quality_events.py +3 -1
  14. epyt_flow/simulation/scada/scada_data.py +201 -12
  15. epyt_flow/simulation/scenario_simulator.py +142 -59
  16. epyt_flow/topology.py +8 -7
  17. epyt_flow/utils.py +30 -0
  18. epyt_flow/visualization/scenario_visualizer.py +159 -69
  19. epyt_flow/visualization/visualization_utils.py +144 -17
  20. {epyt_flow-0.11.0.dist-info → epyt_flow-0.12.0.dist-info}/METADATA +4 -4
  21. {epyt_flow-0.11.0.dist-info → epyt_flow-0.12.0.dist-info}/RECORD +24 -27
  22. {epyt_flow-0.11.0.dist-info → epyt_flow-0.12.0.dist-info}/WHEEL +1 -1
  23. epyt_flow/gym/control_gyms.py +0 -55
  24. epyt_flow/metrics.py +0 -471
  25. epyt_flow/models/__init__.py +0 -2
  26. epyt_flow/models/event_detector.py +0 -36
  27. epyt_flow/models/sensor_interpolation_detector.py +0 -123
  28. epyt_flow/simulation/scada/advanced_control.py +0 -138
  29. {epyt_flow-0.11.0.dist-info → epyt_flow-0.12.0.dist-info/licenses}/LICENSE +0 -0
  30. {epyt_flow-0.11.0.dist-info → epyt_flow-0.12.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]
@@ -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
@@ -2124,7 +2177,7 @@ class ScenarioSimulator():
2124
2177
  "surface_species_concentration_raw": surface_species_concentrations,
2125
2178
  "sensor_readings_time": np.array([0])}
2126
2179
  else:
2127
- data = ScadaData(network_topo=self.get_topology(), sensor_config=self._sensor_config,
2180
+ data = ScadaData(network_topo=network_topo, sensor_config=self._sensor_config,
2128
2181
  bulk_species_node_concentration_raw=bulk_species_node_concentrations,
2129
2182
  bulk_species_link_concentration_raw=bulk_species_link_concentrations,
2130
2183
  surface_species_concentration_raw=surface_species_concentrations,
@@ -2168,7 +2221,7 @@ class ScenarioSimulator():
2168
2221
  "surface_species_concentration_raw": surface_species_concentrations,
2169
2222
  "sensor_readings_time": np.array([total_time])}
2170
2223
  else:
2171
- data = ScadaData(network_topo=self.get_topology(),
2224
+ data = ScadaData(network_topo=network_topo,
2172
2225
  sensor_config=self._sensor_config,
2173
2226
  bulk_species_node_concentration_raw=
2174
2227
  bulk_species_node_concentrations,
@@ -2310,6 +2363,8 @@ class ScenarioSimulator():
2310
2363
  requested_time_step = quality_time_step
2311
2364
  reporting_time_step = quality_time_step
2312
2365
 
2366
+ network_topo = self.get_topology()
2367
+
2313
2368
  self.epanet_api.useHydraulicFile(hyd_file_in)
2314
2369
 
2315
2370
  self.epanet_api.openQualityAnalysis()
@@ -2338,10 +2393,11 @@ class ScenarioSimulator():
2338
2393
  pass
2339
2394
 
2340
2395
  # Compute current time step
2341
- t = self.epanet_api.runQualityAnalysis()
2396
+ t = self.epanet_api.api.ENrunQ()
2342
2397
  total_time = t
2343
2398
 
2344
2399
  # Fetch data
2400
+ error_code = self.epanet_api.get_last_error_code()
2345
2401
  quality_node_data = self.epanet_api.getNodeActualQuality().reshape(1, -1)
2346
2402
  quality_link_data = self.epanet_api.getLinkActualQuality().reshape(1, -1)
2347
2403
 
@@ -2350,13 +2406,15 @@ class ScenarioSimulator():
2350
2406
  if return_as_dict is True:
2351
2407
  data = {"node_quality_data_raw": quality_node_data,
2352
2408
  "link_quality_data_raw": quality_link_data,
2353
- "sensor_readings_time": np.array([total_time])}
2409
+ "sensor_readings_time": np.array([total_time]),
2410
+ "warnings_code": np.array([error_code])}
2354
2411
  else:
2355
- data = ScadaData(network_topo=self.get_topology(),
2412
+ data = ScadaData(network_topo=network_topo,
2356
2413
  sensor_config=self._sensor_config,
2357
2414
  node_quality_data_raw=quality_node_data,
2358
2415
  link_quality_data_raw=quality_link_data,
2359
2416
  sensor_readings_time=np.array([total_time]),
2417
+ warnings_code=np.array([error_code]),
2360
2418
  sensor_reading_events=self._sensor_reading_events,
2361
2419
  sensor_noise=self._sensor_noise,
2362
2420
  frozen_sensor_config=frozen_sensor_config)
@@ -2369,9 +2427,9 @@ class ScenarioSimulator():
2369
2427
  yield data
2370
2428
 
2371
2429
  # Next
2372
- tstep = self.epanet_api.stepQualityAnalysisTimeLeft()
2430
+ tstep = self.epanet_api.api.ENstepQ()
2373
2431
 
2374
- self.epanet_api.closeHydraulicAnalysis()
2432
+ self.epanet_api.closeQualityAnalysis()
2375
2433
 
2376
2434
  def run_hydraulic_simulation(self, hyd_export: str = None, verbose: bool = False,
2377
2435
  frozen_sensor_config: bool = False) -> ScadaData:
@@ -2493,8 +2551,8 @@ class ScenarioSimulator():
2493
2551
 
2494
2552
  self.__running_simulation = True
2495
2553
 
2496
- self.epanet_api.openHydraulicAnalysis()
2497
- self.epanet_api.openQualityAnalysis()
2554
+ self.epanet_api.api.ENopenH()
2555
+ self.epanet_api.api.ENopenQ()
2498
2556
  self.epanet_api.initializeHydraulicAnalysis(ToolkitConstants.EN_SAVE)
2499
2557
  self.epanet_api.initializeQualityAnalysis(ToolkitConstants.EN_SAVE)
2500
2558
 
@@ -2502,6 +2560,8 @@ class ScenarioSimulator():
2502
2560
  reporting_time_start = self.epanet_api.getTimeReportingStart()
2503
2561
  reporting_time_step = self.epanet_api.getTimeReportingStep()
2504
2562
 
2563
+ network_topo = self.get_topology()
2564
+
2505
2565
  if verbose is True:
2506
2566
  print("Running EPANET ...")
2507
2567
  n_iterations = math.ceil(self.epanet_api.getTimeSimulationDuration() /
@@ -2531,8 +2591,11 @@ class ScenarioSimulator():
2531
2591
  event.step(total_time + tstep)
2532
2592
 
2533
2593
  # Compute current time step
2534
- t = self.epanet_api.runHydraulicAnalysis()
2535
- 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()
2536
2599
  total_time = t
2537
2600
 
2538
2601
  # Fetch data
@@ -2551,7 +2614,7 @@ class ScenarioSimulator():
2551
2614
  link_valve_idx = self.epanet_api.getLinkValveIndex()
2552
2615
  valves_state_data = self.epanet_api.getLinkStatus(link_valve_idx).reshape(1, -1)
2553
2616
 
2554
- scada_data = ScadaData(network_topo=self.get_topology(),
2617
+ scada_data = ScadaData(network_topo=network_topo,
2555
2618
  sensor_config=self._sensor_config,
2556
2619
  pressure_data_raw=pressure_data,
2557
2620
  flow_data_raw=flow_data,
@@ -2564,6 +2627,7 @@ class ScenarioSimulator():
2564
2627
  pumps_energy_usage_data_raw=pumps_energy_usage_data,
2565
2628
  pumps_efficiency_data_raw=pumps_efficiency_data,
2566
2629
  sensor_readings_time=np.array([total_time]),
2630
+ warnings_code=np.array([error_code]),
2567
2631
  sensor_reading_events=self._sensor_reading_events,
2568
2632
  sensor_noise=self._sensor_noise,
2569
2633
  frozen_sensor_config=frozen_sensor_config)
@@ -2581,7 +2645,8 @@ class ScenarioSimulator():
2581
2645
  "tanks_volume_data_raw": tanks_volume_data,
2582
2646
  "pumps_energy_usage_data_raw": pumps_energy_usage_data,
2583
2647
  "pumps_efficiency_data_raw": pumps_efficiency_data,
2584
- "sensor_readings_time": np.array([total_time])}
2648
+ "sensor_readings_time": np.array([total_time]),
2649
+ "warnings_code": np.array([error_code])}
2585
2650
  else:
2586
2651
  data = scada_data
2587
2652
 
@@ -2597,11 +2662,11 @@ class ScenarioSimulator():
2597
2662
  control.step(scada_data)
2598
2663
 
2599
2664
  # Next
2600
- tstep = self.epanet_api.nextHydraulicAnalysisStep()
2601
- self.epanet_api.nextQualityAnalysisStep()
2665
+ tstep = self.epanet_api.api.ENnextH()
2666
+ self.epanet_api.api.ENnextQ()
2602
2667
 
2603
- self.epanet_api.closeQualityAnalysis()
2604
- self.epanet_api.closeHydraulicAnalysis()
2668
+ self.epanet_api.api.ENcloseQ()
2669
+ self.epanet_api.api.ENcloseH()
2605
2670
 
2606
2671
  self.__running_simulation = False
2607
2672
 
@@ -2718,6 +2783,7 @@ class ScenarioSimulator():
2718
2783
 
2719
2784
  def set_general_parameters(self, demand_model: dict = None, simulation_duration: int = None,
2720
2785
  hydraulic_time_step: int = None, quality_time_step: int = None,
2786
+ advanced_quality_time_step: int = None,
2721
2787
  reporting_time_step: int = None, reporting_time_start: int = None,
2722
2788
  flow_units_id: int = None, quality_model: dict = None) -> None:
2723
2789
  """
@@ -2750,6 +2816,12 @@ class ScenarioSimulator():
2750
2816
  Quality time step -- i.e. the interval at which qualities are computed.
2751
2817
  Should be much smaller than the hydraulic time step!
2752
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
+
2753
2825
  The default is None.
2754
2826
  reporting_time_step : `int`, optional
2755
2827
  Report time step -- i.e. the interval at which hydraulics and quality states are
@@ -2862,6 +2934,14 @@ class ScenarioSimulator():
2862
2934
  "greater than the hydraulic time step")
2863
2935
  self.epanet_api.setTimeQualityStep(quality_time_step)
2864
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
+
2865
2945
  if quality_model is not None:
2866
2946
  if quality_model["type"] == "NONE":
2867
2947
  self.epanet_api.setQualityType("none")
@@ -3459,7 +3539,7 @@ class ScenarioSimulator():
3459
3539
  source_type: int, pattern_id: str = None,
3460
3540
  source_strength: int = 1.) -> None:
3461
3541
  """
3462
- 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.
3463
3543
 
3464
3544
  Only for EPANET-MSX scenarios.
3465
3545
 
@@ -3472,6 +3552,8 @@ class ScenarioSimulator():
3472
3552
  is placed.
3473
3553
  pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
3474
3554
  1d source pattern.
3555
+
3556
+ Note that the pattern time step is equivalent to the EPANET pattern time step.
3475
3557
  source_type : `int`,
3476
3558
  Type of the external (bulk or surface) species injection source -- must be one of
3477
3559
  the following EPANET toolkit constants:
@@ -3537,17 +3619,18 @@ class ScenarioSimulator():
3537
3619
  any(not isinstance(species_id, str) or not isinstance(node_initial_conc, list)
3538
3620
  for species_id, node_initial_conc in inital_conc.items()) or \
3539
3621
  any(not isinstance(node_initial_conc, tuple)
3540
- for node_initial_conc in inital_conc.values()) or \
3622
+ for node_initial_conc in list(itertools.chain(*inital_conc.values()))) or \
3541
3623
  any(not isinstance(node_id, str) or not isinstance(conc, float)
3542
- for node_id, conc in inital_conc.values()):
3624
+ for node_id, conc in list(itertools.chain(*inital_conc.values()))):
3543
3625
  raise TypeError("'inital_conc' must be an instance of " +
3544
3626
  "'dict[str, list[tuple[str, float]]'")
3627
+ inital_conc_values = list(itertools.chain(*inital_conc.values()))
3545
3628
  if any(species_id not in self.sensor_config.bulk_species
3546
3629
  for species_id in inital_conc.keys()):
3547
3630
  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()):
3631
+ if any(node_id not in self.sensor_config.nodes for node_id, _ in inital_conc_values):
3549
3632
  raise ValueError("Unknown node ID in 'inital_conc'")
3550
- if any(conc < 0 for _, conc in inital_conc.values()):
3633
+ if any(conc < 0 for _, conc in inital_conc_values):
3551
3634
  raise ValueError("Initial node concentration can not be negative")
3552
3635
 
3553
3636
  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)
epyt_flow/utils.py CHANGED
@@ -67,6 +67,36 @@ def volume_to_level(tank_volume: float, tank_diameter: float) -> float:
67
67
  return (4. / (math.pow(tank_diameter, 2) * math.pi)) * tank_volume
68
68
 
69
69
 
70
+ def level_to_volume(tank_level: float, tank_diameter: float) -> float:
71
+ """
72
+ Computes the volume of water in a tank given the water level in the tank.
73
+
74
+ Parameters
75
+ ----------
76
+ tank_level : `float`
77
+ Water level in the tank.
78
+ tank_diameter : `float`
79
+ Diameter of the tank.
80
+
81
+ Returns
82
+ -------
83
+ `float`
84
+ Water volume in tank.
85
+ """
86
+ if not isinstance(tank_level, float):
87
+ raise TypeError("'tank_level' must be an instace of 'float' " +
88
+ f"but not of '{type(tank_level)}'")
89
+ if tank_level < 0:
90
+ raise ValueError("'tank_level' can not be negative")
91
+ if not isinstance(tank_diameter, float):
92
+ raise TypeError("'tank_diameter' must be an instace of 'float' " +
93
+ f"but not of '{type(tank_diameter)}'")
94
+ if tank_diameter <= 0:
95
+ raise ValueError("'tank_diameter' must be greater than zero")
96
+
97
+ return tank_level * math.pow(0.5 * tank_diameter, 2) * math.pi
98
+
99
+
70
100
  def plot_timeseries_data(data: np.ndarray, labels: list[str] = None, x_axis_label: str = None,
71
101
  y_axis_label: str = None, y_ticks: tuple[list[float], list[str]] = None,
72
102
  show: bool = True, save_to_file: str = None,