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
@@ -2,8 +2,11 @@
2
2
  Module provides a class for implementing sensor configurations.
3
3
  """
4
4
  from copy import deepcopy
5
+ import warnings
6
+ import itertools
5
7
  import numpy as np
6
8
  import epyt
9
+ from epyt.epanet import ToolkitConstants
7
10
 
8
11
  from ..serialization import SENSOR_CONFIG_ID, JsonSerializable, serializable
9
12
 
@@ -20,6 +23,268 @@ SENSOR_TYPE_NODE_BULK_SPECIES = 9
20
23
  SENSOR_TYPE_LINK_BULK_SPECIES = 10
21
24
  SENSOR_TYPE_SURFACE_SPECIES = 11
22
25
 
26
+ AREA_UNIT_FT2 = 1
27
+ AREA_UNIT_M2 = 2
28
+ AREA_UNIT_CM2 = 3
29
+ MASS_UNIT_MG = 4
30
+ MASS_UNIT_UG = 5
31
+ MASS_UNIT_MOL = 6
32
+ MASS_UNIT_MMOL = 7
33
+ TIME_UNIT_HRS = 8
34
+
35
+
36
+ def areaunit_to_id(unit_desc: str) -> int:
37
+ """
38
+ Converts a given area units string to the corresponding ID.
39
+
40
+ Parameters
41
+ ----------
42
+ unit_desc : `str`
43
+ Area units string.
44
+
45
+ Returns
46
+ -------
47
+ `int`
48
+ Corresponding area unit ID.
49
+ """
50
+ return {"FT2": AREA_UNIT_FT2,
51
+ "M2": AREA_UNIT_M2,
52
+ "CM2": AREA_UNIT_CM2}[unit_desc]
53
+
54
+
55
+ def massunit_to_id(unit_desc: str) -> int:
56
+ """
57
+ Converts a given mass units string to the corresponding ID.
58
+
59
+ Parameters
60
+ ----------
61
+ unit_desc : `str`
62
+ Mass units string.
63
+
64
+ Returns
65
+ -------
66
+ `int`
67
+ Corresponding mass unit ID.
68
+ """
69
+ return {"MG": MASS_UNIT_MG,
70
+ "UG": MASS_UNIT_UG,
71
+ "MOL": MASS_UNIT_MOL,
72
+ "MMOL": MASS_UNIT_MMOL}[unit_desc]
73
+
74
+
75
+ def qualityunit_to_id(unit_desc: str) -> int:
76
+ """
77
+ Converts a given measurement unit description to the corresponding mass unit ID.
78
+
79
+ Parameters
80
+ ----------
81
+ unit_desc : `str`
82
+ Mass unit.
83
+
84
+ Returns
85
+ -------
86
+ `int`
87
+ Mass unit ID.
88
+
89
+ Will be either None (if no water quality analysis was set up) or
90
+ one of the following constants:
91
+
92
+ - MASS_UNIT_MG = 4 (mg/L)
93
+ - MASS_UNIT_UG = 5 (ug/L)
94
+ - TIME_UNIT_HRS = 8 (hrs)
95
+ """
96
+ if unit_desc == "mg/L":
97
+ return MASS_UNIT_MG
98
+ elif unit_desc == "ug/L":
99
+ return MASS_UNIT_UG
100
+ elif unit_desc == "hrs":
101
+ return TIME_UNIT_HRS
102
+ else:
103
+ return None
104
+
105
+
106
+ def massunit_to_str(unit_id: int) -> str:
107
+ """
108
+ Converts a given mass unit ID to the corresponding description.
109
+
110
+ Parameters
111
+ ----------
112
+ unit_id : `int`
113
+ ID of the flow unit.
114
+
115
+ Must be one of the following constant:
116
+
117
+ - MASS_UNIT_MG = 4
118
+ - MASS_UNIT_UG = 5
119
+ - MASS_UNIT_MOL = 6
120
+ - MASS_UNIT_MMOL = 7
121
+
122
+ Returns
123
+ -------
124
+ `str`
125
+ Mass unit description.
126
+ """
127
+ if unit_id is None:
128
+ return ""
129
+ elif unit_id == MASS_UNIT_MG:
130
+ return "MG"
131
+ elif unit_id == MASS_UNIT_UG:
132
+ return "UG"
133
+ elif unit_id == MASS_UNIT_MOL:
134
+ return "MOL"
135
+ elif unit_id == MASS_UNIT_MMOL:
136
+ return "MMOL"
137
+ else:
138
+ raise ValueError(f"Unknown mass unit ID '{unit_id}'")
139
+
140
+
141
+ def flowunit_to_str(unit_id: int) -> str:
142
+ """
143
+ Converts a given flow unit ID to the corresponding description.
144
+
145
+ Parameters
146
+ ----------
147
+ unit_id : `int`
148
+ ID of the flow unit.
149
+
150
+ Must be one of the following EPANET toolkit constants:
151
+
152
+ - EN_CFS = 0 (cubic foot/sec)
153
+ - EN_GPM = 1 (gal/min)
154
+ - EN_MGD = 2 (Million gal/day)
155
+ - EN_IMGD = 3 (Imperial MGD)
156
+ - EN_AFD = 4 (ac-foot/day)
157
+ - EN_LPS = 5 (liter/sec)
158
+ - EN_LPM = 6 (liter/min)
159
+ - EN_MLD = 7 (Megaliter/day)
160
+ - EN_CMH = 8 (cubic meter/hr)
161
+ - EN_CMD = 9 (cubic meter/day)
162
+
163
+ Returns
164
+ -------
165
+ `str`
166
+ Flow unit description.
167
+ """
168
+ if unit_id is None:
169
+ return ""
170
+ elif unit_id == ToolkitConstants.EN_CFS:
171
+ return "cubic foot/sec"
172
+ elif unit_id == ToolkitConstants.EN_GPM:
173
+ return "gal/min"
174
+ elif unit_id == ToolkitConstants.EN_MGD:
175
+ return "Million gal/day"
176
+ elif unit_id == ToolkitConstants.EN_IMGD:
177
+ return "Imperial MGD"
178
+ elif unit_id == ToolkitConstants.EN_AFD:
179
+ return "ac-foot/day"
180
+ elif unit_id == ToolkitConstants.EN_LPS:
181
+ return "liter/sec"
182
+ elif unit_id == ToolkitConstants.EN_LPM:
183
+ return "liter/min"
184
+ elif unit_id == ToolkitConstants.EN_MLD:
185
+ return "Megaliter/day"
186
+ elif unit_id == ToolkitConstants.EN_CMH:
187
+ return "cubic meter/hr"
188
+ elif unit_id == ToolkitConstants.EN_CMD:
189
+ return "cubic meter/day"
190
+ else:
191
+ raise ValueError(f"Unknown unit ID '{unit_id}'")
192
+
193
+
194
+ def qualityunit_to_str(unit_id: int) -> str:
195
+ """
196
+ Converts a given quality measurement unit ID to the corresponding description.
197
+
198
+ Parameters
199
+ ----------
200
+ unit_id : `int`
201
+ ID of the quality unit.
202
+
203
+ Must be one of the following constants:
204
+
205
+ - MASS_UNIT_MG = 4 (mg/L)
206
+ - MASS_UNIT_UG = 5 (ug/L)
207
+ - TIME_UNIT_HRS = 8 (hrs)
208
+
209
+ Returns
210
+ -------
211
+ `str`
212
+ Mass unit description.
213
+ """
214
+ if unit_id is None:
215
+ return ""
216
+ elif unit_id == MASS_UNIT_MG:
217
+ return "mg/L"
218
+ elif unit_id == MASS_UNIT_UG:
219
+ return "ug/L"
220
+ elif unit_id == TIME_UNIT_HRS:
221
+ return "hrs"
222
+ else:
223
+ raise ValueError(f"Unknown unit ID '{unit_id}'")
224
+
225
+
226
+ def areaunit_to_str(unit_id: int) -> str:
227
+ """
228
+ Converts a given area measurement unit ID to the corresponding description.
229
+
230
+ Parameters
231
+ ----------
232
+ unit_id : `int`
233
+ ID of the area unit.
234
+
235
+ Must be one of the following constants:
236
+
237
+ - AREA_UNIT_FT2 = 1
238
+ - AREA_UNIT_M2 = 2
239
+ - AREA_UNIT_CM2 = 3
240
+
241
+ Returns
242
+ -------
243
+ `str`
244
+ Area unit description.
245
+ """
246
+ if unit_id is None:
247
+ return None
248
+ elif unit_id == AREA_UNIT_FT2:
249
+ return "FT2"
250
+ elif unit_id == AREA_UNIT_M2:
251
+ return "M2"
252
+ elif unit_id == AREA_UNIT_CM2:
253
+ return "CM2"
254
+ else:
255
+ raise ValueError(f"Unknown unit ID '{unit_id}'")
256
+
257
+
258
+ def is_flowunit_simetric(unit_id: int) -> bool:
259
+ """
260
+ Checks if a given flow unit belongs to SI metric units.
261
+
262
+ Parameters
263
+ ----------
264
+ unit_id : `int`
265
+ ID of the flow unit.
266
+
267
+ Must be one of the following EPANET toolkit constants:
268
+
269
+ - EN_CFS = 0 (cubic foot/sec)
270
+ - EN_GPM = 1 (gal/min)
271
+ - EN_MGD = 2 (Million gal/day)
272
+ - EN_IMGD = 3 (Imperial MGD)
273
+ - EN_AFD = 4 (ac-foot/day)
274
+ - EN_LPS = 5 (liter/sec)
275
+ - EN_LPM = 6 (liter/min)
276
+ - EN_MLD = 7 (Megaliter/day)
277
+ - EN_CMH = 8 (cubic meter/hr)
278
+ - EN_CMD = 9 (cubic meter/day)
279
+
280
+ Returns
281
+ -------
282
+ `bool`
283
+ True if the fiven unit is a SI metric unit, False otherwise.
284
+ """
285
+ return unit_id in [ToolkitConstants.EN_LPM, ToolkitConstants.EN_MLD,
286
+ ToolkitConstants.EN_CMH, ToolkitConstants.EN_CMD]
287
+
23
288
 
24
289
  @serializable(SENSOR_CONFIG_ID, ".epytflow_sensor_config")
25
290
  class SensorConfig(JsonSerializable):
@@ -39,7 +304,7 @@ class SensorConfig(JsonSerializable):
39
304
  tanks : `list[str]`
40
305
  List of all tanks (i.e. IDs) in the network.
41
306
  species : `list[str]`
42
- List of all (EPANET-MSX) species (i.e. IDs) in the network
307
+ List of all (EPANET-MSX) species (i.e. IDs) in the network
43
308
  pressure_sensors : `list[str]`, optional
44
309
  List of all nodes (i.e. IDs) at which a pressure sensor is placed.
45
310
 
@@ -131,9 +396,65 @@ class SensorConfig(JsonSerializable):
131
396
  sorted according to their EPANET index.
132
397
 
133
398
  The default is None.
399
+ flow_unit : `int`
400
+ Specifies the flow units and consequently all other hydraulic units
401
+ (US CUSTOMARY or SI METRIC) as well.
402
+
403
+ Must be one of the following EPANET toolkit constants:
404
+
405
+ - EN_CFS = 0 (cubic foot/sec)
406
+ - EN_GPM = 1 (gal/min)
407
+ - EN_MGD = 2 (Million gal/day)
408
+ - EN_IMGD = 3 (Imperial MGD)
409
+ - EN_AFD = 4 (ac-foot/day)
410
+ - EN_LPS = 5 (liter/sec)
411
+ - EN_LPM = 6 (liter/min)
412
+ - EN_MLD = 7 (Megaliter/day)
413
+ - EN_CMH = 8 (cubic meter/hr)
414
+ - EN_CMD = 9 (cubic meter/day)
415
+ quality_unit : `str`, optional
416
+ Measurement unit (in a basic quality analysis) -- only relevant
417
+ if basic water quality is enabled.
418
+
419
+ Must be one of the following constants:
420
+
421
+ - MASS_UNIT_MG = 4 (mg/L)
422
+ - MASS_UNIT_UG = 5 (ug/L)
423
+ - TIME_UNIT_HRS = 8 (hrs)
424
+
425
+ bulk_species_mass_unit : `list[int]`, optional
426
+ Specifies the mass unit for each bulk species -- only relevant if EPANET-MSX is used.
427
+
428
+ Must be one of the following constants:
429
+
430
+ - MASS_UNIT_MG = 4 (milligram)
431
+ - MASS_UNIT_UG = 5 (microgram)
432
+ - MASS_UNIT_MOL = 6 (mole)
433
+ - MASS_UNIT_MMOL = 7 (millimole)
434
+
435
+ Note that the assumed ordering is the same as given in 'bulk_species'.
436
+ surface_species_mass_unit : `list[int]`, optional
437
+ Specifies the mass unit for each surface species -- only relevant if EPANET-MSX is used.
438
+
439
+ Must be one of the following constants:
440
+
441
+ - MASS_UNIT_MG = 4 (milligram)
442
+ - MASS_UNIT_UG = 5 (microgram)
443
+ - MASS_UNIT_MOL = 6 (mole)
444
+ - MASS_UNIT_MMOL = 7 (millimole)
445
+
446
+ Note that the assumed ordering is the same as given in 'surface_species'.
447
+ surface_species_area_unit : `int`, optional
448
+ Species the area unit of all surface species -- only relevant if EPANET-MSX is used.
449
+ Must be one of the following constants:
450
+
451
+ - AREA_UNIT_FT2 = 1 (square feet)
452
+ - AREA_UNIT_M2 = 2 (square meters)
453
+ - AREA_UNIT_CM2 = 3 (square centimeters)
134
454
  """
