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.
- anemoi/graphs/__init__.py +10 -0
- anemoi/graphs/__main__.py +28 -0
- anemoi/graphs/_version.py +16 -0
- anemoi/graphs/commands/__init__.py +24 -0
- anemoi/graphs/commands/create.py +28 -0
- anemoi/graphs/create.py +100 -0
- anemoi/graphs/edges/__init__.py +5 -0
- anemoi/graphs/edges/attributes.py +149 -0
- anemoi/graphs/edges/builder.py +328 -0
- anemoi/graphs/edges/directional.py +85 -0
- anemoi/graphs/generate/__init__.py +0 -0
- anemoi/graphs/generate/hexagonal.py +229 -0
- anemoi/graphs/generate/icosahedral.py +207 -0
- anemoi/graphs/generate/transforms.py +95 -0
- anemoi/graphs/nodes/__init__.py +6 -0
- anemoi/graphs/nodes/attributes.py +126 -0
- anemoi/graphs/nodes/builder.py +246 -0
- anemoi/graphs/normalizer.py +44 -0
- anemoi/graphs/utils.py +133 -0
- anemoi_graphs-0.2.0.dist-info/LICENSE +201 -0
- anemoi_graphs-0.2.0.dist-info/METADATA +258 -0
- anemoi_graphs-0.2.0.dist-info/RECORD +25 -0
- anemoi_graphs-0.2.0.dist-info/WHEEL +5 -0
- anemoi_graphs-0.2.0.dist-info/entry_points.txt +2 -0
- anemoi_graphs-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
anemoi/graphs/create.py
ADDED
|
@@ -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,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)
|