Skip to content

Commit 123922c

Browse files
rajeejaerogluorhan
andauthored
Fix HEALPix face area calculation to enforce equal area property (#1379)
* Fix HEALPix face area calculation to enforce equal area property * o Add area logic to io folder for HEALPix mesh, document notebook and area cal * o Fix notebook and cleanup * Add note * Addressing PR comments * Update uxarray/grid/grid.py Co-authored-by: Orhan Eroglu <[email protected]> * o More cleanup; The compute_face_areas() method is still present in the code but marked as deprecated * o Add note * Update area and healpix notebooks * o Cleanup notebook * o Add area math * Small rephrasing on two notebooks --------- Co-authored-by: Orhan Eroglu <[email protected]> Co-authored-by: erogluorhan <[email protected]>
1 parent 0a28e41 commit 123922c

File tree

10 files changed

+376
-1568
lines changed

10 files changed

+376
-1568
lines changed

docs/api.rst

Lines changed: 0 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

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/grid/test_grid.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ def test_face_areas_calculate_total_face_area_sphere(self, gridpath, mesh_consta
358358
def test_face_areas_compute_face_areas_geoflow_small(gridpath):
359359
"""Checks if the GeoFlow Small can generate a face areas output."""
360360
grid_geoflow = ux.open_grid(gridpath("ugrid", "geoflow-small", "grid.nc"))
361-
grid_geoflow.compute_face_areas()
361+
grid_geoflow._compute_face_areas()
362362

363363
class TestFaceAreas:
364364
def test_face_areas_verts_calc_area(self, gridpath, mesh_constants):

test/io/test_healpix.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,106 @@ def test_invalid_cells():
9595
with pytest.raises(ValueError):
9696
uxda = ux.UxDataset.from_healpix(xrda)
9797

98+
@pytest.mark.parametrize("resolution_level", [0, 2]) # Test lowest and a mid-level
99+
def test_healpix_equal_area_property(resolution_level):
100+
"""Test that all HEALPix faces have equal area as per the HEALPix specification.
101+
102+
HEALPix (Hierarchical Equal Area isoLatitude Pixelization) is designed so that
103+
all pixels/faces have exactly the same spherical area.
104+
"""
105+
# Create HEALPix grid with boundaries for area calculation
106+
uxgrid = ux.Grid.from_healpix(resolution_level, pixels_only=False)
107+
108+
# Calculate face areas and expected theoretical area
109+
face_areas = uxgrid.face_areas.values
110+
nside = hp.order2nside(resolution_level)
111+
npix = hp.nside2npix(nside)
112+
expected_area_per_pixel = 4 * np.pi / npix
113+
114+
# All face areas should be equal to the theoretical value
115+
# Use a more reasonable tolerance for numerical computations
116+
np.testing.assert_allclose(
117+
face_areas,
118+
expected_area_per_pixel,
119+
rtol=1e-12, # Relaxed from 1e-14 for numerical stability
120+
atol=1e-15, # Added absolute tolerance
121+
err_msg=f"HEALPix faces do not have equal areas at resolution {resolution_level}"
122+
)
123+
124+
# Verify total surface area equals 4π
125+
total_area = np.sum(face_areas)
126+
expected_total_area = 4 * np.pi
127+
np.testing.assert_allclose(
128+
total_area,
129+
expected_total_area,
130+
rtol=1e-12, # Relaxed from 1e-14
131+
atol=1e-15, # Added absolute tolerance
132+
err_msg=f"Total HEALPix surface area incorrect at resolution {resolution_level}"
133+
)
134+
135+
136+
def test_healpix_face_areas_consistency():
137+
"""Test that HEALPix face areas are consistent across different resolution levels."""
138+
resolution_levels = [0, 1] # Just test basic functionality
139+
140+
for resolution_level in resolution_levels:
141+
uxgrid = ux.Grid.from_healpix(resolution_level, pixels_only=False)
142+
face_areas = uxgrid.face_areas.values
143+
144+
# All faces should have identical areas
145+
area_std = np.std(face_areas)
146+
area_mean = np.mean(face_areas)
147+
148+
# Avoid division by zero
149+
if area_mean == 0:
150+
pytest.fail(f"Mean face area is zero at resolution {resolution_level}")
151+
152+
relative_std = area_std / area_mean
153+
154+
# Relative standard deviation should be essentially zero (numerical precision)
155+
# Relaxed tolerance for numerical stability
156+
assert relative_std < 1e-12, (
157+
f"Face areas not equal at resolution {resolution_level}: "
158+
f"relative_std={relative_std:.2e}"
159+
)
160+
161+
# Check that face areas match theoretical calculation
162+
nside = hp.order2nside(resolution_level)
163+
npix = hp.nside2npix(nside)
164+
theoretical_area = 4 * np.pi / npix
165+
166+
# Use consistent tolerance with the parametrized test
167+
np.testing.assert_allclose(
168+
face_areas,
169+
theoretical_area,
170+
rtol=1e-12,
171+
atol=1e-15,
172+
err_msg=f"Face areas incorrect at resolution {resolution_level}"
173+
)
174+
175+
176+
@pytest.mark.parametrize("resolution_level", [0, 1]) # Just test the scaling relationship
177+
def test_healpix_area_scaling(resolution_level):
178+
"""Test that face areas scale correctly with resolution level."""
179+
# Create grids at consecutive resolution levels
180+
uxgrid_current = ux.Grid.from_healpix(resolution_level, pixels_only=False)
181+
uxgrid_next = ux.Grid.from_healpix(resolution_level + 1, pixels_only=False)
182+
183+
area_current = uxgrid_current.face_areas.values[0] # All areas are equal
184+
area_next = uxgrid_next.face_areas.values[0]
185+
186+
# Each resolution level increases npix by factor of 4, so area decreases by factor of 4
187+
expected_ratio = 4.0
188+
actual_ratio = area_current / area_next
189+
190+
np.testing.assert_allclose(
191+
actual_ratio,
192+
expected_ratio,
193+
rtol=1e-12,
194+
err_msg=f"Area scaling incorrect between resolution {resolution_level} and {resolution_level + 1}"
195+
)
196+
197+
98198
def test_healpix_round_trip_consistency(tmp_path):
99199
"""Test round-trip serialization of HEALPix grid through UGRID and Exodus formats.
100200

test/test_helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def test_calculate_face_area(mesh_constants):
6060
fill_value=-1,
6161
)
6262

63-
area, _ = grid.compute_face_areas()
63+
area, _ = grid._compute_face_areas()
6464
nt.assert_almost_equal(area, mesh_constants['TRI_AREA'], decimal=5)
6565

6666
def test_quadrature():

uxarray/core/dataarray.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -484,9 +484,7 @@ def integrate(
484484
>>> integral = uxds["psi"].integrate()
485485
"""
486486
if self.values.shape[-1] == self.uxgrid.n_face:
487-
face_areas, face_jacobian = self.uxgrid.compute_face_areas(
488-
quadrature_rule, order
489-
)
487+
face_areas = self.uxgrid.face_areas.values
490488

491489
# perform dot product between face areas and last dimension of data
492490
integral = np.einsum("i,...i", face_areas, self.values)

uxarray/core/dataset.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -586,10 +586,7 @@ def integrate(self, quadrature_rule="triangular", order=4):
586586

587587
integral = 0.0
588588

589-
# call function to get area of all the faces as a np array
590-
face_areas, face_jacobian = self.uxgrid.compute_face_areas(
591-
quadrature_rule, order
592-
)
589+
face_areas = self.uxgrid.face_areas.values
593590

594591
# TODO: Should we fix this requirement? Shouldn't it be applicable to
595592
# TODO: all variables of dataset or a dataarray instead?

0 commit comments

Comments
 (0)