epyt-flow 0.8.1__py3-none-any.whl → 0.10.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 (50) hide show
  1. epyt_flow/VERSION +1 -1
  2. epyt_flow/__init__.py +1 -0
  3. epyt_flow/data/benchmarks/batadal.py +1 -1
  4. epyt_flow/data/benchmarks/battledim.py +4 -3
  5. epyt_flow/data/benchmarks/gecco_water_quality.py +4 -4
  6. epyt_flow/data/benchmarks/leakdb.py +7 -7
  7. epyt_flow/data/benchmarks/water_usage.py +2 -2
  8. epyt_flow/data/networks.py +1 -1
  9. epyt_flow/gym/control_gyms.py +2 -2
  10. epyt_flow/gym/scenario_control_env.py +9 -1
  11. epyt_flow/metrics.py +28 -28
  12. epyt_flow/models/sensor_interpolation_detector.py +3 -3
  13. epyt_flow/rest_api/base_handler.py +4 -4
  14. epyt_flow/rest_api/scada_data/data_handlers.py +11 -11
  15. epyt_flow/rest_api/scada_data/export_handlers.py +2 -2
  16. epyt_flow/rest_api/scada_data/handlers.py +9 -9
  17. epyt_flow/rest_api/scenario/event_handlers.py +6 -6
  18. epyt_flow/rest_api/scenario/handlers.py +15 -15
  19. epyt_flow/rest_api/scenario/simulation_handlers.py +7 -7
  20. epyt_flow/rest_api/scenario/uncertainty_handlers.py +6 -6
  21. epyt_flow/serialization.py +8 -2
  22. epyt_flow/simulation/events/actuator_events.py +1 -1
  23. epyt_flow/simulation/events/leakages.py +1 -1
  24. epyt_flow/simulation/events/quality_events.py +16 -5
  25. epyt_flow/simulation/events/sensor_reading_attack.py +17 -4
  26. epyt_flow/simulation/events/sensor_reading_event.py +21 -6
  27. epyt_flow/simulation/events/system_event.py +1 -1
  28. epyt_flow/simulation/parallel_simulation.py +1 -1
  29. epyt_flow/simulation/scada/__init__.py +3 -1
  30. epyt_flow/simulation/scada/advanced_control.py +8 -4
  31. epyt_flow/simulation/scada/complex_control.py +625 -0
  32. epyt_flow/simulation/scada/custom_control.py +134 -0
  33. epyt_flow/simulation/scada/scada_data.py +133 -130
  34. epyt_flow/simulation/scada/scada_data_export.py +1 -1
  35. epyt_flow/simulation/scada/simple_control.py +317 -0
  36. epyt_flow/simulation/scenario_config.py +124 -24
  37. epyt_flow/simulation/scenario_simulator.py +514 -49
  38. epyt_flow/simulation/scenario_visualizer.py +9 -9
  39. epyt_flow/simulation/sensor_config.py +38 -28
  40. epyt_flow/topology.py +2 -2
  41. epyt_flow/uncertainty/model_uncertainty.py +624 -147
  42. epyt_flow/uncertainty/sensor_noise.py +94 -19
  43. epyt_flow/uncertainty/uncertainties.py +4 -4
  44. epyt_flow/uncertainty/utils.py +7 -7
  45. epyt_flow/utils.py +9 -8
  46. {epyt_flow-0.8.1.dist-info → epyt_flow-0.10.0.dist-info}/LICENSE +1 -1
  47. {epyt_flow-0.8.1.dist-info → epyt_flow-0.10.0.dist-info}/METADATA +7 -6
  48. {epyt_flow-0.8.1.dist-info → epyt_flow-0.10.0.dist-info}/RECORD +50 -47
  49. {epyt_flow-0.8.1.dist-info → epyt_flow-0.10.0.dist-info}/WHEEL +1 -1
  50. {epyt_flow-0.8.1.dist-info → epyt_flow-0.10.0.dist-info}/top_level.txt +0 -0
@@ -5,6 +5,8 @@ import sys
5
5
  import os
6
6
  import pathlib
7
7
  import time
8
+ from datetime import timedelta
9
+ from datetime import datetime
8
10
  from typing import Generator, Union
9
11
  from copy import deepcopy
10
12
  import shutil
@@ -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,10 @@ 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._advanced_controls = []
116
+ self._custom_controls = []
117
+ self._simple_controls = []
118
+ self._complex_controls = []
109
119
  self._system_events = []
110
120
  self._sensor_reading_events = []
111
121
  self.__running_simulation = False
@@ -168,6 +178,9 @@ class ScenarioSimulator():
168
178
  if self.__f_msx_in is not None:
169
179
  self.epanet_api.loadMSXFile(my_f_msx_in, customMSXlib=custom_epanetmsx_lib)
170
180
 
181
+ self._simple_controls = self._parse_simple_control_rules()
182
+ self._complex_controls = self._parse_complex_control_rules()
183
+
171
184
  self._sensor_config = self._get_empty_sensor_config()
172
185
  if scenario_config is not None:
173
186
  if scenario_config.general_params is not None:
@@ -177,8 +190,15 @@ class ScenarioSimulator():
177
190
  self._sensor_noise = scenario_config.sensor_noise
178
191
  self._sensor_config = scenario_config.sensor_config
179
192
 
