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
@@ -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
 
@@ -40,6 +41,10 @@ class ScadaData(Serializable):
40
41
 
41
42
  This parameter is expected to be a 1d array with the same size as
42
43
  the number of rows in `sensor_readings_data_raw`.
44
+ network_topo : :class:`~epyt_flow.topology.NetworkTopology`
45
+ Topology of the water distribution network.
46
+ warnings_code : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
47
+ Codes/IDs of EPANET errors/warnings (if any) for each time step.
43
48
  pressure_data_raw : `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_, optional
44
49
  Raw pressure values of all nodes as a two-dimensional array --
45
50
  first dimension encodes time, second dimension pressure at nodes.
@@ -129,6 +134,7 @@ class ScadaData(Serializable):
129
134
  The default is False.
130
135
  """
131
136
  def __init__(self, sensor_config: SensorConfig, sensor_readings_time: np.ndarray,
137
+ network_topo: NetworkTopology, warnings_code: np.ndarray = None,
132
138
  pressure_data_raw: Union[np.ndarray, bsr_array] = None,
133
139
  flow_data_raw: Union[np.ndarray, bsr_array] = None,
134
140
  demand_data_raw: Union[np.ndarray, bsr_array] = None,
@@ -149,6 +155,10 @@ class ScadaData(Serializable):
149
155
  sensor_reading_events: list[SensorReadingEvent] = [],
150
156
  sensor_noise: SensorNoise = None, frozen_sensor_config: bool = False,
151
157
  **kwds):
158
+ if not isinstance(network_topo, NetworkTopology):
159
+ raise TypeError("'network_topo' must be an instance of " +
160
+ "'epyt_flow.topology.NetworkTopology' but not " +
161
+ f"of '{type(network_topo)}'")
152
162
  if not isinstance(sensor_config, SensorConfig):
153
163
  raise TypeError("'sensor_config' must be an instance of " +
154
164
  "'epyt_flow.simulation.SensorConfig' but not of " +
@@ -156,6 +166,14 @@ class ScadaData(Serializable):
156
166
  if not isinstance(sensor_readings_time, np.ndarray):
157
167
  raise TypeError("'sensor_readings_time' must be an instance of 'numpy.ndarray' " +
158
168
  f"but not of '{type(sensor_readings_time)}'")
169
+ if warnings_code is None:
170
+ warnings.warn("Loading a file that was created with an outdated version of EPyT-Flow" +
171
+ " -- support of such old files will be removed in the next release!",
172
+ DeprecationWarning)
173
+ else:
174
+ if not isinstance(warnings_code, np.ndarray):
175
+ raise TypeError("'warnings_code' must be an instance of 'numpy.ndarray' " +
176
+ f"but not of '{type(warnings_code)}'")
159
177
  if pressure_data_raw is not None:
160
178
  if not isinstance(pressure_data_raw, np.ndarray) and \
161
179
  not isinstance(pressure_data_raw, bsr_array):
@@ -293,6 +311,9 @@ class ScadaData(Serializable):
293
311
  "must match number of raw measurements.")
294
312
 
295
313
  n_time_steps = sensor_readings_time.shape[0]
314
+ if warnings_code is not None:
315
+ if warnings_code.shape[0] != n_time_steps:
316
+ __raise_shape_mismatch("warnings_code")
296
317
  if pressure_data_raw is not None:
297
318
  if pressure_data_raw.shape[0] != n_time_steps:
298
319
  __raise_shape_mismatch("pressure_data_raw")
@@ -345,7 +366,9 @@ class ScadaData(Serializable):
345
366
  if pumps_efficiency_data_raw.shape[0] != n_time_steps:
346
367
  __raise_shape_mismatch("pumps_efficiency_data_raw")
347
368
 
369
+ self.__network_topo = network_topo
348
370
  self.__sensor_config = sensor_config
371
+ self.__warnings_code = warnings_code
349
372
  self.__sensor_noise = sensor_noise
350
373
  self.__sensor_reading_events = sensor_faults + sensor_reading_attacks + \
351
374
  sensor_reading_events
@@ -1030,7 +1053,9 @@ class ScadaData(Serializable):
1030
1053
  surface_species_mass_unit=new_surface_species_mass_unit,
1031
1054
  surface_species_area_unit=new_surface_species_area_unit)
1032
1055
 
1033
- return ScadaData(sensor_config=sensor_config,
1056
+ return ScadaData(network_topo=self.network_topo,
1057
+ warnings_code=self.warnings_code,
1058
+ sensor_config=sensor_config,
1034
1059
  sensor_readings_time=self.sensor_readings_time,
1035
1060
  sensor_reading_events=self.sensor_reading_events,
1036
1061
  sensor_noise=self.sensor_noise,
@@ -1049,6 +1074,31 @@ class ScadaData(Serializable):
1049
1074
  bulk_species_link_concentration_raw=bulk_species_link_concentrations,
1050
1075
  surface_species_concentration_raw=surface_species_concentrations)
1051
1076
 
1077
+ @property
1078
+ def network_topo(self) -> NetworkTopology:
1079
+ """
1080
+ Returns the topology of the water distribution network.
1081
+
1082
+ Returns
1083
+ -------
1084
+ :class:`epyt_flow.topology.NetworkTopology`
1085
+ Topology of the network.
1086
+ """
1087
+ return deepcopy(self.__network_topo)
1088
+
1089
+ @property
1090
+ def warnings_code(self) -> np.ndarray:
1091
+ """
1092
+ Returns the codes/IDs of EPANET errors/warnings (if any) for each time step.
1093
+ Note that zero denotes the absence of any error/warning.
1094
+
1095
+ Returns:
1096
+ --------
1097
+ `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
1098
+ Codes/IDs of EPANET errors/warnings (if any) for each time step.
1099
+ """
1100
+ return deepcopy(self.__warnings_code)
1101
+
1052
1102
  @property
1053
1103
  def frozen_sensor_config(self) -> bool:
1054
1104
  """
