epyt-flow 0.1.1__py3-none-any.whl → 0.3.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 (31) hide show
  1. epyt_flow/EPANET/compile_linux.sh +4 -0
  2. epyt_flow/EPANET/compile_macos.sh +4 -0
  3. epyt_flow/VERSION +1 -1
  4. epyt_flow/__init__.py +29 -18
  5. epyt_flow/data/benchmarks/leakdb.py +7 -12
  6. epyt_flow/data/networks.py +404 -40
  7. epyt_flow/rest_api/base_handler.py +14 -0
  8. epyt_flow/rest_api/scada_data/__init__.py +0 -0
  9. epyt_flow/rest_api/{scada_data_handler.py → scada_data/data_handlers.py} +3 -162
  10. epyt_flow/rest_api/scada_data/export_handlers.py +140 -0
  11. epyt_flow/rest_api/scada_data/handlers.py +209 -0
  12. epyt_flow/rest_api/scenario/__init__.py +0 -0
  13. epyt_flow/rest_api/scenario/event_handlers.py +118 -0
  14. epyt_flow/rest_api/{scenario_handler.py → scenario/handlers.py} +86 -67
  15. epyt_flow/rest_api/scenario/simulation_handlers.py +174 -0
  16. epyt_flow/rest_api/scenario/uncertainty_handlers.py +118 -0
  17. epyt_flow/rest_api/server.py +61 -24
  18. epyt_flow/simulation/events/leakages.py +27 -17
  19. epyt_flow/simulation/scada/scada_data.py +545 -14
  20. epyt_flow/simulation/scada/scada_data_export.py +39 -12
  21. epyt_flow/simulation/scenario_config.py +14 -20
  22. epyt_flow/simulation/scenario_simulator.py +358 -114
  23. epyt_flow/simulation/sensor_config.py +693 -37
  24. epyt_flow/topology.py +149 -8
  25. epyt_flow/utils.py +75 -18
  26. {epyt_flow-0.1.1.dist-info → epyt_flow-0.3.0.dist-info}/METADATA +33 -5
  27. {epyt_flow-0.1.1.dist-info → epyt_flow-0.3.0.dist-info}/RECORD +30 -22
  28. epyt_flow/EPANET/compile.sh +0 -4
  29. {epyt_flow-0.1.1.dist-info → epyt_flow-0.3.0.dist-info}/LICENSE +0 -0
  30. {epyt_flow-0.1.1.dist-info → epyt_flow-0.3.0.dist-info}/WHEEL +0 -0
  31. {epyt_flow-0.1.1.dist-info → epyt_flow-0.3.0.dist-info}/top_level.txt +0 -0
@@ -17,15 +17,17 @@ from epyt.epanet import ToolkitConstants
17
17
  from tqdm import tqdm
18
18
 
19
19
  from .scenario_config import ScenarioConfig
20
- from .sensor_config import SensorConfig, SENSOR_TYPE_LINK_FLOW, SENSOR_TYPE_LINK_QUALITY, \
21
- SENSOR_TYPE_NODE_DEMAND, SENSOR_TYPE_NODE_PRESSURE, SENSOR_TYPE_NODE_QUALITY, \
20
+ from .sensor_config import SensorConfig, areaunit_to_id, massunit_to_id, qualityunit_to_id, \
21
+ qualityunit_to_str, MASS_UNIT_MG, \
22
+ SENSOR_TYPE_LINK_FLOW, SENSOR_TYPE_LINK_QUALITY, SENSOR_TYPE_NODE_DEMAND, \
23
+ SENSOR_TYPE_NODE_PRESSURE, SENSOR_TYPE_NODE_QUALITY, \
22
24
  SENSOR_TYPE_PUMP_STATE, SENSOR_TYPE_TANK_VOLUME, SENSOR_TYPE_VALVE_STATE, \
23
25
  SENSOR_TYPE_NODE_BULK_SPECIES, SENSOR_TYPE_LINK_BULK_SPECIES, SENSOR_TYPE_SURFACE_SPECIES
24
26
  from ..uncertainty import ModelUncertainty, SensorNoise
25
27
  from .events import SystemEvent, Leakage, ActuatorEvent, SensorFault, SensorReadingAttack, \
26
28
  SensorReadingEvent
27
29
  from .scada import ScadaData, AdvancedControlModule
28
- from ..topology import NetworkTopology
30
+ from ..topology import NetworkTopology, UNITS_SIMETRIC, UNITS_USCUSTOM
29
31
  from ..utils import get_temp_folder
30
32
 
31
33
 
@@ -48,6 +50,10 @@ class ScenarioSimulator():
48
50
 
49
51
  If this is None, then 'f_inp_in' must be set with a valid path to the .inp file
50
52
  that is to be simulated.
53
+ epanet_verbose : `bool`, optional
54
+ If True, EPyT is verbose and might print messages from time to time.
55
+
56
+ The default is False.
51
57
 
52
58
  Attributes
53
59
  ----------