135
455
  def __init__(self, nodes: list[str], links: list[str], valves: list[str], pumps: list[str],
136
456
  tanks: list[str], bulk_species: list[str], surface_species: list[str],
457
+ flow_unit: int = None,
137
458
  pressure_sensors: list[str] = [],
138
459
  flow_sensors: list[str] = [],
139
460
  demand_sensors: list[str] = [],
@@ -148,7 +469,12 @@ class SensorConfig(JsonSerializable):
148
469
  node_id_to_idx: dict = None, link_id_to_idx: dict = None,
149
470
  valve_id_to_idx: dict = None, pump_id_to_idx: dict = None,
150
471
  tank_id_to_idx: dict = None, bulkspecies_id_to_idx: dict = None,
151
- surfacespecies_id_to_idx: dict = None, **kwds):
472
+ surfacespecies_id_to_idx: dict = None,
473
+ quality_unit: int = None,
474
+ bulk_species_mass_unit : list[int] = [],
475
+ surface_species_mass_unit : list[int] = [],
476
+ surface_species_area_unit : int = None,
477
+ **kwds):
152
478
  if not isinstance(nodes, list):
153
479
  raise TypeError("'nodes' must be an instance of 'list[str]' " +
154
480
  f"but not of '{type(nodes)}'")
@@ -259,7 +585,8 @@ class SensorConfig(JsonSerializable):
259
585
  if any(bulk_species_id not in bulk_species
260
586
  for bulk_species_id in bulk_species_node_sensors.keys()):
