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
[docs]
446 def get_all_links(self) -> list[tuple[str, tuple[str, str]]]:
447 """
448 Gets a list of all links/pipes (incl. their end points).
449
450 Returns
451 -------
452 `list[tuple[str, tuple[str, str]]]`
453 List of links -- (link ID, (left node ID, right node ID)).
454 """
455 return [(link_id, end_points) for link_id, end_points, _ in self.__links]
456
[docs]
457 def get_number_of_links(self) -> int:
458 """
459 Returns the number of links.
460
461 Returns
462 -------
463 `int`
464 Number of links.
465 """
466 return len(self.get_all_links())
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
[docs]
646 def get_link_info(self, link_id: str) -> dict:
647 """
648 Gets all information (e.g. diameter, length, etc.) associated with a given link.
649
650 Note that links can be pipes, pumps, or valves.
651
652 Parameters
653 ----------
654 link_id : `str`
655 ID of the link.
656
657 Returns
658 -------
659 `dict`
660 Information associated with the given link.
661 """
662 for link_id_, link_nodes, link_info in self.__links:
663 if link_id_ == link_id:
664 return {"nodes": link_nodes} | link_info
665
666 raise ValueError(f"Unknown link '{link_id}'")
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
[docs]
1008 def get_adjacent_links(self, node_id: str) -> list[tuple[str, tuple[str, str]]]:
1009 """
1010 Gets all adjacent links/pipes of a given node.
1011
1012 Parameters
1013 ----------
1014 node_id : `str`
1015 ID of the node.
1016
1017 Returns
1018 -------
1019 `list[tuple[str, tuple[str, str]]]`
1020 Adjacent links -- i.e. (link ID, IDs of node end points).
1021 """
1022 if node_id not in self.get_all_nodes():
1023 raise ValueError(f"Unknown node '{node_id}'")
1024
1025 links = []
1026
1027 for link_id, nodes_id, _ in self.__links:
1028 if node_id in nodes_id:
1029 links.append((link_id, nodes_id))
1030
1031 return links
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))