@@ -56,7 +62,7 @@ class ScenarioSimulator():
56
62
  """
57
63
 
58
64
  def __init__(self, f_inp_in: str = None, f_msx_in: str = None,
59
- scenario_config: ScenarioConfig = None):
65
+ scenario_config: ScenarioConfig = None, epanet_verbose: bool = False):
60
66
  if f_msx_in is not None and f_inp_in is None:
61
67
  raise ValueError("'f_inp_in' must be set if 'f_msx_in' is set.")
62
68
  if f_inp_in is None and scenario_config is None:
@@ -74,6 +80,9 @@ class ScenarioSimulator():
74
80
  raise TypeError("'scenario_config' must be an instance of " +
75
81
  "'epyt_flow.simulation.ScenarioConfig' but not of " +
76
82
  f"'{type(scenario_config)}'")
83
+ if not isinstance(epanet_verbose, bool):
84
+ raise TypeError("'epanet_verbose' must be an instance of 'bool' " +
85
+ f"but not of '{type(epanet_verbose)}'")
77
86
 
78
87
  self.__f_inp_in = f_inp_in if scenario_config is None else scenario_config.f_inp_in
79
88
  self.__f_msx_in = f_msx_in if scenario_config is None else scenario_config.f_msx_in
@@ -86,37 +95,28 @@ class ScenarioSimulator():
86
95
 
87
96
  custom_epanet_lib = None
88
97
  custom_epanetmsx_lib = None
89
- if sys.platform.startswith("linux"):
98
+ if sys.platform.startswith("linux") or sys.platform.startswith("darwin") :
90
99
  path_to_custom_libs = os.path.join(pathlib.Path(__file__).parent.resolve(),
91
100
  "..", "customlibs")
92
101
 
93
- if os.path.isfile(os.path.join(path_to_custom_libs, "libepanet2_2.so")):
94
- custom_epanet_lib = os.path.join(path_to_custom_libs, "libepanet2_2.so")
95
- if os.path.isfile(os.path.join(path_to_custom_libs, "libepanetmsx2_2_0.so")):
96
- custom_epanetmsx_lib = os.path.join(path_to_custom_libs, "libepanetmsx2_2_0.so")
102
+ libepanet_name = "libepanet2_2.so" if sys.platform.startswith("linux") \
103
+ else "libepanet2_2.dylib"
104
+ libepanetmsx_name = "libepanetmsx2_2_0.so" if sys.platform.startswith("linux") \
105
+ else "libepanetmsx2_2_0.dylib"
106
+
107
+ if os.path.isfile(os.path.join(path_to_custom_libs, libepanet_name)):
108
+ custom_epanet_lib = os.path.join(path_to_custom_libs, libepanet_name)
109
+ if os.path.isfile(os.path.join(path_to_custom_libs, libepanetmsx_name)):
110
+ custom_epanetmsx_lib = os.path.join(path_to_custom_libs, libepanetmsx_name)
97
111
 
98
112
  self.epanet_api = epanet(self.__f_inp_in, ph=self.__f_msx_in is None,
99
- customlib=custom_epanet_lib)
113
+ customlib=custom_epanet_lib, loadfile=True,
114
+ display_msg=epanet_verbose)
100
115
 
101
- bulk_species = []
102
- surface_species = []
103
116
  if self.__f_msx_in is not None:
104
117
  self.epanet_api.loadMSXFile(self.__f_msx_in, customMSXlib=custom_epanetmsx_lib)
105
118
 
106
- for species_id, species_type in zip(self.epanet_api.getMSXSpeciesNameID(),
107
- self.epanet_api.getMSXSpeciesType()):
108
- if species_type == "BULK":
109
- bulk_species.append(species_id)
110
- elif species_type == "WALL":
111
- surface_species.append(species_id)
112
-
113
- self.__sensor_config = SensorConfig(nodes=self.epanet_api.getNodeNameID(),
114
- links=self.epanet_api.getLinkNameID(),
115
- valves=self.epanet_api.getLinkValveNameID(),
116
- pumps=self.epanet_api.getLinkPumpNameID(),
117
- tanks=self.epanet_api.getNodeTankNameID(),
118
- bulk_species=bulk_species,
119
- surface_species=surface_species)
119
+ self.__sensor_config = self.__get_empty_sensor_config()
120
120
  if scenario_config is not None:
121
121
  if scenario_config.general_params is not None:
122
122
  self.set_general_parameters(**scenario_config.general_params)
@@ -132,6 +132,51 @@ class ScenarioSimulator():
132
132
  for event in scenario_config.sensor_reading_events:
133
133
  self.add_sensor_reading_event(event)
134
134
 
135
+ def __get_empty_sensor_config(self, node_id_to_idx: dict = None, link_id_to_idx: dict = None,
136
+ valve_id_to_idx: dict = None, pump_id_to_idx: dict = None,
137
+ tank_id_to_idx: dict = None, bulkspecies_id_to_idx: dict = None,
138
+ surfacespecies_id_to_idx: dict = None) -> SensorConfig:
139
+ flow_unit = self.epanet_api.api.ENgetflowunits()
140
+ quality_unit = qualityunit_to_id(self.epanet_api.getQualityInfo().QualityChemUnits)
141
+ bulk_species = []
142
+ surface_species = []
143
+ bulk_species_mass_unit = []
144
+ surface_species_mass_unit = []
145
+ surface_species_area_unit = None
146
+
147
+ if self.__f_msx_in is not None:
148
+ surface_species_area_unit = areaunit_to_id(self.epanet_api.getMSXAreaUnits())
149
+
150
+ for species_id, species_type, mass_unit in zip(self.epanet_api.getMSXSpeciesNameID(),
151
+ self.epanet_api.getMSXSpeciesType(),
152
+ self.epanet_api.getMSXSpeciesUnits()):
153
+ if species_type == "BULK":
154
+ bulk_species.append(species_id)
155
+ bulk_species_mass_unit.append(massunit_to_id(mass_unit))
156
+ elif species_type == "WALL":
157
+ surface_species.append(species_id)
158
+ surface_species_mass_unit.append(massunit_to_id(mass_unit))
159
+
160
+ return SensorConfig(nodes=self.epanet_api.getNodeNameID(),
161
+ links=self.epanet_api.getLinkNameID(),
162
+ valves=self.epanet_api.getLinkValveNameID(),
163
+ pumps=self.epanet_api.getLinkPumpNameID(),
164
+ tanks=self.epanet_api.getNodeTankNameID(),
165
+ bulk_species=bulk_species,
166
+ surface_species=surface_species,
167
+ flow_unit=flow_unit,
168
+ quality_unit=quality_unit,
169
+ bulk_species_mass_unit=bulk_species_mass_unit,
170
+ surface_species_mass_unit=surface_species_mass_unit,
171
+ surface_species_area_unit=surface_species_area_unit,
172
+ node_id_to_idx=node_id_to_idx,
173
+ link_id_to_idx=link_id_to_idx,
174
+ valve_id_to_idx=valve_id_to_idx,
175
+ pump_id_to_idx=pump_id_to_idx,
176
+ tank_id_to_idx=tank_id_to_idx,
177
+ bulkspecies_id_to_idx=bulkspecies_id_to_idx,
178
+ surfacespecies_id_to_idx=surfacespecies_id_to_idx)
179
+
135
180
  @property
136
181
  def f_inp_in(self) -> str:
137
182
  """
