1"""
2Module provides a class for scenario simulations.
3"""
4import sys
5import os
6import pathlib
7import time
8import itertools
9from datetime import timedelta
10from datetime import datetime
11from typing import Generator, Union, Optional
12from copy import deepcopy
13import shutil
14import warnings
15import random
16import math
17import uuid
18import numpy as np
19from tqdm import tqdm
20from epanet_plus import EPyT, EpanetConstants
21
22from .scenario_config import ScenarioConfig
23from .sensor_config import SensorConfig, \
24 SENSOR_TYPE_LINK_FLOW, SENSOR_TYPE_LINK_QUALITY, SENSOR_TYPE_NODE_DEMAND, \
25 SENSOR_TYPE_NODE_PRESSURE, SENSOR_TYPE_NODE_QUALITY, \
26 SENSOR_TYPE_PUMP_STATE, SENSOR_TYPE_PUMP_EFFICIENCY, SENSOR_TYPE_PUMP_ENERGYCONSUMPTION, \
27 SENSOR_TYPE_TANK_VOLUME, SENSOR_TYPE_VALVE_STATE, SENSOR_TYPE_NODE_BULK_SPECIES, \
28 SENSOR_TYPE_LINK_BULK_SPECIES, SENSOR_TYPE_SURFACE_SPECIES
29from ..uncertainty import ModelUncertainty, SensorNoise
30from .events import SystemEvent, Leakage, ActuatorEvent, SensorFault, SensorReadingAttack, \
31 SensorReadingEvent
32from .scada import ScadaData, CustomControlModule, SimpleControlModule, ComplexControlModule, \
33 RuleCondition, RuleAction, ActuatorConstants, EN_R_ACTION_SETTING, RULESTATUS
34from ..topology import NetworkTopology, UNITS_SIMETRIC, UNITS_USCUSTOM
35from ..utils import get_temp_folder, areaunit_to_id, massunit_to_id, qualityunit_to_id, \
36 qualityunit_to_str, MASS_UNIT_MG
37
38
[docs]
39class ScenarioSimulator():
40 """
41 Class for running a simulation of a water distribution network scenario.
42
43 Parameters
44 ----------
45 f_inp_in : `str`
46 Path to the .inp file.
47
48 If this is None, then 'scenario_config' must be set with a valid configuration.
49 f_msx_in : `str`, option
50 Path to the .msx file -- optional, only necessary if EPANET-MSX is used.
51
52 The default is None.
53 scenario_config : :class:`~epyt_flow.simulation.scenario_config.ScenarioConfig`
54 Configuration of the scenario -- i.e. a description of the scenario to be simulated.
55
56 If this is None, then 'f_inp_in' must be set with a valid path to the .inp file
57 that is to be simulated.
58 epanet_verbose : `bool`, optional
59 If True, EPyT is verbose and might print messages from time to time.
60
61 The default is False.
62 raise_exception_on_error : `bool`, optional
63 If True, an exception is raised whenever an error occurs in EPANET or EPANET-MSX.
64
65 The default is False.
66 warn_on_error : `bool`, optional
67 If True, a warning is generated whenever an error occurs in EPANET or EPANET-MSX.
68
69 The default is True.
70 ignore_error_codes : `list[int]`, optional
71 List of error codes that should be ignored -- i.e., no exception or
72 warning will be generated.
73 However, error codes will still be included in the SCADA data.
74
75 The default is [].
76
77 Attributes
78 ----------
79 epanet_api : :class:`~epyt_flow.simulation.backend.my_epyt.EPyT`
80 API to EPANET and EPANET-MSX.
81 _model_uncertainty : :class:`~epyt_flow.uncertainty.model_uncertainty.ModelUncertainty`, protected
82 Model uncertainty.
83 _sensor_noise : :class:`~epyt_flow.uncertainty.sensor_noise.SensorNoise`, protected
84 Sensor noise.
85 _sensor_config : :class:`~epyt_flow.simulation.sensor_config.SensorConfig`, protected
86 Sensor configuration.
87 _custom_controls : list[:class:`~epyt_flow.simulation.scada.custom_control.CustomControlModule`], protected
88 List of custom control modules.
89 _simple_controls : list[:class:`~epyt_flow.simulation.scada.simple_control.SimpleControlModule`], protected
90 List of simle EPANET control rules.
91 _complex_controls : list[:class:`~epyt_flow.simulation.scada.complex_control.ComplexControlModule`], protected
92 List of complex (IF-THEN-ELSE) EPANET control rules.
93 _system_events : list[:class:`~epyt_flow.simulation.events.system_event.SystemEvent`], protected
94 Lsit of system events such as leakages.
95 _sensor_reading_events : list[:class:`~epyt_flow.simulation.events.sensor_reading_event.SensorReadingEvent`], protected
96 List of sensor reading events such as sensor override attacks.
97 """
98
99 def __init__(self, f_inp_in: str = None, f_msx_in: str = None,
100 scenario_config: ScenarioConfig = None, epanet_verbose: bool = False,
101 raise_exception_on_error: bool = False, warn_on_error: bool = True,
102 ignore_error_codes: list[int] = []):
103 if f_msx_in is not None and f_inp_in is None:
104 raise ValueError("'f_inp_in' must be set if 'f_msx_in' is set.")
105 if f_inp_in is None and scenario_config is None:
106 raise ValueError("Either 'f_inp_in' or 'scenario_config' must be set.")
107 if scenario_config is not None and f_inp_in is not None:
108 raise ValueError("'f_inp_in' or 'scenario_config' can not be used at the same time")
109 if f_inp_in is not None:
110 if not isinstance(f_inp_in, str):
111 raise TypeError("'f_inp_in' must be an instance of 'str' but not of " +
112 f"'{type(f_inp_in)}'")
113 if f_msx_in is not None:
114 if not isinstance(f_msx_in, str):
115 raise TypeError("'f_msx_in' must be an instance of 'str' but not of " +
116 f"'{type(f_msx_in)}'")
117 if scenario_config is not None:
118 if not isinstance(scenario_config, ScenarioConfig):
119 raise TypeError("'scenario_config' must be an instance of " +
120 "'epyt_flow.simulation.ScenarioConfig' but not of " +
121 f"'{type(scenario_config)}'")
122 if not isinstance(epanet_verbose, bool):
123 raise TypeError("'epanet_verbose' must be an instance of 'bool' " +
124 f"but not of '{type(epanet_verbose)}'")
125
126 self.__f_inp_in = f_inp_in if scenario_config is None else scenario_config.f_inp_in
127 self.__f_msx_in = f_msx_in if scenario_config is None else scenario_config.f_msx_in
128 self._model_uncertainty = ModelUncertainty()
129 self._sensor_noise = None
130 self._sensor_config = None
131 self._custom_controls = []
132 self._simple_controls = []
133 self._complex_controls = []
134 self._system_events = []
135 self._sensor_reading_events = []
136 self.__running_simulation = False
137 self.__uncertainties_applied = False
138
139 def __file_exists(file_in: str) -> bool:
140 try:
141 return pathlib.Path(file_in).is_file()
142 except Exception:
143 return False
144
145 if scenario_config is not None: # Extract .inp file from NetworkTopology if necessary
146 if __file_exists(self.__f_inp_in) is False:
147 network_topo = scenario_config.network_topology
148 if network_topo is not None:
149 warnings.info(".inp file not found -- extracting network data from NetworkTopology")
150 network_topo.to_inp_file(self.__f_inp_in)
151 else:
152 raise ValueError(".inp file does not exist and 'scenario_config' does not " +
153 "contain a specification of the network topology")
154
155 from epanet_plus import EPyT # Workaround: Sphinx autodoc "importlib.import_module TypeError: __mro_entries__"
156 self.epanet_api = EPyT(self.__f_inp_in, use_project=self.__f_msx_in is None)
157
158 if self.__f_msx_in is not None:
159 self.epanet_api.load_msx_file(self.__f_msx_in)
160
161 # Do not raise exceptions in the case of EPANET warnings and errors
162 self.epanet_api.set_error_handling(raise_exception_on_error=raise_exception_on_error,
163 warn_on_error=warn_on_error,
164 ignore_error_codes=ignore_error_codes)
165
166 # Parse and initialize scenario
167 self._simple_controls = self._parse_simple_control_rules()
168 self._complex_controls = self._parse_complex_control_rules()
169
170 self._sensor_config = self._get_empty_sensor_config()
171 if scenario_config is not None:
172 if scenario_config.general_params is not None:
173 self.set_general_parameters(**scenario_config.general_params)
174
175 self._model_uncertainty = scenario_config.model_uncertainty
176 self._sensor_noise = scenario_config.sensor_noise
177 if scenario_config.sensor_config is not None:
178 self._sensor_config = scenario_config.sensor_config
179
180 for control in scenario_config.custom_controls:
181 self.add_custom_control(control)
182 for control in scenario_config.simple_controls:
183 self.add_simple_control(control)
184 for control in scenario_config.complex_controls:
185 self.add_complex_control(control)
186 for event in scenario_config.system_events:
187 self.add_system_event(event)
188 for event in scenario_config.sensor_reading_events:
189 self.add_sensor_reading_event(event)
190
191 def _get_empty_sensor_config(self, node_id_to_idx: dict = None, link_id_to_idx: dict = None,
192 valve_id_to_idx: dict = None, pump_id_to_idx: dict = None,
193 tank_id_to_idx: dict = None, bulkspecies_id_to_idx: dict = None,
194 surfacespecies_id_to_idx: dict = None) -> SensorConfig:
195 flow_unit = self.get_flow_units()
196 pressure_unit = self.get_pressure_units()
197 quality_unit = qualityunit_to_id(self.epanet_api.get_quality_info()["chemUnits"])
198 bulk_species = []
199 surface_species = []
200 bulk_species_mass_unit = []
201 surface_species_mass_unit = []
202 surface_species_area_unit = None
203
204 if self.__f_msx_in is not None:
205 surface_species_area_unit = areaunit_to_id("FT2")#self.epanet_api.get_msx_options()["areaUnits"])
206
207 for species_id, species_info in zip(self.epanet_api.get_all_msx_species_id(),
208 self.epanet_api.get_all_msx_species_info()):
209 if species_info["type"] == EpanetConstants.MSX_BULK:
210 bulk_species.append(species_id)
211 bulk_species_mass_unit.append(massunit_to_id(species_info["units"]))
212 elif species_info["type"] == EpanetConstants.MSX_WALL:
213 surface_species.append(species_id)
214 surface_species_mass_unit.append(massunit_to_id(species_info["units"]))
215
216 return SensorConfig(nodes=self.epanet_api.get_all_nodes_id(),
217 links=self.epanet_api.get_all_links_id(),
218 valves=self.epanet_api.get_all_valves_id(),
219 pumps=self.epanet_api.get_all_pumps_id(),
220 tanks=self.epanet_api.get_all_tanks_id(),
221 bulk_species=bulk_species,
222 surface_species=surface_species,
223 flow_unit=flow_unit,
224 pressure_unit=pressure_unit,
225 quality_unit=quality_unit,
226 bulk_species_mass_unit=bulk_species_mass_unit,
227 surface_species_mass_unit=surface_species_mass_unit,
228 surface_species_area_unit=surface_species_area_unit,
229 node_id_to_idx=node_id_to_idx,
230 link_id_to_idx=link_id_to_idx,
231 valve_id_to_idx=valve_id_to_idx,
232 pump_id_to_idx=pump_id_to_idx,
233 tank_id_to_idx=tank_id_to_idx,
234 bulkspecies_id_to_idx=bulkspecies_id_to_idx,
235 surfacespecies_id_to_idx=surfacespecies_id_to_idx)
236
237 @property
238 def f_inp_in(self) -> str:
239 """
240 Gets the path to the .inp file.
241
242 Returns
243 -------
244 `str`
245 Path to the .inp file.
246 """
247 self._adapt_to_network_changes()
248
249 return self.__f_inp_in
250
251 @property
252 def f_msx_in(self) -> str:
253 """
254 Gets the path to the .msx file.
255
256 Returns
257 -------
258 `str`
259 Path to the .msx file.
260 """
261 self._adapt_to_network_changes()
262
263 return self.__f_msx_in
264
265 @property
266 def model_uncertainty(self) -> ModelUncertainty:
267 """
268 Gets the model uncertainty specification.
269
270 Returns
271 -------
272 :class:`~epyt_flow.uncertainty.model_uncertainty.ModelUncertainty`
273 Model uncertainty.
274 """
275 self._adapt_to_network_changes()
276
277 return deepcopy(self._model_uncertainty)
278
279 @model_uncertainty.setter
280 def model_uncertainty(self, model_uncertainty: ModelUncertainty) -> None:
281 self._adapt_to_network_changes()
282
283 self.set_model_uncertainty(model_uncertainty)
284
285 @property
286 def sensor_noise(self) -> SensorNoise:
287 """
288 Gets the sensor noise/uncertainty.
289
290 Returns
291 -------
292 :class:`~epyt_flow.uncertainty.sensor_noise.SensorNoise`
293 Sensor noise.
294 """
295 self._adapt_to_network_changes()
296
297 return deepcopy(self._sensor_noise)
298
299 @sensor_noise.setter
300 def sensor_noise(self, sensor_noise: SensorNoise) -> None:
301 self._adapt_to_network_changes()
302
303 self.set_sensor_noise(sensor_noise)
304
305 @property
306 def sensor_config(self) -> SensorConfig:
307 """
308 Gets the sensor configuration.
309
310 Returns
311 -------
312 :class:`~epyt_flow.simulation.sensor_config.SensorConfig`
313 Sensor configuration.
314 """
315 self._adapt_to_network_changes()
316
317 return deepcopy(self._sensor_config)
318
319 @sensor_config.setter
320 def sensor_config(self, sensor_config: SensorConfig) -> None:
321 if not isinstance(sensor_config, SensorConfig):
322 raise TypeError("'sensor_config' must be an instance of " +
323 "'epyt_flow.simulation.SensorConfig' but not of " +
324 f"'{type(sensor_config)}'")
325
326 sensor_config.validate(self.epanet_api)
327
328 self._sensor_config = sensor_config
329
330 @property
331 def custom_controls(self) -> list[CustomControlModule]:
332 """
333 Returns all custom control modules.
334
335 Returns
336 -------
337 list[:class:`~epyt_flow.simulation.scada.custom_control.CustomControlModule`]
338 All custom control modules.
339 """
340 self._adapt_to_network_changes()
341
342 return deepcopy(self._custom_controls)
343
344 @property
345 def simple_controls(self) -> list[SimpleControlModule]:
346 """
347 Gets all simple EPANET control rules.
348
349 Returns
350 -------
351 list[:class:`~epyt_flow.simulation.scada.simple_control.SimpleControlModule`]
352 All simple EPANET control rules.
353 """
354 self._adapt_to_network_changes()
355
356 return deepcopy(self._simple_controls)
357
358 @property
359 def complex_controls(self) -> list[SimpleControlModule]:
360 """
361 Gets all complex (IF-THEN-ELSE) EPANET control rules.
362
363 Returns
364 -------
365 list[:class:`~epyt_flow.simulation.scada.complex_control.ComplexControlModule`]
366 All complex EPANET control rules.
367 """
368 self._adapt_to_network_changes()
369
370 return deepcopy(self._complex_controls)
371
372 @property
373 def leakages(self) -> list[Leakage]:
374 """
375 Gets all leakages.
376
377 Returns
378 -------
379 list[:class:`~epyt_flow.simulation.events.leakages.Leakage`]
380 All leakages.
381 """
382 self._adapt_to_network_changes()
383
384 return deepcopy(list(filter(lambda e: isinstance(e, Leakage), self._system_events)))
385
386 @property
387 def actuator_events(self) -> list[ActuatorEvent]:
388 """
389 Gets all actuator events.
390
391 Returns
392 -------
393 list[:class:`~epyt_flow.simulation.events.actuator_event.ActuatorEvent`]
394 All actuator events.
395 """
396 self._adapt_to_network_changes()
397
398 return deepcopy(list(filter(lambda e: isinstance(e, ActuatorEvent), self._system_events)))
399
400 @property
401 def system_events(self) -> list[SystemEvent]:
402 """
403 Gets all system events (e.g. leakages, etc.).
404
405 Returns
406 -------
407 list[:class:`~epyt_flow.simulation.events.system_event.SystemEvent`]
408 All system events.
409 """
410 self._adapt_to_network_changes()
411
412 return deepcopy(self._system_events)
413
414 @property
415 def sensor_faults(self) -> list[SensorFault]:
416 """
417 Gets all sensor faults.
418
419 Returns
420 -------
421 list[:class:`~epyt_flow.simulation.events.sensor_faults.SensorFault`]
422 All sensor faults.
423 """
424 self._adapt_to_network_changes()
425
426 return deepcopy(list(filter(lambda e: isinstance(e, SensorFault),
427 self._sensor_reading_events)))
428
429 @property
430 def sensor_reading_attacks(self) -> list[SensorReadingAttack]:
431 """
432 Gets all sensor reading attacks.
433
434 Returns
435 -------
436 list[:class:`~epyt_flow.simulation.events.sensor_reading_attacks.SensorReadingAttack`]
437 All sensor reading attacks.
438 """
439 self._adapt_to_network_changes()
440
441 return deepcopy(list(filter(lambda e: isinstance(e, SensorReadingAttack)),
442 self._sensor_reading_events))
443
444 @property
445 def sensor_reading_events(self) -> list[SensorReadingEvent]:
446 """
447 Gets all sensor reading events (e.g. sensor faults, etc.).
448
449 Returns
450 -------
451 list[:class:`~epyt_flow.simulation.events.sensor_reading_event.SensorReadingEvent`]
452 All sensor reading events.
453 """
454 self._adapt_to_network_changes()
455
456 return deepcopy(self._sensor_reading_events)
457
458 def _parse_simple_control_rules(self) -> list[SimpleControlModule]:
459 controls = []
460
461 for idx in range(self.epanet_api.get_num_controls()):
462 cond_type, link_idx, link_status, node_idx, level = \
463 self.epanet_api.getcontrol(idx + 1)
464
465 if node_idx != 0:
466 cond_var_value = self.epanet_api.get_node_id(node_idx)
467 cond_comp_value = level
468 else:
469 if cond_type == EpanetConstants.EN_TIMER:
470 cond_var_value = int(level / 3600)
471 elif cond_type == EpanetConstants.EN_TIMEOFDAY:
472 sec = level
473 if sec <= 43200:
474 cond_var_value = \
475 f"{':'.join(str(timedelta(seconds=sec)).split(':')[:2])} AM"
476 else:
477 sec -= 43200
478 cond_var_value = \
479 f"{':'.join(str(timedelta(seconds=sec)).split(':')[:2])} PM"
480 cond_comp_value = None
481
482 controls.append(SimpleControlModule(link_id=self.epanet_api.get_link_id(link_idx),
483 link_status=link_status,
484 cond_type=cond_type,
485 cond_var_value=cond_var_value,
486 cond_comp_value=cond_comp_value))
487
488 return controls
489
490 def _parse_complex_control_rules(self) -> list[ComplexControlModule]:
491 controls = []
492
493 all_rules_id = self.epanet_api.get_all_rules_id()
494 for rule_idx, rule_id in enumerate(all_rules_id, start=1):
495 n_rule_premises, n_rule_then_actions, n_rule_else_actions, rule_priority = \
496 self.epanet_api.getrule(rule_idx)
497
498 condition_1 = None
499 additional_conditions = []
500 for j in range(1, n_rule_premises + 1):
501 [logop, object_type_id, obj_idx, variable_type_id, relop, status, value_premise] = \
502 self.epanet_api.getpremise(rule_idx, j)
503
504 object_id = None
505 if object_type_id == EpanetConstants.EN_R_NODE:
506 object_id = self.epanet_api.get_node_id(obj_idx)
507 elif object_type_id == EpanetConstants.EN_R_LINK:
508 object_id = self.epanet_api.get_link_id(obj_idx)
509 elif object_type_id == EpanetConstants.EN_R_SYSTEM:
510 object_id = ""
511
512 if variable_type_id >= EpanetConstants.EN_R_TIME:
513 value_premise = datetime.fromtimestamp(value_premise)\
514 .strftime("%I:%M %p")
515 if status != 0:
516 value_premise = RULESTATUS[status - 1]
517
518 condition = RuleCondition(object_type_id, object_id, variable_type_id,
519 relop, value_premise)
520 if condition_1 is None:
521 condition_1 = condition
522 else:
523 additional_conditions.append((logop, condition))
524
525 # Parse actions
526 actions = []
527 for j in range(1, n_rule_then_actions + 1):
528 [link_idx, link_status, link_setting] = \
529 self.epanet_api.getthenaction(rule_idx, j)
530
531 link_type_id = self.epanet_api.getlinktype(link_idx)
532 link_id = self.epanet_api.get_link_id(link_idx)
533 if link_status >= 0:
534 action_type_id = link_status
535 action_value = link_status
536 else:
537 action_type_id = EN_R_ACTION_SETTING
538 action_value = link_setting
539
540 actions.append(RuleAction(link_type_id, link_id, action_type_id, action_value))
541
542 else_actions = []
543 for j in range(1, n_rule_else_actions + 1):
544 [link_idx, link_status, link_setting] = \
545 self.epanet_api.getelseaction(rule_idx, j)
546
547 link_type_id = self.epanet_api.getlinktype(link_idx)
548 link_id = self.epanet_api.get_link_id(link_idx)
549 if link_status <= 3:
550 action_type_id = link_status
551 action_value = link_status
552 else:
553 action_type_id = EN_R_ACTION_SETTING
554 action_value = link_setting
555
556 else_actions.append(RuleAction(link_type_id, link_id, action_type_id, action_value))
557
558 # Create and add control module
559 controls.append(ComplexControlModule(rule_id, condition_1, additional_conditions,
560 actions, else_actions, int(rule_priority)))
561
562 return controls
563
564 def _adapt_to_network_changes(self):
565 nodes = self.epanet_api.get_all_nodes_id()
566 links = self.epanet_api.get_all_links_id()
567
568 node_id_to_idx = {node_id: self.epanet_api.get_node_idx(node_id) - 1 for node_id in nodes}
569 link_id_to_idx = {link_id: self.epanet_api.get_link_idx(link_id) - 1 for link_id in links}
570 valve_id_to_idx = None # {valve_id: self.epanet_api.getLinkValveIndex(valve_id) for valve_id in valves}
571 pump_id_to_idx = None # {pump_id: self.epanet_api.getLinkPumpIndex(pump_id) - 1 for pump_id in pumps}
572 tank_id_to_idx = None # {tank_id: self.epanet_api.getNodeTankIndex(tank_id) - 1 for tank_id in tanks}
573 bulkspecies_id_to_idx = None
574 surfacespecies_id_to_idx = None
575
576 # Adapt sensor configuration to potential cahnges in the network's topology
577 new_sensor_config = self._get_empty_sensor_config(node_id_to_idx, link_id_to_idx,
578 valve_id_to_idx, pump_id_to_idx,
579 tank_id_to_idx, bulkspecies_id_to_idx,
580 surfacespecies_id_to_idx)
581 new_sensor_config.pressure_sensors = self._sensor_config.pressure_sensors
582 new_sensor_config.flow_sensors = self._sensor_config.flow_sensors
583 new_sensor_config.demand_sensors = self._sensor_config.demand_sensors
584 new_sensor_config.quality_node_sensors = self._sensor_config.quality_node_sensors
585 new_sensor_config.quality_link_sensors = self._sensor_config.quality_link_sensors
586 new_sensor_config.pump_state_sensors = self._sensor_config.pump_state_sensors
587 new_sensor_config.pump_efficiency_sensors = self._sensor_config.pump_efficiency_sensors
588 new_sensor_config.pump_energyconsumption_sensors = self._sensor_config.\
589 pump_energyconsumption_sensors
590 new_sensor_config.valve_state_sensors = self._sensor_config.valve_state_sensors
591 new_sensor_config.tank_volume_sensors = self._sensor_config.tank_volume_sensors
592 new_sensor_config.bulk_species_node_sensors = self._sensor_config.bulk_species_node_sensors
593 new_sensor_config.bulk_species_link_sensors = self._sensor_config.bulk_species_link_sensors
594 new_sensor_config.surface_species_sensors = self._sensor_config.surface_species_sensors
595
596 self._sensor_config = new_sensor_config
597
[docs]
598 def close(self):
599 """
600 Closes & unloads all resources and libraries.
601
602 Call this function after the simulation is done -- do not call this function before!
603 """
604 self.epanet_api.close()
605
606 def __enter__(self):
607 return self
608
609 def __exit__(self, *args):
610 self.close()
611
[docs]
612 def save_to_epanet_file(self, inp_file_path: str, msx_file_path: str = None,
613 export_sensor_config: bool = True, undo_system_events: bool = True
614 ) -> None:
615 """
616 Exports this scenario to EPANET files -- i.e. an .inp file
617 and (optionally) a .msx file if EPANET-MSX was loaded.
618
619 Parameters
620 ----------
621 inp_file_path : `str`
622 Path to the .inp file where this scenario will be stored.
623
624 If 'inp_file_path' is None, 'msx_file_path' must not be None!
625 msx_file_path : `str`, optional
626 Path to the .msx file where this MSX component of this scneario will be stored.
627
628 Note that this is only applicable if EPANET-MSX was loaded.
629
630 The default is None.
631 export_sensor_config : `bool`, optional
632 If True, the current sensor placement is exported as well.
633
634 The default is True.
635 """
636 if inp_file_path is None and msx_file_path is None:
637 raise ValueError("At least one of the paths (.inp and .msx) must not be None")
638 if inp_file_path is not None:
639 if not isinstance(inp_file_path, str):
640 raise TypeError("'inp_file_path' must be an instance of 'str' " +
641 f"but not of '{type(inp_file_path)}'")
642 if msx_file_path is not None:
643 if not isinstance(msx_file_path, str):
644 raise TypeError("msx_file_path' msut be an instance of 'str' " +
645 f"but not of {type(msx_file_path)}")
646 if not isinstance(export_sensor_config, bool):
647 raise TypeError("'export_sensor_config' must be an instance of 'bool' " +
648 f"but not of '{type(export_sensor_config)}'")
649
650 def __override_report_section(file_in: str, report_desc: str) -> None:
651 with open(file_in, mode="r+", encoding="utf-8") as f_in:
652 # Find and remove exiting REPORT section
653 content = f_in.read()
654 try:
655 report_section_start_idx = content.index("[REPORT]")
656 report_section_end_idx = content.index("[", report_section_start_idx + 1)
657
658 content = content[:report_section_start_idx] + content[report_section_end_idx:]
659 f_in.seek(0)
660 f_in.write(content)
661 f_in.truncate()
662 except ValueError:
663 pass
664
665 # Write new REPORT section in the very end of the file
666 write_end_section = False
667 try:
668 end_idx = content.index("[END]")
669 write_end_section = True
670 f_in.seek(end_idx)
671 except ValueError:
672 pass
673 f_in.write(report_desc)
674 if write_end_section is True:
675 f_in.write("\n[END]")
676
677 if undo_system_events is True:
678 for event in self._system_events:
679 event.cleanup()
680
681 if inp_file_path is not None:
682 self.epanet_api.saveinpfile(inp_file_path)
683 self.__f_inp_in = inp_file_path
684
685 if export_sensor_config is True:
686 report_desc = "\n\n[REPORT]\n"
687 report_desc += "ENERGY YES\n"
688 report_desc += "STATUS YES\n"
689
690 nodes = []
691 links = []
692
693 # Parse sensor config
694 pressure_sensors = self._sensor_config.pressure_sensors
695 if len(pressure_sensors) != 0:
696 report_desc += "Pressure YES\n"
697 nodes += pressure_sensors
698
699 flow_sensors = self._sensor_config.flow_sensors
700 if len(flow_sensors) != 0:
701 report_desc += "Flow YES\n"
702 links += flow_sensors
703
704 demand_sensors = self._sensor_config.demand_sensors
705 if len(demand_sensors) != 0:
706 report_desc += "Demand YES\n"
707 nodes += demand_sensors
708
709 node_quality_sensors = self._sensor_config.quality_node_sensors
710 if len(node_quality_sensors) != 0:
711 report_desc += "Quality YES\n"
712 nodes += node_quality_sensors
713
714 link_quality_sensors = self._sensor_config.quality_link_sensors
715 if len(link_quality_sensors) != 0:
716 if len(node_quality_sensors) == 0:
717 report_desc += "Quality YES\n"
718 links += link_quality_sensors
719
720 # Create final REPORT section
721 nodes = list(set(nodes))
722 links = list(set(links))
723
724 if len(nodes) != 0:
725 if set(nodes) == set(self._sensor_config.nodes):
726 nodes = ["ALL"]
727 report_desc += f"NODES {' '.join(nodes)}\n"
728
729 if len(links) != 0:
730 if set(links) == set(self._sensor_config.links):
731 links = ["ALL"]
732 report_desc += f"LINKS {' '.join(links)}\n"
733
734 __override_report_section(inp_file_path, report_desc)
735
736 if self.__f_msx_in is not None and msx_file_path is not None:
737 self.epanet_api.MSXsavemsxfile(msx_file_path)
738 self.__f_msx_in = msx_file_path
739
740 if export_sensor_config is True:
741 report_desc = "\n\n[REPORT]\n"
742 species = []
743 nodes = []
744 links = []
745
746 # Parse sensor config
747 bulk_species_node_sensors = self._sensor_config.bulk_species_node_sensors
748 for bulk_species_id in bulk_species_node_sensors.keys():
749 species.append(bulk_species_id)
750 nodes += bulk_species_node_sensors[bulk_species_id]
751
752 bulk_species_link_sensors = self._sensor_config.bulk_species_link_sensors
753 for bulk_species_id in bulk_species_link_sensors.keys():
754 species.append(bulk_species_id)
755 links += bulk_species_link_sensors[bulk_species_id]
756
757 surface_species_link_sensors = self._sensor_config.surface_species_sensors
758 for surface_species_id in surface_species_link_sensors.keys():
759 species.append(surface_species_id)
760 links += surface_species_link_sensors[surface_species_id]
761
762 nodes = list(set(nodes))
763 links = list((set(links)))
764 species = list(set(species))
765
766 # Create REPORT section
767 if len(nodes) != 0:
768 if set(nodes) == set(self._sensor_config.nodes):
769 nodes = ["ALL"]
770 report_desc += f"NODES {' '.join(nodes)}\n"
771
772 if len(links) != 0:
773 if set(links) == set(self._sensor_config.links):
774 links = ["ALL"]
775 report_desc += f"LINKS {' '.join(links)}\n"
776
777 for species_id in species:
778 report_desc += f"SPECIES {species_id} YES\n"
779
780 __override_report_section(msx_file_path, report_desc)
781
782 if undo_system_events is True:
783 for event in self._system_events:
784 event.init(self.epanet_api)
785
[docs]
786 def get_flow_units(self) -> int:
787 """
788 Gets the flow units.
789
790 Will be one of the following EPANET constants:
791
792 - EN_CFS = 0 (cu foot/sec)
793 - EN_GPM = 1 (gal/min)
794 - EN_MGD = 2 (Million gal/day)
795 - EN_IMGD = 3 (Imperial MGD)
796 - EN_AFD = 4 (ac-foot/day)
797 - EN_LPS = 5 (liter/sec)
798 - EN_LPM = 6 (liter/min)
799 - EN_MLD = 7 (Megaliter/day)
800 - EN_CMH = 8 (cubic meter/hr)
801 - EN_CMD = 9 (cubic meter/day)
802
803 Returns
804 -------
805 `int`
806 Flow units.
807 """
808 return self.epanet_api.getflowunits()
809
[docs]
810 def get_pressure_units(self) -> int:
811 """
812 Returns the current pressure units.
813
814 Will be one of the following EPANET constants:
815
816 - EN_PSI = 0 (Pounds per square inch)
817 - EN_KPA = 1 (Kilopascals)
818 - EN_METERS = 2 (Meters)
819 - EN_BAR = 3 (Bar)
820 - EN_FEET = 4 (Feet)
821
822 Returns
823 -------
824 `int`
825 Pressure units.
826 """
827 return int(self.epanet_api.getoption(EpanetConstants.EN_PRESS_UNITS))
828
[docs]
829 def get_units_category(self) -> int:
830 """
831 Gets the category of units -- i.e. US Customary or SI Metric units.
832
833 Will be one of the following constants:
834
835 - UNITS_USCUSTOM = 0 (US Customary)
836 - UNITS_SIMETRIC = 1 (SI Metric)
837
838 Returns
839 -------
840 `int`
841 Units category.
842 """
843 if self.get_flow_units() in [EpanetConstants.EN_CFS, EpanetConstants.EN_GPM,
844 EpanetConstants.EN_MGD, EpanetConstants.EN_IMGD,
845 EpanetConstants.EN_AFD]:
846 return UNITS_USCUSTOM
847 else:
848 return UNITS_SIMETRIC
849
[docs]
850 def get_hydraulic_time_step(self) -> int:
851 """
852 Gets the hydraulic time step -- i.e. time step in the hydraulic simulation.
853
854 Returns
855 -------
856 `int`
857 Hydraulic time step in seconds.
858 """
859 return self.epanet_api.get_hydraulic_time_step()
860
[docs]
861 def get_quality_time_step(self) -> int:
862 """
863 Gets the quality time step -- i.e. time step in the simple quality simulation.
864
865 Returns
866 -------
867 `int`
868 Quality time step in seconds.
869 """
870 return self.epanet_api.get_quality_time_step()
871
[docs]
872 def get_simulation_duration(self) -> int:
873 """
874 Gets the simulation duration -- i.e. time length to be simulated.
875
876 Returns
877 -------
878 `int`
879 Simulation duration in seconds.
880 """
881 return self.epanet_api.get_simulation_duration()
882
[docs]
883 def get_demand_model(self) -> dict:
884 """
885 Gets the demand model and its parameters.
886
887 Returns
888 -------
889 `dict`
890 Demand model.
891 """
892 demand_info = self.epanet_api.get_demand_model()
893
894 return {"type": demand_info["type"],
895 "pressure_min": demand_info["pmin"],
896 "pressure_required": demand_info["preq"],
897 "pressure_exponent": demand_info["pexp"]}
898
[docs]
899 def get_quality_model(self) -> dict:
900 """
901 Gets the quality model and its parameters.
902
903 Note that this quality model refers to the basic quality analysis
904 as implemented in EPANET.
905
906 Returns
907 -------
908 `dict`
909 Quality model.
910 """
911 qual_info = self.epanet_api.get_quality_info()
912
913 return {"code": self.epanet_api.getqualtype(),
914 "type": qual_info["qualType"],
915 "chemical_name": qual_info["chemName"],
916 "units": qualityunit_to_id(qual_info["chemUnits"]),
917 "trace_node_id": qual_info["traceNode"]}
918
[docs]
919 def get_reporting_time_step(self) -> int:
920 """
921 Gets the reporting time steps -- i.e. time steps at which sensor readings are provided.
922
923 Is always a multiple of the hydraulic time step.
924
925 Returns
926 -------
927 `int`
928 Reporting time steps in seconds.
929 """
930 return self.epanet_api.get_reporting_time_step()
931
[docs]
932 def get_scenario_config(self, include_network_topology: bool = True) -> ScenarioConfig:
933 """
934 Gets the configuration of this scenario -- i.e. all information & elements
935 that completely describe this scenario.
936
937 Parameters
938 ----------
939 include_network_topology : `bool`, optional
940 If True, the full specification of the network topology (incl. demand patterns)
941 will be included in the scenario configuration.
942
943 The default is True.
944
945 Returns
946 -------
947 :class:`~epyt_flow.simulation.scenario_config.ScenarioConfig`
948 Complete scenario specification.
949 """
950 self._adapt_to_network_changes()
951
952 general_params = {"hydraulic_time_step": self.get_hydraulic_time_step(),
953 "quality_time_step": self.get_quality_time_step(),
954 "reporting_time_step": self.get_reporting_time_step(),
955 "simulation_duration": self.get_simulation_duration(),
956 "flow_units_id": self.get_flow_units(),
957 "pressure_units_id": self.get_pressure_units(),
958 "quality_model": self.get_quality_model(),
959 "demand_model": self.get_demand_model()}
960
961 network_topology = None
962 if include_network_topology is True:
963 network_topology = self.get_topology(include_demand_patterns=True)
964
965 return ScenarioConfig(f_inp_in=self.__f_inp_in, f_msx_in=self.__f_msx_in,
966 network_topology=network_topology,
967 general_params=general_params, sensor_config=self.sensor_config,
968 memory_consumption_estimate=self.estimate_memory_consumption(),
969 custom_controls=self.custom_controls,
970 simple_controls=self.simple_controls,
971 complex_controls=self.complex_controls,
972 sensor_noise=self.sensor_noise,
973 model_uncertainty=self.model_uncertainty,
974 system_events=self.system_events,
975 sensor_reading_events=self.sensor_reading_events)
976
[docs]
977 def estimate_memory_consumption(self) -> float:
978 """
979 Estimates the memory consumption of the simulation -- i.e. the amount of memory that is
980 needed on the hard disk as well as in RAM.
981
982 Returns
983 -------
984 `float`
985 Estimated memory consumption in MB.
986 """
987 self._adapt_to_network_changes()
988
989 n_time_steps = int(self.epanet_api.get_simulation_duration() /
990 self.epanet_api.get_reporting_time_step())
991 n_quantities = self.epanet_api.get_num_nodes() * 3 + self.epanet_api.get_num_tanks() + \
992 self.epanet_api.get_num_valves() + self.epanet_api.get_num_pumps() + \
993 self.epanet_api.get_num_links() * 2
994
995 if self.__f_msx_in is not None:
996 n_quantities += self.epanet_api.get_num_links() * 2 * self.epanet_api.get_num_msx_species() + \
997 self.epanet_api.get_num_nodes() * self.epanet_api.get_num_msx_species()
998
999 n_bytes_per_quantity = 64
1000
1001 return n_time_steps * n_quantities * n_bytes_per_quantity * .000001
1002
[docs]
1003 def get_topology(self, include_demand_patterns: bool = False) -> NetworkTopology:
1004 """
1005 Gets the topology (incl. information such as elevations, pipe diameters, etc.) of this WDN.
1006
1007 Parameters
1008 ----------
1009 include_demand_patterns : `bool`, optional
1010 If True, demand patterns will be included -- be aware that this will increase
1011 the object's memory footprint.
1012
1013 The default is False.
1014
1015 Returns
1016 -------
1017 :class:`~epyt_flow.topology.NetworkTopology`
1018 Topology of this WDN as a graph.
1019 """
1020 self._adapt_to_network_changes()
1021
1022 # Collect information about the topology of the water distribution network
1023 patterns = {}
1024 if include_demand_patterns is True:
1025 patterns = {pattern_id: self.epanet_api.get_pattern(
1026 self.epanet_api.getpatternindex(pattern_id))
1027 for pattern_id in self.epanet_api.get_all_patterns_id()}
1028
1029 nodes_id = self.epanet_api.get_all_nodes_id()
1030 nodes_elevation = [self.epanet_api.get_node_elevation(node_idx)
1031 for node_idx in self.epanet_api.get_all_nodes_idx()]
1032 nodes_type = [self.epanet_api.get_node_type(node_idx)
1033 for node_idx in self.epanet_api.get_all_nodes_idx()]
1034 nodes_coord = [self.epanet_api.getcoord(node_idx)
1035 for node_idx in self.epanet_api.get_all_nodes_idx()]
1036 nodes_comments = [self.epanet_api.get_node_comment(node_idx)
1037 for node_idx in self.epanet_api.get_all_nodes_idx()]
1038 nodes_base_demand = [self.epanet_api.get_node_base_demand(node_idx)
1039 for node_idx in self.epanet_api.get_all_nodes_idx()]
1040
1041 node_demand_patterns_id = []
1042 for node_idx in self.epanet_api.get_all_nodes_idx():
1043 r = []
1044 for pattern_idx in self.epanet_api.get_node_demand_patterns_idx(node_idx):
1045 if pattern_idx != 0:
1046 r.append(self.epanet_api.getpatternid(pattern_idx))
1047 node_demand_patterns_id.append(r)
1048
1049 links_id = self.epanet_api.get_all_links_id()
1050 links_type = [self.epanet_api.get_link_type(link_idx)
1051 for link_idx in self.epanet_api.get_all_links_idx()]
1052 links_data = self.epanet_api.get_all_links_connecting_nodes_id()
1053 links_diameter = [self.epanet_api.get_link_diameter(link_idx)
1054 for link_idx in self.epanet_api.get_all_links_idx()]
1055 links_length = [self.epanet_api.get_link_length(link_idx)
1056 for link_idx in self.epanet_api.get_all_links_idx()]
1057 links_roughness_coeff = [self.epanet_api.get_link_roughness(link_idx)
1058 for link_idx in self.epanet_api.get_all_links_idx()]
1059 links_bulk_coeff = [self.epanet_api.get_link_bulk_reaction_coeff(link_idx)
1060 for link_idx in self.epanet_api.get_all_links_idx()]
1061 links_wall_coeff = [self.epanet_api.get_link_wall_reaction_coeff(link_idx)
1062 for link_idx in self.epanet_api.get_all_links_idx()]
1063 links_loss_coeff = [self.epanet_api.get_link_minorloss(link_idx)
1064 for link_idx in self.epanet_api.get_all_links_idx()]
1065 link_init_setting = [self.epanet_api.get_link_init_setting(link_idx)
1066 for link_idx in self.epanet_api.get_all_links_idx()]
1067 link_init_status = [self.epanet_api.get_link_init_status(link_idx)
1068 for link_idx in self.epanet_api.get_all_links_idx()]
1069
1070 pumps_id = self.epanet_api.get_all_pumps_id()
1071 pumps_type = [self.epanet_api.get_pump_type(pump_idx)
1072 for pump_idx in self.epanet_api.get_all_pumps_idx()]
1073 pumps_hcurve = [int(self.epanet_api.getlinkvalue(pump_idx, EpanetConstants.EN_PUMP_HCURVE))
1074 for pump_idx in self.epanet_api.get_all_pumps_idx()]
1075
1076 valves_id = self.epanet_api.get_all_valves_id()
1077
1078 # Build graph describing the topology
1079 curves = {}
1080 def __add_curve(curve_id: str) -> None:
1081 curve_idx = self.epanet_api.getcurveindex(curve_id)
1082 curve_type = self.epanet_api.getcurvetype(curve_idx)
1083 len = self.epanet_api.getcurvelen(curve_idx)
1084 curve_data = []
1085 for i in range(len):
1086 x, y = self.epanet_api.getcurvevalue(curve_idx, i+1)
1087 curve_data.append((x, y))
1088 curves[curve_id] = (curve_type, curve_data)
1089
1090 nodes = []
1091 for node_id, node_elevation, node_type, \
1092 node_coord, node_comment, node_base_demand, node_demand_patterns in \
1093 zip(nodes_id, nodes_elevation, nodes_type, nodes_coord,
1094 nodes_comments, nodes_base_demand, node_demand_patterns_id):
1095 node_info = {"elevation": node_elevation,
1096 "coord": node_coord,
1097 "comment": node_comment,
1098 "type": node_type,
1099 "base_demand": node_base_demand}
1100 if include_demand_patterns is True:
1101 node_info["demand_patterns_id"] = node_demand_patterns
1102 if node_type == EpanetConstants.EN_TANK:
1103 node_tank_idx = self.epanet_api.get_node_idx(node_id)
1104 node_info["diameter"] = float(self.epanet_api.get_tank_diameter(node_tank_idx))
1105 node_info["max_level"] = float(self.epanet_api.get_tank_max_level(node_tank_idx))
1106 node_info["min_level"] = float(self.epanet_api.get_tank_min_level(node_tank_idx))
1107 node_info["min_vol"] = float(self.epanet_api.get_tank_min_vol(node_tank_idx))
1108 node_info["mixing_fraction"] = float(self.epanet_api.get_tank_mix_fraction(node_tank_idx))
1109 node_info["mixing_model"] = int(self.epanet_api.get_tank_mix_model(node_tank_idx))
1110 node_info["init_vol"] = self.epanet_api.getnodevalue(node_tank_idx,
1111 EpanetConstants.EN_INITVOLUME)
1112 node_info["cylindric"] = self.epanet_api.getnodevalue(node_tank_idx,
1113 EpanetConstants.EN_VOLCURVE) == 0
1114 node_info["can_overflow"] = bool(self.epanet_api.can_tank_overflow(node_tank_idx))
1115
1116 node_info["vol_curve_id"] = ""
1117 tank_vol_curve_idx = int(self.epanet_api.get_tank_vol_curve_idx(node_tank_idx))
1118 if tank_vol_curve_idx != 0:
1119 curve_id = self.epanet_api.getcurveid(tank_vol_curve_idx)
1120 node_info["vol_curve_id"] = curve_id
1121
1122 if curve_id not in curves:
1123 __add_curve(curve_id)
1124
1125 nodes.append((node_id, node_info))
1126
1127 links = []
1128 for link_id, link_type, link, diameter, length, roughness_coeff, bulk_coeff, \
1129 wall_coeff, loss_coeff, initial_setting, initial_status in \
1130 zip(links_id, links_type, links_data, links_diameter, links_length,
1131 links_roughness_coeff, links_bulk_coeff, links_wall_coeff, links_loss_coeff,
1132 link_init_setting, link_init_status):
1133 links.append((link_id, list(link),
1134 {"type": link_type, "diameter": diameter, "length": length,
1135 "roughness_coeff": roughness_coeff,
1136 "bulk_coeff": bulk_coeff, "wall_coeff": wall_coeff,
1137 "loss_coeff": loss_coeff, "init_setting": initial_setting,
1138 "init_status": initial_status}))
1139
1140 pumps = {}
1141 for pump_id, pump_type, pump_hcurve_idx in zip(pumps_id, pumps_type, pumps_hcurve):
1142 link_idx = links_id.index(pump_id)
1143 link = links_data[link_idx]
1144 pump_init_setting = link_init_setting[link_idx]
1145 pump_init_status = link_init_status[link_idx]
1146
1147 curve_id = None
1148 if pump_hcurve_idx != 0:
1149 curve_id = self.epanet_api.getcurveid(pump_hcurve_idx)
1150
1151 if curve_id not in curves:
1152 __add_curve(curve_id)
1153
1154 pumps[pump_id] = {"type": pump_type, "end_points": link,
1155 "init_setting": pump_init_setting,
1156 "init_status": pump_init_status,
1157 "curve_id": curve_id}
1158
1159 valves = {}
1160 for valve_id in valves_id:
1161 link_idx = links_id.index(valve_id)
1162 link = links_data[link_idx]
1163 valve_type = links_type[link_idx]
1164 valve_diameter = links_diameter[link_idx]
1165 valve_init_setting = link_init_setting[link_idx]
1166 valve_init_status = link_init_status[link_idx]
1167 valves[valve_id] = {"type": valve_type, "end_points": link,
1168 "diameter": valve_diameter,
1169 "initial_setting": valve_init_setting,
1170 "initial_status": valve_init_status}
1171
1172 return NetworkTopology(f_inp=self.f_inp_in, nodes=nodes, links=links, pumps=pumps,
1173 valves=valves, curves=curves, patterns=patterns,
1174 flow_units=self.get_flow_units(),
1175 pressure_units=self.get_pressure_units())
1176
[docs]
1177 def plot_topology(self, export_to_file: str = None) -> None:
1178 """
1179 Plots the topology of the water distribution network.
1180
1181 Parameters
1182 ----------
1183 export_to_file : `str`, optional
1184 Path to the file where the visualization will be stored.
1185 If None, visualization will be just shown but NOT be stored
1186 anywhere.
1187
1188 The default is None.
1189 """
1190 from ..visualization import ScenarioVisualizer
1191 ScenarioVisualizer(self).show_plot(export_to_file)
1192
[docs]
1193 def randomize_demands(self) -> None:
1194 """
1195 Randomizes all demand patterns.
1196 """
1197 if self.__running_simulation is True:
1198 raise RuntimeError("Can not change general parameters when simulation is running.")
1199
1200 self._adapt_to_network_changes()
1201
1202 # Get all demand patterns
1203 demand_patterns_idx = [self.epanet_api.get_node_demand_patterns_idx(node_idx)
1204 for node_idx in self.epanet_api.get_all_nodes_idx()]
1205 demand_patterns_idx = list(filter(lambda idx: idx != 0, set(itertools.chain.from_iterable(demand_patterns_idx))))
1206
1207 # Process each pattern separately
1208 for pattern_idx in demand_patterns_idx:
1209 pattern_length = self.epanet_api.getpatternlen(pattern_idx)
1210 pattern = []
1211 for t in range(pattern_length): # Get pattern
1212 pattern.append(self.epanet_api.getpatternvalue(pattern_idx, t + 1))
1213
1214 random.shuffle(pattern) # Shuffle pattern
1215
1216 for t in range(pattern_length): # Set shuffled/randomized pattern
1217 self.epanet_api.setpatternvalue(pattern_idx, t + 1, pattern[t])
1218
[docs]
1219 def get_pattern(self, pattern_id: str) -> np.ndarray:
1220 """
1221 Returns the EPANET pattern (i.e. all multiplier factors over time) given its ID.
1222
1223 Parameters
1224 ----------
1225 pattern_id : `str`
1226 ID of the pattern.
1227
1228 Returns
1229 -------
1230 `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
1231 The pattern -- i.e. multiplier factors over time.
1232 """
1233 if not isinstance(pattern_id, str):
1234 raise TypeError("'pattern_id' must be an instance of 'str' " +
1235 f"but not of '{type(pattern_id)}'")
1236
1237 pattern_idx = self.epanet_api.getpatternindex(pattern_id)
1238 if pattern_idx == 0:
1239 raise ValueError(f"Unknown pattern '{pattern_id}'")
1240
1241 return np.array(self.epanet_api.get_pattern(pattern_idx))
1242
[docs]
1243 def add_pattern(self, pattern_id: str, pattern: np.ndarray) -> None:
1244 """
1245 Adds a pattern to the EPANET scenario.
1246
1247 Parameters
1248 ----------
1249 pattern_id : `str`
1250 ID of the pattern.
1251 pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
1252 Pattern of multipliers over time.
1253 """
1254 self._adapt_to_network_changes()
1255
1256 if not isinstance(pattern_id, str):
1257 raise TypeError("'pattern_id' must be an instance of 'str' " +
1258 f"but not of '{type(pattern_id)}'")
1259 if not isinstance(pattern, np.ndarray):
1260 raise TypeError("'pattern' must be an instance of 'numpy.ndarray' " +
1261 f"but not of '{type(pattern)}'")
1262 if len(pattern.shape) > 1:
1263 raise ValueError(f"Inconsistent pattern shape '{pattern.shape}' " +
1264 "detected. Expected a one dimensional array!")
1265
1266 pattern_idx = self.epanet_api.add_pattern(pattern_id, pattern.tolist())
1267 if pattern_idx == 0:
1268 raise RuntimeError("Failed to add pattern! " +
1269 "Maybe pattern name contains invalid characters or is too long?")
1270
[docs]
1271 def get_node_base_demand(self, node_id: str) -> float:
1272 """
1273 Returns the base demand of a given node. None, if there does not exist any base demand.
1274
1275 Note that base demands are summed up in the case of different demand categories.
1276
1277 Parameters
1278 ----------
1279 node_id : `str`
1280 ID of the node.
1281
1282 Returns
1283 -------
1284 `float`
1285 Base demand.
1286 """
1287 if node_id not in self._sensor_config.nodes:
1288 raise ValueError(f"Unknown node '{node_id}'")
1289
1290 node_idx = self.epanet_api.get_node_idx(node_id)
1291 n_demand_categories = self.epanet_api.getnumdemands(node_idx)
1292
1293 if n_demand_categories == 0:
1294 return None
1295 else:
1296 base_demand = 0
1297 for demand_idx in range(n_demand_categories):
1298 base_demand += self.epanet_api.getbasedemand(node_idx, demand_idx + 1)
1299
1300 return base_demand
1301
[docs]
1302 def get_node_demand_pattern(self, node_id: str) -> np.ndarray:
1303 """
1304 Returns the values of the primary demand pattern of a given node --
1305 i.e. multiplier factors that are applied to the base demand.
1306
1307 Parameters
1308 ----------
1309 node_id : `str`
1310 ID of the node.
1311
1312 Returns
1313 -------
1314 `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
1315 The demand pattern -- i.e. multiplier factors over time.
1316 """
1317 if not isinstance(node_id, str):
1318 raise TypeError("'node_id' must be an instance of 'str' " +
1319 f"but not of '{type(node_id)}'")
1320 if node_id not in self._sensor_config.nodes:
1321 raise ValueError(f"Unknown node '{node_id}'")
1322
1323 node_idx = self.epanet_api.get_node_idx(node_id)
1324
1325 if self.epanet_api.getnodetype(node_idx) != EpanetConstants.EN_RESERVOIR:
1326 demand_pattern_idx = self.epanet_api.getdemandpattern(node_idx, 1)
1327 else:
1328 demand_pattern_idx = self.epanet_api.getnodevalue(node_idx, EpanetConstants.EN_PATTERN)
1329
1330 if demand_pattern_idx == 0:
1331 return None
1332 else:
1333 return self.get_pattern(self.epanet_api.getpatternid(demand_pattern_idx))
1334
[docs]
1335 def set_node_demand_pattern(self, node_id: str, base_demand: float, demand_pattern_id: str,
1336 demand_pattern: np.ndarray = None) -> None:
1337 """
1338 Sets the demand pattern (incl. base demand) at a given node.
1339
1340 Parameters
1341 ----------
1342 node_id : `str`
1343 ID of the node for which the demand pattern is set.
1344 base_demand : `float`
1345 Base demand.
1346 demand_pattern_id : `str`
1347 ID of the (new) demand pattern. Existing demand pattern will be overriden if it already exisits.
1348 demand_pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, optional
1349 Demand pattern over time. Final demand over time = base_demand * demand_pattern
1350 If None, the pattern demand_pattern_id is assumed to already exist.
1351
1352 The default is None.
1353 """
1354 self._adapt_to_network_changes()
1355
1356 if node_id not in self._sensor_config.nodes:
1357 raise ValueError(f"Unknown node '{node_id}'")
1358 if not isinstance(base_demand, float):
1359 raise TypeError("'base_demand' must be an instance of 'float' " +
1360 f"but not if '{type(base_demand)}'")
1361 if not isinstance(demand_pattern_id, str):
1362 raise TypeError("'demand_pattern_id' must be an instance of 'str' " +
1363 f"but not of '{type(demand_pattern_id)}'")
1364 if demand_pattern is not None:
1365 if not isinstance(demand_pattern, np.ndarray):
1366 raise TypeError("'demand_pattern' must be an instance of 'numpy.ndarray' " +
1367 f"but not of '{type(demand_pattern)}'")
1368 if len(demand_pattern.shape) > 1:
1369 raise ValueError(f"Inconsistent demand pattern shape '{demand_pattern.shape}' " +
1370 "detected. Expected a one dimensional array!")
1371
1372 node_idx = self.epanet_api.get_node_idx(node_id)
1373
1374 if demand_pattern_id not in self.epanet_api.get_all_patterns_id():
1375 if demand_pattern is None:
1376 raise ValueError("'demand_pattern' can not be None if " +
1377 "'demand_pattern_id' does not already exist.")
1378 self.epanet_api.add_pattern(demand_pattern_id, demand_pattern.tolist())
1379 else:
1380 if demand_pattern is not None:
1381 pattern_idx = self.epanet_api.get_node_pattern_idx(demand_pattern_id)
1382 self.epanet_api.set_pattern(pattern_idx, demand_pattern.tolist())
1383
1384 self.epanet_api.setjuncdata(node_idx, self.epanet_api.get_node_elevation(node_idx),
1385 base_demand, demand_pattern_id)
1386
[docs]
1387 def add_custom_control(self, control: CustomControlModule) -> None:
1388 """
1389 Adds a custom control module to the scenario simulation.
1390
1391 Parameters
1392 ----------
1393 control : :class:`~epyt_flow.simulation.scada.custom_control.CustomControlModule`
1394 Custom control module.
1395 """
1396 self._adapt_to_network_changes()
1397
1398 if not isinstance(control, CustomControlModule):
1399 raise TypeError("'control' must be an instance of " +
1400 "'epyt_flow.simulation.scada.CustomControlModule' not of " +
1401 f"'{type(control)}'")
1402
1403 self._custom_controls.append(control)
1404
[docs]
1405 def add_simple_control(self, control: SimpleControlModule) -> None:
1406 """
1407 Adds a simple EPANET control rule to the scenario simulation.
1408
1409 Parameters
1410 ----------
1411 control : :class:`~epyt_flow.simulation.scada.simple_control.SimpleControlModule`
1412 Simple EPANET control module.
1413 """
1414 self._adapt_to_network_changes()
1415
1416 if not isinstance(control, SimpleControlModule):
1417 raise TypeError("'control' must be an instance of " +
1418 "'epyt_flow.simulation.scada.SimpleControlModule' not of " +
1419 f"'{type(control)}'")
1420
1421 if not any(c == control for c in self._simple_controls):
1422 self._simple_controls.append(control)
1423
1424 link_idx = self.epanet_api.get_link_idx(control.link_id)
1425 cond_var_value = control.cond_var_value
1426 if control.cond_type == EpanetConstants.EN_LOWLEVEL or \
1427 control.cond_type == EpanetConstants.EN_HILEVEL:
1428 cond_var_value = self.epanet_api.get_node_idx(cond_var_value)
1429 self.epanet_api.addcontrol(control.cond_type, link_idx, control.link_status,
1430 cond_var_value, control.cond_comp_value)
1431
[docs]
1432 def remove_all_simple_controls(self) -> None:
1433 """
1434 Removes all simple EPANET controls from the scenario.
1435 """
1436 self.epanet_api.remove_all_controls()
1437 self._simple_controls = []
1438
[docs]
1439 def remove_simple_control(self, control: SimpleControlModule) -> None:
1440 """
1441 Removes a given simple EPANET control rule from the scenario.
1442
1443 Parameters
1444 ----------
1445 control : :class:`~epyt_flow.simulation.scada.simple_control.SimpleControlModule`
1446 Simple EPANET control module to be removed.
1447 """
1448 self._adapt_to_network_changes()
1449
1450 if not isinstance(control, SimpleControlModule):
1451 raise TypeError("'control' must be an instance of " +
1452 "'epyt_flow.simulation.scada.SimpleControlModule' not of " +
1453 f"'{type(control)}'")
1454
1455 control_idx = None
1456 for idx, c in enumerate(self._simple_controls):
1457 if c == control:
1458 control_idx = idx + 1
1459 break
1460 if control_idx is None:
1461 raise ValueError("Invalid/Unknown control module.")
1462
1463 self.epanet_api.deletecontrol(control_idx)
1464 self._simple_controls.remove(control)
1465
[docs]
1466 def add_complex_control(self, control: ComplexControlModule) -> None:
1467 """
1468 Adds an complex (IF-THEN-ELSE) EPANET control rule to the scenario simulation.
1469
1470 Parameters
1471 ----------
1472 control : :class:`~epyt_flow.simulation.scada.complex_control.ComplexControlModule`
1473 Complex EPANET control module.
1474 """
1475 self._adapt_to_network_changes()
1476
1477 if not isinstance(control, ComplexControlModule):
1478 raise TypeError("'control' must be an instance of " +
1479 "'epyt_flow.simulation.scada.ComplexControlModule' not of " +
1480 f"'{type(control)}'")
1481
1482 if not any(c == control for c in self._complex_controls):
1483 self._complex_controls.append(control)
1484 self.epanet_api.addrule(str(control))
1485
[docs]
1486 def remove_all_complex_controls(self) -> None:
1487 """
1488 Removes all complex EPANET controls from the scenario.
1489 """
1490 self.epanet_api.remove_all_rules()
1491 self._complex_controls = []
1492
[docs]
1493 def remove_complex_control(self, control: ComplexControlModule) -> None:
1494 """
1495 Removes a given complex (IF-THEN-ELSE) EPANET control rule from the scenario.
1496
1497 Parameters
1498 ----------
1499 control : :class:`~epyt_flow.simulation.scada.complex_control.ComplexControlModule`
1500 Complex EPANET control module to be removed.
1501 """
1502 self._adapt_to_network_changes()
1503
1504 if not isinstance(control, ComplexControlModule):
1505 raise TypeError("'control' must be an instance of " +
1506 "'epyt_flow.simulation.scada.ComplexControlModule' not of " +
1507 f"'{type(control)}'")
1508
1509 all_rules_id = self.epanet_api.get_all_rules_id()
1510 if control.rule_id not in all_rules_id:
1511 raise ValueError("Invalid/Unknown control module. " +
1512 f"Can not find rule ID '{control.rule_id}'")
1513
1514 rule_idx = all_rules_id.index(control.rule_id) + 1
1515 self.epanet_api.deleterule(rule_idx)
1516 self._complex_controls.remove(control)
1517
[docs]
1518 def add_leakage(self, leakage_event: Leakage) -> None:
1519 """
1520 Adds a leakage to the scenario simulation.
1521
1522 Parameters
1523 ----------
1524 event : :class:`~epyt_flow.simulation.events.leakages.Leakage`
1525 Leakage.
1526 """
1527 self._adapt_to_network_changes()
1528
1529 if not isinstance(leakage_event, Leakage):
1530 raise TypeError("'leakage_event' must be an instance of " +
1531 "'epyt_flow.simulation.events.Leakage' not of " +
1532 f"'{type(leakage_event)}'")
1533
1534 self.add_system_event(leakage_event)
1535
[docs]
1536 def add_actuator_event(self, event: ActuatorEvent) -> None:
1537 """
1538 Adds an actuator event to the scenario simulation.
1539
1540 Parameters
1541 ----------
1542 event : :class:`~epyt_flow.simulation.events.actuator_events.ActuatorEvent`
1543 Actuator event.
1544 """
1545 self._adapt_to_network_changes()
1546
1547 if not isinstance(event, ActuatorEvent):
1548 raise TypeError("'event' must be an instance of " +
1549 f"'epyt_flow.simulation.events.ActuatorEvent' not of '{type(event)}'")
1550
1551 self.add_system_event(event)
1552
[docs]
1553 def add_system_event(self, event: SystemEvent) -> None:
1554 """
1555 Adds a system event to the scenario simulation -- i.e. an event directly
1556 affecting the EPANET simulation.
1557
1558 Parameters
1559 ----------
1560 event : :class:`~epyt_flow.simulation.events.system_event.SystemEvent`
1561 System event.
1562 """
1563 if self.__running_simulation is True:
1564 raise RuntimeError("Can not add events when simulation is running.")
1565
1566 self._adapt_to_network_changes()
1567
1568 if not isinstance(event, SystemEvent):
1569 raise TypeError("'event' must be an instance of " +
1570 f"'epyt_flow.simulation.events.SystemEvent' not of '{type(event)}'")
1571
1572 event.init(self.epanet_api)
1573
1574 self._system_events.append(event)
1575
[docs]
1576 def add_sensor_fault(self, sensor_fault_event: SensorFault) -> None:
1577 """
1578 Adds a sensor fault to the scenario simulation.
1579
1580 Parameters
1581 ----------
1582 sensor_fault_event : :class:`~epyt_flow.simulation.events.sensor_faults.SensorFault`
1583 Sensor fault specifications.
1584 """
1585 if self.__running_simulation is True:
1586 raise RuntimeError("Can not add events when simulation is running.")
1587
1588 self._adapt_to_network_changes()
1589
1590 sensor_fault_event.validate(self._sensor_config)
1591
1592 if not isinstance(sensor_fault_event, SensorFault):
1593 raise TypeError("'sensor_fault_event' must be an instance of " +
1594 "'epyt_flow.simulation.events.SensorFault' not of " +
1595 f"'{type(sensor_fault_event)}'")
1596
1597 self._sensor_reading_events.append(sensor_fault_event)
1598
[docs]
1599 def add_sensor_reading_attack(self, sensor_reading_attack: SensorReadingAttack) -> None:
1600 """
1601 Adds a sensor reading attack to the scenario simulation.
1602
1603 Parameters
1604 ----------
1605 sensor_reading_attack : :class:`~epyt_flow.simulation.events.sensor_reading_attack.SensorReadingAttack`
1606 Sensor fault specifications.
1607 """
1608 if self.__running_simulation is True:
1609 raise RuntimeError("Can not add events when simulation is running.")
1610
1611 self._adapt_to_network_changes()
1612
1613 sensor_reading_attack.validate(self._sensor_config)
1614
1615 if not isinstance(sensor_reading_attack, SensorReadingAttack):
1616 raise TypeError("'sensor_reading_attack' must be an instance of " +
1617 "'epyt_flow.simulation.events.SensorReadingAttack' not of " +
1618 f"'{type(sensor_reading_attack)}'")
1619
1620 self._sensor_reading_events.append(sensor_reading_attack)
1621
[docs]
1622 def add_sensor_reading_event(self, event: SensorReadingEvent) -> None:
1623 """
1624 Adds a sensor reading event to the scenario simulation.
1625
1626 Parameters
1627 ----------
1628 event : :class:`~epyt_flow.simulation.events.sensor_reading_event.SensorReadingEvent`
1629 Sensor reading event.
1630 """
1631 if self.__running_simulation is True:
1632 raise RuntimeError("Can not add events when simulation is running.")
1633
1634 self._adapt_to_network_changes()
1635
1636 event.validate(self._sensor_config)
1637
1638 if not isinstance(event, SensorReadingEvent):
1639 raise TypeError("'event' must be an instance of " +
1640 "'epyt_flow.simulation.events.SensorReadingEvent' not of " +
1641 f"'{type(event)}'")
1642
1643 self._sensor_reading_events.append(event)
1644
[docs]
1645 def set_sensors(self, sensor_type: int, sensor_locations: Union[list[str], dict]) -> None:
1646 """
1647 Specifies all sensors of a given type (e.g. pressure sensor, flow sensor, etc.)
1648
1649 Parameters
1650 ----------
1651 sensor_type : `int`
1652 Sensor type. Must be one of the following:
1653 - SENSOR_TYPE_NODE_PRESSURE = 1
1654 - SENSOR_TYPE_NODE_QUALITY = 2
1655 - SENSOR_TYPE_NODE_DEMAND = 3
1656 - SENSOR_TYPE_LINK_FLOW = 4
1657 - SENSOR_TYPE_LINK_QUALITY = 5
1658 - SENSOR_TYPE_VALVE_STATE = 6
1659 - SENSOR_TYPE_PUMP_STATE = 7
1660 - SENSOR_TYPE_TANK_VOLUME = 8
1661 - SENSOR_TYPE_BULK_SPECIES = 9
1662 - SENSOR_TYPE_SURFACE_SPECIES = 10
1663 - SENSOR_TYPE_PUMP_EFFICIENCY = 12
1664 - SENSOR_TYPE_PUMP_ENERGYCONSUMPTION = 13
1665 sensor_locations : `list[str]` or `dict`
1666 Locations (IDs) of sensors either as a list or as a dict in the case of
1667 bulk and surface species.
1668 """
1669 self._adapt_to_network_changes()
1670
1671 if sensor_type == SENSOR_TYPE_NODE_PRESSURE:
1672 self._sensor_config.pressure_sensors = sensor_locations
1673 elif sensor_type == SENSOR_TYPE_LINK_FLOW:
1674 self._sensor_config.flow_sensors = sensor_locations
1675 elif sensor_type == SENSOR_TYPE_NODE_DEMAND:
1676 self._sensor_config.demand_sensors = sensor_locations
1677 elif sensor_type == SENSOR_TYPE_NODE_QUALITY:
1678 self._sensor_config.quality_node_sensors = sensor_locations
1679 elif sensor_type == SENSOR_TYPE_LINK_QUALITY:
1680 self._sensor_config.quality_link_sensors = sensor_locations
1681 elif sensor_type == SENSOR_TYPE_VALVE_STATE:
1682 self._sensor_config.valve_state_sensors = sensor_locations
1683 elif sensor_type == SENSOR_TYPE_PUMP_STATE:
1684 self._sensor_config.pump_state_sensors = sensor_locations
1685 elif sensor_type == SENSOR_TYPE_PUMP_EFFICIENCY:
1686 self._sensor_config.pump_efficiency_sensors = sensor_locations
1687 elif sensor_type == SENSOR_TYPE_PUMP_ENERGYCONSUMPTION:
1688 self._sensor_config.pump_energyconsumption_sensors = sensor_locations
1689 elif sensor_type == SENSOR_TYPE_TANK_VOLUME:
1690 self._sensor_config.tank_volume_sensors = sensor_locations
1691 elif sensor_type == SENSOR_TYPE_NODE_BULK_SPECIES:
1692 self._sensor_config.bulk_species_node_sensors = sensor_locations
1693 elif sensor_type == SENSOR_TYPE_LINK_BULK_SPECIES:
1694 self._sensor_config.bulk_species_link_sensors = sensor_locations
1695 elif sensor_type == SENSOR_TYPE_SURFACE_SPECIES:
1696 self._sensor_config.surface_species_sensors = sensor_locations
1697 else:
1698 raise ValueError(f"Unknown sensor type '{sensor_type}'")
1699
1700 self._sensor_config.validate(self.epanet_api)
1701
[docs]
1702 def set_pressure_sensors(self, sensor_locations: list[str]) -> None:
1703 """
1704 Sets the pressure sensors -- i.e. measuring pressure at some nodes in the network.
1705
1706 Parameters
1707 ----------
1708 sensor_locations : `list[str]`
1709 Locations (IDs) of sensors.
1710 """
1711 self.set_sensors(SENSOR_TYPE_NODE_PRESSURE, sensor_locations)
1712
[docs]
1713 def place_pressure_sensors_everywhere(self, junctions_only: bool = False) -> None:
1714 """
1715 Places a pressure sensor at every node in the network.
1716
1717 Parameters
1718 ----------
1719 junctions_only : `bool`, optional
1720 If True, pressure sensors are only placed at junctions but not at tanks and reservoirs.
1721
1722 The default is False.
1723 """
1724 if junctions_only is True:
1725 self.set_pressure_sensors(self.epanet_api.get_all_junctions_id())
1726 else:
1727 self.set_pressure_sensors(self._sensor_config.nodes)
1728
[docs]
1729 def set_flow_sensors(self, sensor_locations: list[str]) -> None:
1730 """
1731 Sets the flow sensors -- i.e. measuring flows at some links/pipes in the network.
1732
1733 Parameters
1734 ----------
1735 sensor_locations : `list[str]`
1736 Locations (IDs) of sensors.
1737 """
1738 self.set_sensors(SENSOR_TYPE_LINK_FLOW, sensor_locations)
1739
[docs]
1740 def place_flow_sensors_everywhere(self) -> None:
1741 """
1742 Places a flow sensors at every link/pipe in the network.
1743 """
1744 self.set_flow_sensors(self._sensor_config.links)
1745
[docs]
1746 def set_demand_sensors(self, sensor_locations: list[str]) -> None:
1747 """
1748 Sets the demand sensors -- i.e. measuring demands at some nodes in the network.
1749
1750 Parameters
1751 ----------
1752 sensor_locations : `list[str]`
1753 Locations (IDs) of sensors.
1754 """
1755 self.set_sensors(SENSOR_TYPE_NODE_DEMAND, sensor_locations)
1756
[docs]
1757 def place_demand_sensors_everywhere(self) -> None:
1758 """
1759 Places a demand sensor at every node in the network.
1760 """
1761 self.set_demand_sensors(self._sensor_config.nodes)
1762
[docs]
1763 def set_node_quality_sensors(self, sensor_locations: list[str]) -> None:
1764 """
1765 Sets the node quality sensors -- i.e. measuring the water quality
1766 (e.g. age, chlorine concentration, etc.) at some nodes in the network.
1767
1768 Parameters
1769 ----------
1770 sensor_locations : `list[str]`
1771 Locations (IDs) of sensors.
1772 """
1773 self.set_sensors(SENSOR_TYPE_NODE_QUALITY, sensor_locations)
1774
[docs]
1775 def place_node_quality_sensors_everywhere(self) -> None:
1776 """
1777 Places a water quality sensor at every node in the network.
1778 """
1779 self.set_node_quality_sensors(self._sensor_config.nodes)
1780
[docs]
1781 def set_link_quality_sensors(self, sensor_locations: list[str]) -> None:
1782 """
1783 Sets the link quality sensors -- i.e. measuring the water quality
1784 (e.g. age, chlorine concentration, etc.) at some links/pipes in the network.
1785
1786 Parameters
1787 ----------
1788 sensor_locations : `list[str]`
1789 Locations (IDs) of sensors.
1790 """
1791 self.set_sensors(SENSOR_TYPE_LINK_QUALITY, sensor_locations)
1792
[docs]
1793 def place_link_quality_sensors_everywhere(self) -> None:
1794 """
1795 Places a water quality sensor at every link/pipe in the network.
1796 """
1797 self.set_link_quality_sensors(self._sensor_config.links)
1798
[docs]
1799 def set_valve_sensors(self, sensor_locations: list[str]) -> None:
1800 """
1801 Sets the valve state sensors -- i.e. retrieving the state of some valves in the network.
1802
1803 Parameters
1804 ----------
1805 sensor_locations : `list[str]`
1806 Locations (IDs) of sensors.
1807 """
1808 self.set_sensors(SENSOR_TYPE_VALVE_STATE, sensor_locations)
1809
[docs]
1810 def place_valve_sensors_everywhere(self) -> None:
1811 """
1812 Places a valve state sensor at every valve in the network.
1813 """
1814 if len(self._sensor_config.valves) == 0:
1815 warnings.warn("Network does not contain any valves", UserWarning)
1816
1817 self.set_valve_sensors(self._sensor_config.valves)
1818
[docs]
1819 def set_pump_state_sensors(self, sensor_locations: list[str]) -> None:
1820 """
1821 Sets the pump state sensors -- i.e. retrieving the state of some pumps in the network.
1822
1823 Parameters
1824 ----------
1825 sensor_locations : `list[str]`
1826 Locations (IDs) of sensors.
1827 """
1828 self.set_sensors(SENSOR_TYPE_PUMP_STATE, sensor_locations)
1829
[docs]
1830 def place_pump_state_sensors_everywhere(self) -> None:
1831 """
1832 Places a pump state sensor at every pump in the network.
1833 """
1834 if len(self._sensor_config.pumps) == 0:
1835 warnings.warn("Network does not contain any pumps", UserWarning)
1836
1837 self.set_pump_state_sensors(self._sensor_config.pumps)
1838
[docs]
1839 def set_pump_efficiency_sensors(self, sensor_locations: list[str]) -> None:
1840 """
1841 Sets the pump efficiency sensors -- i.e. retrieving the efficiency of
1842 some pumps in the network.
1843
1844 Parameters
1845 ----------
1846 sensor_locations : `list[str]`
1847 Locations (IDs) of sensors.
1848 """
1849 self.set_sensors(SENSOR_TYPE_PUMP_EFFICIENCY, sensor_locations)
1850
[docs]
1851 def place_pump_efficiency_sensors_everywhere(self) -> None:
1852 """
1853 Places a pump efficiency sensor at every pump in the network.
1854 """
1855 if len(self._sensor_config.pumps) == 0:
1856 warnings.warn("Network does not contain any pumps", UserWarning)
1857
1858 self.set_pump_efficiency_sensors(self._sensor_config.pumps)
1859
[docs]
1860 def set_pump_energyconsumption_sensors(self, sensor_locations: list[str]) -> None:
1861 """
1862 Sets the pump energy consumption sensors -- i.e. retrieving the energy consumption of
1863 some pumps in the network.
1864
1865 Parameters
1866 ----------
1867 sensor_locations : `list[str]`
1868 Locations (IDs) of sensors.
1869 """
1870 self.set_sensors(SENSOR_TYPE_PUMP_ENERGYCONSUMPTION, sensor_locations)
1871
[docs]
1872 def place_pump_energyconsumption_sensors_everywhere(self) -> None:
1873 """
1874 Places a pump energy consumption sensor at every pump in the network.
1875 """
1876 if len(self._sensor_config.pumps) == 0:
1877 warnings.warn("Network does not contain any pumps", UserWarning)
1878
1879 self.set_pump_energyconsumption_sensors(self._sensor_config.pumps)
1880
[docs]
1881 def set_pump_sensors(self, sensor_locations: list[str]) -> None:
1882 """
1883 Sets the pump sensors -- i.e. retrieving the state, efficiency, and energy consumption
1884 of some pumps in the network.
1885
1886 Parameters
1887 ----------
1888 sensor_locations : `list[str]`
1889 Locations (IDs) of sensors.
1890 """
1891 self.set_sensors(SENSOR_TYPE_PUMP_STATE, sensor_locations)
1892 self.set_sensors(SENSOR_TYPE_PUMP_EFFICIENCY, sensor_locations)
1893 self.set_sensors(SENSOR_TYPE_PUMP_ENERGYCONSUMPTION, sensor_locations)
1894
[docs]
1895 def place_pump_sensors_everywhere(self) -> None:
1896 """
1897 Palces pump sensors at every pump in the network -- i.e. retrieving the state, efficiency,
1898 and energy consumption of all pumps in the network.
1899 """
1900 if len(self._sensor_config.pumps) == 0:
1901 warnings.warn("Network does not contain any pumps", UserWarning)
1902
1903 self.set_pump_sensors(self._sensor_config.pumps)
1904
[docs]
1905 def set_tank_sensors(self, sensor_locations: list[str]) -> None:
1906 """
1907 Sets the tank volume sensors -- i.e. measuring water volumes in some tanks in the network.
1908
1909 Parameters
1910 ----------
1911 sensor_locations : `list[str]`
1912 Locations (IDs) of sensors.
1913 """
1914 self.set_sensors(SENSOR_TYPE_TANK_VOLUME, sensor_locations)
1915
[docs]
1916 def place_tank_sensors_everywhere(self) -> None:
1917 """
1918 Places a water tank volume sensor at every tank in the network.
1919 """
1920 if len(self._sensor_config.tanks) == 0:
1921 warnings.warn("Network does not contain any tanks", UserWarning)
1922
1923 self.set_tank_sensors(self._sensor_config.tanks)
1924
[docs]
1925 def set_bulk_species_node_sensors(self, sensor_info: dict) -> None:
1926 """
1927 Sets the bulk species node sensors -- i.e. measuring bulk species concentrations
1928 at nodes in the network.
1929
1930 Parameters
1931 ----------
1932 sensor_info : `dict`
1933 Bulk species sensors -- keys: bulk species IDs, values: node IDs.
1934 """
1935 self.set_sensors(SENSOR_TYPE_NODE_BULK_SPECIES, sensor_info)
1936
[docs]
1937 def place_bulk_species_node_sensors_everywhere(self, bulk_species: list[str] = None) -> None:
1938 """
1939 Places bulk species concentration sensors at every node in the network for
1940 every bulk species.
1941
1942 Parameters
1943 ----------
1944 bulk_species : `list[str]`, optional
1945 List of bulk species IDs which we want to monitor at every node.
1946 If None, every bulk species will be monitored at every node.
1947
1948 The default is None.
1949 """
1950 if bulk_species is None:
1951 self.set_bulk_species_node_sensors({species_id: self._sensor_config.nodes
1952 for species_id in
1953 self._sensor_config.bulk_species})
1954 else:
1955 if any(species_id not in self._sensor_config.bulk_species
1956 for species_id in bulk_species):
1957 raise ValueError("Invalid bulk species ID in 'bulk_species'")
1958
1959 self.set_bulk_species_node_sensors({species_id: self._sensor_config.nodes
1960 for species_id in bulk_species})
1961
[docs]
1962 def set_bulk_species_link_sensors(self, sensor_info: dict) -> None:
1963 """
1964 Sets the bulk species link/pipe sensors -- i.e. measuring bulk species concentrations
1965 at links/pipes in the network.
1966
1967 Parameters
1968 ----------
1969 sensor_info : `dict`
1970 Bulk species sensors -- keys: bulk species IDs, values: node IDs.
1971 """
1972 self.set_sensors(SENSOR_TYPE_LINK_BULK_SPECIES, sensor_info)
1973
[docs]
1974 def place_bulk_species_link_sensors_everywhere(self, bulk_species: list[str] = None) -> None:
1975 """
1976 Places bulk species concentration sensors at every link/pipe in the network
1977 for every bulk species.
1978
1979 Parameters
1980 ----------
1981 bulk_species : `list[str]`, optional
1982 List of bulk species IDs which we want to monitor at every link/pipe.
1983 If None, every bulk species will be monitored at every link/pipe.
1984
1985 The default is None.
1986 """
1987 if bulk_species is None:
1988 self.set_bulk_species_link_sensors({species_id: self._sensor_config.links
1989 for species_id in
1990 self._sensor_config.bulk_species})
1991 else:
1992 if any(species_id not in self._sensor_config.bulk_species
1993 for species_id in bulk_species):
1994 raise ValueError("Invalid bulk species ID in 'bulk_species'")
1995
1996 self.set_bulk_species_link_sensors({species_id: self._sensor_config.links
1997 for species_id in bulk_species})
1998
[docs]
1999 def set_surface_species_sensors(self, sensor_info: dict) -> None:
2000 """
2001 Sets the surface species sensors -- i.e. measuring surface species concentrations
2002 at nodes in the network.
2003
2004 Parameters
2005 ----------
2006 sensor_info : `dict`
2007 Surface species sensors -- keys: surface species IDs, values: link/pipe IDs.
2008 """
2009 self.set_sensors(SENSOR_TYPE_SURFACE_SPECIES, sensor_info)
2010
[docs]
2011 def place_surface_species_sensors_everywhere(self, surface_species_id: list[str] = None
2012 ) -> None:
2013 """
2014 Places surface species concentration sensors at every link/pipe in the network
2015 for every surface species.
2016
2017 Parameters
2018 ----------
2019 surface_species_id : `list[str]`, optional
2020 List of surface species IDs which we want to monitor at every link/pipe.
2021 If None, every surface species will be monitored at every link/pipe.
2022
2023 The default is None.
2024 """
2025 if surface_species_id is None:
2026 self.set_bulk_species_node_sensors({species_id: self._sensor_config.links
2027 for species_id in
2028 self._sensor_config.surface_species})
2029 else:
2030 if any(species_id not in self._sensor_config.surface_species
2031 for species_id in surface_species_id):
2032 raise ValueError("Invalid surface species ID in 'surface_species_id'")
2033
2034 self.set_bulk_species_node_sensors({species_id: self._sensor_config.links
2035 for species_id in surface_species_id})
2036
[docs]
2037 def place_sensors_everywhere(self) -> None:
2038 """
2039 Places sensors everywhere -- i.e. every possible quantity is monitored
2040 at every position in the network.
2041 """
2042 self._sensor_config.place_sensors_everywhere()
2043
2044 def _prepare_simulation(self, reapply_uncertainties: bool = False) -> None:
2045 self._adapt_to_network_changes()
2046
2047 if self._model_uncertainty is not None:
2048 if self.__uncertainties_applied is False:
2049 self._model_uncertainty.apply(self.epanet_api)
2050 self.__uncertainties_applied = True
2051 elif self.__uncertainties_applied is True and reapply_uncertainties is True:
2052 self._model_uncertainty.undo(self.epanet_api)
2053 self._model_uncertainty.apply(self.epanet_api)
2054
2055 for event in self._system_events:
2056 event.reset()
2057
2058 if self._custom_controls is not None:
2059 for control in self._custom_controls:
2060 control.init(self.epanet_api)
2061
[docs]
2062 def run_advanced_quality_simulation(self, hyd_file_in: str, verbose: bool = False,
2063 frozen_sensor_config: bool = False,
2064 use_quality_time_step_as_reporting_time_step: bool = False,
2065 reapply_uncertainties: bool = False,
2066 float_type: type = np.float32
2067 ) -> ScadaData:
2068 """
2069 Runs an advanced quality analysis using EPANET-MSX.
2070
2071 Parameters
2072 ----------
2073 hyd_file_in : `str`
2074 Path to an EPANET .hyd file for storing the simulated hydraulics --
2075 the quality analysis is computed using those hydraulics.
2076 verbose : `bool`, optional
2077 If True, method will be verbose (e.g. showing a progress bar).
2078
2079 The default is False.
2080 frozen_sensor_config : `bool`, optional
2081 If True, the sensor config can not be changed and only the required sensor nodes/links
2082 will be stored -- this usually leads to a significant reduction in memory consumption.
2083
2084 The default is False.
2085 use_quality_time_step_as_reporting_time_step : `bool`, optional
2086 If True, the water quality time step will be used as the reporting time step.
2087
2088 As a consequence, the simualtion results can not be merged
2089 with the hydraulic simulation.
2090
2091 The default is False.
2092 reapply_uncertainties: `bool`, optional
2093 If True, the uncertainties are re-applied on the original properties.
2094
2095 The default is False.
2096 float_type : `type`, optional
2097 Floating point type (precision).
2098
2099 The default is 32bit -- i.e., numpy.float32
2100
2101 Returns
2102 -------
2103 :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
2104 Quality simulation results as SCADA data.
2105 """
2106 if self.__running_simulation is True:
2107 raise RuntimeError("A simulation is already running.")
2108
2109 if self.__f_msx_in is None:
2110 raise ValueError("No .msx file specified")
2111
2112 result = None
2113
2114 gen = self.run_advanced_quality_simulation_as_generator
2115 for scada_data, _ in gen(hyd_file_in=hyd_file_in,
2116 verbose=verbose,
2117 return_as_dict=True,
2118 frozen_sensor_config=frozen_sensor_config,
2119 use_quality_time_step_as_reporting_time_step=
2120 use_quality_time_step_as_reporting_time_step,
2121 reapply_uncertainties=reapply_uncertainties,
2122 float_type=float_type):
2123 if result is None:
2124 result = {}
2125 for data_type, data in scada_data.items():
2126 result[data_type] = [data]
2127 else:
2128 for data_type, data in scada_data.items():
2129 result[data_type].append(data)
2130
2131 # Build ScadaData instance
2132 for data_type in result:
2133 if not any(d is None for d in result[data_type]):
2134 result[data_type] = np.concatenate(result[data_type], axis=0)
2135 else:
2136 result[data_type] = None
2137
2138 return ScadaData(**result,
2139 network_topo=self.get_topology(),
2140 sensor_config=self._sensor_config,
2141 sensor_reading_events=self._sensor_reading_events,
2142 sensor_noise=self._sensor_noise,
2143 frozen_sensor_config=frozen_sensor_config)
2144
[docs]
2145 def run_advanced_quality_simulation_as_generator(self, hyd_file_in: str, verbose: bool = False,
2146 support_abort: bool = False,
2147 return_as_dict: bool = False,
2148 frozen_sensor_config: bool = False,
2149 use_quality_time_step_as_reporting_time_step: bool = False,
2150 reapply_uncertainties: bool = False,
2151 float_type: type = np.float32,
2152 ) -> Generator[Union[tuple[ScadaData, bool], tuple[dict, bool]], bool, None]:
2153 """
2154 Runs an advanced quality analysis using EPANET-MSX.
2155
2156 Parameters
2157 ----------
2158 support_abort : `bool`, optional
2159 hyd_file_in : `str`
2160 Path to an EPANET .hyd file for storing the simulated hydraulics --
2161 the quality analysis is computed using those hydraulics.
2162 verbose : `bool`
2163 If True, method will be verbose (e.g. showing a progress bar).
2164 return_as_dict : `bool`, optional
2165 If True, simulation results/states are returned as a dictionary instead of a
2166 :class:`~epyt_flow.simulation.scada.scada_data.ScadaData` instance.
2167
2168 The default is False.
2169 frozen_sensor_config : `bool`, optional
2170 If True, the sensor config can not be changed and only the required sensor nodes/links
2171 will be stored -- this usually leads to a significant reduction in memory consumption.
2172
2173 The default is False.
2174 use_quality_time_step_as_reporting_time_step : `bool`, optional
2175 If True, the water quality time step will be used as the reporting time step.
2176
2177 As a consequence, the simualtion results can not be merged
2178 with the hydraulic simulation.
2179
2180 The default is False.
2181 reapply_uncertainties : `bool`, optional
2182 If True, the uncertainties are re-applied on the original properties.
2183
2184 The default is False.
2185 float_type : `type`, optional
2186 Floating point type (precision).
2187
2188 The default is 32bit -- i.e., numpy.float32
2189
2190 Returns
2191 -------
2192 :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
2193 Generator containing the current EPANET-MSX simulation results as SCADA data
2194 (i.e. species concentrations) and a boolean indicating whether the simulation terminated or not.
2195 """
2196 if self.__running_simulation is True:
2197 raise RuntimeError("A simulation is already running.")
2198
2199 if self.__f_msx_in is None:
2200 raise ValueError("No .msx file specified")
2201
2202 self._prepare_simulation(reapply_uncertainties)
2203
2204 # Load pre-computed hydraulics
2205 self.epanet_api.MSXusehydfile(hyd_file_in)
2206
2207 # Initialize simulation
2208 n_nodes = self.epanet_api.get_num_nodes()
2209 n_links = self.epanet_api.get_num_links()
2210
2211 reporting_time_start = self.epanet_api.get_reporting_start_time()
2212 reporting_time_step = self.epanet_api.get_reporting_time_step()
2213 hyd_time_step = self.epanet_api.get_hydraulic_time_step()
2214
2215 network_topo = self.get_topology()
2216
2217 if use_quality_time_step_as_reporting_time_step is True:
2218 quality_time_step = self.epanet_api.get_msx_time_step()
2219 reporting_time_step = quality_time_step
2220 hyd_time_step = quality_time_step
2221
2222 self.epanet_api.MSXinit(EpanetConstants.EN_NOSAVE)
2223
2224 self.__running_simulation = True
2225
2226 bulk_species_idx = [self.epanet_api.get_msx_species_idx(species_id)
2227 for species_id in self._sensor_config.bulk_species]
2228 surface_species_idx = [self.epanet_api.get_msx_species_idx(species_id)
2229 for species_id in self._sensor_config.surface_species]
2230
2231 if verbose is True:
2232 print("Running EPANET-MSX ...")
2233 n_iterations = math.ceil(self.epanet_api.get_simulation_duration() /
2234 hyd_time_step)
2235 progress_bar = iter(tqdm(range(n_iterations + 1), ascii=True, desc="Time steps"))
2236
2237 def __get_concentrations(init_qual=False):
2238 if init_qual is True:
2239 msx_get_cur_value = self.epanet_api.MSXgetinitqual
2240 else:
2241 msx_get_cur_value = self.epanet_api.get_msx_species_concentration
2242
2243 # Bulk species
2244 bulk_species_node_concentrations = []
2245 bulk_species_link_concentrations = []
2246 for species_idx in bulk_species_idx:
2247 cur_species_concentrations = []
2248 for node_idx in range(1, n_nodes + 1):
2249 concen = msx_get_cur_value(EpanetConstants.MSX_NODE, node_idx, species_idx)
2250 cur_species_concentrations.append(concen)
2251 bulk_species_node_concentrations.append(cur_species_concentrations)
2252
2253 cur_species_concentrations = []
2254 for link_idx in range(1, n_links + 1):
2255 concen = msx_get_cur_value(EpanetConstants.MSX_LINK, link_idx, species_idx)
2256 cur_species_concentrations.append(concen)
2257 bulk_species_link_concentrations.append(cur_species_concentrations)
2258
2259 if len(bulk_species_node_concentrations) == 0:
2260 bulk_species_node_concentrations = None
2261 else:
2262 bulk_species_node_concentrations = np.array(bulk_species_node_concentrations,
2263 dtype=float_type). \
2264 reshape((1, len(bulk_species_idx), n_nodes))
2265
2266 if len(bulk_species_link_concentrations) == 0:
2267 bulk_species_link_concentrations = None
2268 else:
2269 bulk_species_link_concentrations = np.array(bulk_species_link_concentrations,
2270 dtype=float_type). \
2271 reshape((1, len(bulk_species_idx), n_links))
2272
2273 # Surface species
2274 surface_species_concentrations = []
2275 for species_idx in surface_species_idx:
2276 cur_species_concentrations = []
2277
2278 for link_idx in range(1, n_links + 1):
2279 concen = msx_get_cur_value(EpanetConstants.MSX_LINK, link_idx, species_idx)
2280 cur_species_concentrations.append(concen)
2281
2282 surface_species_concentrations.append(cur_species_concentrations)
2283
2284 if len(surface_species_concentrations) == 0:
2285 surface_species_concentrations = None
2286 else:
2287 surface_species_concentrations = np.array(surface_species_concentrations,
2288 dtype=float_type). \
2289 reshape((1, len(surface_species_idx), n_links))
2290
2291 return bulk_species_node_concentrations, bulk_species_link_concentrations, \
2292 surface_species_concentrations
2293
2294 # Initial concentrations:
2295 bulk_species_node_concentrations, bulk_species_link_concentrations, \
2296 surface_species_concentrations = __get_concentrations(init_qual=True)
2297
2298 if verbose is True:
2299 try:
2300 next(progress_bar)
2301 except StopIteration:
2302 pass
2303
2304 if reporting_time_start == 0:
2305 msx_error_code = self.epanet_api.get_last_error_code()
2306
2307 if return_as_dict is True:
2308 data = {"bulk_species_node_concentration_raw": bulk_species_node_concentrations,
2309 "bulk_species_link_concentration_raw": bulk_species_link_concentrations,
2310 "surface_species_concentration_raw": surface_species_concentrations,
2311 "sensor_readings_time": np.array([0]),
2312 "warnings_code": np.array([msx_error_code])
2313 }
2314 else:
2315 data = ScadaData(network_topo=network_topo, sensor_config=self._sensor_config,
2316 bulk_species_node_concentration_raw=bulk_species_node_concentrations,
2317 bulk_species_link_concentration_raw=bulk_species_link_concentrations,
2318 surface_species_concentration_raw=surface_species_concentrations,
2319 sensor_readings_time=np.array([0]),
2320 warnings_code=np.array([msx_error_code]),
2321 sensor_reading_events=self._sensor_reading_events,
2322 sensor_noise=self._sensor_noise,
2323 frozen_sensor_config=frozen_sensor_config)
2324
2325 if support_abort is True: # Can the simulation be aborted? If so, handle it.
2326 abort = yield
2327 if abort is True:
2328 return None
2329
2330 yield (data, False)
2331
2332 # Run step-by-step simulation
2333 tleft = 1
2334 total_time = 0
2335 last_msx_error_code = 0
2336 while tleft > 0:
2337 # Compute current time step
2338 total_time, tleft = self.epanet_api.MSXstep()
2339 msx_error_code = self.epanet_api.get_last_error_code()
2340 if last_msx_error_code == 0:
2341 last_msx_error_code = msx_error_code
2342
2343 # Fetch data at regular time intervals
2344 if total_time % hyd_time_step == 0:
2345 if verbose is True:
2346 try:
2347 next(progress_bar)
2348 except StopIteration:
2349 pass
2350
2351 bulk_species_node_concentrations, bulk_species_link_concentrations, \
2352 surface_species_concentrations = __get_concentrations()
2353
2354 # Report results in a regular time interval only!
2355 if total_time % reporting_time_step == 0 and total_time >= reporting_time_start:
2356 if return_as_dict is True:
2357 data = {"bulk_species_node_concentration_raw":
2358 bulk_species_node_concentrations,
2359 "bulk_species_link_concentration_raw":
2360 bulk_species_link_concentrations,
2361 "surface_species_concentration_raw": surface_species_concentrations,
2362 "sensor_readings_time": np.array([total_time]),
2363 "warnings_code": np.array([last_msx_error_code]),
2364 }
2365 else:
2366 data = ScadaData(network_topo=network_topo,
2367 sensor_config=self._sensor_config,
2368 bulk_species_node_concentration_raw=
2369 bulk_species_node_concentrations,
2370 bulk_species_link_concentration_raw=
2371 bulk_species_link_concentrations,
2372 surface_species_concentration_raw=
2373 surface_species_concentrations,
2374 sensor_readings_time=np.array([total_time]),
2375 warnings_code=np.array([last_msx_error_code]),
2376 sensor_reading_events=self._sensor_reading_events,
2377 sensor_noise=self._sensor_noise,
2378 frozen_sensor_config=frozen_sensor_config)
2379
2380 if support_abort is True: # Can the simulation be aborted? If so, handle it.
2381 abort = yield
2382 if abort is not False:
2383 break
2384
2385 yield (data, tleft <= 0)
2386
2387 self.__running_simulation = False
2388
[docs]
2389 def run_basic_quality_simulation(self, hyd_file_in: str, verbose: bool = False,
2390 frozen_sensor_config: bool = False,
2391 use_quality_time_step_as_reporting_time_step: bool = False,
2392 float_type: type = np.float32
2393 ) -> ScadaData:
2394 """
2395 Runs a basic quality analysis using EPANET.
2396
2397 Parameters
2398 ----------
2399 hyd_file_in : `str`
2400 Path to an EPANET .hyd file for storing the simulated hydraulics --
2401 the quality analysis is computed using those hydraulics.
2402 verbose : `bool`, optional
2403 If True, method will be verbose (e.g. showing a progress bar).
2404
2405 The default is False.
2406 frozen_sensor_config : `bool`, optional
2407 If True, the sensor config can not be changed and only the required sensor nodes/links
2408 will be stored -- this usually leads to a significant reduction in memory consumption.
2409
2410 The default is False.
2411 use_quality_time_step_as_reporting_time_step : `bool`, optional
2412 If True, the water quality time step will be used as the reporting time step.
2413
2414 As a consequence, the simualtion results can not be merged
2415 with the hydraulic simulation.
2416
2417 The default is False.
2418 float_type : `type`, optional
2419 Floating point type (precision).
2420
2421 The default is 32bit -- i.e., numpy.float32
2422
2423 Returns
2424 -------
2425 :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
2426 Quality simulation results as SCADA data.
2427 """
2428 if self.__running_simulation is True:
2429 raise RuntimeError("A simulation is already running.")
2430
2431 result = None
2432
2433 # Run simulation step-by-step
2434 gen = self.run_basic_quality_simulation_as_generator
2435 for scada_data, _ in gen(hyd_file_in=hyd_file_in,
2436 verbose=verbose,
2437 return_as_dict=True,
2438 frozen_sensor_config=frozen_sensor_config,
2439 use_quality_time_step_as_reporting_time_step=
2440 use_quality_time_step_as_reporting_time_step,
2441 float_type=float_type):
2442 if result is None:
2443 result = {}
2444 for data_type, data in scada_data.items():
2445 result[data_type] = [data]
2446 else:
2447 for data_type, data in scada_data.items():
2448 result[data_type].append(data)
2449
2450 # Build ScadaData instance
2451 for data_type in result:
2452 result[data_type] = np.concatenate(result[data_type], axis=0)
2453
2454 return ScadaData(**result,
2455 network_topo=self.get_topology(),
2456 sensor_config=self._sensor_config,
2457 sensor_reading_events=self._sensor_reading_events,
2458 sensor_noise=self._sensor_noise,
2459 frozen_sensor_config=frozen_sensor_config)
2460
[docs]
2461 def run_basic_quality_simulation_as_generator(self, hyd_file_in: str, verbose: bool = False,
2462 support_abort: bool = False,
2463 return_as_dict: bool = False,
2464 frozen_sensor_config: bool = False,
2465 use_quality_time_step_as_reporting_time_step: bool = False,
2466 float_type: type = np.float32
2467 ) -> Generator[Union[tuple[ScadaData, bool], tuple[dict, bool]], bool, None]:
2468 """
2469 Runs a basic quality analysis using EPANET.
2470
2471 Parameters
2472 ----------
2473 support_abort : `bool`, optional
2474 hyd_file_in : `str`
2475 Path to an EPANET .hyd file for storing the simulated hydraulics --
2476 the quality analysis is computed using those hydraulics.
2477 verbose : `bool`, optional
2478 If True, method will be verbose (e.g. showing a progress bar).
2479
2480 The default is False.
2481 return_as_dict : `bool`, optional
2482 If True, simulation results/states are returned as a dictionary instead of a
2483 :class:`~epyt_flow.simulation.scada.scada_data.ScadaData` instance.
2484
2485 The default is False.
2486 frozen_sensor_config : `bool`, optional
2487 If True, the sensor config can not be changed and only the required sensor nodes/links
2488 will be stored -- this usually leads to a significant reduction in memory consumption.
2489
2490 The default is False.
2491 use_quality_time_step_as_reporting_time_step : `bool`, optional
2492 If True, the water quality time step will be used as the reporting time step.
2493
2494 As a consequence, the simualtion results can not be merged
2495 with the hydraulic simulation.
2496
2497 The default is False.
2498 float_type : `type`, optional
2499 Floating point type (precision).
2500
2501 The default is 32bit -- i.e., numpy.float32
2502
2503 Returns
2504 -------
2505 :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
2506 Generator with the current simulation results/states as SCADA data and a
2507 boolean indicating whether the simulation terminated or not.
2508 """
2509 if self.__running_simulation is True:
2510 raise RuntimeError("A simulation is already running.")
2511
2512 requested_total_time = self.epanet_api.get_simulation_duration()
2513 requested_time_step = self.epanet_api.get_hydraulic_time_step()
2514 reporting_time_start = self.epanet_api.get_reporting_start_time()
2515 reporting_time_step = self.epanet_api.get_reporting_time_step()
2516
2517 if use_quality_time_step_as_reporting_time_step is True:
2518 quality_time_step = self.epanet_api.get_quality_time_step()
2519 requested_time_step = quality_time_step
2520 reporting_time_step = quality_time_step
2521
2522 network_topo = self.get_topology()
2523
2524 self.epanet_api.usehydfile(hyd_file_in)
2525
2526 self.epanet_api.openQ()
2527 self.epanet_api.initQ(EpanetConstants.EN_NOSAVE)
2528
2529 if verbose is True:
2530 print("Running basic quality analysis using EPANET ...")
2531 n_iterations = math.ceil(self.epanet_api.get_simulation_duration() /
2532 requested_time_step)
2533 progress_bar = iter(tqdm(range(n_iterations + 1), ascii=True, desc="Time steps"))
2534
2535 # Run simulation step by step
2536 total_time = 0
2537 tstep = 1
2538 first_itr = True
2539 last_error_code = 0
2540 while tstep > 0:
2541 if first_itr is True: # Fix current time in the first iteration
2542 tstep = 0
2543 first_itr = False
2544
2545 if verbose is True:
2546 if (total_time + tstep) % requested_time_step == 0:
2547 try:
2548 next(progress_bar)
2549 except StopIteration:
2550 pass
2551
2552 # Compute current time step
2553 t = self.epanet_api.runQ()
2554 total_time = t
2555
2556 # Fetch data
2557 error_code = self.epanet_api.get_last_error_code()
2558 if last_error_code == 0:
2559 last_error_code = error_code
2560 quality_node_data = np.array(self.epanet_api.getnodevalues(EpanetConstants.EN_QUALITY),
2561 dtype=float_type).reshape(1, -1)
2562 quality_link_data = np.array(self.epanet_api.getlinkvalues(EpanetConstants.EN_QUALITY),
2563 dtype=float_type).reshape(1, -1)
2564
2565 # Yield results in a regular time interval only!
2566 if total_time % reporting_time_step == 0 and total_time >= reporting_time_start:
2567 if return_as_dict is True:
2568 data = {"node_quality_data_raw": quality_node_data,
2569 "link_quality_data_raw": quality_link_data,
2570 "sensor_readings_time": np.array([total_time]),
2571 "warnings_code": np.array([last_error_code])}
2572 else:
2573 data = ScadaData(network_topo=network_topo,
2574 sensor_config=self._sensor_config,
2575 node_quality_data_raw=quality_node_data,
2576 link_quality_data_raw=quality_link_data,
2577 sensor_readings_time=np.array([total_time]),
2578 warnings_code=np.array([last_error_code]),
2579 sensor_reading_events=self._sensor_reading_events,
2580 sensor_noise=self._sensor_noise,
2581 frozen_sensor_config=frozen_sensor_config)
2582
2583 if support_abort is True: # Can the simulation be aborted? If so, handle it.
2584 abort = yield
2585 if abort is True:
2586 break
2587
2588 yield (data, total_time >= requested_total_time)
2589
2590 # Next
2591 tstep = self.epanet_api.stepQ()
2592 error_code = self.epanet_api.get_last_error_code()
2593 if last_error_code == 0:
2594 last_error_code = error_code
2595
2596 self.epanet_api.closeQ()
2597
[docs]
2598 def run_hydraulic_simulation(self, hyd_export: str = None, verbose: bool = False,
2599 frozen_sensor_config: bool = False,
2600 reapply_uncertainties: bool = False,
2601 float_type: type = np.float32) -> ScadaData:
2602 """
2603 Runs the hydraulic simulation of this scenario (incl. basic quality if set).
2604
2605 Note that this function does not call EPANET-MSX even if an .msx file was provided.
2606
2607 Parameters
2608 ----------
2609 hyd_export : `str`, optional
2610 Path to an EPANET .hyd file for storing the simulated hydraulics -- these hydraulics
2611 can be used later for an advanced quality analysis using EPANET-MSX.
2612
2613 If None, the simulated hydraulics will NOT be exported to an EPANET .hyd file.
2614
2615 The default is None.
2616 verbose : `bool`, optional
2617 If True, method will be verbose (e.g. showing a progress bar).
2618
2619 The default is False.
2620 frozen_sensor_config : `bool`, optional
2621 If True, the sensor config can not be changed and only the required sensor nodes/links
2622 will be stored -- this usually leads to a significant reduction in memory consumption.
2623
2624 The default is False.
2625 reapply_uncertainties : `bool`, optional
2626 If True, the uncertainties are re-applied on the original properties.
2627
2628 The default is False.
2629 float_type : `type`, optional
2630 Floating point type (precision).
2631
2632 The default is 32bit -- i.e., numpy.float32
2633
2634 Returns
2635 -------
2636 :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
2637 Simulation results as SCADA data (i.e. sensor readings).
2638 """
2639 if self.__running_simulation is True:
2640 raise RuntimeError("A simulation is already running.")
2641
2642 self._adapt_to_network_changes()
2643
2644 result = None
2645
2646 # Run hydraulic simulation step-by-step
2647 gen = self.run_hydraulic_simulation_as_generator
2648 for scada_data, _ in gen(hyd_export=hyd_export,
2649 verbose=verbose,
2650 return_as_dict=True,
2651 frozen_sensor_config=frozen_sensor_config,
2652 reapply_uncertainties=reapply_uncertainties,
2653 float_type=float_type):
2654 if result is None:
2655 result = {}
2656 for data_type, data in scada_data.items():
2657 if data is None:
2658 result[data_type] = None
2659 else:
2660 result[data_type] = [data]
2661 else:
2662 for data_type, data in scada_data.items():
2663 if result[data_type] is not None:
2664 result[data_type].append(data)
2665
2666 for data_type in result:
2667 if result[data_type] is not None:
2668 result[data_type] = np.concatenate(result[data_type], axis=0)
2669
2670 result = ScadaData(**result,
2671 network_topo=self.get_topology(),
2672 sensor_config=self._sensor_config,
2673 sensor_reading_events=self._sensor_reading_events,
2674 sensor_noise=self._sensor_noise,
2675 frozen_sensor_config=frozen_sensor_config)
2676
2677 return result
2678
[docs]
2679 def run_hydraulic_simulation_as_generator(self, hyd_export: str = None, verbose: bool = False,
2680 support_abort: bool = False,
2681 return_as_dict: bool = False,
2682 frozen_sensor_config: bool = False,
2683 reapply_uncertainties: bool = False,
2684 float_type: type = np.float32
2685 ) -> Generator[Union[tuple[ScadaData, bool], tuple[dict, bool]], bool, None]:
2686 """
2687 Runs the hydraulic simulation of this scenario (incl. basic quality if set) and
2688 provides the results as a generator.
2689
2690 Note that this function does not run EPANET-MSX, even if an .msx file was provided.
2691
2692 Parameters
2693 ----------
2694 hyd_export : `str`, optional
2695 Path to an EPANET .hyd file for storing the simulated hydraulics -- these hydraulics
2696 can be used later for an advanced quality analysis using EPANET-MSX.
2697
2698 If None, the simulated hydraulics will NOT be exported to an EPANET .hyd file.
2699
2700 The default is None.
2701 verbose : `bool`, optional
2702 If True, method will be verbose (e.g. showing a progress bar).
2703
2704 The default is False.
2705 support_abort : `bool`, optional
2706 If True, the simulation can be aborted after every time step -- i.e. the generator
2707 takes a boolean as an input (send) to indicate whether the simulation
2708 is to be aborted or not.
2709
2710 The default is False.
2711 return_as_dict : `bool`, optional
2712 If True, simulation results/states are returned as a dictionary instead of a
2713 :class:`~epyt_flow.simulation.scada.scada_data.ScadaData` instance.
2714
2715 The default is False.
2716 frozen_sensor_config : `bool`, optional
2717 If True, the sensor config can not be changed and only the required sensor nodes/links
2718 will be stored -- this usually leads to a significant reduction in memory consumption.
2719
2720 The default is False.
2721 reapply_uncertainties : `bool`, optional
2722 If True, the uncertainties are re-applied on the original properties.
2723
2724 The default is False.
2725 float_type : `type`, optional
2726 Floating point type (precision).
2727
2728 The default is 32bit -- i.e., numpy.float32
2729
2730 Returns
2731 -------
2732 :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
2733 Generator with the current simulation results/states as SCADA data
2734 (i.e. sensor readings) and a boolean indicating whether the simulation terminated or not.
2735 """
2736 if self.__running_simulation is True:
2737 raise RuntimeError("A simulation is already running.")
2738
2739 self._adapt_to_network_changes()
2740
2741 self._prepare_simulation(reapply_uncertainties)
2742
2743 self.__running_simulation = True
2744
2745 self.epanet_api.openH()
2746 self.epanet_api.openQ()
2747 self.epanet_api.initH(EpanetConstants.EN_SAVE_AND_INIT)
2748 self.epanet_api.initQ(EpanetConstants.EN_SAVE)
2749
2750 requested_total_time = self.epanet_api.get_simulation_duration()
2751 requested_time_step = self.epanet_api.get_hydraulic_time_step()
2752 reporting_time_start = self.epanet_api.get_reporting_start_time()
2753 reporting_time_step = self.epanet_api.get_reporting_time_step()
2754
2755 network_topo = self.get_topology()
2756
2757 if verbose is True:
2758 print("Running EPANET ...")
2759 n_iterations = math.ceil(self.epanet_api.get_simulation_duration() /
2760 requested_time_step)
2761 progress_bar = iter(tqdm(range(n_iterations + 1), ascii=True, desc="Time steps"))
2762
2763 try:
2764 # Run simulation step by step
2765 total_time = 0
2766 tstep = 1
2767 first_itr = True
2768 last_error_code = 0
2769 while tstep > 0:
2770 if first_itr is True: # Fix current time in the first iteration
2771 tstep = 0
2772 first_itr = False
2773
2774 if verbose is True:
2775 if (total_time + tstep) % requested_time_step == 0:
2776 try:
2777 next(progress_bar)
2778 except StopIteration:
2779 pass
2780
2781 # Apply system events in a regular time interval only!
2782 if (total_time + tstep) % requested_time_step == 0:
2783 for event in self._system_events:
2784 event.step(total_time + tstep)
2785
2786 # Compute current time step
2787 t = self.epanet_api.runH()
2788 error_code = self.epanet_api.get_last_error_code()
2789 self.epanet_api.runQ()
2790 if error_code == 0:
2791 error_code = self.epanet_api.get_last_error_code()
2792 total_time = t
2793 if last_error_code == 0:
2794 last_error_code = error_code
2795
2796 # Fetch data
2797 pressure_data = np.array(self.epanet_api.getnodevalues(EpanetConstants.EN_PRESSURE),
2798 dtype=float_type).reshape(1, -1)
2799 flow_data = np.array(self.epanet_api.getlinkvalues(EpanetConstants.EN_FLOW),
2800 dtype=float_type).reshape(1, -1)
2801 demand_data = np.array(self.epanet_api.getnodevalues(EpanetConstants.EN_DEMAND),
2802 dtype=float_type).reshape(1, -1)
2803 quality_node_data = np.array(self.epanet_api.getnodevalues(EpanetConstants.EN_QUALITY),
2804 dtype=float_type).reshape(1, -1)
2805 quality_link_data = np.array(self.epanet_api.getlinkvalues(EpanetConstants.EN_QUALITY),
2806 dtype=float_type).reshape(1, -1)
2807
2808 tanks_volume_data = None
2809 if len(self.epanet_api.get_all_tanks_idx()) > 0:
2810 tanks_volume_data = np.array([self.epanet_api.get_tank_volume(tank_idx)
2811 for tank_idx in self.epanet_api.get_all_tanks_idx()],
2812 dtype=float_type).reshape(1, -1)
2813
2814 pumps_state_data = None
2815 pumps_energy_usage_data = None
2816 pumps_efficiency_data = None
2817 if len(self.epanet_api.get_all_pumps_idx()) > 0:
2818 pumps_state_data = np.array([self.epanet_api.getlinkvalue(link_idx, EpanetConstants.EN_PUMP_STATE)
2819 for link_idx in self.epanet_api.get_all_pumps_idx()],
2820 dtype=float_type).reshape(1, -1)
2821 pumps_energy_usage_data = np.array([self.epanet_api.get_pump_energy_usage(pump_idx)
2822 for pump_idx in self.epanet_api.get_all_pumps_idx()],
2823 dtype=float_type).reshape(1, -1)
2824 pumps_efficiency_data = np.array([self.epanet_api.get_pump_efficiency(pump_idx)
2825 for pump_idx in self.epanet_api.get_all_pumps_idx()],
2826 dtype=float_type).reshape(1, -1)
2827
2828 valves_state_data = None
2829 if len(self.epanet_api.get_all_valves_idx()) > 0:
2830 valves_state_data = np.array([self.epanet_api.getlinkvalue(link_valve_idx, EpanetConstants.EN_STATUS)
2831 for link_valve_idx in self.epanet_api.get_all_valves_idx()],
2832 dtype=float_type).reshape(1, -1)
2833
2834 scada_data = ScadaData(network_topo=network_topo,
2835 sensor_config=self._sensor_config,
2836 pressure_data_raw=pressure_data,
2837 flow_data_raw=flow_data,
2838 demand_data_raw=demand_data,
2839 node_quality_data_raw=quality_node_data,
2840 link_quality_data_raw=quality_link_data,
2841 pumps_state_data_raw=pumps_state_data,
2842 valves_state_data_raw=valves_state_data,
2843 tanks_volume_data_raw=tanks_volume_data,
2844 pumps_energy_usage_data_raw=pumps_energy_usage_data,
2845 pumps_efficiency_data_raw=pumps_efficiency_data,
2846 sensor_readings_time=np.array([total_time]),
2847 warnings_code=np.array([last_error_code]),
2848 sensor_reading_events=self._sensor_reading_events,
2849 sensor_noise=self._sensor_noise,
2850 frozen_sensor_config=frozen_sensor_config)
2851
2852 # Yield results in a regular time interval only!
2853 if total_time % reporting_time_step == 0 and total_time >= reporting_time_start:
2854 if return_as_dict is True:
2855 data = {"pressure_data_raw": pressure_data,
2856 "flow_data_raw": flow_data,
2857 "demand_data_raw": demand_data,
2858 "node_quality_data_raw": quality_node_data,
2859 "link_quality_data_raw": quality_link_data,
2860 "pumps_state_data_raw": pumps_state_data,
2861 "valves_state_data_raw": valves_state_data,
2862 "tanks_volume_data_raw": tanks_volume_data,
2863 "pumps_energy_usage_data_raw": pumps_energy_usage_data,
2864 "pumps_efficiency_data_raw": pumps_efficiency_data,
2865 "sensor_readings_time": np.array([total_time]),
2866 "warnings_code": np.array([last_error_code])}
2867 else:
2868 data = scada_data
2869
2870 last_error_code = 0
2871
2872 if support_abort is True: # Can the simulation be aborted? If so, handle it.
2873 abort = yield
2874 if abort is True:
2875 break
2876
2877 yield (data, total_time >= requested_total_time)
2878
2879 # Apply control modules
2880 for control in self._custom_controls:
2881 control.step(scada_data)
2882
2883 # Next
2884 tstep = self.epanet_api.nextH()
2885 error_code = self.epanet_api.get_last_error_code()
2886 if last_error_code == 0:
2887 last_error_code = error_code
2888
2889 self.epanet_api.nextQ()
2890 error_code = self.epanet_api.get_last_error_code()
2891 if last_error_code == 0:
2892 last_error_code = error_code
2893
2894 self.epanet_api.closeQ()
2895 self.epanet_api.closeH()
2896
2897 self.__running_simulation = False
2898
2899 if hyd_export is not None:
2900 self.epanet_api.savehydfile(hyd_export)
2901 except Exception as ex:
2902 self.__running_simulation = False
2903 raise ex
2904
[docs]
2905 def run_simulation(self, hyd_export: str = None, verbose: bool = False,
2906 frozen_sensor_config: bool = False,
2907 reapply_uncertainties: bool = False,
2908 float_type: type = np.float32) -> ScadaData:
2909 """
2910 Runs the simulation of this scenario.
2911
2912 Parameters
2913 ----------
2914 hyd_export : `str`, optional
2915 Path to an EPANET .hyd file for storing the simulated hydraulics -- these hydraulics
2916 can be used later for an advanced quality analysis using EPANET-MSX.
2917
2918 If None, the simulated hydraulics will NOT be exported to an EPANET .hyd file.
2919
2920 The default is None.
2921 verbose : `bool`, optional
2922 If True, method will be verbose (e.g. showing a progress bar).
2923
2924 The default is False.
2925 frozen_sensor_config : `bool`, optional
2926 If True, the sensor config can not be changed and only the required sensor nodes/links
2927 will be stored -- this usually leads to a significant reduction in memory consumption.
2928
2929 The default is False.
2930 reapply_uncertainties: `bool`, optional
2931 If True, the uncertainties are re-applied on the original properties.
2932
2933 The default is False.
2934 float_type : `type`, optional
2935 Floating point type (precision).
2936
2937 The default is 32bit -- i.e., numpy.float32
2938
2939 Returns
2940 -------
2941 :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
2942 Simulation results as SCADA data (i.e. sensor readings).
2943 """
2944 if self.__running_simulation is True:
2945 raise RuntimeError("A simulation is already running.")
2946
2947 self._adapt_to_network_changes()
2948
2949 result = None
2950
2951 hyd_export_old = hyd_export
2952 if self.__f_msx_in is not None:
2953 hyd_export = os.path.join(get_temp_folder(), f"epytflow_MSX_{uuid.uuid4()}.hyd")
2954
2955 # Run hydraulic simulation step-by-step
2956 result = self.run_hydraulic_simulation(hyd_export=hyd_export, verbose=verbose,
2957 frozen_sensor_config=frozen_sensor_config,
2958 reapply_uncertainties=reapply_uncertainties,
2959 float_type=float_type)
2960
2961 # If necessary, run advanced quality simulation utilizing the computed hydraulics
2962 if self.f_msx_in is not None:
2963 gen = self.run_advanced_quality_simulation
2964 result_msx = gen(hyd_file_in=hyd_export,
2965 verbose=verbose,
2966 frozen_sensor_config=frozen_sensor_config,
2967 reapply_uncertainties=reapply_uncertainties,
2968 float_type=float_type)
2969 result.join(result_msx)
2970
2971 if hyd_export_old is not None:
2972 shutil.copyfile(hyd_export, hyd_export_old)
2973
2974 try:
2975 # temp solution
2976 os.remove(hyd_export)
2977 except:
2978 warnings.warn(f"Failed to remove temporary file '{hyd_export}'")
2979
2980 return result
2981
[docs]
2982 def set_model_uncertainty(self, model_uncertainty: ModelUncertainty) -> None:
2983 """
2984 Specifies the model uncertainties.
2985
2986 Parameters
2987 ----------
2988 model_uncertainty : :class:`~epyt_flow.uncertainty.model_uncertainty.ModelUncertainty`
2989 Model uncertainty specifications.
2990 """
2991 if self.__running_simulation is True:
2992 raise RuntimeError("Can not set uncertainties when simulation is running.")
2993
2994 self._adapt_to_network_changes()
2995
2996 if not isinstance(model_uncertainty, ModelUncertainty):
2997 raise TypeError("'model_uncertainty' must be an instance of " +
2998 "'epyt_flow.uncertainty.ModelUncertainty' but not of " +
2999 f"'{type(model_uncertainty)}'")
3000
3001 self._model_uncertainty = model_uncertainty
3002 self.__uncertainties_applied = False
3003
[docs]
3004 def set_sensor_noise(self, sensor_noise: SensorNoise) -> None:
3005 """
3006 Specifies the sensor noise -- i.e. uncertainties of sensor readings.
3007
3008 Parameters
3009 ----------
3010 sensor_noise : :class:`~epyt_flow.uncertainties.sensor_noise.SensorNoise`
3011 Sensor noise specification.
3012 """
3013 if self.__running_simulation is True:
3014 raise RuntimeError("Can not set sensor noise when simulation is running.")
3015
3016 self._adapt_to_network_changes()
3017
3018 if not isinstance(sensor_noise, SensorNoise):
3019 raise TypeError("'sensor_noise' must be an instance of " +
3020 "'epyt_flow.uncertainties.SensorNoise' but not of " +
3021 f"'{type(sensor_noise)}'")
3022
3023 self._sensor_noise = sensor_noise
3024
[docs]
3025 def set_general_parameters(self, demand_model: dict = None, simulation_duration: int = None,
3026 hydraulic_time_step: int = None,
3027 pattern_time_step: int = None, pattern_time_start: int = None,
3028 quality_time_step: int = None, advanced_quality_time_step: int = None,
3029 reporting_time_step: int = None, reporting_time_start: int = None,
3030 flow_units_id: int = None, pressure_units_id: int = None,
3031 quality_model: dict = None) -> None:
3032 """
3033 Sets some general parameters.
3034
3035 Note that all these parameters can be stated in the .inp file as well.
3036
3037 You only have to specify the parameters that are to be changed -- all others
3038 can be left as None and will not be changed.
3039
3040 Parameters
3041 ----------
3042 demand_model : `dict`, optional
3043 Specifies the demand model (e.g. pressure-driven or demand-driven) -- the dictionary
3044 must contain the "type", the minimal pressure ("pressure_min"),
3045 the required pressure ("pressure_required"), and the
3046 pressure exponent ("pressure_exponent").
3047
3048 The default is None.
3049
3050 simulation_duration : `int`, optional
3051 Number of seconds to be simulated.
3052
3053 The default is None.
3054 hydraulic_time_step : `int`, optional
3055 Hydraulic time step -- i.e. the interval at which hydraulics are computed.
3056
3057 The default is None.
3058 pattern_time_step : `int`, optional
3059 Pattern time step -- i.e. the interval at which patterns are applied.
3060
3061 Must be a multiple of `hydraulic_time_step`.
3062
3063 The default is None.
3064 pattern_time_start : `int`, optional
3065 Pattern time start -- i.e. the offset with which patterns are applied.
3066
3067 The default is None.
3068 quality_time_step : `int`, optional
3069 Quality time step -- i.e. the interval at which qualities are computed.
3070 Should be much smaller than the hydraulic time step!
3071
3072 The default is None.
3073 advanced_quality_time_step : `ìnt`, optional
3074 Time step in the advanced quality simulation -- i.e. EPANET-MSX simulation.
3075 This number specifies the interval at which all species concentrations are.
3076 Should be much smaller than the hydraulic time step!
3077
3078 The default is None.
3079 reporting_time_step : `int`, optional
3080 Report time step -- i.e. the interval at which hydraulics and quality states are
3081 reported.
3082
3083 Must be a multiple of `hydraulic_time_step`.
3084
3085 If None, it will be set equal to `hydraulic_time_step`
3086
3087 The default is None.
3088 reporting_time_start : `int`, optional
3089 Start time (in seconds) at which reporting of hydraulic and quality states starts.
3090
3091 The default is None.
3092 flow_units_id : `int`, optional
3093 Specifies the flow units -- i.e. all flows will be reported in these units.
3094 If None, the units from the .inp file will be used.
3095
3096 Must be one of the following EPANET constants:
3097
3098 - EN_CFS = 0 (cubic foot/sec)
3099 - EN_GPM = 1 (gal/min)
3100 - EN_MGD = 2 (Million gal/day)
3101 - EN_IMGD = 3 (Imperial MGD)
3102 - EN_AFD = 4 (ac-foot/day)
3103 - EN_LPS = 5 (liter/sec)
3104 - EN_LPM = 6 (liter/min)
3105 - EN_MLD = 7 (Megaliter/day)
3106 - EN_CMH = 8 (cubic meter/hr)
3107 - EN_CMD = 9 (cubic meter/day)
3108 - EN_CMS = 10 (cubic meters per second)
3109
3110 The default is None.
3111
3112 pressure_units_id : `int`, optional
3113 Specifies the pressure units -- i.e. all pressures will be reported in these units.
3114 If None, the units from the .inp file will be used.
3115
3116 Must be one of the following EPANET constants:
3117
3118 - EN_PSI = 0 (Pounds per square inch)
3119 - EN_KPA = 1 (Kilopascals)
3120 - EN_METERS = 2 (Meters)
3121 - EN_BAR = 3 (Bar)
3122 - EN_FEET = 4 (Feet)
3123
3124 The default is None.
3125
3126 quality_model : `dict`, optional
3127 Specifies the quality model -- the dictionary must contain,
3128 "type", "chemical_name", "chemical_units", and "trace_node_id", of the
3129 requested quality model.
3130
3131 The default is None.
3132 """
3133 if self.__running_simulation is True:
3134 raise RuntimeError("Can not change general parameters when simulation is running.")
3135
3136 self._adapt_to_network_changes()
3137
3138 if flow_units_id is not None:
3139 if flow_units_id == EpanetConstants.EN_CFS:
3140 self.epanet_api.setflowunits(flow_units_id)
3141 elif flow_units_id == EpanetConstants.EN_GPM:
3142 self.epanet_api.setflowunits(flow_units_id)
3143 elif flow_units_id == EpanetConstants.EN_MGD:
3144 self.epanet_api.setflowunits(flow_units_id)
3145 elif flow_units_id == EpanetConstants.EN_IMGD:
3146 self.epanet_api.setflowunits(flow_units_id)
3147 elif flow_units_id == EpanetConstants.EN_AFD:
3148 self.epanet_api.setflowunits(flow_units_id)
3149 elif flow_units_id == EpanetConstants.EN_LPS:
3150 self.epanet_api.setflowunits(flow_units_id)
3151 elif flow_units_id == EpanetConstants.EN_LPM:
3152 self.epanet_api.setflowunits(flow_units_id)
3153 elif flow_units_id == EpanetConstants.EN_MLD:
3154 self.epanet_api.setflowunits(flow_units_id)
3155 elif flow_units_id == EpanetConstants.EN_CMH:
3156 self.epanet_api.setflowunits(flow_units_id)
3157 elif flow_units_id == EpanetConstants.EN_CMD:
3158 self.epanet_api.setflowunits(flow_units_id)
3159 elif flow_units_id == EpanetConstants.EN_CMS:
3160 self.epanet_api.setflowunits(flow_units_id)
3161 else:
3162 raise ValueError(f"Unknown flow units '{flow_units_id}'")
3163
3164 if pressure_units_id is not None:
3165 if pressure_units_id == EpanetConstants.EN_PSI or \
3166 pressure_units_id == EpanetConstants.EN_METERS or \
3167 pressure_units_id == EpanetConstants.EN_KPA or \
3168 pressure_units_id == EpanetConstants.EN_FEET or \
3169 pressure_units_id == EpanetConstants.EN_BAR:
3170 self.epanet_api.setoption(EpanetConstants.EN_PRESS_UNITS, pressure_units_id)
3171 else:
3172 raise ValueError(f"Unknown pressure units '{pressure_units_id}'")
3173
3174 if demand_model is not None:
3175 self.epanet_api.set_demand_model(demand_model["type"], demand_model["pressure_min"],
3176 demand_model["pressure_required"],
3177 demand_model["pressure_exponent"])
3178
3179 if simulation_duration is not None:
3180 if not isinstance(simulation_duration, int) or simulation_duration <= 0:
3181 raise ValueError("'simulation_duration' must be a positive integer specifying " +
3182 "the number of seconds to simulate")
3183 self.epanet_api.set_simulation_duration(simulation_duration)
3184
3185 if hydraulic_time_step is not None:
3186 if not isinstance(hydraulic_time_step, int) or hydraulic_time_step <= 0:
3187 raise ValueError("'hydraulic_time_step' must be a positive integer specifying " +
3188 "the time steps of the hydraulic simulation")
3189 if len(self._system_events) != 0:
3190 raise RuntimeError("Hydraulic time step cannot be changed after system events " +
3191 "such as leakages have been added to the scenario")
3192 self.epanet_api.set_hydraulic_time_step(hydraulic_time_step)
3193 if reporting_time_step is None:
3194 warnings.warn("No report time steps specified -- using 'hydraulic_time_step'")
3195 self.epanet_api.set_reporting_time_step(hydraulic_time_step)
3196
3197 if pattern_time_step is not None:
3198 hydraulic_time_step = self.epanet_api.get_hydraulic_time_step()
3199 if not isinstance(pattern_time_step, int) or \
3200 pattern_time_step % hydraulic_time_step != 0:
3201 raise ValueError("'pattern_time_step' must be a positive integer " +
3202 "and a multiple of 'hydraulic_time_step'")
3203 self.epanet_api.set_pattern_time_step(pattern_time_step)
3204
3205 if pattern_time_start is not None:
3206 if not isinstance(pattern_time_start, int) or pattern_time_start <= 0:
3207 raise ValueError("'pattern_time_start' must be a positive integer specifying " +
3208 "the time at which pattern starts")
3209 self.epanet_api.set_pattern_start_time(pattern_time_start)
3210
3211 if reporting_time_step is not None:
3212 hydraulic_time_step = self.epanet_api.get_hydraulic_time_step()
3213 if not isinstance(reporting_time_step, int) or \
3214 reporting_time_step % hydraulic_time_step != 0:
3215 raise ValueError("'reporting_time_step' must be a positive integer " +
3216 "and a multiple of 'hydraulic_time_step'")
3217 self.epanet_api.set_reporting_time_step(reporting_time_step)
3218
3219 if reporting_time_start is not None:
3220 if not isinstance(reporting_time_start, int) or reporting_time_start <= 0:
3221 raise ValueError("'reporting_time_start' must be a positive integer specifying " +
3222 "the time at which reporting starts")
3223 self.epanet_api.set_reporting_start_time(reporting_time_start)
3224
3225 if quality_time_step is not None:
3226 if not isinstance(quality_time_step, int) or quality_time_step <= 0 or \
3227 quality_time_step > self.epanet_api.get_hydraulic_time_step():
3228 raise ValueError("'quality_time_step' must be a positive integer that is not " +
3229 "greater than the hydraulic time step")
3230 self.epanet_api.set_quality_time_step(quality_time_step)
3231
3232 if advanced_quality_time_step is not None:
3233 if not isinstance(advanced_quality_time_step, int) or \
3234 advanced_quality_time_step <= 0 or \
3235 advanced_quality_time_step > self.epanet_api.get_hydraulic_time_step():
3236 raise ValueError("'advanced_quality_time_step' must be a positive integer " +
3237 "that is not greater than the hydraulic time step")
3238 self.epanet_api.set_msx_time_step(advanced_quality_time_step)
3239
3240 if quality_model is not None:
3241 chem_name = quality_model["chem_name"] if "chem_name" in quality_model else ""
3242 chem_units = quality_model["chem_units"] if "chem_units" in quality_model else ""
3243 trace_node_id = quality_model["trace_node_id"] \
3244 if "trace_node_id" in quality_model else ""
3245 self.epanet_api.set_quality_type(quality_model["type"], chem_name, chem_units,
3246 trace_node_id)
3247
[docs]
3248 def get_events_active_time_points(self) -> list[int]:
3249 """
3250 Gets a list of time points (i.e. seconds since simulation start) at which
3251 at least one event (system or sensor readinge event) is active.
3252
3253 Returns
3254 -------
3255 `list[int]`
3256 List of time points at which at least one event is active.
3257 """
3258 events_times = []
3259
3260 hyd_time_step = self.epanet_api.get_hydraulic_time_step()
3261
3262 def __process_event(event) -> None:
3263 cur_time = event.start_time
3264 while cur_time < event.end_time:
3265 events_times.append(cur_time)
3266 cur_time += hyd_time_step
3267
3268 for event in self._sensor_reading_events:
3269 __process_event(event)
3270
3271 for event in self._system_events:
3272 __process_event(event)
3273
3274 return list(set(events_times))
3275
[docs]
3276 def set_pump_energy_price_pattern(self, pump_id: str, pattern: np.ndarray,
3277 pattern_id: Optional[str] = None) -> None:
3278 """
3279 Specifies/sets the energy price pattern of a given pump.
3280
3281 Overwrites any existing (energy price) patterns of the given pump.
3282
3283 Parameters
3284 ----------
3285 pump_id : `str`
3286 ID of the pump.
3287 pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
3288 Pattern of multipliers.
3289 pattern_id : `str`, optional
3290 ID of the pattern.
3291 If not specified, 'energy_price_{pump_id}' will be used as the pattern ID.
3292
3293 The default is None.
3294 """
3295 if not isinstance(pump_id, str):
3296 raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
3297 if pump_id not in self._sensor_config.pumps:
3298 raise ValueError(f"Unknown pump '{pump_id}'")
3299 if not isinstance(pattern, np.ndarray):
3300 raise TypeError("'pattern' must be an instance of 'numpy.ndarray' " +
3301 f"but no of '{type(pattern)}'")
3302 if len(pattern.shape) > 1:
3303 raise ValueError("'pattern' must be 1-dimensional")
3304 if pattern_id is not None:
3305 if not isinstance(pattern_id, str):
3306 raise TypeError("'pattern_id' must be an instance of 'str' " +
3307 f"but not of '{type(pattern_id)}'")
3308 else:
3309 pattern_id = f"energy_price_{pump_id}"
3310
3311 pattern_idx = self.epanet_api.getpatternindex(pattern_id)
3312 if pattern_idx != 0:
3313 warnings.warn(f"Overwriting existing pattern '{pattern_id}'")
3314
3315 pump_idx = self.epanet_api.get_link_idx(pump_id)
3316 pattern_idx = self.epanet_api.getlinkvalue(pump_idx, EpanetConstants.EN_PUMP_EPAT)
3317 if pattern_idx != 0:
3318 warnings.warn(f"Overwriting existing energy price pattern of pump '{pump_id}'")
3319
3320 self.add_pattern(pattern_id, pattern.tolist())
3321 pattern_idx = self.epanet_api.getpatternindex(pattern_id)
3322 self.epanet_api.setlinkvalue(pump_idx, EpanetConstants.PUMP_EPAT, pattern_idx)
3323
[docs]
3324 def get_pump_energy_price_pattern(self, pump_id: str) -> np.ndarray:
3325 """
3326 Returns the energy price pattern of a given pump.
3327
3328 Parameters
3329 ----------
3330 pump_id : `str`
3331 ID of the pump.
3332
3333 Returns
3334 -------
3335 `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
3336 Energy price pattern. None, if none exists.
3337 """
3338 if not isinstance(pump_id, str):
3339 raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
3340 if pump_id not in self._sensor_config.pumps:
3341 raise ValueError(f"Unknown pump '{pump_id}'")
3342
3343 pump_idx = self.epanet_api.get_link_idx(pump_id)
3344 pattern_idx = self.epanet_api.getlinkvalue(pump_idx, EpanetConstants.EN_PUMP_EPAT)
3345 if pattern_idx == 0:
3346 return None
3347 else:
3348 return np.array(self.epanet_api.get_pattern(pattern_idx))
3349
[docs]
3350 def get_pump_energy_price(self, pump_id: str) -> float:
3351 """
3352 Returns the energy price of a given pump.
3353
3354 Parameters
3355 ----------
3356 pump_id : `str`
3357 ID of the pump.
3358
3359 Returns
3360 -------
3361 `float`
3362 Energy price.
3363 """
3364 if not isinstance(pump_id, str):
3365 raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
3366 if pump_id not in self._sensor_config.pumps:
3367 raise ValueError(f"Unknown pump '{pump_id}'")
3368
3369 pump_idx = self.epanet_api.get_link_idx(pump_id)
3370 return self.epanet_api.get_pump_avg_energy_price(pump_idx)
3371
[docs]
3372 def set_pump_energy_price(self, pump_id, price: float) -> None:
3373 """
3374 Sets the energy price of a given pump.
3375
3376 Parameters
3377 ----------
3378 pump_id : `str`
3379 ID of the pump.
3380 price : `float`
3381 Energy price.
3382 """
3383 if not isinstance(pump_id, str):
3384 raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
3385 if pump_id not in self._sensor_config.pumps:
3386 raise ValueError(f"Unknown pump '{pump_id}'")
3387 if not isinstance(price, float):
3388 raise TypeError(f"'price' must be an instance of 'float' but not of '{type(price)}'")
3389 if price <= 0:
3390 raise ValueError("'price' must be positive")
3391
3392 pump_idx = self.epanet_api.get_link_idx(pump_id)
3393 self.epanet_api.setlinkvalue(pump_idx, EpanetConstants.EN_PUMP_ECOST, price)
3394
[docs]
3395 def set_initial_link_status(self, link_id: str, status: int) -> None:
3396 """
3397 Sets the initial status (open or closed) of a given link.
3398
3399 Parameters
3400 ----------
3401 link_id : `str`
3402 ID of the link.
3403 status : `int`
3404 Initial status of the link. Must be one of the following EPANET constants:
3405
3406 - EN_CLOSED = 0
3407 - EN_OPEN = 1
3408 """
3409 if not isinstance(link_id, str):
3410 raise TypeError(f"'link_id' must be an instance of 'str' but not of '{type(link_id)}'")
3411 if link_id not in self._sensor_config.pumps:
3412 raise ValueError("Invalid link ID '{link_id}'")
3413 if not isinstance(status, int):
3414 raise TypeError(f"'status' must be an instance of 'int' but not of '{type(status)}'")
3415 if status not in [ActuatorConstants.EN_CLOSED, ActuatorConstants.EN_OPEN]:
3416 raise ValueError(f"Invalid link status '{status}'")
3417
3418 link_idx = self.epanet_api.get_link_idx(link_id)
3419 self.epanet_api.setlinkvalue(link_idx, EpanetConstants.EN_INITSTATUS, status)
3420
[docs]
3421 def set_initial_pump_speed(self, pump_id: str, speed: float) -> None:
3422 """
3423 Sets the initial pump speed of a given pump.
3424
3425 Parameters
3426 ----------
3427 pump_id : `str`
3428 ID of the pump.
3429 speed : `float`
3430 Initial speed of the pump.
3431 """
3432 if not isinstance(pump_id, str):
3433 raise TypeError(f"'pump_id' must be an instance of 'str' but not of '{type(pump_id)}'")
3434 if pump_id not in self._sensor_config.pumps:
3435 raise ValueError("Invalid pump ID '{tank_id}'")
3436 if not isinstance(speed, float):
3437 raise TypeError(f"'speed' must be an instance of 'int' but not of '{type(speed)}'")
3438 if speed < 0:
3439 raise ValueError("'speed' can not be negative")
3440
3441 pump_idx = self.epanet_api.get_link_idx(pump_id)
3442 self.epanet_api.setlinkvalue(pump_idx, EpanetConstants.EN_INITSETTING, speed)
3443
[docs]
3444 def set_initial_tank_level(self, tank_id, level: float) -> None:
3445 """
3446 Sets the initial water level of a given tank.
3447
3448 Parameters
3449 ----------
3450 tank_id : `str`
3451 ID of the tank.
3452 level : `float`
3453 Initial water level in the tank.
3454 """
3455 if not isinstance(tank_id, str):
3456 raise TypeError(f"'tank_id' must be an instance of 'str' but not of '{type(tank_id)}'")
3457 if tank_id not in self._sensor_config.tanks:
3458 raise ValueError("Invalid tank ID '{tank_id}'")
3459 if not isinstance(level, float):
3460 raise TypeError(f"'level' must be an instance of 'float' but not of '{type(level)}'")
3461 if level < 0:
3462 raise ValueError("'level' can not be negative")
3463
3464 tank_idx = self.epanet_api.get_node_idx(tank_id)
3465 self.epanet_api.setnodevalue(tank_idx, EpanetConstants.EN_TANKLEVEL, level)
3466
3467 def __warn_if_quality_set(self):
3468 qual_code = self.epanet_api.getqualinfo()[0]
3469 if qual_code != EpanetConstants.EN_NONE:
3470 warnings.warn("You are overriding current quality settings " +
3471 f"'{qual_code}'")
3472
[docs]
3473 def enable_waterage_analysis(self) -> None:
3474 """
3475 Sets water age analysis -- i.e. estimates the water age (in hours) at
3476 all places in the network.
3477 """
3478 if self.__running_simulation is True:
3479 raise RuntimeError("Can not change general parameters when simulation is running.")
3480
3481 self._adapt_to_network_changes()
3482
3483 self.__warn_if_quality_set()
3484 self.set_general_parameters(quality_model={"type": EpanetConstants.EN_AGE})
3485
[docs]
3486 def enable_chemical_analysis(self, chemical_name: str = "Chlorine",
3487 chemical_units: int = MASS_UNIT_MG) -> None:
3488 """
3489 Sets chemical analysis.
3490
3491 ATTENTION: Do not forget to inject this chemical into the WDN.
3492
3493 Parameters
3494 ----------
3495 chemical_name : `str`, optional
3496 Name of the chemical being analyzed.
3497
3498 The default is "Chlorine".
3499 chemical_units : `str`, optional
3500 Units that the chemical is measured in.
3501
3502 Must be one of the following constants:
3503
3504 - MASS_UNIT_MG = 4 (mg/L)
3505 - MASS_UNIT_UG = 5 (ug/L)
3506
3507 The default is MASS_UNIT_MG.
3508 """
3509 if self.__running_simulation is True:
3510 raise RuntimeError("Can not change general parameters when simulation is running.")
3511
3512 self._adapt_to_network_changes()
3513
3514 self.__warn_if_quality_set()
3515 self.set_general_parameters(quality_model={"type": EpanetConstants.EN_CHEM,
3516 "chem_name": chemical_name,
3517 "chem_units": qualityunit_to_str(chemical_units)})
3518
[docs]
3519 def add_quality_source(self, node_id: str, source_type: int, pattern: np.ndarray = None,
3520 pattern_id: str = None, source_strength: int = 1.) -> None:
3521 """
3522 Adds a new external water quality source at a particular node.
3523
3524 Parameters
3525 ----------
3526 node_id : `str`
3527 ID of the node at which this external water quality source is placed.
3528 source_type : `int`,
3529 Types of the external water quality source -- must be of the following
3530 EPANET constants:
3531
3532 - EN_CONCEN = 0
3533 - EN_MASS = 1
3534 - EN_SETPOINT = 2
3535 - EN_FLOWPACED = 3
3536
3537 Description:
3538
3539 - E_CONCEN Sets the concentration of external inflow entering a node
3540 - EN_MASS Injects a given mass/minute into a node
3541 - EN_SETPOINT Sets the concentration leaving a node to a given value
3542 - EN_FLOWPACED Adds a given value to the concentration leaving a node
3543 pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, optional
3544 1d source pattern multipiers over time -- i.e. quality-source = source_strength * pattern.
3545
3546 If None, the pattern pattern_id is assume to already exist.
3547
3548 The default is None.
3549 pattern_id : `str`, optional
3550 ID of the source pattern.
3551
3552 If None, a pattern_id will be generated automatically -- be aware that this
3553 could conflict with existing pattern IDs (in this case, an exception is raised).
3554
3555 The default is None.
3556 source_strength : `int`, optional
3557 Quality source strength -- i.e. quality-source = source_strength * pattern.
3558
3559 The default is 1.
3560 """
3561 if self.__running_simulation is True:
3562 raise RuntimeError("Can not change general parameters when simulation is running.")
3563
3564 self._adapt_to_network_changes()
3565
3566 if self.epanet_api.getqualinfo()[0] != EpanetConstants.EN_CHEM:
3567 raise RuntimeError("Chemical analysis is not enabled -- " +
3568 "call 'enable_chemical_analysis()' before calling this function.")
3569 if node_id not in self._sensor_config.nodes:
3570 raise ValueError(f"Unknown node '{node_id}'")
3571 if not isinstance(source_type, int) or not 0 <= source_type <= 3:
3572 raise ValueError("Invalid type of water quality source")
3573 if pattern is not None:
3574 if not isinstance(pattern, np.ndarray):
3575 raise TypeError("'pattern' must be an instance of 'numpy.ndarray' " +
3576 f"but not of '{type(pattern)}'")
3577 if pattern is None and pattern_id is None:
3578 raise ValueError("'pattern_id' and 'pattern' can not be None at the same time")
3579 if pattern_id is None:
3580 pattern_id = f"qual_src_pat_{node_id}"
3581
3582 node_idx = self.epanet_api.get_node_idx(node_id)
3583
3584 if pattern is None:
3585 pattern_idx = self.epanet_api.getpatternindex(pattern_id)
3586 else:
3587 self.epanet_api.add_pattern(pattern_id, pattern.tolist())
3588 pattern_idx = self.epanet_api.getpatternindex(pattern_id)
3589 if pattern_idx == 0:
3590 raise RuntimeError("Failed to add/get pattern! " +
3591 "Maybe pattern name contains invalid characters or is too long?")
3592
3593 self.epanet_api.setnodevalue(node_idx, EpanetConstants.EN_SOURCETYPE, source_type)
3594 self.epanet_api.setnodevalue(node_idx, EpanetConstants.EN_SOURCEQUAL, source_strength)
3595 self.epanet_api.setnodevalue(node_idx, EpanetConstants.EN_SOURCEPAT, pattern_idx)
3596
[docs]
3597 def set_initial_node_quality(self, node_id: str, initial_quality: float) -> None:
3598 """
3599 Specifies the initial quality at a given node.
3600 Quality represents concentration for chemicals, hours for water age,
3601 or percent for source tracing.
3602
3603 Parameters
3604 ----------
3605 node_id : `str`
3606 ID of the node.
3607 initial_quality : `float`
3608 Initial node quality.
3609 """
3610 self.set_quality_parameters(initial_quality={node_id, initial_quality})
3611
[docs]
3612 def set_quality_parameters(self, initial_quality: Optional[dict[str, float]] = None,
3613 order_wall: Optional[int] = None, order_tank: Optional[int] = None,
3614 order_bulk: Optional[int] = None,
3615 global_wall_reaction_coefficient: Optional[float] = None,
3616 global_bulk_reaction_coefficient: Optional[float] = None,
3617 local_wall_reaction_coefficient: Optional[dict[str, float]] = None,
3618 local_bulk_reaction_coefficient: Optional[dict[str, float]] = None,
3619 local_tank_reaction_coefficient: Optional[dict[str, float]] = None,
3620 limiting_potential: Optional[float] = None) -> None:
3621 """
3622 Specifies some parameters of the EPANET quality analysis.
3623 Note that those parameters are only relevant for EPANET but not for EPANET-MSX.
3624
3625 Parameters
3626 ----------
3627 initial_quality : `dict[str, float]`, optional
3628 Specifies the initial quality (value in the dictionary) at nodes
3629 (key in the dictionary).
3630 Quality represents concentration for chemicals, hours for water age,
3631 or percent for source tracing.
3632
3633 The default is None.
3634 order_wall : `int`, optional
3635 Specifies the order of reactions occurring in the bulk fluid at pipe walls.
3636 Value for wall reactions must be either 0 or 1.
3637 If not specified, the default reaction order is 1.0.
3638
3639 The default is None.
3640 order_bulk : `int`, optional
3641 Specifies the order of reactions occurring in the bulk fluid in tanks.
3642 Value must be either 0 or 1.
3643 If not specified, the default reaction order is 1.0.
3644
3645 The default is None.
3646 global_wall_reaction_coefficient : `float`, optional
3647 Specifies the global value for all pipe wall reaction coefficients (pipes and tanks).
3648 If not specified, the default value is zero.
3649
3650 The default is None.
3651 global_bulk_reaction_coefficient : `float`, optional
3652 Specifies the global value for all bulk reaction coefficients (pipes and tanks).
3653 If not specified, the default value is zero.
3654
3655 The default is None.
3656 local_wall_reaction_coefficient : `dict[str, float]`, optional
3657 Overrides the global reaction coefficients for specific pipes (key in dictionary).
3658
3659 The default is None.
3660 local_bulk_reaction_coefficient : `dict[str, float]`, optional
3661 Overrides the global reaction coefficients for specific pipes (key in dictionary).
3662
3663 The default is None.
3664 local_tank_reaction_coefficient : `dict[str, float]`, optional
3665 Overrides the global reaction coefficients for specific tanks (key in dictionary).
3666
3667 The default is None.
3668 limiting_potential : `float`, optional
3669 Specifies that reaction rates are proportional to the difference between the
3670 current concentration and some (specified) limiting potential value.
3671
3672 The default is None.
3673 """
3674 if initial_quality is not None:
3675 if not isinstance(initial_quality, dict):
3676 raise TypeError("'initial_quality' must be an instance of 'dict[str, float]' " +
3677 f"but not of '{type(initial_quality)}'")
3678 if any(not isinstance(key, str) or not isinstance(value, float)
3679 for key, value in initial_quality):
3680 raise TypeError("'initial_quality' must be an instance of 'dict[str, float]'")
3681 for node_id, node_init_qual in initial_quality:
3682 if node_id not in self._sensor_config.nodes:
3683 raise ValueError(f"Invalid node ID '{node_id}'")
3684 if node_init_qual < 0:
3685 raise ValueError(f"{node_id}: Initial node quality can not be negative")
3686
3687 for node_id, node_init_qual in initial_quality.items():
3688 node_idx = self.epanet_api.get_node_idx(node_id)
3689 self.epanet_api.set_node_init_quality(node_idx, node_init_qual)
3690
3691 if order_wall is not None:
3692 if not isinstance(order_wall, int):
3693 raise TypeError("'order_wall' must be an instance of 'int' " +
3694 f"but not of '{type(order_wall)}'")
3695 if order_wall not in [0, 1]:
3696 raise ValueError(f"Invalid value '{order_wall}' for order_wall")
3697
3698 self.epanet_api.setoption(EpanetConstants.EN_WALLORDER, order_wall)
3699
3700 if order_bulk is not None:
3701 if not isinstance(order_bulk, int):
3702 raise TypeError("'order_bulk' must be an instance of 'int' " +
3703 f"but not of '{type(order_bulk)}'")
3704 if order_bulk not in [0, 1]:
3705 raise ValueError(f"Invalid value '{order_bulk}' for order_bulk")
3706
3707 self.epanet_api.setoption(EpanetConstants.EN_BULKORDER, order_bulk)
3708
3709 if order_tank is not None:
3710 if not isinstance(order_tank, int):
3711 raise TypeError("'order_tank' must be an instance of 'int' " +
3712 f"but not of '{type(order_tank)}'")
3713 if order_tank not in [0, 1]:
3714 raise ValueError(f"Invalid value '{order_tank}' for order_wall")
3715
3716 self.epanet_api.setoption(EpanetConstants.EN_TANKORDER, order_tank)
3717
3718 if global_wall_reaction_coefficient is not None:
3719 if not isinstance(global_wall_reaction_coefficient, float):
3720 raise TypeError("'global_wall_reaction_coefficient' must be an instance of " +
3721 f"'float' but not of '{type(global_wall_reaction_coefficient)}'")
3722
3723 for link_idx in self.epanet_api.get_all_links_idx():
3724 self.epanet_api.setlinkvalue(link_idx, EpanetConstants.EN_KWALL,
3725 global_wall_reaction_coefficient)
3726
3727 if global_bulk_reaction_coefficient is not None:
3728 if not isinstance(global_bulk_reaction_coefficient, float):
3729 raise TypeError("'global_bulk_reaction_coefficient' must be an instance of " +
3730 f"'float' but not of '{type(global_bulk_reaction_coefficient)}'")
3731
3732 for link_idx in self.epanet_api.get_all_links_idx():
3733 self.epanet_api.setlinkvalue(link_idx, EpanetConstants.EN_KBULK,
3734 global_bulk_reaction_coefficient)
3735
3736 if local_wall_reaction_coefficient is not None:
3737 if not isinstance(local_wall_reaction_coefficient, dict):
3738 raise TypeError("'local_wall_reaction_coefficient' must be an instance " +
3739 "of 'dict[str, float]' but not of " +
3740 f"'{type(local_wall_reaction_coefficient)}'")
3741 if any(not isinstance(key, str) or not isinstance(value, float)
3742 for key, value in local_wall_reaction_coefficient):
3743 raise TypeError("'local_wall_reaction_coefficient' must be an instance " +
3744 "of 'dict[str, float]'")
3745 for link_id, _ in local_wall_reaction_coefficient:
3746 if link_id not in self._sensor_config.links:
3747 raise ValueError(f"Invalid link ID '{link_id}'")
3748
3749 for link_id, link_reaction_coeff in local_wall_reaction_coefficient:
3750 link_idx = self.epanet_api.get_link_idx(link_id)
3751 self.epanet_api.setlinkvalue(link_idx, EpanetConstants.EN_KWALL, link_reaction_coeff)
3752
3753 if local_bulk_reaction_coefficient is not None:
3754 if not isinstance(local_bulk_reaction_coefficient, dict):
3755 raise TypeError("'local_bulk_reaction_coefficient' must be an instance " +
3756 "of 'dict[str, float]' but not of " +
3757 f"'{type(local_bulk_reaction_coefficient)}'")
3758 if any(not isinstance(key, str) or not isinstance(value, float)
3759 for key, value in local_bulk_reaction_coefficient):
3760 raise TypeError("'local_bulk_reaction_coefficient' must be an instance " +
3761 "of 'dict[str, float]'")
3762 for link_id, _ in local_bulk_reaction_coefficient:
3763 if link_id not in self._sensor_config.links:
3764 raise ValueError(f"Invalid link ID '{link_id}'")
3765
3766 for link_id, link_reaction_coeff in local_bulk_reaction_coefficient:
3767 link_idx = self.epanet_api.get_link_idx(link_id)
3768 self.epanet_api.setlinkvalue(link_idx, EpanetConstants.EN_KBULK, link_reaction_coeff)
3769
3770 if local_tank_reaction_coefficient is not None:
3771 if not isinstance(local_tank_reaction_coefficient, dict):
3772 raise TypeError("'local_tank_reaction_coefficient' must be an instance " +
3773 "of 'dict[str, float]' but not of " +
3774 f"'{type(local_tank_reaction_coefficient)}'")
3775 if any(not isinstance(key, str) or not isinstance(value, float)
3776 for key, value in local_tank_reaction_coefficient):
3777 raise TypeError("'local_tank_reaction_coefficient' must be an instance " +
3778 "of 'dict[str, float]'")
3779 for tank_id, _ in local_tank_reaction_coefficient:
3780 if tank_id not in self._sensor_config.tanks:
3781 raise ValueError(f"Invalid tank ID '{tank_id}'")
3782
3783 for tank_id, tank_reaction_coeff in local_tank_reaction_coefficient:
3784 tank_idx = self.epanet_api.get_node_idx(tank_id)
3785 self.epanet_api.setnodevalue(tank_idx, EpanetConstants.EN_TANK_KBULK,
3786 tank_reaction_coeff)
3787
3788 if limiting_potential is not None:
3789 if not isinstance(limiting_potential, float):
3790 raise TypeError("'limiting_potential' must be an instance of 'float' " +
3791 f"but not of '{type(limiting_potential)}'")
3792 if limiting_potential < 0:
3793 raise ValueError("'limiting_potential' can not be negative")
3794
3795 self.epanet_api.setoption(EpanetConstants.EN_CONCENLIMIT, limiting_potential)
3796
[docs]
3797 def enable_sourcetracing_analysis(self, trace_node_id: str) -> None:
3798 """
3799 Set source tracing analysis -- i.e. tracks the percentage of flow from a given node
3800 reaching all other nodes over time.
3801
3802 Parameters
3803 ----------
3804 trace_node_id : `str`
3805 ID of the node traced in the source tracing analysis.
3806 """
3807 if self.__running_simulation is True:
3808 raise RuntimeError("Can not change general parameters when simulation is running.")
3809
3810 self._adapt_to_network_changes()
3811
3812 if trace_node_id not in self._sensor_config.nodes:
3813 raise ValueError(f"Invalid node ID '{trace_node_id}'")
3814
3815 self.__warn_if_quality_set()
3816 self.set_general_parameters(quality_model={"type": EpanetConstants.EN_TRACE,
3817 "trace_node_id": trace_node_id})
3818
[docs]
3819 def add_species_injection_source(self, species_id: str, node_id: str, pattern: np.ndarray,
3820 source_type: int, pattern_id: str = None,
3821 source_strength: float = 1.) -> None:
3822 """
3823 Adds a new external bulk species injection source at a particular node.
3824
3825 Only for EPANET-MSX scenarios.
3826
3827 Parameters
3828 ----------
3829 species_id : `str`
3830 ID of the (bulk or surface) species.
3831 node_id : `str`
3832 ID of the node at which this external (bulk or surface) species injection source
3833 is placed.
3834 pattern : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
3835 1d source pattern.
3836
3837 Note that the pattern time step is equivalent to the EPANET pattern time step.
3838 source_type : `int`,
3839 Type of the external (bulk or surface) species injection source -- must be one of
3840 the following EPANET constants:
3841
3842 - EN_CONCEN = 0
3843 - EN_MASS = 1
3844 - EN_SETPOINT = 2
3845 - EN_FLOWPACED = 3
3846
3847 Description:
3848
3849 - E_CONCEN Sets the concentration of external inflow entering a node
3850 - EN_MASS Injects a given mass/minute into a node
3851 - EN_SETPOINT Sets the concentration leaving a node to a given value
3852 - EN_FLOWPACED Adds a given value to the concentration leaving a node
3853 pattern_id : `str`, optional
3854 ID of the source pattern.
3855
3856 If None, a pattern_id will be generated automatically -- be aware that this
3857 could conflict with existing pattern IDs (in this case, an exception is raised).
3858
3859 The default is None.
3860 source_strength : `float`, optional
3861 Injection source strength -- i.e. injection = source_strength * pattern.
3862
3863 The default is 1.
3864 """
3865 if pattern_id is None:
3866 pattern_id = f"{species_id}_{node_id}"
3867 if pattern_id in self.epanet_api.get_all_msx_pattern_id():
3868 raise ValueError("Invalid 'pattern_id' -- " +
3869 f"there already exists a pattern with ID '{pattern_id}'")
3870
3871 self.epanet_api.MSXaddpattern(pattern_id)
3872 pattern_idx = self.epanet_api.MSXgetindex(EpanetConstants.MSX_PATTERN, pattern_id)
3873 self.epanet_api.MSXsetpattern(pattern_idx, pattern.tolist(), len(pattern))
3874 self.epanet_api.MSXsetsource(self.epanet_api.get_node_idx(node_id),
3875 self.epanet_api.get_msx_species_idx(species_id),
3876 source_type, source_strength, pattern_idx)
3877
[docs]
3878 def set_bulk_species_node_initial_concentrations(self,
3879 inital_conc: dict[str, list[tuple[str, float]]]
3880 ) -> None:
3881 """
3882 Species the initial bulk species concentration at nodes.
3883
3884 Only for EPANET-MSX scenarios.
3885
3886 Parameters
3887 ----------
3888 inital_conc : `dict[str, list[tuple[str, float]]]`
3889 Initial concentration of species (key) at nodes -- i.e.
3890 value: list of node ID and initial concentration.
3891 """
3892 if not isinstance(inital_conc, dict) or \
3893 any(not isinstance(species_id, str) or not isinstance(node_initial_conc, list)
3894 for species_id, node_initial_conc in inital_conc.items()) or \
3895 any(not isinstance(node_initial_conc, tuple)
3896 for node_initial_conc in list(itertools.chain(*inital_conc.values()))) or \
3897 any(not isinstance(node_id, str) or not isinstance(conc, float)
3898 for node_id, conc in list(itertools.chain(*inital_conc.values()))):
3899 raise TypeError("'inital_conc' must be an instance of " +
3900 "'dict[str, list[tuple[str, float]]'")
3901 inital_conc_values = list(itertools.chain(*inital_conc.values()))
3902 if any(species_id not in self.sensor_config.bulk_species
3903 for species_id in inital_conc.keys()):
3904 raise ValueError("Unknown bulk species in 'inital_conc'")
3905 if any(node_id not in self.sensor_config.nodes for node_id, _ in inital_conc_values):
3906 raise ValueError("Unknown node ID in 'inital_conc'")
3907 if any(conc < 0 for _, conc in inital_conc_values):
3908 raise ValueError("Initial node concentration can not be negative")
3909
3910 for species_id, node_initial_conc in inital_conc.items():
3911 species_idx = self.epanet_api.get_msx_species_idx(species_id)
3912
3913 for node_id, initial_conc in node_initial_conc:
3914 node_idx = self.epanet_api.get_node_idx(node_id)
3915 self.epanet_api.MSXsetinitqual(EpanetConstants.MSX_NODE, node_idx, species_idx,
3916 initial_conc)
3917
[docs]
3918 def set_species_link_initial_concentrations(self,
3919 inital_conc: dict[str, list[tuple[str, float]]]
3920 ) -> None:
3921 """
3922 Species the initial (bulk or surface) species concentration at links.
3923
3924 Only for EPANET-MSX scenarios.
3925
3926 Parameters
3927 ----------
3928 inital_conc : `dict[str, list[tuple[str, float]]]`
3929 Initial concentration of species (key) at links -- i.e.
3930 value: list of link ID and initial concentration.
3931 """
3932 if not isinstance(inital_conc, dict) or \
3933 any(not isinstance(species_id, str) or not isinstance(link_initial_conc, list)
3934 for species_id, link_initial_conc in inital_conc.items()) or \
3935 any(not isinstance(link_initial_conc, tuple)
3936 for link_initial_conc in list(itertools.chain(*inital_conc.values()))) or \
3937 any(not isinstance(link_id, str) or not isinstance(conc, float)
3938 for link_id, conc in list(itertools.chain(*inital_conc.values()))):
3939 raise TypeError("'inital_conc' must be an instance of " +
3940 "'dict[str, list[tuple[str, float]]'")
3941 if any(species_id not in self.sensor_config.bulk_species
3942 for species_id in inital_conc.keys()):
3943 raise ValueError("Unknown bulk species in 'inital_conc'")
3944 if any(link_id not in self.sensor_config.links for link_id, _ in
3945 list(itertools.chain(*inital_conc.values()))):
3946 raise ValueError("Unknown link ID in 'inital_conc'")
3947 if any(conc < 0 for _, conc in list(itertools.chain(*inital_conc.values()))):
3948 raise ValueError("Initial link concentration can not be negative")
3949
3950 for species_id, link_initial_conc in inital_conc.items():
3951 species_idx = self.epanet_api.get_msx_species_idx(species_id)
3952
3953 for link_id, initial_conc in link_initial_conc:
3954 link_idx = self.epanet_api.get_link_idx(link_id)
3955 self.epanet_api.MSXsetinitqual(EpanetConstants.MSX_LINK, link_idx, species_idx,
3956 initial_conc)