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.
@@ -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
  """
@@ -1374,7 +1401,8 @@ class ScadaData(Serializable):
1374
1401
  self.__sensor_readings = None
1375
1402
 
1376
1403
  def get_attributes(self) -> dict:
1377
- attr = {"sensor_config": self.__sensor_config,
1404
+ attr = {"network_topo": self.__network_topo,
1405
+ "sensor_config": self.__sensor_config,
1378
1406
  "frozen_sensor_config": self.__frozen_sensor_config,
1379
1407
  "sensor_noise": self.__sensor_noise,
1380
1408
  "sensor_reading_events": self.__sensor_reading_events,
@@ -1525,7 +1553,8 @@ class ScadaData(Serializable):
1525
1553
  raise TypeError(f"Can not compare 'ScadaData' instance to '{type(other)}' instance")
1526
1554
 
1527
1555
  try:
1528
- return self.__sensor_config == other.sensor_config \
1556
+ return self.__network_topo == other.network_topo \
1557
+ and self.__sensor_config == other.sensor_config \
1529
1558
  and self.__frozen_sensor_config == other.frozen_sensor_config \
1530
1559
  and self.__sensor_noise == other.sensor_noise \
1531
1560
  and all(a == b for a, b in
@@ -1553,7 +1582,7 @@ class ScadaData(Serializable):
1553
1582
  return False
1554
1583
 
1555
1584
  def __str__(self) -> str:
1556
- return f"sensor_config: {self.__sensor_config} " + \
1585
+ return f"network_topo: {self.__network_topo} sensor_config: {self.__sensor_config} " + \
1557
1586
  f"frozen_sensor_config: {self.__frozen_sensor_config} " + \
1558
1587
  f"sensor_noise: {self.__sensor_noise} " + \
1559
1588
  f"sensor_reading_events: {self.__sensor_reading_events} " + \
@@ -1751,7 +1780,7 @@ class ScadaData(Serializable):
1751
1780
  if self.__pumps_efficiency_data_raw is not None:
1752
1781
  pumps_efficiency_data_raw = self.__pumps_efficiency_data_raw[start_idx:end_idx, :]
1753
1782
 
1754
- return ScadaData(sensor_config=self.sensor_config,
1783
+ return ScadaData(network_topo=self.network_topo, sensor_config=self.sensor_config,
1755
1784
  sensor_readings_time=self.sensor_readings_time[start_idx:end_idx],
1756
1785
  frozen_sensor_config=self.frozen_sensor_config,
1757
1786
  sensor_noise=self.sensor_noise,
@@ -1789,6 +1818,8 @@ class ScadaData(Serializable):
1789
1818
  if not isinstance(other, ScadaData):
1790
1819
  raise TypeError("'other' must be an instance of 'ScadaData' " +
1791
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")
1792
1823
  if self.__frozen_sensor_config != other.frozen_sensor_config:
1793
1824
  raise ValueError("Sensor configurations of both instances must be " +
1794
1825
  "either frozen or not frozen")
@@ -1891,6 +1922,8 @@ class ScadaData(Serializable):
1891
1922
  """
1892
1923
  if not isinstance(other, ScadaData):
1893
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")
1894
1927
  if self.__sensor_config != other.sensor_config:
1895
1928
  raise ValueError("Sensor configurations must be the same!")
1896
1929
  if self.__frozen_sensor_config != other.frozen_sensor_config:
@@ -1967,11 +2000,91 @@ class ScadaData(Serializable):
1967
2000
  (self.__pumps_efficiency_data_raw, other.pumps_efficiency_data_raw),
1968
2001
  axis=0)
1969
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
+
1970
2067
  def get_data(self) -> np.ndarray:
1971
2068
  """
1972
2069
  Computes the final sensor readings -- note that those might be subject to
1973
2070
  given sensor faults and sensor noise/uncertainty.
1974
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
+
1975
2088
  Returns
1976
2089
  -------
1977
2090
  `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
@@ -2050,6 +2163,116 @@ class ScadaData(Serializable):
2050
2163
 
2051
2164
  return sensor_readings
2052
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
+
2053
2276
  def __get_x_axis_label(self) -> str:
2054
2277
  if len(self.__sensor_readings_time) > 1:
2055
2278
  time_step = self.__sensor_readings_time[1] - self.__sensor_readings_time[0]
@@ -2101,6 +2324,38 @@ class ScadaData(Serializable):
2101
2324
  for s_id in sensor_locations]
2102
2325
  return self.__sensor_readings[:, idx]
2103
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
+
2104
2359
  def plot_pressures(self, sensor_locations: list[str] = None, show: bool = True,
2105
2360
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2106
2361
  ) -> matplotlib.axes.Axes:
@@ -2188,6 +2443,43 @@ class ScadaData(Serializable):
2188
2443
  for s_id in sensor_locations]
2189
2444
  return self.__sensor_readings[:, idx]
2190
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
+
2191
2483
  def plot_flows(self, sensor_locations: list[str] = None, show: bool = True,
2192
2484
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2193
2485
  ) -> matplotlib.axes.Axes:
@@ -2274,6 +2566,38 @@ class ScadaData(Serializable):
2274
2566
  for s_id in sensor_locations]
2275
2567
  return self.__sensor_readings[:, idx]
2276
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
+
2277
2601
  def plot_demands(self, sensor_locations: list[str] = None, show: bool = True,
2278
2602
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2279
2603
  ) -> matplotlib.axes.Axes:
@@ -2361,6 +2685,39 @@ class ScadaData(Serializable):
2361
2685
  for s_id in sensor_locations]
2362
2686
  return self.__sensor_readings[:, idx]
2363
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
+
2364
2721
  def plot_nodes_quality(self, sensor_locations: list[str] = None, show: bool = True,
2365
2722
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2366
2723
  ) -> matplotlib.axes.Axes:
@@ -2450,6 +2807,38 @@ class ScadaData(Serializable):
2450
2807
  for s_id in sensor_locations]
2451
2808
  return self.__sensor_readings[:, idx]
2452
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
+
2453
2842
  def plot_links_quality(self, sensor_locations: list[str] = None, show: bool = True,
2454
2843
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2455
2844
  ) -> matplotlib.axes.Axes:
@@ -2988,6 +3377,50 @@ class ScadaData(Serializable):
2988
3377
  for link_id in surface_species_sensor_locations[species_id]]
2989
3378
  return self.__sensor_readings[:, idx]
2990
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
+
2991
3424
  def plot_surface_species_concentration(self, surface_species_sensor_locations: dict = None,
2992
3425
  show: bool = True, save_to_file: str = None,
2993
3426
  ax: matplotlib.axes.Axes = None
@@ -3104,6 +3537,51 @@ class ScadaData(Serializable):
3104
3537
  for node_id in bulk_species_sensor_locations[species_id]]
3105
3538
  return self.__sensor_readings[:, idx]
3106
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
+
3107
3585
  def plot_bulk_species_node_concentration(self, bulk_species_node_sensors: dict = None,
3108
3586
  show: bool = True, save_to_file: str = None,
3109
3587
  ax: matplotlib.axes.Axes = None
@@ -3219,6 +3697,50 @@ class ScadaData(Serializable):
3219
3697
  for node_id in bulk_species_sensor_locations[species_id]]
3220
3698
  return self.__sensor_readings[:, idx]
3221
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
+
3222
3744
  def plot_bulk_species_link_concentration(self, bulk_species_link_sensors: dict = None,
3223
3745
  show: bool = True, save_to_file: str = None,
3224
3746
  ax: matplotlib.axes.Axes = None