261
587
  raise ValueError("Unknown bulk species ID in 'bulk_species_node_sensors'")
262
- if any(node_id not in nodes for node_id in bulk_species_node_sensors.values()):
588
+ if any(node_id not in nodes for node_id in list(itertools.chain(
589
+ *bulk_species_node_sensors.values()))):
263
590
  raise ValueError("Unknown node ID in 'bulk_species_node_sensors'")
264
591
 
265
592
  if not isinstance(bulk_species_link_sensors, dict):
@@ -268,7 +595,8 @@ class SensorConfig(JsonSerializable):
268
595
  if any(bulk_species_id not in bulk_species
269
596
  for bulk_species_id in bulk_species_link_sensors.keys()):
270
597
  raise ValueError("Unknown bulk species ID in 'bulk_species_link_sensors'")
271
- if any(link_id not in links for link_id in bulk_species_link_sensors.values()):
598
+ if any(link_id not in links for link_id in list(itertools.chain(
599
+ *bulk_species_link_sensors.values()))):
272
600
  raise ValueError("Unknown link/pipe ID in 'bulk_species_link_sensors'")
273
601
 
274
602
  if not isinstance(surface_species_sensors, dict):
@@ -277,7 +605,8 @@ class SensorConfig(JsonSerializable):
277
605
  if any(surface_species_id not in surface_species_sensors
278
606
  for surface_species_id in surface_species_sensors.keys()):
279
607
  raise ValueError("Unknown surface species ID in 'surface_species_sensors'")