@@ -1374,7 +1424,9 @@ class ScadaData(Serializable):
1374
1424
  self.__sensor_readings = None
1375
1425
 
1376
1426
  def get_attributes(self) -> dict:
1377
- attr = {"sensor_config": self.__sensor_config,
1427
+ attr = {"network_topo": self.__network_topo,
1428
+ "warnings_code": self.__warnings_code,
1429
+ "sensor_config": self.__sensor_config,
1378
1430
  "frozen_sensor_config": self.__frozen_sensor_config,
1379
1431
  "sensor_noise": self.__sensor_noise,
1380
1432
  "sensor_reading_events": self.__sensor_reading_events,
@@ -1525,7 +1577,9 @@ class ScadaData(Serializable):
1525
1577
  raise TypeError(f"Can not compare 'ScadaData' instance to '{type(other)}' instance")
1526
1578
 
1527
1579
  try:
1528
- return self.__sensor_config == other.sensor_config \
1580
+ return self.__network_topo == other.network_topo \
1581
+ and np.all(self.__warnings_code == other.warnings_code) \
1582
+ and self.__sensor_config == other.sensor_config \
1529
1583
  and self.__frozen_sensor_config == other.frozen_sensor_config \
1530
1584
  and self.__sensor_noise == other.sensor_noise \
1531
1585
  and all(a == b for a, b in
@@ -1553,7 +1607,8 @@ class ScadaData(Serializable):
1553
1607
  return False
1554
1608
 
1555
1609
  def __str__(self) -> str:
1556
- return f"sensor_config: {self.__sensor_config} " + \
1610
+ return f"network_topo: {self.__network_topo} sensor_config: {self.__sensor_config} " + \
1611
+ f"warnings_code: {self.__warnings_code} " + \
1557
1612
  f"frozen_sensor_config: {self.__frozen_sensor_config} " + \
1558
1613
  f"sensor_noise: {self.__sensor_noise} " + \
1559
1614
  f"sensor_reading_events: {self.__sensor_reading_events} " + \
@@ -1751,8 +1806,9 @@ class ScadaData(Serializable):
1751
1806
  if self.__pumps_efficiency_data_raw is not None:
1752
1807
  pumps_efficiency_data_raw = self.__pumps_efficiency_data_raw[start_idx:end_idx, :]
1753
1808
 
1754
- return ScadaData(sensor_config=self.sensor_config,
1809
+ return ScadaData(network_topo=self.network_topo, sensor_config=self.sensor_config,
1755
1810
  sensor_readings_time=self.sensor_readings_time[start_idx:end_idx],
1811
+ warnings_code=self.__warnings_code[start_idx:end_idx],
1756
1812
  frozen_sensor_config=self.frozen_sensor_config,
1757
1813
  sensor_noise=self.sensor_noise,
1758
1814
  sensor_reading_events=self.sensor_reading_events,
@@ -1789,6 +1845,8 @@ class ScadaData(Serializable):
1789
1845
  if not isinstance(other, ScadaData):
1790
1846
  raise TypeError("'other' must be an instance of 'ScadaData' " +
1791
1847
  f"but not of '{type(other)}'")
1848
+ if self.__network_topo != other.network_topo:
1849
+ raise ValueError("Network topology must be the same in both instances")
1792
1850
  if self.__frozen_sensor_config != other.frozen_sensor_config:
1793
1851
  raise ValueError("Sensor configurations of both instances must be " +
1794
1852
  "either frozen or not frozen")
@@ -1891,6 +1949,8 @@ class ScadaData(Serializable):
1891
1949
  """
1892
1950
  if not isinstance(other, ScadaData):
1893
1951
  raise TypeError(f"'other' must be an instance of 'ScadaData' but not of {type(other)}")
1952
+ if self.__network_topo != other.network_topo:
1953
+ raise ValueError("Network topology must be the same")
1894
1954
  if self.__sensor_config != other.sensor_config:
1895
1955
  raise ValueError("Sensor configurations must be the same!")
1896
1956
  if self.__frozen_sensor_config != other.frozen_sensor_config:
@@ -1907,6 +1967,8 @@ class ScadaData(Serializable):
1907
1967
  self.__sensor_readings_time = np.concatenate(
1908
1968
  (self.__sensor_readings_time, other.sensor_readings_time), axis=0)
1909
1969
 
1970
+ self.__warnings_code = np.concatenate((self.__warnings_code, other.warnings_code), axis=0)
1971
+
1910
1972
  if self.__pressure_data_raw is not None:
1911
1973
  self.__pressure_data_raw = np.concatenate(
1912
1974
  (self.__pressure_data_raw, other.pressure_data_raw), axis=0)
@@ -1967,11 +2029,91 @@ class ScadaData(Serializable):
1967
2029
  (self.__pumps_efficiency_data_raw, other.pumps_efficiency_data_raw),
1968
2030
  axis=0)
1969
2031
 
2032
+ def topo_adj_matrix(self) -> bsr_array:
2033
+ """
2034
+ Returns the adjacency matrix of the network.
2035
+
2036
+ Nodes are ordered according to EPANET.
2037
+
2038
+ Returns
2039
+ -------
2040
+ `scipy.bsr_array <https://docs.scipy.org/doc/scipy/reference/generated/scipy.sparse.bsr_array.html>`_
2041
+ Adjacency matrix as a sparse array of shape [num_nodes, num_nodes].
2042
+ """
2043
+ return self.__network_topo.get_adj_matrix()
2044
+
2045
+ def map_link_id_to_edge_idx(self, link_id: str) -> tuple[int, int]:
2046
+ """
2047
+ Maps a given link to the corresponding two indices in the edge indices as computed by
2048
+ :func:`epyt_flow.simulation.scada.scada_data.ScadaData.topo_edge_indices`.
2049
+
2050
+ Returns
2051
+ -------
2052
+ `tuple[int, int]`
2053
+ Indices.
2054
+ """
2055
+ if not isinstance(link_id, str):
2056
+ raise TypeError(f"'link_id' must be an instance of 'str' but not of '{type(link_id)}'")
2057
+ if link_id not in self.__sensor_config.links:
2058
+ raise ValueError(f"Unknown link '{link_id}'")
2059
+
2060
+ idx = 0
2061
+ for l_id, [node_a_id, node_b_id] in self.__network_topo.get_all_links():
2062
+ if l_id == link_id:
2063
+ return (idx, idx+1)
2064
+
2065
+ idx += 2
2066
+
2067
+ def get_topo_edge_indices(self) -> np.ndarray:
2068
+ """
2069
+ Returns the edge indices -- i.e. a 2 dimensional array where the first dimension denotes
2070
+ the source node indices and the second dimension denotes the target node indices
2071
+ for all links in the network.
2072
+ Nodes are ordered according to EPANET.
2073
+
2074
+ Note that the network is consideres as a directed graph -- i.e. one link corresponds to
2075
+ two edges in opposite directions!
2076
+
2077
+ Returns
2078
+ -------
2079
+ `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
2080
+ Edge indices of shape [2, num_links * 2].
2081
+ """
2082
+ edge_indices = [[], []]
2083
+
2084
+ nodes_id = self.__network_topo.get_all_nodes()
2085
+ links = self.__network_topo.get_all_links()
2086
+
2087
+ for _, [node_a_id, node_b_id] in self.__network_topo.get_all_links():
2088
+ node_a_idx = nodes_id.index(node_a_id)
2089
+ node_b_idx = nodes_id.index(node_b_id)
2090
+
2091
+ edge_indices[0] += [node_a_idx, node_b_idx]
2092
+ edge_indices[1] += [node_b_idx, node_a_idx]
2093
+
2094
+ return np.array(edge_indices)
2095
+
1970
2096
  def get_data(self) -> np.ndarray:
1971
2097
  """
1972
2098
  Computes the final sensor readings -- note that those might be subject to
1973
2099
  given sensor faults and sensor noise/uncertainty.
1974
2100
 
2101
+ Columns (i.e. sensor readings) are ordered as follows:
2102
+
2103
+ 1. Pressures
2104
+ 2. Flows
2105
+ 3. Demands
2106
+ 4. Nodes quality
2107
+ 5. Links quality
2108
+ 6. Valve state
2109
+ 7. Pumps state
2110
+ 8. Pumps efficiency
2111
+ 9. Pumps energy consumption
2112
+ 10. Tanks volume
2113
+ 11. Surface species concentrations
2114
+ 12. Bulk species nodes concentrations
2115
+ 13. Bulk species links concentrations
2116
+
1975
2117
  Returns
1976
2118
  -------
1977
2119
  `numpy.ndarray <https://numpy.org/doc/stable/reference/generated/numpy.ndarray.html>`_
@@ -2050,6 +2192,116 @@ class ScadaData(Serializable):
2050
2192
 
2051
2193
  return sensor_readings
2052
2194
 
2195
+ def get_data_node_features(self, default_missing_value: float = 0.
2196
+ ) -> tuple[np.ndarray, np.ndarray]:
2197
+ """
2198
+ Returns the sensor readings as node features together with a boolean mask indicating the
2199
+ presence of a sensor -- i.e. pressure, demand, quality, bulk species concentration
2200
+ at each node.
2201
+
2202
+ Note that only quantities with at least one sensor are considered.
2203
+
2204
+ Parameters
2205
+ ----------
2206
+ default_missing_value : `float`, optional
2207
+ Default value (i.e. missing value) for nodes where no sensor is installed.
2208
+
2209
+ The default is 0.
2210
+
2211
+ Returns
2212
+ -------
2213
+ 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>`_]
2214
+ Node features of shape [num_nodes, num_time_steps, num_node_features], and
2215
+ mask of shape [num_nodes, num_node_features].
2216
+ """
2217
+ node_features = []
2218
+ node_features_mask = []
2219
+
2220
+ if len(self.__sensor_config.pressure_sensors) != 0:
2221
+ features, node_mask = self.get_data_pressures_as_node_features(default_missing_value)
2222
+ features = features.T
2223
+ features = features.reshape(features.shape[0], features.shape[1], 1)
2224
+ node_features.append(features)
2225
+ node_features_mask.append(node_mask.reshape(-1, 1))
2226
+
2227
+ if len(self.__sensor_config.demand_sensors) != 0:
2228
+ features, node_mask = self.get_data_demands_as_node_features(default_missing_value)
2229
+ features = features.T
2230
+ features = features.reshape(features.shape[0], features.shape[1], 1)
2231
+ node_features.append(features)
2232
+ node_features_mask.append(node_mask.reshape(-1, 1))
2233
+
2234
+ if len(self.__sensor_config.quality_node_sensors) != 0:
2235
+ features, node_mask = self.get_data_nodes_quality_as_node_features(default_missing_value)
2236
+ features = features.T
2237
+ features = features.reshape(features.shape[0], features.shape[1], 1)
2238
+ node_features.append(features)
2239
+ node_features_mask.append(node_mask.reshape(-1, 1))
2240
+
2241
+ if len(self.__sensor_config.bulk_species_node_sensors) != 0:
2242
+ features, node_mask = self.\
2243
+ get_data_bulk_species_concentrations_as_node_features(default_missing_value)
2244
+ features = np.swapaxes(features, 0, 1)
2245
+ node_features.append(features)
2246
+ node_features_mask.append(node_mask)
2247
+
2248
+ return np.concatenate(node_features, axis=2), np.concatenate(node_features_mask, axis=1)
2249
+
2250
+ def get_data_edge_features(self, default_missing_value: float = 0.
2251
+ ) -> tuple[np.ndarray, np.ndarray]:
2252
+ """
2253
+ Returns the sensor readings as edge features together with a boolean mask indicating the
2254
+ presence of a sensor -- i.e. flow, quality, surface species concentration,
2255
+ bulk species concentration at each link.
2256
+
2257
+ Note that only quantities with at least one sensor are considered.
2258
+
2259
+ Parameters
2260
+ ----------
2261
+ default_missing_value : `float`, optional
2262
+ Default value (i.e. missing value) for links where no sensor is installed.
2263
+
2264
+ The default is 0.
2265
+
2266
+ Returns
2267
+ -------
2268
+ 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>`_]
2269
+ Edge features of shape [num_links, num_time_steps, num_edge_features] and
2270
+ mask of shape [num_links, num_edge_features].
2271
+ """
2272
+ edge_features = []
2273
+ edge_features_mask = []
2274
+
2275
+ if len(self.__sensor_config.flow_sensors) != 0:
2276
+ features, link_mask = self.get_data_flows_as_edge_features(default_missing_value)
2277
+ features = features.T
2278
+ features = features.reshape(features.shape[0], features.shape[1], 1)
2279
+ edge_features.append(features)
2280
+ edge_features_mask.append(link_mask.reshape(-1, 1))
2281
+
2282
+ if len(self.__sensor_config.quality_link_sensors) != 0:
2283
+ features, link_mask = self.get_data_links_quality_as_edge_features(default_missing_value)
2284
+ features = features.T
2285
+ features = features.reshape(features.shape[0], features.shape[1], 1)
2286
+ edge_features.append(features)
2287
+ edge_features_mask.append(link_mask.reshape(-1, 1))
2288
+
2289
+ if len(self.__sensor_config.surface_species_sensors) != 0:
2290
+ features, link_mask = self.\
2291
+ get_data_surface_species_concentrations_as_edge_features(default_missing_value)
2292
+ features = np.swapaxes(features, 0, 1)
2293
+ edge_features.append(features)
2294
+ edge_features_mask.append(link_mask)
2295
+
2296
+ if len(self.__sensor_config.bulk_species_link_sensors) != 0:
2297
+ features, link_mask = self.\
2298
+ get_data_bulk_species_concentrations_as_edge_features(default_missing_value)
2299
+ features = np.swapaxes(features, 0, 1)
2300
+ edge_features.append(features)
2301
+ edge_features_mask.append(link_mask)
2302
+
2303
+ return np.concatenate(edge_features, axis=2), np.concatenate(edge_features_mask, axis=1)
2304
+
2053
2305
  def __get_x_axis_label(self) -> str:
2054
2306
  if len(self.__sensor_readings_time) > 1:
2055
2307
  time_step = self.__sensor_readings_time[1] - self.__sensor_readings_time[0]
@@ -2101,6 +2353,38 @@ class ScadaData(Serializable):
2101
2353
  for s_id in sensor_locations]
2102
2354
  return self.__sensor_readings[:, idx]
2103
2355
 
2356
+ def get_data_pressures_as_node_features(self,
2357
+ default_missing_value: float = 0.
2358
+ ) -> tuple[np.ndarray, np.ndarray]:
2359
+ """
2360
+ Returns the pressures as node features together with a boolean mask indicating the
2361
+ presence of a sensor.
2362
+
2363
+ Parameters
2364
+ ----------
2365
+ default_missing_value : `float`, optional
2366
+ Default value (i.e. missing value) for nodes where no pressure sensor is installed.
2367
+
2368
+ The default is 0.
2369
+
2370
+ Returns
2371
+ -------
2372
+ 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>`_]
2373
+ Pressures as node features of shape [num_time_steps, num_nodes], and mask of shape [num_nodes].
2374
+ """
2375
+ mask = np.zeros(len(self.__sensor_config.nodes))
2376
+ node_features = np.array([[default_missing_value] * len(self.__sensor_config.nodes)
2377
+ for _ in range(len(self.__sensor_readings_time))])
2378
+ nodes_id = self.__network_topo.get_all_nodes()
2379
+
2380
+ pressure_readings = self.get_data_pressures()
2381
+ for pressures_idx, node_id in enumerate(self.__sensor_config.pressure_sensors):
2382
+ idx = nodes_id.index(node_id)
2383
+ node_features[:, idx] = pressure_readings[:, pressures_idx]
2384
+ mask[idx] = 1
2385
+
2386
+ return node_features, mask
2387
+
2104
2388
  def plot_pressures(self, sensor_locations: list[str] = None, show: bool = True,
2105
2389
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2106
2390
  ) -> matplotlib.axes.Axes:
@@ -2188,6 +2472,43 @@ class ScadaData(Serializable):
2188
2472
  for s_id in sensor_locations]
2189
2473
  return self.__sensor_readings[:, idx]
2190
2474
 
2475
+ def get_data_flows_as_edge_features(self, default_missing_value: float = 0.
2476
+ ) -> tuple[np.ndarray, np.ndarray]:
2477
+ """
2478
+ Returns the flows as edge features together with a boolean mask indicating the
2479
+ presence of a sensor.
2480
+
2481
+ Note that the second link has the opposite flow direction of the flow at the first link --
2482
+ recall that we have an undirected graph, i.e. two edges per link.
2483
+
2484
+ Parameters
2485
+ ----------
2486
+ default_missing_value : `float`, optional
2487
+ Default value (i.e. missing value) for links where no flow sensor is installed.
2488
+
2489
+ The default is 0.
2490
+
2491
+ Returns
2492
+ -------
2493
+ 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>`_]
2494
+ Flows as edge features of shape [num_time_steps, num_links * 2] and mask of shape [num_links * 2].
2495
+ """
2496
+ mask = np.zeros(2 * len(self.__sensor_config.links))
2497
+ edge_features = np.array([[default_missing_value] * 2 * len(self.__sensor_config.links)
2498
+ for _ in range(len(self.__sensor_readings_time))])
2499
+
2500
+ flow_readings = self.get_data_flows()
2501
+ for flows_idx, link_id in enumerate(self.__sensor_config.flow_sensors):
2502
+ idx1, idx2 = self.map_link_id_to_edge_idx(link_id)
2503
+
2504
+ mask[idx1] = 1
2505
+ mask[idx2] = 1
2506
+
2507
+ edge_features[:, idx1] = flow_readings[:, flows_idx]
2508
+ edge_features[:, idx2] = -1 * flow_readings[:, flows_idx]
2509
+
2510
+ return edge_features, mask
2511
+
2191
2512
  def plot_flows(self, sensor_locations: list[str] = None, show: bool = True,
2192
2513
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2193
2514
  ) -> matplotlib.axes.Axes:
@@ -2274,6 +2595,38 @@ class ScadaData(Serializable):
2274
2595
  for s_id in sensor_locations]
2275
2596
  return self.__sensor_readings[:, idx]
2276
2597
 
2598
+ def get_data_demands_as_node_features(self,
2599
+ default_missing_value: float = 0.
2600
+ ) -> tuple[np.ndarray, np.ndarray]:
2601
+ """
2602
+ Returns the demands as node features together with a boolean mask indicating the
2603
+ presence of a sensor.
2604
+
2605
+ Parameters
2606
+ ----------
2607
+ default_missing_value : `float`, optional
2608
+ Default value (i.e. missing value) for nodes where no demand sensor is installed.
2609
+
2610
+ The default is 0.
2611
+
2612
+ Returns
2613
+ -------
2614
+ 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>`_]
2615
+ Demands as node features of shape [num_time_steps, num_nodes], and mask of shape [num_nodes].
2616
+ """
2617
+ mask = np.zeros(len(self.__sensor_config.nodes))
2618
+ node_features = np.array([[default_missing_value] * len(self.__sensor_config.nodes)
2619
+ for _ in range(len(self.__sensor_readings_time))])
2620
+ nodes_id = self.__network_topo.get_all_nodes()
2621
+
2622
+ demand_readings = self.get_data_demands()
2623
+ for demands_idx, node_id in enumerate(self.__sensor_config.demand_sensors):
2624
+ idx = nodes_id.index(node_id)
2625
+ node_features[:, idx] = demand_readings[:, demands_idx]
2626
+ mask[idx] = 1
2627
+
2628
+ return node_features, mask
2629
+
2277
2630
  def plot_demands(self, sensor_locations: list[str] = None, show: bool = True,
2278
2631
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2279
2632
  ) -> matplotlib.axes.Axes:
@@ -2361,6 +2714,39 @@ class ScadaData(Serializable):
2361
2714
  for s_id in sensor_locations]
2362
2715
  return self.__sensor_readings[:, idx]
2363
2716
 
2717
+ def get_data_nodes_quality_as_node_features(self,
2718
+ default_missing_value: float = 0
2719
+ ) -> tuple[np.ndarray, np.ndarray]:
2720
+ """
2721
+ Returns the nodes' quality as node features together with a boolean mask indicating the
2722
+ presence of a sensor.
2723
+
2724
+ Parameters
2725
+ ----------
2726
+ default_missing_value : `float`, optional
2727
+ Default value (i.e. missing value) for nodes where no quality sensor is installed.
2728
+
2729
+ The default is 0.
2730
+
2731
+ Returns
2732
+ -------
2733
+ 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>`_]
2734
+ Nodes' quality as node features of shape [num_time_steps, num_nodes], and mask of
2735
+ shape [num_nodes].
2736
+ """
2737
+ mask = np.zeros(len(self.__sensor_config.nodes))
2738
+ node_features = np.array([[default_missing_value] * len(self.__sensor_config.nodes)
2739
+ for _ in range(len(self.__sensor_readings_time))])
2740
+ nodes_id = self.__network_topo.get_all_nodes()
2741
+
2742
+ node_quality_readings = self.get_data_nodes_quality()
2743
+ for quality_idx, node_id in enumerate(self.__sensor_config.quality_node_sensors):
2744
+ idx = nodes_id.index(node_id)
2745
+ node_features[:, idx] = node_quality_readings[:, quality_idx]
2746
+ mask[idx] = 1
2747
+
2748
+ return node_features, mask
2749
+
2364
2750
  def plot_nodes_quality(self, sensor_locations: list[str] = None, show: bool = True,
2365
2751
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2366
2752
  ) -> matplotlib.axes.Axes:
@@ -2450,6 +2836,38 @@ class ScadaData(Serializable):
2450
2836
  for s_id in sensor_locations]
