anemoi-graphs 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ # (C) Copyright 2023 European Centre for Medium-Range Weather Forecasts.
2
+ # This software is licensed under the terms of the Apache Licence Version 2.0
3
+ # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
4
+ # In applying this licence, ECMWF does not waive the privileges and immunities
5
+ # granted to it by virtue of its status as an intergovernmental organisation
6
+ # nor does it submit to any jurisdiction.
7
+
8
+ from ._version import __version__
9
+
10
+ EARTH_RADIUS = 6371.0 # km
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env python
2
+ # (C) Copyright 2024 ECMWF.
3
+ #
4
+ # This software is licensed under the terms of the Apache Licence Version 2.0
5
+ # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
6
+ # In applying this licence, ECMWF does not waive the privileges and immunities
7
+ # granted to it by virtue of its status as an intergovernmental organisation
8
+ # nor does it submit to any jurisdiction.
9
+ #
10
+
11
+ from anemoi.utils.cli import cli_main
12
+ from anemoi.utils.cli import make_parser
13
+
14
+ from . import __version__
15
+ from .commands import COMMANDS
16
+
17
+
18
+ # For read-the-docs
19
+ def create_parser():
20
+ return make_parser(__doc__, COMMANDS)
21
+
22
+
23
+ def main():
24
+ cli_main(__version__, __doc__, COMMANDS)
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
@@ -0,0 +1,16 @@
1
+ # file generated by setuptools_scm
2
+ # don't change, don't track in version control
3
+ TYPE_CHECKING = False
4
+ if TYPE_CHECKING:
5
+ from typing import Tuple, Union
6
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
7
+ else:
8
+ VERSION_TUPLE = object
9
+
10
+ version: str
11
+ __version__: str
12
+ __version_tuple__: VERSION_TUPLE
13
+ version_tuple: VERSION_TUPLE
14
+
15
+ __version__ = version = '0.2.0'
16
+ __version_tuple__ = version_tuple = (0, 2, 0)
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env python
2
+ # (C) Copyright 2024 ECMWF.
3
+ #
4
+ # This software is licensed under the terms of the Apache Licence Version 2.0
5
+ # which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
6
+ # In applying this licence, ECMWF does not waive the privileges and immunities
7
+ # granted to it by virtue of its status as an intergovernmental organisation
8
+ # nor does it submit to any jurisdiction.
9
+ #
10
+
11
+ import os
12
+
13
+ from anemoi.utils.cli import Command
14
+ from anemoi.utils.cli import Failed
15
+ from anemoi.utils.cli import register_commands
16
+
17
+ __all__ = ["Command"]
18
+
19
+ COMMANDS = register_commands(
20
+ os.path.dirname(__file__),
21
+ __name__,
22
+ lambda x: x.command(),
23
+ lambda name, error: Failed(name, error),
24
+ )
@@ -0,0 +1,28 @@
1
+ from anemoi.graphs.create import GraphCreator
2
+
3
+ from . import Command
4
+
5
+
6
+ class Create(Command):
7
+ """Create a graph."""
8
+
9
+ internal = True
10
+ timestamp = True
11
+
12
+ def add_arguments(self, command_parser):
13
+ command_parser.add_argument(
14
+ "--overwrite",
15
+ action="store_true",
16
+ help="Overwrite existing files. This will delete the target graph if it already exists.",
17
+ )
18
+ command_parser.add_argument("config", help="Configuration yaml file defining the recipe to create the graph.")
19
+ command_parser.add_argument("path", help="Path to store the created graph.")
20
+
21
+ def run(self, args):
22
+ kwargs = vars(args)
23
+
24
+ c = GraphCreator(**kwargs)
25
+ c.create()
26
+
27
+
28
+ command = Create
@@ -0,0 +1,100 @@
1
+ import logging
2
+ import os
3
+
4
+ import torch
5
+ from anemoi.utils.config import DotDict
6
+ from hydra.utils import instantiate
7
+ from torch_geometric.data import HeteroData
8
+
9
+ LOGGER = logging.getLogger(__name__)
10
+
11
+
12
+ class GraphCreator:
13
+ """Graph creator."""
14
+
15
+ def __init__(
16
+ self,
17
+ path,
18
+ config=None,
19
+ cache=None,
20
+ print=print,
21
+ overwrite=False,
22
+ **kwargs,
23
+ ):
24
+ if isinstance(config, str) or isinstance(config, os.PathLike):
25
+ self.config = DotDict.from_file(config)
26
+ else:
27
+ self.config = config
28
+
29
+ self.path = path # Output path
30
+ self.cache = cache
31
+ self.print = print
32
+ self.overwrite = overwrite
33
+
34
+ def init(self):
35
+ if self._path_readable() and not self.overwrite:
36
+ raise Exception(f"{self.path} already exists. Use overwrite=True to overwrite.")
37
+
38
+ def generate_graph(self) -> HeteroData:
39
+ """Generate the graph.
40
+
41
+ It instantiates the node builders and edge builders defined in the configuration
42
+ file and applies them to the graph.
43
+
44
+ Returns
45
+ -------
46
+ HeteroData: The generated graph.
47
+ """
48
+ graph = HeteroData()
49
+
50
+ for nodes_name, nodes_cfg in self.config.nodes.items():
51
+ graph = instantiate(nodes_cfg.node_builder, name=nodes_name).update_graph(
52
+ graph, nodes_cfg.get("attributes", {})
53
+ )
54
+
55
+ for edges_cfg in self.config.edges:
56
+ graph = instantiate(edges_cfg.edge_builder, edges_cfg.source_name, edges_cfg.target_name).update_graph(
57
+ graph, edges_cfg.get("attributes", {})
58
+ )
59
+
60
+ return graph
61
+
62
+ def clean(self, graph: HeteroData) -> HeteroData:
63
+ """Clean the hidden attributes of the nodes and edges."""
64
+ for nodes_name in graph.node_types:
65
+ node_attrs = list(graph[nodes_name].keys())
66
+ for node_attr_name in node_attrs:
67
+ if node_attr_name.startswith("_"):
68
+ del graph[nodes_name][node_attr_name]
69
+
70
+ for edge_key in graph.edge_types:
71
+ edge_attrs = graph[edge_key].keys()
72
+ for edge_attr_name in edge_attrs:
73
+ if edge_attr_name.startswith("_"):
74
+ del graph[edge_key][edge_attr_name]
75
+
76
+ return graph
77
+
78
+ def save(self, graph: HeteroData) -> None:
79
+ """Save the graph to the output path."""
80
+ if not os.path.exists(self.path) or self.overwrite:
81
+ torch.save(graph, self.path)
82
+ self.print(f"Graph saved at {self.path}.")
83
+
84
+ def create(self) -> HeteroData:
85
+ """Create the graph and save it to the output path."""
86
+ self.init()
87
+ graph = self.generate_graph()
88
+ graph = self.clean(graph)
89
+ self.save(graph)
90
+ return graph
91
+
92
+ def _path_readable(self) -> bool:
93
+ """Check if the output path is readable."""
94
+ import torch
95
+
96
+ try:
97
+ torch.load(self.path)
98
+ return True
99
+ except FileNotFoundError:
100
+ return False
@@ -0,0 +1,5 @@
1
+ from .builder import CutOffEdges
2
+ from .builder import KNNEdges
3
+ from .builder import MultiScaleEdges
4
+
5
+ __all__ = ["KNNEdges", "CutOffEdges", "MultiScaleEdges"]
@@ -0,0 +1,149 @@
1
+ import logging
2
+ from abc import ABC
3
+ from abc import abstractmethod
4
+ from typing import Optional
5
+
6
+ import numpy as np
7
+ import torch
8
+ from torch_geometric.data import HeteroData
9
+
10
+ from anemoi.graphs.edges.directional import directional_edge_features
11
+ from anemoi.graphs.normalizer import NormalizerMixin
12
+ from anemoi.graphs.utils import haversine_distance
13
+
14
+ LOGGER = logging.getLogger(__name__)
15
+
16
+
17
+ class BaseEdgeAttribute(ABC, NormalizerMixin):
18
+ """Base class for edge attributes."""
19
+
20
+ def __init__(self, norm: Optional[str] = None) -> None:
21
+ self.norm = norm
22
+
23
+ @abstractmethod
24
+ def get_raw_values(self, graph: HeteroData, source_name: str, target_name: str, *args, **kwargs) -> np.ndarray: ...
25
+
26
+ def post_process(self, values: np.ndarray) -> torch.Tensor:
27
+ """Post-process the values."""
28
+ if values.ndim == 1:
29
+ values = values[:, np.newaxis]
30
+
31
+ normed_values = self.normalize(values)
32
+
33
+ return torch.tensor(normed_values, dtype=torch.float32)
34
+
35
+ def compute(self, graph: HeteroData, edges_name: tuple[str, str, str], *args, **kwargs) -> torch.Tensor:
36
+ """Compute the edge attributes."""
37
+ source_name, _, target_name = edges_name
38
+ assert (
39
+ source_name in graph.node_types
40
+ ), f"Node \"{source_name}\" not found in graph. Optional nodes are {', '.join(graph.node_types)}."
41
+ assert (
42
+ target_name in graph.node_types
43
+ ), f"Node \"{target_name}\" not found in graph. Optional nodes are {', '.join(graph.node_types)}."
44
+
45
+ values = self.get_raw_values(graph, source_name, target_name, *args, **kwargs)
46
+ return self.post_process(values)
47
+
48
+
49
+ class EdgeDirection(BaseEdgeAttribute):
50
+ """Edge direction feature.
51
+
52
+ If using the rotated features, the direction of the edge is computed
53
+ rotating the target nodes to the north pole. If not, it is computed
54
+ as the diference in latitude and longitude between the source and
55
+ target nodes.
56
+
57
+ Attributes
58
+ ----------
59
+ norm : Optional[str]
60
+ Normalization method.
61
+ luse_rotated_features : bool
62
+ Whether to use rotated features.
63
+
64
+ Methods
65
+ -------
66
+ get_raw_values(graph, source_name, target_name)
67
+ Compute directions between nodes connected by edges.
68
+ compute(graph, source_name, target_name)
69
+ Compute directional attributes.
70
+ """
71
+
72
+ def __init__(self, norm: Optional[str] = None, luse_rotated_features: bool = True) -> None:
73
+ super().__init__(norm)
74
+ self.luse_rotated_features = luse_rotated_features
75
+
76
+ def get_raw_values(self, graph: HeteroData, source_name: str, target_name: str) -> np.ndarray:
77
+ """Compute directional features for edges.
78
+
79
+ Parameters
80
+ ----------
81
+ graph : HeteroData
82
+ The graph.
83
+ source_name : str
84
+ The name of the source nodes.
85
+ target_name : str
86
+ The name of the target nodes.
87
+
88
+ Returns
89
+ -------
90
+ np.ndarray
91
+ The directional features.
92
+ """
93
+ edge_index = graph[(source_name, "to", target_name)].edge_index
94
+ source_coords = graph[source_name].x.numpy()[edge_index[0]].T
95
+ target_coords = graph[target_name].x.numpy()[edge_index[1]].T
96
+ edge_dirs = directional_edge_features(source_coords, target_coords, self.luse_rotated_features).T
97
+ return edge_dirs
98
+
99
+
100
+ class EdgeLength(BaseEdgeAttribute):
101
+ """Edge length feature.
102
+
103
+ Attributes
104
+ ----------
105
+ norm : str
106
+ Normalization method.
107
+ invert : bool
108
+ Whether to invert the edge lengths, i.e. 1 - edge_length.
109
+
110
+ Methods
111
+ -------
112
+ get_raw_values(graph, source_name, target_name)
113
+ Compute haversine distance between nodes connected by edges.
114
+ compute(graph, source_name, target_name)
115
+ Compute edge lengths attributes.
116
+ """
117
+
118
+ def __init__(self, norm: Optional[str] = None, invert: bool = False) -> None:
119
+ super().__init__(norm)
120
+ self.invert = invert
121
+
122
+ def get_raw_values(self, graph: HeteroData, source_name: str, target_name: str) -> np.ndarray:
123
+ """Compute haversine distance (in kilometers) between nodes connected by edges.
124
+
125
+ Parameters
126
+ ----------
127
+ graph : HeteroData
128
+ The graph.
129
+ source_name : str
130
+ The name of the source nodes.
131
+ target_name : str
132
+ The name of the target nodes.
133
+
134
+ Returns
135
+ -------
136
+ np.ndarray
137
+ The edge lengths.
138
+ """
139
+ edge_index = graph[(source_name, "to", target_name)].edge_index
140
+ source_coords = graph[source_name].x.numpy()[edge_index[0]]
141
+ target_coords = graph[target_name].x.numpy()[edge_index[1]]
142
+ edge_lengths = haversine_distance(source_coords, target_coords)
143
+ return edge_lengths
144
+
145
+ def post_process(self, values: np.ndarray) -> torch.Tensor:
146
+ """Post-process edge lengths."""
147
+ if self.invert:
148
+ values = 1 - values
149
+ return super().post_process(values)
@@ -0,0 +1,328 @@
1
+ import logging
2
+ from abc import ABC
3
+ from abc import abstractmethod
4
+ from typing import Optional
5
+
6
+ import networkx as nx
7
+ import numpy as np
8
+ import torch
9
+ from anemoi.utils.config import DotDict
10
+ from hydra.utils import instantiate
11
+ from sklearn.neighbors import NearestNeighbors
12
+ from torch_geometric.data import HeteroData
13
+ from torch_geometric.data.storage import NodeStorage
14
+
15
+ from anemoi.graphs import EARTH_RADIUS
16
+ from anemoi.graphs.generate import hexagonal
17
+ from anemoi.graphs.generate import icosahedral
18
+ from anemoi.graphs.nodes.builder import HexNodes
19
+ from anemoi.graphs.nodes.builder import TriNodes
20
+ from anemoi.graphs.utils import get_grid_reference_distance
21
+
22
+ LOGGER = logging.getLogger(__name__)
23
+
24
+
25
+ class BaseEdgeBuilder(ABC):
26
+ """Base class for edge builders."""
27
+
28
+ def __init__(self, source_name: str, target_name: str):
29
+ self.source_name = source_name
30
+ self.target_name = target_name
31
+
32
+ @property
33
+ def name(self) -> tuple[str, str, str]:
34
+ """Name of the edge subgraph."""
35
+ return self.source_name, "to", self.target_name
36
+
37
+ @abstractmethod
38
+ def get_adjacency_matrix(self, source_nodes: NodeStorage, target_nodes: NodeStorage): ...
39
+
40
+ def prepare_node_data(self, graph: HeteroData) -> tuple[NodeStorage, NodeStorage]:
41
+ """Prepare node information and get source and target nodes."""
42
+ return graph[self.source_name], graph[self.target_name]
43
+
44
+ def get_edge_index(self, graph: HeteroData) -> torch.Tensor:
45
+ """Get the edge indices of source and target nodes.
46
+
47
+ Parameters
48
+ ----------
49
+ graph : HeteroData
50
+ The graph.
51
+
52
+ Returns
53
+ -------
54
+ torch.Tensor of shape (2, num_edges)
55
+ The edge indices.
56
+ """
57
+ source_nodes, target_nodes = self.prepare_node_data(graph)
58
+
59
+ adjmat = self.get_adjacency_matrix(source_nodes, target_nodes)
60
+
61
+ # Get source & target indices of the edges
62
+ edge_index = np.stack([adjmat.col, adjmat.row], axis=0)
63
+
64
+ return torch.from_numpy(edge_index).to(torch.int32)
65
+
66
+ def register_edges(self, graph: HeteroData) -> HeteroData:
67
+ """Register edges in the graph.
68
+
69
+ Parameters
70
+ ----------
71
+ graph : HeteroData
72
+ The graph to register the edges.
73
+
74
+ Returns
75
+ -------
76
+ HeteroData
77
+ The graph with the registered edges.
78
+ """
79
+ graph[self.name].edge_index = self.get_edge_index(graph)
80
+ graph[self.name].edge_type = type(self).__name__
81
+ return graph
82
+
83
+ def register_attributes(self, graph: HeteroData, config: DotDict) -> HeteroData:
84
+ """Register attributes in the edges of the graph specified.
85
+
86
+ Parameters
87
+ ----------
88
+ graph : HeteroData
89
+ The graph to register the attributes.
90
+ config : DotDict
91
+ The configuration of the attributes.
92
+
93
+ Returns
94
+ -------
95
+ HeteroData
96
+ The graph with the registered attributes.
97
+ """
98
+ for attr_name, attr_config in config.items():
99
+ graph[self.name][attr_name] = instantiate(attr_config).compute(graph, self.name)
100
+ return graph
101
+
102
+ def update_graph(self, graph: HeteroData, attrs_config: Optional[DotDict] = None) -> HeteroData:
103
+ """Update the graph with the edges.
104
+
105
+ Parameters
106
+ ----------
107
+ graph : HeteroData
108
+ The graph.
109
+ attrs_config : DotDict
110
+ The configuration of the edge attributes.
111
+
112
+ Returns
113
+ -------
114
+ HeteroData
115
+ The graph with the edges.
116
+ """
117
+ graph = self.register_edges(graph)
118
+
119
+ if attrs_config is None:
120
+ return graph
121
+
122
+ graph = self.register_attributes(graph, attrs_config)
123
+
124
+ return graph
125
+
126
+
127
+ class KNNEdges(BaseEdgeBuilder):
128
+ """Computes KNN based edges and adds them to the graph.
129
+
130
+ Attributes
131
+ ----------
132
+ source_name : str
133
+ The name of the source nodes.
134
+ target_name : str
135
+ The name of the target nodes.
136
+ num_nearest_neighbours : int
137
+ Number of nearest neighbours.
138
+
139
+ Methods
140
+ -------
141
+ get_adjacency_matrix(source_nodes, target_nodes)
142
+ Compute the adjacency matrix for the KNN method.
143
+ register_edges(graph)
144
+ Register the edges in the graph.
145
+ register_attributes(graph, config)
146
+ Register attributes in the edges of the graph.
147
+ update_graph(graph, attrs_config)
148
+ Update the graph with the edges.
149
+ """
150
+
151
+ def __init__(self, source_name: str, target_name: str, num_nearest_neighbours: int):
152
+ super().__init__(source_name, target_name)
153
+ assert isinstance(num_nearest_neighbours, int), "Number of nearest neighbours must be an integer"
154
+ assert num_nearest_neighbours > 0, "Number of nearest neighbours must be positive"
155
+ self.num_nearest_neighbours = num_nearest_neighbours
156
+
157
+ def get_adjacency_matrix(self, source_nodes: np.ndarray, target_nodes: np.ndarray):
158
+ """Compute the adjacency matrix for the KNN method.
159
+
160
+ Parameters
161
+ ----------
162
+ source_nodes : np.ndarray
163
+ The source nodes.
164
+ target_nodes : np.ndarray
165
+ The target nodes.
166
+ """
167
+ assert self.num_nearest_neighbours is not None, "number of neighbors required for knn encoder"
168
+ LOGGER.info(
169
+ "Using KNN-Edges (with %d nearest neighbours) between %s and %s.",
170
+ self.num_nearest_neighbours,
171
+ self.source_name,
172
+ self.target_name,
173
+ )
174
+
175
+ nearest_neighbour = NearestNeighbors(metric="haversine", n_jobs=4)
176
+ nearest_neighbour.fit(source_nodes.x.numpy())
177
+ adj_matrix = nearest_neighbour.kneighbors_graph(
178
+ target_nodes.x.numpy(),
179
+ n_neighbors=self.num_nearest_neighbours,
180
+ mode="distance",
181
+ ).tocoo()
182
+ return adj_matrix
183
+
184
+
185
+ class CutOffEdges(BaseEdgeBuilder):
186
+ """Computes cut-off based edges and adds them to the graph.
187
+
188
+ Attributes
189
+ ----------
190
+ source_name : str
191
+ The name of the source nodes.
192
+ target_name : str
193
+ The name of the target nodes.
194
+ cutoff_factor : float
195
+ Factor to multiply the grid reference distance to get the cut-off radius.
196
+
197
+ Methods
198
+ -------
199
+ get_cutoff_radius(graph, mask_attr)
200
+ Compute the cut-off radius.
201
+ get_adjacency_matrix(source_nodes, target_nodes)
202
+ Get the adjacency matrix for the cut-off method.
203
+ register_edges(graph)
204
+ Register the edges in the graph.
205
+ register_attributes(graph, config)
206
+ Register attributes in the edges of the graph.
207
+ update_graph(graph, attrs_config)
208
+ Update the graph with the edges.
209
+ """
210
+
211
+ def __init__(self, source_name: str, target_name: str, cutoff_factor: float):
212
+ super().__init__(source_name, target_name)
213
+ assert isinstance(cutoff_factor, (int, float)), "Cutoff factor must be a float"
214
+ assert cutoff_factor > 0, "Cutoff factor must be positive"
215
+ self.cutoff_factor = cutoff_factor
216
+
217
+ def get_cutoff_radius(self, graph: HeteroData, mask_attr: Optional[torch.Tensor] = None):
218
+ """Compute the cut-off radius.
219
+
220
+ The cut-off radius is computed as the product of the target nodes reference distance and the cut-off factor.
221
+
222
+ Parameters
223
+ ----------
224
+ graph : HeteroData
225
+ The graph.
226
+ mask_attr : torch.Tensor
227
+ The mask attribute.
228
+
229
+ Returns
230
+ -------
231
+ float
232
+ The cut-off radius.
233
+ """
234
+ target_nodes = graph[self.target_name]
235
+ mask = target_nodes[mask_attr] if mask_attr is not None else None
236
+ target_grid_reference_distance = get_grid_reference_distance(target_nodes.x, mask)
237
+ radius = target_grid_reference_distance * self.cutoff_factor
238
+ return radius
239
+
240
+ def prepare_node_data(self, graph: HeteroData) -> tuple[NodeStorage, NodeStorage]:
241
+ """Prepare node information and get source and target nodes."""
242
+ self.radius = self.get_cutoff_radius(graph)
243
+ return super().prepare_node_data(graph)
244
+
245
+ def get_adjacency_matrix(self, source_nodes: NodeStorage, target_nodes: NodeStorage):
246
+ """Get the adjacency matrix for the cut-off method.
247
+
248
+ Parameters
249
+ ----------
250
+ source_nodes : NodeStorage
251
+ The source nodes.
252
+ target_nodes : NodeStorage
253
+ The target nodes.
254
+ """
255
+ LOGGER.info(
256
+ "Using CutOff-Edges (with radius = %.1f km) between %s and %s.",
257
+ self.radius * EARTH_RADIUS,
258
+ self.source_name,
259
+ self.target_name,
260
+ )
261
+
262
+ nearest_neighbour = NearestNeighbors(metric="haversine", n_jobs=4)
263
+ nearest_neighbour.fit(source_nodes.x)
264
+ adj_matrix = nearest_neighbour.radius_neighbors_graph(target_nodes.x, radius=self.radius).tocoo()
265
+ return adj_matrix
266
+
267
+
268
+ class MultiScaleEdges(BaseEdgeBuilder):
269
+ """Base class for multi-scale edges in the nodes of a graph."""
270
+
271
+ def __init__(self, source_name: str, target_name: str, x_hops: int):
272
+ super().__init__(source_name, target_name)
273
+ assert source_name == target_name, f"{self.__class__.__name__} requires source and target nodes to be the same."
274
+ assert isinstance(x_hops, int), "Number of x_hops must be an integer"
275
+ assert x_hops > 0, "Number of x_hops must be positive"
276
+ self.x_hops = x_hops
277
+
278
+ def adjacency_from_tri_nodes(self, source_nodes: NodeStorage):
279
+ source_nodes["_nx_graph"] = icosahedral.add_edges_to_nx_graph(
280
+ source_nodes["_nx_graph"],
281
+ resolutions=source_nodes["_resolutions"],
282
+ x_hops=self.x_hops,
283
+ ) # HeteroData refuses to accept None
284
+
285
+ adjmat = nx.to_scipy_sparse_array(
286
+ source_nodes["_nx_graph"], nodelist=list(range(len(source_nodes["_nx_graph"]))), format="coo"
287
+ )
288
+ return adjmat
289
+
290
+ def adjacency_from_hex_nodes(self, source_nodes: NodeStorage):
291
+
292
+ source_nodes["_nx_graph"] = hexagonal.add_edges_to_nx_graph(
293
+ source_nodes["_nx_graph"],
294
+ resolutions=source_nodes["_resolutions"],
295
+ x_hops=self.x_hops,
296
+ )
297
+
298
+ adjmat = nx.to_scipy_sparse_array(source_nodes["_nx_graph"], format="coo")
299
+ return adjmat
300
+
301
+ def get_adjacency_matrix(self, source_nodes: NodeStorage, target_nodes: NodeStorage):
302
+ if self.node_type == TriNodes.__name__:
303
+ adjmat = self.adjacency_from_tri_nodes(source_nodes)
304
+ elif self.node_type == HexNodes.__name__:
305
+ adjmat = self.adjacency_from_hex_nodes(source_nodes)
306
+ else:
307
+ raise ValueError(f"Invalid node type {self.node_type}")
308
+
309
+ adjmat = self.post_process_adjmat(source_nodes, adjmat)
310
+
311
+ return adjmat
312
+
313
+ def post_process_adjmat(self, nodes: NodeStorage, adjmat):
314
+ graph_sorted = {node_pos: i for i, node_pos in enumerate(nodes["_node_ordering"])}
315
+ sort_func = np.vectorize(graph_sorted.get)
316
+ adjmat.row = sort_func(adjmat.row)
317
+ adjmat.col = sort_func(adjmat.col)
318
+ return adjmat
319
+
320
+ def update_graph(self, graph: HeteroData, attrs_config: DotDict | None = None) -> HeteroData:
321
+ assert (
322
+ graph[self.source_name].node_type == TriNodes.__name__
323
+ or graph[self.source_name].node_type == HexNodes.__name__
324
+ ), f"{self.__class__.__name__} requires {TriNodes.__name__} or {HexNodes.__name__}."
325
+
326
+ self.node_type = graph[self.source_name].node_type
327
+
328
+ return super().update_graph(graph, attrs_config)