Skip to content

Commit 5711d8a

Browse files
committed
Merge branch 'main' into rajeeja/zonal-mean-analytic-band
2 parents e0e0422 + 8449148 commit 5711d8a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

52 files changed

+3771
-4998
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
fail-fast: false
2020
matrix:
2121
os: [ "ubuntu-latest", "macos-latest", "windows-latest"]
22-
python-version: [ "3.10", "3.11", "3.12", "3.13"]
22+
python-version: [ "3.11", "3.12", "3.13"]
2323
steps:
2424
- name: Cancel previous runs
2525
uses: styfle/[email protected]

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ repos:
1414

1515
- repo: https://github.com/astral-sh/ruff-pre-commit
1616
# Ruff version.
17-
rev: v0.13.0
17+
rev: v0.13.1
1818
hooks:
1919
- id: ruff
2020
name: lint with ruff

docs/api.rst

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,6 @@ Methods
148148
Grid.copy
149149
Grid.chunk
150150
Grid.validate
151-
Grid.compute_face_areas
152151
Grid.calculate_total_face_area
153152
Grid.normalize_cartesian_coordinates
154153
Grid.construct_face_centers
@@ -453,6 +452,17 @@ on each face.
453452
UxDataArray.topological_all
454453
UxDataArray.topological_any
455454

455+
Azimuthal
456+
~~~~~~~~~
457+
458+
Azimuthal aggregations apply an aggregation (i.e. averaging) along circles of constant great-circle distance from a specified point on the sphere.
459+
460+
461+
.. autosummary::
462+
:toctree: generated/
463+
464+
UxDataArray.azimuthal_mean
465+
456466
Zonal Average
457467
~~~~~~~~~~~~~
458468
.. autosummary::

docs/user-guide/area_calc.ipynb

Lines changed: 56 additions & 1533 deletions
Large diffs are not rendered by default.

