Source code for epyt_flow.simulation.scenario_simulator

   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 1792 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 1973 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 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