Skip to content
This repository was archived by the owner on Dec 20, 2024. It is now read-only.

Commit ccae142

Browse files
chore: Merge Release 0.4.2 pull request #97 from ecmwf/develop
Release 0.4.2
2 parents 837fd06 + cd46c2b commit ccae142

File tree

12 files changed

+363
-23
lines changed

12 files changed

+363
-23
lines changed

.pre-commit-config.yaml

+2-3
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ repos:
4040
- --force-single-line-imports
4141
- --profile black
4242
- repo: https://github.com/astral-sh/ruff-pre-commit
43-
rev: v0.7.2
43+
rev: v0.8.1
4444
hooks:
4545
- id: ruff
4646
args:
@@ -64,7 +64,7 @@ repos:
6464
hooks:
6565
- id: pyproject-fmt
6666
- repo: https://github.com/jshwi/docsig # Check docstrings against function sig
67-
rev: v0.64.0
67+
rev: v0.65.0
6868
hooks:
6969
- id: docsig
7070
args:
@@ -74,6 +74,5 @@ repos:
7474
- --check-protected # Check protected methods
7575
- --check-class # Check class docstrings
7676
- --disable=E113 # Disable empty docstrings
77-
- --summary # Print a summary
7877
ci:
7978
autoupdate_schedule: monthly

CHANGELOG.md

+10
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ Keep it human-readable, your future self will thank you!
1010

1111
## [Unreleased](https://github.com/ecmwf/anemoi-graphs/compare/0.4.1...HEAD)
1212

