epyt-flow 0.9.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.
Files changed (35) hide show
  1. epyt_flow/VERSION +1 -1
  2. epyt_flow/data/networks.py +27 -14
  3. epyt_flow/gym/control_gyms.py +8 -0
  4. epyt_flow/gym/scenario_control_env.py +17 -4
  5. epyt_flow/metrics.py +5 -0
  6. epyt_flow/models/event_detector.py +5 -0
  7. epyt_flow/models/sensor_interpolation_detector.py +5 -0
  8. epyt_flow/serialization.py +5 -0
  9. epyt_flow/simulation/__init__.py +0 -1
  10. epyt_flow/simulation/events/actuator_events.py +7 -1
  11. epyt_flow/simulation/events/sensor_reading_attack.py +16 -3
  12. epyt_flow/simulation/events/sensor_reading_event.py +18 -3
  13. epyt_flow/simulation/scada/__init__.py +3 -1
  14. epyt_flow/simulation/scada/advanced_control.py +6 -2
  15. epyt_flow/simulation/scada/complex_control.py +625 -0
  16. epyt_flow/simulation/scada/custom_control.py +134 -0
  17. epyt_flow/simulation/scada/scada_data.py +547 -8
  18. epyt_flow/simulation/scada/simple_control.py +317 -0
  19. epyt_flow/simulation/scenario_config.py +87 -26
  20. epyt_flow/simulation/scenario_simulator.py +865 -51
  21. epyt_flow/simulation/sensor_config.py +34 -2
  22. epyt_flow/topology.py +16 -0
  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 +15 -1
  28. epyt_flow/visualization/__init__.py +2 -0
  29. epyt_flow/{simulation → visualization}/scenario_visualizer.py +429 -586
  30. epyt_flow/visualization/visualization_utils.py +611 -0
  31. {epyt_flow-0.9.0.dist-info → epyt_flow-0.11.0.dist-info}/LICENSE +1 -1
  32. {epyt_flow-0.9.0.dist-info → epyt_flow-0.11.0.dist-info}/METADATA +18 -6
  33. {epyt_flow-0.9.0.dist-info → epyt_flow-0.11.0.dist-info}/RECORD +35 -30
  34. {epyt_flow-0.9.0.dist-info → epyt_flow-0.11.0.dist-info}/WHEEL +1 -1
  35. {epyt_flow-0.9.0.dist-info → epyt_flow-0.11.0.dist-info}/top_level.txt +0 -0
@@ -22,6 +22,7 @@ from ..sensor_config import SensorConfig, is_flowunit_simetric, massunit_to_str,
22
22
  from ..events import SensorFault, SensorReadingAttack, SensorReadingEvent
23
23
  from ...uncertainty import SensorNoise
24
24
  from ...serialization import serializable, Serializable, SCADA_DATA_ID
25
+ from ...topology import NetworkTopology
25
26
  from ...utils import plot_timeseries_data
26
27
 
27
28
 
@@ -127,6 +128,8 @@ class ScadaData(Serializable):
127
128
  will be stored -- this usually leads to a significant reduction in memory consumption.
128
129
 
129
130
  The default is False.
131
+ network_topo : :class:`~epyt_flow.topology.NetworkTopology`
132
+ Topology of the water distribution network.
130
133
  """
131
134
  def __init__(self, sensor_config: SensorConfig, sensor_readings_time: np.ndarray,
132
135
  pressure_data_raw: Union[np.ndarray, bsr_array] = None,
@@ -148,7 +151,17 @@ class ScadaData(Serializable):
148
151
  sensor_reading_attacks: list[SensorReadingAttack] = [],
149
152
  sensor_reading_events: list[SensorReadingEvent] = [],
150
153
  sensor_noise: SensorNoise = None, frozen_sensor_config: bool = False,
154
+ network_topo: NetworkTopology = None,
151
155
  **kwds):
156
+ if network_topo is not None:
157
+ if not isinstance(network_topo, NetworkTopology):
158
+ raise TypeError("'network_topo' must be an instance of " +
159
+ "'epyt_flow.topology.NetworkTopology' but not " +
160
+ f"of '{type(network_topo)}'")
161
+ else:
162
+ warnings.warn("You are loading a SCADA data instance that was created with an " +
163
+ "outdated version of EPyT-Flow. Future releases will require " +
164
+ "'network_topo' != None. Please upgrade!")
152
165
  if not isinstance(sensor_config, SensorConfig):
153
166
  raise TypeError("'sensor_config' must be an instance of " +
154
167
  "'epyt_flow.simulation.SensorConfig' but not of " +
@@ -345,6 +358,7 @@ class ScadaData(Serializable):
345
358
  if pumps_efficiency_data_raw.shape[0] != n_time_steps:
346
359
  __raise_shape_mismatch("pumps_efficiency_data_raw")
347
360
 
361
+ self.__network_topo = network_topo
348
362
  self.__sensor_config = sensor_config
349
363
  self.__sensor_noise = sensor_noise
350
364
  self.__sensor_reading_events = sensor_faults + sensor_reading_attacks + \
@@ -1030,7 +1044,8 @@ class ScadaData(Serializable):
1030
1044
  surface_species_mass_unit=new_surface_species_mass_unit,
1031
1045
  surface_species_area_unit=new_surface_species_area_unit)
1032
1046
 
1033
- return ScadaData(sensor_config=sensor_config,
1047
+ return ScadaData(network_topo=self.network_topo,
1048
+ sensor_config=sensor_config,
1034
1049
  sensor_readings_time=self.sensor_readings_time,
1035
1050
  sensor_reading_events=self.sensor_reading_events,
1036
1051
  sensor_noise=self.sensor_noise,
@@ -1049,6 +1064,18 @@ class ScadaData(Serializable):
1049
1064
  bulk_species_link_concentration_raw=bulk_species_link_concentrations,
1050
1065
  surface_species_concentration_raw=surface_species_concentrations)
1051
1066
 
1067
+ @property
1068
+ def network_topo(self) -> NetworkTopology:
1069
+ """
1070
+ Returns the topology of the water distribution network.
1071
+
1072
+ Returns
1073
+ -------
1074
+ :class:`epyt_flow.topology.NetworkTopology`
1075
+ Topology of the network.
1076
+ """
1077
+ return deepcopy(self.__network_topo)
1078
+
1052
1079
  @property
1053
1080
  def frozen_sensor_config(self) -> bool:
1054
1081
  """