docs/user-guide/healpix.ipynb

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,9 @@
109109
"cell_type": "markdown",
110110
"id": "b464bd06cf9dbf3b",
111111
"metadata": {},
112-
"source": "However, even if you load in a HEALPix grid specifying that you do not want the connectivity upfront, they can still be constructed when desired because of UXarray's internal design."
112+
"source": [
113+
"However, even if you load in a HEALPix grid specifying that you do not want the connectivity upfront, they can still be constructed when desired because of UXarray's internal design."
114+
]
113115
},
114116
{
115117
"cell_type": "code",
@@ -191,7 +193,9 @@
191193
"cell_type": "markdown",
192194
"id": "e1ef5671b3006b0c",
193195
"metadata": {},
194-
"source": "Before remapping, we can plot our Source and Destination grids."
196+
"source": [
197+
"Before remapping, we can plot our Source and Destination grids."
198+
]
195199
},
196200
{
197201
"cell_type": "code",
@@ -209,7 +213,9 @@
209213
"cell_type": "markdown",
210214
"id": "d9f677166dd1b9f",
211215
"metadata": {},
212-
"source": "We can now perform our remapping. In this case, we apply a simple nearest neighbor remapping."
216+
"source": [
217+
"We can now perform our remapping. In this case, we apply a simple nearest neighbor remapping."
218+
]
213219
},
214220
{
215221
"cell_type": "code",
@@ -226,7 +232,9 @@
226232
"cell_type": "markdown",
227233
"id": "f829232283eeb4",
228234
"metadata": {},
229-
"source": "Our original data variable now resides on our HEALPix grid."
235+
"source": [
236+
"Our original data variable now resides on our HEALPix grid."
237+
]
230238
},
231239
{
232240
"cell_type": "code",
@@ -259,6 +267,94 @@
259267
"psi_hp.to_dataset().to_xarray(grid_format=\"HEALPix\").to_netcdf(\"psi_healpix.nc\")"
260268
]
261269
},
270+
{
271+
"cell_type": "markdown",
272+
"id": "healpix_equal_area_section",
273+
"metadata": {},
274+
"source": [
275+
"## HEALPix Equal Area Property\n",
276+
"\n",
277+
"HEALPix's fundamental property is that **all pixels at a given resolution have exactly the same spherical area**. This \"equal area\" property is crucial for scientific applications such as global averaging, zonal means, and conservation properties in regridding operations.\n",
278+
"\n",
279+
"The theoretical area of each HEALPix pixel is given by:\n",
280+
"\n",
281+
"$$\n",
282+
"A_{\\text{pixel}} = \\frac{4\\pi}{N_{\\text{pix}}} = \\frac{4\\pi}{12 \\cdot 4^{\\text{resolution}}}\n",
283+
"$$\n",
284+
"\n",
285+
"where \n",
286+
"$N_{\\text{pix}} = 12 \\cdot 4^{\\text{resolution}}$ is the total number of pixels at a given resolution level, and \n",
287+
"$4\\pi$ is the total surface area of the unit sphere in steradians. \n",
288+
"\n",
289+
"### Geometric Representation Considerations\n",
290+
"\n",
291+
"UXarray represents HEALPix grids using Great Circle Arcs (GCAs) to define pixel boundaries, following UGRID conventions. However, this geometric representation can introduce systematic errors when computing areas numerically, potentially violating HEALPix's equal-area property. \n",
292+
"\n",
293+
"```{note}\n",
294+
"To alleviate the impacts of this systematic differences between UXarray and HEALPix, we adjust our `Grid.face_areas` property to fulfill the HEALPix equal area property, making sure that all the faces in a HEALPix mesh have the same theoretical HEALPix area.\n",
295+
"```\n",
296+
"\n",
297+
"Let's demonstrate this with HEALPix's 12 base pixels:"
298+
]
299+
},
300+
{
301+
"cell_type": "code",
302+
"execution_count": null,
303+
"id": "healpix_area_demo",
304+
"metadata": {},
305+
"outputs": [],
306+
"source": [
307+
"import numpy as np\n",
308+
"\n",
309+
"# Create HEALPix grid at resolution 0 (12 base pixels)\n",
310+
"grid = ux.Grid.from_healpix(0, pixels_only=False)\n",
311+
"\n",
312+
"# Using face_areas property ensures equal areas for HEALPix\n",
313+
"hp_areas = grid.face_areas.values\n",
314+
"print(f\"Standard deviation: {np.std(hp_areas):.2e}\")\n",
315+
"print(f\"All pixels have area: {hp_areas[0]:.6f} steradians\")"
316+
]
317+
},
318+
{
319+
"cell_type": "markdown",
320+
"id": "6e8b7185-c3ae-4f50-939b-4cb28a952e6f",
321+
"metadata": {},
322+
"source": [
323+
"### Still need geometric face area calculations for a HEALPix mesh?\n",
324+
"\n",
325+
"For most use cases, the `Grid.face_areas` property provides the recommended approach for accessing face areas. However, if you specifically need geometric calculations of individual HEALPix faces as they are represented in UXarray (rather than the theoretical equal areas), you may want to access the internal computation method, `Grid._compute_face_areas()`. Note that this approach may not preserve HEALPix's equal-area property due to geometric representation differences. \n",
326+
"\n",
327+
"Look at the following example:"
328+
]
329+
},
330+
{
331+
"cell_type": "code",
332+
"execution_count": null,
333+
"id": "dfd494b5-0b3e-472f-8896-bd3566747bdb",
334+
"metadata": {},
335+
"outputs": [],
336+
"source": [
337+
"# For advanced use cases: access geometric calculations (may not preserve equal-area property)\n",
338+
"hp_geometric_areas, face_jacobians = grid._compute_face_areas(\n",
339+
" quadrature_rule=\"triangular\", order=4\n",
340+
")\n",
341+
"print(\n",
342+
" f\"Geometric std deviation: {hp_geometric_areas.std():.2e} (vs theoretical equal areas)\"\n",
343+
")"
344+
]
345+
},
346+
{
347+
"cell_type": "markdown",
348+
"id": "healpix_error_analysis",
349+
"metadata": {},
350+
"source": [
351+
"If you are interested in futher details of the systematic errors due to geometric calculation with a clear spatial pattern, our analysis across resolution levels 0-7 shows:\n",
352+
"\n",
353+
"- **Equatorial pixels** (lat≈0°): +20.5% area error\n",
354+
"- **Mid-latitude pixels** (lat≈±42°): -9.9% area error \n",
355+
"- **Maximum errors**: Persist at ~10% even at fine resolutions\n"
356+
]
357+
},
262358
{
263359
"cell_type": "markdown",
264360
"id": "7988c071af403bf2",
@@ -293,7 +389,9 @@
293389
"cell_type": "markdown",
294390
"id": "11ff4e1ad036ea53",
295391
"metadata": {},
296-
"source": "The interface above looks almost identical to what you would see if you loaded in the file directly with Xarray."
392+
"source": [
393+
"The interface above looks almost identical to what you would see if you loaded in the file directly with Xarray."
394+
]
297395
},
298396
{
299397
"cell_type": "code",
@@ -359,7 +457,7 @@
359457
"name": "python",
360458
"nbconvert_exporter": "python",
361459
"pygments_lexer": "ipython3",
362-
"version": "3.12.3"
460+
"version": "3.12.6"
363461
}
364462
},
365463
"nbformat": 4,

