epyt-flow 0.1.0__py3-none-any.whl → 0.2.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.
@@ -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, qualityunits_to_id, \
21
+ qualityunits_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 = qualityunits_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,57 +372,33 @@ 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}
331
378
  valve_id_to_idx = None # {valve_id: self.epanet_api.getLinkValveIndex(valve_id) for valve_id in valves}
332
- pump_id_to_idx = None # {pump_id: self.epanet_api.getLinkPumpIndex(pump_id) - 1 for pump_id in pumps}
333
- tank_id_to_idx = None #{tank_id: self.epanet_api.getNodeTankIndex(tank_id) - 1 for tank_id in tanks}
379
+ pump_id_to_idx = None # {pump_id: self.epanet_api.getLinkPumpIndex(pump_id) - 1 for pump_id in pumps}
380
+ tank_id_to_idx = None # {tank_id: self.epanet_api.getNodeTankIndex(tank_id) - 1 for tank_id in tanks}
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": qualityunits_to_id(qual_info.QualityChemUnits),
475
695
  "trace_node_id": qual_info.TraceNode}
476
696
 
477
697
  def get_scenario_config(self) -> ScenarioConfig:
@@ -516,8 +736,12 @@ class ScenarioSimulator():
516
736
  n_time_steps = int(self.epanet_api.getTimeSimulationDuration() /
517
737
  self.epanet_api.getTimeReportingStep())
518
738
  n_quantities = self.epanet_api.getNodeCount() * 3 + self.epanet_api.getNodeTankCount() + \
519
- self.epanet_api.getLinkValveCount() + self.epanet_api.getLinkPumpCount() + \
520
- self.epanet_api.getLinkCount() * 2
739
+ self.epanet_api.getLinkValveCount() + self.epanet_api.getLinkPumpCount() + \
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
@@ -561,7 +785,8 @@ class ScenarioSimulator():
561
785
  "bulk_coeff": bulk_coeff, "wall_coeff": wall_coeff,
562
786
  "loss_coeff": loss_coeff}))
563
787
 
564
- return NetworkTopology(f_inp=self.f_inp_in, nodes=nodes, links=links)
788
+ return NetworkTopology(f_inp=self.f_inp_in, nodes=nodes, links=links,
789
+ units=self.get_units_category())
565
790
 
566
791
  def randomize_demands(self) -> None:
567
792
  """
@@ -974,30 +1199,48 @@ class ScenarioSimulator():
974
1199
  :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
975
1200
  Quality simulation results as SCADA data.
976
1201
  """
1202
+ if self.__f_msx_in is None:
1203
+ raise ValueError("No .msx file specified")
1204
+
977
1205
  result = None
978
1206
 
979
1207
  gen = self.run_advanced_quality_simulation_as_generator
980
1208
  for scada_data in gen(hyd_file_in=hyd_file_in,
981
1209
  verbose=verbose,
1210
+ return_as_dict=True,
982
1211
  frozen_sensor_config=frozen_sensor_config):
983
1212
  if result is None:
984
- result = scada_data
1213
+ result = {}
1214
+ for data_type, data in scada_data.items():
1215
+ result[data_type] = [data]
985
1216
  else:
986
- result.concatenate(scada_data)
1217
+ for data_type, data in scada_data.items():
1218
+ result[data_type].append(data)
987
1219
 
988
- return result
1220
+ # Build ScadaData instance
1221
+ for data_type in result:
1222
+ if not any(d is None for d in result[data_type]):
1223
+ result[data_type] = np.concatenate(result[data_type], axis=0)
1224
+ else:
1225
+ result[data_type] = None
1226
+
1227
+ return ScadaData(**result,
1228
+ sensor_config=self.__sensor_config,
1229
+ sensor_reading_events=self.__sensor_reading_events,
1230
+ sensor_noise=self.__sensor_noise,
1231
+ frozen_sensor_config=frozen_sensor_config)
989
1232
 