180
- for control in scenario_config.controls:
181
- self.add_control(control)
193
+ if scenario_config.advanced_controls is not None:
194
+ for control in scenario_config.advanced_controls:
195
+ self.add_advanced_control(control)
196
+ for control in scenario_config.custom_controls:
197
+ self.add_custom_control(control)
198
+ for control in scenario_config.simple_controls:
199
+ self.add_simple_control(control)
200
+ for control in scenario_config.complex_controls:
201
+ self.add_complex_control(control)
182
202
  for event in scenario_config.system_events:
183
203
  self.add_system_event(event)
184
204
  for event in scenario_config.sensor_reading_events:
@@ -323,18 +343,62 @@ class ScenarioSimulator():
323
343
  self._sensor_config = sensor_config
324
344
 
325
345
  @property
326
- def controls(self) -> list[AdvancedControlModule]:
346
+ def advanced_controls(self) -> list:
327
347
  """
328
- Gets all control modules.
348
+ Returns all advanced control modules.
329
349
 
330
350
  Returns
331
351
  -------
332
352
  list[:class:`~epyt_flow.simulation.scada.advanced_control.AdvancedControlModule`]
333
- All control modules.
353
+ All advanced control modules.
354
+ """
355
+ warnings.warn("'AdvancedControlModule' is deprecated and will be removed in a " +
356
+ "future release -- use 'CustomControlModule' instead")
357
+ self._adapt_to_network_changes()
358
+
359
+ return deepcopy(self._advanced_controls)
360
+
361
+ @property
362
+ def custom_controls(self) -> list[CustomControlModule]:
363
+ """
364
+ Returns all custom control modules.
365
+
366
+ Returns
367
+ -------
368
+ list[:class:`~epyt_flow.simulation.scada.custom_control.CustomControlModule`]
369
+ All custom control modules.
370
+ """
371
+ self._adapt_to_network_changes()
372
+
373
+ return deepcopy(self._custom_controls)
374
+
375
+ @property
376
+ def simple_controls(self) -> list[SimpleControlModule]:
377
+ """
378
+ Gets all simple EPANET control rules.
379
+
380
+ Returns
381
+ -------
382
+ list[:class:`~epyt_flow.simulation.scada.simple_control.SimpleControlModule`]
383
+ All simple EPANET control rules.
384
+ """
385
+ self._adapt_to_network_changes()
386
+
387
+ return deepcopy(self._simple_controls)
388
+
389
+ @property
390
+ def complex_controls(self) -> list[SimpleControlModule]:
391
+ """
392
+ Gets all complex (IF-THEN-ELSE) EPANET control rules.
393
+
394
+ Returns
395
+ -------
396
+ list[:class:`~epyt_flow.simulation.scada.complex_control.ComplexControlModule`]
397
+ All complex EPANET control rules.
334
398
  """
335
399
  self._adapt_to_network_changes()
336
400
 
337
- return deepcopy(self._controls)
401
+ return deepcopy(self._complex_controls)
338
402
 
339
403
  @property
340
404
  def leakages(self) -> list[Leakage]:
@@ -422,6 +486,132 @@ class ScenarioSimulator():
422
486
 
423
487
  return deepcopy(self._sensor_reading_events)
424
488
 