280
- if any(link_id not in links for link_id in surface_species_sensors.values()):
608
+ if any(link_id not in links for link_id in list(itertools.chain(
609
+ *surface_species_sensors.values()))):
281
610
  raise ValueError("Unknown link ID in 'surface_species_sensors'")
282
611
 
283
612
  if node_id_to_idx is not None:
@@ -329,6 +658,48 @@ class SensorConfig(JsonSerializable):
329
658
  if any(s not in surface_species for s in surfacespecies_id_to_idx.keys()):
330
659
  raise ValueError("Unknown surface species ID in 'surfacespecies_id_to_idx'")
331
660
 
661
+ if flow_unit is not None:
662
+ if not isinstance(flow_unit, int):
663
+ raise TypeError("'flow_unit' must be a an instance of 'int' " +
664
+ f"but not of '{type(flow_unit)}'")
665
+ if flow_unit not in range(10):
666
+ raise ValueError("Invalid value of 'flow_unit'")
667
+ else:
668
+ warnings.warn("Loading a file that was created with an outdated version of EPyT-Flow" +
669
+ " -- support of such old files will be removed in the next release!",
670
+ DeprecationWarning)
671
+
672
+ if quality_unit is not None:
673
+ if not isinstance(quality_unit, int):
674
+ raise TypeError("'quality_mass_unit' must be an instance of 'int' " +
675
+ f"but not of '{type(quality_unit)}'")
676
+ if quality_unit not in [MASS_UNIT_MG, MASS_UNIT_UG, TIME_UNIT_HRS]:
677
+ raise ValueError("Invalid value of 'quality_unit'")
678
+
679
+ if len(bulk_species_mass_unit) != len(bulk_species):
680
+ raise ValueError("Inconsistency between 'bulk_species_mass_unit' and 'bulk_species'")
681
+ if any(not isinstance(mass_unit, int) for mass_unit in bulk_species_mass_unit):
682
+ raise TypeError("All items in 'bulk_species_mass_unit' must be an instance of 'int'")
683
+ if any(mass_unit not in [MASS_UNIT_MG, MASS_UNIT_UG, MASS_UNIT_MOL, MASS_UNIT_MMOL]
684
+ for mass_unit in bulk_species_mass_unit):
685
+ raise ValueError("Invalid mass unit in 'bulk_species_mass_unit'")
686
+
687
+ if len(surface_species_mass_unit) != len(surface_species):
688
+ raise ValueError("Inconsistency between 'surface_species_mass_unit' " +
689
+ "and 'surface_species'")
690
+ if any(not isinstance(mass_unit, int) for mass_unit in surface_species_mass_unit):
691
+ raise TypeError("All items in 'surface_species_mass_unit' must be an instance of 'int'")
692
+ if any(mass_unit not in [MASS_UNIT_MG, MASS_UNIT_UG, MASS_UNIT_MOL, MASS_UNIT_MMOL]
693
+ for mass_unit in surface_species_mass_unit):
694
+ raise ValueError("Invalid mass unit in 'surface_species_mass_unit'")
695
+
696
+ if surface_species_area_unit is not None:
697
+ if not isinstance(surface_species_area_unit, int):
698
+ raise TypeError("'surface_species_area_unit' must be a an instance of 'int' " +
699
+ f"but not of '{type(surface_species_area_unit)}'")
700
+ if surface_species_area_unit not in [AREA_UNIT_FT2, AREA_UNIT_M2, AREA_UNIT_CM2]:
701
+ raise ValueError("Invalid area unit 'surface_species_area_unit'")
702
+
332
703
  self.__nodes = nodes
333
704
  self.__links = links
334
705
  self.__valves = valves
@@ -354,12 +725,165 @@ class SensorConfig(JsonSerializable):
354
725
  self.__tank_id_to_idx = tank_id_to_idx
355
726
  self.__bulkspecies_id_to_idx = bulkspecies_id_to_idx
356
727
  self.__surfacespecies_id_to_idx = surfacespecies_id_to_idx
728
+ self.__flow_unit = flow_unit
729
+ self.__quality_unit = quality_unit
730
+ self.__bulk_species_mass_unit = bulk_species_mass_unit
731
+ self.__surface_species_mass_unit = surface_species_mass_unit
732
+ self.__surface_species_area_unit = surface_species_area_unit
357
733
 
358
734
  self.__compute_indices() # Compute indices
359
735
 
360
736
  super().__init__(**kwds)
361
737
 