990
1233
  def run_advanced_quality_simulation_as_generator(self, hyd_file_in: str, verbose: bool = False,
991
1234
  support_abort: bool = False,
992
1235
  return_as_dict: bool = False,
993
1236
  frozen_sensor_config: bool = False
994
- ) -> Generator[Union[ScadaData, dict],
995
- bool, None]:
1237
+ ) -> Generator[Union[ScadaData, dict], bool, None]:
996
1238
  """
997
1239
  Runs an advanced quality analysis using EPANET-MSX.
998
1240
 
999
1241
  Parameters
1000
1242
  ----------
1243
+ support_abort : `bool`, optional
1001
1244
  hyd_file_in : `str`
1002
1245
  Path to an EPANET .hyd file for storing the simulated hydraulics --
1003
1246
  the quality analysis is computed using those hydraulics.
@@ -1020,6 +1263,9 @@ class ScenarioSimulator():
1020
1263
  Generator containing the current EPANET-MSX simulation results as SCADA data
1021
1264
  (i.e. species concentrations).
1022
1265
  """
1266
+ if self.__f_msx_in is None:
1267
+ raise ValueError("No .msx file specified")
1268
+
1023
1269
  # Load pre-computed hydraulics
1024
1270
  self.epanet_api.useMSXHydraulicFile(hyd_file_in)
1025
1271
 
@@ -1054,13 +1300,13 @@ class ScenarioSimulator():
1054
1300
  bulk_species_link_concentrations = []
1055
1301
  for species_idx in bulk_species_idx:
1056
1302
  cur_species_concentrations = []
1057
- for node_idx in range(1, n_nodes+1):
1303
+ for node_idx in range(1, n_nodes + 1):
1058
1304
  concen = msx_get_cur_value(0, node_idx, species_idx)
1059
1305
  cur_species_concentrations.append(concen)
1060
1306
  bulk_species_node_concentrations.append(cur_species_concentrations)
1061
1307
 
1062
1308
  cur_species_concentrations = []
1063
- for link_idx in range(1, n_links+1):
1309
+ for link_idx in range(1, n_links + 1):
1064
1310
  concen = msx_get_cur_value(1, link_idx, species_idx)
1065
1311
  cur_species_concentrations.append(concen)
1066
1312
  bulk_species_link_concentrations.append(cur_species_concentrations)
@@ -1068,13 +1314,13 @@ class ScenarioSimulator():
1068
1314
  if len(bulk_species_node_concentrations) == 0:
1069
1315
  bulk_species_node_concentrations = None
1070
1316
  else:
1071
- bulk_species_node_concentrations = np.array(bulk_species_node_concentrations).\
1317
+ bulk_species_node_concentrations = np.array(bulk_species_node_concentrations). \
1072
1318
  reshape((1, len(bulk_species_idx), n_nodes))
1073
1319
 
1074
1320
  if len(bulk_species_link_concentrations) == 0:
1075
1321
  bulk_species_link_concentrations = None
1076
1322
  else:
1077
- bulk_species_link_concentrations = np.array(bulk_species_link_concentrations).\
1323
+ bulk_species_link_concentrations = np.array(bulk_species_link_concentrations). \
1078
1324
  reshape((1, len(bulk_species_idx), n_links))
1079
1325
 
1080
1326
  # Surface species
@@ -1082,7 +1328,7 @@ class ScenarioSimulator():
1082
1328
  for species_idx in surface_species_idx:
1083
1329
  cur_species_concentrations = []
1084
1330
 
1085
- for link_idx in range(1, n_links+1):
1331
+ for link_idx in range(1, n_links + 1):
1086
1332
  concen = msx_get_cur_value(1, link_idx, species_idx)
1087
1333
  cur_species_concentrations.append(concen)
1088
1334
 
@@ -1091,7 +1337,7 @@ class ScenarioSimulator():
1091
1337
  if len(surface_species_concentrations) == 0:
1092
1338
  surface_species_concentrations = None
1093
1339
  else:
1094
- surface_species_concentrations = np.array(surface_species_concentrations).\
1340
+ surface_species_concentrations = np.array(surface_species_concentrations). \
1095
1341
  reshape((1, len(surface_species_idx), n_links))
1096
1342
 
1097
1343
  return bulk_species_node_concentrations, bulk_species_link_concentrations, \
@@ -1102,14 +1348,17 @@ class ScenarioSimulator():
1102
1348
  surface_species_concentrations = __get_concentrations(init_qual=True)
1103
1349
 
1104
1350
  if verbose is True:
1105
- next(progress_bar)
1351
+ try:
1352
+ next(progress_bar)
1353
+ except StopIteration:
1354
+ pass
1106
1355
 