489
+ def _parse_simple_control_rules(self) -> list[SimpleControlModule]:
490
+ controls = []
491
+
492
+ for idx in self.epanet_api.getControls():
493
+ control = self.epanet_api.getControls(idx)
494
+
495
+ if control.Setting == "OPEN":
496
+ link_status = ActuatorConstants.EN_OPEN
497
+ else:
498
+ link_status = ActuatorConstants.EN_CLOSED
499
+
500
+ if control.Type == "LOWLEVEL":
501
+ cond_type = ToolkitConstants.EN_LOWLEVEL
502
+ elif control.Type == "HIGHLEVEL":
503
+ cond_type = ToolkitConstants.EN_HILEVEL
504
+ elif control.Type == "TIMER":
505
+ cond_type = ToolkitConstants.EN_TIMER
506
+ elif control.Type == "TIMEOFDAY":
507
+ cond_type = ToolkitConstants.EN_TIMEOFDAY
508
+
509
+ if control.NodeID is not None:
510
+ cond_var_value = control.NodeID
511
+ cond_comp_value = control.Value
512
+ else:
513
+ if cond_type == ToolkitConstants.EN_TIMER:
514
+ cond_var_value = int(control.Value / 3600)
515
+ elif cond_type == ToolkitConstants.EN_TIMEOFDAY:
516
+ sec = control.Value
517
+ if sec <= 43200:
518
+ cond_var_value = \
519
+ f"{':'.join(str(timedelta(seconds=sec)).split(':')[:2])} AM"
520
+ else:
521
+ sec -= 43200
522
+ cond_var_value = \
523
+ f"{':'.join(str(timedelta(seconds=sec)).split(':')[:2])} PM"
524
+ cond_comp_value = None
525
+
526
+ controls.append(SimpleControlModule(link_id=control.LinkID,
527
+ link_status=link_status,
528
+ cond_type=cond_type,
529
+ cond_var_value=cond_var_value,
530
+ cond_comp_value=cond_comp_value))
531
+
532
+ return controls
533
+
534
+ def _parse_complex_control_rules(self) -> list[ComplexControlModule]:
535
+ controls = []
536
+
537
+ rules = self.epanet_api.getRules()
538
+ for rule_idx, rule in rules.items():
539
+ rule_info = self.epanet_api.getRuleInfo(rule_idx)
540
+
541
+ rule_id = rule["Rule_ID"]
542
+ rule_priority, *_ = rule_info.Priority
543
+
544
+ # Parse conditions
545
+ n_rule_premises, *_ = rule_info.Premises
546
+
547
+ condition_1 = None
548
+ additional_conditions = []
549
+ for j in range(1, n_rule_premises + 1):
550
+ [logop, object_type_id, obj_idx, variable_type_id, relop, status, value_premise] = \
551
+ self.epanet_api.api.ENgetpremise(rule_idx, j)
552
+
553
+ object_id = None
554
+ if object_type_id == ToolkitConstants.EN_R_NODE:
555
+ object_id = self.epanet_api.getNodeNameID(obj_idx)
556
+ elif object_type_id == ToolkitConstants.EN_R_LINK:
557
+ object_id = self.epanet_api.getLinkNameID(obj_idx)
558
+ elif object_type_id == ToolkitConstants.EN_R_SYSTEM:
559
+ object_id = ""
560
+
561
+ if variable_type_id >= ToolkitConstants.EN_R_TIME:
562
+ value_premise = datetime.fromtimestamp(value_premise)\
563
+ .strftime("%I:%M %p")
564
+ if status != 0:
565
+ value_premise = self.epanet_api.RULESTATUS[status - 1]
566
+
567
+ condition = RuleCondition(object_type_id, object_id, variable_type_id,
568
+ relop, value_premise)
569
+ if condition_1 is None:
570
+ condition_1 = condition
571
+ else:
572
+ additional_conditions.append((logop, condition))
573
+
574
+ # Parse actions
575
+ n_rule_then_actions, *_ = rule_info.ThenActions
576
+ actions = []
577
+ for j in range(1, n_rule_then_actions + 1):
578
+ [link_idx, link_status, link_setting] = \
579
+ self.epanet_api.api.ENgetthenaction(rule_idx, j)
580
+
581
+ link_type_id = self.epanet_api.getLinkTypeIndex(link_idx)
582
+ link_id = self.epanet_api.getLinkNameID(link_idx)
583
+ if link_status >= 0:
584
+ action_type_id = link_status
585
+ action_value = link_status
586
+ else:
587
+ action_type_id = EN_R_ACTION_SETTING
588
+ action_value = link_setting
589
+
590
+ actions.append(RuleAction(link_type_id, link_id, action_type_id, action_value))
591
+
592
+ n_rule_else_actions, *_ = rule_info.ElseActions
593
+ else_actions = []
594
+ for j in range(1, n_rule_else_actions + 1):
595
+ [link_idx, link_status, link_setting] = \
596
+ self.epanet_api.api.ENgetelseaction(rule_idx, j)
597
+
598
+ link_type_id = self.epanet_api.getLinkType(link_idx)
599
+ link_id = self.epanet_api.getLinkNameID(link_idx)
600
+ if link_status <= 3:
601
+ action_type_id = link_status
602
+ action_value = link_status
603
+ else:
604
+ action_type_id = EN_R_ACTION_SETTING
605
+ action_value = link_setting
606
+
607
+ else_actions.append(RuleAction(link_type_id, link_id, action_type_id, action_value))
608
+
609
+ # Create and add control module
610
+ controls.append(ComplexControlModule(rule_id, condition_1, additional_conditions,
611
+ actions, else_actions, int(rule_priority)))
612
+
613
+ return controls
614
+
425
615
  def _adapt_to_network_changes(self):
426
616
  nodes = self.epanet_api.getNodeNameID()
427
617
  links = self.epanet_api.getLinkNameID()
@@ -477,7 +667,8 @@ class ScenarioSimulator():
477
667
  self.close()
478
668
 
479
669
  def save_to_epanet_file(self, inp_file_path: str, msx_file_path: str = None,
480
- export_sensor_config: bool = True) -> None:
670
+ export_sensor_config: bool = True, undo_system_events: bool = True
671
+ ) -> None:
481
672
  """
482
673
  Exports this scenario to EPANET files -- i.e. an .inp file
483
674
  and (optionally) a .msx file if EPANET-MSX was loaded.
@@ -540,6 +731,10 @@ class ScenarioSimulator():
540
731
  if write_end_section is True:
541
732
  f_in.write("\n[END]")
542
733
 
734
+ if undo_system_events is True:
735
+ for event in self._system_events:
736
+ event.cleanup()
737
+
543
738
  if inp_file_path is not None:
544
739
  self.epanet_api.saveInputFile(inp_file_path)
545
740
  self.__f_inp_in = inp_file_path
@@ -641,6 +836,10 @@ class ScenarioSimulator():
641
836
 
642
837
  __override_report_section(msx_file_path, report_desc)
643
838
 
839
+ if undo_system_events is True:
840
+ for event in self._system_events:
841
+ event.init(self.epanet_api)
842
+
644
843
  def get_flow_units(self) -> int:
645
844
  """
646
845
  Gets the flow units.
@@ -791,7 +990,11 @@ class ScenarioSimulator():
791
990
  return ScenarioConfig(f_inp_in=self.__f_inp_in, f_msx_in=self.__f_msx_in,
792
991
  general_params=general_params, sensor_config=self.sensor_config,
793
992
  memory_consumption_estimate=self.estimate_memory_consumption(),
794
- controls=self.controls, sensor_noise=self.sensor_noise,
993
+ advanced_controls=None if len(self._advanced_controls) == 0 else self.advanced_controls,
994
+ custom_controls=self.custom_controls,
995
+ simple_controls=self.simple_controls,
996
+ complex_controls=self.complex_controls,
997
+ sensor_noise=self.sensor_noise,
795
998
  model_uncertainty=self.model_uncertainty,
796
999
  system_events=self.system_events,
797
1000
  sensor_reading_events=self.sensor_reading_events)