362
- def node_id_to_idx(self, node_id: str) -> int:
738
+ @staticmethod
739
+ def create_empty_sensor_config(sensor_config):
740
+ """
741
+ Creates an empty sensor configuration from a given sensor configuration
742
+ -- i.e. a clone of the given sensor configuration except that no sensors are set.
743
+
744
+ Parameters
745
+ ----------
746
+ sensor_config : :class:`~epyt_flow.simulation.sensor_config.SensorConfig`
747
+ Sensor configuration used as a basis.
748
+
749
+ Returns
750
+ -------
751
+ :class:`epyt_flow.simulation.sensor_config.SensorConfig`
752
+ Empty sensor configuration.
753
+ """
754
+ return SensorConfig(nodes=sensor_config.nodes,
755
+ links=sensor_config.links,
756
+ valves=sensor_config.valves,
757
+ pumps=sensor_config.pumps,
758
+ tanks=sensor_config.tanks,
759
+ flow_unit=sensor_config.flow_unit,
760
+ quality_unit=sensor_config.quality_unit,
761
+ bulk_species=sensor_config.bulk_species,
762
+ surface_species=sensor_config.surface_species,
763
+ bulk_species_mass_unit=sensor_config.bulk_species_mass_unit,
764
+ surface_species_mass_unit=sensor_config.surface_species_mass_unit,
765
+ surface_species_area_unit=sensor_config.surface_species_area_unit,
766
+ node_id_to_idx=sensor_config.node_id_to_idx,
767
+ link_id_to_idx=sensor_config.link_id_to_idx,
768
+ valve_id_to_idx=sensor_config.valve_id_to_idx,
769
+ pump_id_to_idx=sensor_config.pump_id_to_idx,
770
+ tank_id_to_idx=sensor_config.tank_id_to_idx,
771
+ bulkspecies_id_to_idx=sensor_config.bulkspecies_id_to_idx,
772
+ surfacespecies_id_to_idx=sensor_config.surfacespecies_id_to_idx)
773
+
774
+ @property
775
+ def node_id_to_idx(self) -> dict:
776
+ """
777
+ Mapping of a surface node ID to the EPANET index
778
+ (i.e. position in the raw sensor reading data).
779
+
780
+ If None, it is assumed that the nodes (in 'nodes') are
781
+ sorted according to their EPANET index.
782
+
783
+ Returns
784
+ -------
785
+ `dict`
786
+ Node ID to index mapping.
787
+ """
788
+ return self.__node_id_to_idx
789
+
790
+ @property
791
+ def link_id_to_idx(self) -> dict:
792
+ """
793
+ Mapping of a link/pipe ID to the EPANET index
794
+ (i.e. position in the raw sensor reading data).
795
+
796
+ If None is given, it is assumed that the links/pipes (in 'links') are
797
+ sorted according to their EPANET index.
798
+
799
+ Returns
800
+ -------
801
+ `dict`
802
+ Link/Pipe ID to index mapping.
803
+ """
804
+ return self.__link_id_to_idx
805
+
806
+ @property
807
+ def valve_id_to_idx(self) -> dict:
808
+ """
809
+ Mapping of a valve ID to the EPANET index
810
+ (i.e. position in the raw sensor reading data).
811
+
812
+ If None, it is assumed that the valves (in 'valves') are
813
+ sorted according to their EPANET index.
814
+
815
+ Returns
816
+ -------
817
+ `dict`
818
+ Valve ID to index mapping.
819
+ """
820
+ return self.__valve_id_to_idx
821
+
822
+ @property
823
+ def pump_id_to_idx(self) -> dict:
824
+ """
825
+ Mapping of a pump ID to the EPANET index
826
+ (i.e. position in the raw sensor reading data).
827
+
828
+ If None, it is assumed that the pumps (in 'pumps') are
829
+ sorted according to their EPANET index.
830
+
831
+ Returns
832
+ -------
833
+ `dict`
834
+ Pump ID to index mapping.
835
+ """
836
+ return self.__pump_id_to_idx
837
+
838
+ @property
839
+ def tank_id_to_idx(self) -> dict:
840
+ """
841
+ Mapping of a tank ID to the EPANET index
842
+ (i.e. position in the raw sensor reading data).
843
+
844
+ If None, it is assumed that the tanks (in 'tanks') are
845
+ sorted according to their EPANET index.
846
+
847
+ Returns
848
+ -------
849
+ `dict`
850
+ Tank ID to index mapping.
851
+ """
852
+ return self.__tank_id_to_idx
853
+
854
+ @property
855
+ def bulkspecies_id_to_idx(self) -> dict:
856
+ """
857
+ Mapping of a bulk species ID to the EPANET index
858
+ (i.e. position in the raw sensor reading data).
859
+
860
+ If None, it is assumed that the bulk species (in 'bulk_species') are
861
+ sorted according to their EPANET index.
862
+
863
+ Returns
864
+ -------
865
+ `dict`
866
+ Bulk species ID to index mapping.
867
+ """
868
+ return self.__bulkspecies_id_to_idx
869
+
870
+ @property
871
+ def surfacespecies_id_to_idx(self) -> dict:
872
+ """
873
+ Mapping of a surface species ID to the EPANET index
874
+ (i.e. position in the raw sensor reading data).
875
+
876
+ If None, it is assumed that the surface species (in 'surface_species') are
877
+ sorted according to their EPANET index.
878
+
879
+ Returns
880
+ -------
881
+ `dict`
882
+ Surface species ID to index mapping.
883
+ """
884
+ return self.__surfacespecies_id_to_idx
885
+
886
+ def map_node_id_to_idx(self, node_id: str) -> int:
363
887
  """
364
888
  Gets the index of a given node ID.
365
889
 
@@ -378,7 +902,7 @@ class SensorConfig(JsonSerializable):
378
902
  else:
379
903
  return self.__nodes.index(node_id)
380
904
 
381
- def link_id_to_idx(self, link_id: str) -> int:
905
+ def map_link_id_to_idx(self, link_id: str) -> int:
382
906
  """
383
907
  Gets the index of a given link ID.
384
908
 
@@ -397,7 +921,7 @@ class SensorConfig(JsonSerializable):
397
921
  else:
398
922
  return self.__links.index(link_id)
399
923
 
400
- def valve_id_to_idx(self, valve_id: str) -> int:
924
+ def map_valve_id_to_idx(self, valve_id: str) -> int:
401
925
  """
402
926
  Gets the index of a given valve ID.
403
927
 
@@ -416,7 +940,7 @@ class SensorConfig(JsonSerializable):
416
940
  else:
417
941
  return self.__valves.index(valve_id)
418
942
 
419
- def pump_id_to_idx(self, pump_id: str) -> int:
943
+ def map_pump_id_to_idx(self, pump_id: str) -> int:
420
944
  """
421
945
  Gets the index of a given pump ID.
422
946
 
@@ -435,7 +959,7 @@ class SensorConfig(JsonSerializable):
435
959
  else:
436
960
  return self.__pumps.index(pump_id)
437
961
 
438
- def tank_id_to_idx(self, tank_id: str) -> int:
962
+ def map_tank_id_to_idx(self, tank_id: str) -> int:
439
963
  """
440
964
  Gets the index of a given tank ID.
441
965
 
@@ -454,7 +978,7 @@ class SensorConfig(JsonSerializable):
454
978
  else:
455
979
  return self.__tanks.index(tank_id)
456
980
 
457
- def bulkspecies_id_to_idx(self, bulk_species_id: str) -> int:
981
+ def map_bulkspecies_id_to_idx(self, bulk_species_id: str) -> int:
458
982
  """
459
983
  Gets the index of a given bulk species ID.
460
984
 
@@ -473,7 +997,7 @@ class SensorConfig(JsonSerializable):
473
997
  else:
474
998
  return self.__bulk_species.index(bulk_species_id)
475
999
 
476
- def surfacespecies_id_to_idx(self, surface_species_id: str) -> int:
1000
+ def map_surfacespecies_id_to_idx(self, surface_species_id: str) -> int:
477
1001
  """
478
1002
  Gets the index of a given surface species ID.
479
1003
 
@@ -493,35 +1017,35 @@ class SensorConfig(JsonSerializable):
493
1017
  return self.__surface_species.index(surface_species_id)
494
1018
 
495
1019
  def __compute_indices(self):
496
- self.__pressure_idx = np.array([self.node_id_to_idx(n)
1020
+ self.__pressure_idx = np.array([self.map_node_id_to_idx(n)
497
1021
  for n in self.__pressure_sensors], dtype=np.int32)
498
- self.__flow_idx = np.array([self.link_id_to_idx(link)
1022
+ self.__flow_idx = np.array([self.map_link_id_to_idx(link)
499
1023
  for link in self.__flow_sensors], dtype=np.int32)
500
- self.__demand_idx = np.array([self.node_id_to_idx(n)
1024
+ self.__demand_idx = np.array([self.map_node_id_to_idx(n)
501
1025
  for n in self.__demand_sensors], dtype=np.int32)
502
- self.__quality_node_idx = np.array([self.node_id_to_idx(n)
1026
+ self.__quality_node_idx = np.array([self.map_node_id_to_idx(n)
503
1027
  for n in self.__quality_node_sensors], dtype=np.int32)
504
- self.__quality_link_idx = np.array([self.link_id_to_idx(link)
1028
+ self.__quality_link_idx = np.array([self.map_link_id_to_idx(link)
505
1029
  for link in self.__quality_link_sensors],
506
1030
  dtype=np.int32)
507
- self.__valve_state_idx = np.array([self.valve_id_to_idx(v)
1031
+ self.__valve_state_idx = np.array([self.map_valve_id_to_idx(v)
508
1032
  for v in self.__valve_state_sensors], dtype=np.int32)
509
- self.__pump_state_idx = np.array([self.pump_id_to_idx(p)
1033
+ self.__pump_state_idx = np.array([self.map_pump_id_to_idx(p)
510
1034
  for p in self.__pump_state_sensors], dtype=np.int32)
511
- self.__tank_volume_idx = np.array([self.tank_id_to_idx(t)
1035
+ self.__tank_volume_idx = np.array([self.map_tank_id_to_idx(t)
512
1036
  for t in self.__tank_volume_sensors], dtype=np.int32)
513
- self.__bulk_species_node_idx = np.array([(self.bulkspecies_id_to_idx(s),
514
- [self.node_id_to_idx(node_id)
1037
+ self.__bulk_species_node_idx = np.array([(self.map_bulkspecies_id_to_idx(s),
1038
+ [self.map_node_id_to_idx(node_id)
515
1039
  for node_id in self.__bulk_species_node_sensors[s]])
516
1040
  for s in self.__bulk_species_node_sensors.keys()],
517
1041
  dtype=object)
518
- self.__bulk_species_link_idx = np.array([(self.bulkspecies_id_to_idx(s),
519
- [self.link_id_to_idx(link_id)
1042
+ self.__bulk_species_link_idx = np.array([(self.map_bulkspecies_id_to_idx(s),
1043
+ [self.map_link_id_to_idx(link_id)
520
1044
  for link_id in self.__bulk_species_link_sensors[s]])
521
1045
  for s in self.__bulk_species_link_sensors.keys()],
522
1046
  dtype=object)
523
- self.__surface_species_idx = np.array([(self.surfacespecies_id_to_idx(s),
524
- [self.link_id_to_idx(link_id)
1047
+ self.__surface_species_idx = np.array([(self.map_surfacespecies_id_to_idx(s),
1048
+ [self.map_link_id_to_idx(link_id)
525
1049
  for link_id in self.__surface_species_sensors[s]])
526
1050
  for s in self.__surface_species_sensors.keys()],
527
1051
  dtype=object)
@@ -534,8 +1058,10 @@ class SensorConfig(JsonSerializable):
534
1058
  n_valve_state_sensors = len(self.__valve_state_sensors)
535
1059
  n_pump_state_sensors = len(self.__pump_state_sensors)
536
1060
  n_tank_volume_sensors = len(self.__tank_volume_sensors)
537
- n_bulk_species_node_sensors = len(self.__bulk_species_node_sensors.values())
538
- n_bulk_species_link_sensors = len(self.__bulk_species_link_sensors.values())
1061
+ n_bulk_species_node_sensors = len(list(itertools.chain(
1062
+ *self.__bulk_species_node_sensors.values())))
1063
+ n_bulk_species_link_sensors = len(list(itertools.chain(
1064
+ *self.__bulk_species_link_sensors.values())))
539
1065
 
540
1066
  pressure_idx_shift = 0
541
1067
  flow_idx_shift = pressure_idx_shift + n_pressure_sensors
@@ -701,6 +1227,50 @@ class SensorConfig(JsonSerializable):
701
1227
  """
