Skip to content

Commit 56d7190

Browse files
committed
Preserve coords and attrs in topological aggregations
Node-to-face and node-to-edge aggregations rebuilt the output UxDataArray from data/dims/name only, so every coordinate and all variable metadata were dropped. A result keeps its 'time' dimension but loses the 'time' coordinate, which breaks label-based indexing downstream: .sel(time=...), groupby('time.season') and .resample(time=...) all raise KeyError, and units/long_name are lost for plotting and CF output. Carry over any coordinate that does not span the reduced node dimension, along with the variable attrs. Coordinates along n_node are still dropped, since they no longer match the length of the output dimension.
1 parent 3228024 commit 56d7190

2 files changed

Lines changed: 83 additions & 0 deletions

File tree

test/core/test_topological_agg.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import uxarray as ux
22

3+
import numpy as np
4+
import numpy.testing as nt
5+
import pandas as pd
36
import pytest
47

58

@@ -32,3 +35,63 @@ def test_node_to_edge_aggs(gridpath):
3235
grid_reduction = getattr(uxds['areaTriangle'], agg_func)(destination='edge')
3336

3437
assert 'n_edge' in grid_reduction.dims
38+
39+
40+
def _timeseries_uxda(gridpath):
41+
"""Node-centered data with a labelled time axis and CF-style attributes."""
42+
uxgrid = ux.open_grid(gridpath("mpas", "QU", "oQU480.231010.nc"))
43+
rng = np.random.default_rng(0)
44+
return ux.UxDataArray(
45+
rng.random((6, uxgrid.n_node)),
46+
dims=("time", "n_node"),
47+
coords={"time": pd.date_range("2000-01-01", periods=6, freq="MS")},
48+
uxgrid=uxgrid,
49+
name="var",
50+
attrs={"units": "m", "long_name": "sea surface height"},
51+
)
52+
53+
54+
@pytest.mark.parametrize("destination", ["face", "edge"])
55+
def test_agg_preserves_leading_coords_and_attrs(gridpath, destination):
56+
"""Aggregating over the node dimension must not discard the leading
57+
coordinates or the variable metadata. Regression test for topological
58+
aggregations returning a coordinate-less result, which broke label-based
59+
indexing (``.sel``/``.groupby``/``.resample``) on the output.
60+
"""
61+
uxda = _timeseries_uxda(gridpath)
62+
63+
for agg_func in AGGS:
64+
result = getattr(uxda, agg_func)(destination=destination)
65+
66+
assert "time" in result.coords
67+
nt.assert_array_equal(result.time.values, uxda.time.values)
68+
assert result.attrs == uxda.attrs
69+
70+
71+
@pytest.mark.parametrize("destination", ["face", "edge"])
72+
def test_agg_result_supports_label_based_indexing(gridpath, destination):
73+
"""The preserved time axis must actually be usable downstream."""
74+
result = _timeseries_uxda(gridpath).topological_mean(destination=destination)
75+
76+
grid_dim = f"n_{destination}"
77+
assert result.sel(time="2000-03-01").dims == (grid_dim,)
78+
assert (
79+
result.groupby("time.season").mean().sizes[grid_dim] == result.sizes[grid_dim]
80+
)
81+
assert result.resample(time="QS").mean().sizes["time"] == 2
82+
83+
84+
@pytest.mark.parametrize("destination", ["face", "edge"])
85+
def test_agg_drops_node_spanning_coords(gridpath, destination):
86+
"""Coordinates along the reduced dimension cannot be carried over, since
87+
they no longer match the length of the output dimension.
88+
"""
89+
uxda = _timeseries_uxda(gridpath)
90+
rng = np.random.default_rng(1)
91+
uxda = uxda.assign_coords(node_lon=("n_node", rng.random(uxda.uxgrid.n_node)))
92+
93+
result = uxda.topological_mean(destination=destination)
94+
95+
assert "node_lon" not in result.coords
96+
assert "n_node" not in result.dims
97+
assert "time" in result.coords

uxarray/core/aggregation.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,22 @@
1818
}
1919

2020

21+
def _non_source_coords(uxda, source_dim):
22+
"""Coordinates that survive a topological aggregation.
23+
24+
The source dimension is reduced away, so any coordinate spanning it (the
25+
grid dimension itself, or auxiliary coordinates like ``node_lon``) cannot be
26+
carried over. Everything else -- most importantly the leading dimensions
27+
such as ``time`` or ``lev`` -- is untouched by the aggregation and must be
28+
preserved so that label-based indexing keeps working on the result.
29+
"""
30+
return {
31+
name: coord
32+
for name, coord in uxda.coords.items()
33+
if source_dim not in coord.dims
34+
}
35+
36+
2137
def _uxda_grid_aggregate(uxda, destination, aggregation, **kwargs):
2238
"""Applies a desired aggregation on the data stored in the provided
2339
UxDataArray."""
@@ -96,6 +112,8 @@ def _node_to_face_aggregation(uxda, aggregation, aggregation_func_kwargs):
96112
uxgrid=uxda.uxgrid,
97113
data=aggregated_var,
98114
dims=uxda.dims,
115+
coords=_non_source_coords(uxda, "n_node"),
116+
attrs=uxda.attrs,
99117
name=uxda.name,
100118
).rename({"n_node": "n_face"})
101119

@@ -164,6 +182,8 @@ def _node_to_edge_aggregation(uxda, aggregation, aggregation_func_kwargs):
164182
uxgrid=uxda.uxgrid,
165183
data=aggregation_var,
166184
dims=uxda.dims,
185+
coords=_non_source_coords(uxda, "n_node"),
186+
attrs=uxda.attrs,
167187
name=uxda.name,
168188
).rename({"n_node": "n_edge"})
169189

0 commit comments

Comments
 (0)