@@ -938,8 +1141,72 @@ class ScenarioSimulator():
938
1141
  for t in range(pattern_length): # Set shuffled/randomized pattern
939
1142
  self.epanet_api.setPatternValue(pattern_id, t + 1, pattern[t])
940
1143
 
1144
+ def get_pattern(self, pattern_id: str) -> np.ndarray:
1145
+ """
1146
+ Returns the EPANET pattern (i.e. all multiplier factors over time) given its ID.
1147
+
1148
+ Parameters
1149
+ ----------
1150
+ pattern_id : `str`
1151
+ ID of the pattern.
1152
+
1153
+ Returns
1154
+ -------
1155
+ `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
1156
+ The pattern -- i.e. multiplier factors over time.
1157
+ """
1158
+ pattern_idx = self.epanet_api.getPatternIndex(pattern_id)
1159
+ pattern_length = self.epanet_api.getPatternLengths(pattern_idx)
1160
+ return np.array([self.epanet_api.getPatternValue(pattern_idx, t+1)
1161
+ for t in range(pattern_length)])
1162
+
1163
+ def add_pattern(self, pattern_id: str, pattern: np.ndarray) -> None:
1164
+ """
1165
+ Adds a pattern to the EPANET scenario.
1166
+
1167
+ Parameters
1168
+ ----------
1169
+ pattern_id : `str`
1170
+ ID of the pattern.
1171
+ pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
1172
+ Pattern of multipliers over time.
1173
+ """
1174
+ self._adapt_to_network_changes()
1175
+
1176
+ if not isinstance(pattern_id, str):
1177
+ raise TypeError("'pattern_id' must be an instance of 'str' " +
1178
+ f"but not of '{type(pattern_id)}'")
1179
+ if not isinstance(pattern, np.ndarray):
1180
+ raise TypeError("'pattern' must be an instance of 'numpy.ndarray' " +
1181
+ f"but not of '{type(pattern)}'")
1182
+ if len(pattern.shape) > 1:
1183
+ raise ValueError(f"Inconsistent pattern shape '{pattern.shape}' " +
1184
+ "detected. Expected a one dimensional array!")
1185
+
1186
+ self.epanet_api.addPattern(pattern_id, pattern)
1187
+
1188
+ def get_node_demand_pattern(self, node_id: str) -> np.ndarray:
1189
+ """
1190
+ Returns the values of the demand pattern of a given node --
1191
+ i.e. multiplier factors that are applied to the base demand.
1192
+
1193
+ Parameters
1194
+ ----------
1195
+ node_id : `str`
1196
+ ID of the node.
1197
+
1198
+ Returns
1199
+ -------
1200
+ `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
1201
+ The demand pattern -- i.e. multiplier factors over time.
1202
+ """
1203
+ node_idx = self.epanet_api.getNodeIndex(node_id)
1204
+ demand_category = self.epanet_api.getNodeDemandCategoriesNumber()[node_idx]
1205
+ demand_pattern_id = self.epanet_api.getNodeDemandPatternNameID()[demand_category][node_idx - 1]
1206
+ return self.get_pattern(demand_pattern_id)
1207
+
941
1208
  def set_node_demand_pattern(self, node_id: str, base_demand: float, demand_pattern_id: str,
942
- demand_pattern: np.ndarray) -> None:
1209
+ demand_pattern: np.ndarray = None) -> None:
943
1210
  """
944
1211
  Sets the demand pattern (incl. base demand) at a given node.
945
1212
 
@@ -950,9 +1217,12 @@ class ScenarioSimulator():
950
1217
  base_demand : `float`
951
1218
  Base demand.
952
1219
  demand_pattern_id : `str`
953
- ID of the new demand pattern.
954
- demand_pattern : `numpy.ndarray`
1220
+ ID of the (new) demand pattern. Existing demand pattern will be overriden if it already exisits.
1221
+ demand_pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, optional
955
1222
  Demand pattern over time. Final demand over time = base_demand * demand_pattern
1223
+ If None, the pattern demand_pattern_id is assumed to already exist.
1224
+
1225
+ The default is None.
956
1226
  """
957
1227
  self._adapt_to_network_changes()
958
1228
 
@@ -964,35 +1234,170 @@ class ScenarioSimulator():
964
1234
  if not isinstance(demand_pattern_id, str):
965
1235
  raise TypeError("'demand_pattern_id' must be an instance of 'str' " +
966
1236
  f"but not of '{type(demand_pattern_id)}'")
967
- if not isinstance(demand_pattern, np.ndarray):
968
- raise TypeError("'demand_pattern' must be an instance of 'numpy.ndarray' " +
969
- f"but not of '{type(demand_pattern)}'")
970
- if len(demand_pattern.shape) > 1:
971
- raise ValueError(f"Inconsistent demand pattern shape '{demand_pattern.shape}' " +
972
- "detected. Expected a one dimensional array!")
1237
+ if demand_pattern is not None:
1238
+ if not isinstance(demand_pattern, np.ndarray):
1239
+ raise TypeError("'demand_pattern' must be an instance of 'numpy.ndarray' " +
1240
+ f"but not of '{type(demand_pattern)}'")
1241
+ if len(demand_pattern.shape) > 1:
1242
+ raise ValueError(f"Inconsistent demand pattern shape '{demand_pattern.shape}' " +
1243
+ "detected. Expected a one dimensional array!")
973
1244
 
974
1245
  node_idx = self.epanet_api.getNodeIndex(node_id)