test/core/test_accessors.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,10 @@ def test_resample_reduces_time_dimension(gridpath):
138138
uxds = ux.UxDataset(ds, uxgrid=ux.open_grid(gridpath("ugrid", "outCSne30", "outCSne30.ug")))
139139

140140
# Test monthly resampling reduces from 365 days to 12 months
141-
monthly = uxds.resample(time="1M").mean()
141+
monthly = uxds.resample(time="1ME").mean()
142142
assert "time" in monthly.dims, "time dimension missing after resample"
143-
assert monthly.dims["time"] < uxds.dims["time"], "time dimension not reduced"
144-
assert monthly.dims["time"] <= 12, "monthly resampling should give 12 or fewer time points"
143+
assert monthly.sizes["time"] < uxds.sizes["time"], "time dimension not reduced"
144+
assert monthly.sizes["time"] <= 12, "monthly resampling should give 12 or fewer time points"
145145

146146

147147
def test_resample_with_cftime(gridpath):
@@ -166,10 +166,10 @@ def test_resample_with_cftime(gridpath):
166166
uxds = ux.UxDataset(ds, uxgrid=ux.open_grid(gridpath("ugrid", "outCSne30", "outCSne30.ug")))
167167

168168
# Test that quarterly resampling works with cftime
169-
quarterly = uxds.resample(time="Q").mean()
169+
quarterly = uxds.resample(time="QE").mean()
170170
assert hasattr(quarterly, "uxgrid"), "uxgrid not preserved with cftime resampling"
171171
assert "time" in quarterly.dims, "time dimension missing after cftime resample"
172-
assert quarterly.dims["time"] < uxds.dims["time"], "time dimension not reduced with cftime"
172+
assert quarterly.sizes["time"] < uxds.sizes["time"], "time dimension not reduced with cftime"
173173

174174

175175
def test_rolling_preserves_uxgrid(gridpath):
@@ -237,7 +237,7 @@ def test_coarsen_preserves_uxgrid(gridpath):
237237

238238
# Test that coarsen reduces dimension correctly
239239
assert len(da_result.time) == 8, "coarsen by 3 should reduce 24 points to 8"
240-
assert ds_result.dims["time"] == 8, "coarsen should reduce time dimension"
240+
assert ds_result.sizes["time"] == 8, "coarsen should reduce time dimension"
241241

242242

243243
def test_weighted_preserves_uxgrid(gridpath, datasetpath):
@@ -251,7 +251,7 @@ def test_weighted_preserves_uxgrid(gridpath, datasetpath):
251251
gridpath("ugrid", "outCSne30", "outCSne30.ug"),
252252
datasetpath("ugrid", "outCSne30", "outCSne30_var2.nc")
253253
)
254-
n_face = uxds_base.dims["n_face"]
254+
n_face = uxds_base.sizes["n_face"]
255255

256256
# Create data with time and face dimensions
257257
temp_data = np.random.rand(10, n_face)

