epyt-flow 0.9.0__py3-none-any.whl → 0.11.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 (35) hide show
  1. epyt_flow/VERSION +1 -1
  2. epyt_flow/data/networks.py +27 -14
  3. epyt_flow/gym/control_gyms.py +8 -0
  4. epyt_flow/gym/scenario_control_env.py +17 -4
  5. epyt_flow/metrics.py +5 -0
  6. epyt_flow/models/event_detector.py +5 -0
  7. epyt_flow/models/sensor_interpolation_detector.py +5 -0
  8. epyt_flow/serialization.py +5 -0
  9. epyt_flow/simulation/__init__.py +0 -1
  10. epyt_flow/simulation/events/actuator_events.py +7 -1
  11. epyt_flow/simulation/events/sensor_reading_attack.py +16 -3
  12. epyt_flow/simulation/events/sensor_reading_event.py +18 -3
  13. epyt_flow/simulation/scada/__init__.py +3 -1
  14. epyt_flow/simulation/scada/advanced_control.py +6 -2
  15. epyt_flow/simulation/scada/complex_control.py +625 -0
  16. epyt_flow/simulation/scada/custom_control.py +134 -0
  17. epyt_flow/simulation/scada/scada_data.py +547 -8
  18. epyt_flow/simulation/scada/simple_control.py +317 -0
  19. epyt_flow/simulation/scenario_config.py +87 -26
  20. epyt_flow/simulation/scenario_simulator.py +865 -51
  21. epyt_flow/simulation/sensor_config.py +34 -2
  22. epyt_flow/topology.py +16 -0
  23. epyt_flow/uncertainty/model_uncertainty.py +80 -62
  24. epyt_flow/uncertainty/sensor_noise.py +15 -4
  25. epyt_flow/uncertainty/uncertainties.py +71 -18
  26. epyt_flow/uncertainty/utils.py +40 -13
  27. epyt_flow/utils.py +15 -1
  28. epyt_flow/visualization/__init__.py +2 -0
  29. epyt_flow/{simulation → visualization}/scenario_visualizer.py +429 -586
  30. epyt_flow/visualization/visualization_utils.py +611 -0
  31. {epyt_flow-0.9.0.dist-info → epyt_flow-0.11.0.dist-info}/LICENSE +1 -1
  32. {epyt_flow-0.9.0.dist-info → epyt_flow-0.11.0.dist-info}/METADATA +18 -6
  33. {epyt_flow-0.9.0.dist-info → epyt_flow-0.11.0.dist-info}/RECORD +35 -30
  34. {epyt_flow-0.9.0.dist-info → epyt_flow-0.11.0.dist-info}/WHEEL +1 -1
  35. {epyt_flow-0.9.0.dist-info → epyt_flow-0.11.0.dist-info}/top_level.txt +0 -0
@@ -5,7 +5,9 @@ import sys
5
5
  import os
6
6
  import pathlib
7
7
  import time
8
- from typing import Generator, Union
8
+ from datetime import timedelta
9
+ from datetime import datetime
10
+ from typing import Generator, Union, Optional
9
11
  from copy import deepcopy
10
12
  import shutil
11
13
  import warnings
@@ -28,7 +30,8 @@ from .sensor_config import SensorConfig, areaunit_to_id, massunit_to_id, quality
28
30
  from ..uncertainty import ModelUncertainty, SensorNoise
29
31
  from .events import SystemEvent, Leakage, ActuatorEvent, SensorFault, SensorReadingAttack, \
30
32
  SensorReadingEvent
31
- from .scada import ScadaData, AdvancedControlModule
33
+ from .scada import ScadaData, CustomControlModule, SimpleControlModule, ComplexControlModule, \
34
+ RuleCondition, RuleAction, ActuatorConstants, EN_R_ACTION_SETTING
32
35
  from ..topology import NetworkTopology, UNITS_SIMETRIC, UNITS_USCUSTOM
33
36
  from ..utils import get_temp_folder
34
37
 
@@ -67,8 +70,12 @@ class ScenarioSimulator():
67
70
  Sensor noise.
68
71
  _sensor_config : :class:`~epyt_flow.simulation.sensor_config.SensorConfig`, protected
69
72
  Sensor configuration.
70
- _controls : list[:class:`~epyt_flow.simulation.scada.advanced_control.AdvancedControlModule`], protected
73
+ _custom_controls : list[:class:`~epyt_flow.simulation.scada.custom_control.CustomControlModule`], protected
71
74
  List of custom control modules.
75
+ _simple_controls : list[:class:`~epyt_flow.simulation.scada.simple_control.SimpleControlModule`], protected
76
+ List of simle EPANET control rules.
77
+ _complex_controls : list[:class:`~epyt_flow.simulation.scada.complex_control.ComplexControlModule`], protected
78
+ List of complex (IF-THEN-ELSE) EPANET control rules.
72
79
  _system_events : list[:class:`~epyt_flow.simulation.events.system_event.SystemEvent`], protected
73
80
  Lsit of system events such as leakages.
74
81
  _sensor_reading_events : list[:class:`~epyt_flow.simulation.events.sensor_reading_event.SensorReadingEvent`], protected
@@ -105,7 +112,9 @@ class ScenarioSimulator():
105
112
  self._model_uncertainty = ModelUncertainty()
106
113
  self._sensor_noise = None
107
114
  self._sensor_config = None
108
- self._controls = []
115
+ self._custom_controls = []
116
+ self._simple_controls = []
117
+ self._complex_controls = []
109
118
  self._system_events = []
110
119
  self._sensor_reading_events = []
111
120
  self.__running_simulation = False
@@ -168,6 +177,9 @@ class ScenarioSimulator():
168
177
  if self.__f_msx_in is not None:
169
178
  self.epanet_api.loadMSXFile(my_f_msx_in, customMSXlib=custom_epanetmsx_lib)
170
179
 
180
+ self._simple_controls = self._parse_simple_control_rules()
181
+ self._complex_controls = self._parse_complex_control_rules()
182
+
171
183
  self._sensor_config = self._get_empty_sensor_config()
172
184
  if scenario_config is not None:
173
185
  if scenario_config.general_params is not None:
@@ -177,8 +189,12 @@ class ScenarioSimulator():
177
189
  self._sensor_noise = scenario_config.sensor_noise
178
190
  self._sensor_config = scenario_config.sensor_config
179
191
 
180
- for control in scenario_config.controls:
181
- self.add_control(control)
192
+ for control in scenario_config.custom_controls:
193
+ self.add_custom_control(control)
194
+ for control in scenario_config.simple_controls:
195
+ self.add_simple_control(control)
196
+ for control in scenario_config.complex_controls:
197
+ self.add_complex_control(control)
182
198
  for event in scenario_config.system_events:
183
199
  self.add_system_event(event)
184
200
  for event in scenario_config.sensor_reading_events:
@@ -323,18 +339,46 @@ class ScenarioSimulator():
323
339
  self._sensor_config = sensor_config
324
340
 
325
341
  @property
326
- def controls(self) -> list[AdvancedControlModule]:
342
+ def custom_controls(self) -> list[CustomControlModule]:
343
+ """
344
+ Returns all custom control modules.
345
+
346
+ Returns
347
+ -------
348
+ list[:class:`~epyt_flow.simulation.scada.custom_control.CustomControlModule`]
349
+ All custom control modules.
350
+ """
351
+ self._adapt_to_network_changes()
352
+
353
+ return deepcopy(self._custom_controls)
354
+
355
+ @property
356
+ def simple_controls(self) -> list[SimpleControlModule]:
327
357
  """
328
- Gets all control modules.
358
+ Gets all simple EPANET control rules.
329
359
 
330
360
  Returns
331
361
  -------
332
- list[:class:`~epyt_flow.simulation.scada.advanced_control.AdvancedControlModule`]
333
- All control modules.
362
+ list[:class:`~epyt_flow.simulation.scada.simple_control.SimpleControlModule`]
363
+ All simple EPANET control rules.
334
364
  """
335
365
  self._adapt_to_network_changes()
336
366
 