2451
2837
  return self.__sensor_readings[:, idx]
2452
2838
 
2839
+ def get_data_links_quality_as_edge_features(self,
2840
+ default_missing_value: float = 0
2841
+ ) -> tuple[np.ndarray, np.ndarray]:
2842
+ """
2843
+ Returns the links' quality as edge features together with a boolean mask indicating the
2844
+ presence of a sensor.
2845
+
2846
+ Parameters
2847
+ ----------
2848
+ default_missing_value : `float`, optional
2849
+ Default value (i.e. missing value) for links where no quality sensor is installed.
2850
+
2851
+ The default is 0.
2852
+
2853
+ Returns
2854
+ -------
2855
+ 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>`_]
2856
+ Links' quality as edge features of shape [num_time_steps, num_links * 2], and mask of
2857
+ shape [num_links * 2].
2858
+ """
2859
+ mask = np.zeros(2 * len(self.__sensor_config.links))
2860
+ edge_features = np.array([[default_missing_value] * 2 * len(self.__sensor_config.links)
2861
+ for _ in range(len(self.__sensor_readings_time))])
2862
+
2863
+ links_quality_readings = self.get_data_links_quality()
2864
+ for quality_idx, link_id in enumerate(self.__sensor_config.quality_link_sensors):
2865
+ for idx in self.map_link_id_to_edge_idx(link_id):
2866
+ edge_features[:, idx] = links_quality_readings[:, quality_idx]
2867
+ mask[idx] = 1
2868
+
2869
+ return edge_features, mask
2870
+
2453
2871
  def plot_links_quality(self, sensor_locations: list[str] = None, show: bool = True,
2454
2872
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2455
2873
  ) -> matplotlib.axes.Axes:
@@ -2539,6 +2957,38 @@ class ScadaData(Serializable):
2539
2957
  for s_id in sensor_locations]
2540
2958
  return self.__sensor_readings[:, idx]
2541
2959
 
2960
+ def get_data_pumps_state_as_node_features(self,
2961
+ default_missing_value: float = 0.
2962
+ ) -> tuple[np.ndarray, np.ndarray]:
2963
+ """
2964
+ Returns the pump state as node features together with a boolean mask indicating the
2965
+ presence of a sensor.
2966
+
2967
+ Parameters
2968
+ ----------
2969
+ default_missing_value : `float`, optional
2970
+ Default value (i.e. missing value) for nodes where no pump state sensor is installed.
2971
+
2972
+ The default is 0.
2973
+
2974
+ Returns
2975
+ -------
2976
+ 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>`_]
2977
+ Pump state as node features of shape [num_time_steps, num_nodes], and mask of shape [num_nodes].
2978
+ """
2979
+ mask = np.zeros(len(self.__sensor_config.pumps))
2980
+ pump_features = np.array([[default_missing_value] * len(self.__sensor_config.pumps)
2981
+ for _ in range(len(self.__sensor_readings_time))])
2982
+ pumps_id = self.__network_topo.get_all_pumps()
2983
+
2984
+ state_readings = self.get_data_pumps_state()
2985
+ for pumps_state_idx, pump_id in enumerate(self.__sensor_config.pump_state_sensors):
2986
+ idx = pumps_id.index(pump_id)
2987
+ pump_features[:, idx] = state_readings[:, pumps_state_idx]
2988
+ mask[idx] = 1
2989
+
2990
+ return pump_features, mask
2991
+
2542
2992
  def plot_pumps_state(self, sensor_locations: list[str] = None, show: bool = True,
2543
2993
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2544
2994
  ) -> matplotlib.axes.Axes:
@@ -2626,6 +3076,38 @@ class ScadaData(Serializable):
2626
3076
  for s_id in sensor_locations]
2627
3077
  return self.__sensor_readings[:, idx]
2628
3078
 
3079
+ def get_data_pumps_efficiency_as_node_features(self,
3080
+ default_missing_value: float = 0.
3081
+ ) -> tuple[np.ndarray, np.ndarray]:
3082
+ """
3083
+ Returns the pump efficiency as node features together with a boolean mask indicating the
3084
+ presence of a sensor.
3085
+
3086
+ Parameters
3087
+ ----------
3088
+ default_missing_value : `float`, optional
3089
+ Default value (i.e. missing value) for nodes where no pump efficiency sensor is installed.
3090
+
3091
+ The default is 0.
3092
+
3093
+ Returns
3094
+ -------
3095
+ 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>`_]
3096
+ Pump efficiencies as node features of shape [num_time_steps, num_nodes], and mask of shape [num_nodes].
3097
+ """
3098
+ mask = np.zeros(len(self.__sensor_config.pumps))
3099
+ pump_features = np.array([[default_missing_value] * len(self.__sensor_config.pumps)
3100
+ for _ in range(len(self.__sensor_readings_time))])
3101
+ pumps_id = self.__network_topo.get_all_pumps()
3102
+
3103
+ efficiency_readings = self.get_data_pumps_efficiency()
3104
+ for pumps_efficiency_idx, pump_id in enumerate(self.__sensor_config.pump_efficiency_sensors):
3105
+ idx = pumps_id.index(pump_id)
3106
+ pump_features[:, idx] = efficiency_readings[:, pumps_efficiency_idx]
3107
+ mask[idx] = 1
3108
+
3109
+ return pump_features, mask
3110
+
2629
3111
  def plot_pumps_efficiency(self, sensor_locations: list[str] = None, show: bool = True,
2630
3112
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2631
3113
  ) -> matplotlib.axes.Axes:
@@ -2714,6 +3196,38 @@ class ScadaData(Serializable):
2714
3196
  for s_id in sensor_locations]
2715
3197
  return self.__sensor_readings[:, idx]
2716
3198
 
3199
+ def get_data_pumps_energyconsumption_as_node_features(self,
3200
+ default_missing_value: float = 0.
3201
+ ) -> tuple[np.ndarray, np.ndarray]:
3202
+ """
3203
+ Returns the pump energy consumption as node features together with a boolean mask indicating the
3204
+ presence of a sensor.
3205
+
3206
+ Parameters
3207
+ ----------
3208
+ default_missing_value : `float`, optional
3209
+ Default value (i.e. missing value) for nodes where no pump energy consumption sensor is installed.
3210
+
3211
+ The default is 0.
3212
+
3213
+ Returns
3214
+ -------
3215
+ 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>`_]
3216
+ Pump energy consumptions as node features of shape [num_time_steps, num_nodes], and mask of shape [num_nodes].
3217
+ """
3218
+ mask = np.zeros(len(self.__sensor_config.pumps))
3219
+ pump_features = np.array([[default_missing_value] * len(self.__sensor_config.pumps)
3220
+ for _ in range(len(self.__sensor_readings_time))])
3221
+ pumps_id = self.__network_topo.get_all_pumps()
3222
+
3223
+ energyconsumption_readings = self.get_data_pumps_energyconsumption()
3224
+ for pumps_energyconsumption_idx, pump_id in enumerate(self.__sensor_config.pump_energyconsumption_sensors):
3225
+ idx = pumps_id.index(pump_id)
3226
+ pump_features[:, idx] = energyconsumption_readings[:, pumps_energyconsumption_idx]
3227
+ mask[idx] = 1
3228
+
3229
+ return pump_features, mask
3230
+
2717
3231
  def plot_pumps_energyconsumption(self, sensor_locations: list[str] = None, show: bool = True,
2718
3232
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2719
3233
  ) -> matplotlib.axes.Axes:
@@ -2801,6 +3315,38 @@ class ScadaData(Serializable):
2801
3315
  for s_id in sensor_locations]
2802
3316
  return self.__sensor_readings[:, idx]
2803
3317
 
3318
+ def get_data_valves_state_as_node_features(self,
3319
+ default_missing_value: float = 0.
3320
+ ) -> tuple[np.ndarray, np.ndarray]:
3321
+ """
3322
+ Returns the valves state as node features together with a boolean mask indicating the
3323
+ presence of a sensor.
3324
+
3325
+ Parameters
3326
+ ----------
3327
+ default_missing_value : `float`, optional
3328
+ Default value (i.e. missing value) for nodes where no valves state sensor is installed.
3329
+
3330
+ The default is 0.
3331
+
3332
+ Returns
3333
+ -------
3334
+ 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>`_]
3335
+ Valves state as node features of shape [num_time_steps, num_nodes], and mask of shape [num_nodes].
3336
+ """
3337
+ mask = np.zeros(len(self.__sensor_config.valves))
3338
+ valve_features = np.array([[default_missing_value] * len(self.__sensor_config.valves)
3339
+ for _ in range(len(self.__sensor_readings_time))])
3340
+ valves_id = self.__network_topo.get_all_valves()
3341
+
3342
+ state_readings = self.get_data_valves_state()
3343
+ for valves_state_idx, valve_id in enumerate(self.__sensor_config.valve_state_sensors):
3344
+ idx = valves_id.index(valve_id)
3345
+ valve_features[:, idx] = state_readings[:, valves_state_idx]
3346
+ mask[idx] = 1
3347
+
3348
+ return valve_features, mask
3349
+
2804
3350
  def plot_valves_state(self, sensor_locations: list[str] = None, show: bool = True,
2805
3351
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2806
3352
  ) -> matplotlib.axes.Axes:
@@ -2888,6 +3434,38 @@ class ScadaData(Serializable):
2888
3434
  for s_id in sensor_locations]
2889
3435
  return self.__sensor_readings[:, idx]
2890
3436
 
3437
+ def get_data_tanks_water_volume_as_node_features(self,
3438
+ default_missing_value: float = 0.
3439
+ ) -> tuple[np.ndarray, np.ndarray]:
3440
+ """
3441
+ Returns the tank water volume as node features together with a boolean mask indicating the
3442
+ presence of a sensor.
3443
+
3444
+ Parameters
3445
+ ----------
3446
+ default_missing_value : `float`, optional
3447
+ Default value (i.e. missing value) for nodes where no tank water volume sensor is installed.
3448
+
3449
+ The default is 0.
3450
+
3451
+ Returns
3452
+ -------
3453
+ 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>`_]
3454
+ Tank water volumes as node features of shape [num_time_steps, num_nodes], and mask of shape [num_nodes].
3455
+ """
3456
+ mask = np.zeros(len(self.__sensor_config.tanks))
3457
+ tank_features = np.array([[default_missing_value] * len(self.__sensor_config.tanks)
3458
+ for _ in range(len(self.__sensor_readings_time))])
3459
+ tanks_id = self.__network_topo.get_all_tanks()
3460
+
3461
+ water_volume_readings = self.get_data_tanks_water_volume()
3462
+ for tanks_water_volume_idx, tank_id in enumerate(self.__sensor_config.tank_volume_sensors):
3463
+ idx = tanks_id.index(tank_id)
3464
+ tank_features[:, idx] = water_volume_readings[:, tanks_water_volume_idx]
3465
+ mask[idx] = 1
3466
+
3467
+ return tank_features, mask
3468
+
2891
3469
  def plot_tanks_water_volume(self, sensor_locations: list[str] = None, show: bool = True,
2892
3470
  save_to_file: str = None, ax: matplotlib.axes.Axes = None
2893
3471
  ) -> matplotlib.axes.Axes:
@@ -2988,6 +3566,50 @@ class ScadaData(Serializable):
2988
3566
  for link_id in surface_species_sensor_locations[species_id]]
2989
3567
  return self.__sensor_readings[:, idx]
2990
3568
 
3569
+ def get_data_surface_species_concentrations_as_edge_features(self,
3570
+ default_missing_value: float = 0.
3571
+ ) -> tuple[np.ndarray, np.ndarray]:
3572
+ """
3573
+ Returns the concentrations of surface species as edge features together with a
3574
+ boolean mask indicating the presence of a sensor.
3575
+
3576
+ Note that only surface species with at least one sensor are considered.
3577
+
3578
+ Parameters
3579
+ ----------
3580
+ default_missing_value : `float`, optional
3581
+ Default value (i.e. missing value) for links where no surface species
3582
+ sensor is installed.
3583
+
3584
+ The default is 0.
3585
+
3586
+ Returns
3587
+ -------
3588
+ 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>`_]
3589
+ Concentrations of surface species as edge features of shape
3590
+ [num_time_steps, num_links * 2, num_species], and mask of
3591
+ shape [num_links * 2, num_species].
3592
+ """
3593
+ masks = []
3594
+ results = []
3595
+
3596
+ surface_species_sensor_locations = self.__sensor_config.surface_species_sensors
3597
+ for species_id, links_id in surface_species_sensor_locations.items():
3598
+ mask = np.zeros(2 * len(self.__sensor_config.links))
3599
+ edge_features = np.array([[default_missing_value] * 2 * len(self.__sensor_config.links)
3600
+ for _ in range(len(self.__sensor_readings_time))])
3601
+
3602
+ sensor_readings = self.get_data_surface_species_concentration({species_id: links_id})
3603
+ for sensor_readings_idx, link_id in enumerate(links_id):
3604
+ for idx in self.map_link_id_to_edge_idx(link_id):
3605
+ edge_features[:, idx] = sensor_readings[:, sensor_readings_idx]
3606
+ mask[idx] = 1
3607
+
3608
+ results.append(edge_features.reshape(edge_features.shape[0], edge_features.shape[1], 1))
3609
+ masks.append(mask.reshape(-1, 1))
3610
+
3611
+ return np.concatenate(results, axis=2), np.concatenate(masks, axis=1)
3612
+
2991
3613
  def plot_surface_species_concentration(self, surface_species_sensor_locations: dict = None,
2992
3614
  show: bool = True, save_to_file: str = None,
2993
3615
  ax: matplotlib.axes.Axes = None
@@ -3104,6 +3726,51 @@ class ScadaData(Serializable):
3104
3726
  for node_id in bulk_species_sensor_locations[species_id]]