1107
1356
  if reporting_time_start == 0:
1108
1357
  if return_as_dict is True:
1109
1358
  yield {"bulk_species_node_concentration_raw": bulk_species_node_concentrations,
1110
1359
  "bulk_species_link_concentration_raw": bulk_species_link_concentrations,
1111
1360
  "surface_species_concentration_raw": surface_species_concentrations,
1112
- "sensor_readings_time": np.array([total_time])}
1361
+ "sensor_readings_time": np.array([0])}
1113
1362
  else:
1114
1363
  yield ScadaData(sensor_config=self.__sensor_config,
1115
1364
  bulk_species_node_concentration_raw=bulk_species_node_concentrations,
@@ -1131,21 +1380,24 @@ class ScenarioSimulator():
1131
1380
  # Compute current time step
1132
1381
  total_time, tleft = self.epanet_api.stepMSXQualityAnalysisTimeLeft()
1133
1382
 
1383
+ if verbose is True:
1384
+ try:
1385
+ next(progress_bar)
1386
+ except StopIteration:
1387
+ pass
1388
+
1134
1389
  # Fetch data at regular time intervals
1135
1390
  if total_time % hyd_time_step == 0:
1136
1391
  bulk_species_node_concentrations, bulk_species_link_concentrations, \
1137
1392
  surface_species_concentrations = __get_concentrations()
1138
1393
 
1139
- if verbose is True:
1140
- next(progress_bar)
1141
-
1142
1394
  # Report results in a regular time interval only!
1143
1395
  if total_time % reporting_time_step == 0 and total_time >= reporting_time_start:
1144
1396
  if return_as_dict is True:
1145
1397
  yield {"bulk_species_node_concentration_raw":
1146
- bulk_species_node_concentrations,
1398
+ bulk_species_node_concentrations,
1147
1399
  "bulk_species_link_concentration_raw":
1148
- bulk_species_link_concentrations,
1400
+ bulk_species_link_concentrations,
1149
1401
  "surface_species_concentration_raw": surface_species_concentrations,
1150
1402
  "sensor_readings_time": np.array([total_time])}
1151
1403
  else:
@@ -1216,13 +1468,13 @@ class ScenarioSimulator():
1216
1468
  support_abort: bool = False,
1217
1469
  return_as_dict: bool = False,
1218
1470
  frozen_sensor_config: bool = False,
1219
- ) -> Generator[Union[ScadaData, dict],
1220
- bool, None]:
1471
+ ) -> Generator[Union[ScadaData, dict], bool, None]:
1221
1472
  """
1222
1473
  Runs a basic quality analysis using EPANET.
1223
1474
 
1224
1475
  Parameters
1225
1476
  ----------
1477
+ support_abort : `bool`, optional
1226
1478
  hyd_file_in : `str`
1227
1479
  Path to an EPANET .hyd file for storing the simulated hydraulics --
1228
1480
  the quality analysis is computed using those hydraulics.
@@ -1277,16 +1529,16 @@ class ScenarioSimulator():
1277
1529
 
1278
1530
  if verbose is True:
1279
1531
  if (total_time + tstep) % requested_time_step == 0:
1280
- next(progress_bar)
1532
+ try:
1533
+ next(progress_bar)
1534
+ except StopIteration:
1535
+ pass
1281
1536
 
1282
1537
  # Compute current time step
1283
1538
  t = self.epanet_api.runQualityAnalysis()
1284
1539
  total_time = t
1285
1540
 
1286
1541
  # Fetch data
1287
- quality_node_data = None
1288
- quality_link_data = None
1289
-
1290
1542
  quality_node_data = self.epanet_api.getNodeActualQuality().reshape(1, -1)
1291
1543
  quality_link_data = self.epanet_api.getLinkActualQuality().reshape(1, -1)
1292
1544
 
@@ -1381,7 +1633,11 @@ class ScenarioSimulator():
1381
1633
  if hyd_export_old is not None:
1382
1634
  shutil.copyfile(hyd_export, hyd_export_old)
1383
1635
 
1384
- os.remove(hyd_export)
1636
+ try:
1637
+ # temp solution
1638
+ os.remove(hyd_export)
1639
+ except:
1640
+ warnings.warn(f"Failed to remove temporary file '{hyd_export}'")
1385
1641
 
1386
1642
  return result
1387
1643
 
@@ -1464,8 +1720,11 @@ class ScenarioSimulator():
1464
1720
  first_itr = False
1465
1721
 
1466
1722
  if verbose is True:
1467
- if (total_time + tstep) % requested_time_step == 0:
1723
+ #if (total_time + tstep) % requested_time_step == 0:
1724
+ try:
1468
1725
  next(progress_bar)
1726
+ except StopIteration:
1727
+ pass
1469
1728
 
1470
1729
  # Apply system events in a regular time interval only!
1471
1730
  if (total_time + tstep) % requested_time_step == 0:
@@ -1478,14 +1737,6 @@ class ScenarioSimulator():
1478
1737
  total_time = t
1479
1738
 
1480
1739
  # Fetch data
1481
- pressure_data = None
1482
- flow_data = None
1483
- demand_data = None
1484
- quality_node_data = None
1485
- quality_link_data = None
1486
- pumps_state_data = None
1487
- valves_state_data = None
1488
-
1489
1740
  pressure_data = self.epanet_api.getNodePressure().reshape(1, -1)
1490
1741
  flow_data = self.epanet_api.getLinkFlows().reshape(1, -1)
1491
1742
  demand_data = self.epanet_api.getNodeActualDemand().reshape(1,
@@ -1641,16 +1892,16 @@ class ScenarioSimulator():
1641
1892
 
1642
1893
  Must be one of the following EPANET toolkit constants:
1643
1894
 
1644
- - EN_CFS = 0
1645
- - EN_GPM = 1
1646
- - EN_MGD = 2
1647
- - EN_IMGD = 3
1648
- - EN_AFD = 4
1649
- - EN_LPS = 5
1650
- - EN_LPM = 6
1651
- - EN_MLD = 7
1652
- - EN_CMH = 8
1653
- - EN_CMD = 9
1895
+ - EN_CFS = 0 (cu foot/sec)
1896
+ - EN_GPM = 1 (gal/min)
1897
+ - EN_MGD = 2 (Million gal/day)
1898
+ - EN_IMGD = 3 (Imperial MGD)
1899
+ - EN_AFD = 4 (ac-foot/day)
1900
+ - EN_LPS = 5 (liter/sec)
1901
+ - EN_LPM = 6 (liter/min)
1902
+ - EN_MLD = 7 (Megaliter/day)
1903
+ - EN_CMH = 8 (cubic meter/hr)
1904
+ - EN_CMD = 9 (cubic meter/day)
1654
1905
 
1655
1906
  The default is None.
1656
1907
  quality_model : `dict`, optional
@@ -1738,7 +1989,7 @@ class ScenarioSimulator():
1738
1989
  self.epanet_api.setQualityType("age")
1739
1990
  elif quality_model["type"] == "CHEM":
1740
1991
  self.epanet_api.setQualityType("chem", quality_model["chemical_name"],
1741
- quality_model["units"])
1992
+ qualityunits_to_str(quality_model["units"]))
1742
1993
  elif quality_model["type"] == "TRACE":
1743
1994
  self.epanet_api.setQualityType("trace", quality_model["trace_node_id"])
1744
1995
  else:
@@ -1789,7 +2040,7 @@ class ScenarioSimulator():
1789
2040
  self.set_general_parameters(quality_model={"type": "AGE"})
1790
2041
 
1791
2042
  def enable_chemical_analysis(self, chemical_name: str = "Chlorine",
1792
- chemical_units: str = "mg/L") -> None:
2043
+ chemical_units: int = MASS_UNIT_MG) -> None:
1793
2044
  """
1794
2045
  Sets chemical analysis.
1795
2046
 
@@ -1803,9 +2054,13 @@ class ScenarioSimulator():
1803
2054
  The default is "Chlorine".
1804
2055
  chemical_units : `str`, optional
1805
2056
  Units that the chemical is measured in.
1806
- Either "mg/L" or "ug/L".
1807
2057
 
1808
- The default is "mg/L".
2058
+ Must be one of the following constants:
2059
+
2060
+ - MASS_UNIT_MG = 4 (mg/L)
2061
+ - MASS_UNIT_UG = 5 (ug/L)
2062
+
2063
+ The default is MASS_UNIT_MG.
1809
2064
  """
1810
2065
  self.__adapt_to_network_changes()
1811
2066