@@ -295,6 +340,21 @@ class ScenarioSimulator():
295
340
  return deepcopy(list(filter(lambda e: isinstance(e, SensorFault),
296
341
  self.__sensor_reading_events)))
297
342
 
343
+ @property
344
+ def sensor_reading_attacks(self) -> list[SensorReadingAttack]:
345
+ """
346
+ Gets all sensor reading attacks.
347
+
348
+ Returns
349
+ -------
350
+ list[:class:`~epyt_flow.simulation.events.sensor_reading_attacks.SensorReadingAttack`]
351
+ All sensor reading attacks.
352
+ """
353
+ self.__adapt_to_network_changes()
354
+
355
+ return deepcopy(list(filter(lambda e: isinstance(e, SensorReadingAttack)),
356
+ self.__sensor_reading_events))
357
+
298
358
  @property
299
359
  def sensor_reading_events(self) -> list[SensorReadingEvent]:
300
360
  """
@@ -312,19 +372,6 @@ class ScenarioSimulator():
312
372
  def __adapt_to_network_changes(self):
313
373
  nodes = self.epanet_api.getNodeNameID()
314
374
  links = self.epanet_api.getLinkNameID()
315
- valves = self.epanet_api.getLinkValveNameID()
316
- pumps = self.epanet_api.getLinkPumpNameID()
317
- tanks = self.epanet_api.getNodeTankNameID()
318
- bulk_species = []
319
- surface_species = []
320
-
321
- if self.__f_msx_in is not None:
322
- for species_id, species_type in zip(self.epanet_api.getMSXSpeciesNameID(),
323
- self.epanet_api.getMSXSpeciesType()):
324
- if species_type == "BULK":
325
- bulk_species.append(species_id)
326
- elif species_type == "WALL":
327
- surface_species.append(species_id)
328
375
 
329
376
  node_id_to_idx = {node_id: self.epanet_api.getNodeIndex(node_id) - 1 for node_id in nodes}
330
377
  link_id_to_idx = {link_id: self.epanet_api.getLinkIndex(link_id) - 1 for link_id in links}
@@ -334,35 +381,24 @@ class ScenarioSimulator():
334
381
  bulkspecies_id_to_idx = None
335
382
  surfacespecies_id_to_idx = None
336
383
 
337
- if nodes != self.__sensor_config.nodes or links != self.__sensor_config.links or \
338
- valves != self.__sensor_config.valves or pumps != self.__sensor_config.pumps or \
339
- tanks != self.__sensor_config.tanks or \
340
- bulk_species != self.__sensor_config.bulk_species or \
341
- surface_species != self.__sensor_config.surface_species:
342
- # Adapt sensor configuration if anything in the network topology changed
343
- new_sensor_config = SensorConfig(nodes=nodes, links=links, valves=valves, pumps=pumps,
344
- tanks=tanks, bulk_species=bulk_species,
345
- surface_species=surface_species,
346
- node_id_to_idx=node_id_to_idx,
347
- link_id_to_idx=link_id_to_idx,
348
- valve_id_to_idx=valve_id_to_idx,
349
- pump_id_to_idx=pump_id_to_idx,
350
- tank_id_to_idx=tank_id_to_idx,
351
- bulkspecies_id_to_idx=bulkspecies_id_to_idx,
352
- surfacespecies_id_to_idx=surfacespecies_id_to_idx)
353
- new_sensor_config.pressure_sensors = self.__sensor_config.pressure_sensors
354
- new_sensor_config.flow_sensors = self.__sensor_config.flow_sensors
355
- new_sensor_config.demand_sensors = self.__sensor_config.demand_sensors
356
- new_sensor_config.quality_node_sensors = self.__sensor_config.quality_node_sensors
357
- new_sensor_config.quality_link_sensors = self.__sensor_config.quality_link_sensors
358
- new_sensor_config.pump_state_sensors = self.__sensor_config.pump_state_sensors
359
- new_sensor_config.valve_state_sensors = self.__sensor_config.valve_state_sensors
360
- new_sensor_config.tank_volume_sensors = self.__sensor_config.tank_volume_sensors
361
- new_sensor_config.bulk_species_node_sensors = self.__sensor_config.bulk_species_node_sensors
362
- new_sensor_config.bulk_species_link_sensors = self.__sensor_config.bulk_species_link_sensors
363
- new_sensor_config.surface_species_sensors = self.__sensor_config.surface_species_sensors
364
-
365
- self.__sensor_config = new_sensor_config
384
+ # Adapt sensor configuration to potential cahnges in the network's topology
385
+ new_sensor_config = self.__get_empty_sensor_config(node_id_to_idx, link_id_to_idx,
386
+ valve_id_to_idx, pump_id_to_idx,
387
+ tank_id_to_idx, bulkspecies_id_to_idx,
388
+ surfacespecies_id_to_idx)
389
+ new_sensor_config.pressure_sensors = self.__sensor_config.pressure_sensors
390
+ new_sensor_config.flow_sensors = self.__sensor_config.flow_sensors
391
+ new_sensor_config.demand_sensors = self.__sensor_config.demand_sensors
392
+ new_sensor_config.quality_node_sensors = self.__sensor_config.quality_node_sensors
393
+ new_sensor_config.quality_link_sensors = self.__sensor_config.quality_link_sensors
394
+ new_sensor_config.pump_state_sensors = self.__sensor_config.pump_state_sensors
395
+ new_sensor_config.valve_state_sensors = self.__sensor_config.valve_state_sensors
396
+ new_sensor_config.tank_volume_sensors = self.__sensor_config.tank_volume_sensors
397
+ new_sensor_config.bulk_species_node_sensors = self.__sensor_config.bulk_species_node_sensors
398
+ new_sensor_config.bulk_species_link_sensors = self.__sensor_config.bulk_species_link_sensors
399
+ new_sensor_config.surface_species_sensors = self.__sensor_config.surface_species_sensors
400
+
401
+ self.__sensor_config = new_sensor_config
366
402
 
367
403
  def close(self):
368
404
  """