337
- return deepcopy(self._controls)
367
+ return deepcopy(self._simple_controls)
368
+
369
+ @property
370
+ def complex_controls(self) -> list[SimpleControlModule]:
371
+ """
372
+ Gets all complex (IF-THEN-ELSE) EPANET control rules.
373
+
374
+ Returns
375
+ -------
376
+ list[:class:`~epyt_flow.simulation.scada.complex_control.ComplexControlModule`]
377
+ All complex EPANET control rules.
378
+ """
379
+ self._adapt_to_network_changes()
380
+
381
+ return deepcopy(self._complex_controls)
338
382
 
339
383
  @property
340
384
  def leakages(self) -> list[Leakage]:
@@ -422,6 +466,132 @@ class ScenarioSimulator():
422
466
 
423
467
  return deepcopy(self._sensor_reading_events)
424
468
 
469
+ def _parse_simple_control_rules(self) -> list[SimpleControlModule]:
470
+ controls = []
471
+
472
+ for idx in self.epanet_api.getControls():
473
+ control = self.epanet_api.getControls(idx)
474
+
475
+ if control.Setting == "OPEN":
476
+ link_status = ActuatorConstants.EN_OPEN
477
+ else:
478
+ link_status = ActuatorConstants.EN_CLOSED
479
+
480
+ if control.Type == "LOWLEVEL":
481
+ cond_type = ToolkitConstants.EN_LOWLEVEL
482
+ elif control.Type == "HIGHLEVEL":
483
+ cond_type = ToolkitConstants.EN_HILEVEL
484
+ elif control.Type == "TIMER":
485
+ cond_type = ToolkitConstants.EN_TIMER
486
+ elif control.Type == "TIMEOFDAY":
487
+ cond_type = ToolkitConstants.EN_TIMEOFDAY
488
+
489
+ if control.NodeID is not None:
490
+ cond_var_value = control.NodeID
491
+ cond_comp_value = control.Value
492
+ else:
493
+ if cond_type == ToolkitConstants.EN_TIMER:
494
+ cond_var_value = int(control.Value / 3600)
495
+ elif cond_type == ToolkitConstants.EN_TIMEOFDAY:
496
+ sec = control.Value
497
+ if sec <= 43200:
498
+ cond_var_value = \
499
+ f"{':'.join(str(timedelta(seconds=sec)).split(':')[:2])} AM"
500
+ else:
501
+ sec -= 43200
502
+ cond_var_value = \
503
+ f"{':'.join(str(timedelta(seconds=sec)).split(':')[:2])} PM"
504
+ cond_comp_value = None
505
+
506
+ controls.append(SimpleControlModule(link_id=control.LinkID,
507
+ link_status=link_status,
508
+ cond_type=cond_type,
509
+ cond_var_value=cond_var_value,
510
+ cond_comp_value=cond_comp_value))
511
+
512
+ return controls
513
+
514
+ def _parse_complex_control_rules(self) -> list[ComplexControlModule]:
515
+ controls = []
516
+
517
+ rules = self.epanet_api.getRules()
518
+ for rule_idx, rule in rules.items():
519
+ rule_info = self.epanet_api.getRuleInfo(rule_idx)
520
+
521
+ rule_id = rule["Rule_ID"]
522
+ rule_priority, *_ = rule_info.Priority
523
+
524
+ # Parse conditions
525
+ n_rule_premises, *_ = rule_info.Premises
526
+
527
+ condition_1 = None
528
+ additional_conditions = []
529
+ for j in range(1, n_rule_premises + 1):
530
+ [logop, object_type_id, obj_idx, variable_type_id, relop, status, value_premise] = \
531
+ self.epanet_api.api.ENgetpremise(rule_idx, j)
532
+
533
+ object_id = None
534
+ if object_type_id == ToolkitConstants.EN_R_NODE:
535
+ object_id = self.epanet_api.getNodeNameID(obj_idx)
536
+ elif object_type_id == ToolkitConstants.EN_R_LINK:
537
+ object_id = self.epanet_api.getLinkNameID(obj_idx)
538
+ elif object_type_id == ToolkitConstants.EN_R_SYSTEM:
539
+ object_id = ""
540
+
541
+ if variable_type_id >= ToolkitConstants.EN_R_TIME:
542
+ value_premise = datetime.fromtimestamp(value_premise)\
543
+ .strftime("%I:%M %p")
544
+ if status != 0:
545
+ value_premise = self.epanet_api.RULESTATUS[status - 1]
546
+
547
+ condition = RuleCondition(object_type_id, object_id, variable_type_id,
548
+ relop, value_premise)
549
+ if condition_1 is None:
550
+ condition_1 = condition
551
+ else:
552
+ additional_conditions.append((logop, condition))
553
+
554
+ # Parse actions
555
+ n_rule_then_actions, *_ = rule_info.ThenActions
556
+ actions = []
557
+ for j in range(1, n_rule_then_actions + 1):
558
+ [link_idx, link_status, link_setting] = \
559
+ self.epanet_api.api.ENgetthenaction(rule_idx, j)
560
+
561
+ link_type_id = self.epanet_api.getLinkTypeIndex(link_idx)
562
+ link_id = self.epanet_api.getLinkNameID(link_idx)
563
+ if link_status >= 0:
564
+ action_type_id = link_status
565
+ action_value = link_status
566
+ else:
567
+ action_type_id = EN_R_ACTION_SETTING
568
+ action_value = link_setting
569
+
570
+ actions.append(RuleAction(link_type_id, link_id, action_type_id, action_value))
571
+
572
+ n_rule_else_actions, *_ = rule_info.ElseActions
573
+ else_actions = []
574
+ for j in range(1, n_rule_else_actions + 1):
575
+ [link_idx, link_status, link_setting] = \
576
+ self.epanet_api.api.ENgetelseaction(rule_idx, j)
577
+
578
+ link_type_id = self.epanet_api.getLinkType(link_idx)
579
+ link_id = self.epanet_api.getLinkNameID(link_idx)
580
+ if link_status <= 3:
581
+ action_type_id = link_status
582
+ action_value = link_status
583
+ else:
584
+ action_type_id = EN_R_ACTION_SETTING
585
+ action_value = link_setting
586
+
587
+ else_actions.append(RuleAction(link_type_id, link_id, action_type_id, action_value))
588
+
589
+ # Create and add control module
590
+ controls.append(ComplexControlModule(rule_id, condition_1, additional_conditions,
591
+ actions, else_actions, int(rule_priority)))
592
+
593
+ return controls
594
+
425
595
  def _adapt_to_network_changes(self):
426
596
  nodes = self.epanet_api.getNodeNameID()
427
597
  links = self.epanet_api.getLinkNameID()
@@ -800,7 +970,10 @@ class ScenarioSimulator():
800
970
  return ScenarioConfig(f_inp_in=self.__f_inp_in, f_msx_in=self.__f_msx_in,
801
971
  general_params=general_params, sensor_config=self.sensor_config,
802
972
  memory_consumption_estimate=self.estimate_memory_consumption(),
803
- controls=self.controls, sensor_noise=self.sensor_noise,
973
+ custom_controls=self.custom_controls,
974
+ simple_controls=self.simple_controls,
975
+ complex_controls=self.complex_controls,
976
+ sensor_noise=self.sensor_noise,
804
977
  model_uncertainty=self.model_uncertainty,
805
978
  system_events=self.system_events,
806
979
  sensor_reading_events=self.sensor_reading_events)
@@ -847,6 +1020,7 @@ class ScenarioSimulator():
847
1020
  nodes_type = [self.epanet_api.TYPENODE[i] for i in self.epanet_api.getNodeTypeIndex()]
848
1021
  nodes_coord = [self.epanet_api.api.ENgetcoord(node_idx)
849
1022
  for node_idx in self.epanet_api.getNodeIndex()]
1023
+ nodes_comments = self.epanet_api.getNodeComment()
850
1024
  node_tank_names = self.epanet_api.getNodeTankNameID()
851
1025
 
852
1026
  links_id = self.epanet_api.getLinkNameID()
@@ -866,10 +1040,12 @@ class ScenarioSimulator():
866
1040
 
867
1041
  # Build graph describing the topology
868
1042
  nodes = []