975
- self.epanet_api.addPattern(demand_pattern_id, demand_pattern)
1246
+
1247
+ if demand_pattern_id not in self.epanet_api.getPatternNameID():
1248
+ if demand_pattern is None:
1249
+ raise ValueError("'demand_pattern' can not be None if " +
1250
+ "'demand_pattern_id' does not already exist.")
1251
+ self.epanet_api.addPattern(demand_pattern_id, demand_pattern)
1252
+ else:
1253
+ if demand_pattern is not None:
1254
+ pattern_idx = self.epanet_api.getPatternIndex(demand_pattern_id)
1255
+ self.epanet_api.setPattern(pattern_idx, demand_pattern)
1256
+
976
1257
  self.epanet_api.setNodeJunctionData(node_idx, self.epanet_api.getNodeElevations(node_idx),
977
1258
  base_demand, demand_pattern_id)
978
1259
 
979
- def add_control(self, control: AdvancedControlModule) -> None:
1260
+ def add_advanced_control(self, control) -> None:
980
1261
  """
981
- Adds a control module to the scenario simulation.
1262
+ Adds an advanced control module to the scenario simulation.
982
1263
 
983
1264
  Parameters
984
1265
  ----------
985
1266
  control : :class:`~epyt_flow.simulation.scada.advanced_control.AdvancedControlModule`
986
- Control module.
1267
+ Advanced control module.
987
1268
  """
988
1269
  self._adapt_to_network_changes()
989
1270
 
1271
+ from .scada.advanced_control import AdvancedControlModule
990
1272
  if not isinstance(control, AdvancedControlModule):
991
1273
  raise TypeError("'control' must be an instance of " +
992
1274
  "'epyt_flow.simulation.scada.AdvancedControlModule' not of " +
993
1275
  f"'{type(control)}'")
994
1276
 
995
- self._controls.append(control)
1277
+ self._advanced_controls.append(control)
1278
+
1279
+ def add_custom_control(self, control: CustomControlModule) -> None:
1280
+ """
1281
+ Adds a custom control module to the scenario simulation.
1282
+
1283
+ Parameters
1284
+ ----------
1285
+ control : :class:`~epyt_flow.simulation.scada.custom_control.CustomControlModule`
1286
+ Custom control module.
1287
+ """
1288
+ self._adapt_to_network_changes()
1289
+
1290
+ if not isinstance(control, CustomControlModule):
1291
+ raise TypeError("'control' must be an instance of " +
1292
+ "'epyt_flow.simulation.scada.CustomControlModule' not of " +
1293
+ f"'{type(control)}'")
1294
+
1295
+ self._custom_controls.append(control)
1296
+
1297
+ def add_simple_control(self, control: SimpleControlModule) -> None:
1298
+ """
1299
+ Adds a simple EPANET control rule to the scenario simulation.
1300
+
1301
+ Parameters
1302
+ ----------
1303
+ control : :class:`~epyt_flow.simulation.scada.simple_control.SimpleControlModule`
1304
+ Simple EPANET control module.
1305
+ """
1306
+ self._adapt_to_network_changes()
1307
+
1308
+ if not isinstance(control, SimpleControlModule):
1309
+ raise TypeError("'control' must be an instance of " +
1310
+ "'epyt_flow.simulation.scada.SimpleControlModule' not of " +
1311
+ f"'{type(control)}'")
1312
+
1313
+ if not any(c == control for c in self._simple_controls):
1314
+ self._simple_controls.append(control)
1315
+ self.epanet_api.addControls(str(control))
1316
+
1317
+ def remove_all_simple_controls(self) -> None:
1318
+ """
1319
+ Removes all simple EPANET controls from the scenario.
1320
+ """
1321
+ self.epanet_api.deleteControls()
1322
+ self._simple_controls = []
1323
+
1324
+ def remove_simple_control(self, control: SimpleControlModule) -> None:
1325
+ """
1326
+ Removes a given simple EPANET control rule from the scenario.
1327
+
1328
+ Parameters
1329
+ ----------
1330
+ control : :class:`~epyt_flow.simulation.scada.simple_control.SimpleControlModule`
1331
+ Simple EPANET control module to be removed.
1332
+ """
1333
+ self._adapt_to_network_changes()
1334
+
1335
+ if not isinstance(control, SimpleControlModule):
1336
+ raise TypeError("'control' must be an instance of " +
1337
+ "'epyt_flow.simulation.scada.SimpleControlModule' not of " +
1338
+ f"'{type(control)}'")
1339
+
1340
+ control_idx = None
1341
+ for idx, c in enumerate(self._simple_controls):
1342
+ if c == control:
1343
+ control_idx = idx + 1
1344
+ break
1345
+ if control_idx is None:
1346
+ raise ValueError("Invalid/Unknown control module.")
1347
+
1348
+ self.epanet_api.deleteControls(control_idx)
1349
+ self._simple_controls.remove(control)
1350
+
1351
+ def add_complex_control(self, control: ComplexControlModule) -> None:
1352
+ """
1353
+ Adds an complex (IF-THEN-ELSE) EPANET control rule to the scenario simulation.
1354
+
1355
+ Parameters
1356
+ ----------
1357
+ control : :class:`~epyt_flow.simulation.scada.complex_control.ComplexControlModule`
1358
+ Complex EPANET control module.
1359
+ """
1360
+ self._adapt_to_network_changes()
1361
+
1362
+ if not isinstance(control, ComplexControlModule):
1363
+ raise TypeError("'control' must be an instance of " +
1364
+ "'epyt_flow.simulation.scada.ComplexControlModule' not of " +
1365
+ f"'{type(control)}'")
1366
+
1367
+ if not any(c == control for c in self._complex_controls):
1368
+ self._complex_controls.append(control)
1369
+ self.epanet_api.addRules(str(control))
1370
+
1371
+ def remove_all_complex_controls(self) -> None:
1372
+ """
1373
+ Removes all complex EPANET controls from the scenario.
1374
+ """
1375
+ self.epanet_api.deleteRules()
1376
+ self._complex_controls = []
1377
+
1378
+ def remove_complex_control(self, control: ComplexControlModule) -> None:
1379
+ """
1380
+ Removes a given complex (IF-THEN-ELSE) EPANET control rule from the scenario.
1381
+
1382
+ Parameters
1383
+ ----------
1384
+ control : :class:`~epyt_flow.simulation.scada.complex_control.ComplexControlModule`
1385
+ Complex EPANET control module to be removed.
1386
+ """
1387
+ self._adapt_to_network_changes()
1388
+
1389
+ if not isinstance(control, ComplexControlModule):
1390
+ raise TypeError("'control' must be an instance of " +
1391
+ "'epyt_flow.simulation.scada.ComplexControlModule' not of " +
1392
+ f"'{type(control)}'")
1393
+
1394
+ if control.rule_id not in self.epanet_api.getRuleID():
1395
+ raise ValueError("Invalid/Unknown control module. " +
1396
+ f"Can not find rule ID '{control.rule_id}'")
1397
+
1398
+ rule_idx = self.epanet_api.getRuleID().index(control.rule_id) + 1
1399
+ self.epanet_api.deleteRules(rule_idx)
1400
+ self._complex_controls.remove(control)
996
1401
 