@@ -381,22 +417,185 @@ class ScenarioSimulator():
381
417
  def __exit__(self, *args):
382
418
  self.close()
383
419
 
420
+ def save_to_epanet_file(self, inp_file_path: str, msx_file_path: str = None,
421
+ export_sensor_config: bool = True) -> None:
422
+ """
423
+ Exports this scenario to EPANET files -- i.e. an .inp file
424
+ and (optionally) a .msx file if EPANET-MSX was loaded.
425
+
426
+ Parameters
427
+ ----------
428
+ inp_file_path : `str`
429
+ Path to the .inp file where this scenario will be stored.
430
+
431
+ If 'inp_file_path' is None, 'msx_file_path' must not be None!
432
+ msx_file_path : `str`, optional
433
+ Path to the .msx file where this MSX component of this scneario will be stored.
434
+
435
+ Note that this is only applicable if EPANET-MSX was loaded.
436
+
437
+ The default is None.
438
+ export_sensor_config : `bool`, optional
439
+ If True, the current sensor placement is exported as well.
440
+
441
+ The default is True.
442
+ """
443
+ if inp_file_path is None and msx_file_path is None:
444
+ raise ValueError("At least one of the paths (.inp and .msx) must not be None")
445
+ if inp_file_path is not None:
446
+ if not isinstance(inp_file_path, str):
447
+ raise TypeError("'inp_file_path' must be an instance of 'str' " +
448
+ f"but not of '{type(inp_file_path)}'")
449
+ if msx_file_path is not None:
450
+ if not isinstance(msx_file_path, str):
451
+ raise TypeError("msx_file_path' msut be an instance of 'str' " +
452
+ f"but not of {type(msx_file_path)}")
453
+ if not isinstance(export_sensor_config, bool):
454
+ raise TypeError("'export_sensor_config' must be an instance of 'bool' " +
455
+ f"but not of '{type(export_sensor_config)}'")
456
+
457
+ def __override_report_section(file_in: str, report_desc: str) -> None:
458
+ with open(file_in, mode="r+", encoding="utf-8") as f_in:
459
+ # Find and remove exiting REPORT section
460
+ content = f_in.read()
461
+ try:
462
+ report_section_start_idx = content.index("[REPORT]")
463
+ report_section_end_idx = content.index("[", report_section_start_idx + 1)
464
+
465
+ content = content[:report_section_start_idx] + content[report_section_end_idx:]
466
+ f_in.seek(0)
467
+ f_in.write(content)
468
+ f_in.truncate()
469
+ except ValueError:
470
+ pass
471
+
472
+ # Write new REPORT section in the very end of the file
473
+ write_end_section = False
474
+ try:
475
+ end_idx = content.index("[END]")
476
+ write_end_section = True
477
+ f_in.seek(end_idx)
478
+ except ValueError:
479
+ pass
480
+ f_in.write(report_desc)
481
+ if write_end_section is True:
482
+ f_in.write("\n[END]")
483
+
484
+ if inp_file_path is not None:
485
+ self.epanet_api.saveInputFile(inp_file_path)
486
+
487
+ if export_sensor_config is True:
488
+ report_desc = "\n\n[REPORT]\n"
489
+ report_desc += "ENERGY YES\n"
490
+ report_desc += "STATUS YES\n"
491
+
492
+ nodes = []
493
+ links = []
494
+
495
+ # Parse sensor config
496
+ pressure_sensors = self.__sensor_config.pressure_sensors
497
+ if len(pressure_sensors) != 0:
498
+ report_desc += "Pressure YES\n"
499
+ nodes += pressure_sensors
500
+
501
+ flow_sensors = self.__sensor_config.flow_sensors
502
+ if len(flow_sensors) != 0:
503
+ report_desc += "Flow YES\n"
504
+ links += flow_sensors
505
+
506
+ demand_sensors = self.__sensor_config.demand_sensors
507
+ if len(demand_sensors) != 0:
508
+ report_desc += "Demand YES\n"
509
+ nodes += demand_sensors
510
+
511
+ node_quality_sensors = self.__sensor_config.quality_node_sensors
512
+ if len(node_quality_sensors) != 0:
513
+ report_desc += "Quality YES\n"
514
+ nodes += node_quality_sensors
515
+
516
+ link_quality_sensors = self.__sensor_config.quality_link_sensors
517
+ if len(link_quality_sensors) != 0:
518
+ if len(node_quality_sensors) == 0:
519
+ report_desc += "Quality YES\n"
520
+ links += link_quality_sensors
521
+
522
+ # Create final REPORT section
523
+ nodes = list(set(nodes))
524
+ links = list(set(links))
525
+
526
+ if len(nodes) != 0:
527
+ if set(nodes) == set(self.__sensor_config.nodes):
528
+ nodes = ["ALL"]
529
+ report_desc += f"NODES {' '.join(nodes)}\n"
530
+
531
+ if len(links) != 0:
532
+ if set(links) == set(self.__sensor_config.links):
533
+ links = ["ALL"]
534
+ report_desc += f"LINKS {' '.join(links)}\n"
535
+
536
+ __override_report_section(inp_file_path, report_desc)
537
+
538
+ if self.__f_msx_in is not None and msx_file_path is not None:
539
+ self.epanet_api.saveMSXFile(msx_file_path)
540
+
541
+ if export_sensor_config is True:
542
+ report_desc = "\n\n[REPORT]\n"
543
+ species = []
544
+ nodes = []
545
+ links = []
546
+
547
+ # Parse sensor config
548
+ bulk_species_node_sensors = self.__sensor_config.bulk_species_node_sensors
549
+ for bulk_species_id in bulk_species_node_sensors.keys():
550
+ species.append(bulk_species_id)
551
+ nodes += bulk_species_node_sensors[bulk_species_id]
552
+
553
+ bulk_species_link_sensors = self.__sensor_config.bulk_species_link_sensors
554
+ for bulk_species_id in bulk_species_link_sensors.keys():
555
+ species.append(bulk_species_id)
556
+ links += bulk_species_link_sensors[bulk_species_id]
557
+
558
+ surface_species_link_sensors = self.__sensor_config.surface_species_sensors
559
+ for surface_species_id in surface_species_link_sensors.keys():
560
+ species.append(surface_species_id)
561
+ links += surface_species_link_sensors[surface_species_id]
562
+
563
+ nodes = list(set(nodes))
564
+ links = list((set(links)))
565
+ species = list(set(species))
566
+
567
+ # Create REPORT section
568
+ if len(nodes) != 0:
569
+ if set(nodes) == set(self.__sensor_config.nodes):
570
+ nodes = ["ALL"]
571
+ report_desc += f"NODES {' '.join(nodes)}\n"
572
+
573
+ if len(links) != 0:
574
+ if set(links) == set(self.__sensor_config.links):
575
+ links = ["ALL"]
576
+ report_desc += f"LINKS {' '.join(links)}\n"
577
+
578
+ for species_id in species:
579
+ report_desc += f"SPECIES {species_id} YES\n"
580
+
581
+ __override_report_section(msx_file_path, report_desc)
582
+
384
583
  def get_flow_units(self) -> int:
385
584
  """
386
585
  Gets the flow units.
387
586
 
388
587
  Will be one of the following EPANET toolkit constants:
389
588
 
390
- - EN_CFS = 0
391
- - EN_GPM = 1
392
- - EN_MGD = 2
393
- - EN_IMGD = 3
394
- - EN_AFD = 4
395
- - EN_LPS = 5
396
- - EN_LPM = 6
397
- - EN_MLD = 7
398
- - EN_CMH = 8
399
- - EN_CMD = 9
589
+ - EN_CFS = 0 (cu foot/sec)
590
+ - EN_GPM = 1 (gal/min)
591
+ - EN_MGD = 2 (Million gal/day)
592
+ - EN_IMGD = 3 (Imperial MGD)
593
+ - EN_AFD = 4 (ac-foot/day)
594
+ - EN_LPS = 5 (liter/sec)
595
+ - EN_LPM = 6 (liter/min)
596
+ - EN_MLD = 7 (Megaliter/day)
597
+ - EN_CMH = 8 (cubic meter/hr)
598
+ - EN_CMD = 9 (cubic meter/day)
400
599
 
401
600
  Returns
402
601
  -------
@@ -405,6 +604,27 @@ class ScenarioSimulator():
405
604
  """
406
605
  return self.epanet_api.api.ENgetflowunits()