@@ -1314,7 +1341,7 @@ class ScadaData(Serializable):
1314
1341
  """
1315
1342
  return deepcopy(self.__pumps_efficiency_data_raw)
1316
1343
 
1317
- def __map_sensor_to_idx(self, sensor_type: int, sensor_id: str) -> int:
1344
+ def __map_sensor_to_idx(self, sensor_type: int, sensor_id: Union[str, tuple[str, str]]) -> int:
1318
1345
  if sensor_type == SENSOR_TYPE_NODE_PRESSURE:
1319
1346
  return self.__sensor_config.get_index_of_reading(pressure_sensor=sensor_id)
1320
1347
  elif sensor_type == SENSOR_TYPE_NODE_QUALITY:
@@ -1351,13 +1378,31 @@ class ScadaData(Serializable):
1351
1378
 
1352
1379
  self.__apply_sensor_reading_events = []
1353
1380
  for sensor_event in self.__sensor_reading_events:
1354
- idx = self.__map_sensor_to_idx(sensor_event.sensor_type, sensor_event.sensor_id)
1355
- self.__apply_sensor_reading_events.append((idx, sensor_event.apply))
1381
+ sensor_id = sensor_event.sensor_id
1382
+ if sensor_event.sensor_type == SENSOR_TYPE_NODE_BULK_SPECIES:
1383
+ for species_id, node_sensors_id in self.__sensor_config.bulk_species_node_sensors.items():
1384
+ if sensor_id in node_sensors_id:
1385
+ idx = self.__map_sensor_to_idx(sensor_event.sensor_type, (species_id, sensor_id))
1386
+ self.__apply_sensor_reading_events.append((idx, sensor_event.apply))
1387
+ elif sensor_event.sensor_type == SENSOR_TYPE_LINK_BULK_SPECIES:
1388
+ for species_id, link_sensors_id in self.__sensor_config.bulk_species_link_sensors.items():
1389
+ if sensor_id in link_sensors_id:
1390
+ idx = self.__map_sensor_to_idx(sensor_event.sensor_type, (species_id, sensor_id))
1391
+ self.__apply_sensor_reading_events.append((idx, sensor_event.apply))
1392
+ elif sensor_event.sensor_type == SENSOR_TYPE_SURFACE_SPECIES:
1393
+ for species_id, link_sensors_id in self.__sensor_config.surface_species_sensors.items():
1394
+ if sensor_id in link_sensors_id:
1395
+ idx = self.__map_sensor_to_idx(sensor_event.sensor_type, (species_id, sensor_id))
1396
+ self.__apply_sensor_reading_events.append((idx, sensor_event.apply))
1397
+ else:
1398
+ idx = self.__map_sensor_to_idx(sensor_event.sensor_type, sensor_event.sensor_id)
1399
+ self.__apply_sensor_reading_events.append((idx, sensor_event.apply))
1356
1400
 
1357
1401
  self.__sensor_readings = None
1358
1402
 
1359
1403
  def get_attributes(self) -> dict:
1360
- attr = {"sensor_config": self.__sensor_config,
1404
+ attr = {"network_topo": self.__network_topo,
1405
+ "sensor_config": self.__sensor_config,
1361
1406
  "frozen_sensor_config": self.__frozen_sensor_config,
1362
1407
  "sensor_noise": self.__sensor_noise,
1363
1408
  "sensor_reading_events": self.__sensor_reading_events,
@@ -1508,7 +1553,8 @@ class ScadaData(Serializable):
1508
1553
  raise TypeError(f"Can not compare 'ScadaData' instance to '{type(other)}' instance")
1509
1554
 
1510
1555
  try:
1511
- return self.__sensor_config == other.sensor_config \
1556
+ return self.__network_topo == other.network_topo \
1557
+ and self.__sensor_config == other.sensor_config \
1512
1558
  and self.__frozen_sensor_config == other.frozen_sensor_config \
1513
1559
  and self.__sensor_noise == other.sensor_noise \
1514
1560
  and all(a == b for a, b in
@@ -1536,7 +1582,7 @@ class ScadaData(Serializable):
1536
1582
  return False
1537
1583
 
1538
1584
  def __str__(self) -> str:
1539
- return f"sensor_config: {self.__sensor_config} " + \
1585
+ return f"network_topo: {self.__network_topo} sensor_config: {self.__sensor_config} " + \
1540
1586
  f"frozen_sensor_config: {self.__frozen_sensor_config} " + \
1541
1587
  f"sensor_noise: {self.__sensor_noise} " + \
1542
1588
  f"sensor_reading_events: {self.__sensor_reading_events} " + \
@@ -1734,7 +1780,7 @@ class ScadaData(Serializable):
1734
1780
  if self.__pumps_efficiency_data_raw is not None:
1735
1781
  pumps_efficiency_data_raw = self.__pumps_efficiency_data_raw[start_idx:end_idx, :]
1736
1782
 
1737
- return ScadaData(sensor_config=self.sensor_config,
1783
+ return ScadaData(network_topo=self.network_topo, sensor_config=self.sensor_config,
1738
1784
  sensor_readings_time=self.sensor_readings_time[start_idx:end_idx],
1739
1785
  frozen_sensor_config=self.frozen_sensor_config,
1740
1786
  sensor_noise=self.sensor_noise,
@@ -1772,6 +1818,8 @@ class ScadaData(Serializable):
1772
1818
  if not isinstance(other, ScadaData):
1773
1819
  raise TypeError("'other' must be an instance of 'ScadaData' " +
1774
1820
  f"but not of '{type(other)}'")
1821
+ if self.__network_topo != other.network_topo:
1822
+ raise ValueError("Network topology must be the same in both instances")
1775
1823
  if self.__frozen_sensor_config != other.frozen_sensor_config:
1776
1824
  raise ValueError("Sensor configurations of both instances must be " +
1777
1825
  "either frozen or not frozen")
@@ -1874,6 +1922,8 @@ class ScadaData(Serializable):
1874
1922
  """
