epyt-flow 0.10.0__py3-none-any.whl → 0.11.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,611 @@
1
+ """
2
+ Module provides helper functions and data management classes for visualizing
3
+ scenarios.
4
+ """
5
+ import inspect
6
+ from dataclasses import dataclass
7
+ from typing import Optional, Union, List, Tuple
8
+
9
+ import matplotlib as mpl
10
+ import networkx.drawing.nx_pylab as nxp
11
+ import numpy as np
12
+ from scipy.interpolate import CubicSpline
13
+
14
+ from ..serialization import COLOR_SCHEMES_ID, JsonSerializable, serializable
15
+ from ..simulation.scada.scada_data import ScadaData
16
+
17
+ # Selection of functions for processing visualization data
18
+ stat_funcs = {
19
+ 'mean': np.mean,
20
+ 'min': np.min,
21
+ 'max': np.max
22
+ }
23
+
24
+
25
+ @dataclass
26
+ class JunctionObject:
27
+ """
28
+ Represents a junction component (e.g. nodes, tanks, reservoirs, ...) in a
29
+ water distribution network and manages all relevant attributes for drawing.
30
+
31
+ Attributes
32
+ ----------
33
+ nodelist : `list`
34
+ List of all nodes in WDN pertaining to this component type.
35
+ pos : `dict`
36
+ A dictionary mapping nodes to their coordinates in the correct format
37
+ for drawing.
38
+ node_shape : :class:`matplotlib.path.Path` or None
39
+ A shape representing the object, if none, the networkx default circle
40
+ is used.
41
+ node_size : `int`, default = 10
42
+ The size of each node.
43
+ node_color : `str` or `list`, default = 'k'
44
+ If `string`: the color for all nodes, if `list`: a list of lists
45
+ containing a numerical value for each node per frame, which will be
46
+ used for coloring.
47
+ interpolated : `bool`, default = False
48
+ Set to True, if node_colors are interpolated for smoother animation.
49
+ """
50
+ nodelist: list
51
+ pos: dict
52
+ node_shape: mpl.path.Path = None
53
+ node_size: int = 10
54
+ node_color: Union[str, list] = 'k'
55
+ interpolated: bool = False
56
+
57
+ def add_frame(self, statistic: str, values: np.ndarray,
58
+ pit: int, intervals: Union[int, List[Union[int, float]]]):
59
+ """
60
+ Adds a new frame of node_color based on a given statistic.
61
+
62
+ Parameters
63
+ ----------
64
+ statistic : `str`
65
+ The statistic to calculate for the data. Can be 'mean', 'min',
66
+ 'max' or 'time_step'.
67
+ values : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
68
+ The node values over time as extracted from the scada data.
69
+ pit : `int`
70
+ The point in time for the 'time_step' statistic.
71
+ intervals : `int`, `list[int]` or `list[float]`
72
+ If provided, the data will be grouped into intervals. It can be an
73
+ integer specifying the number of groups or a list of boundary
74
+ points.
75
+
76
+ Raises
77
+ ------
78
+ ValueError
79
+ If interval, pit or statistic is not correctly provided.
80
+
81
+ """
82
+ if statistic in stat_funcs:
83
+ stat_values = stat_funcs[statistic](values, axis=0)
84
+ elif statistic == 'time_step':
85
+ if not pit and pit != 0:
86
+ raise ValueError(
87
+ 'Please input point in time (pit) parameter when selecting'
88
+ ' time_step statistic')
89
+ stat_values = np.take(values, pit, axis=0)
90
+ else:
91
+ raise ValueError(
92
+ 'Statistic parameter must be mean, min, max or time_step')
93
+
94
+ if intervals is None:
95
+ pass
96
+ elif isinstance(intervals, (int, float)):
97
+ interv = np.linspace(stat_values.min(), stat_values.max(),
98
+ intervals + 1)
99
+ stat_values = np.digitize(stat_values, interv) - 1
100
+ elif isinstance(intervals, list):
101
+ stat_values = np.digitize(stat_values, intervals) - 1
102
+ else:
103
+ raise ValueError(
104
+ 'Intervals must be either number of groups or list of interval'
105
+ ' boundary points')
106
+
107
+ sorted_values = [v for _, v in zip(self.nodelist, stat_values)]
108
+
109
+ if isinstance(self.node_color, str):
110
+ # First run of this method
111
+ self.node_color = []
112
+ self.vmin = min(sorted_values)
113
+ self.vmax = max(sorted_values)
114
+
115
+ self.node_color.append(sorted_values)
116
+ self.vmin = min(*sorted_values, self.vmin)
117
+ self.vmax = max(*sorted_values, self.vmin)
118
+
119
+ def get_frame(self, frame_number: int = 0):
120
+ """
121
+ Returns all attributes necessary for networkx to draw the specified
122
+ frame.
123
+
124
+ Parameters
125
+ ----------
126
+ frame_number : `int`, default = 0
127
+ The frame whose parameters should be returned. Default is 0, this
128
+ is also used if only 1 frame exists (e.g. for plots, not
129
+ animations).
130
+
131
+ Returns
132
+ -------
133
+ valid_params : `dict`
134
+ A dictionary containing all attributes that function as parameters
135
+ for `networkx.drawing.nx_pylab.draw_networkx_nodes() <https://networkx.org/documentation/stable/reference/generated/networkx.drawing.nx_pylab.draw_networkx_nodes.html#draw-networkx-nodes>`_.
136
+ """
137
+
138
+ attributes = vars(self).copy()
139
+
140
+ if not isinstance(self.node_color, str):
141
+ if self.interpolated:
142
+ if frame_number > len(self.node_color_inter):
143
+ frame_number = -1
144
+ attributes['node_color'] = self.node_color_inter[frame_number]
145
+ else:
146
+ if frame_number > len(self.node_color):
147
+ frame_number = -1
148
+ attributes['node_color'] = self.node_color[frame_number]
149
+
150
+ sig = inspect.signature(nxp.draw_networkx_nodes)
151
+
152
+ valid_params = {
153
+ key: value for key, value in attributes.items()
154
+ if key in sig.parameters and value is not None
155
+ }
156
+
157
+ return valid_params
158
+
159
+ def interpolate(self, num_inter_frames: int):
160
+ """
161
+ Interpolates node_color values for smoother animations.
162
+
163
+ Parameters
164
+ ----------
165
+ num_inter_frames : `int`
166
+ The number of total frames after interpolation.
167
+ """
168
+ if isinstance(self.node_color, str) or len(self.node_color) <= 1:
169
+ return
170
+
171
+ tmp_node_color = np.array(self.node_color)
172
+ steps, num_nodes = tmp_node_color.shape
173
+
174
+ x_axis = np.linspace(0, steps - 1, steps)
175
+ new_x_axis = np.linspace(0, steps - 1, num_inter_frames)
176
+
177
+ self.node_color_inter = np.zeros(((len(new_x_axis)), num_nodes))
178
+
179
+ for node in range(num_nodes):
180
+ cs = CubicSpline(x_axis, tmp_node_color[:, node])
181
+ self.node_color_inter[:, node] = cs(new_x_axis)
182
+
183
+ self.interpolated = True
184
+
185
+ def add_attributes(self, attributes: dict):
186
+ """
187
+ Adds the given attributes dict as class attributes.
188
+
189
+ Parameters
190
+ ----------
191
+ attributes : `dict`
192
+ Attributes dict, which is to be added as class attributes.
193
+ """
194
+ for key, value in attributes.items():
195
+ setattr(self, key, value)
196
+
197
+
198
+ @dataclass
199
+ class EdgeObject:
200
+ """
201
+ Represents an edge component (pipes) in a water distribution network and
202
+ manages all relevant attributes for drawing.
203
+
204
+ Attributes
205
+ ----------
206
+ edgelist : `list`
207
+ List of all edges in WDN pertaining to this component type.
208
+ pos : `dict`
209
+ A dictionary mapping pipes to their coordinates in the correct format
210
+ for drawing.
211
+ edge_color : `str` or `list`, default = 'k'
212
+ If `string`: the color for all edges, if `list`: a list of lists
213
+ containing a numerical value for each edge per frame, which will be
214
+ used for coloring.
215
+ interpolated : `dict`, default = {}
216
+ Filled with interpolated frames if interpolation method is called.
217
+ """
218
+ edgelist: list
219
+ pos: dict
220
+ edge_color: Union[str, list] = 'k'
221
+ interpolated = {}
222
+
223
+ def rescale_widths(self, line_widths: Tuple[int] = (1, 2)):
224
+ """
225
+ Rescales all edge widths to the given interval.
226
+
227
+ Parameters
228
+ ----------
229
+ line_widths : `Tuple[int]`, default = (1, 2)
230
+ Min and max value, to which the edge widths should be scaled.
231
+
232
+ Raises
233
+ ------
234
+ AttributeError
235
+ If no edge width attribute exists yet.
236
+ """
237
+ if not hasattr(self, 'width'):
238
+ raise AttributeError(
239
+ 'Please call add_frame with edge_param=width before rescaling'
240
+ ' the widths.')
241
+
242
+ vmin = min(min(l) for l in self.width)
243
+ vmax = max(max(l) for l in self.width)
244
+
245
+ tmp = []
246
+ for il in self.width:
247
+ tmp.append(
248
+ self.__rescale(il, line_widths, values_min_max=(vmin, vmax)))
249
+ self.width = tmp
250
+
251
+ def add_frame(
252
+ self, topology, edge_param: str,
253
+ scada_data: Optional[ScadaData],
254
+ parameter: str = 'flow_rate', statistic: str = 'mean',
255
+ pit: Optional[Union[int, Tuple[int]]] = None,
256
+ intervals: Optional[Union[int, List[Union[int, float]]]] = None):
257
+ """
258
+ Adds a new frame of edge_color or edge width based on the given data
259
+ and statistic.
260
+
261
+ Parameters
262
+ ----------
263
+ topology : :class:`~epyt_flow.topology.NetworkTopology`
264
+ Topology object retrieved from the scenario, containing the
265
+ structure of the water distribution network.
266
+ edge_param : `str`
267
+ Method can be called with edge_width or edge_color to calculate
268
+ either the width or color for the next frame.
269
+ scada_data : :class:`~epyt_flow.simulation.scada.scada_data.ScadaData`
270
+ SCADA data created by the :class:`~epyt_flow.simulation.scenario_simulator.ScenarioSimulator`
271
+ instance, is used to retrieve data for the next frame.
272
+ parameter : `str`, default = 'flow_rate'
273
+ The link data to visualize. Options are 'flow_rate', 'velocity', or
274
+ 'status'. Default is 'flow_rate'.
275
+ statistic : `str`, default = 'mean'
276
+ The statistic to calculate for the data. Can be 'mean', 'min',
277
+ 'max' or 'time_step'.
278
+ pit : `int`
279
+ The point in time for the 'time_step' statistic.
280
+ intervals : `int`, `list[int]` or `list[float]`
281
+ If provided, the data will be grouped into intervals. It can be an
282
+ integer specifying the number of groups or a list of boundary
283
+ points.
284
+
285
+ Raises
286
+ ------
287
+ ValueError
288
+ If parameter, interval, pit or statistic is not set correctly.
289
+ """
290
+ if edge_param == 'edge_width' and not hasattr(self, 'width'):
291
+ self.width = []
292
+ elif edge_param == 'edge_color':
293
+ if isinstance(self.edge_color, str):
294
+ self.edge_color = []
295
+ self.edge_vmin = float('inf')
296
+ self.edge_vmax = float('-inf')
297
+
298
+ if parameter == 'flow_rate':
299
+ values = scada_data.flow_data_raw
300
+ elif parameter == 'link_quality':
301
+ values = scada_data.link_quality_data_raw
302
+ elif parameter == 'custom_data':
303
+ values = scada_data
304
+ elif parameter == 'diameter':
305
+ value_dict = {
306
+ link[0]: topology.get_link_info(link[0])['diameter'] for
307
+ link in topology.get_all_links()}
308
+ sorted_values = [value_dict[x[0]] for x in
309
+ topology.get_all_links()]
310
+
311
+ if edge_param == 'edge_width':
312
+ self.width.append(sorted_values)
313
+ else:
314
+ self.edge_color.append(sorted_values)
315
+ self.edge_vmin = min(*sorted_values, self.edge_vmin)
316
+ self.edge_vmax = max(*sorted_values, self.edge_vmax)
317
+
318
+ return
319
+ else:
320
+ raise ValueError('Parameter must be flow_rate, link_quality, '
321
+ 'diameter or custom_data.')
322
+
323
+ if statistic in stat_funcs:
324
+ stat_values = stat_funcs[statistic](values, axis=0)
325
+ elif statistic == 'time_step':
326
+ if not pit and pit != 0:
327
+ raise ValueError(
328
+ 'Please input point in time (pit) parameter when selecting'
329
+ ' time_step statistic')
330
+ stat_values = np.take(values, pit, axis=0)
331
+ else:
332
+ raise ValueError(
333
+ 'Statistic parameter must be mean, min, max or time_step')
334
+
335
+ if intervals is None:
336
+ pass
337
+ elif isinstance(intervals, (int, float)):
338
+ interv = np.linspace(stat_values.min(), stat_values.max(),
339
+ intervals + 1)
340
+ stat_values = np.digitize(stat_values, interv) - 1
341
+ elif isinstance(intervals, list):
342
+ stat_values = np.digitize(stat_values, intervals) - 1
343
+ else:
344
+ raise ValueError(
345
+ 'Intervals must be either number of groups or list of interval'
346
+ ' boundary points')
347
+
348
+ sorted_values = list(stat_values)
349
+
350
+ if edge_param == 'edge_width':
351
+ self.width.append(sorted_values)
352
+ else:
353
+ self.edge_color.append(sorted_values)
354
+ self.edge_vmin = min(*sorted_values, self.edge_vmin)
355
+ self.edge_vmax = max(*sorted_values, self.edge_vmax)
356
+
357
+ def get_frame(self, frame_number: int = 0):
358
+ """
359
+ Returns all attributes necessary for networkx to draw the specified
360
+ frame.
361
+
362
+ Parameters
363
+ ----------
364
+ frame_number : `int`, default = 0
365
+ The frame whose parameters should be returned. Default is 0, this
366
+ is also used if only 1 frame exists (e.g. for plots, not
367
+ animations).
368
+
369
+ Returns
370
+ -------
371
+ valid_params : `dict`
372
+ A dictionary containing all attributes that function as parameters
373
+ for `networkx.drawing.nx_pylab.draw_networkx_edges() <https://networkx.org/documentation/stable/reference/generated/networkx.drawing.nx_pylab.draw_networkx_edges.html#draw-networkx-edges>`_.
374
+ """
375
+ attributes = vars(self).copy()
376
+
377
+ if not isinstance(self.edge_color, str):
378
+ if 'edge_color' in self.interpolated.keys():
379
+ if frame_number > len(self.interpolated['edge_color']):
380
+ frame_number = -1
381
+ attributes['edge_color'] = self.interpolated['edge_color'][
382
+ frame_number]
383
+ else:
384
+ if frame_number > len(self.edge_color):
385
+ frame_number = -1
386
+ attributes['edge_color'] = self.edge_color[frame_number]
387
+
388
+ if hasattr(self, 'width'):
389
+ if 'width' in self.interpolated.keys():
390
+ if frame_number > len(self.interpolated['width']):
391
+ frame_number = -1
392
+ attributes['width'] = self.interpolated['width'][frame_number]
393
+ else:
394
+ if frame_number > len(self.width):
395
+ frame_number = -1
396
+ attributes['width'] = self.width[frame_number]
397
+
398
+ sig = inspect.signature(nxp.draw_networkx_edges)
399
+
400
+ valid_params = {
401
+ key: value for key, value in attributes.items()
402
+ if key in sig.parameters and value is not None
403
+ }
404
+
405
+ return valid_params
406
+
407
+ def interpolate(self, num_inter_frames: int):
408
+ """
409
+ Interpolates edge_color and width values for smoother animations.
410
+
411
+ Parameters
412
+ ----------
413
+ num_inter_frames : `int`
414
+ The number of total frames after interpolation.
415
+ """
416
+ targets = {'edge_color': self.edge_color}
417
+ if hasattr(self, 'width'):
418
+ targets['width'] = self.width
419
+
420
+ for name, inter_target in targets.items():
421
+ if isinstance(inter_target, str) or len(inter_target) <= 1:
422
+ continue
423
+
424
+ tmp_target = np.array(inter_target)
425
+ steps, num_edges = tmp_target.shape
426
+
427
+ x_axis = np.linspace(0, steps - 1, steps)
428
+ new_x_axis = np.linspace(0, steps - 1, num_inter_frames)
429
+
430
+ vals_inter = np.zeros(((len(new_x_axis)), num_edges))
431
+
432
+ for edge in range(num_edges):
433
+ cs = CubicSpline(x_axis, tmp_target[:, edge])
434
+ vals_inter[:, edge] = cs(new_x_axis)
435
+
436
+ self.interpolated[name] = vals_inter
437
+
438
+ def add_attributes(self, attributes: dict):
439
+ """
440
+ Adds the given attributes dict as class attributes.
441
+
442
+ Parameters
443
+ ----------
444
+ attributes : `dict`
445
+ Attributes dict, which is to be added as class attributes.
446
+ """
447
+ for key, value in attributes.items():
448
+ setattr(self, key, value)
449
+
450
+ @staticmethod
451
+ def __rescale(values: Union[np.ndarray, list],
452
+ scale_min_max: Union[List, Tuple[int]],
453
+ values_min_max: Union[List, Tuple[int, int]] = None) -> List:
454
+ """
455
+ Rescales the given values to a new range.
456
+
457
+ This method rescales an array of values to fit within a specified
458
+ minimum and maximum scale range. Optionally, the minimum and maximum
459
+ of the input values can be manually provided; otherwise, they are
460
+ automatically determined from the data.
461
+
462
+ Parameters
463
+ ----------
464
+ values : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_ or `list`
465
+ The array of numerical values to be rescaled.
466
+ scale_min_max : `list` or `tuple`
467
+ A list or tuple containing two elements: the minimum and maximum
468
+ values of the desired output range.
469
+ values_min_max : `list` or `tuple`, optional
470
+ A list or tuple containing two elements: the minimum and maximum
471
+ values of the input data. If not provided, they are computed from
472
+ the input `values`. Default is `None`.
473
+
474
+ Returns
475
+ -------
476
+ rescaled_values : `list`
477
+ A list of values rescaled to the range specified by
478
+ `scale_min_max`.
479
+ """
480
+ if not values_min_max:
481
+ min_val, max_val = min(values), max(values)
482
+ else:
483
+ min_val, max_val = values_min_max
484
+ scale = scale_min_max[1] - scale_min_max[0]
485
+
486
+ def range_map(x):
487
+ return scale_min_max[0] + (x - min_val) / (
488
+ max_val - min_val) * scale
489
+
490
+ return [range_map(x) for x in values]
491
+
492
+
493
+ @serializable(COLOR_SCHEMES_ID, ".epyt_flow_color_scheme")
494
+ class ColorScheme(JsonSerializable):
495
+ """
496
+ A class containing the color scheme for the
497
+ :class:`~epyt_flow.visualization.ScenarioVisualizer`.
498
+ """
499
+ def __init__(self, pipe_color: str, node_color: str, pump_color: str,
500
+ tank_color: str, reservoir_color: str,
501
+ valve_color: str) -> None:
502
+ """Initializes the ColorScheme class with the given component colors.
503
+
504
+ Accepted formats are the string representations accepted by matplotlib:
505
+ https://matplotlib.org/stable/users/explain/colors/colors.html#color-formats
506
+
507
+ Parameters
508
+ ----------
509
+ pipe_color : str
510
+ String color format accepted by matplotlib.
511
+ node_color : str
512
+ String color format accepted by matplotlib.
513
+ pump_color : str
514
+ String color format accepted by matplotlib.
515
+ tank_color : str
516
+ String color format accepted by matplotlib.
517
+ reservoir_color : str
518
+ String color format accepted by matplotlib.
519
+ valve_color : str
520
+ String color format accepted by matplotlib.
521
+ """
522
+ self.pipe_color = pipe_color
523
+ self.node_color = node_color
524
+ self.pump_color = pump_color
525
+ self.tank_color = tank_color
526
+ self.reservoir_color = reservoir_color
527
+ self.valve_color = valve_color
528
+ super().__init__()
529
+
530
+ def get_attributes(self) -> dict:
531
+ """
532
+ Gets all attributes needed for serialization.
533
+
534
+ Returns
535
+ -------
536
+ attr : A dictionary containing all attributes to be serialized.
537
+ """
538
+ attr = {
539
+ k: v for k, v in self.__dict__.items()
540
+ if not (k.startswith("__") or k.startswith("_")) and not callable(v)
541
+ }
542
+ return super().get_attributes() | attr
543
+
544
+ def __eq__(self, other: any) -> bool:
545
+ """
546
+ Checks if two ColorScheme instances are equal.
547
+
548
+ Parameters
549
+ ----------
550
+ other : :class:`~epyt_flow.visualization_utils.ColorScheme`
551
+ The other ColorScheme instance to compare this one with.
552
+
553
+ Returns
554
+ -------
555
+ bool
556
+ True if all attributes are the same, False otherwise.
557
+ """
558
+ if not isinstance(other, ColorScheme):
559
+ return False
560
+ return (
561
+ self.pipe_color == other.pipe_color and
562
+ self.node_color == other.node_color and
563
+ self.pump_color == other.pump_color and
564
+ self.tank_color == other.tank_color and
565
+ self.reservoir_color == other.reservoir_color and
566
+ self.valve_color == other.valve_color
567
+ )
568
+
569
+ def __str__(self) -> str:
570
+ """
571
+ Returns a string representation of the ColorScheme instance.
572
+
573
+ Returns
574
+ -------
575
+ str
576
+ A string describing the ColorScheme instance.
577
+ """
578
+ return (f"ColorScheme(pipe_color={self.pipe_color}, "
579
+ f"node_color={self.node_color}, "
580
+ f"pump_color={self.pump_color}, "
581
+ f"tank_color={self.tank_color}, "
582
+ f"reservoir_color={self.reservoir_color}, "
583
+ f"valve_color={self.valve_color})")
584
+
585
+
586
+ epanet_colors = ColorScheme(
587
+ pipe_color="#0403ee",
588
+ node_color="#0403ee",
589
+ pump_color="#fe00ff",
590
+ tank_color="#02fffd",
591
+ reservoir_color="#00ff00",
592
+ valve_color="#000000"
593
+ )
594
+
595
+ epyt_flow_colors = ColorScheme(
596
+ pipe_color="#29222f",
597
+ node_color="#29222f",
598
+ pump_color="#d79233",
599
+ tank_color="#607b80",
600
+ reservoir_color="#33483d",
601
+ valve_color="#a3320b"
602
+ )
603
+
604
+ black_colors = ColorScheme(
605
+ pipe_color="#000000",
606
+ node_color="#000000",
607
+ pump_color="#000000",
608
+ tank_color="#000000",
609
+ reservoir_color="#000000",
610
+ valve_color="#000000"
611
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: epyt-flow
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: EPyT-Flow -- EPANET Python Toolkit - Flow
5
5
  Author-email: André Artelt <aartelt@techfak.uni-bielefeld.de>, "Marios S. Kyriakou" <kiriakou.marios@ucy.ac.cy>, "Stelios G. Vrachimis" <vrachimis.stelios@ucy.ac.cy>
6
6
  License: MIT License
@@ -184,6 +184,17 @@ Besides that, you can read in-depth about the different functionalities of EPyT-
184
184
  we recommend reading the chapters in the order in which they are presented;
185
185
  you might decide to skip some of the last chapters if their content is not relevant to you.
186
186
 
187
+ ## More Networks and Benchmarks
188
+
189
+ More Water Distribution Networks (WDNs) and benchmarks are available on the
190
+ [WaterBenchmarkHub](https://waterfutures.github.io/WaterBenchmarkHub) platform.
191
+
192
+ ## More on Control
193
+
194
+ We recommend checking out [EPyT-Control](https://github.com/WaterFutures/EPyT-Control)
195
+ if you are intersted in (data-driven) control and relates tasks such as state estimation
196
+ and event diagnosis in Water Distribution Networks.
197
+
187
198
  ## License
188
199
 
189
200
  MIT license -- see [LICENSE](LICENSE)