407
606
 
607
+ def get_units_category(self) -> int:
608
+ """
609
+ Gets the category of units -- i.e. US Customary or SI Metric units.
610
+
611
+ Will be one of the following constants:
612
+
613
+ - UNITS_USCUSTOM = 0 (US Customary)
614
+ - UNITS_SIMETRIC = 1 (SI Metric)
615
+
616
+ Returns
617
+ -------
618
+ `int`
619
+ Units category.
620
+ """
621
+ if self.get_flow_units() in [ToolkitConstants.EN_CFS, ToolkitConstants.EN_GPM,
622
+ ToolkitConstants.EN_MGD, ToolkitConstants.EN_IMGD,
623
+ ToolkitConstants.EN_AFD]:
624
+ return UNITS_USCUSTOM
625
+ else:
626
+ return UNITS_SIMETRIC
627
+
408
628
  def get_hydraulic_time_step(self) -> int:
409
629
  """
410
630
  Gets the hydraulic time step -- i.e. time step in the hydraulic simulation.
@@ -471,7 +691,7 @@ class ScenarioSimulator():
471
691
  return {"code": qual_info.QualityCode,
472
692
  "type": qual_info.QualityType,
473
693
  "chemical_name": qual_info.QualityChemName,
474
- "units": qual_info.QualityChemUnits,
694
+ "units": qualityunit_to_id(qual_info.QualityChemUnits),
475
695
  "trace_node_id": qual_info.TraceNode}
476
696
 
477
697
  def get_scenario_config(self) -> ScenarioConfig:
@@ -518,6 +738,10 @@ class ScenarioSimulator():
518
738
  n_quantities = self.epanet_api.getNodeCount() * 3 + self.epanet_api.getNodeTankCount() + \
519
739
  self.epanet_api.getLinkValveCount() + self.epanet_api.getLinkPumpCount() + \
520
740
  self.epanet_api.getLinkCount() * 2
741
+
742
+ if self.__f_msx_in is not None:
743
+ n_quantities += self.epanet_api.getLinkCount() * 2 + self.epanet_api.getNodeCount()
744
+
521
745
  n_bytes_per_quantity = 64
522
746
 
523
747
  return n_time_steps * n_quantities * n_bytes_per_quantity * .000001
@@ -537,6 +761,9 @@ class ScenarioSimulator():
537
761
  nodes_id = self.epanet_api.getNodeNameID()
538
762
  nodes_elevation = self.epanet_api.getNodeElevations()
539
763
  nodes_type = [self.epanet_api.TYPENODE[i] for i in self.epanet_api.getNodeTypeIndex()]
764
+ nodes_coord = [self.epanet_api.api.ENgetcoord(node_idx)
765
+ for node_idx in self.epanet_api.getNodeIndex()]
766
+ node_tank_names = self.epanet_api.getNodeTankNameID()
540
767
 
541
768
  links_id = self.epanet_api.getLinkNameID()
542
769
  links_data = self.epanet_api.getNodesConnectingLinksID()
@@ -549,8 +776,16 @@ class ScenarioSimulator():
549
776
 
550
777
  # Build graph describing the topology
551
778
  nodes = []
552
- for node, node_elevation, node_type in zip(nodes_id, nodes_elevation, nodes_type):
553
- nodes.append((node, {"elevation": node_elevation, "type": node_type}))
779
+ for node_id, node_elevation, node_type, node_coord in zip(nodes_id, nodes_elevation,
780
+ nodes_type, nodes_coord):
781
+ node_info = {"elevation": node_elevation,
782
+ "coord": node_coord,
783
+ "type": node_type}
784
+ if node_type == "TANK":
785
+ node_tank_idx = node_tank_names.index(node_id) + 1
786
+ node_info["diameter"] = float(self.epanet_api.getNodeTankDiameter(node_tank_idx))
787
+
788
+ nodes.append((node_id, node_info))
554
789
 
555
790
  links = []
556
791
  for link_id, link, diameter, length, roughness_coeff, bulk_coeff, wall_coeff, loss_coeff \
@@ -561,7 +796,8 @@ class ScenarioSimulator():
561
796
  "bulk_coeff": bulk_coeff, "wall_coeff": wall_coeff,
562
797
  "loss_coeff": loss_coeff}))
563
798
 
564
- return NetworkTopology(f_inp=self.f_inp_in, nodes=nodes, links=links)
799
+ return NetworkTopology(f_inp=self.f_inp_in, nodes=nodes, links=links,
800
+ units=self.get_units_category())
565
801
 
566
802
  def randomize_demands(self) -> None:
567
803
  """