13+
### Added
14+
15+
- feat: Support for providing lon/lat coordinates from a text file (loaded with numpy loadtxt method) to build the graph `TextNodes` (#93)
16+
- feat: Build 2D graphs with `Voronoi` in case `SphericalVoronoi` does not work well/is an overkill (LAM). Set `flat=true` in the nodes attributes to compute area weight using Voronoi with a qhull options preventing the empty region creation (#93
17+
- feat: Support for defining nodes from lat& lon NumPy arrays (#98)
18+
- feat: new transform functions to map from sin&cos values to latlon (#98)
19+
20+
### Changed
21+
- fix: faster edge builder for tri icosahedron. (#92)
22+
1323
## [0.4.1 - ICON graphs, multiple edge builders and post processors](https://github.com/ecmwf/anemoi-graphs/compare/0.4.0...0.4.1) - 2024-11-26
1424

1525
### Added

docs/graphs/node_coordinates.rst

+2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ a file:
2525
node_coordinates/zarr_dataset
2626
node_coordinates/npz_file
2727
node_coordinates/icon_mesh
28+
node_coordinates/text_file
29+
node_coordinates/latlon_arrays
2830

2931
or based on other algorithms. A commonn approach is to use an
3032
icosahedron to project the earth's surface, and refine it iteratively to
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#######################################
2+
From latitude & longitude coordinates
3+
#######################################
4+
5+
Nodes can also be created directly using latitude and longitude
6+
coordinates. Below is an example demonstrating how to add these nodes to
7+
a graph:
8+
9+
.. code:: python
10+
11+
from anemoi.graphs.nodes import LatLonNodes
12+
13+
...
14+
15+
lats = np.array([45.0, 45.0, 40.0, 40.0])
16+
lons = np.array([5.0, 10.0, 10.0, 5.0])
17+
18+
graph = LatLonNodes(latitudes=lats, longitudes=lons, name="my_nodes").update_graph(graph)
+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
################
2+
From text file
3+
################
4+
5+
To define the `node coordinates` based on a `.txt` file, you can
6+
configure the `.yaml` as follows:
7+
8+
.. code:: yaml
9+
10+
nodes:
11+
data: # name of the nodes
12+
node_builder:
13+
_target_: anemoi.graphs.nodes.TextNodes
14+
dataset: my_file.txt
15+
idx_lon: 0
16+
idx_lat: 1
17+
18+
Here, dataset refers to the path of the `.txt` file that contains the
19+
latitude and longitude values in the columns specified by `idx_lat` and
20+
`idx_lon`, respectively.

src/anemoi/graphs/generate/transforms.py

+36
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,42 @@ def cartesian_to_latlon_rad(xyz: np.ndarray) -> np.ndarray:
5252
return np.array((lat, lon), dtype=np.float32).transpose()
5353

5454

55+
def sincos_to_latlon_rad(sincos: np.ndarray) -> np.ndarray:
56+
"""Sine & cosine components to lat-lon coordinates.
57+
58+
Parameters
59+
----------
60+
sincos : np.ndarray
61+
The sine and cosine componenets of the latitude and longitude. Shape: (N, 4).
62+
The dimensions correspond to: sin(lat), cos(lat), sin(lon) and cos(lon).
63+
64+
Returns
65+
-------
66+
np.ndarray
67+
A 2D array of the coordinates of shape (N, 2) in radians.
68+
"""
69+
latitudes = np.arctan2(sincos[:, 0], sincos[:, 1])
70+
longitudes = np.arctan2(sincos[:, 2], sincos[:, 3])
71+
return np.stack([latitudes, longitudes], axis=-1)
72+
73+
74+
def sincos_to_latlon_degrees(sincos: np.ndarray) -> np.ndarray:
75+
"""Sine & cosine components to lat-lon coordinates.
76+
77+
Parameters
78+
----------
79+
sincos : np.ndarray
80+
The sine and cosine componenets of the latitude and longitude. Shape: (N, 4).
81+
The dimensions correspond to: sin(lat), cos(lat), sin(lon) and cos(lon).
82+
83+
Returns
84+
-------
85+
np.ndarray
86+
A 2D array of the coordinates of shape (N, 2) in degrees.
87+
"""
88+
return np.rad2deg(sincos_to_latlon_rad(sincos))
89+
90+
5591
def latlon_rad_to_cartesian(loc: tuple[np.ndarray, np.ndarray], radius: float = 1) -> np.ndarray:
5692
"""Convert planar coordinates to 3D coordinates in a sphere.
5793

src/anemoi/graphs/generate/tri_icosahedron.py

+41-3
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,8 @@ def add_edges_to_nx_graph(
183183
node_neighbours = get_neighbours_within_hops(r_sphere, x_hops, valid_nodes=valid_nodes)
184184

185185
_, vertex_mapping_index = tree.query(r_vertices_rad, k=1)
186-
for idx_node, idx_neighbours in node_neighbours.items():
187-
graph = add_neigbours_edges(graph, idx_node, idx_neighbours, vertex_mapping_index=vertex_mapping_index)
188-
186+
neighbour_pairs = create_node_neighbours_list(graph, node_neighbours, vertex_mapping_index)
187+
graph.add_edges_from(neighbour_pairs)
189188
return graph
190189

191190

@@ -270,3 +269,42 @@ def add_neigbours_edges(
270269
graph.add_edge(node_neighbour, node)
271270

272271
return graph
272+
273+
274+
def create_node_neighbours_list(
275+
graph: nx.Graph,
276+
node_neighbours: dict[int, set[int]],
277+
vertex_mapping_index: np.ndarray | None = None,
278+
self_loops: bool = False,
279+
) -> list[tuple]:
280+
"""Preprocesses the dict of node neighbours.
281+
282+
Parameters:
283+
-----------
284+
graph: nx.Graph
285+
The graph.
286+
node_neighbours: dict[int, set[int]]
287+
dictionairy with key: node index and value: set of neighbour node indices
288+
vertex_mapping_index: np.ndarry
289+
Index to map the vertices from the refined sphere to the original one, by default None.
290+
self_loops: bool
291+
Whether is supported to add self-loops, by default False.
292+
293+
Returns:
294+
--------
295+
list: tuple
296+
A list with containing node neighbour pairs in tuples
297+
"""
298+
graph_nodes_idx = list(sorted(graph.nodes))
299+
300+
if vertex_mapping_index is None:
301+
vertex_mapping_index = np.arange(len(graph.nodes)).reshape(len(graph.nodes), 1)
302+
303+
neighbour_pairs = [
304+
(graph_nodes_idx[vertex_mapping_index[node_neighbour][0]], graph_nodes_idx[vertex_mapping_index[node][0]])
305+
for node, neighbours in node_neighbours.items()
306+
for node_neighbour in neighbours
307+
if node != node_neighbour or (self_loops and node == node_neighbour)
308+
]
309+
310+
return neighbour_pairs

src/anemoi/graphs/nodes/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from .builders.from_file import LimitedAreaNPZFileNodes
1111
from .builders.from_file import NPZFileNodes
12+
from .builders.from_file import TextNodes
1213
from .builders.from_file import ZarrDatasetNodes
1314
from .builders.from_healpix import HEALPixNodes
1415
from .builders.from_healpix import LimitedAreaHEALPixNodes
@@ -20,13 +21,15 @@
2021
from .builders.from_refined_icosahedron import LimitedAreaTriNodes
2122
from .builders.from_refined_icosahedron import StretchedTriNodes
2223
from .builders.from_refined_icosahedron import TriNodes
24+
from .builders.from_vectors import LatLonNodes
2325

2426
__all__ = [
2527
"ZarrDatasetNodes",
2628
"NPZFileNodes",
2729
"TriNodes",
2830
"HexNodes",
2931
"HEALPixNodes",
32+
"LatLonNodes",
3033
"LimitedAreaHEALPixNodes",
3134
"LimitedAreaNPZFileNodes",
3235
"LimitedAreaTriNodes",
@@ -35,4 +38,5 @@
3538
"ICONMultimeshNodes",
3639
"ICONCellGridNodes",
3740
"ICONNodes",
41+
"TextNodes",
3842
]

src/anemoi/graphs/nodes/attributes.py

+64-16
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
import numpy as np
1919
import torch
2020
from anemoi.datasets import open_dataset
21+
from scipy.spatial import ConvexHull
2122
from scipy.spatial import SphericalVoronoi
23+
from scipy.spatial import Voronoi
2224
from torch_geometric.data import HeteroData
2325
from torch_geometric.data.storage import NodeStorage
2426

@@ -101,6 +103,68 @@ def get_raw_values(self, nodes: NodeStorage, **kwargs) -> np.ndarray:
101103
class AreaWeights(BaseNodeAttribute):
102104
"""Implements the area of the nodes as the weights.
103105
106+
Attributes
107+
----------
108+
flat: bool
109+
If True, the area is computed in 2D, otherwise in 3D.
110+
**other: Any
111+
Additional keyword arguments, see PlanarAreaWeights and SphericalAreaWeights
112+
for details.
113+
114+
Methods
115+
-------
116+
compute(self, graph, nodes_name)
117+
Compute the area attributes for each node.
118+
"""
119+
120+
def __new__(cls, flat: bool = False, **kwargs):
121+
logging.warning(
122+
"Creating %s with flat=%s and kwargs=%s. In a future release, AreaWeights will be deprecated: please use directly PlanarAreaWeights or SphericalAreaWeights.",
123+
cls.__name__,
124+
flat,
125+
kwargs,
126+
)
127+
if flat:
128+
return PlanarAreaWeights(**kwargs)
129+
return SphericalAreaWeights(**kwargs)
130+
131+
132+
class PlanarAreaWeights(BaseNodeAttribute):
133+
"""Implements the 2D area of the nodes as the weights.
134+
135+
Attributes
136+
----------
137+
norm : str
138+
Normalisation of the weights.
139+
140+
Methods
141+
-------
142+
compute(self, graph, nodes_name)
143+
Compute the area attributes for each node.
144+
"""
145+
146+
def __init__(
147+
self,
148+
norm: str | None = None,
149+
dtype: str = "float32",
150+
) -> None:
151+
super().__init__(norm, dtype)
152+
153+
def get_raw_values(self, nodes: NodeStorage, **kwargs) -> np.ndarray:
154+
latitudes, longitudes = nodes.x[:, 0], nodes.x[:, 1]
155+
points = np.stack([latitudes, longitudes], -1)
156+
v = Voronoi(points, qhull_options="QJ Pp")
157+
areas = []
158+
for r in v.regions:
159+
area = ConvexHull(v.vertices[r, :]).volume
160+
areas.append(area)
161+
result = np.asarray(areas)
162+
return result
163+
164+
165+
class SphericalAreaWeights(BaseNodeAttribute):
166+
"""Implements the 3D area of the nodes as the weights.
167+
104168
Attributes
105169
----------
106170
norm : str
@@ -132,22 +196,6 @@ def __init__(
132196
self.fill_value = fill_value
133197

134198
def get_raw_values(self, nodes: NodeStorage, **kwargs) -> np.ndarray:
135-
"""Compute the area associated to each node.
136-
137-
It uses Voronoi diagrams to compute the area of each node.
138-
139-
Parameters
140-
----------
141-
nodes : NodeStorage
142-
Nodes of the graph.
143-
kwargs : dict
144-
Additional keyword arguments.
145-
146-
Returns
147-
-------
148-
np.ndarray
149-
Attributes.
150-
"""
151199
latitudes, longitudes = nodes.x[:, 0], nodes.x[:, 1]
152200
points = latlon_rad_to_cartesian((np.asarray(latitudes), np.asarray(longitudes)))
153201
sv = SphericalVoronoi(points, self.radius, self.centre)

src/anemoi/graphs/nodes/builders/from_file.py

+35-1
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,37 @@ def get_coordinates(self) -> torch.Tensor:
6363
return self.reshape_coords(dataset.latitudes, dataset.longitudes)
6464

6565

66+
class TextNodes(BaseNodeBuilder):
67+
"""Nodes from text file.
68+
69+
Attributes
70+
----------
71+
dataset : str | DictConfig
72+
The path to txt file containing the coordinates of the nodes.
73+
idx_lon : int
74+
The index of the longitude in the dataset.
75+
idx_lat : int
76+
The index of the latitude in the dataset.
77+
"""
78+
79+
def __init__(self, dataset, name: str, idx_lon: int = 0, idx_lat: int = 1) -> None:
80+
LOGGER.info("Reading the dataset from %s.", dataset)
81+
self.dataset = np.loadtxt(dataset)
82+
self.idx_lon = idx_lon
83+
self.idx_lat = idx_lat
84+
super().__init__(name)
85+
86+
def get_coordinates(self) -> torch.Tensor:
87+
"""Get the coordinates of the nodes.
88+
89+
Returns
90+
-------
91+
torch.Tensor of shape (num_nodes, 2)
92+
A 2D tensor with the coordinates, in radians.
93+
"""
94+
return self.reshape_coords(self.dataset[self.idx_lat, :], self.dataset[self.idx_lon, :])
95+
96+
6697
class NPZFileNodes(BaseNodeBuilder):
6798
"""Nodes from NPZ defined grids.
6899
@@ -146,7 +177,10 @@ def get_coordinates(self) -> np.ndarray:
146177
)
147178
area_mask = self.area_mask_builder.get_mask(coords)
148179

149-
LOGGER.info("Dropping %d nodes from the processor mesh.", len(area_mask) - area_mask.sum())
180+
LOGGER.info(
181+
"Dropping %d nodes from the processor mesh.",
182+
len(area_mask) - area_mask.sum(),
183+
)
150184
coords = coords[area_mask]
151185

152186
return coords

0 commit comments

Comments
 (0)