epyt-flow 0.10.0__py3-none-any.whl → 0.12.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.
Files changed (42) hide show
  1. epyt_flow/VERSION +1 -1
  2. epyt_flow/data/benchmarks/gecco_water_quality.py +2 -2
  3. epyt_flow/data/benchmarks/leakdb.py +40 -5
  4. epyt_flow/data/benchmarks/water_usage.py +4 -3
  5. epyt_flow/data/networks.py +27 -14
  6. epyt_flow/gym/__init__.py +0 -3
  7. epyt_flow/gym/scenario_control_env.py +11 -13
  8. epyt_flow/rest_api/scenario/control_handlers.py +118 -0
  9. epyt_flow/rest_api/scenario/event_handlers.py +114 -1
  10. epyt_flow/rest_api/scenario/handlers.py +33 -0
  11. epyt_flow/rest_api/server.py +14 -2
  12. epyt_flow/serialization.py +1 -0
  13. epyt_flow/simulation/__init__.py +0 -1
  14. epyt_flow/simulation/backend/__init__.py +1 -0
  15. epyt_flow/simulation/backend/my_epyt.py +1056 -0
  16. epyt_flow/simulation/events/actuator_events.py +7 -1
  17. epyt_flow/simulation/events/quality_events.py +3 -1
  18. epyt_flow/simulation/scada/scada_data.py +716 -5
  19. epyt_flow/simulation/scenario_config.py +1 -40
  20. epyt_flow/simulation/scenario_simulator.py +645 -119
  21. epyt_flow/simulation/sensor_config.py +18 -2
  22. epyt_flow/topology.py +24 -7
  23. epyt_flow/uncertainty/model_uncertainty.py +80 -62
  24. epyt_flow/uncertainty/sensor_noise.py +15 -4
  25. epyt_flow/uncertainty/uncertainties.py +71 -18
  26. epyt_flow/uncertainty/utils.py +40 -13
  27. epyt_flow/utils.py +45 -1
  28. epyt_flow/visualization/__init__.py +2 -0
  29. epyt_flow/visualization/scenario_visualizer.py +1240 -0
  30. epyt_flow/visualization/visualization_utils.py +738 -0
  31. {epyt_flow-0.10.0.dist-info → epyt_flow-0.12.0.dist-info}/METADATA +15 -4
  32. {epyt_flow-0.10.0.dist-info → epyt_flow-0.12.0.dist-info}/RECORD +35 -36
  33. {epyt_flow-0.10.0.dist-info → epyt_flow-0.12.0.dist-info}/WHEEL +1 -1
  34. epyt_flow/gym/control_gyms.py +0 -47
  35. epyt_flow/metrics.py +0 -466
  36. epyt_flow/models/__init__.py +0 -2
  37. epyt_flow/models/event_detector.py +0 -31
  38. epyt_flow/models/sensor_interpolation_detector.py +0 -118
  39. epyt_flow/simulation/scada/advanced_control.py +0 -138
  40. epyt_flow/simulation/scenario_visualizer.py +0 -1307
  41. {epyt_flow-0.10.0.dist-info → epyt_flow-0.12.0.dist-info/licenses}/LICENSE +0 -0
  42. {epyt_flow-0.10.0.dist-info → epyt_flow-0.12.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1240 @@
1
+ """
2
+ Module provides a class for visualizing scenarios.
3
+ """
4
+ from typing import Optional, Union, List, Tuple
5
+
6
+ import numpy as np
7
+
8
+ import matplotlib.pyplot as plt
9
+ from matplotlib.animation import FuncAnimation
10
+ import matplotlib as mpl
11
+ import networkx.drawing.nx_pylab as nxp
12
+ from svgpath2mpl import parse_path
13
+
14
+ from ..simulation.scenario_simulator import ScenarioSimulator
15
+ from ..simulation.scada.scada_data import ScadaData
16
+ from ..visualization import JunctionObject, EdgeObject, ColorScheme, \
17
+ epyt_flow_colors
18
+
19
+ PUMP_PATH = ('M 202.5 93 A 41.5 42 0 0 0 161 135 A 41.5 42 0 0 0 202.5 177 A '
20
+ '41.5 42 0 0 0 244 135 A 41.5 42 0 0 0 241.94922 122 L 278 122 '
21
+ 'L 278 93 L 203 93 L 203 93.011719 A 41.5 42 0 0 0 202.5 93 z')
22
+ RESERVOIR_PATH = ('M 325 41 A 43 24.5 0 0 0 282.05664 65 L 282 65 L 282 65.5 '
23
+ 'L 282 163 L 282 168 L 282 216 L 305 216 L 305 168 L 345 '
24
+ '168 L 345 216 L 368 216 L 368 168 L 368 163 L 368 65.5 L '
25
+ '368 65 L 367.98047 65 A 43 24.5 0 0 0 325 41 z')
26
+ TANK_PATH = ('M 325 41 A 43 24.5 0 0 0 282.05664 65 L 282 65 L 282 65.5 L 282 '
27
+ '185 L 368 185 L 368 65.5 L 368 65 L 367.98047 65 A 43 24.5 0 0'
28
+ ' 0 325 41 z')
29
+ VALVE_PATH = ('M 9.9999064 9.9999064 L 9.9999064 110 L 69.999862 59.999955 L '
30
+ '9.9999064 9.9999064 z M 69.999862 59.999955 L 129.99982 110 L '
31
+ '129.99982 9.9999064 L 69.999862 59.999955 z')
32
+
33
+
34
+ class Marker:
35
+ """
36
+ The Marker class provides svg representations of hydraulic components
37
+ (pump, reservoir, tank and valve), which are loaded from their respective
38
+ svg paths and transformed into
39
+ `~matplotlib.path.Path <https://matplotlib.org/stable/api/path_api.html#matplotlib.path.Path>`_
40
+ objects in order to be used with the matplotlib library.
41
+
42
+ Attributes
43
+ ----------
44
+ pump : `matplotlib.path.Path <https://matplotlib.org/stable/api/path_api.html#matplotlib.path.Path>`_
45
+ Marker for the pump, loaded from PUMP_PATH.
46
+ reservoir : `matplotlib.path.Path <https://matplotlib.org/stable/api/path_api.html#matplotlib.path.Path>`_
47
+ Marker for the reservoir, loaded from RESERVOIR_PATH.
48
+ tank : `matplotlib.path.Path <https://matplotlib.org/stable/api/path_api.html#matplotlib.path.Path>`_
49
+ Marker for the tank, loaded from TANK_PATH.
50
+ valve : `matplotlib.path.Path <https://matplotlib.org/stable/api/path_api.html#matplotlib.path.Path>`_
51
+ Marker for the valve, loaded from VALVE_PATH.
52
+
53
+ Methods
54
+ -------
55
+ __marker_from_path(path, scale_p=1)
56
+ Loads and applies transformations to the marker shape from the given
57
+ path.
58
+ """
59
+
60
+ def __init__(self):
61
+ """
62
+ Initializes the Marker class and assigns
63
+ `matplotlib.path.Path <https://matplotlib.org/stable/api/path_api.html#matplotlib.path.Path>`_
64
+ markers for pump, reservoir, tank, and valve components.
65
+ """
66
+ self.pump = self.__marker_from_path(PUMP_PATH, 2)
67
+ self.reservoir = self.__marker_from_path(RESERVOIR_PATH)
68
+ self.tank = self.__marker_from_path(TANK_PATH)
69
+ self.valve = self.__marker_from_path(VALVE_PATH)
70
+
71
+ @staticmethod
72
+ def __marker_from_path(path: str, scale_p: int = 1) -> mpl.path.Path:
73
+ """
74
+ Loads the marker from the specified path and adjusts it representation
75
+ by aligning, rotating and scaling it.
76
+
77
+ Parameters
78
+ ----------
79
+ path : `str`
80
+ The svg path describing the marker shape.
81
+ scale_p : `float`, optional
82
+ Scaling factor for the marker (default is 1).
83
+
84
+ Returns
85
+ -------
86
+ `matplotlib.path.Path <https://matplotlib.org/stable/api/path_api.html#matplotlib.path.Path>`_
87
+ The transformed marker object after loading and adjusting it.
88
+ """
89
+ marker_tmp = parse_path(path)
90
+ marker_tmp.vertices -= marker_tmp.vertices.mean(axis=0)
91
+ marker_tmp = marker_tmp.transformed(
92
+ mpl.transforms.Affine2D().rotate_deg(180))
93
+ marker_tmp = marker_tmp.transformed(
94
+ mpl.transforms.Affine2D().scale(-scale_p, scale_p))
95
+ return marker_tmp
96
+
97
+
98
+ class ScenarioVisualizer:
99
+ """
100
+ This class provides the necessary function to generate visualizations in
101
+ the form of plots or animations from water network data.
102
+
103
+ Given a :class:`~epyt_flow.simulation.scenario_simulator.ScenarioSimulator`
104
+ object, this class provides the necessary functions to plot the network
105
+ topology and to color hydraulic elements according to simulation data. The
106
+ resulting plot can then be displayed or saved.
107
+
108
+ Attributes
109
+ ----------
110
+ __scenario : :class:`~epyt_flow.simulation.scenario_simulator.ScenarioSimulator`
111
+ ScenarioSimulator object containing the network topology and
112
+ configurations to obtain the simulation data which should be displayed.
113
+ fig : `matplotlib.pyplot.Figure <https://matplotlib.org/stable/api/_as_gen/matplotlib.figure.Figure.html#matplotlib.figure.Figure>`_ or None
114
+ Figure object used for plotting, created and customized by calling the
115
+ methods of this class, initialized as None.
116
+ ax : `~matplotlib.axes.Axes <https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.html#matplotlib-axes-axes>`_ or None
117
+ The axes for plotting, initialized as None.
118
+ scada_data : :class:`~epyt_flow.simulation.scada.scada_data.ScadaData` or None
119
+ SCADA data created by the ScenarioSimulator object, initialized as
120
+ None.
121
+ topology : :class:`~epyt_flow.topology.NetworkTopology`
122
+ Topology object retrieved from the scenario, containing the structure
123
+ of the water distribution network.
124
+ color_scheme : :class:`~epyt_flow.visualization.visualization_utils.ColorScheme`
125
+ Contains the selected ColorScheme for visualization.
126
+ pipe_parameters : :class:`~epyt_flow.visualization.visualization_utils.EdgeObject`
127
+ Class contains parameters for visualizing pipes in the correct format
128
+ for drawing.
129
+ junction_parameters : :class:`~epyt_flow.visualization.visualization_utils.JunctionObject`
130
+ Class contains parameters for visualizing junctions in the correct
131
+ format for drawing.
132
+ tank_parameters : :class:`~epyt_flow.visualization.visualization_utils.JunctionObject`
133
+ Class contains parameters for visualizing tanks in the correct format
134
+ for drawing.
135
+ reservoir_parameters : :class:`~epyt_flow.visualization.visualization_utils.JunctionObject`
136
+ Class contains parameters for visualizing reservoirs in the correct
137
+ format for
138
+ drawing.
139
+ valve_parameters : :class:`~epyt_flow.visualization.visualization_utils.JunctionObject`
140
+ Class contains parameters for visualizing valves in the correct format
141
+ for drawing.
142
+ pump_parameters : :class:`~epyt_flow.visualization.visualization_utils.JunctionObject`
143
+ Class contains parameters for visualizing pumps in the correct format
144
+ for drawing.
145
+ colorbars : `dict`
146
+ A dictionary containing the necessary data for drawing the required
147
+ colorbars.
148
+ labels : `dict`
149
+ A dictionary containing components as keys and drawing information for
150
+ the labels as values.
151
+
152
+ """
153
+
154
+ def __init__(self, scenario: ScenarioSimulator,
155
+ color_scheme: ColorScheme = epyt_flow_colors) -> None:
156
+ """
157
+ Initializes the class with a given scenario, sets up the topology,
158
+ SCADA data, and the classes containing parameters for visualizing
159
+ various hydraulic components (pipes, junctions, tanks, reservoirs,
160
+ valves, and pumps).
161
+
162
+ Parameters
163
+ ----------
164
+ scenario : :class:`~epyt_flow.simulation.scenario_simulator.ScenarioSimulator`
165
+ An instance of the `ScenarioSimulator` class, used to simulate and
166
+ retrieve the system topology.
167
+ color_scheme : :class:`~epyt_flow.visualization.visualization_utils.ColorScheme`
168
+ Contains the selected ColorScheme for visualization. Default is
169
+ EPYT_FLOW.
170
+
171
+ Raises
172
+ ------
173
+ TypeError
174
+ If `scenario` is not an instance of
175
+ :class:`~epyt_flow.simulation.scenario_simulator.ScenarioSimulator`.
176
+ """
177
+ if not isinstance(scenario, ScenarioSimulator):
178
+ raise TypeError("'scenario' must be an instance of " +
179
+ "'epyt_flow.simulation.ScenarioSimulator' " +
180
+ f"but not of '{type(scenario)}'")
181
+
182
+ self.__scenario = scenario
183
+ self.fig = None
184
+ self.ax = None
185
+ self.scada_data = None
186
+ markers = Marker()
187
+ self.topology = self.__scenario.get_topology()
188
+
189
+ pos_dict = {x: self.topology.get_node_info(x)['coord'] for x in
190
+ self.topology.get_all_nodes()}
191
+
192
+ self.color_scheme = color_scheme
193
+
194
+ self.pipe_parameters = EdgeObject(
195
+ [x[1] for x in self.topology.get_all_links()], pos_dict,
196
+ self.color_scheme.pipe_color)
197
+ self.junction_parameters = JunctionObject(
198
+ self.topology.get_all_junctions(), pos_dict,
199
+ node_color=self.color_scheme.node_color)
200
+ self.tank_parameters = JunctionObject(self.topology.get_all_tanks(),
201
+ pos_dict, node_size=100,
202
+ node_shape=markers.tank,
203
+ node_color=self.color_scheme.tank_color)
204
+ self.reservoir_parameters = JunctionObject(
205
+ self.topology.get_all_reservoirs(), pos_dict, node_size=100,
206
+ node_shape=markers.reservoir,
207
+ node_color=self.color_scheme.reservoir_color)
208
+ self.valve_parameters = JunctionObject(self.topology.get_all_valves(),
209
+ self._get_midpoints(
210
+ self.topology.get_all_valves()),
211
+ node_size=50,
212
+ node_shape=markers.valve,
213
+ node_color=self.color_scheme.valve_color)
214
+ self.pump_parameters = JunctionObject(self.topology.get_all_pumps(),
215
+ self._get_midpoints(
216
+ self.topology.get_all_pumps()),
217
+ node_size=50,
218
+ node_shape=markers.pump,
219
+ node_color=self.color_scheme.pump_color)
220
+
221
+ self.colorbars = {}
222
+ self.labels = {}
223
+ self.masks = {}
224
+
225
+ def _get_midpoints(self, elements: List[str]) -> dict[
226
+ str, tuple[float, float]]:
227
+ """
228
+ Computes and returns the midpoints for drawing either valves or pumps
229
+ in a water distribution network.
230
+
231
+ For each element ID in the provided list, the method calculates the
232
+ midpoint between its start and end nodes' coordinates.
233
+
234
+ Parameters
235
+ ----------
236
+ elements : `list[str]`
237
+ A list of element IDs (e.g., pump IDs, valve IDs) for which to
238
+ compute the midpoints.
239
+
240
+ Returns
241
+ -------
242
+ elements_dict : `dict`
243
+ A dictionary where the keys are element IDs and the values are the
244
+ corresponding midpoints, represented as 2D coordinates [x, y].
245
+ """
246
+ elements_pos_dict = {}
247
+ for element in elements:
248
+ if element in self.topology.pumps:
249
+ start_node, end_node = self.topology.get_pump_info(element)[
250
+ 'end_points']
251
+ elif element in self.topology.valves:
252
+ start_node, end_node = self.topology.get_valve_info(element)[
253
+ 'end_points']
254
+ else:
255
+ raise ValueError(f"Unknown element '{element}'")
256
+ start_pos = self.topology.get_node_info(start_node)['coord']
257
+ end_pos = self.topology.get_node_info(end_node)['coord']
258
+ pos = [(start_pos[0] + end_pos[0]) / 2,
259
+ (start_pos[1] + end_pos[1]) / 2]
260
+ elements_pos_dict[element] = pos
261
+ return elements_pos_dict
262
+
263
+ def _get_next_frame(self, frame_number: int) -> None:
264
+ """
265
+ Draws the next frame of a water distribution network animation.
266
+
267
+ This method updates a visualization animation with the hydraulic
268
+ components colored according to the scada data corresponding to the
269
+ current frame.
270
+
271
+ Parameters
272
+ ----------
273
+ frame_number : `int`
274
+ The current frame number used to retrieve the data corresponding to
275
+ that frame
276
+ """
277
+ plt.clf()
278
+ self.ax = self.fig.add_subplot(111)
279
+ self.ax.axis('off')
280
+
281
+ nxp.draw_networkx_edges(self.topology, ax=self.ax,
282
+ label='Pipes',
283
+ **self.pipe_parameters.get_frame(frame_number))
284
+ nxp.draw_networkx_nodes(self.topology, ax=self.ax,
285
+ label='Junctions',
286
+ **self.junction_parameters.get_frame(
287
+ frame_number))
288
+ nxp.draw_networkx_nodes(self.topology, ax=self.ax,
289
+ label='Tanks',
290
+ **self.tank_parameters.get_frame(frame_number))
291
+ nxp.draw_networkx_nodes(self.topology, ax=self.ax,
292
+ label='Reservoirs',
293
+ **self.reservoir_parameters.get_frame(
294
+ frame_number))
295
+ nxp.draw_networkx_nodes(
296
+ self.topology, ax=self.ax,
297
+ label='Valves', **self.valve_parameters.get_frame(frame_number))
298
+ nxp.draw_networkx_nodes(
299
+ self.topology,
300
+ ax=self.ax, label='Pumps',
301
+ **self.pump_parameters.get_frame(frame_number))
302
+
303
+ for key, mask in self.masks.items():
304
+ if key == 'nodes':
305
+ nxp.draw_networkx_nodes(self.topology, ax=self.ax,
306
+ **self.junction_parameters.get_frame_mask(
307
+ mask,
308
+ self.color_scheme.node_color))
309
+ if key == 'pumps':
310
+ nxp.draw_networkx_nodes(
311
+ self.topology,
312
+ ax=self.ax,
313
+ **self.pump_parameters.get_frame_mask(mask,
314
+ self.color_scheme.pump_color))
315
+ if key == 'links':
316
+ nxp.draw_networkx_edges(self.topology, ax=self.ax,
317
+ **self.pipe_parameters.get_frame_mask(
318
+ frame_number,
319
+ self.color_scheme.pipe_color))
320
+ if key == 'tanks':
321
+ nxp.draw_networkx_nodes(self.topology, ax=self.ax,
322
+ **self.tank_parameters.get_frame_mask(
323
+ mask,
324
+ self.color_scheme.tank_color))
325
+ if key == 'valves':
326
+ nxp.draw_networkx_nodes(
327
+ self.topology, ax=self.ax,
328
+ **self.valve_parameters.get_frame_mask(mask,
329
+ self.color_scheme.valve_color))
330
+
331
+ self._draw_labels()
332
+ self.ax.legend(fontsize=6)
333
+
334
+ for colorbar_stats in self.colorbars.values():
335
+ self.fig.colorbar(ax=self.ax, **colorbar_stats)
336
+
337
+ def _interpolate_frames(self, num_inter_frames: int):
338
+ """
339
+ Interpolates intermediate values between frames using cubic spline
340
+ interpolation for smoother animation.
341
+
342
+ Parameters
343
+ ----------
344
+ num_inter_frames : `int`
345
+ Number of total frames after interpolation.
346
+
347
+ Returns
348
+ -------
349
+ num_inter_frames : `int`
350
+ Number of total frames after interpolation.
351
+ """
352
+ for node_source in [self.junction_parameters, self.tank_parameters,
353
+ self.reservoir_parameters, self.valve_parameters,
354
+ self.pump_parameters]:
355
+ node_source.interpolate(num_inter_frames)
356
+ self.pipe_parameters.interpolate(num_inter_frames)
357
+
358
+ return num_inter_frames
359
+
360
+ def _draw_labels(self):
361
+ """
362
+ Method accesses the dict `self.labels` and draws all generated labels
363
+ within.
364
+ """
365
+ for k, v in self.labels.items():
366
+ if k in ['pipes']:
367
+ nxp.draw_networkx_edge_labels(self.topology, ax=self.ax, **v)
368
+ continue
369
+ nxp.draw_networkx_labels(self.topology, ax=self.ax, **v)
370
+
371
+ def _get_sensor_config_nodes_and_links(self):
372
+ """
373
+ Iterates through the sensor config and collects all nodes and links
374
+ within, that have a sensor attached.
375
+
376
+ Returns
377
+ -------
378
+ highlighted_links : `list`
379
+ List of all links with sensors.
380
+ highlighted_nodes : `list`
381
+ List of all nodes with sensors.
382
+ """
383
+ highlighted_nodes = []
384
+ highlighted_links = []
385
+
386
+ sensor_config = self.__scenario.sensor_config
387
+ highlighted_nodes += (sensor_config.pressure_sensors
388
+ + sensor_config.demand_sensors
389
+ + sensor_config.quality_node_sensors)
390
+ highlighted_links += (sensor_config.flow_sensors
391
+ + sensor_config.quality_link_sensors)
392
+ return highlighted_nodes, highlighted_links
393
+
394
+ def add_labels(self, components: str or list or tuple = (),
395
+ font_size: int = 8):
396
+ """
397
+ Adds labels to hydraulic components according to the specified
398
+ components.
399
+
400
+ Parameters
401
+ ----------
402
+ components : `str` or `list` or `tuple`, default is ()
403
+ Can either be 'all': all components, 'sensor_config': all nodes and
404
+ pipes which have a sensor attached, or a list of the component
405
+ names that are to be labeled: nodes, tanks, reservoirs, pipes,
406
+ valves, pumps. If the list is empty, all nodes are labeled.
407
+ font_size : `int`, default is 8
408
+ Font size of the labels.
409
+ """
410
+ sc_nodes, sc_links = None, None
411
+ if components == 'all':
412
+ components = ['nodes', 'tanks', 'reservoirs', 'pipes', 'valves',
413
+ 'pumps']
414
+ elif components == 'sensor_config':
415
+ components = ['nodes', 'tanks', 'reservoirs', 'pipes', 'valves',
416
+ 'pumps']
417
+ sc_nodes, sc_links = self._get_sensor_config_nodes_and_links()
418
+
419
+ elif len(components) == 0:
420
+ components = ['nodes']
421
+
422
+ component_mapping = {
423
+ 'nodes': self.junction_parameters,
424
+ 'tanks': self.tank_parameters,
425
+ 'reservoirs': self.reservoir_parameters,
426
+ 'valves': self.valve_parameters,
427
+ 'pumps': self.pump_parameters
428
+ }
429
+
430
+ for component, parameters in component_mapping.items():
431
+ if component in components:
432
+ labels = {n: str(n) for n in parameters.nodelist if
433
+ sc_nodes is None or n in sc_nodes}
434
+ self.labels[component] = {'pos': parameters.pos,
435
+ 'labels': labels,
436
+ 'font_size': font_size}
437
+ if component in ['pumps', 'valves']:
438
+ self.labels[component]['verticalalignment'] = 'bottom'
439
+ if 'pipes' in components:
440
+ labels = {tuple(n[1]): n[0] for n in self.topology.get_all_links()
441
+ if sc_links is None or n[0] in sc_links}
442
+ self.labels['pipes'] = {'pos': self.pipe_parameters.pos,
443
+ 'edge_labels': labels,
444
+ 'font_size': font_size}
445
+
446
+ def show_animation(self, export_to_file: str = None,
447
+ return_animation: bool = False, duration: int = 5,
448
+ fps: int = 15, interpolate: bool = True) \
449
+ -> Optional[FuncAnimation]:
450
+ """
451
+ Displays, exports, or returns an animation of a water distribution
452
+ network over time.
453
+
454
+ This method generates an animation of a network and either shows it or
455
+ returns the :class:`~FuncAnimation` object. Optionally, the animation
456
+ is saved to a file.
457
+
458
+ Parameters
459
+ ----------
460
+ export_to_file : `str`, optional
461
+ The file path where the animation should be saved, if provided.
462
+ Default is `None`.
463
+ return_animation : `bool`, optional
464
+ If `True`, the animation object is returned. If `False`, the
465
+ animation will be shown, but not returned. Default is `False`.
466
+ duration : `int`, default is 5
467
+ Duration of the animation in seconds.
468
+ fps : `int`, default is 15
469
+ Frames per seconds, is achieved through interpolation.
470
+ interpolate : `bool`, default is True
471
+ Whether to allow interpolating the sensor values or not. Necessary
472
+ for fixed fps.
473
+
474
+ Returns
475
+ -------
476
+ anim : :class:`~FuncAnimation` or None
477
+ Returns the animation object if `return_animation` is `True`.
478
+ Otherwise, returns `None`.
479
+ """
480
+ self.fig = plt.figure(figsize=(6.4, 4.8), dpi=200)
481
+
482
+ total_frames = float('inf')
483
+ for node_source in [self.junction_parameters, self.tank_parameters,
484
+ self.reservoir_parameters, self.valve_parameters,
485
+ self.pump_parameters]:
486
+ if not isinstance(node_source.node_color, str) and len(
487
+ node_source.node_color) > 1:
488
+ total_frames = min(total_frames, len(node_source.node_color))
489
+ if hasattr(self.pipe_parameters, 'edge_color'):
490
+ if not isinstance(self.pipe_parameters.edge_color, str) and len(
491
+ self.pipe_parameters.edge_color) > 1:
492
+ total_frames = min(total_frames,
493
+ len(self.pipe_parameters.edge_color))
494
+ if hasattr(self.pipe_parameters, 'width'):
495
+ if not isinstance(self.pipe_parameters.width, str) and len(
496
+ self.pipe_parameters.width) > 1:
497
+ total_frames = min(total_frames,
498
+ len(self.pipe_parameters.width))
499
+
500
+ if total_frames == 0 or total_frames == float('inf'):
501
+ raise RuntimeError("The color or resize functions must be called "
502
+ "with a time_step range (pit) > 1 to enable "
503
+ "animations")
504
+
505
+ if interpolate:
506
+ total_frames = self._interpolate_frames(fps * duration)
507
+
508
+ anim = FuncAnimation(self.fig, self._get_next_frame,
509
+ frames=total_frames,
510
+ interval=round(duration * 100 / total_frames))
511
+
512
+ if export_to_file is not None:
513
+ anim.save(export_to_file, writer='ffmpeg', fps=fps)
514
+ if return_animation:
515
+ plt.close(self.fig)
516
+ return anim
517
+ plt.show()
518
+ return None
519
+
520
+ def show_plot(self, export_to_file: str = None,
521
+ suppress_plot: bool = False) -> None:
522
+ """
523
+ Displays a static plot of the water distribution network.
524
+
525
+ This method generates a static plot of the water distribution network,
526
+ visualizing pipes, junctions, tanks, reservoirs, valves, and pumps.
527
+ The plot can be displayed and saved to a file.
528
+
529
+ Parameters
530
+ ----------
531
+ export_to_file : `str`, optional
532
+ The file path where the plot should be saved, if provided.
533
+ Default is `None`.
534
+ suppress_plot : `bool`, default is False
535
+ If true, no plot is displayed after running this method.
536
+ """
537
+ self.fig = plt.figure(figsize=(6.4, 4.8), dpi=200)
538
+ self._get_next_frame(0)
539
+
540
+ if export_to_file is not None:
541
+ plt.savefig(export_to_file, transparent=True, bbox_inches='tight',
542
+ dpi=900)
543
+ if not suppress_plot:
544
+ plt.show()
545
+ else:
546
+ plt.close(self.fig)
547
+ plt.clf()
548
+
549
+ def color_nodes(
550
+ self, data: Optional[Union[ScadaData, np.ndarray]] = None,
551
+ parameter: str = 'pressure', statistic: str = 'mean',
552
+ pit: Optional[Union[int, Tuple[int, int]]] = None,
553
+ species: str = None,
554
+ colormap: str = 'viridis',
555
+ intervals: Optional[Union[int, List[Union[int, float]]]] = None,
556
+ conversion: Optional[dict] = None,
557
+ show_colorbar: bool = False,
558
+ use_sensor_data: bool = False) -> None:
559
+ """
560
+ Colors the nodes (junctions) in the water distribution network based on
561
+ the SCADA data and the specified parameters.
562
+
563
+ This method either takes or generates SCADA data, applies a statistic
564
+ to the chosen parameter, optionally groups the results and prepares the
565
+ results to be either displayed statically ot animated.
566
+
567
+ Parameters
568
+ ----------
569
+ data : :class:`~epyt_flow.simulation.scada.scada_data.ScadaData` or
570
+ `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, optional
571
+ The SCADA data object containing node data or a numpy array of the
572
+ shape nodes*timesteps. If `None`, a simulation is run to generate
573
+ SCADA data. Default is `None`.
574
+ parameter : `str`, optional
575
+ The node data to visualize. Must be 'pressure', 'demand', or
576
+ 'node_quality'. Default is 'pressure'.
577
+ statistic : `str`, optional
578
+ The statistic to calculate for the data. Can be 'mean', 'min',
579
+ 'max', or 'time_step'. Default is 'mean'.
580
+ pit : `int`, `tuple(int, int)`, optional
581
+ The point in time or range of time steps for the 'time_step'
582
+ statistic. If a tuple is provided, it should contain two integers
583
+ representing the start and end time steps. A tuple is necessary to
584
+ process the data for the :meth:`~ScenarioVisualizer.show_animation`
585
+ method. Default is `None`.
586
+ species: `str`, optional
587
+ Key of species. Only necessary for parameter
588
+ 'bulk_species_concentration'.
589
+ colormap : `str`, optional
590
+ The colormap to use for visualizing node values. Default is
591
+ 'viridis'.
592
+ intervals : `int`, `list[int]` or `list[float]`, optional
593
+ If provided, the data will be grouped into intervals. It can be an
594
+ integer specifying the number of groups or a list of boundary
595
+ points. Default is `None`.
596
+ conversion : `dict`, optional
597
+ A dictionary of conversion parameters to convert SCADA data units.
598
+ Default is `None`.
599
+ show_colorbar : `bool`, optional
600
+ If `True`, a colorbar will be displayed on the plot to indicate the
601
+ range of node values. Default is `False`.
602
+ use_sensor_data : `bool`, optional
603
+ If `True`, instead of using raw simulation data, the data recorded
604
+ by the corresponding sensors in the system is used for the
605
+ visualization. Note: Not all components may have a sensor attached
606
+ and sensors may be subject to sensor faults or noise.
607
+
608
+ Raises
609
+ ------
610
+ ValueError
611
+ If the `parameter` is not one of 'pressure', 'demand', or
612
+ 'node_quality', or if `pit` is not correctly provided for the
613
+ 'time_step' statistic.
614
+
615
+ """
616
+ self.junction_parameters.cmap = colormap
617
+
618
+ if data is not None:
619
+ self.scada_data = data
620
+ elif not self.scada_data:
621
+ self.scada_data = self.__scenario.run_simulation()
622
+
623
+ if conversion:
624
+ self.scada_data = self.scada_data.convert_units(**conversion)
625
+
626
+ # TODO: is there any way to make this look better (e.g. do a mapping somewhere??)
627
+ if parameter == 'pressure':
628
+ if use_sensor_data:
629
+ values, self.masks[
630
+ 'nodes'] = self.scada_data.get_data_pressures_as_node_features()
631
+ else:
632
+ values = self.scada_data.pressure_data_raw
633
+ elif parameter == 'demand':
634
+ if use_sensor_data:
635
+ values, self.masks[
636
+ 'nodes'] = self.scada_data.get_data_demands_as_node_features()
637
+ else:
638
+ values = self.scada_data.demand_data_raw
639
+ elif parameter == 'node_quality':
640
+ if use_sensor_data:
641
+ values, self.masks[
642
+ 'nodes'] = self.scada_data.get_data_nodes_quality_as_node_features()
643
+ else:
644
+ values = self.scada_data.node_quality_data_raw
645
+ elif parameter == 'custom_data':
646
+ # Custom should have the dimensions (timesteps, nodes)
647
+ values = self.scada_data
648
+ elif parameter == 'bulk_species_concentration':
649
+ if not species:
650
+ raise ValueError('Species must be set when using bulk_species_'
651
+ 'concentration.')
652
+ if use_sensor_data:
653
+ values, self.masks[
654
+ 'nodes'] = self.scada_data.get_data_bulk_species_concentrations_as_node_features()
655
+ self.masks['nodes'] = self.masks['nodes'][:,
656
+ self.scada_data.sensor_config.bulk_species.index(
657
+ species)]
658
+ values = values[:, :,
659
+ self.scada_data.sensor_config.bulk_species.index(
660
+ species)]
661
+ else:
662
+ values = self.scada_data.bulk_species_node_concentration_raw[:,
663
+ self.scada_data.sensor_config.bulk_species.index(
664
+ species), :]
665
+ else:
666
+ raise ValueError(
667
+ 'Parameter must be pressure, demand, node_quality or custom_'
668
+ 'data.')
669
+
670
+ if statistic == 'time_step' and isinstance(pit, tuple) and len(
671
+ pit) == 2 and all(isinstance(i, int) for i in pit):
672
+ rng = pit
673
+ if pit[1] == -1:
674
+ rng = (pit[0], values.shape[0])
675
+ for frame in range(*rng):
676
+ if frame > values.shape[0] - 1:
677
+ break
678
+ self.junction_parameters.add_frame(statistic, values, frame,
679
+ intervals)
680
+ else:
681
+ self.junction_parameters.add_frame(statistic, values, pit,
682
+ intervals)
683
+
684
+ if show_colorbar:
685
+ if statistic == 'time_step':
686
+ label = str(parameter).capitalize() + ' at timestep ' + str(
687
+ pit)
688
+ else:
689
+ label = str(statistic).capitalize() + ' ' + str(
690
+ parameter).replace('_', ' ')
691
+ self.colorbars['junctions'] = {'mappable': plt.cm.ScalarMappable(
692
+ norm=mpl.colors.Normalize(
693
+ vmin=self.junction_parameters.vmin,
694
+ vmax=self.junction_parameters.vmax), cmap=colormap),
695
+ 'label': label}
696
+
697
+ def color_links(
698
+ self, data: Optional[Union[ScadaData, np.ndarray]] = None,
699
+ parameter: str = 'flow_rate', statistic: str = 'mean',
700
+ pit: Optional[Union[int, Tuple[int, int]]] = None,
701
+ species: str = None,
702
+ colormap: str = 'coolwarm',
703
+ intervals: Optional[Union[int, List[Union[int, float]]]] = None,
704
+ conversion: Optional[dict] = None,
705
+ show_colorbar: bool = False,
706
+ use_sensor_data: bool = False) -> None:
707
+ """
708
+ Colors the links (pipes) in the water distribution network based on the
709
+ SCADA data and the specified parameters.
710
+
711
+ This method either takes or generates SCADA data, applies a statistic
712
+ to the chosen parameter, optionally groups the results and prepares the
713
+ results to be either displayed statically ot animated.
714
+
715
+ Parameters
716
+ ----------
717
+ data : :class:`~epyt_flow.simulation.scada.scada_data.ScadaData` or
718
+ `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, optional
719
+ The SCADA data object containing link data or a numpy array of the
720
+ shape links*timesteps. If `None`, a simulation is run to generate
721
+ SCADA data. Default is `None`.
722
+ parameter : `str`, optional
723
+ The link data to visualize. Options are 'flow_rate', 'velocity', or
724
+ 'status'. Default is 'flow_rate'.
725
+ statistic : `str`, optional
726
+ The statistic to calculate for the data. Can be 'mean', 'min',
727
+ 'max', or 'time_step'. Default is 'mean'.
728
+ pit : `int` or `tuple(int, int)`, optional
729
+ The point in time or range of time steps for the 'time_step'
730
+ statistic. If a tuple is provided, it should contain two integers
731
+ representing the start and end time steps. A tuple is necessary to
732
+ process the data for the :func:`~ScenarioVisualizer.show_animation`
733
+ method. Default is `None`.
734
+ species: `str`, optional
735
+ Key of species. Only necessary for parameter
736
+ 'bulk_species_concentration'.
737
+ colormap : `str`, optional
738
+ The colormap to use for visualizing link values. Default is
739
+ 'coolwarm'.
740
+ intervals : `int`, `list[int]`, `list[float]`, optional
741
+ If provided, the data will be grouped into intervals. It can be an
742
+ integer specifying the number of groups or a list of boundary
743
+ points. Default is `None`.
744
+ conversion : `dict`, optional
745
+ A dictionary of conversion parameters to convert SCADA data units.
746
+ Default is `None`.
747
+ show_colorbar : `bool`, optional
748
+ If `True`, a colorbar will be displayed on the plot to indicate the
749
+ range of values. Default is `False`.
750
+ use_sensor_data : `bool`, optional
751
+ If `True`, instead of using raw simulation data, the data recorded
752
+ by the corresponding sensors in the system is used for the
753
+ visualization. Note: Not all components may have a sensor attached
754
+ and sensors may be subject to sensor faults or noise.
755
+
756
+ Raises
757
+ ------
758
+ ValueError
759
+ If `parameter` is not a valid link data parameter or if `pit` is
760
+ incorrectly provided for the 'time_step' statistic.
761
+
762
+ """
763
+ sim_length = None
764
+
765
+ if data is not None:
766
+ self.scada_data = data
767
+ if not isinstance(self.scada_data, ScadaData):
768
+ sim_length = self.scada_data.shape[0]
769
+ elif not self.scada_data:
770
+ self.scada_data = self.__scenario.run_simulation()
771
+
772
+ if conversion:
773
+ self.scada_data = self.scada_data.convert_units(**conversion)
774
+
775
+ self.pipe_parameters.edge_cmap = mpl.colormaps[colormap]
776
+
777
+ if sim_length is None:
778
+ sim_length = self.scada_data.sensor_readings_time.shape[0]
779
+
780
+ if statistic == 'time_step' and isinstance(pit, tuple) and len(
781
+ pit) == 2 and all(isinstance(i, int) for i in pit):
782
+ rng = pit
783
+ if pit[1] == -1:
784
+ rng = (pit[0], sim_length)
785
+ for frame in range(*rng):
786
+ if frame >= sim_length:
787
+ break
788
+ self.pipe_parameters.add_frame(self.topology, 'edge_color',
789
+ self.scada_data, parameter,
790
+ statistic, frame, species,
791
+ intervals, use_sensor_data)
792
+ else:
793
+ self.pipe_parameters.add_frame(self.topology, 'edge_color',
794
+ self.scada_data, parameter,
795
+ statistic, pit, species, intervals,
796
+ use_sensor_data)
797
+
798
+ if hasattr(self.pipe_parameters, 'mask'):
799
+ self.masks['links'] = self.pipe_parameters.mask
800
+
801
+ if show_colorbar:
802
+ if statistic == 'time_step':
803
+ label = (str(parameter).capitalize().replace('_', ' ')
804
+ + ' at timestep ' + str(pit))
805
+ else:
806
+ label = str(statistic).capitalize() + ' ' + str(
807
+ parameter).replace('_', ' ')
808
+ self.colorbars['pipes'] = {'mappable': plt.cm.ScalarMappable(
809
+ norm=mpl.colors.Normalize(
810
+ vmin=self.pipe_parameters.edge_vmin,
811
+ vmax=self.pipe_parameters.edge_vmax), cmap=colormap),
812
+ 'label': label}
813
+
814
+ def color_pumps(
815
+ self, data: Optional[Union[ScadaData, np.ndarray]] = None,
816
+ parameter: str = 'efficiency', statistic: str = 'mean',
817
+ pit: Optional[Union[int, Tuple[int]]] = None,
818
+ intervals: Optional[Union[int, List[Union[int, float]]]] = None,
819
+ colormap: str = 'viridis', show_colorbar: bool = False,
820
+ use_sensor_data: bool = False) -> None:
821
+ """
822
+ Colors the pumps in the water distribution network based on SCADA data
823
+ and the specified parameters.
824
+
825
+ This method either takes or generates SCADA data, applies a statistic
826
+ to the chosen parameter, optionally groups the results and prepares the
827
+ results to be either displayed statically ot animated.
828
+
829
+ Parameters
830
+ ----------
831
+ data : :class:`~epyt_flow.simulation.scada.scada_data.ScadaData` or
832
+ `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, optional
833
+ The SCADA data object containing pump data or a numpy array of the
834
+ shape pumps*timesteps. If `None`, a simulation is run to generate
835
+ SCADA data. Default is `None`.
836
+ parameter : `str`, optional
837
+ The pump data to visualize. Must be 'efficiency',
838
+ 'energy_consumption', or 'state'. Default is 'efficiency'.
839
+ statistic : `str`, optional
840
+ The statistic to calculate for the data. Can be 'mean', 'min',
841
+ 'max', or 'time_step'. Default is 'mean'.
842
+ pit : `int`, `tuple(int, int)`, optional
843
+ The point in time or range of time steps for the 'time_step'
844
+ statistic. If a tuple is provided, it should contain two integers
845
+ representing the start and end time steps. A tuple is necessary to
846
+ process the data for the :meth:`~ScenarioVisualizer.show_animation`
847
+ method. Default is `None`.
848
+ intervals : `int`, `list[int]`, `list[float]`, optional
849
+ If provided, the data will be grouped into intervals. It can be an
850
+ integer specifying the number of groups or a list of boundary
851
+ points. Default is `None`.
852
+ colormap : `str`, optional
853
+ The colormap to use for visualizing pump values. Default is
854
+ 'viridis'.
855
+ show_colorbar : `bool`, optional
856
+ If `True`, a colorbar will be displayed on the plot to indicate the
857
+ range of pump values. Default is `False`.
858
+ use_sensor_data : `bool`, optional
859
+ If `True`, instead of using raw simulation data, the data recorded
860
+ by the corresponding sensors in the system is used for the
861
+ visualization. Note: Not all components may have a sensor attached
862
+ and sensors may be subject to sensor faults or noise.
863
+
864
+ Raises
865
+ ------
866
+ ValueError
867
+ If the `parameter` is not one of 'efficiency',
868
+ 'energy_consumption', or 'state', or if `pit` is not correctly
869
+ provided for the 'time_step' statistic.
870
+
871
+ """
872
+
873
+ self.pump_parameters.cmap = colormap
874
+
875
+ if data is not None:
876
+ self.scada_data = data
877
+ elif not self.scada_data:
878
+ self.scada_data = self.__scenario.run_simulation()
879
+
880
+ if parameter == 'efficiency':
881
+ if use_sensor_data:
882
+ values, self.masks[
883
+ 'pumps'] = self.scada_data.get_data_pumps_efficiency_as_node_features()
884
+ else:
885
+ values = self.scada_data.pumps_efficiency_data_raw
886
+ elif parameter == 'energy_consumption':
887
+ if use_sensor_data:
888
+ values, self.masks[
889
+ 'pumps'] = self.scada_data.get_data_pumps_energyconsumption_as_node_features()
890
+ else:
891
+ values = self.scada_data.pumps_energyconsumption_data_raw
892
+ elif parameter == 'state':
893
+ if use_sensor_data:
894
+ values, self.masks[
895
+ 'pumps'] = self.scada_data.get_data_pumps_state_as_node_features()
896
+ else:
897
+ values = self.scada_data.pumps_state_data_raw
898
+ elif parameter == 'custom_data':
899
+ values = self.scada_data
900
+ else:
901
+ raise ValueError(
902
+ 'Parameter must be efficiency, energy_consumption, state or custom_data')
903
+
904
+ if statistic == 'time_step' and isinstance(pit, tuple) and len(
905
+ pit) == 2 and all(isinstance(i, int) for i in pit):
906
+ rng = pit
907
+ if pit[1] == -1:
908
+ rng = (pit[0], values.shape[0])
909
+ for frame in range(*rng):
910
+ if frame > values.shape[0] - 1:
911
+ break
912
+ self.pump_parameters.add_frame(statistic, values, frame,
913
+ intervals)
914
+ else:
915
+ self.pump_parameters.add_frame(statistic, values, pit, intervals)
916
+
917
+ if show_colorbar:
918
+ if statistic == 'time_step':
919
+ label = str(parameter).capitalize().replace(
920
+ '_', ' ') + ' at timestep ' + str(pit)
921
+ else:
922
+ label = str(statistic).capitalize() + ' ' + str(
923
+ parameter).replace('_', ' ')
924
+ self.colorbars['pumps'] = {'mappable': plt.cm.ScalarMappable(
925
+ norm=mpl.colors.Normalize(vmin=self.pump_parameters.vmin,
926
+ vmax=self.pump_parameters.vmax),
927
+ cmap=colormap), 'label': label}
928
+
929
+ def color_tanks(
930
+ self, data: Optional[Union[ScadaData, np.ndarray]] = None,
931
+ statistic: str = 'mean',
932
+ pit: Optional[Union[int, Tuple[int, int]]] = None,
933
+ intervals: Optional[Union[int, List[Union[int, float]]]] = None,
934
+ colormap: str = 'viridis', show_colorbar: bool = False,
935
+ use_sensor_data: bool = False) -> None:
936
+ """
937
+ Colors the tanks in the water distribution network based on the SCADA
938
+ tank volume data and the specified statistic.
939
+
940
+ This method either takes or generates SCADA data, applies a statistic
941
+ to the tank volume data, optionally groups the results and prepares
942
+ them to be either displayed statically ot animated.
943
+
944
+ Parameters
945
+ ----------
946
+ data : :class:`~epyt_flow.simulation.scada.scada_data.ScadaData` or
947
+ `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, optional
948
+ The SCADA data object containing tank data or a numpy array of the
949
+ shape tanks*timesteps. If `None`, a simulation is run to generate
950
+ SCADA data. Default is `None`.
951
+ statistic : `str`, optional
952
+ The statistic to calculate for the data. Can be 'mean', 'min',
953
+ 'max', or 'time_step'. Default is 'mean'.
954
+ pit : `int`, `tuple(int, int)`, optional
955
+ The point in time or range of time steps for the 'time_step'
956
+ statistic. If a tuple is provided, it should contain two integers
957
+ representing the start and end time steps. A tuple is necessary to
958
+ process the data for the :meth:`~ScenarioVisualizer.show_animation`
959
+ method. Default is `None`.
960
+ intervals : `int`, `list[int]`, `list[float]`, optional
961
+ If provided, the data will be grouped into intervals. It can be an
962
+ integer specifying the number of groups or a list of boundary
963
+ points. Default is `None`.
964
+ colormap : `str`, optional
965
+ The colormap to use for visualizing tank values. Default is
966
+ 'viridis'.
967
+ show_colorbar : `bool`, optional
968
+ If `True`, a colorbar will be displayed on the plot to indicate the
969
+ range of tank volume values. Default is `False`.
970
+ use_sensor_data : `bool`, optional
971
+ If `True`, instead of using raw simulation data, the data recorded
972
+ by the corresponding sensors in the system is used for the
973
+ visualization. Note: Not all components may have a sensor attached
974
+ and sensors may be subject to sensor faults or noise.
975
+
976
+ Raises
977
+ ------
978
+ ValueError
979
+ If `pit` is not correctly provided for the 'time_step' statistic.
980
+
981
+ """
982
+ self.tank_parameters.cmap = colormap
983
+
984
+ if data is not None:
985
+ self.scada_data = data
986
+ elif not self.scada_data:
987
+ self.scada_data = self.__scenario.run_simulation()
988
+
989
+ if isinstance(self.scada_data, ScadaData):
990
+ if use_sensor_data:
991
+ values, self.masks[
992
+ 'tanks'] = self.scada_data.get_data_tanks_water_volume_as_node_features()
993
+ else:
994
+ values = self.scada_data.tanks_volume_data_raw
995
+ parameter = 'tank volume'
996
+ else:
997
+ values = self.scada_data
998
+ parameter = 'custom data'
999
+
1000
+ if statistic == 'time_step' and isinstance(pit, tuple) and len(
1001
+ pit) == 2 and all(isinstance(i, int) for i in pit):
1002
+ rng = pit
1003
+ if pit[1] == -1:
1004
+ rng = (pit[0], values.shape[0])
1005
+ for frame in range(*rng):
1006
+ if frame > values.shape[0] - 1:
1007
+ break
1008
+ self.tank_parameters.add_frame(statistic, values, frame,
1009
+ intervals)
1010
+ else:
1011
+ self.tank_parameters.add_frame(statistic, values, pit, intervals)
1012
+
1013
+ if show_colorbar:
1014
+ if statistic == 'time_step':
1015
+ label = parameter.capitalize() + ' at timestep ' + str(pit)
1016
+ else:
1017
+ label = str(statistic).capitalize() + ' ' + parameter
1018
+ self.colorbars['tanks'] = {'mappable': plt.cm.ScalarMappable(
1019
+ norm=mpl.colors.Normalize(vmin=self.tank_parameters.vmin,
1020
+ vmax=self.tank_parameters.vmin),
1021
+ cmap=colormap), 'label': label}
1022
+
1023
+ def color_valves(
1024
+ self, data: Optional[Union[ScadaData, np.ndarray]] = None,
1025
+ statistic: str = 'mean',
1026
+ pit: Optional[Union[int, Tuple[int, int]]] = None,
1027
+ intervals: Optional[Union[int, List[Union[int, float]]]] = None,
1028
+ colormap: str = 'viridis', show_colorbar: bool = False,
1029
+ use_sensor_data: bool = False) -> None:
1030
+ """
1031
+ Colors the valves in the water distribution network based on SCADA
1032
+ valve state data and the specified statistic.
1033
+
1034
+ This method either takes or generates SCADA data, applies a statistic
1035
+ to the valve state data, optionally groups the results and prepares
1036
+ them to be either displayed statically ot animated.
1037
+
1038
+ Parameters
1039
+ ----------
1040
+ data : :class:`~epyt_flow.simulation.scada.scada_data.ScadaData` or
1041
+ `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, optional
1042
+ The SCADA data object containing valve data or a numpy array of the
1043
+ shape valves*timesteps. If `None`, a simulation is run to generate
1044
+ SCADA data. Default is `None`.
1045
+ statistic : `str`, optional
1046
+ The statistic to calculate for the data. Can be 'mean', 'min',
1047
+ 'max', or 'time_step'. Default is 'mean'.
1048
+ pit : `int`, `tuple(int)`, optional
1049
+ The point in time or range of time steps for the 'time_step'
1050
+ statistic. If a tuple is provided, it should contain two integers
1051
+ representing the start and end time steps. A tuple is necessary to
1052
+ process the data for the :meth:`~ScenarioVisualizer.show_animation`
1053
+ method. Default is `None`.
1054
+ intervals : `int`, `list[int]`, `list[float]`, optional
1055
+ If provided, the data will be grouped into intervals. It can be an
1056
+ integer specifying the number of groups or a list of
1057
+ boundary points. Default is `None`.
1058
+ colormap : `str`, optional
1059
+ The colormap to use for visualizing valve state values. Default is
1060
+ 'viridis'.
1061
+ show_colorbar : `bool`, optional
1062
+ If `True`, a colorbar will be displayed on the plot to indicate the
1063
+ range of valve state values. Default is `False`.
1064
+ use_sensor_data : `bool`, optional
1065
+ If `True`, instead of using raw simulation data, the data recorded
1066
+ by the corresponding sensors in the system is used for the
1067
+ visualization. Note: Not all components may have a sensor attached
1068
+ and sensors may be subject to sensor faults or noise.
1069
+
1070
+ Raises
1071
+ ------
1072
+ ValueError
1073
+ If `pit` is not correctly provided for the 'time_step' statistic.
1074
+
1075
+ """
1076
+
1077
+ self.valve_parameters.cmap = colormap
1078
+
1079
+ if data is not None:
1080
+ self.scada_data = data
1081
+ elif not self.scada_data:
1082
+ self.scada_data = self.__scenario.run_simulation()
1083
+
1084
+ if isinstance(self.scada_data, ScadaData):
1085
+ if use_sensor_data:
1086
+ values, self.masks[
1087
+ 'valves'] = self.scada_data.get_data_valves_state_as_node_features()
1088
+ else:
1089
+ values = self.scada_data.valves_state_data_raw
1090
+ parameter = 'valve state'
1091
+ else:
1092
+ values = self.scada_data
1093
+ parameter = 'custom data'
1094
+
1095
+ if statistic == 'time_step' and isinstance(pit, tuple) and len(
1096
+ pit) == 2 and all(isinstance(i, int) for i in pit):
1097
+ rng = pit
1098
+ if pit[1] == -1:
1099
+ rng = (pit[0], values.shape[0])
1100
+ for frame in range(*rng):
1101
+ if frame > values.shape[0] - 1:
1102
+ break
1103
+ self.valve_parameters.add_frame(statistic, values, frame,
1104
+ intervals)
1105
+ else:
1106
+ self.valve_parameters.add_frame(statistic, values, pit,
1107
+ intervals)
1108
+
1109
+ if show_colorbar:
1110
+ if statistic == 'time_step':
1111
+ label = parameter.capitalize() + ' at timestep ' + str(pit)
1112
+ else:
1113
+ label = str(statistic).capitalize() + ' ' + 'valve state'
1114
+ self.colorbars['valves'] = {'mappable': plt.cm.ScalarMappable(
1115
+ norm=mpl.colors.Normalize(vmin=self.valve_parameters.vmin,
1116
+ vmax=self.valve_parameters.vmax),
1117
+ cmap=colormap), 'label': label}
1118
+
1119
+ def resize_links(
1120
+ self, data: Optional[Union[ScadaData, np.ndarray]] = None,
1121
+ parameter: str = 'flow_rate', statistic: str = 'mean',
1122
+ line_widths: Tuple[int, int] = (1, 2),
1123
+ pit: Optional[Union[int, Tuple[int, int]]] = None,
1124
+ species: str = None,
1125
+ intervals: Optional[Union[int, List[Union[int, float]]]] = None,
1126
+ conversion: Optional[dict] = None,
1127
+ use_sensor_data: bool = False) -> None:
1128
+ """
1129
+ Resizes the width of the links (pipes) in the water distribution
1130
+ network based on SCADA data and the specified parameters.
1131
+
1132
+ This method either takes or generates SCADA data, applies a statistic,
1133
+ optionally groups the results and prepares them to be either displayed
1134
+ statically ot animated as link width.
1135
+
1136
+ Parameters
1137
+ ----------
1138
+ data : :class:`~epyt_flow.simulation.scada.scada_data.ScadaData` or
1139
+ `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, optional
1140
+ The SCADA data object containing link data or a numpy array of the
1141
+ shape links*timesteps. If `None`, a simulation is run to generate
1142
+ SCADA data. Default is `None`.
1143
+ parameter : `str`, optional
1144
+ The data used to resize to. Default is 'flow_rate'.
1145
+ statistic : `str`, optional
1146
+ The statistic to calculate for the data. Can be 'mean', 'min',
1147
+ 'max', or 'time_step'. Default is 'mean'.
1148
+ line_widths : `tuple(int, int)`, optional
1149
+ A tuple specifying the range of line widths to use when resizing
1150
+ links based on the data. Default is (1, 2).
1151
+ pit : `int` or `tuple(int, int)`, optional
1152
+ The point in time or range of time steps for the 'time_step'
1153
+ statistic. If a tuple is provided, it should contain two integers
1154
+ representing the start and end time steps. A tuple is necessary to
1155
+ process the data for the :meth:`~ScenarioVisualizer.show_animation`
1156
+ method. Default is `None`.
1157
+ species: `str`, optional
1158
+ Key of species. Only necessary for parameter
1159
+ 'bulk_species_concentration'.
1160
+ intervals : `int` or `list[int]` or `list[float]`, optional
1161
+ If provided, the data will be grouped into intervals. It can be an
1162
+ integer specifying the number of groups or a list of boundary
1163
+ points. Default is `None`.
1164
+ conversion : `dict`, optional
1165
+ A dictionary of conversion parameters to convert SCADA data units.
1166
+ Default is `None`.
1167
+ use_sensor_data : `bool`, optional
1168
+ If `True`, instead of using raw simulation data, the data recorded
1169
+ by the corresponding sensors in the system is used for the
1170
+ visualization. Note: Not all components may have a sensor attached
1171
+ and sensors may be subject to sensor faults or noise.
1172
+ """
1173
+ sim_length = None
1174
+
1175
+ if data is not None:
1176
+ self.scada_data = data
1177
+ if not isinstance(self.scada_data, ScadaData):
1178
+ sim_length = self.scada_data.shape[0]
1179
+ elif not self.scada_data:
1180
+ self.scada_data = self.__scenario.run_simulation()
1181
+
1182
+ if conversion:
1183
+ self.scada_data = self.scada_data.convert_units(**conversion)
1184
+
1185
+ if sim_length is None:
1186
+ sim_length = self.scada_data.sensor_readings_time.shape[0]
1187
+
1188
+ if statistic == 'time_step' and isinstance(pit, tuple) and len(
1189
+ pit) == 2 and all(isinstance(i, int) for i in pit):
1190
+ rng = pit
1191
+ if pit[1] == -1:
1192
+ rng = (pit[0], sim_length)
1193
+ for frame in range(*rng):
1194
+ if frame >= sim_length:
1195
+ break
1196
+ self.pipe_parameters.add_frame(self.topology, 'edge_width',
1197
+ self.scada_data, parameter,
1198
+ statistic, frame, species,
1199
+ intervals, use_sensor_data)
1200
+ else:
1201
+ self.pipe_parameters.add_frame(self.topology, 'edge_width',
1202
+ self.scada_data, parameter,
1203
+ statistic, pit, species, intervals,
1204
+ use_sensor_data)
1205
+ self.pipe_parameters.rescale_widths(line_widths)
1206
+
1207
+ def hide_nodes(self) -> None:
1208
+ """
1209
+ Hides all nodes (junctions) in the water distribution network
1210
+ visualization.
1211
+
1212
+ This method clears the node list from the `junction_parameters`
1213
+ class, effectively removing all nodes from view in the current
1214
+ visualization.
1215
+ """
1216
+ self.junction_parameters.nodelist = []
1217
+
1218
+ def highlight_sensor_config(self) -> None:
1219
+ """
1220
+ Highlights nodes and links that have sensors in the sensor_config in
1221
+ the water distribution network visualization.
1222
+
1223
+ This method identifies nodes and links equipped with different types of
1224
+ sensors from the
1225
+ :class:`~epyt_flow.simulation.sensor_config.SensorConfig` and
1226
+ updates their visual appearance. Nodes with sensors are highlighted
1227
+ with an orange border, while links with sensors are displayed with a
1228
+ dashed line style.
1229
+ """
1230
+ highlighted_nodes, highlighted_links = self._get_sensor_config_nodes_and_links()
1231
+
1232
+ node_edges = [
1233
+ (17, 163, 252) if node in highlighted_nodes else (0, 0, 0) for node
1234
+ in self.topology]
1235
+ pipe_style = ['dashed' if link[0] in highlighted_links else 'solid' for
1236
+ link in self.topology.get_all_links()]
1237
+
1238
+ self.junction_parameters.add_attributes(
1239
+ {'linewidths': 1, 'edgecolors': node_edges})
1240
+ self.pipe_parameters.add_attributes({'style': pipe_style})