3105
3727
  return self.__sensor_readings[:, idx]
3106
3728
 
3729
+ def get_data_bulk_species_concentrations_as_node_features(self,
3730
+ default_missing_value: float = 0.
3731
+ ) -> tuple[np.ndarray, np.ndarray]:
3732
+ """
3733
+ Returns the concentrations of bulk species as node features together with a boolean mask
3734
+ indicating the presence of a sensor.
3735
+
3736
+ Note that only bulk species with at least one sensor are considered.
3737
+
3738
+ Parameters
3739
+ ----------
3740
+ default_missing_value : `float`, optional
3741
+ Default value (i.e. missing value) for nodes where no bulk species
3742
+ sensor is installed.
3743
+
3744
+ The default is 0.
3745
+
3746
+ Returns
3747
+ -------
3748
+ 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>`_]
3749
+ Concentrations of bulk species as node features of shape
3750
+ [num_time_steps, num_nodes, num_species], and mask of shape [num_nodes, num_species].
3751
+ """
3752
+ masks = []
3753
+ results = []
3754
+
3755
+ all_nodes_id = self.__network_topo.get_all_nodes()
3756
+ bulk_species_sensor_locations = self.__sensor_config.bulk_species_node_sensors
3757
+
3758
+ for species_id, nodes_id in bulk_species_sensor_locations.items():
3759
+ mask = np.zeros(len(self.__sensor_config.nodes))
3760
+ node_features = np.array([[default_missing_value] * len(self.__sensor_config.nodes)
3761
+ for _ in range(len(self.__sensor_readings_time))])
3762
+
3763
+ sensor_readings = self.get_data_bulk_species_node_concentration({species_id: nodes_id})
3764
+ for sensor_readings_idx, node_id in enumerate(nodes_id):
3765
+ idx = all_nodes_id.index(node_id)
3766
+ node_features[:, idx] = sensor_readings[:, sensor_readings_idx]
3767
+ mask[idx] = 1
3768
+
3769
+ results.append(node_features.reshape(node_features.shape[0], node_features.shape[1],1))
3770
+ masks.append(mask.reshape(-1, 1))
3771
+
3772
+ return np.concatenate(results, axis=2), np.concatenate(masks, axis=1)
3773
+
3107
3774
  def plot_bulk_species_node_concentration(self, bulk_species_node_sensors: dict = None,
3108
3775
  show: bool = True, save_to_file: str = None,
3109
3776
  ax: matplotlib.axes.Axes = None
@@ -3219,6 +3886,50 @@ class ScadaData(Serializable):
3219
3886
  for node_id in bulk_species_sensor_locations[species_id]]
3220
3887
  return self.__sensor_readings[:, idx]
3221
3888
 
3889
+ def get_data_bulk_species_concentrations_as_edge_features(self,
3890
+ default_missing_value: float = 0.
3891
+ ) -> tuple[np.ndarray, np.ndarray]:
3892
+ """
3893
+ Returns the concentrations of bulk species as edge features together with a boolean mask
3894
+ indicating the presence of a sensor.
3895
+
3896
+ Note that only bulk species with at least one sensor are considered.
3897
+
3898
+ Parameters
3899
+ ----------
3900
+ default_missing_value : `float`, optional
3901
+ Default value (i.e. missing value) for links where no bulk species
3902
+ sensor is installed.
3903
+
3904
+ The default is 0.
3905
+
3906
+ Returns
3907
+ -------
3908
+ 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>`_]
3909
+ Concentrations of bulk species as edge features of shape
3910
+ [num_time_steps, num_links * 2, num_species], and mask of
3911
+ shape [num_links * 2, num_species].
3912
+ """
3913
+ masks = []
3914
+ results = []
3915
+
3916
+ bulk_species_sensor_locations = self.__sensor_config.bulk_species_link_sensors
3917
+ for species_id, links_id in bulk_species_sensor_locations.items():
3918
+ mask = np.zeros(2 * len(self.__sensor_config.links))
3919
+ edge_features = np.array([[default_missing_value] * 2 * len(self.__sensor_config.links)
3920
+ for _ in range(len(self.__sensor_readings_time))])
3921
+
3922
+ sensor_readings = self.get_data_bulk_species_link_concentration({species_id: links_id})
3923
+ for sensor_readings_idx, link_id in enumerate(links_id):
3924
+ for idx in self.map_link_id_to_edge_idx(link_id):
3925
+ edge_features[:, idx] = sensor_readings[:, sensor_readings_idx]
3926
+ mask[idx] = 1
3927
+
3928
+ results.append(edge_features.reshape(edge_features.shape[0], edge_features.shape[1], 1))
3929
+ masks.append(mask.reshape(-1, 1))
3930
+
3931
+ return np.concatenate(results, axis=2), np.concatenate(masks, axis=1)
3932
+
3222
3933
  def plot_bulk_species_link_concentration(self, bulk_species_link_sensors: dict = None,
3223
3934
  show: bool = True, save_to_file: str = None,
3224
3935
  ax: matplotlib.axes.Axes = None