997
1402
  def add_leakage(self, leakage_event: Leakage) -> None:
998
1403
  """
@@ -1529,12 +1934,17 @@ class ScenarioSimulator():
1529
1934
  for event in self._system_events:
1530
1935
  event.reset()
1531
1936
 
1532
- if self._controls is not None:
1533
- for c in self._controls:
1937
+ if self._advanced_controls is not None:
1938
+ for c in self._advanced_controls:
1534
1939
  c.init(self.epanet_api)
1940
+ if self._custom_controls is not None:
1941
+ for control in self._custom_controls:
1942
+ control.init(self.epanet_api)
1535
1943
 
1536
1944
  def run_advanced_quality_simulation(self, hyd_file_in: str, verbose: bool = False,
1537
- frozen_sensor_config: bool = False) -> ScadaData:
1945
+ frozen_sensor_config: bool = False,
1946
+ use_quality_time_step_as_reporting_time_step: bool = False
1947
+ ) -> ScadaData:
1538
1948
  """
1539
1949
  Runs an advanced quality analysis using EPANET-MSX.
1540
1950
 
@@ -1551,6 +1961,13 @@ class ScenarioSimulator():
1551
1961
  If True, the sensor config can not be changed and only the required sensor nodes/links
1552
1962
  will be stored -- this usually leads to a significant reduction in memory consumption.
1553
1963
 
1964
+ The default is False.
1965
+ use_quality_time_step_as_reporting_time_step : `bool`, optional
1966
+ If True, the water quality time step will be used as the reporting time step.
1967
+
1968
+ As a consequence, the simualtion results can not be merged
1969
+ with the hydraulic simulation.
1970
+
1554
1971
  The default is False.
1555
1972
 
1556
1973
  Returns
@@ -1570,7 +1987,9 @@ class ScenarioSimulator():
1570
1987
  for scada_data in gen(hyd_file_in=hyd_file_in,
1571
1988
  verbose=verbose,
1572
1989
  return_as_dict=True,
1573
- frozen_sensor_config=frozen_sensor_config):
1990
+ frozen_sensor_config=frozen_sensor_config,
1991
+ use_quality_time_step_as_reporting_time_step=
1992
+ use_quality_time_step_as_reporting_time_step):
1574
1993
  if result is None:
1575
1994
  result = {}
1576
1995
  for data_type, data in scada_data.items():
@@ -1595,7 +2014,8 @@ class ScenarioSimulator():
1595
2014
  def run_advanced_quality_simulation_as_generator(self, hyd_file_in: str, verbose: bool = False,
1596
2015
  support_abort: bool = False,
1597
2016
  return_as_dict: bool = False,
1598
- frozen_sensor_config: bool = False
2017
+ frozen_sensor_config: bool = False,
2018
+ use_quality_time_step_as_reporting_time_step: bool = False,
1599
2019
  ) -> Generator[Union[ScadaData, dict], bool, None]:
1600
2020
  """
1601
2021
  Runs an advanced quality analysis using EPANET-MSX.
@@ -1617,6 +2037,13 @@ class ScenarioSimulator():
1617
2037
  If True, the sensor config can not be changed and only the required sensor nodes/links
1618
2038
  will be stored -- this usually leads to a significant reduction in memory consumption.
1619
2039
 
2040
+ The default is False.
2041
+ use_quality_time_step_as_reporting_time_step : `bool`, optional
2042
+ If True, the water quality time step will be used as the reporting time step.
2043
+
2044
+ As a consequence, the simualtion results can not be merged
2045
+ with the hydraulic simulation.
2046
+
1620
2047
  The default is False.
1621
2048
 
1622
2049
  Returns
@@ -1642,6 +2069,11 @@ class ScenarioSimulator():
1642
2069
  reporting_time_step = self.epanet_api.getTimeReportingStep()
1643
2070
  hyd_time_step = self.epanet_api.getTimeHydraulicStep()
1644
2071
 
2072
+ if use_quality_time_step_as_reporting_time_step is True:
2073
+ quality_time_step = self.epanet_api.getMSXTimeStep()
2074
+ reporting_time_step = quality_time_step
2075
+ hyd_time_step = quality_time_step
2076
+
1645
2077
  self.epanet_api.initializeMSXQualityAnalysis(ToolkitConstants.EN_NOSAVE)
1646
2078
 
1647
2079
  self.__running_simulation = True
@@ -1654,7 +2086,7 @@ class ScenarioSimulator():
1654
2086
  print("Running EPANET-MSX ...")
1655
2087
  n_iterations = math.ceil(self.epanet_api.getTimeSimulationDuration() /
1656
2088
  hyd_time_step)
1657
- progress_bar = iter(tqdm(range(n_iterations + 1), desc="Time steps"))
2089
+ progress_bar = iter(tqdm(range(n_iterations + 1), ascii=True, desc="Time steps"))
1658
2090
 
1659
2091
  def __get_concentrations(init_qual=False):
1660
2092
  if init_qual is True:
@@ -1793,7 +2225,9 @@ class ScenarioSimulator():
1793
2225
  self.__running_simulation = False
1794
2226
 
1795
2227
  def run_basic_quality_simulation(self, hyd_file_in: str, verbose: bool = False,
1796
- frozen_sensor_config: bool = False) -> ScadaData:
2228
+ frozen_sensor_config: bool = False,
2229
+ use_quality_time_step_as_reporting_time_step: bool = False
2230
+ ) -> ScadaData:
1797
2231
  """
1798
2232
  Runs a basic quality analysis using EPANET.
1799
2233
 
@@ -1810,6 +2244,13 @@ class ScenarioSimulator():
1810
2244
  If True, the sensor config can not be changed and only the required sensor nodes/links
1811
2245
  will be stored -- this usually leads to a significant reduction in memory consumption.
1812
2246
 
2247
+ The default is False.
2248
+ use_quality_time_step_as_reporting_time_step : `bool`, optional
2249
+ If True, the water quality time step will be used as the reporting time step.
2250
+
2251
+ As a consequence, the simualtion results can not be merged
2252
+ with the hydraulic simulation.
2253
+
1813
2254
  The default is False.
1814
2255
 
1815
2256
  Returns
@@ -1827,7 +2268,9 @@ class ScenarioSimulator():
1827
2268
  for scada_data in gen(hyd_file_in=hyd_file_in,
1828
2269
  verbose=verbose,
1829
2270
  return_as_dict=True,
1830
- frozen_sensor_config=frozen_sensor_config):
2271
+ frozen_sensor_config=frozen_sensor_config,
2272
+ use_quality_time_step_as_reporting_time_step=
2273
+ use_quality_time_step_as_reporting_time_step):
1831
2274
  if result is None:
1832
2275
  result = {}
1833
2276
  for data_type, data in scada_data.items():
@@ -1850,6 +2293,7 @@ class ScenarioSimulator():
1850
2293
  support_abort: bool = False,
1851
2294
  return_as_dict: bool = False,
1852
2295
  frozen_sensor_config: bool = False,
2296
+ use_quality_time_step_as_reporting_time_step: bool = False
1853
2297
  ) -> Generator[Union[ScadaData, dict], bool, None]:
1854
2298
  """
1855
2299
  Runs a basic quality analysis using EPANET.
@@ -1873,6 +2317,13 @@ class ScenarioSimulator():
1873
2317
  If True, the sensor config can not be changed and only the required sensor nodes/links
1874
2318
  will be stored -- this usually leads to a significant reduction in memory consumption.
1875
2319
 
2320
+ The default is False.
2321
+ use_quality_time_step_as_reporting_time_step : `bool`, optional
2322
+ If True, the water quality time step will be used as the reporting time step.
2323
+
2324
+ As a consequence, the simualtion results can not be merged
2325
+ with the hydraulic simulation.
2326
+
1876
2327
  The default is False.
1877
2328
 
1878
2329
  Returns
@@ -1887,6 +2338,11 @@ class ScenarioSimulator():
1887
2338
  reporting_time_start = self.epanet_api.getTimeReportingStart()
1888
2339
  reporting_time_step = self.epanet_api.getTimeReportingStep()
1889
2340
 
2341
+ if use_quality_time_step_as_reporting_time_step is True:
2342
+ quality_time_step = self.epanet_api.getTimeQualityStep()
2343
+ requested_time_step = quality_time_step
2344
+ reporting_time_step = quality_time_step
2345
+
1890
2346
  self.epanet_api.useHydraulicFile(hyd_file_in)
1891
2347
 
1892
2348
  self.epanet_api.openQualityAnalysis()
@@ -1896,7 +2352,7 @@ class ScenarioSimulator():
1896
2352
  print("Running basic quality analysis using EPANET ...")
1897
2353
  n_iterations = math.ceil(self.epanet_api.getTimeSimulationDuration() /
1898
2354
  requested_time_step)
1899
- progress_bar = iter(tqdm(range(n_iterations + 1), desc="Time steps"))
2355
+ progress_bar = iter(tqdm(range(n_iterations + 1), ascii=True, desc="Time steps"))
1900
2356
 
1901
2357
  # Run simulation step by step
1902
2358
  total_time = 0
@@ -1945,7 +2401,7 @@ class ScenarioSimulator():
1945
2401
  yield data
1946
2402
 
1947
2403
  # Next
1948
- tstep = self.epanet_api.nextQualityAnalysisStep()
2404
+ tstep = self.epanet_api.stepQualityAnalysisTimeLeft()
1949
2405
 
1950
2406
  self.epanet_api.closeHydraulicAnalysis()
1951
2407
 
@@ -2081,7 +2537,7 @@ class ScenarioSimulator():
2081
2537
  print("Running EPANET ...")
2082
2538
  n_iterations = math.ceil(self.epanet_api.getTimeSimulationDuration() /
2083
2539
  requested_time_step)
2084
- progress_bar = iter(tqdm(range(n_iterations + 1), desc="Time steps"))
2540
+ progress_bar = iter(tqdm(range(n_iterations + 1), ascii=True, desc="Time steps"))
2085
2541
 
2086
2542
  try:
2087
2543
  # Run simulation step by step
@@ -2167,7 +2623,9 @@ class ScenarioSimulator():
2167
2623
  yield data
2168
2624
 
2169
2625
  # Apply control modules
2170
- for control in self._controls:
2626
+ for control in self._advanced_controls:
2627
+ control.step(scada_data)
2628
+ for control in self._custom_controls:
2171
2629
  control.step(scada_data)
2172
2630
 
2173
2631
  # Next
@@ -2538,7 +2996,7 @@ class ScenarioSimulator():
2538
2996
  self.set_general_parameters(quality_model={"type": "CHEM", "chemical_name": chemical_name,
2539
2997
  "units": chemical_units})
2540
2998
 
2541
- def add_quality_source(self, node_id: str, pattern: np.ndarray, source_type: int,
2999
+ def add_quality_source(self, node_id: str, source_type: int, pattern: np.ndarray = None,
2542
3000
  pattern_id: str = None, source_strength: int = 1.) -> None:
2543
3001
  """
