Source code for epyt_flow.topology

   1"""
   2Module provides a class for representing the topology of WDN.
   3"""
   4from copy import deepcopy
   5import warnings
   6from typing import Any
   7import math
   8import numpy as np
   9import networkx as nx
  10from scipy.sparse import bsr_array
  11from geopandas import GeoDataFrame
  12from shapely.geometry import Point, LineString
  13from epanet_plus import EpanetConstants, EPyT
  14
  15from .serialization import serializable, JsonSerializable, NETWORK_TOPOLOGY_ID
  16from .utils import _get_flow_convert_factor, flowunit_to_str, pressureunit_to_str, \
  17    is_flowunit_simetric
  18
  19
  20UNITS_USCUSTOM = 0
  21UNITS_SIMETRIC = 1
  22
  23
[docs] 24def unitscategoryid_to_str(unit_category_id: int) -> str: 25 """ 26 Converts a given units category ID to the corresponding description. 27 28 Parameters 29 ---------- 30 unit_category_id : `int` 31 ID of the units category. 32 33 Must be one of the following constants: 34 35 - UNITS_USCUSTOM = 0 36 - UNITS_SIMETRIC = 1 37 38 Returns 39 ------- 40 `str` 41 Units category description. 42 """ 43 if unit_category_id is None: 44 return "" 45 elif unit_category_id == UNITS_USCUSTOM: 46 return "US CUSTOMARY" 47 elif unit_category_id == UNITS_SIMETRIC: 48 return "SI METRIC" 49 else: 50 raise ValueError(f"Unknown units category ID '{unit_category_id}'")
51 52
[docs] 53@serializable(NETWORK_TOPOLOGY_ID, ".epytflow_topology") 54class NetworkTopology(nx.Graph, JsonSerializable): 55 """ 56 Class representing the topology of a WDN. 57 58 Parameters 59 ---------- 60 f_inp : `str` 61 Path to .inp file to which this topology belongs. 62 nodes : `list[tuple[str, dict]]` 63 List of all nodes -- i.e. node ID and node information such as type and elevation. 64 links : `list[tuple[str, tuple[str, str], dict]]` 65 List of all links/pipes -- i.e. link ID, ID of connecting nodes, and link information 66 such as pipe diameter, length, etc. 67 pumps : `dict` 68 List of all pumps -- i.e. valve ID, and information such as 69 pump type and connecting nodes. 70 valves : `dict` 71 List of all valves -- i.e. valve ID, and information such as 72 valve type and connecting nodes. 73 curves : `dict[str, tuple[int, list[tuple[float, float]]]]` 74 All curves -- i.e. curve ID, and list of points. 75 patterns : `dict[str, list[float]]` 76 All time patterns -- i.e., pattern ID and list of multipliers. 77 flow_units : `int` 78 Flow units ID. 79 80 Must be one of the following EPANET constants: 81 82 - EN_CFS = 0 (cubic foot/sec) 83 - EN_GPM = 1 (gal/min) 84 - EN_MGD = 2 (Million gal/day) 85 - EN_IMGD = 3 (Imperial MGD) 86 - EN_AFD = 4 (ac-foot/day) 87 - EN_LPS = 5 (liter/sec) 88 - EN_LPM = 6 (liter/min) 89 - EN_MLD = 7 (Megaliter/day) 90 - EN_CMH = 8 (cubic meter/hr) 91 - EN_CMD = 9 (cubic meter/day) 92 - EN_CMS = 10 (cubic meter/sec) 93 pressure_units : `int` 94 Pressue unit ID. 95 96 Must be one of the following EPANET constants: 97 98 - EN_PSI = 0 (Pounds per square inch) 99 - EN_KPA = 1 (Kilopascals) 100 - EN_METERS = 2 (Meters) 101 - EN_BAR = 3 (Bar) 102 - EN_FEET = 4 (Feet) 103 """ 104 def __init__(self, f_inp: str, nodes: list[tuple[str, dict]], 105 links: list[tuple[str, tuple[str, str], dict]], 106 pumps: dict, 107 valves: dict, 108 curves: dict[str, tuple[int, list[tuple[float, float]]]], 109 patterns: dict[str, list[float]], 110 flow_units: int, pressure_units: int, 111 **kwds): 112 nx.Graph.__init__(self, name=f_inp, **kwds) 113 JsonSerializable.__init__(self) 114 115 self.__nodes = nodes 116 self.__links = links 117 self.__pumps = pumps 118 self.__valves = valves 119 self.__curves = curves 120 self.__patterns = patterns 121 self.__flow_units = flow_units 122 self.__pressure_units = pressure_units 123 124 for key in self.__curves.keys(): # Fix value types -- tuple gets converted to list when deserializing it 125 self.__curves[key] = (self.__curves[key][0], 126 [tuple(value) for value in self.__curves[key][1]]) 127 128 for node_id, node_info in nodes: 129 node_elevation = node_info["elevation"] 130 node_type = node_info["type"] 131 self.add_node(node_id, info={"elevation": node_elevation, "type": node_type}) 132 133 for link_id, link, link_info in links: 134 link_type = link_info["type"] 135 link_diameter = link_info["diameter"] 136 link_length = link_info["length"] 137 self.add_edge(link[0], link[1], length=link_length, 138 info={"id": link_id, "type": link_type, "nodes": link, 139 "diameter": link_diameter, "length": link_length}) 140
[docs] 141 def to_inp_file(self, inp_file_out: str) -> None: 142 """ 143 Creates an .inp file with the network layout and parameters as specified in 144 this instance. 145 Note that no control rules are set! 146 147 Parameters 148 ---------- 149 inp_file_out : `str` 150 Path to the .inp file. 151 """ 152 with EPyT(inp_file_in=inp_file_out, use_project=False) as epanet_api: 153 epanet_api.setflowunits(self.__flow_units) 154 epanet_api.setoption(EpanetConstants.EN_PRESS_UNITS, self.__pressure_units) 155 156 for curve_id, (curve_type, curve_data) in self.__curves.items(): 157 epanet_api.addcurve(curve_id) 158 curve_idx = epanet_api.getcurveindex(curve_id) 159 epanet_api.setcurvetype(curve_idx, curve_type) 160 for i, (x, y) in enumerate(curve_data): 161 epanet_api.setcurvevalue(curve_idx, i+1, x, y) 162 163 for pattern_id, values in self.__patterns.items(): 164 epanet_api.add_pattern(pattern_id, values) 165 166 for junc_id in self.get_all_junctions(): 167 epanet_api.addnode(junc_id, EpanetConstants.EN_JUNCTION) 168 169 node_idx = epanet_api.get_node_idx(junc_id) 170 junc_info = self.get_node_info(junc_id) 171 epanet_api.setnodevalue(node_idx, EpanetConstants.EN_ELEVATION, 172 junc_info["elevation"]) 173 epanet_api.setbasedemand(node_idx, 1, junc_info["base_demand"]) 174 epanet_api.setcoord(node_idx, junc_info["coord"][0], junc_info["coord"][1]) 175 epanet_api.setcomment(EpanetConstants.EN_NODE, node_idx, junc_info["comment"]) 176 if "demand_patterns_id" in junc_info: 177 for i, demand_pattern_id in enumerate(junc_info["demand_patterns_id"]): 178 epanet_api.setdemandpattern(node_idx, i+1, 179 epanet_api.getpatternindex(demand_pattern_id)) 180 181 for reservoir_id in self.get_all_reservoirs(): 182 epanet_api.addnode(reservoir_id, EpanetConstants.EN_RESERVOIR) 183 184 node_idx = epanet_api.get_node_idx(reservoir_id) 185 reservoir_info = self.get_node_info(reservoir_id) 186 epanet_api.setnodevalue(node_idx, EpanetConstants.EN_ELEVATION, 187 reservoir_info["elevation"]) 188 epanet_api.setcoord(node_idx, reservoir_info["coord"][0], 189 reservoir_info["coord"][1]) 190 epanet_api.setcomment(EpanetConstants.EN_NODE, node_idx, 191 reservoir_info["comment"]) 192 193 for tank_id in self.get_all_tanks(): 194 epanet_api.addnode(tank_id, EpanetConstants.EN_TANK) 195 196 node_idx = epanet_api.get_node_idx(tank_id) 197 tank_info = self.get_node_info(tank_id) 198 if tank_info["cylindric"] is False: 199 raise NotImplementedError("Non-cylindric tanks are not supported!") 200 else: 201 epanet_api.setnodevalue(node_idx, EpanetConstants.EN_ELEVATION, 202 tank_info["elevation"]) 203 epanet_api.setcoord(node_idx, tank_info["coord"][0], tank_info["coord"][1]) 204 epanet_api.setcomment(EpanetConstants.EN_NODE, node_idx, tank_info["comment"]) 205 epanet_api.setnodevalue(node_idx, EpanetConstants.EN_TANKDIAM, 206 tank_info["diameter"]) 207 epanet_api.setnodevalue(node_idx, EpanetConstants.EN_MIXFRACTION, 208 tank_info["mixing_fraction"]) 209 epanet_api.setnodevalue(node_idx, EpanetConstants.EN_MIXMODEL, 210 tank_info["mixing_model"]) 211 epanet_api.setnodevalue(node_idx, EpanetConstants.EN_CANOVERFLOW, 212 float(tank_info["can_overflow"])) 213 214 tank_info["min_level"] = tank_info["min_vol"] / \ 215 (math.pi * (0.5 * tank_info["diameter"])**2) 216 tank_info["init_level"] = tank_info["init_vol"] / \ 217 (math.pi * (0.5 * tank_info["diameter"])**2) 218 219 epanet_api.settankdata(node_idx, tank_info["elevation"], 220 tank_info["init_level"], tank_info["min_level"], 221 tank_info["max_level"], tank_info["diameter"], 222 tank_info["min_vol"], tank_info["vol_curve_id"]) 223 224 for pipe_id, (node_a, node_b) in self.get_all_pipes(): 225 epanet_api.addlink(pipe_id, EpanetConstants.EN_PIPE, node_a, node_b) 226 227 pipe_idx = epanet_api.get_link_idx(pipe_id) 228 pipe_info = self.get_link_info(pipe_id) 229 epanet_api.setlinkvalue(pipe_idx, EpanetConstants.EN_LENGTH, pipe_info["length"]) 230 epanet_api.setlinkvalue(pipe_idx, EpanetConstants.EN_DIAMETER, 231 pipe_info["diameter"]) 232 epanet_api.setlinkvalue(pipe_idx, EpanetConstants.EN_ROUGHNESS, 233 pipe_info["roughness_coeff"]) 234 epanet_api.setlinkvalue(pipe_idx, EpanetConstants.EN_KBULK, pipe_info["bulk_coeff"]) 235 epanet_api.setlinkvalue(pipe_idx, EpanetConstants.EN_KWALL, pipe_info["wall_coeff"]) 236 epanet_api.setlinkvalue(pipe_idx, EpanetConstants.EN_MINORLOSS, 237 pipe_info["loss_coeff"]) 238 epanet_api.setlinkvalue(pipe_idx, EpanetConstants.EN_INITSETTING, 239 pipe_info["init_setting"]) 240 epanet_api.setlinkvalue(pipe_idx, EpanetConstants.EN_INITSTATUS, 241 pipe_info["init_status"]) 242 243 for valve_id in self.get_all_valves(): 244 valve_info = self.get_valve_info(valve_id) 245 node_a, node_b = valve_info["end_points"] 246 epanet_api.addlink(valve_id, valve_info["type"], node_a, node_b) 247 link_idx = epanet_api.get_link_idx(valve_id) 248 epanet_api.setlinkvalue(link_idx, EpanetConstants.EN_DIAMETER, 249 valve_info["diameter"]) 250 epanet_api.setlinkvalue(link_idx, EpanetConstants.EN_INITSETTING, 251 valve_info["initial_setting"]) 252 if valve_info["type"] not in [EpanetConstants.EN_GPV, EpanetConstants.EN_PRV, 253 EpanetConstants.EN_CVPIPE]: 254 epanet_api.setlinkvalue(link_idx, EpanetConstants.EN_INITSTATUS, 255 valve_info["initial_status"]) 256 257 for pump_id in self.get_all_pumps(): 258 pump_info = self.get_pump_info(pump_id) 259 node_a, node_b = pump_info["end_points"] 260 epanet_api.addlink(pump_id, pump_info["type"], node_a, node_b) 261 262 link_idx = link_idx = epanet_api.get_link_idx(pump_id) 263 epanet_api.setlinkvalue(link_idx, EpanetConstants.EN_INITSETTING, 264 pump_info["init_setting"]) 265 epanet_api.setlinkvalue(link_idx, EpanetConstants.EN_INITSTATUS, 266 pump_info["init_status"]) 267 268 if pump_info["curve_id"] is not None: 269 curve_idx = epanet_api.getcurveindex(pump_info["curve_id"]) 270 epanet_api.setheadcurveindex(link_idx, curve_idx) 271 272 epanet_api.saveinpfile(inp_file_out)
273
[docs] 274 def convert_units(self, flow_units: int, pressure_units: int) -> Any: 275 """ 276 Converts this instance to a :class:`~epyt_flow.topology.NetworkTopology` instance 277 where everything is measured in given flow and pressure units. 278 279 Parameters 280 ---------- 281 flow_units : `int` 282 Flow unit ID. 283 284 Must be one of the following EPANET constants: 285 286 - EN_CFS = 0 (cubic foot/sec) 287 - EN_GPM = 1 (gal/min) 288 - EN_MGD = 2 (Million gal/day) 289 - EN_IMGD = 3 (Imperial MGD) 290 - EN_AFD = 4 (ac-foot/day) 291 - EN_LPS = 5 (liter/sec) 292 - EN_LPM = 6 (liter/min) 293 - EN_MLD = 7 (Megaliter/day) 294 - EN_CMH = 8 (cubic meter/hr) 295 - EN_CMD = 9 (cubic meter/day) 296 - EN_CMS = 10 (cubic meter/sec) 297 298 pressure_units : `int` 299 Pressure unit ID. 300 301 Must be one of the following EPANET constants: 302 303 - EN_PSI = 0 (Pounds per square inch) 304 - EN_KPA = 1 (Kilopascals) 305 - EN_METERS = 2 (Meters) 306 - EN_BAR = 3 (Bar) 307 - EN_FEET = 4 (Feet) 308 309 Returns 310 ------- 311 :class:`~epyt_flow.topology.NetworkTopology` 312 Network topology with the new measurements units. 313 """ 314 if self.__flow_units is None or self.__pressure_units is None: 315 raise ValueError("This instance does not contain any units!") 316 317 if not isinstance(flow_units, int): 318 raise TypeError("'units' must be an instance of 'int' " + 319 f"but not of '{type(flow_units)}'") 320 if flow_units not in range(11): 321 raise ValueError(f"Invalid units '{flow_units}'") 322 323 if not isinstance(pressure_units, int): 324 raise TypeError("'pressure_units' must be an instance of 'int' " + 325 f"but not of '{type(pressure_units)}'") 326 if pressure_units not in range(5): 327 raise ValueError(f"Invalid units '{pressure_units}'") 328 329 if flow_units == self.__flow_units and pressure_units == self.__pressure_units: 330 warnings.warn("Units already set in this NetworkTopology instance -- nothing to do!") 331 return deepcopy(self) 332 333 # Get all data and convert units 334 inch_to_millimeter = 25.4 335 feet_to_meter = 0.3048 336 cubicmeter_to_cubicfeet = 35.3146667215 337 338 units_category = is_flowunit_simetric(flow_units) 339 340 nodes = [] 341 for node_id in self.get_all_nodes(): 342 node_info = self.get_node_info(node_id) 343 if units_category == UNITS_USCUSTOM: 344 conv_factor = 1. / feet_to_meter 345 else: 346 conv_factor = feet_to_meter 347 348 node_info["elevation"] *= conv_factor 349 if "diameter" in node_info: 350 node_info["diameter"] *= conv_factor 351 if "max_level" in node_info: 352 node_info["max_level"] *= conv_factor 353 if "min_level" in node_info: 354 node_info["min_level"] *= conv_factor 355 if "init_level" in node_info: 356 node_info["init_level"] *= conv_factor 357 if "min_vol" in node_info: 358 if units_category == UNITS_USCUSTOM: 359 node_info["min_vol"] *= cubicmeter_to_cubicfeet 360 else: 361 node_info["min_vol"] *= 1. / cubicmeter_to_cubicfeet 362 363 nodes.append((node_id, node_info)) 364 365 links = [] 366 for link_id, link_nodes in self.get_all_links(): 367 link_info = self.get_link_info(link_id) 368 369 if units_category == UNITS_USCUSTOM: 370 conv_factor = 1. / feet_to_meter 371 else: 372 conv_factor = feet_to_meter 373 link_info["length"] *= conv_factor 374 375 if units_category == UNITS_USCUSTOM: 376 conv_factor = 1. / inch_to_millimeter 377 else: 378 conv_factor = inch_to_millimeter 379 link_info["diameter"] *= conv_factor 380 381 links.append((link_id, link_nodes, link_info)) 382 383 curves = {} 384 flow_convert_factor = _get_flow_convert_factor(flow_units, self.__flow_units) 385 for curve_id, (curve_type, curve_data) in self.__curves.items(): 386 x_conv_factor, y_conv_factor = None, None 387 388 if curve_type == EpanetConstants.EN_VOLUME_CURVE: 389 if units_category == UNITS_USCUSTOM: 390 x_conv_factor = 1. / feet_to_meter 391 y_conv_factor = 1. / cubicmeter_to_cubicfeet 392 else: 393 x_conv_factor = feet_to_meter 394 y_conv_factor = cubicmeter_to_cubicfeet 395 elif curve_type == EpanetConstants.EN_PUMP_CURVE: 396 x_conv_factor = flow_convert_factor 397 if units_category == UNITS_USCUSTOM: 398 y_conv_factor = 1. / feet_to_meter 399 else: 400 y_conv_factor = feet_to_meter 401 elif curve_type == EpanetConstants.EN_EFFIC_CURVE: 402 x_conv_factor = flow_convert_factor 403 y_conv_factor = 1. 404 elif curve_type == EpanetConstants.EN_HLOSS_CURVE: 405 x_conv_factor = flow_convert_factor 406 if units_category == UNITS_USCUSTOM: 407 y_conv_factor = 1. / feet_to_meter 408 else: 409 y_conv_factor = feet_to_meter 410 else: 411 warnings.warn("Unit conversion: Curve type is not supported") 412 413 curve_data_new = [] 414 for x, y in curve_data: 415 curve_data_new.append((x * x_conv_factor, y * y_conv_factor)) 416 417 curves[curve_id] = (curve_type, curve_data_new) 418 419 return NetworkTopology(f_inp=self.name, nodes=nodes, links=links, pumps=self.pumps, 420 valves=self.valves, flow_units=flow_units, 421 pressure_units=pressure_units, curves=curves, 422 patterns=self.__patterns)
423
[docs] 424 def get_all_nodes(self) -> list[str]: 425 """ 426 Gets a list of all nodes. 427 428 Returns 429 ------- 430 `list[str]` 431 List of all nodes ID. 432 """ 433 return [node_id for node_id, _ in self.__nodes]
434
[docs] 435 def get_number_of_nodes(self) -> int: 436 """ 437 Returns the number of nodes. 438 439 Returns 440 ------- 441 `int` 442 Number of nodes. 443 """ 444 return len(self.get_all_nodes())
445 456 467
[docs] 468 def get_all_junctions(self) -> list[str]: 469 """ 470 Gets all junctions -- i.e. nodes that are not tanks or reservoirs. 471 472 Returns 473 ------- 474 `list[str]` 475 List of all junctions. 476 """ 477 r = [] 478 479 for node_id in self.get_all_nodes(): 480 if self.get_node_info(node_id)["type"] == EpanetConstants.EN_JUNCTION: 481 r.append(node_id) 482 483 return r
484
[docs] 485 def get_number_of_junctions(self) -> int: 486 """ 487 Returns the number of junctions. 488 489 Returns 490 ------- 491 `int` 492 Number of junctions. 493 """ 494 return len(self.get_all_junctions())
495
[docs] 496 def get_all_tanks(self) -> list[str]: 497 """ 498 Gets all tanks -- i.e. nodes that are not junctions or reservoirs. 499 500 Returns 501 ------- 502 `list[str]` 503 List of all tanks. 504 """ 505 r = [] 506 507 for node_id in self.get_all_nodes(): 508 if self.get_node_info(node_id)["type"] == EpanetConstants.EN_TANK: 509 r.append(node_id) 510 511 return r
512
[docs] 513 def get_number_of_tanks(self) -> int: 514 """ 515 Returns the number of tanks. 516 517 Returns 518 ------- 519 `int` 520 Number of tanks. 521 """ 522 return len(self.get_all_tanks())
523
[docs] 524 def get_all_reservoirs(self) -> list[str]: 525 """ 526 Gets all reservoirs -- i.e. nodes that are not junctions or tanks. 527 528 Returns 529 ------- 530 `list[str]` 531 List of all reservoirs. 532 """ 533 r = [] 534 535 for node_id in self.get_all_nodes(): 536 if self.get_node_info(node_id)["type"] == EpanetConstants.EN_RESERVOIR: 537 r.append(node_id) 538 539 return r
540
[docs] 541 def get_number_of_reservoirs(self) -> int: 542 """ 543 Returns the number of reservoirs. 544 545 Returns 546 ------- 547 `int` 548 Number of reservoirs. 549 """ 550 return len(self.get_all_reservoirs())
551
[docs] 552 def get_all_pipes(self) -> list[tuple[str, tuple[str, str]]]: 553 """ 554 Gets all pipes -- i.e. links that not valves or pumps. 555 556 Returns 557 ------- 558 `list[tuple[str, tuple[str, str]]]` 559 List of all pipes -- (link ID, (left node ID, right node ID)). 560 """ 561 r = [] 562 563 for link_id, link_nodes in self.get_all_links(): 564 link_info = self.get_link_info(link_id) 565 566 if link_info["type"] == EpanetConstants.EN_PIPE: 567 r.append((link_id, link_nodes)) 568 569 return r
570
[docs] 571 def get_number_of_pipes(self) -> int: 572 """ 573 Returns the number of pipes. 574 575 Returns 576 ------- 577 `int` 578 Number of pipes. 579 """ 580 return len(self.get_all_pipes())
581
[docs] 582 def get_all_pumps(self) -> list[str]: 583 """ 584 Gets the IDs of all pumps. 585 586 Returns 587 ------- 588 `list[str]` 589 Pump IDs. 590 """ 591 return list(self.__pumps.keys())
592
[docs] 593 def get_number_of_pumps(self) -> int: 594 """ 595 Returns the number of pumps. 596 597 Returns 598 ------- 599 `int` 600 Number of pumps. 601 """ 602 return len(self.get_all_pumps())
603
[docs] 604 def get_all_valves(self) -> list[str]: 605 """ 606 Gets the IDs of all valves. 607 608 Returns 609 ------- 610 `list[str]` 611 Valve IDs. 612 """ 613 return list(self.__valves.keys())
614
[docs] 615 def get_number_of_valves(self) -> int: 616 """ 617 Returns the number of valves. 618 619 Returns 620 ------- 621 `int` 622 Number of valves. 623 """ 624 return len(self.get_all_valves())
625
[docs] 626 def get_node_info(self, node_id: str) -> dict: 627 """ 628 Gets all information (e.g. elevation, type, etc.) associated with a given node. 629 630 Parameters 631 ---------- 632 node_id : `str` 633 ID of the node. 634 635 Returns 636 ------- 637 `dict` 638 Information associated with the given node. 639 """ 640 for node_id_, node_info in self.__nodes: 641 if node_id_ == node_id: 642 return node_info 643 644 raise ValueError(f"Unknown node '{node_id}'")
645 667
[docs] 668 def get_pump_info(self, pump_id: str) -> dict: 669 """ 670 Gets all information associated with a given pump. 671 672 Parameters 673 ---------- 674 pump_id : `str` 675 ID of the pump. 676 677 Returns 678 ------- 679 `dict` 680 Pump information. 681 """ 682 if pump_id in self.__pumps: 683 return self.__pumps[pump_id] 684 else: 685 raise ValueError(f"Unknown pump: '{pump_id}'")
686
[docs] 687 def get_valve_info(self, valve_id: str) -> dict: 688 """ 689 Gets all information associated with a given valve. 690 691 Parameters 692 ---------- 693 valve_id : `str` 694 ID of the valve. 695 696 Returns 697 ------- 698 `dict` 699 Valve information. 700 """ 701 if valve_id in self.__valves: 702 return self.__valves[valve_id] 703 else: 704 raise ValueError(f"Unknown valve: '{valve_id}'")
705 706 @property 707 def curves(self) -> dict[str, tuple[int, list[tuple[float, float]]]]: 708 """ 709 Gets all curves -- i.e., ID and list of points. 710 711 Returns 712 ------- 713 `dict[str, tuple[int, list[tuple[float, float]]]]` 714 All curves. 715 """ 716 return deepcopy(self.__curves) 717 718 @property 719 def patterns(self) -> dict[str, list[float]]: 720 """ 721 Returns all time patterns -- i.e., ID and list of multipliers. 722 723 Returns 724 ------- 725 `dict[str, list[float]]` 726 All time patterns. 727 """ 728 return deepcopy(self.__patterns) 729 730 @property 731 def pumps(self) -> dict: 732 """ 733 Gets all pumps -- i.e. ID and associated information such as the pump type. 734 735 Returns 736 ------- 737 `dict` 738 All pumps and their associated information. 739 """ 740 return deepcopy(self.__pumps) 741 742 @property 743 def valves(self) -> dict: 744 """ 745 Gets all valves -- i.e. ID and associated information such as the valve type. 746 747 Returns 748 ------- 749 `dict` 750 All valves and their associated information. 751 """ 752 return deepcopy(self.__valves) 753 754 @property 755 def flow_units(self) -> int: 756 """ 757 Return the flow unit ID. 758 759 Will be one of the following EPANET constants: 760 761 - EN_CFS = 0 (cubic foot/sec) 762 - EN_GPM = 1 (gal/min) 763 - EN_MGD = 2 (Million gal/day) 764 - EN_IMGD = 3 (Imperial MGD) 765 - EN_AFD = 4 (ac-foot/day) 766 - EN_LPS = 5 (liter/sec) 767 - EN_LPM = 6 (liter/min) 768 - EN_MLD = 7 (Megaliter/day) 769 - EN_CMH = 8 (cubic meter/hr) 770 - EN_CMD = 9 (cubic meter/day) 771 - EN_CMS = 10 (cubic meter/sec) 772 773 Returns 774 ------- 775 `int` 776 Flow unit ID. 777 """ 778 return self.__flow_units 779 780 @property 781 def pressure_units(self) -> int: 782 """ 783 Returns the pressure unit ID. 784 785 Will be one of the following EPANET constants: 786 787 - EN_PSI = 0 (Pounds per square inch) 788 - EN_KPA = 1 (Kilopascals) 789 - EN_METERS = 2 (Meters) 790 - EN_BAR = 3 (Bar) 791 - EN_FEET = 4 (Feet) 792 793 Returns 794 ------- 795 `int` 796 Pressure unit ID. 797 """ 798 return self.__pressure_units 799 800 def __eq__(self, other) -> bool: 801 if not isinstance(other, NetworkTopology): 802 raise TypeError("Can not compare 'NetworkTopology' instance to " + 803 f"'{type(other)}' instance") 804 805 adj_matrix = self.get_adj_matrix() 806 other_adj_matrix = other.get_adj_matrix() 807 808 return self.name == other.name \ 809 and not np.any(adj_matrix.data != other_adj_matrix.data) \ 810 and not np.any(adj_matrix.indices != other_adj_matrix.indices) \ 811 and not np.any(adj_matrix.indptr != other_adj_matrix.indptr) \ 812 and self.get_all_nodes() == other.get_all_nodes() \ 813 and all(link_a[0] == link_b[0] and link_a[1] == link_b[1] 814 for link_a, link_b in zip(self.get_all_links(), other.get_all_links())) \ 815 and self.__flow_units == other.flow_units \ 816 and self.__pressure_units == other.pressure_units \ 817 and self.get_all_pumps() == other.get_all_pumps() \ 818 and self.get_all_valves() == other.get_all_valves() \ 819 and self.__curves == other.curves \ 820 and self.__patterns == other.patterns 821 822 def __str__(self) -> str: 823 return f"f_inp: {self.name} nodes: {self.__nodes} links: {self.__links} " +\ 824 f"pumps: {self.__pumps} valves: {self.__valves} " +\ 825 f"flow_units: {flowunit_to_str(self.__flow_units)} " +\ 826 f"pressure_units: {pressureunit_to_str(self.__pressure_units)}" 827
[docs] 828 def get_attributes(self) -> dict: 829 return super().get_attributes() | {"f_inp": self.name, 830 "nodes": self.__nodes, 831 "links": self.__links, 832 "pumps": self.__pumps, 833 "valves": self.__valves, 834 "curves": self.__curves, 835 "patterns": self.__patterns, 836 "flow_units": self.__flow_units, 837 "pressure_units": self.__pressure_units}
838
[docs] 839 def to_gis(self, coord_reference_system: str = None, pumps_as_points: bool = False, 840 valves_as_points: bool = False) -> dict: 841 """ 842 Gets the network topology as a dictionary of `geopandas.GeoDataFrames` instances -- 843 i.e. each quantity (nodes, links/pipes, valves, etc.) is represented by a 844 `geopandas.GeoDataFrames` instance. 845 846 Parameters 847 ---------- 848 coord_reference_system : `str`, optional 849 Coordinate reference system. 850 851 The default is None. 852 pumps_as_points : `bool`, optional 853 If True, pumps are represented by points, otherwise by lines. 854 855 The default is False. 856 857 valves_as_points : `bool`, optional 858 If True, valves are represented by points, otherwise by lines. 859 860 The default is False. 861 862 Returns 863 ------- 864 `dict` 865 Network topology as a dictionary of `geopandas.GeoDataFrames <https://geopandas.org/en/stable/docs/reference/api/geopandas.GeoDataFrame.html>`_ instances. 866 If a quantity does not exist, the data frame will be None. 867 """ 868 gis = {"nodes": None, "links": None, 869 "tanks": None, "reservoirs": None, 870 "valves": None, "pumps": None} 871 872 # Nodes 873 node_data = {"id": [], "type": [], "elevation": [], "geometry": []} 874 tank_data = {"id": [], "min_vol": [], "max_level": [], "min_level": [], "mixing_fraction": [], 875 "elevation": [], "diameter": [], "geometry": [], "init_vol": [], "mixing_model": []} 876 reservoir_data = {"id": [], "elevation": [], "geometry": []} 877 for node_id in self.get_all_nodes(): 878 node_info = self.get_node_info(node_id) 879 880 node_data["id"].append(node_id) 881 node_data["type"].append(node_info["type"]) 882 node_data["elevation"].append(node_info["elevation"]) 883 node_data["geometry"].append(Point(node_info["coord"])) 884 885 if node_info["type"] == EpanetConstants.EN_TANK: 886 tank_data["id"].append(node_id) 887 tank_data["elevation"].append(node_info["elevation"]) 888 tank_data["diameter"].append(node_info["diameter"]) 889 tank_data["max_level"].append(node_info["max_level"]) 890 tank_data["min_level"].append(node_info["min_level"]) 891 tank_data["min_vol"].append(node_info["min_vol"]) 892 tank_data["init_vol"].append(node_info["init_vol"]) 893 tank_data["mixing_fraction"].append(node_info["mixing_fraction"]) 894 tank_data["mixing_model"].append(node_info["mixing_model"]) 895 tank_data["geometry"].append(Point(node_info["coord"])) 896 elif node_info["type"] == EpanetConstants.EN_RESERVOIR: 897 reservoir_data["id"].append(node_id) 898 reservoir_data["elevation"].append(node_info["elevation"]) 899 reservoir_data["geometry"].append(Point(node_info["coord"])) 900 901 gis["nodes"] = GeoDataFrame(node_data, crs=coord_reference_system) 902 gis["tanks"] = GeoDataFrame(tank_data, crs=coord_reference_system) 903 gis["reservoirs"] = GeoDataFrame(reservoir_data, crs=coord_reference_system) 904 905 # Links 906 pipe_data = {"id": [], "type": [], "end_point_a": [], "end_point_b": [], 907 "length": [], "diameter": [], "geometry": []} 908 valve_data = {"id": [], "type": [], "geometry": []} 909 pump_data = {"id": [], "type": [], "geometry": []} 910 for link_id, link_nodes in self.get_all_links(): 911 link_info = self.get_link_info(link_id) 912 end_points_coord = [self.get_node_info(n)["coord"] for n in link_nodes] 913 914 if link_info["type"] == EpanetConstants.EN_PIPE: 915 pipe_data["id"].append(link_id) 916 pipe_data["type"].append(link_info["type"]) 917 pipe_data["end_point_a"].append(link_nodes[0]) 918 pipe_data["end_point_b"].append(link_nodes[1]) 919 pipe_data["length"].append(link_info["length"]) 920 pipe_data["diameter"].append(link_info["diameter"]) 921 pipe_data["geometry"].append(LineString(end_points_coord)) 922 elif link_info["type"] == EpanetConstants.EN_PUMP: 923 pump_data["id"].append(link_id) 924 pump_data["type"].append(self.get_pump_info(link_id)["type"]) 925 if pumps_as_points is True: 926 pump_data["geometry"].append(Point(end_points_coord[0])) 927 else: 928 pump_data["geometry"].append(LineString(end_points_coord)) 929 else: # Valve 930 valve_data["id"].append(link_id) 931 valve_data["type"].append(self.get_valve_info(link_id)["type"]) 932 if valves_as_points is True: 933 valve_data["geometry"].append(Point(end_points_coord[0])) 934 else: 935 valve_data["geometry"].append(LineString(end_points_coord)) 936 937 gis["pipes"] = GeoDataFrame(pipe_data, crs=coord_reference_system) 938 gis["valves"] = GeoDataFrame(valve_data, crs=coord_reference_system) 939 gis["pumps"] = GeoDataFrame(pump_data, crs=coord_reference_system) 940 941 return gis
942
[docs] 943 def get_adj_matrix(self) -> bsr_array: 944 """ 945 Gets the adjacency matrix of this graph. 946 947 Returns 948 ------- 949 `scipy.bsr_array <https://docs.scipy.org/doc/scipy/reference/generated/scipy.sparse.bsr_array.html>`_ 950 Adjacency matrix as a sparse array. 951 """ 952 nodes_id = [node_id for node_id, _ in self.__nodes] 953 n_nodes = len(self.__nodes) 954 955 row = [] 956 col = [] 957 for _, link_end_points, _ in self.__links: 958 a = nodes_id.index(link_end_points[0]) 959 b = nodes_id.index(link_end_points[1]) 960 961 row.append(a) 962 col.append(b) 963 964 row.append(b) 965 col.append(a) 966 967 for i in range(n_nodes): 968 row.append(i) 969 col.append(i) 970 971 return bsr_array((np.ones(len(row)), (row, col)), shape=(n_nodes, n_nodes))
972
[docs] 973 def get_adj_list(self) -> dict[str, list[str]]: 974 """ 975 Returns the connectivity of the nodes (node IDs) as an adjacency list. 976 977 Returns 978 ------- 979 `dict[str, list[str]]` 980 Adjacency list as a dictionary. 981 """ 982 adj_list = {} 983 984 for node_id in self.get_all_nodes(): 985 adj_list[node_id] = self.get_neighbors(node_id) 986 987 return adj_list
988
[docs] 989 def get_neighbors(self, node_id: str) -> list[str]: 990 """ 991 Gets all neighboring nodes of a given node. 992 993 Parameters 994 ---------- 995 node_id : `str` 996 ID of the node. 997 998 Returns 999 ------- 1000 `list[str]` 1001 IDs of neighboring nodes. 1002 """ 1003 if node_id not in self.get_all_nodes(): 1004 raise ValueError(f"Unknown node '{node_id}'") 1005 1006 return list(self.neighbors(node_id))
1007 1032
[docs] 1033 def get_shortest_path(self, start_node_id: str, end_node_id: str, 1034 use_pipe_length_as_weight: bool = True) -> list[str]: 1035 """ 1036 Computes the shortest path between two nodes in this graph. 1037 1038 Parameters 1039 ---------- 1040 start_node_id : `str` 1041 ID of start node. 1042 end_node_id : `str` 1043 ID of end node. 1044 use_pipe_length_as_weight : `bool`, optional 1045 If True, pipe lengths are used for the edge weights -- otherwise, 1046 each edge weight is set to one. 1047 1048 The default is True. 1049 """ 1050 if start_node_id not in self.get_all_nodes(): 1051 raise ValueError(f"Unknown node '{start_node_id}'") 1052 if end_node_id not in self.get_all_nodes(): 1053 raise ValueError(f"Unknown node '{end_node_id}'") 1054 1055 weight = "length" if use_pipe_length_as_weight is True else None 1056 return nx.shortest_path(self, source=start_node_id, target=end_node_id, weight=weight)
1057
[docs] 1058 def get_all_pairs_shortest_path(self, use_pipe_length_as_weight: bool = True) -> dict: 1059 """ 1060 Computes the shortest path between all pairs of nodes in this graph. 1061 1062 Parameters 1063 ---------- 1064 use_pipe_length_as_weight : `bool`, optional 1065 If True, pipe lengths are used for the edge weights -- otherwise, 1066 each edge weight is set to one. 1067 1068 The default is True. 1069 1070 Returns 1071 ------- 1072 `dict` 1073 Shortest paths between all pairs of nodes as nested dictionaries -- 1074 first key is the start node, second key is the end node. 1075 """ 1076 weight = "length" if use_pipe_length_as_weight is True else None 1077 return nx.shortest_path(self, weight=weight)
1078
[docs] 1079 def get_shortest_path_length(self, start_node_id: str, end_node_id: str, 1080 use_pipe_length_as_weight: bool = True) -> list[str]: 1081 """ 1082 Computes the shortest path length between two nodes in this graph. 1083 1084 Parameters 1085 ---------- 1086 start_node_id : `str` 1087 ID of start node. 1088 end_node_id : `str` 1089 ID of end node. 1090 use_pipe_length_as_weight : `bool`, optional 1091 If True, pipe lengths are used for the edge weights -- otherwise, 1092 each edge weight is set to one. 1093 1094 The default is True. 1095 """ 1096 if start_node_id not in self.get_all_nodes(): 1097 raise ValueError(f"Unknown node '{start_node_id}'") 1098 if end_node_id not in self.get_all_nodes(): 1099 raise ValueError(f"Unknown node '{end_node_id}'") 1100 1101 weight = "length" if use_pipe_length_as_weight is True else None 1102 return nx.shortest_path_length(self, source=start_node_id, target=end_node_id, 1103 weight=weight)
1104
[docs] 1105 def get_all_pairs_shortest_path_length(self, use_pipe_length_as_weight: bool = True) -> dict: 1106 """ 1107 Computes the shortest path length between all pairs of nodes in this graph. 1108 1109 Parameters 1110 ---------- 1111 use_pipe_length_as_weight : `bool`, optional 1112 If True, pipe lengths are used for the edge weights -- otherwise, 1113 each edge weight is set to one. 1114 1115 The default is True. 1116 1117 Returns 1118 ------- 1119 `dict` 1120 Shortest paths between all pairs of nodes as nested dictionaries -- 1121 first key is the start node, second key is the end node. 1122 """ 1123 weight = "length" if use_pipe_length_as_weight is True else None 1124 return dict(nx.shortest_path_length(self, weight=weight))