test/core/test_azimuthal.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import pytest
2+
import uxarray as ux
3+
import numpy as np
4+
5+
6+
7+
def test_gaussian(gridpath, datasetpath):
8+
uxds = ux.open_dataset(
9+
gridpath("mpas", "dyamond-30km", "gradient_grid_subset.nc"),
10+
datasetpath("mpas", "dyamond-30km", "gradient_data_subset.nc")
11+
)
12+
13+
res = uxds['gaussian'].azimuthal_mean(center_coord=(45, 0), outer_radius=2, radius_step=0.5)
14+
15+
# Expects decreasing values from center
16+
valid_vals = res[1:]
17+
18+
np.testing.assert_array_less(
19+
valid_vals.diff("radius").values, 1e-12
20+
)
21+
22+
23+
24+
def test_inverse_gaussian(gridpath, datasetpath):
25+
26+
uxds = ux.open_dataset(
27+
gridpath("mpas", "dyamond-30km", "gradient_grid_subset.nc"),
28+
datasetpath("mpas", "dyamond-30km", "gradient_data_subset.nc")
29+
)
30+
31+
res = uxds['inverse_gaussian'].azimuthal_mean(center_coord=(45, 0), outer_radius=2, radius_step=0.5)
32+
33+
# Expects increasing values from center
34+
atol = 1e-12
35+
diffs = res[1:].diff("radius").values
36+
diffs = diffs[np.isfinite(diffs)]
37+
np.testing.assert_array_less(-atol, diffs)
38+
39+
def test_non_zero_hit_counts(gridpath, datasetpath):
40+
uxds = ux.open_dataset(
41+
gridpath("mpas", "dyamond-30km", "gradient_grid_subset.nc"),
42+
datasetpath("mpas", "dyamond-30km", "gradient_data_subset.nc")
43+
)
44+
45+
res, hit_counts = uxds['inverse_gaussian'].azimuthal_mean(center_coord=(45, 0), outer_radius=2, radius_step=0.5, return_hit_counts=True)
46+
47+
# At least one hit after the first circle
48+
assert np.all(hit_counts[1:] > 1)
49+
50+
assert 'radius' in hit_counts.dims
51+
assert hit_counts.sizes['radius'] == res.sizes['radius']
52+
53+
def test_zero_hit_counts(gridpath, datasetpath):
54+
uxds = ux.open_dataset(
55+
gridpath("mpas", "dyamond-30km", "gradient_grid_subset.nc"),
56+
datasetpath("mpas", "dyamond-30km", "gradient_data_subset.nc")
57+
)
58+
59+
# Outside of grid domain
60+
res, hit_counts = uxds['inverse_gaussian'].azimuthal_mean(center_coord=(-45, 0), outer_radius=2, radius_step=0.5, return_hit_counts=True)
61+
62+
assert 'radius' in hit_counts.dims
63+
assert hit_counts.sizes['radius'] == res.sizes['radius']
64+
65+
# No hits
66+
assert np.all(hit_counts == 0)
67+
68+
print(res)

test/core/test_inheritance.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def ds():
1616

1717
uxds["fc"] = uxds["fc"].assign_coords(face_id=("n_face", np.arange(uxgrid.n_face)))
1818
uxds["nc"] = uxds["nc"].assign_coords(node_id=("n_node", np.arange(uxgrid.n_node)))
19-
uxds["t"] = uxds["t"].assign_coords(time_id=("time", np.arange(uxds.dims["time"])))
19+
uxds["t"] = uxds["t"].assign_coords(time_id=("time", np.arange(uxds.sizes["time"])))
2020

2121
return uxds
2222

@@ -109,7 +109,7 @@ def test_groupby(self, ds):
109109
assert hasattr(grouped, 'uxgrid')
110110

111111
def test_assign_coords(self, ds):
112-
n = ds.dims['n_face']
112+
n = ds.sizes['n_face']
113113
new_coord = xr.DataArray(np.arange(n) * 10, dims=['n_face'])
114114
out = ds.assign_coords(scaled_id=new_coord)
115115
assert isinstance(out, ux.UxDataset)
@@ -121,7 +121,7 @@ def test_expand_dims(self, ds):
121121
out = ds.expand_dims({'member': 1})
122122
assert isinstance(out, ux.UxDataset)
123123
assert hasattr(out, 'uxgrid') and out.uxgrid is ds.uxgrid
124-
assert 'member' in out.dims and out.dims['member'] == 1
124+
assert 'member' in out.dims and out.sizes['member'] == 1
125125
assert 'member' in out['t'].dims and out['t'].sizes['member'] == 1
126126

127127
def test_method_chaining(self, ds):
@@ -147,7 +147,7 @@ def test_stack_unstack(self, ds):
147147
assert unstacked['fc'].shape == ds_fc['fc'].shape
148148

149149
def test_sortby(self, ds):
150-
n = ds.dims['n_face']
150+
n = ds.sizes['n_face']
151151
ds_fc = ds[['fc']].assign_coords(reverse_id=('n_face', np.arange(n)[::-1]))
152152
out = ds_fc.sortby('reverse_id')
153153
assert isinstance(out, ux.UxDataset)

test/grid/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Integration test module."""

test/grid/geometry/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)