2544
3002
  Adds a new external water quality source at a particular node.
@@ -2547,8 +3005,6 @@ class ScenarioSimulator():
2547
3005
  ----------
2548
3006
  node_id : `str`
2549
3007
  ID of the node at which this external water quality source is placed.
2550
- pattern : `numpy.ndarray`
2551
- 1d source pattern.
2552
3008
  source_type : `int`,
2553
3009
  Types of the external water quality source -- must be of the following
2554
3010
  EPANET toolkit constants:
@@ -2564,6 +3020,12 @@ class ScenarioSimulator():
2564
3020
  - EN_MASS Injects a given mass/minute into a node
2565
3021
  - EN_SETPOINT Sets the concentration leaving a node to a given value
2566
3022
  - EN_FLOWPACED Adds a given value to the concentration leaving a node
3023
+ pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, optional
3024
+ 1d source pattern multipiers over time -- i.e. quality-source = source_strength * pattern.
3025
+
3026
+ If None, the pattern pattern_id is assume to already exist.
3027
+
3028
+ The default is None.
2567
3029
  pattern_id : `str`, optional
2568
3030
  ID of the source pattern.
2569
3031
 
@@ -2586,20 +3048,23 @@ class ScenarioSimulator():
2586
3048
  "call 'enable_chemical_analysis()' before calling this function.")
2587
3049
  if node_id not in self._sensor_config.nodes:
2588
3050
  raise ValueError(f"Unknown node '{node_id}'")
2589
- if not isinstance(pattern, np.ndarray):
2590
- raise TypeError("'pattern' must be an instance of 'numpy.ndarray' " +
2591
- f"but not of '{type(pattern)}'")
2592
3051
  if not isinstance(source_type, int) or not 0 <= source_type <= 3:
2593
3052
  raise ValueError("Invalid type of water quality source")
2594
-
3053
+ if pattern is not None:
3054
+ if not isinstance(pattern, np.ndarray):
3055
+ raise TypeError("'pattern' must be an instance of 'numpy.ndarray' " +
3056
+ f"but not of '{type(pattern)}'")
3057
+ if pattern is None and pattern_id is None:
3058
+ raise ValueError("'pattern_id' and 'pattern' can not be None at the same time")
2595
3059
  if pattern_id is None:
2596
3060
  pattern_id = f"quality_source_pattern_node={node_id}"
2597
- if pattern_id in self.epanet_api.getPatternNameID():
2598
- raise ValueError("Invalid 'pattern_id' -- " +
2599
- f"there already exists a pattern with ID '{pattern_id}'")
2600
3061
 
2601
3062
  node_idx = self.epanet_api.getNodeIndex(node_id)
2602
- pattern_idx = self.epanet_api.addPattern(pattern_id, pattern)
3063
+
3064
+ if pattern is None:
3065
+ pattern_idx = self.epanet_api.getPatternIndex(pattern_id)
3066
+ else:
3067
+ pattern_idx = self.epanet_api.addPattern(pattern_id, pattern)
2603
3068
 
2604
3069
  self.epanet_api.api.ENsetnodevalue(node_idx, ToolkitConstants.EN_SOURCETYPE, source_type)
2605
3070
  self.epanet_api.setNodeSourceQuality(node_idx, source_strength)
@@ -2640,7 +3105,7 @@ class ScenarioSimulator():
2640
3105
  node_id : `str`
2641
3106
  ID of the node at which this external (bulk or surface) species injection source
2642
3107
  is placed.
2643
- pattern : `numpy.ndarray`
3108
+ pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
2644
3109
  1d source pattern.
2645
3110
  source_type : `int`,
2646
3111
  Type of the external (bulk or surface) species injection source -- must be one of