702
1228
  return self.__tanks.copy()
703
1229
 
1230
+ @property
1231
+ def flow_unit(self) -> int:
1232
+ """
1233
+ Gets the flow units.
1234
+ Note that this specifies all other hydraulic units as well.
1235
+
1236
+ Will be one of the following EPANET toolkit constants:
1237
+
1238
+ - EN_CFS = 0 (cubic foot/sec)
1239
+ - EN_GPM = 1 (gal/min)
1240
+ - EN_MGD = 2 (Million gal/day)
1241
+ - EN_IMGD = 3 (Imperial MGD)
1242
+ - EN_AFD = 4 (ac-foot/day)
1243
+ - EN_LPS = 5 (liter/sec)
1244
+ - EN_LPM = 6 (liter/min)
1245
+ - EN_MLD = 7 (Megaliter/day)
1246
+ - EN_CMH = 8 (cubic meter/hr)
1247
+ - EN_CMD = 9 (cubic meter/day)
1248
+
1249
+ Returns
1250
+ -------
1251
+ `int`
1252
+ Flow unit ID.
1253
+ """
1254
+ return self.__flow_unit
1255
+
1256
+ @property
1257
+ def quality_unit(self) -> int:
1258
+ """
1259
+ Gets the measurement unit ID used in the basic quality analysis.
1260
+
1261
+ Will be one of the following constants:
1262
+
1263
+ - MASS_UNIT_MG = 4 (milligram)
1264
+ - MASS_UNIT_UG = 5 (microgram)
1265
+ - TIME_UNIT_HRS = 6 (hours)
1266
+
1267
+ Returns
1268
+ -------
1269
+ `int`
1270
+ Mass unit ID.
1271
+ """
1272
+ return self.__quality_unit
1273
+
704
1274
  @property
705
1275
  def bulk_species(self) -> list[str]:
706
1276
  """
@@ -725,6 +1295,62 @@ class SensorConfig(JsonSerializable):
725
1295
  """
726
1296
  return self.__surface_species.copy()
727
1297
 
1298
+ @property
1299
+ def bulk_species_mass_unit(self) -> list[int]:
1300
+ """
1301
+ Gets the mass unit of each bulk species.
1302
+
1303
+ Will be one of the following constants:
1304
+
1305
+ - MASS_UNIT_MG = 4 (milligram)
1306
+ - MASS_UNIT_UG = 5 (microgram)
1307
+ - MASS_UNIT_MOL = 6 (mole)
1308
+ - MASS_UNIT_MMOL = 7 (millimole)
1309
+
1310
+ Returns
1311
+ -------
1312
+ `int`
1313
+ Mass unit ID.
1314
+ """
1315
+ return self.__bulk_species_mass_unit
1316
+
1317
+ @property
1318
+ def surface_species_mass_unit(self) -> list[int]:
1319
+ """
1320
+ Gets the mass unit of each surface species.
1321
+
1322
+ Will be one of the following constants:
1323
+
1324
+ - MASS_UNIT_MG = 4 (milligram)
1325
+ - MASS_UNIT_UG = 5 (microgram)
1326
+ - MASS_UNIT_MOL = 6 (mole)
1327
+ - MASS_UNIT_MMOL = 7 (millimole)
1328
+
1329
+ Returns
1330
+ -------
1331
+ `int`
1332
+ Mass unit ID.
1333
+ """
1334
+ return self.__surface_species_mass_unit
1335
+
1336
+ @property
1337
+ def surface_species_area_unit(self) -> int:
1338
+ """
1339
+ Gets the surface species area unit.
1340
+
1341
+ Will be one of the following constants:
1342
+
1343
+ - AREA_UNIT_FT2 = 1 (square feet)
1344
+ - AREA_UNIT_M2 = 2 (square meters)
1345
+ - AREA_UNIT_CM2 = 3 (square centimeters)
1346
+
1347
+ Returns
1348
+ -------
1349
+ `int`
1350
+ Area unit ID.
1351
+ """
1352
+ return self.__surface_species_area_unit
1353
+
728
1354
  @property
729
1355
  def pressure_sensors(self) -> list[str]:
730
1356
  """
@@ -972,7 +1598,8 @@ class SensorConfig(JsonSerializable):
972
1598
  f"but not of '{type(bulk_species_sensors)}'")
973
1599
  if any(species_id not in self.__bulk_species for species_id in bulk_species_sensors.keys()):
974
1600
  raise ValueError("Unknown bulk species ID in 'bulk_species_sensors'")
975
- if any(link_id not in self.__links for link_id in sum(bulk_species_sensors.values(), [])):
1601
+ if any(link_id not in self.__links for link_id in list(itertools.chain(
1602
+ *bulk_species_sensors.values()))):
976
1603
  raise ValueError("Unknown link/pipe ID in 'bulk_species_sensors'")
977
1604
 
978
1605
  self.__bulk_species_link_sensors = bulk_species_sensors
@@ -1001,7 +1628,7 @@ class SensorConfig(JsonSerializable):
1001
1628
  for species_id in surface_species_sensors.keys()):
1002
1629
  raise ValueError("Unknown surface species ID in 'surface_species_sensors'")
1003
1630
  if any(link_id not in self.__links
1004
- for link_id in sum(surface_species_sensors.values(), [])):
1631
+ for link_id in list(itertools.chain(*surface_species_sensors.values()))):
1005
1632
  raise ValueError("Unknown link/pipe ID in 'surface_species_sensors'")
1006
1633
 
1007
1634
  self.__surface_species_sensors = surface_species_sensors
@@ -1042,7 +1669,12 @@ class SensorConfig(JsonSerializable):
1042
1669
  "pump_id_to_idx": self.__pump_id_to_idx,
1043
1670
  "tank_id_to_idx": self.__tank_id_to_idx,
1044
1671
  "bulkspecies_id_to_idx": self.__bulkspecies_id_to_idx,
1045
- "surfacespecies_id_to_idx": self.__surfacespecies_id_to_idx}
1672
+ "surfacespecies_id_to_idx": self.__surfacespecies_id_to_idx,
1673
+ "flow_unit": self.__flow_unit,
1674
+ "quality_unit": self.__quality_unit,
1675
+ "bulk_species_mass_unit": self.__bulk_species_mass_unit,
1676
+ "surface_species_mass_unit": self.__surface_species_mass_unit,
1677
+ "surface_species_area_unit": self.__surface_species_area_unit}
1046
1678
 
1047
1679
  return super().get_attributes() | attr
1048
1680
 
@@ -1065,22 +1697,46 @@ class SensorConfig(JsonSerializable):
1065
1697
  and self.__tank_volume_sensors == other.tank_volume_sensors \
1066
1698
  and self.__bulk_species_node_sensors == other.bulk_species_node_sensors \
1067
1699
  and self.__bulk_species_link_sensors == other.bulk_species_link_sensors \
1068
- and self.__surface_species_sensors == other.surface_species_sensors
1700
+ and self.__surface_species_sensors == other.surface_species_sensors \
1701
+ and self.__flow_unit == other.flow_unit \
1702
+ and self.__quality_unit == other.quality_unit \
1703
+ and self.__bulk_species_mass_unit == other.bulk_species_mass_unit \
1704
+ and self.__surface_species_mass_unit == other.surface_species_mass_unit \
1705
+ and self.__surface_species_area_unit == other.surface_species_area_unit \
1706
+ and self.__node_id_to_idx == other.node_id_to_idx \
1707
+ and self.__link_id_to_idx == other.link_id_to_idx \
1708
+ and self.__valve_id_to_idx == other.valve_id_to_idx \
1709
+ and self.__pump_id_to_idx == other.pump_id_to_idx \
1710
+ and self.__tank_id_to_idx == other.tank_id_to_idx \
1711
+ and self.__bulkspecies_id_to_idx == other.bulkspecies_id_to_idx \
1712
+ and self.__surfacespecies_id_to_idx == other.surfacespecies_id_to_idx
1069
1713
 
1070
1714
  def __str__(self) -> str:
1071
1715
  return f"nodes: {self.__nodes} links: {self.__links} valves: {self.__valves} " +\
1072
1716
  f"pumps: {self.__pumps} tanks: {self.__tanks} bulk_species: {self.__bulk_species} " +\
1073
- f"surface_species: {self.__surface_species}" + \
1717
+ f"surface_species: {self.__surface_species} " + \
1718
+ f"node_id_to_idx: {self.__node_id_to_idx} link_id_to_idx: {self.__link_id_to_idx} " +\
1719
+ f"pump_id_to_idx: {self.__pump_id_to_idx} tank_id_to_idx: {self.__tank_id_to_idx} " +\
1720
+ f"valve_id_to_idx: {self.__valve_id_to_idx} " +\
1721
+ f"bulkspecies_id_to_idx: {self.__bulkspecies_id_to_idx} " +\
1722
+ f"surfacespecies_id_to_idx: {self.__surfacespecies_id_to_idx}" +\
1074
1723
  f"pressure_sensors: {self.__pressure_sensors} flow_sensors: {self.__flow_sensors} " +\
1075
1724
  f"demand_sensors: {self.__demand_sensors} " +\
1076
1725
  f"quality_node_sensors: {self.__quality_node_sensors} " +\
1077
1726
  f"quality_link_sensors: {self.__quality_link_sensors} " +\
1078
1727
  f"valve_state_sensors: {self.__valve_state_sensors} " +\
1079
1728
  f"pump_state_sensors: {self.__pump_state_sensors} " +\
1080
- f"tank_volume_sensors: {self.__tank_volume_sensors}" +\
1081
- f"bulk_species_node_sensors: {self.__bulk_species_node_sensors}" +\
1082
- f"bulk_species_link_sensors: {self.__bulk_species_link_sensors}" +\
1083
- f"surface_species_sensors: {self.__surface_species_sensors}"
1729
+ f"tank_volume_sensors: {self.__tank_volume_sensors} " +\
1730
+ f"bulk_species_node_sensors: {self.__bulk_species_node_sensors} " +\
1731
+ f"bulk_species_link_sensors: {self.__bulk_species_link_sensors} " +\
1732
+ f"surface_species_sensors: {self.__surface_species_sensors} " +\
1733
+ f"flow_unit: {flowunit_to_str(self.__flow_unit)} " +\
1734
+ f"quality_unit: {qualityunit_to_str(self.__quality_unit)} " +\
1735
+ "bulk_species_mass_unit: " +\
1736
+ f"{list(map(massunit_to_str, self.__bulk_species_mass_unit))} " +\
1737
+ "surface_species_mass_unit: " +\
1738
+ f"{list(map(massunit_to_str, self.__surface_species_mass_unit))} " +\
1739
+ f"surface_species_area_unit: {areaunit_to_str(self.__surface_species_area_unit)}"
1084
1740
 
1085
1741
  def compute_readings(self, pressures: np.ndarray, flows: np.ndarray, demands: np.ndarray,
1086
1742
  nodes_quality: np.ndarray, links_quality: np.ndarray,