1875
1923
  if not isinstance(other, ScadaData):
1876
1924
  raise TypeError(f"'other' must be an instance of 'ScadaData' but not of {type(other)}")
1925
+ if self.__network_topo != other.network_topo:
1926
+ raise ValueError("Network topology must be the same")
1877
1927
  if self.__sensor_config != other.sensor_config:
1878
1928
  raise ValueError("Sensor configurations must be the same!")
1879
1929
  if self.__frozen_sensor_config != other.frozen_sensor_config:
@@ -1950,11 +2000,91 @@ class ScadaData(Serializable):
1950
2000
  (self.__pumps_efficiency_data_raw, other.pumps_efficiency_data_raw),
1951
2001
  axis=0)
1952
2002
 
2003
+ def topo_adj_matrix(self) -> bsr_array:
2004
+ """
2005
+ Returns the adjacency matrix of the network.
2006
+
2007
+ Nodes are ordered according to EPANET.
2008
+
2009
+ Returns
2010
+ -------
2011
+ `scipy.bsr_array <https://docs.scipy.org/doc/scipy/reference/generated/scipy.sparse.bsr_array.html>`_
2012
+ Adjacency matrix as a sparse array of shape [num_nodes, num_nodes].
2013
+ """
2014
+ return self.__network_topo.get_adj_matrix()
2015
+
2016
+ def map_link_id_to_edge_idx(self, link_id: str) -> tuple[int, int]:
2017
+ """
2018
+ Maps a given link to the corresponding two indices in the edge indices as computed by
2019
+ :func:`epyt_flow.simulation.scada.scada_data.ScadaData.topo_edge_indices`.
2020
+
2021
+ Returns
2022
+ -------
2023
+ `tuple[int, int]`
2024
+ Indices.
2025
+ """
2026
+ if not isinstance(link_id, str):
2027
+ raise TypeError(f"'link_id' must be an instance of 'str' but not of '{type(link_id)}'")
2028
+ if link_id not in self.__sensor_config.links:
2029
+ raise ValueError(f"Unknown link '{link_id}'")
2030
+
2031
+ idx = 0
2032
+ for l_id, [node_a_id, node_b_id] in self.__network_topo.get_all_links():
2033
+ if l_id == link_id:
2034
+ return (idx, idx+1)
2035
+
2036
+ idx += 2
2037
+
2038
+ def get_topo_edge_indices(self) -> np.ndarray:
2039
+ """
2040
+ Returns the edge indices -- i.e. a 2 dimensional array where the first dimension denotes
2041
+ the source node indices and the second dimension denotes the target node indices
2042
+ for all links in the network.
2043
+ Nodes are ordered according to EPANET.
2044
+
2045
+ Note that the network is consideres as a directed graph -- i.e. one link corresponds to
2046
+ two edges in opposite directions!
2047
+
2048
+ Returns
2049
+ -------
2050
+ `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
2051
+ Edge indices of shape [2, num_links * 2].
2052
+ """
2053
+ edge_indices = [[], []]
2054
+
2055
+ nodes_id = self.__network_topo.get_all_nodes()
2056
+ links = self.__network_topo.get_all_links()
2057
+
2058
+ for _, [node_a_id, node_b_id] in self.__network_topo.get_all_links():
2059
+ node_a_idx = nodes_id.index(node_a_id)
2060
+ node_b_idx = nodes_id.index(node_b_id)
2061
+
2062
+ edge_indices[0] += [node_a_idx, node_b_idx]
2063
+ edge_indices[1] += [node_b_idx, node_a_idx]
2064
+
2065
+ return np.array(edge_indices)
2066
+
1953
2067
  def get_data(self) -> np.ndarray:
1954
2068
  """
1955
2069
  Computes the final sensor readings -- note that those might be subject to
1956
2070
  given sensor faults and sensor noise/uncertainty.
1957
2071
 
2072
+ Columns (i.e. sensor readings) are ordered as follows:
2073
+
2074
+ 1. Pressures
2075
+ 2. Flows
2076
+ 3. Demands
2077
+ 4. Nodes quality
2078
+ 5. Links quality
2079
+ 6. Valve state
2080
+ 7. Pumps state
2081
+ 8. Pumps efficiency
2082
+ 9. Pumps energy consumption
2083
+ 10. Tanks volume
2084
+ 11. Surface species concentrations
2085
+ 12. Bulk species nodes concentrations
2086
+ 13. Bulk species links concentrations
2087
+
1958
2088
  Returns
1959
2089
  -------
1960
2090
  `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
@@ -2033,6 +2163,116 @@ class ScadaData(Serializable):
2033
2163
 
2034
2164
  return sensor_readings
2035
2165
 
2166
+ def get_data_node_features(self, default_missing_value: float = 0.
2167
+ ) -> tuple[np.ndarray, np.ndarray]:
2168
+ """
2169
+ Returns the sensor readings as node features together with a boolean mask indicating the
2170
+ presence of a sensor -- i.e. pressure, demand, quality, bulk species concentration
2171
+ at each node.
2172
+
2173
+ Note that only quantities with at least one sensor are considered.
2174
+
2175
+ Parameters
2176
+ ----------
2177
+ default_missing_value : `float`, optional
2178
+ Default value (i.e. missing value) for nodes where no sensor is installed.
2179
+
2180
+ The default is 0.
2181
+
2182
+ Returns
2183
+ -------
2184
+ tuple[`numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_]
2185
+ Node features of shape [num_nodes, num_time_steps, num_node_features], and
2186
+ mask of shape [num_nodes, num_node_features].
2187
+ """
2188
+ node_features = []
2189
+ node_features_mask = []
2190
+
2191
+ if len(self.__sensor_config.pressure_sensors) != 0:
2192
+ features, node_mask = self.get_data_pressures_as_node_features(default_missing_value)
2193
+ features = features.T
2194
+ features = features.reshape(features.shape[0], features.shape[1], 1)
2195
+ node_features.append(features)
2196
+ node_features_mask.append(node_mask.reshape(-1, 1))
2197
+
2198
+ if len(self.__sensor_config.demand_sensors) != 0:
2199
+ features, node_mask = self.get_data_demands_as_node_features(default_missing_value)
2200
+ features = features.T
2201
+ features = features.reshape(features.shape[0], features.shape[1], 1)
2202
+ node_features.append(features)
2203
+ node_features_mask.append(node_mask.reshape(-1, 1))
2204
+
2205
+ if len(self.__sensor_config.quality_node_sensors) != 0:
2206
+ features, node_mask = self.get_data_nodes_quality_as_node_features(default_missing_value)
2207
+ features = features.T
2208
+ features = features.reshape(features.shape[0], features.shape[1], 1)
2209
+ node_features.append(features)
2210
+ node_features_mask.append(node_mask.reshape(-1, 1))
2211
+
2212
+ if len(self.__sensor_config.bulk_species_node_sensors) != 0:
2213
+ features, node_mask = self.\
2214
+ get_data_bulk_species_concentrations_as_node_features(default_missing_value)
2215
+ features = np.swapaxes(features, 0, 1)
2216
+ node_features.append(features)
2217
+ node_features_mask.append(node_mask)
2218
+
2219
+ return np.concatenate(node_features, axis=2), np.concatenate(node_features_mask, axis=1)
2220
+
2221
+ def get_data_edge_features(self, default_missing_value: float = 0.
2222
+ ) -> tuple[np.ndarray, np.ndarray]:
2223
+ """
2224
+ Returns the sensor readings as edge features together with a boolean mask indicating the
2225
+ presence of a sensor -- i.e. flow, quality, surface species concentration,
2226
+ bulk species concentration at each link.
2227
+
2228
+ Note that only quantities with at least one sensor are considered.
2229
+
2230
+ Parameters
2231
+ ----------
2232
+ default_missing_value : `float`, optional
2233
+ Default value (i.e. missing value) for links where no sensor is installed.
2234
+
2235
+ The default is 0.
2236
+
2237
+ Returns
2238
+ -------
2239
+ tuple[`numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_]
2240
+ Edge features of shape [num_links, num_time_steps, num_edge_features] and
2241
+ mask of shape [num_links, num_edge_features].
2242
+ """
2243
+ edge_features = []
2244
+ edge_features_mask = []
2245
+
2246
+ if len(self.__sensor_config.flow_sensors) != 0:
2247
+ features, link_mask = self.get_data_flows_as_edge_features(default_missing_value)
2248
+ features = features.T
2249
+ features = features.reshape(features.shape[0], features.shape[1], 1)
2250
+ edge_features.append(features)
2251
+ edge_features_mask.append(link_mask.reshape(-1, 1))
2252
+
2253
+ if len(self.__sensor_config.quality_link_sensors) != 0:
2254
+ features, link_mask = self.get_data_links_quality_as_edge_features(default_missing_value)
2255
+ features = features.T
2256
+ features = features.reshape(features.shape[0], features.shape[1], 1)
2257
+ edge_features.append(features)
2258
+ edge_features_mask.append(link_mask.reshape(-1, 1))
2259
+
2260
+ if len(self.__sensor_config.surface_species_sensors) != 0:
2261
+ features, link_mask = self.\
2262
+ get_data_surface_species_concentrations_as_edge_features(default_missing_value)
2263
+ features = np.swapaxes(features, 0, 1)
2264
+ edge_features.append(features)
2265
+ edge_features_mask.append(link_mask)
2266
+
2267
+ if len(self.__sensor_config.bulk_species_link_sensors) != 0:
2268
+ features, link_mask = self.\
2269
+ get_data_bulk_species_concentrations_as_edge_features(default_missing_value)
2270
+ features = np.swapaxes(features, 0, 1)
2271
+ edge_features.append(features)
2272
+ edge_features_mask.append(link_mask)
2273
+
2274
+ return np.concatenate(edge_features, axis=2), np.concatenate(edge_features_mask, axis=1)
2275
+
2036
2276
  def __get_x_axis_label(self) -> str:
2037
2277
  if len(self.__sensor_readings_time) > 1:
2038
2278
  time_step = self.__sensor_readings_time[1] - self.__sensor_readings_time[0]
@@ -2084,6 +2324,38 @@ class ScadaData(Serializable):
2084
2324
  for s_id in sensor_locations]
2085
2325
  return self.__sensor_readings[:, idx]
2086
2326
 
2327
+ def get_data_pressures_as_node_features(self,
2328
+ default_missing_value: float = 0.
2329
+ ) -> tuple[np.ndarray, np.ndarray]:
2330
+ """
2331
+ Returns the pressures as node features together with a boolean mask indicating the
2332
+ presence of a sensor.
2333
+
2334
+ Parameters
2335
+ ----------
2336
+ default_missing_value : `float`, optional
2337
+ Default value (i.e. missing value) for nodes where no pressure sensor is installed.
2338
+
2339
+ The default is 0.
2340
+
2341
+ Returns
2342
+ -------
2343
+ tuple[`numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_]
2344
+ Pressures as node features of shape [num_time_steps, num_nodes], and mask of shape [num_nodes].
2345
+ """
2346
+ mask = np.zeros(len(self.__sensor_config.nodes))
2347
+ node_features = np.array([[default_missing_value] * len(self.__sensor_config.nodes)
2348
+ for _ in range(len(self.__sensor_readings_time))])
2349
+ nodes_id = self.__network_topo.get_all_nodes()
2350
+
2351
+ pressure_readings = self.get_data_pressures()
2352
+ for pressures_idx, node_id in enumerate(self.__sensor_config.pressure_sensors):
2353
+ idx = nodes_id.index(node_id)
2354
+ node_features[:, idx] = pressure_readings[:, pressures_idx]
2355
+ mask[idx] = 1
2356
+
2357
+ return node_features, mask
2358
+
2087
2359
  def plot_pressures(self, sensor_locations: list[str] = None, show: bool = True,
2088
2360
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2089
2361
  ) -> matplotlib.axes.Axes:
@@ -2171,6 +2443,43 @@ class ScadaData(Serializable):
2171
2443
  for s_id in sensor_locations]
2172
2444
  return self.__sensor_readings[:, idx]
2173
2445
 
2446
+ def get_data_flows_as_edge_features(self, default_missing_value: float = 0.
2447
+ ) -> tuple[np.ndarray, np.ndarray]:
2448
+ """
2449
+ Returns the flows as edge features together with a boolean mask indicating the
2450
+ presence of a sensor.
2451
+
2452
+ Note that the second link has the opposite flow direction of the flow at the first link --
2453
+ recall that we have an undirected graph, i.e. two edges per link.
2454
+
2455
+ Parameters
2456
+ ----------
2457
+ default_missing_value : `float`, optional
2458
+ Default value (i.e. missing value) for links where no flow sensor is installed.
2459
+
2460
+ The default is 0.
2461
+
2462
+ Returns
2463
+ -------
2464
+ tuple[`numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_]
2465
+ Flows as edge features of shape [num_time_steps, num_links * 2] and mask of shape [num_links * 2].
2466
+ """
2467
+ mask = np.zeros(2 * len(self.__sensor_config.links))
2468
+ edge_features = np.array([[default_missing_value] * 2 * len(self.__sensor_config.links)
2469
+ for _ in range(len(self.__sensor_readings_time))])
2470
+
2471
+ flow_readings = self.get_data_flows()
2472
+ for flows_idx, link_id in enumerate(self.__sensor_config.flow_sensors):
2473
+ idx1, idx2 = self.map_link_id_to_edge_idx(link_id)
2474
+
2475
+ mask[idx1] = 1
2476
+ mask[idx2] = 1
2477
+
2478
+ edge_features[:, idx1] = flow_readings[:, flows_idx]
2479
+ edge_features[:, idx2] = -1 * flow_readings[:, flows_idx]
2480
+
2481
+ return edge_features, mask
2482
+
2174
2483
  def plot_flows(self, sensor_locations: list[str] = None, show: bool = True,
2175
2484
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2176
2485
  ) -> matplotlib.axes.Axes:
@@ -2257,6 +2566,38 @@ class ScadaData(Serializable):
2257
2566
  for s_id in sensor_locations]
2258
2567
  return self.__sensor_readings[:, idx]
2259
2568
 
2569
+ def get_data_demands_as_node_features(self,
2570
+ default_missing_value: float = 0.
2571
+ ) -> tuple[np.ndarray, np.ndarray]:
2572
+ """
2573
+ Returns the demands as node features together with a boolean mask indicating the
2574
+ presence of a sensor.
2575
+
2576
+ Parameters
2577
+ ----------
2578
+ default_missing_value : `float`, optional
2579
+ Default value (i.e. missing value) for nodes where no demand sensor is installed.
2580
+
2581
+ The default is 0.
2582
+
2583
+ Returns
2584
+ -------
2585
+ tuple[`numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_]
2586
+ Demands as node features of shape [num_time_steps, num_nodes], and mask of shape [num_nodes].
2587
+ """
2588
+ mask = np.zeros(len(self.__sensor_config.nodes))
2589
+ node_features = np.array([[default_missing_value] * len(self.__sensor_config.nodes)
2590
+ for _ in range(len(self.__sensor_readings_time))])
2591
+ nodes_id = self.__network_topo.get_all_nodes()
2592
+
2593
+ demand_readings = self.get_data_demands()
2594
+ for demands_idx, node_id in enumerate(self.__sensor_config.demand_sensors):
2595
+ idx = nodes_id.index(node_id)
2596
+ node_features[:, idx] = demand_readings[:, demands_idx]
2597
+ mask[idx] = 1
2598
+
2599
+ return node_features, mask
2600
+
2260
2601
  def plot_demands(self, sensor_locations: list[str] = None, show: bool = True,
2261
2602
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2262
2603
  ) -> matplotlib.axes.Axes:
@@ -2344,6 +2685,39 @@ class ScadaData(Serializable):
2344
2685
  for s_id in sensor_locations]
2345
2686
  return self.__sensor_readings[:, idx]
2346
2687
 
2688
+ def get_data_nodes_quality_as_node_features(self,
2689
+ default_missing_value: float = 0
2690
+ ) -> tuple[np.ndarray, np.ndarray]:
2691
+ """
2692
+ Returns the nodes' quality as node features together with a boolean mask indicating the
2693
+ presence of a sensor.
2694
+
2695
+ Parameters
2696
+ ----------
2697
+ default_missing_value : `float`, optional
2698
+ Default value (i.e. missing value) for nodes where no quality sensor is installed.
2699
+
2700
+ The default is 0.
2701
+
2702
+ Returns
2703
+ -------
2704
+ tuple[`numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_]
2705
+ Nodes' quality as node features of shape [num_time_steps, num_nodes], and mask of
2706
+ shape [num_nodes].
2707
+ """
2708
+ mask = np.zeros(len(self.__sensor_config.nodes))
2709
+ node_features = np.array([[default_missing_value] * len(self.__sensor_config.nodes)
2710
+ for _ in range(len(self.__sensor_readings_time))])
2711
+ nodes_id = self.__network_topo.get_all_nodes()
2712
+
2713
+ node_quality_readings = self.get_data_nodes_quality()
2714
+ for quality_idx, node_id in enumerate(self.__sensor_config.quality_node_sensors):
2715
+ idx = nodes_id.index(node_id)
2716
+ node_features[:, idx] = node_quality_readings[:, quality_idx]
2717
+ mask[idx] = 1
2718
+
2719
+ return node_features, mask
2720
+
2347
2721
  def plot_nodes_quality(self, sensor_locations: list[str] = None, show: bool = True,
2348
2722
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2349
2723
  ) -> matplotlib.axes.Axes:
@@ -2433,6 +2807,38 @@ class ScadaData(Serializable):
2433
2807
  for s_id in sensor_locations]
2434
2808
  return self.__sensor_readings[:, idx]
2435
2809
 
2810
+ def get_data_links_quality_as_edge_features(self,
2811
+ default_missing_value: float = 0
2812
+ ) -> tuple[np.ndarray, np.ndarray]:
2813
+ """
2814
+ Returns the links' quality as edge features together with a boolean mask indicating the
2815
+ presence of a sensor.
2816
+
2817
+ Parameters
2818
+ ----------
2819
+ default_missing_value : `float`, optional
2820
+ Default value (i.e. missing value) for links where no quality sensor is installed.
2821
+
2822
+ The default is 0.
2823
+
2824
+ Returns
2825
+ -------
2826
+ tuple[`numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_]
2827
+ Links' quality as edge features of shape [num_time_steps, num_links * 2], and mask of
2828
+ shape [num_links * 2].
2829
+ """
2830
+ mask = np.zeros(2 * len(self.__sensor_config.links))
2831
+ edge_features = np.array([[default_missing_value] * 2 * len(self.__sensor_config.links)
2832
+ for _ in range(len(self.__sensor_readings_time))])
2833
+
2834
+ links_quality_readings = self.get_data_links_quality()
2835
+ for quality_idx, link_id in enumerate(self.__sensor_config.quality_link_sensors):
2836
+ for idx in self.map_link_id_to_edge_idx(link_id):
2837
+ edge_features[:, idx] = links_quality_readings[:, quality_idx]
2838
+ mask[idx] = 1
2839
+
2840
+ return edge_features, mask
2841
+
2436
2842
  def plot_links_quality(self, sensor_locations: list[str] = None, show: bool = True,
2437
2843
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2438
2844
  ) -> matplotlib.axes.Axes:
@@ -2971,6 +3377,50 @@ class ScadaData(Serializable):
2971
3377
  for link_id in surface_species_sensor_locations[species_id]]
2972
3378
  return self.__sensor_readings[:, idx]
2973
3379
 
3380
+ def get_data_surface_species_concentrations_as_edge_features(self,
3381
+ default_missing_value: float = 0.
3382
+ ) -> tuple[np.ndarray, np.ndarray]:
3383
+ """
3384
+ Returns the concentrations of surface species as edge features together with a
3385
+ boolean mask indicating the presence of a sensor.
3386
+
3387
+ Note that only surface species with at least one sensor are considered.
3388
+
3389
+ Parameters
3390
+ ----------
3391
+ default_missing_value : `float`, optional
3392
+ Default value (i.e. missing value) for links where no surface species
3393
+ sensor is installed.
3394
+
3395
+ The default is 0.
3396
+
3397
+ Returns
3398
+ -------
3399
+ tuple[`numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_]
3400
+ Concentrations of surface species as edge features of shape
3401
+ [num_time_steps, num_links * 2, num_species], and mask of
3402
+ shape [num_links * 2, num_species].
3403
+ """
3404
+ masks = []
3405
+ results = []
3406
+
3407
+ surface_species_sensor_locations = self.__sensor_config.surface_species_sensors
3408
+ for species_id, links_id in surface_species_sensor_locations.items():
3409
+ mask = np.zeros(2 * len(self.__sensor_config.links))
3410
+ edge_features = np.array([[default_missing_value] * 2 * len(self.__sensor_config.links)
3411
+ for _ in range(len(self.__sensor_readings_time))])
3412
+
3413
+ sensor_readings = self.get_data_surface_species_concentration({species_id: links_id})
3414
+ for sensor_readings_idx, link_id in enumerate(links_id):
3415
+ for idx in self.map_link_id_to_edge_idx(link_id):
3416
+ edge_features[:, idx] = sensor_readings[:, sensor_readings_idx]
3417
+ mask[idx] = 1
3418
+
3419
+ results.append(edge_features.reshape(edge_features.shape[0], edge_features.shape[1], 1))
3420
+ masks.append(mask.reshape(-1, 1))
3421
+
3422
+ return np.concatenate(results, axis=2), np.concatenate(masks, axis=1)
3423
+
2974
3424
  def plot_surface_species_concentration(self, surface_species_sensor_locations: dict = None,
2975
3425
  show: bool = True, save_to_file: str = None,
2976
3426
  ax: matplotlib.axes.Axes = None
@@ -3087,6 +3537,51 @@ class ScadaData(Serializable):
3087
3537
  for node_id in bulk_species_sensor_locations[species_id]]
3088
3538
  return self.__sensor_readings[:, idx]
3089
3539
 
3540
+ def get_data_bulk_species_concentrations_as_node_features(self,
3541
+ default_missing_value: float = 0.
3542
+ ) -> tuple[np.ndarray, np.ndarray]:
3543
+ """
3544
+ Returns the concentrations of bulk species as node features together with a boolean mask
3545
+ indicating the presence of a sensor.
3546
+
3547
+ Note that only bulk species with at least one sensor are considered.
3548
+
3549
+ Parameters
3550
+ ----------
3551
+ default_missing_value : `float`, optional
3552
+ Default value (i.e. missing value) for nodes where no bulk species
3553
+ sensor is installed.
3554
+
3555
+ The default is 0.
3556
+
3557
+ Returns
3558
+ -------
3559
+ tuple[`numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_]
3560
+ Concentrations of bulk species as node features of shape
3561
+ [num_time_steps, num_nodes, num_species], and mask of shape [num_nodes, num_species].
3562
+ """
3563
+ masks = []
3564
+ results = []
3565
+
3566
+ all_nodes_id = self.__network_topo.get_all_nodes()
3567
+ bulk_species_sensor_locations = self.__sensor_config.bulk_species_node_sensors
3568
+
3569
+ for species_id, nodes_id in bulk_species_sensor_locations.items():
3570
+ mask = np.zeros(len(self.__sensor_config.nodes))
3571
+ node_features = np.array([[default_missing_value] * len(self.__sensor_config.nodes)
3572
+ for _ in range(len(self.__sensor_readings_time))])
3573
+
3574
+ sensor_readings = self.get_data_bulk_species_node_concentration({species_id: nodes_id})
3575
+ for sensor_readings_idx, node_id in enumerate(nodes_id):
3576
+ idx = all_nodes_id.index(node_id)
3577
+ node_features[:, idx] = sensor_readings[:, sensor_readings_idx]
3578
+ mask[idx] = 1
3579
+
3580
+ results.append(node_features.reshape(node_features.shape[0], node_features.shape[1],1))
3581
+ masks.append(mask.reshape(-1, 1))
3582
+
3583
+ return np.concatenate(results, axis=2), np.concatenate(masks, axis=1)
3584
+
3090
3585
  def plot_bulk_species_node_concentration(self, bulk_species_node_sensors: dict = None,
3091
3586
  show: bool = True, save_to_file: str = None,
3092
3587
  ax: matplotlib.axes.Axes = None
@@ -3202,6 +3697,50 @@ class ScadaData(Serializable):
3202
3697
  for node_id in bulk_species_sensor_locations[species_id]]
3203
3698
  return self.__sensor_readings[:, idx]
3204
3699
 
3700
+ def get_data_bulk_species_concentrations_as_edge_features(self,
3701
+ default_missing_value: float = 0.
3702
+ ) -> tuple[np.ndarray, np.ndarray]:
3703
+ """
3704
+ Returns the concentrations of bulk species as edge features together with a boolean mask
3705
+ indicating the presence of a sensor.
3706
+
3707
+ Note that only bulk species with at least one sensor are considered.
3708
+
3709
+ Parameters
3710
+ ----------
3711
+ default_missing_value : `float`, optional
3712
+ Default value (i.e. missing value) for links where no bulk species
3713
+ sensor is installed.
3714
+
3715
+ The default is 0.
3716
+
3717
+ Returns
3718
+ -------
3719
+ tuple[`numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_]
3720
+ Concentrations of bulk species as edge features of shape
3721
+ [num_time_steps, num_links * 2, num_species], and mask of
3722
+ shape [num_links * 2, num_species].
3723
+ """
3724
+ masks = []
3725
+ results = []
3726
+
3727
+ bulk_species_sensor_locations = self.__sensor_config.bulk_species_link_sensors
3728
+ for species_id, links_id in bulk_species_sensor_locations.items():
3729
+ mask = np.zeros(2 * len(self.__sensor_config.links))
3730
+ edge_features = np.array([[default_missing_value] * 2 * len(self.__sensor_config.links)
3731
+ for _ in range(len(self.__sensor_readings_time))])
3732
+
3733
+ sensor_readings = self.get_data_bulk_species_link_concentration({species_id: links_id})
3734
+ for sensor_readings_idx, link_id in enumerate(links_id):
3735
+ for idx in self.map_link_id_to_edge_idx(link_id):
3736
+ edge_features[:, idx] = sensor_readings[:, sensor_readings_idx]
3737
+ mask[idx] = 1
3738
+
3739
+ results.append(edge_features.reshape(edge_features.shape[0], edge_features.shape[1], 1))
3740
+ masks.append(mask.reshape(-1, 1))
3741
+
3742
+ return np.concatenate(results, axis=2), np.concatenate(masks, axis=1)
3743
+
3205
3744
  def plot_bulk_species_link_concentration(self, bulk_species_link_sensors: dict = None,
3206
3745
  show: bool = True, save_to_file: str = None,
3207
3746
  ax: matplotlib.axes.Axes = None