869
- for node_id, node_elevation, node_type, node_coord in zip(nodes_id, nodes_elevation,
870
- nodes_type, nodes_coord):
1043
+ for node_id, node_elevation, node_type, \
1044
+ node_coord, node_comment in zip(nodes_id, nodes_elevation, nodes_type, nodes_coord,
1045
+ nodes_comments):
871
1046
  node_info = {"elevation": node_elevation,
872
1047
  "coord": node_coord,
1048
+ "comment": node_comment,
873
1049
  "type": node_type}
874
1050
  if node_type == "TANK":
875
1051
  node_tank_idx = node_tank_names.index(node_id) + 1
@@ -878,9 +1054,10 @@ class ScenarioSimulator():
878
1054
  nodes.append((node_id, node_info))
879
1055
 
880
1056
  links = []
881
- for link_id, link_type, link, diameter, length, roughness_coeff, bulk_coeff, wall_coeff, loss_coeff \
882
- in zip(links_id, links_type, links_data, links_diameter, links_length,
883
- links_roughness_coeff, links_bulk_coeff, links_wall_coeff, links_loss_coeff):
1057
+ for link_id, link_type, link, diameter, length, roughness_coeff, bulk_coeff, \
1058
+ wall_coeff, loss_coeff in zip(links_id, links_type, links_data, links_diameter,
1059
+ links_length, links_roughness_coeff, links_bulk_coeff,
1060
+ links_wall_coeff, links_loss_coeff):
884
1061
  links.append((link_id, list(link),
885
1062
  {"type": link_type, "diameter": diameter, "length": length,
886
1063
  "roughness_coeff": roughness_coeff,
@@ -916,7 +1093,7 @@ class ScenarioSimulator():
916
1093
 
917
1094
  The default is None.
918
1095
  """
919
- from .scenario_visualizer import ScenarioVisualizer
1096
+ from ..visualization import ScenarioVisualizer
920
1097
  ScenarioVisualizer(self).show_plot(export_to_file)
921
1098
 
922
1099
  def randomize_demands(self) -> None:
@@ -989,7 +1166,10 @@ class ScenarioSimulator():
989
1166
  raise ValueError(f"Inconsistent pattern shape '{pattern.shape}' " +
990
1167
  "detected. Expected a one dimensional array!")
991
1168
 
992
- self.epanet_api.addPattern(pattern_id, pattern)
1169
+ pattern_idx = self.epanet_api.addPattern(pattern_id, pattern)
1170
+ if pattern_idx == 0:
1171
+ raise RuntimeError("Failed to add pattern! " +
1172
+ "Maybe pattern name contains invalid characters or is too long?")
993
1173
 
994
1174
  def get_node_demand_pattern(self, node_id: str) -> np.ndarray:
995
1175
  """
@@ -1063,23 +1243,128 @@ class ScenarioSimulator():
1063
1243
  self.epanet_api.setNodeJunctionData(node_idx, self.epanet_api.getNodeElevations(node_idx),
1064
1244
  base_demand, demand_pattern_id)
1065
1245
 
1066
- def add_control(self, control: AdvancedControlModule) -> None:
1246
+ def add_custom_control(self, control: CustomControlModule) -> None:
1067
1247
  """
1068
- Adds a control module to the scenario simulation.
1248
+ Adds a custom control module to the scenario simulation.
1069
1249
 
1070
1250
  Parameters
1071
1251
  ----------
1072
- control : :class:`~epyt_flow.simulation.scada.advanced_control.AdvancedControlModule`
1073
- Control module.
1252
+ control : :class:`~epyt_flow.simulation.scada.custom_control.CustomControlModule`
1253
+ Custom control module.
1074
1254
  """
1075
1255
  self._adapt_to_network_changes()
1076
1256
 
1077
- if not isinstance(control, AdvancedControlModule):
1257
+ if not isinstance(control, CustomControlModule):
1078
1258
  raise TypeError("'control' must be an instance of " +
1079
- "'epyt_flow.simulation.scada.AdvancedControlModule' not of " +
1259
+ "'epyt_flow.simulation.scada.CustomControlModule' not of " +
1080
1260
  f"'{type(control)}'")
1081
1261
 
1082
- self._controls.append(control)
1262
+ self._custom_controls.append(control)
1263
+
1264
+ def add_simple_control(self, control: SimpleControlModule) -> None:
1265
+ """
1266
+ Adds a simple EPANET control rule to the scenario simulation.
1267
+
1268
+ Parameters
1269
+ ----------
1270
+ control : :class:`~epyt_flow.simulation.scada.simple_control.SimpleControlModule`
1271
+ Simple EPANET control module.
1272
+ """
1273
+ self._adapt_to_network_changes()
1274
+
1275
+ if not isinstance(control, SimpleControlModule):
1276
+ raise TypeError("'control' must be an instance of " +
1277
+ "'epyt_flow.simulation.scada.SimpleControlModule' not of " +
1278
+ f"'{type(control)}'")
1279
+
1280
+ if not any(c == control for c in self._simple_controls):
1281
+ self._simple_controls.append(control)
1282
+ self.epanet_api.addControls(str(control))
1283
+
1284
+ def remove_all_simple_controls(self) -> None:
1285
+ """
1286
+ Removes all simple EPANET controls from the scenario.
1287
+ """
1288
+ self.epanet_api.deleteControls()
1289
+ self._simple_controls = []
1290
+
1291
+ def remove_simple_control(self, control: SimpleControlModule) -> None:
1292
+ """
1293
+ Removes a given simple EPANET control rule from the scenario.
1294
+
1295
+ Parameters
1296
+ ----------
1297
+ control : :class:`~epyt_flow.simulation.scada.simple_control.SimpleControlModule`
1298
+ Simple EPANET control module to be removed.
1299
+ """
1300
+ self._adapt_to_network_changes()
1301
+
1302
+ if not isinstance(control, SimpleControlModule):
1303
+ raise TypeError("'control' must be an instance of " +
1304
+ "'epyt_flow.simulation.scada.SimpleControlModule' not of " +
1305
+ f"'{type(control)}'")
1306
+
1307
+ control_idx = None
1308
+ for idx, c in enumerate(self._simple_controls):
1309
+ if c == control:
1310
+ control_idx = idx + 1
1311
+ break
1312
+ if control_idx is None:
1313
+ raise ValueError("Invalid/Unknown control module.")
1314
+
1315
+ self.epanet_api.deleteControls(control_idx)
1316
+ self._simple_controls.remove(control)
1317
+
1318
+ def add_complex_control(self, control: ComplexControlModule) -> None:
1319
+ """
1320
+ Adds an complex (IF-THEN-ELSE) EPANET control rule to the scenario simulation.
1321
+
1322
+ Parameters
1323
+ ----------
1324
+ control : :class:`~epyt_flow.simulation.scada.complex_control.ComplexControlModule`
1325
+ Complex EPANET control module.
1326
+ """
1327
+ self._adapt_to_network_changes()
1328
+
1329
+ if not isinstance(control, ComplexControlModule):
1330
+ raise TypeError("'control' must be an instance of " +
1331
+ "'epyt_flow.simulation.scada.ComplexControlModule' not of " +
1332
+ f"'{type(control)}'")
1333
+
1334
+ if not any(c == control for c in self._complex_controls):
1335
+ self._complex_controls.append(control)
1336
+ self.epanet_api.addRules(str(control))
1337
+
1338
+ def remove_all_complex_controls(self) -> None:
1339
+ """
1340
+ Removes all complex EPANET controls from the scenario.
1341
+ """
1342
+ self.epanet_api.deleteRules()
1343
+ self._complex_controls = []
1344
+
1345
+ def remove_complex_control(self, control: ComplexControlModule) -> None:
1346
+ """
1347
+ Removes a given complex (IF-THEN-ELSE) EPANET control rule from the scenario.
1348
+
1349
+ Parameters
1350
+ ----------
1351
+ control : :class:`~epyt_flow.simulation.scada.complex_control.ComplexControlModule`
1352
+ Complex EPANET control module to be removed.
1353
+ """
1354
+ self._adapt_to_network_changes()
1355
+
1356
+ if not isinstance(control, ComplexControlModule):
1357
+ raise TypeError("'control' must be an instance of " +
1358
+ "'epyt_flow.simulation.scada.ComplexControlModule' not of " +
1359
+ f"'{type(control)}'")
1360
+
1361
+ if control.rule_id not in self.epanet_api.getRuleID():
1362
+ raise ValueError("Invalid/Unknown control module. " +
1363
+ f"Can not find rule ID '{control.rule_id}'")
1364
+
1365
+ rule_idx = self.epanet_api.getRuleID().index(control.rule_id) + 1
1366
+ self.epanet_api.deleteRules(rule_idx)
1367
+ self._complex_controls.remove(control)
1083
1368
 
1084
1369
  def add_leakage(self, leakage_event: Leakage) -> None:
1085
1370
  """
@@ -1616,12 +1901,14 @@ class ScenarioSimulator():
1616
1901
  for event in self._system_events:
1617
1902
  event.reset()
1618
1903
 
1619
- if self._controls is not None:
1620
- for c in self._controls:
1621
- c.init(self.epanet_api)
1904
+ if self._custom_controls is not None:
1905
+ for control in self._custom_controls:
1906
+ control.init(self.epanet_api)
1622
1907
 
1623
1908
  def run_advanced_quality_simulation(self, hyd_file_in: str, verbose: bool = False,
1624
- frozen_sensor_config: bool = False) -> ScadaData:
1909
+ frozen_sensor_config: bool = False,
1910
+ use_quality_time_step_as_reporting_time_step: bool = False
1911
+ ) -> ScadaData:
1625
1912
  """
1626
1913
  Runs an advanced quality analysis using EPANET-MSX.
1627
1914
 
@@ -1638,6 +1925,13 @@ class ScenarioSimulator():
1638
1925
  If True, the sensor config can not be changed and only the required sensor nodes/links
1639
1926
  will be stored -- this usually leads to a significant reduction in memory consumption.
1640
1927
 
1928
+ The default is False.
1929
+ use_quality_time_step_as_reporting_time_step : `bool`, optional
1930
+ If True, the water quality time step will be used as the reporting time step.
1931
+
1932
+ As a consequence, the simualtion results can not be merged
1933
+ with the hydraulic simulation.
1934
+
1641
1935
  The default is False.
1642
1936
 
1643
1937
  Returns
@@ -1657,7 +1951,9 @@ class ScenarioSimulator():
1657
1951
  for scada_data in gen(hyd_file_in=hyd_file_in,
1658
1952
  verbose=verbose,
1659
1953
  return_as_dict=True,
1660
- frozen_sensor_config=frozen_sensor_config):
1954
+ frozen_sensor_config=frozen_sensor_config,
1955
+ use_quality_time_step_as_reporting_time_step=
1956
+ use_quality_time_step_as_reporting_time_step):
1661
1957
  if result is None:
1662
1958
  result = {}
1663
1959
  for data_type, data in scada_data.items():
@@ -1674,6 +1970,7 @@ class ScenarioSimulator():
1674
1970
  result[data_type] = None
1675
1971
 
1676
1972
  return ScadaData(**result,
1973
+ network_topo=self.get_topology(),
1677
1974
  sensor_config=self._sensor_config,
1678
1975
  sensor_reading_events=self._sensor_reading_events,
1679
1976
  sensor_noise=self._sensor_noise,
@@ -1682,7 +1979,8 @@ class ScenarioSimulator():
1682
1979
  def run_advanced_quality_simulation_as_generator(self, hyd_file_in: str, verbose: bool = False,
1683
1980
  support_abort: bool = False,
1684
1981
  return_as_dict: bool = False,
1685
- frozen_sensor_config: bool = False
1982
+ frozen_sensor_config: bool = False,
1983
+ use_quality_time_step_as_reporting_time_step: bool = False,
1686
1984
  ) -> Generator[Union[ScadaData, dict], bool, None]:
1687
1985
  """
1688
1986
  Runs an advanced quality analysis using EPANET-MSX.
@@ -1704,6 +2002,13 @@ class ScenarioSimulator():
1704
2002
  If True, the sensor config can not be changed and only the required sensor nodes/links
1705
2003
  will be stored -- this usually leads to a significant reduction in memory consumption.
1706
2004
 
2005
+ The default is False.
2006
+ use_quality_time_step_as_reporting_time_step : `bool`, optional
2007
+ If True, the water quality time step will be used as the reporting time step.
2008
+
2009
+ As a consequence, the simualtion results can not be merged
2010
+ with the hydraulic simulation.
2011
+
1707
2012
  The default is False.
1708
2013
 
1709
2014
  Returns
@@ -1729,6 +2034,11 @@ class ScenarioSimulator():
1729
2034
  reporting_time_step = self.epanet_api.getTimeReportingStep()
1730
2035
  hyd_time_step = self.epanet_api.getTimeHydraulicStep()
1731
2036
 
2037
+ if use_quality_time_step_as_reporting_time_step is True:
2038
+ quality_time_step = self.epanet_api.getMSXTimeStep()
2039
+ reporting_time_step = quality_time_step
2040
+ hyd_time_step = quality_time_step
2041
+
1732
2042
  self.epanet_api.initializeMSXQualityAnalysis(ToolkitConstants.EN_NOSAVE)
1733
2043
 
1734
2044
  self.__running_simulation = True
@@ -1814,7 +2124,7 @@ class ScenarioSimulator():
1814
2124
  "surface_species_concentration_raw": surface_species_concentrations,
1815
2125
  "sensor_readings_time": np.array([0])}
1816
2126
  else:
1817
- data = ScadaData(sensor_config=self._sensor_config,
2127
+ data = ScadaData(network_topo=self.get_topology(), sensor_config=self._sensor_config,
1818
2128
  bulk_species_node_concentration_raw=bulk_species_node_concentrations,
1819
2129
  bulk_species_link_concentration_raw=bulk_species_link_concentrations,
1820
2130
  surface_species_concentration_raw=surface_species_concentrations,
@@ -1858,7 +2168,8 @@ class ScenarioSimulator():
1858
2168
  "surface_species_concentration_raw": surface_species_concentrations,
1859
2169
  "sensor_readings_time": np.array([total_time])}
1860
2170
  else:
1861
- data = ScadaData(sensor_config=self._sensor_config,
2171
+ data = ScadaData(network_topo=self.get_topology(),
2172
+ sensor_config=self._sensor_config,
1862
2173
  bulk_species_node_concentration_raw=
1863
2174
  bulk_species_node_concentrations,
1864
2175
  bulk_species_link_concentration_raw=
@@ -1880,7 +2191,9 @@ class ScenarioSimulator():
1880
2191
  self.__running_simulation = False
1881
2192
 
1882
2193
  def run_basic_quality_simulation(self, hyd_file_in: str, verbose: bool = False,
1883
- frozen_sensor_config: bool = False) -> ScadaData:
2194
+ frozen_sensor_config: bool = False,
2195
+ use_quality_time_step_as_reporting_time_step: bool = False
2196
+ ) -> ScadaData:
1884
2197
  """
1885
2198
  Runs a basic quality analysis using EPANET.
1886
2199
 
@@ -1897,6 +2210,13 @@ class ScenarioSimulator():
1897
2210
  If True, the sensor config can not be changed and only the required sensor nodes/links
1898
2211
  will be stored -- this usually leads to a significant reduction in memory consumption.
1899
2212
 
2213
+ The default is False.
2214
+ use_quality_time_step_as_reporting_time_step : `bool`, optional
2215
+ If True, the water quality time step will be used as the reporting time step.
2216
+
2217
+ As a consequence, the simualtion results can not be merged
2218
+ with the hydraulic simulation.
2219
+
1900
2220
  The default is False.
1901
2221
 
1902
2222
  Returns
@@ -1914,7 +2234,9 @@ class ScenarioSimulator():
1914
2234
  for scada_data in gen(hyd_file_in=hyd_file_in,
1915
2235
  verbose=verbose,
1916
2236
  return_as_dict=True,
1917
- frozen_sensor_config=frozen_sensor_config):
2237
+ frozen_sensor_config=frozen_sensor_config,
2238
+ use_quality_time_step_as_reporting_time_step=
2239
+ use_quality_time_step_as_reporting_time_step):
1918
2240
  if result is None:
1919
2241
  result = {}
1920
2242
  for data_type, data in scada_data.items():
@@ -1928,6 +2250,7 @@ class ScenarioSimulator():
1928
2250
  result[data_type] = np.concatenate(result[data_type], axis=0)
1929
2251
 
1930
2252
  return ScadaData(**result,
2253
+ network_topo=self.get_topology(),
1931
2254
  sensor_config=self._sensor_config,
1932
2255
  sensor_reading_events=self._sensor_reading_events,
1933
2256
  sensor_noise=self._sensor_noise,
@@ -1937,6 +2260,7 @@ class ScenarioSimulator():
1937
2260
  support_abort: bool = False,
1938
2261
  return_as_dict: bool = False,
1939
2262
  frozen_sensor_config: bool = False,
2263
+ use_quality_time_step_as_reporting_time_step: bool = False
1940
2264
  ) -> Generator[Union[ScadaData, dict], bool, None]:
1941
2265
  """
1942
2266
  Runs a basic quality analysis using EPANET.
@@ -1960,6 +2284,13 @@ class ScenarioSimulator():
1960
2284
  If True, the sensor config can not be changed and only the required sensor nodes/links
1961
2285
  will be stored -- this usually leads to a significant reduction in memory consumption.
1962
2286
 
2287
+ The default is False.
2288
+ use_quality_time_step_as_reporting_time_step : `bool`, optional
2289
+ If True, the water quality time step will be used as the reporting time step.
2290
+
2291
+ As a consequence, the simualtion results can not be merged
2292
+ with the hydraulic simulation.
2293
+
1963
2294
  The default is False.
1964
2295
 
1965
2296
  Returns
@@ -1974,6 +2305,11 @@ class ScenarioSimulator():
1974
2305
  reporting_time_start = self.epanet_api.getTimeReportingStart()
1975
2306
  reporting_time_step = self.epanet_api.getTimeReportingStep()
1976
2307
 
2308
+ if use_quality_time_step_as_reporting_time_step is True:
2309
+ quality_time_step = self.epanet_api.getTimeQualityStep()
2310
+ requested_time_step = quality_time_step
2311
+ reporting_time_step = quality_time_step
2312
+
1977
2313
  self.epanet_api.useHydraulicFile(hyd_file_in)
1978
2314
 
1979
2315
  self.epanet_api.openQualityAnalysis()
@@ -2016,7 +2352,8 @@ class ScenarioSimulator():
2016
2352
  "link_quality_data_raw": quality_link_data,
2017
2353
  "sensor_readings_time": np.array([total_time])}
2018
2354
  else:
2019
- data = ScadaData(sensor_config=self._sensor_config,
2355
+ data = ScadaData(network_topo=self.get_topology(),
2356
+ sensor_config=self._sensor_config,
2020
2357
  node_quality_data_raw=quality_node_data,
2021
2358
  link_quality_data_raw=quality_link_data,
2022
2359
  sensor_readings_time=np.array([total_time]),
@@ -2032,7 +2369,7 @@ class ScenarioSimulator():
2032
2369
  yield data
2033
2370
 
2034
2371
  # Next
2035
- tstep = self.epanet_api.nextQualityAnalysisStep()
2372
+ tstep = self.epanet_api.stepQualityAnalysisTimeLeft()
2036
2373
 
2037
2374
  self.epanet_api.closeHydraulicAnalysis()
2038
2375
 
@@ -2092,6 +2429,7 @@ class ScenarioSimulator():
2092
2429
  result[data_type] = np.concatenate(result[data_type], axis=0)
2093
2430
 
2094
2431
  result = ScadaData(**result,
2432
+ network_topo=self.get_topology(),
2095
2433
  sensor_config=self._sensor_config,
2096
2434
  sensor_reading_events=self._sensor_reading_events,
2097
2435
  sensor_noise=self._sensor_noise,
@@ -2213,7 +2551,8 @@ class ScenarioSimulator():
2213
2551
  link_valve_idx = self.epanet_api.getLinkValveIndex()
2214
2552
  valves_state_data = self.epanet_api.getLinkStatus(link_valve_idx).reshape(1, -1)
2215
2553
 
2216
- scada_data = ScadaData(sensor_config=self._sensor_config,
2554
+ scada_data = ScadaData(network_topo=self.get_topology(),
2555
+ sensor_config=self._sensor_config,
2217
2556
  pressure_data_raw=pressure_data,
2218
2557
  flow_data_raw=flow_data,
2219
2558
  demand_data_raw=demand_data,
@@ -2254,7 +2593,7 @@ class ScenarioSimulator():
2254
2593
  yield data
2255
2594
 
2256
2595
  # Apply control modules
2257
- for control in self._controls:
2596
+ for control in self._custom_controls:
2258
2597
  control.step(scada_data)
2259
2598
 
2260
2599
  # Next
@@ -2272,16 +2611,6 @@ class ScenarioSimulator():
2272
2611
  self.__running_simulation = False
2273
2612
  raise ex
2274
2613
 
2275
- def run_simulation_as_generator(self, hyd_export: str = None, verbose: bool = False,
2276
- support_abort: bool = False,
2277
- return_as_dict: bool = False,
2278
- frozen_sensor_config: bool = False,
2279
- ) -> Generator[Union[ScadaData, dict], bool, None]:
2280
- warnings.warn("'run_simulation_as_generator' is deprecated and will be removed in " +
2281
- "future releases -- use 'run_hydraulic_simulation_as_generator' instead")
2282
- return self.run_hydraulic_simulation_as_generator(hyd_export, verbose, support_abort,
2283
- return_as_dict, frozen_sensor_config)
2284
-
2285
2614
  def run_simulation(self, hyd_export: str = None, verbose: bool = False,
2286
2615
  frozen_sensor_config: bool = False) -> ScadaData:
2287
2616
  """
@@ -2574,6 +2903,202 @@ class ScenarioSimulator():
2574
2903
 
2575
2904
  return list(set(events_times))
2576
2905
 
2906
+ def set_pump_energy_price_pattern(self, pump_id: str, pattern: np.ndarray,
2907
+ pattern_id: Optional[str] = None) -> None:
2908
+ """
2909
+ Specifies/sets the energy price pattern of a given pump.
2910
+
2911
+ Overwrites any existing (energy price) patterns of the given pump.
2912
+
2913
+ Parameters
2914
+ ----------
2915
+ pump_id : `str`
2916
+ ID of the pump.
2917
+ pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
2918
+ Pattern of multipliers.
2919
+ pattern_id : `str`, optional
2920
+ ID of the pattern.
2921
+ If not specified, 'energy_price_{pump_id}' will be used as the pattern ID.
2922
+
2923
+ The default is None.
2924
+ """
2925
+ if not isinstance(pump_id, str):
2926
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
2927
+ if pump_id not in self._sensor_config.pumps:
2928
+ raise ValueError(f"Unknown pump '{pump_id}'")
2929
+ if not isinstance(pattern, np.ndarray):
2930
+ raise TypeError("'pattern' must be an instance of 'numpy.ndarray' " +
2931
+ f"but no of '{type(pattern)}'")
2932
+ if len(pattern.shape) > 1:
2933
+ raise ValueError("'pattern' must be 1-dimensional")
2934
+ if pattern_id is not None:
2935
+ if not isinstance(pattern_id, str):
2936
+ raise TypeError("'pattern_id' must be an instance of 'str' " +
2937
+ f"but not of '{type(pattern_id)}'")
2938
+ else:
2939
+ pattern_id = f"energy_price_{pump_id}"
2940
+
2941
+ pattern_idx = self.epanet_api.getPatternIndex(pattern_id)
2942
+ if pattern_idx != 0:
2943
+ warnings.warn(f"Overwriting existing pattern '{pattern_id}'")
2944
+
2945
+ pump_idx = self.epanet_api.getLinkIndex(pump_id)
2946
+ pattern_idx = self.epanet_api.getLinkPumpEPat(pump_idx)
2947
+ if pattern_idx != 0:
2948
+ warnings.warn(f"Overwriting existing energy price pattern of pump '{pump_id}'")
2949
+
2950
+ self.add_pattern(pattern_id, pattern)
2951
+ pattern_idx = self.epanet_api.getPatternIndex(pattern_id)
2952
+ self.epanet_api.setLinkPumpEPat(pattern_idx)
2953
+
2954
+ def get_pump_energy_price_pattern(self, pump_id: str) -> np.ndarray:
2955
+ """
2956
+ Returns the energy price pattern of a given pump.
2957
+
2958
+ Parameters
2959
+ ----------
2960
+ pump_id : `str`
2961
+ ID of the pump.
2962
+
2963
+ Returns
2964
+ -------
2965
+ `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
2966
+ Energy price pattern. None, if none exists.
2967
+ """
2968
+ if not isinstance(pump_id, str):
2969
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
2970
+ if pump_id not in self._sensor_config.pumps:
2971
+ raise ValueError(f"Unknown pump '{pump_id}'")
2972
+
2973
+ pump_idx = self.epanet_api.getLinkIndex(pump_id)
2974
+ pattern_idx = self.epanet_api.getLinkPumpEPat(pump_idx)
2975
+ if pattern_idx == 0:
2976
+ return None
2977
+ else:
2978
+ pattern_length = self.epanet_api.getPatternLengths(pattern_idx)
2979
+ return np.array([self.epanet_api.getPatternValue(pattern_idx, t+1)
2980
+ for t in range(pattern_length)])
2981
+
2982
+ def get_pump_energy_price(self, pump_id: str) -> float:
2983
+ """
2984
+ Returns the energy price of a given pump.
2985
+
2986
+ Parameters
2987
+ ----------
2988
+ pump_id : `str`
2989
+ ID of the pump.
2990
+
2991
+ Returns
2992
+ -------
2993
+ `float`
2994
+ Energy price.
2995
+ """
2996
+ if not isinstance(pump_id, str):
2997
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
2998
+ if pump_id not in self._sensor_config.pumps:
2999
+ raise ValueError(f"Unknown pump '{pump_id}'")
3000
+
3001
+ pump_idx = self.epanet_api.getLinkIndex(pump_id)
3002
+ return self.epanet_api.getLinkPumpECost(pump_idx)
3003
+
3004
+ def set_pump_energy_price(self, pump_id, price: float) -> None:
3005
+ """
3006
+ Sets the energy price of a given pump.
3007
+
3008
+ Parameters
3009
+ ----------
3010
+ pump_id : `str`
3011
+ ID of the pump.
3012
+ price : `float`
3013
+ Energy price.
3014
+ """
3015
+ if not isinstance(pump_id, str):
3016
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
3017
+ if pump_id not in self._sensor_config.pumps:
3018
+ raise ValueError(f"Unknown pump '{pump_id}'")
3019
+ if not isinstance(price, float):
3020
+ raise TypeError(f"'price' must be an instance of 'float' but not of '{type(price)}'")
3021
+ if price <= 0:
3022
+ raise ValueError("'price' must be positive")
3023
+
3024
+ pump_idx = self._sensor_config.pumps.index(pump_id) + 1
3025
+ pumps_energy_price = self.epanet_api.getLinkPumpECost()
3026
+ pumps_energy_price[pump_idx - 1] = price
3027
+
3028
+ self.epanet_api.setLinkPumpECost(pumps_energy_price)
3029
+
3030
+ def set_initial_link_status(self, link_id: str, status: int) -> None:
3031
+ """
3032
+ Sets the initial status (open or closed) of a given link.
3033
+
3034
+ Parameters
3035
+ ----------
3036
+ link_id : `str`
3037
+ ID of the link.
3038
+ status : `int`
3039
+ Initial status of the link. Must be one of the following EPANET constants:
3040
+
3041
+ - EN_CLOSED = 0
3042
+ - EN_OPEN = 1
3043
+ """
3044
+ if not isinstance(link_id, str):
3045
+ raise TypeError(f"'link_id' must be an instance of 'str' but not of '{type(link_id)}'")
3046
+ if link_id not in self._sensor_config.pumps:
3047
+ raise ValueError("Invalid link ID '{link_id}'")
3048
+ if not isinstance(status, int):
3049
+ raise TypeError(f"'status' must be an instance of 'int' but not of '{type(status)}'")
3050
+ if status not in [ActuatorConstants.EN_CLOSED, ActuatorConstants.EN_OPEN]:
3051
+ raise ValueError(f"Invalid link status '{status}'")
3052
+
3053
+ link_idx = self.epanet_api.getLinkIndex(link_id)
3054
+ self.epanet_api.setLinkInitialStatus(link_idx, status)
3055
+
3056
+ def set_initial_pump_speed(self, pump_id: str, speed: float) -> None:
3057
+ """
3058
+ Sets the initial pump speed of a given pump.
3059
+
3060
+ Parameters
3061
+ ----------
3062
+ pump_id : `str`
3063
+ ID of the pump.
3064
+ speed : `float`
3065
+ Initial speed of the pump.
3066
+ """
3067
+ if not isinstance(pump_id, str):
3068
+ raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
3069
+ if pump_id not in self._sensor_config.pumps:
3070
+ raise ValueError("Invalid pump ID '{tank_id}'")
3071
+ if not isinstance(speed, float):
3072
+ raise TypeError(f"'speed' must be an instance of 'int' but not of '{type(speed)}'")
3073
+ if speed < 0:
3074
+ raise ValueError("'speed' can not be negative")
3075
+
3076
+ pump_idx = self.epanet_api.getLinkIndex(pump_id)
3077
+ self.epanet_api.setLinkInitialSetting(pump_idx, speed)
3078
+
3079
+ def set_initial_tank_level(self, tank_id, level: int) -> None:
3080
+ """
3081
+ Sets the initial water level of a given tank.
3082
+
3083
+ Parameters
3084
+ ----------
3085
+ tank_id : `str`
3086
+ ID of the tank.
3087
+ level : `int`
3088
+ Initial water level in the tank.
3089
+ """
3090
+ if not isinstance(tank_id, str):
3091
+ raise TypeError(f"'tank_id' must be an instance of 'str' but not of '{type(tank_id)}'")
3092
+ if tank_id not in self._sensor_config.tanks:
3093
+ raise ValueError("Invalid tank ID '{tank_id}'")
3094
+ if not isinstance(level, int):
3095
+ raise TypeError(f"'level' must be an instance of 'int' but not of '{type(level)}'")
3096
+ if level < 0:
3097
+ raise ValueError("'level' can not be negative")
3098
+
3099
+ tank_idx = self.epanet_api.getNodeIndex(tank_id)
3100
+ self.epanet_api.setNodeTankInitialLevel(tank_idx, level)
3101
+
2577
3102
  def __warn_if_quality_set(self):
2578
3103
  qual_info = self.epanet_api.getQualityInfo()
2579
3104
  if qual_info.QualityCode != ToolkitConstants.EN_NONE:
@@ -2686,7 +3211,7 @@ class ScenarioSimulator():
2686
3211
  if pattern is None and pattern_id is None:
2687
3212
  raise ValueError("'pattern_id' and 'pattern' can not be None at the same time")
2688
3213
  if pattern_id is None:
2689
- pattern_id = f"quality_source_pattern_node={node_id}"
3214
+ pattern_id = f"qual_src_pat_{node_id}"
2690
3215
 
2691
3216
  node_idx = self.epanet_api.getNodeIndex(node_id)
2692
3217
 
@@ -2694,11 +3219,220 @@ class ScenarioSimulator():
2694
3219
  pattern_idx = self.epanet_api.getPatternIndex(pattern_id)
2695
3220
  else:
2696
3221
  pattern_idx = self.epanet_api.addPattern(pattern_id, pattern)
3222
+ if pattern_idx == 0:
3223
+ raise RuntimeError("Failed to add/get pattern! " +
3224
+ "Maybe pattern name contains invalid characters or is too long?")
2697
3225
 
2698
3226
  self.epanet_api.api.ENsetnodevalue(node_idx, ToolkitConstants.EN_SOURCETYPE, source_type)
2699
3227
  self.epanet_api.setNodeSourceQuality(node_idx, source_strength)
2700
3228
  self.epanet_api.setNodeSourcePatternIndex(node_idx, pattern_idx)
2701
3229
 
3230
+ def set_initial_node_quality(self, node_id: str, initial_quality: float) -> None:
3231
+ """
3232
+ Specifies the initial quality at a given node.
3233
+ Quality represents concentration for chemicals, hours for water age,
3234
+ or percent for source tracing.
3235
+
3236
+ Parameters
3237
+ ----------
3238
+ node_id : `str`
3239
+ ID of the node.
3240
+ initial_quality : `float`
3241
+ Initial node quality.
3242
+ """
3243
+ self.set_quality_parameters(initial_quality={node_id, initial_quality})
3244
+
3245
+ def set_quality_parameters(self, initial_quality: Optional[dict[str, float]] = None,
3246
+ order_wall: Optional[int] = None, order_tank: Optional[int] = None,
3247
+ order_bulk: Optional[int] = None,
3248
+ global_wall_reaction_coefficient: Optional[float] = None,
3249
+ global_bulk_reaction_coefficient: Optional[float] = None,
3250
+ local_wall_reaction_coefficient: Optional[dict[str, float]] = None,
3251
+ local_bulk_reaction_coefficient: Optional[dict[str, float]] = None,
3252
+ local_tank_reaction_coefficient: Optional[dict[str, float]] = None,
3253
+ limiting_potential: Optional[float] = None) -> None:
3254
+ """
3255
+ Specifies some parameters of the EPANET quality analysis.
3256
+ Note that those parameters are only relevant for EPANET but not for EPANET-MSX.
3257
+
3258
+ Parameters
3259
+ ----------
3260
+ initial_quality : `dict[str, float]`, optional
3261
+ Specifies the initial quality (value in the dictionary) at nodes
3262
+ (key in the dictionary).
3263
+ Quality represents concentration for chemicals, hours for water age,
3264
+ or percent for source tracing.
3265
+
3266
+ The default is None.
3267
+ order_wall : `int`, optional
3268
+ Specifies the order of reactions occurring in the bulk fluid at pipe walls.
3269
+ Value for wall reactions must be either 0 or 1.
3270
+ If not specified, the default reaction order is 1.0.
3271
+
3272
+ The default is None.
3273
+ order_bulk : `int`, optional
3274
+ Specifies the order of reactions occurring in the bulk fluid in tanks.
3275
+ Value must be either 0 or 1.
3276
+ If not specified, the default reaction order is 1.0.
3277
+
3278
+ The default is None.
3279
+ global_wall_reaction_coefficient : `float`, optional
3280
+ Specifies the global value for all pipe wall reaction coefficients (pipes and tanks).
3281
+ If not specified, the default value is zero.
3282
+
3283
+ The default is None.
3284
+ global_bulk_reaction_coefficient : `float`, optional
3285
+ Specifies the global value for all bulk reaction coefficients (pipes and tanks).
3286
+ If not specified, the default value is zero.
3287
+
3288
+ The default is None.
3289
+ local_wall_reaction_coefficient : `dict[str, float]`, optional
3290
+ Overrides the global reaction coefficients for specific pipes (key in dictionary).
3291
+
3292
+ The default is None.
3293
+ local_bulk_reaction_coefficient : `dict[str, float]`, optional
3294
+ Overrides the global reaction coefficients for specific pipes (key in dictionary).
3295
+
3296
+ The default is None.
3297
+ local_tank_reaction_coefficient : `dict[str, float]`, optional
3298
+ Overrides the global reaction coefficients for specific tanks (key in dictionary).
3299
+
3300
+ The default is None.
3301
+ limiting_potential : `float`, optional
3302
+ Specifies that reaction rates are proportional to the difference between the
3303
+ current concentration and some (specified) limiting potential value.
3304
+
3305
+ The default is None.
3306
+ """
3307
+ if initial_quality is not None:
3308
+ if not isinstance(initial_quality, dict):
3309
+ raise TypeError("'initial_quality' must be an instance of 'dict[str, float]' " +
3310
+ f"but not of '{type(initial_quality)}'")
3311
+ if any(not isinstance(key, str) or not isinstance(value, float)
3312
+ for key, value in initial_quality):
3313
+ raise TypeError("'initial_quality' must be an instance of 'dict[str, float]'")
3314
+ for node_id, node_init_qual in initial_quality:
3315
+ if node_id not in self._sensor_config.nodes:
3316
+ raise ValueError(f"Invalid node ID '{node_id}'")
3317
+ if node_init_qual < 0:
3318
+ raise ValueError(f"{node_id}: Initial node quality can not be negative")
3319
+
3320
+ init_qual = self.epanet_api.getNodeInitialQuality()
3321
+ for node_id, node_init_qual in initial_quality:
3322
+ node_idx = self.epanet_api.getNodeIndex(node_id) - 1
3323
+ init_qual[node_idx] = node_init_qual
3324
+
3325
+ self.epanet_api.setNodeInitialQuality(init_qual)
3326
+
3327
+ if order_wall is not None:
3328
+ if not isinstance(order_wall, int):
3329
+ raise TypeError("'order_wall' must be an instance of 'int' " +
3330
+ f"but not of '{type(order_wall)}'")
3331
+ if order_wall not in [0, 1]:
3332
+ raise ValueError(f"Invalid value '{order_wall}' for order_wall")
3333
+
3334
+ self.epanet_api.setOptionsPipeWallReactionOrder(order_wall)
3335
+
3336
+ if order_bulk is not None:
3337
+ if not isinstance(order_bulk, int):
3338
+ raise TypeError("'order_bulk' must be an instance of 'int' " +
3339
+ f"but not of '{type(order_bulk)}'")
3340
+ if order_bulk not in [0, 1]:
3341
+ raise ValueError(f"Invalid value '{order_bulk}' for order_bulk")
3342
+
3343
+ self.epanet_api.setOptionsPipeBulkReactionOrder(order_bulk)
3344
+
3345
+ if order_tank is not None:
3346
+ if not isinstance(order_tank, int):
3347
+ raise TypeError("'order_tank' must be an instance of 'int' " +
3348
+ f"but not of '{type(order_tank)}'")
3349
+ if order_tank not in [0, 1]:
3350
+ raise ValueError(f"Invalid value '{order_tank}' for order_wall")
3351
+
3352
+ self.epanet_api.setOptionsTankBulkReactionOrder(order_tank)
3353
+
3354
+ if global_wall_reaction_coefficient is not None:
3355
+ if not isinstance(global_wall_reaction_coefficient, float):
3356
+ raise TypeError("'global_wall_reaction_coefficient' must be an instance of " +
3357
+ f"'float' but not of '{type(global_wall_reaction_coefficient)}'")
3358
+
3359
+ wall_reaction_coeff = self.epanet_api.getLinkWallReactionCoeff()
3360
+ for i in range(len(wall_reaction_coeff)):
3361
+ wall_reaction_coeff[i] = global_wall_reaction_coefficient
3362
+
3363
+ self.epanet_api.setLinkWallReactionCoeff(wall_reaction_coeff)
3364
+
3365
+ if global_bulk_reaction_coefficient is not None:
3366
+ if not isinstance(global_bulk_reaction_coefficient, float):
3367
+ raise TypeError("'global_bulk_reaction_coefficient' must be an instance of " +
3368
+ f"'float' but not of '{type(global_bulk_reaction_coefficient)}'")
3369
+
3370
+ bulk_reaction_coeff = self.epanet_api.getLinkBulkReactionCoeff()
3371
+ for i in range(len(bulk_reaction_coeff)):
3372
+ bulk_reaction_coeff[i] = global_bulk_reaction_coefficient
3373
+
3374
+ self.epanet_api.setLinkBulkReactionCoeff(bulk_reaction_coeff)
3375
+
3376
+ if local_wall_reaction_coefficient is not None:
3377
+ if not isinstance(local_wall_reaction_coefficient, dict):
3378
+ raise TypeError("'local_wall_reaction_coefficient' must be an instance " +
3379
+ "of 'dict[str, float]' but not of " +
3380
+ f"'{type(local_wall_reaction_coefficient)}'")
3381
+ if any(not isinstance(key, str) or not isinstance(value, float)
3382
+ for key, value in local_wall_reaction_coefficient):
3383
+ raise TypeError("'local_wall_reaction_coefficient' must be an instance " +
3384
+ "of 'dict[str, float]'")
3385
+ for link_id, _ in local_wall_reaction_coefficient:
3386
+ if link_id not in self._sensor_config.links:
3387
+ raise ValueError(f"Invalid link ID '{link_id}'")
3388
+
3389
+ for link_id, link_reaction_coeff in local_wall_reaction_coefficient:
3390
+ link_idx = self.epanet_api.getLinkIndex(link_id)
3391
+ self.epanet_api.setLinkWallReactionCoeff(link_idx, link_reaction_coeff)
3392
+
3393
+ if local_bulk_reaction_coefficient is not None:
3394
+ if not isinstance(local_bulk_reaction_coefficient, dict):
3395
+ raise TypeError("'local_bulk_reaction_coefficient' must be an instance " +
3396
+ "of 'dict[str, float]' but not of " +
3397
+ f"'{type(local_bulk_reaction_coefficient)}'")
3398
+ if any(not isinstance(key, str) or not isinstance(value, float)
3399
+ for key, value in local_bulk_reaction_coefficient):
3400
+ raise TypeError("'local_bulk_reaction_coefficient' must be an instance " +
3401
+ "of 'dict[str, float]'")
3402
+ for link_id, _ in local_bulk_reaction_coefficient:
3403
+ if link_id not in self._sensor_config.links:
3404
+ raise ValueError(f"Invalid link ID '{link_id}'")
3405
+
3406
+ for link_id, link_reaction_coeff in local_bulk_reaction_coefficient:
3407
+ link_idx = self.epanet_api.getLinkIndex(link_id)
3408
+ self.epanet_api.setLinkBulkReactionCoeff(link_idx, link_reaction_coeff)
3409
+
3410
+ if local_tank_reaction_coefficient is not None:
3411
+ if not isinstance(local_tank_reaction_coefficient, dict):
3412
+ raise TypeError("'local_tank_reaction_coefficient' must be an instance " +
3413
+ "of 'dict[str, float]' but not of " +
3414
+ f"'{type(local_tank_reaction_coefficient)}'")
3415
+ if any(not isinstance(key, str) or not isinstance(value, float)
3416
+ for key, value in local_tank_reaction_coefficient):
3417
+ raise TypeError("'local_tank_reaction_coefficient' must be an instance " +
3418
+ "of 'dict[str, float]'")
3419
+ for tank_id, _ in local_tank_reaction_coefficient:
3420
+ if tank_id not in self._sensor_config.tanks:
3421
+ raise ValueError(f"Invalid tank ID '{tank_id}'")
3422
+
3423
+ for tank_id, tank_reaction_coeff in local_tank_reaction_coefficient:
3424
+ tank_idx = self.epanet_api.getNodeTankIndex(tank_id)
3425
+ self.epanet_api.setNodeTankBulkReactionCoeff(tank_idx, tank_reaction_coeff)
3426
+
3427
+ if limiting_potential is not None:
3428
+ if not isinstance(limiting_potential, float):
3429
+ raise TypeError("'limiting_potential' must be an instance of 'float' " +
3430
+ f"but not of '{type(limiting_potential)}'")
3431
+ if limiting_potential < 0:
3432
+ raise ValueError("'limiting_potential' can not be negative")
3433
+
3434
+ self.epanet_api.setOptionsLimitingConcentration(limiting_potential)
3435
+
2702
3436
  def enable_sourcetracing_analysis(self, trace_node_id: str) -> None:
2703
3437
  """
2704
3438
  Set source tracing analysis -- i.e. tracks the percentage of flow from a given node
@@ -2727,6 +3461,8 @@ class ScenarioSimulator():
2727
3461
  """
2728
3462
  Adds a new external (bulk or surface) species injection source at a particular node.
2729
3463
 
3464
+ Only for EPANET-MSX scenarios.
3465
+
2730
3466
  Parameters
2731
3467
  ----------
2732
3468
  species_id : `str`
@@ -2782,3 +3518,81 @@ class ScenarioSimulator():
2782
3518
  self.epanet_api.addMSXPattern(pattern_id, pattern)
2783
3519
  self.epanet_api.setMSXSources(node_id, species_id, source_type_, source_strength,
2784
3520
  pattern_id)
3521
+
3522
+ def set_bulk_species_node_initial_concentrations(self,
3523
+ inital_conc: dict[str, list[tuple[str, float]]]
3524
+ ) -> None:
3525
+ """
3526
+ Species the initial bulk species concentration at nodes.
3527
+
3528
+ Only for EPANET-MSX scenarios.
3529
+
3530
+ Parameters
3531
+ ----------
3532
+ inital_conc : `dict[str, list[tuple[str, float]]]`
3533
+ Initial concentration of species (key) at nodes -- i.e.
3534
+ value: list of node ID and initial concentration.
3535
+ """
3536
+ if not isinstance(inital_conc, dict) or \
3537
+ any(not isinstance(species_id, str) or not isinstance(node_initial_conc, list)
3538
+ for species_id, node_initial_conc in inital_conc.items()) or \
3539
+ any(not isinstance(node_initial_conc, tuple)
3540
+ for node_initial_conc in inital_conc.values()) or \
3541
+ any(not isinstance(node_id, str) or not isinstance(conc, float)
3542
+ for node_id, conc in inital_conc.values()):
3543
+ raise TypeError("'inital_conc' must be an instance of " +
3544
+ "'dict[str, list[tuple[str, float]]'")
3545
+ if any(species_id not in self.sensor_config.bulk_species
3546
+ for species_id in inital_conc.keys()):
3547
+ raise ValueError("Unknown bulk species in 'inital_conc'")
3548
+ if any(node_id not in self.sensor_config.nodes for node_id, _ in inital_conc.values()):
3549
+ raise ValueError("Unknown node ID in 'inital_conc'")
3550
+ if any(conc < 0 for _, conc in inital_conc.values()):
3551
+ raise ValueError("Initial node concentration can not be negative")
3552
+
3553
+ for species_id, node_initial_conc in inital_conc.items():
3554
+ species_idx, = self.epanet_api.getMSXSpeciesIndex([species_id])
3555
+
3556
+ for node_id, initial_conc in node_initial_conc:
3557
+ node_idx = self.epanet_api.getNodeIndex(node_id)
3558
+ self.epanet_api.msx.MSXsetinitqual(ToolkitConstants.MSX_NODE, node_idx, species_idx,
3559
+ initial_conc)
3560
+
3561
+ def set_species_link_initial_concentrations(self,
3562
+ inital_conc: dict[str, list[tuple[str, float]]]
3563
+ ) -> None:
3564
+ """
3565
+ Species the initial (bulk or surface) species concentration at links.
3566
+
3567
+ Only for EPANET-MSX scenarios.
3568
+
3569
+ Parameters
3570
+ ----------
3571
+ inital_conc : `dict[str, list[tuple[str, float]]]`
3572
+ Initial concentration of species (key) at links -- i.e.
3573
+ value: list of link ID and initial concentration.
3574
+ """
3575
+ if not isinstance(inital_conc, dict) or \
3576
+ any(not isinstance(species_id, str) or not isinstance(link_initial_conc, list)
3577
+ for species_id, link_initial_conc in inital_conc.items()) or \
3578
+ any(not isinstance(link_initial_conc, tuple)
3579
+ for link_initial_conc in inital_conc.values()) or \
3580
+ any(not isinstance(link_id, str) or not isinstance(conc, float)
3581
+ for link_id, conc in inital_conc.values()):
3582
+ raise TypeError("'inital_conc' must be an instance of " +
3583
+ "'dict[str, list[tuple[str, float]]'")
3584
+ if any(species_id not in self.sensor_config.bulk_species
3585
+ for species_id in inital_conc.keys()):
3586
+ raise ValueError("Unknown bulk species in 'inital_conc'")
3587
+ if any(link_id not in self.sensor_config.links for link_id, _ in inital_conc.values()):
3588
+ raise ValueError("Unknown link ID in 'inital_conc'")
3589
+ if any(conc < 0 for _, conc in inital_conc.values()):
3590
+ raise ValueError("Initial link concentration can not be negative")
3591
+
3592
+ for species_id, link_initial_conc in inital_conc.items():
3593
+ species_idx, = self.epanet_api.getMSXSpeciesIndex([species_id])
3594
+
3595
+ for link_id, initial_conc in link_initial_conc:
3596
+ link_idx = self.epanet_api.getLinkIndex(link_id)
3597
+ self.epanet_api.msx.MSXsetinitqual(ToolkitConstants.MSX_LINK, link_idx, species_idx,
3598
+ initial_conc)