@@ -974,6 +1210,9 @@ class ScenarioSimulator():
974
1210
  :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
975
1211
  Quality simulation results as SCADA data.
976
1212
  """
1213
+ if self.__f_msx_in is None:
1214
+ raise ValueError("No .msx file specified")
1215
+
977
1216
  result = None
978
1217
 
979
1218
  gen = self.run_advanced_quality_simulation_as_generator
@@ -1035,6 +1274,9 @@ class ScenarioSimulator():
1035
1274
  Generator containing the current EPANET-MSX simulation results as SCADA data
1036
1275
  (i.e. species concentrations).
1037
1276
  """
1277
+ if self.__f_msx_in is None:
1278
+ raise ValueError("No .msx file specified")
1279
+
1038
1280
  # Load pre-computed hydraulics
1039
1281
  self.epanet_api.useMSXHydraulicFile(hyd_file_in)
1040
1282
 
@@ -1508,8 +1750,7 @@ class ScenarioSimulator():
1508
1750
  # Fetch data
1509
1751
  pressure_data = self.epanet_api.getNodePressure().reshape(1, -1)
1510
1752
  flow_data = self.epanet_api.getLinkFlows().reshape(1, -1)
1511
- demand_data = self.epanet_api.getNodeActualDemand().reshape(1,
1512
- -1) # TODO: Does not go back after emitter coefficient is changed back to zero
1753
+ demand_data = self.epanet_api.getNodeActualDemand().reshape(1, -1)
1513
1754
  quality_node_data = self.epanet_api.getNodeActualQuality().reshape(1, -1)
1514
1755
  quality_link_data = self.epanet_api.getLinkActualQuality().reshape(1, -1)
1515
1756
  pumps_state_data = self.epanet_api.getLinkPumpState().reshape(1, -1)
