epyt-flow 0.1.1__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.
@@ -118,7 +118,6 @@ class ScadaData(Serializable):
118
118
 
119
119
  The default is False.
120
120
  """
121
-
122
121
  def __init__(self, sensor_config: SensorConfig, sensor_readings_time: np.ndarray,
123
122
  pressure_data_raw: np.ndarray = None, flow_data_raw: np.ndarray = None,
124
123
  demand_data_raw: np.ndarray = None, node_quality_data_raw: np.ndarray = None,
@@ -132,7 +131,8 @@ class ScadaData(Serializable):
132
131
  sensor_faults: list[SensorFault] = [],
133
132
  sensor_reading_attacks: list[SensorReadingAttack] = [],
134
133
  sensor_reading_events: list[SensorReadingEvent] = [],
135
- sensor_noise: SensorNoise = None, frozen_sensor_config: bool = False, **kwds):
134
+ sensor_noise: SensorNoise = None, frozen_sensor_config: bool = False,
135
+ **kwds):
136
136
  if not isinstance(sensor_config, SensorConfig):
137
137
  raise TypeError("'sensor_config' must be an instance of " +
138
138
  "'epyt_flow.simulation.SensorConfig' but not of " +
@@ -73,13 +73,7 @@ class ScadaDataExport():
73
73
  """
74
74
  old_sensor_config = scada_data.sensor_config
75
75
 
76
- sensor_config = SensorConfig(nodes=old_sensor_config.nodes,
77
- links=old_sensor_config.links,
78
- valves=old_sensor_config.valves,
79
- pumps=old_sensor_config.pumps,
80
- tanks=old_sensor_config.tanks,
81
- bulk_species=old_sensor_config.bulk_species,
82
- surface_species=old_sensor_config.surface_species)
76
+ sensor_config = SensorConfig.create_empty_sensor_config(old_sensor_config)
83
77
  sensor_config.pressure_sensors = sensor_config.nodes
84
78
  sensor_config.flow_sensors = sensor_config.links
85
79
  sensor_config.demand_sensors = sensor_config.nodes
@@ -582,24 +582,18 @@ class ScenarioConfig(Serializable):
582
582
  sensor_config = None
583
583
  from .scenario_simulator import ScenarioSimulator
584
584
  with ScenarioSimulator(f_inp_in) as scenario:
585
- sensor_config = SensorConfig(nodes=scenario.sensor_config.nodes,
586
- links=scenario.sensor_config.links,
587
- valves=scenario.sensor_config.valves,
588
- pumps=scenario.sensor_config.pumps,
589
- tanks=scenario.sensor_config.tanks,
590
- bulk_species=scenario.sensor_config.bulk_species,
591
- surface_species=scenario.sensor_config.surface_species,
592
- pressure_sensors=pressure_sensors,
593
- flow_sensors=flow_sensors,
594
- demand_sensors=demand_sensors,
595
- quality_node_sensors=node_quality_sensors,
596
- quality_link_sensors=link_quality_sensors,
597
- valve_state_sensors=valve_state_sensors,
598
- pump_state_sensors=pump_state_sensors,
599
- tank_volume_sensors=tank_volume_sensors,
600
- bulk_species_node_sensors=bulk_species_node_sensors,
601
- bulk_species_link_sensors=bulk_species_link_sensors,
602
- surface_species_sensors=surface_species_sensors)
585
+ sensor_config = SensorConfig.create_empty_sensor_config(scenario.sensor_config)
586
+ sensor_config.pressure_sensors = pressure_sensors
587
+ sensor_config.flow_sensors = flow_sensors
588
+ sensor_config.demand_sensors = demand_sensors
589
+ sensor_config.quality_node_sensors = node_quality_sensors
590
+ sensor_config.quality_link_sensors = link_quality_sensors
591
+ sensor_config.valve_state_sensors = valve_state_sensors
592
+ sensor_config.pump_state_sensors = pump_state_sensors
593
+ sensor_config.tank_volume_sensors = tank_volume_sensors
594
+ sensor_config.bulk_species_node_sensors = bulk_species_node_sensors
595
+ sensor_config.bulk_species_link_sensors = bulk_species_link_sensors
596
+ sensor_config.surface_species_sensors = surface_species_sensors
603
597
 
604
598
  # Create final scenario configuration
605
599
  return ScenarioConfig(f_inp_in=f_inp_in, f_msx_in=f_msx_in, general_params=general_params,
@@ -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,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": qualityunits_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
@@ -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,6 +1199,9 @@ 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
@@ -1035,6 +1263,9 @@ class ScenarioSimulator():
1035
1263
  Generator containing the current EPANET-MSX simulation results as SCADA data
1036
1264
  (i.e. species concentrations).
1037
1265
  """
1266
+ if self.__f_msx_in is None:
1267
+ raise ValueError("No .msx file specified")
1268
+
1038
1269
  # Load pre-computed hydraulics
1039
1270
  self.epanet_api.useMSXHydraulicFile(hyd_file_in)
1040
1271
 
@@ -1661,16 +1892,16 @@ class ScenarioSimulator():
1661
1892
 
1662
1893
  Must be one of the following EPANET toolkit constants:
1663
1894
 
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
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)
1674
1905
 
1675
1906
  The default is None.
1676
1907
  quality_model : `dict`, optional
@@ -1758,7 +1989,7 @@ class ScenarioSimulator():
1758
1989
  self.epanet_api.setQualityType("age")
1759
1990
  elif quality_model["type"] == "CHEM":
1760
1991
  self.epanet_api.setQualityType("chem", quality_model["chemical_name"],
1761
- quality_model["units"])
1992
+ qualityunits_to_str(quality_model["units"]))
1762
1993
  elif quality_model["type"] == "TRACE":
1763
1994
  self.epanet_api.setQualityType("trace", quality_model["trace_node_id"])
1764
1995
  else:
@@ -1809,7 +2040,7 @@ class ScenarioSimulator():
1809
2040
  self.set_general_parameters(quality_model={"type": "AGE"})
1810
2041
 
1811
2042
  def enable_chemical_analysis(self, chemical_name: str = "Chlorine",
1812
- chemical_units: str = "mg/L") -> None:
2043
+ chemical_units: int = MASS_UNIT_MG) -> None:
1813
2044
  """
1814
2045
  Sets chemical analysis.
1815
2046
 
@@ -1823,9 +2054,13 @@ class ScenarioSimulator():
1823
2054
  The default is "Chlorine".
1824
2055
  chemical_units : `str`, optional
1825
2056
  Units that the chemical is measured in.
1826
- Either "mg/L" or "ug/L".
1827
2057
 
1828
- 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.
1829
2064
  """
1830
2065
  self.__adapt_to_network_changes()
1831
2066