@@ -1610,7 +1851,7 @@ class ScenarioSimulator():
1610
1851
  def set_general_parameters(self, demand_model: dict = None, simulation_duration: int = None,
1611
1852
  hydraulic_time_step: int = None, quality_time_step: int = None,
1612
1853
  reporting_time_step: int = None, reporting_time_start: int = None,
1613
- flow_units: int = None, quality_model: dict = None) -> None:
1854
+ flow_units_id: int = None, quality_model: dict = None) -> None:
1614
1855
  """
1615
1856
  Sets some general parameters.
1616
1857
 
@@ -1655,22 +1896,22 @@ class ScenarioSimulator():
1655
1896
  Start time (in seconds) at which reporting of hydraulic and quality states starts.
1656
1897
 
1657
1898
  The default is None.
1658
- flow_units : `int`, optional
1899
+ flow_units_id : `int`, optional
1659
1900
  Specifies the flow units -- i.e. all flows will be reported in these units.
1660
1901
  If None, the units from the .inp file will be used.
1661
1902
 
1662
1903
  Must be one of the following EPANET toolkit constants:
1663
1904
 
1664
- - EN_CFS = 0
1665
- - EN_GPM = 1
1666
- - EN_MGD = 2
1667
- - EN_IMGD = 3
1668
- - EN_AFD = 4
1669
- - EN_LPS = 5
1670
- - EN_LPM = 6
1671
- - EN_MLD = 7
1672
- - EN_CMH = 8
1673
- - EN_CMD = 9
1905
+ - EN_CFS = 0 (cubic foot/sec)
1906
+ - EN_GPM = 1 (gal/min)
1907
+ - EN_MGD = 2 (Million gal/day)
1908
+ - EN_IMGD = 3 (Imperial MGD)
1909
+ - EN_AFD = 4 (ac-foot/day)
1910
+ - EN_LPS = 5 (liter/sec)
1911
+ - EN_LPM = 6 (liter/min)
1912
+ - EN_MLD = 7 (Megaliter/day)
1913
+ - EN_CMH = 8 (cubic meter/hr)
1914
+ - EN_CMD = 9 (cubic meter/day)
1674
1915
 
1675
1916
  The default is None.
1676
1917
  quality_model : `dict`, optional
@@ -1691,8 +1932,7 @@ class ScenarioSimulator():
1691
1932
  if not isinstance(simulation_duration, int) or simulation_duration <= 0:
1692
1933
  raise ValueError("'simulation_duration' must be a positive integer specifying " +
1693
1934
  "the number of seconds to simulate")
1694
- self.epanet_api.setTimeSimulationDuration(simulation_duration) # TODO: Changing the simulation
1695
- # duration from .inp file seems to break EPANET-MSX
1935
+ self.epanet_api.setTimeSimulationDuration(simulation_duration)
1696
1936
 
1697
1937
  if hydraulic_time_step is not None:
1698
1938
  if not isinstance(hydraulic_time_step, int) or hydraulic_time_step <= 0:
@@ -1727,29 +1967,29 @@ class ScenarioSimulator():
1727
1967
  "greater than the hydraulic time step")
1728
1968
  self.epanet_api.setTimeQualityStep(quality_time_step)
1729
1969
 
1730
- if flow_units is not None:
1731
- if flow_units == ToolkitConstants.EN_CFS:
1970
+ if flow_units_id is not None:
1971
+ if flow_units_id == ToolkitConstants.EN_CFS:
1732
1972
  self.epanet_api.setFlowUnitsCFS()
1733
- elif flow_units == ToolkitConstants.EN_GPM:
1973
+ elif flow_units_id == ToolkitConstants.EN_GPM:
1734
1974
  self.epanet_api.setFlowUnitsGPM()
1735
- elif flow_units == ToolkitConstants.EN_MGD:
1975
+ elif flow_units_id == ToolkitConstants.EN_MGD:
1736
1976
  self.epanet_api.setFlowUnitsMGD()
1737
- elif flow_units == ToolkitConstants.EN_IMGD:
1977
+ elif flow_units_id == ToolkitConstants.EN_IMGD:
1738
1978
  self.epanet_api.setFlowUnitsIMGD()
1739
- elif flow_units == ToolkitConstants.EN_AFD:
1979
+ elif flow_units_id == ToolkitConstants.EN_AFD:
1740
1980
  self.epanet_api.setFlowUnitsAFD()
1741
- elif flow_units == ToolkitConstants.EN_LPS:
1981
+ elif flow_units_id == ToolkitConstants.EN_LPS:
1742
1982
  self.epanet_api.setFlowUnitsLPS()
1743
- elif flow_units == ToolkitConstants.EN_LPM:
1983
+ elif flow_units_id == ToolkitConstants.EN_LPM:
1744
1984
  self.epanet_api.setFlowUnitsLPM()
1745
- elif flow_units == ToolkitConstants.EN_MLD:
1985
+ elif flow_units_id == ToolkitConstants.EN_MLD:
1746
1986
  self.epanet_api.setFlowUnitsMLD()
1747
- elif flow_units == ToolkitConstants.EN_CMH:
1987
+ elif flow_units_id == ToolkitConstants.EN_CMH:
1748
1988
  self.epanet_api.setFlowUnitsCMH()
1749
- elif flow_units == ToolkitConstants.EN_CMD:
1989
+ elif flow_units_id == ToolkitConstants.EN_CMD:
1750
1990
  self.epanet_api.setFlowUnitsCMD()
1751
1991
  else:
1752
- raise ValueError(f"Unknown flow units '{flow_units}'")
1992
+ raise ValueError(f"Unknown flow units '{flow_units_id}'")
1753
1993
 
1754
1994
  if quality_model is not None:
1755
1995
  if quality_model["type"] == "NONE":
@@ -1758,7 +1998,7 @@ class ScenarioSimulator():
1758
1998
  self.epanet_api.setQualityType("age")
1759
1999
  elif quality_model["type"] == "CHEM":
1760
2000
  self.epanet_api.setQualityType("chem", quality_model["chemical_name"],
1761
- quality_model["units"])
2001
+ qualityunit_to_str(quality_model["units"]))
1762
2002
  elif quality_model["type"] == "TRACE":
1763
2003
  self.epanet_api.setQualityType("trace", quality_model["trace_node_id"])
1764
2004
  else:
@@ -1809,7 +2049,7 @@ class ScenarioSimulator():
1809
2049
  self.set_general_parameters(quality_model={"type": "AGE"})
1810
2050
 
1811
2051
  def enable_chemical_analysis(self, chemical_name: str = "Chlorine",
1812
- chemical_units: str = "mg/L") -> None:
2052
+ chemical_units: int = MASS_UNIT_MG) -> None:
1813
2053
  """
1814
2054
  Sets chemical analysis.
1815
2055
 
@@ -1823,9 +2063,13 @@ class ScenarioSimulator():
1823
2063
  The default is "Chlorine".
1824
2064
  chemical_units : `str`, optional
1825
2065
  Units that the chemical is measured in.
1826
- Either "mg/L" or "ug/L".
1827
2066
 
1828
- The default is "mg/L".
2067
+ Must be one of the following constants:
2068
+
2069
+ - MASS_UNIT_MG = 4 (mg/L)
2070
+ - MASS_UNIT_UG = 5 (ug/L)
2071
+
2072
+ The default is MASS_UNIT_MG.
1829
2073
  """
1830
2074
  self.__adapt_to_network_changes()
1831
2075