Skip to content

Fix partial-face band overlap areas in conservative zonal weights - #1806

Draft
cmdupuis3 wants to merge 4 commits into
mainfrom
cmd/partial-face-overlap
Draft

cmdupuis3 wants to merge 4 commits into
mainfrom
cmd/partial-face-overlap

Conversation

@cmdupuis3

@cmdupuis3 cmdupuis3 commented Oct 5, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1644

Overview

1. Exact face/latitude-band overlap areas (uxarray/core/zonal.py)

_compute_band_overlap_area rebuilt the face/band intersection polygon by sorting its points by longitude, which loses boundary order. This made partial-face weights wrong in zonal_mean(..., conservative=True) and zonal_anomaly(..., conservative=True). On outCSne30 the error was a median of 1.3% and up to 3.4%. Near the poles it was much larger: −54% for the 85°–90° band on HEALPix z3.

The overlap area is now computed exactly, without building a polygon:

  • Method: in equal-area coordinates (longitude, sin lat), a latitude band is a horizontal strip. By Green's theorem, the overlap area is a line integral around the face boundary.
  • Exact per edge: on each great-circle edge, split where it crosses the band edges, the integral has a closed form.
  • Antimeridian and poles: longitude only enters as local differences, so the antimeridian needs no special case. Pole-enclosing faces and vertices on a pole are handled by tracking how far the boundary winds in longitude.
  • Touching faces: a face that only touches a band gets a weight of exactly 0.

Clipping plus latitude correction was considered and rejected. It can't represent faces that enclose a pole, which are common on MPAS, HEALPix and cubed-sphere grids. On ordinary faces it computes the same quantity, and a test asserts the two agree.

Results:

check (outCSne30) before after
zone-area identity, worst band 0.56% (8.2% at 85°–90°) ~2.5e-11
fully-contained face vs face_areas 1.3% median, 3.4% max < 1e-10
band-mean error of sin(lat), HEALPix z3→z6 flat, ~3e-3 falls ~4× per level

It's also about 2.5× faster.

2. Sign of the constant-latitude area correction (uxarray/grid/area.py)

Found while adding the equivalence test. With latitude_adjusted_area=True, the correction for parallel edges was applied with the wrong sign for counterclockwise faces; its size was right. CORRECTED_TRI_AREA was pinned to the wrong value (0.022448 → 0.021884, verified by brute-force integration). This changes Grid.calculate_total_face_area(latitude_adjusted_area=True) results.

Note: HEALPix weighting (not changed here)

HEALPix face_areas are the theoretical equal areas 4π/N, but uxarray's great-circle polygons for those pixels differ from 4π/N by up to 10% near the poles, at every resolution. Within a band, fully-contained faces carry 4π/N while partial faces carry polygon geometry. So the zone-area identity holds only approximately on HEALPix: 0.004% for 20°–60° at z5, 1.7% for 85°–90°. One option is to scale each partial overlap by face_areas[f] divided by that face's polygon area. That changes what the weights mean, so it's left for a follow-up.

🤖 Generated with Claude Code

PR Checklist

General

  • An issue is created and linked
  • Added appropriate labels (if your uxarray repo permissions allow it)
  • Filled out Overview and Expected Usage (if applicable) sections

Testing & Benchmarking

  • There is adequate test coverage of changes from this PR (add new tests if needed)
  • If this PR could affect performance, ran ASV benchmarks and confirmed they show expected behavior (add a new benchmark if necessary)

Documentation and Examples

  • Docstrings updated with any function changes, and included in all new functions
  • User (public) functions added to docs/api.rst; internal (private) function names start with an underscore (_)

AI Disclosure

AI Usage: Claude Opus 5.5

  • I have tested and take responsibility for all AI-generated content in my PR.

cmdupuis3 and others added 2 commits October 5, 2026 16:31
… weights

_compute_band_overlap_area rebuilt the face/band intersection polygon by
sorting its points by longitude, which does not recover the boundary order.
Partial-face overlap areas were off by ~1.3% median (up to 3.4%) on
outCSne30 and far worse near the poles, so conservative zonal_mean and
zonal_anomaly weights failed the spherical-zone-area identity.

Replace the polygon reconstruction with an exact line integral. In the
equal-area (lon, sin(lat)) coordinates a latitude band is a strip, so by
Green's theorem the overlap area is -closed_integral(clip(z, z_min, z_max)
- z_min) dlon around the face boundary. On each great-circle edge, split
at the band crossings, the integral has a closed form. There is no vertex
ordering to get wrong, the antimeridian needs no special case, and
pole-enclosing faces and vertices on a pole are handled through the
longitude winding. A face whose boundary never enters the band gets an
exact 0 (or the full polar cap), so faces that only touch a band keep
zero weight.

On outCSne30 the band weights now satisfy the zone-area identity to
~2.5e-11 relative, fully-contained faces match face_areas to 1e-10, and
the band-mean error of sin(lat) converges under healpix refinement.

Remove the now-unused _sort_points_by_angle and add regression tests for
the zone-area identity, per-face partition, contained faces, rotation and
orientation invariance, pole-enclosing faces, pole vertices and touching
faces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
With latitude_adjusted_area=True, _face_area_from_quadrature applied the
constant-latitude edge correction with the wrong sign for counterclockwise
faces: it added the area between a parallel and its poleward-bulging
great-circle arc where it should subtract it, and vice versa. The
magnitude from _area_correction was right.

For the reference triangle in the tests (top edge on the 41.8 deg N
parallel) the corrected area was 0.022448 instead of 0.021884; the
CORRECTED_TRI_AREA constant had been pinned to the wrong value. The new
value is checked against brute-force integration and agrees with the
exact band-overlap line integral.

Add a test that clips a face by hand, applies calculate_face_area with
latitude_adjusted_area=True, and checks it matches
_compute_band_overlap_area in both hemispheres.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@cmdupuis3 cmdupuis3 self-assigned this Oct 5, 2026
@cmdupuis3 cmdupuis3 added accuracy Related to mathematical accuracy of operations, such as numerical precision or rounding errors bug Something isn't working labels Oct 5, 2026
@cmdupuis3 cmdupuis3 added the run-benchmark Run ASV benchmark workflow label Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have stayed the same:

Change Before [704d92b] After [ef34b37] Ratio Benchmark (Parameter)
979±20μs 983±20μs 1.00 connectivity.Connectivity.time_edge_face('120km')
490±20μs 476±10μs 0.97 connectivity.Connectivity.time_edge_face('480km')
3.97±0.08ms 4.10±0.2ms 1.03 connectivity.Connectivity.time_edge_node('120km')
1.31±0.1ms 1.33±0.04ms 1.01 connectivity.Connectivity.time_edge_node('480km')
75.4±7μs 76.1±8μs 1.01 connectivity.Connectivity.time_face_edge('120km')
68.1±8μs 71.2±7μs 1.05 connectivity.Connectivity.time_face_edge('480km')
1.01±0.01ms 1.02±0.03ms 1.00 connectivity.Connectivity.time_face_face('120km')
433±20μs 439±10μs 1.02 connectivity.Connectivity.time_face_face('480km')
65.9±10μs 65.8±5μs 1.00 connectivity.Connectivity.time_face_node('120km')
62.8±10μs 64.0±4μs 1.02 connectivity.Connectivity.time_face_node('480km')
486±30μs 487±10μs 1.00 connectivity.Connectivity.time_n_nodes_per_face('120km')
417±20μs 425±20μs 1.02 connectivity.Connectivity.time_n_nodes_per_face('480km')
1.39±0.04ms 1.36±0.02ms 0.98 connectivity.Connectivity.time_node_edge('120km')
499±20μs 492±5μs 0.99 connectivity.Connectivity.time_node_edge('480km')
81.8±6ms 87.1±2ms 1.06 connectivity.Connectivity.time_node_face('120km')
5.05±0.03ms 5.21±0.1ms 1.03 connectivity.Connectivity.time_node_face('480km')
1.42M 1.42M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_face('120km')
106k 106k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_face('480km')
6.48M 6.48M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_node('120km')
417k 417k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_node('480km')
2.42k 2.42k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_edge('120km')
2.42k 2.42k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_edge('480km')
1.6M 1.6M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_face('120km')
101k 101k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_face('480km')
2.54k 2.54k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_node('120km')
2.54k 2.54k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_node('480km')
240k 240k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_n_nodes_per_face('120km')
25.8k 25.8k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_n_nodes_per_face('480km')
1.9M 1.9M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_edge('120km')
127k 127k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_edge('480km')
11.9M 11.9M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_face('120km')
747k 747k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_node_face('480km')
8.37±0.09ms 8.69±0.2ms 1.04 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.81±0.3ms 2.75±0.04ms 0.98 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
10.2±10ms 10.2±0.09ms 1.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.58±0.05ms 1.58±0.05ms 1.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
57.3k 57.3k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
12.3k 12.3k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
123k 123k 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
128 128 1.00 face_bounds.FaceBounds.track_nbytes_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.27M 1.27M 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
50.1k 50.1k 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
1.48M 1.48M 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
712 712 1.00 face_bounds.FaceBounds.track_nbytes_grid_with_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.98M 1.98M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
1.97M 1.97M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
2.14M 2.14M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
35.5k 35.5k 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
326M 326M 1.00 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
326M 326M 1.00 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
327M 327M 1.00 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
327M 326M 1.00 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.15±0.02μs 1.16±0.02μs 1.00 geometry_kernels.AccucrossKernels.time_accucross
2.58±0.03μs 2.60±0.04μs 1.01 geometry_kernels.AccucrossKernels.time_accucross_pair
446±20ns 446±30ns 1.00 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
425±20ns 426±10ns 1.00 geometry_kernels.EFTPrimitives.time_diff_of_products
390±8ns 386±20ns 0.99 geometry_kernels.EFTPrimitives.time_two_prod
691±20ns 666±30ns 0.96 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
726±9ns 771±30ns 1.06 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
836±30ns 792±10ns 0.95 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
777±10ns 786±20ns 1.01 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
907±50ns 897±30ns 0.99 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
1.01±0.02μs 1.03±0.01μs 1.02 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
52.0±0.7μs 50.8±0.4μs 0.98 geometry_kernels.OrientPredicates.time_on_minor_arc
51.9±0.3μs 50.7±0.6μs 0.98 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.06±0ms 3.05±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.16±0ms 1.16±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_kernel
2.29±0.01ms 2.29±0ms 1.00 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
148±0.1μs 148±0.7μs 1.00 geometry_samebody.SameBodyConstLat.time_fp64_kernel
29.6±0.02ms 29.5±0.03ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.33±0.02ms 6.34±0.07ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
23.8±0.1ms 23.5±0.05ms 0.99 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
861±0.9μs 862±5μs 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
932±20ms 934±20ms 1.00 import.Imports.timeraw_import_uxarray
283M 283M 1.00 import.Imports.track_peakmem_import_uxarray
2.47±0.03ms 2.51±0.02ms 1.02 mpas_ocean.CheckNorm.time_check_norm('120km')
2.04±0.02ms 2.02±0.01ms 0.99 mpas_ocean.CheckNorm.time_check_norm('480km')
1.18±0.05ms 1.17±0.02ms 0.99 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
551±10μs 557±20μs 1.01 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
666±10μs 651±8μs 0.98 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
602±20μs 607±10μs 1.01 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.56±0.06ms 5.35±0.05ms 0.96 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.84±0.02ms 3.79±0.03ms 0.99 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
96.3±0.3ms 96.6±0.3ms 1.00 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
9.69±0.4ms 9.45±0.03ms 0.98 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
18.6±0.06ms 18.8±0.05ms 1.01 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
959±10μs 964±20μs 1.01 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.1±0.07ms 10.0±0.04ms 0.99 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
605±10μs 621±30μs 1.03 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
588±4ms 620±10ms 1.05 mpas_ocean.CrossSections.time_const_lat('120km', 1)
302±3ms 303±3ms 1.00 mpas_ocean.CrossSections.time_const_lat('120km', 2)
156±1ms 160±4ms 1.02 mpas_ocean.CrossSections.time_const_lat('120km', 4)
542±6ms 543±7ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 1)
281±2ms 275±0.9ms 0.98 mpas_ocean.CrossSections.time_const_lat('480km', 2)
139±1ms 148±3ms 1.06 mpas_ocean.CrossSections.time_const_lat('480km', 4)
346M 346M 1.00 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 1)
346M 350M 1.01 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 2)
346M 346M 1.00 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 4)
329M 329M 1.00 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 1)
329M 329M 1.00 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 2)
329M 329M 1.00 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 4)
26.1±0.2ms 26.3±0.1ms 1.01 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
3.02±0.1ms 2.88±0.02ms 0.96 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
17.0±4ms 14.9±0.7ms ~0.88 mpas_ocean.FaceAreas.time_face_areas('120km')
4.31±0.2ms 4.35±0.3ms 1.01 mpas_ocean.FaceAreas.time_face_areas('480km')
229k 229k 1.00 mpas_ocean.FaceAreas.track_nbytes_face_areas('120km')
14.3k 14.3k 1.00 mpas_ocean.FaceAreas.track_nbytes_face_areas('480km')
2.12M 2.12M 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('120km')
715k 715k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
907±3ms 913±20ms 1.01 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
51.3±2ms 51.2±2ms 1.00 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
80.3±0.7ms 82.5±0.4ms 1.03 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
4.99±0.08ms 5.37±0.3ms 1.08 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
13.4±0.09ms 14.0±0.1ms 1.05 mpas_ocean.Gradient.time_gradient('120km')
1.81±0.01ms 1.84±0.01ms 1.01 mpas_ocean.Gradient.time_gradient('480km')
457k 457k 1.00 mpas_ocean.Gradient.track_nbytes_gradient('120km')
28.7k 28.7k 1.00 mpas_ocean.Gradient.track_nbytes_gradient('480km')
3.2M 3.2M 1.00 mpas_ocean.Gradient.track_peakmem_gradient('120km')
204k 204k 1.00 mpas_ocean.Gradient.track_peakmem_gradient('480km')
341M 341M 1.00 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('120km')
325M 323M 0.99 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('480km')
273±10μs 298±10μs 1.09 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
148±2μs 146±1μs 0.99 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
205±2μs 204±1μs 1.00 mpas_ocean.Integrate.time_integrate('120km')
186±2μs 186±3μs 1.00 mpas_ocean.Integrate.time_integrate('480km')
18.4M 18.4M 1.00 mpas_ocean.Integrate.track_nbytes_integrate('120km')
1.2M 1.2M 1.00 mpas_ocean.Integrate.track_nbytes_integrate('480km')
180±0.5ms 180±2ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
180±0.8ms 183±3ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
181±0.8ms 182±0.7ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
12.9±0.06ms 13.0±0.2ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
13.1±0.2ms 13.4±0.2ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
12.9±0.1ms 13.2±0.4ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
249±0.6ms 248±0.7ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
1.32±0.01s 1.32±0s 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 15.0)
509±2ms 507±3ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
13.4±0.06ms 13.3±0.04ms 0.99 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
25.8±0.2ms 25.5±0.04ms 0.99 mpas_ocean.NeighborhoodBuild.time_build('480km', 15.0)
16.7±0.04ms 16.5±0.04ms 0.99 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
244±0.7ms 242±0.7ms 0.99 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
1.29±0.01s 1.29±0.01s 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 15.0)
504±2ms 505±3ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
12.9±0.05ms 13.1±0.05ms 1.01 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
24.8±0.07ms 25.4±0.08ms 1.02 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 15.0)
16.3±0.1ms 16.3±0.05ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 5.0)
1.19 1.19 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('120km', 1.0)
612.76 612.76 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('120km', 15.0)
74.17 74.17 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('120km', 5.0)
1.0 1.0 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('480km', 1.0)
37.29 37.29 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('480km', 15.0)
6.57 6.57 1.00 mpas_ocean.NeighborhoodBuild.track_mean_neighbors('480km', 5.0)
728k 728k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('120km', 1.0)
141M 141M 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('120km', 15.0)
17.4M 17.4M 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('120km', 5.0)
43k 43k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('480km', 1.0)
563k 563k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('480km', 15.0)
123k 123k 1.00 mpas_ocean.NeighborhoodBuild.track_nbytes_neighbors('480km', 5.0)
5.72M 5.72M 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('120km', 1.0)
145M 145M 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('120km', 15.0)
21.5M 21.5M 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('120km', 5.0)
362k 362k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 1.0)
825k 825k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 15.0)
384k 384k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 5.0)
40.3±1ms 40.1±0.7ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
18.3±0.1ms 18.4±0.08ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
36.0±0.7ms 36.6±2ms 1.02 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
11.0±0.2ms 11.0±0.2ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
846±10μs 866±70μs 1.02 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
7.59±0.2ms 7.90±0.2ms 1.04 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.83M 5.9M 1.01 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'grid_chunks')
2.75M 2.75M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'numpy')
5.68M 5.68M 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('120km', 'time_chunks')
746k 757k 1.01 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')
177k 177k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'numpy')
547k 539k 0.99 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
12.6±0.02s 12.6±0s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
13.5±0.02s 13.3±0.04s 0.99 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
227±0.7ms 229±1ms 1.01 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
234±3ms 232±0.7ms 0.99 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
1.35±0s 1.34±0s 0.99 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
1.55±0s 1.54±0s 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
26.5±0.2ms 26.2±0.07ms 0.99 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
27.7±0.2ms 27.4±0.3ms 0.99 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
31.3±0.1ms 31.3±0.2ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
230±0.5ms 229±0.9ms 0.99 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
606±10μs 613±30μs 1.01 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
1.86±0.03ms 1.78±0.03ms 0.96 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'median')
239k 239k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'mean')
245k 245k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'median')
19.5k 19.5k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'mean')
20k 20k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'median')
449±40μs 432±30μs 0.96 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
431±10μs 407±9μs 0.95 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
420±10μs 403±30μs 0.96 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
380±10μs 380±10μs 1.00 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
135±2ms 131±0.3ms 0.97 mpas_ocean.RemapDownsample.time_bilinear_remapping
18.1±0.3ms 17.3±0.2ms 0.95 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
16.5±0.2ms 15.8±0.2ms 0.96 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.43±0s 1.43±0s 1.00 mpas_ocean.RemapUpsample.time_bilinear_remapping
27.9±0.8ms 26.9±0.3ms 0.97 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
12.5±0.5ms 11.8±0.1ms 0.94 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
8.77±0.2ms 8.41±0.1ms 0.96 mpas_ocean.ZonalAverage.time_zonal_average('120km')
5.64±0.3ms 5.21±0.07ms 0.92 mpas_ocean.ZonalAverage.time_zonal_average('480km')
347M 347M 1.00 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('120km')
331M 331M 1.00 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('480km')
1.0153977485995496 1.0294127402665274 1.01 nogil_scaling.GILScaling.track_gil_scaling
7.33±0.05ms 7.49±0.2ms 1.02 quad_hexagon.QuadHexagon.time_open_dataset
6.30±0.04ms 6.54±0.2ms 1.04 quad_hexagon.QuadHexagon.time_open_grid
408 408 1.00 quad_hexagon.QuadHexagon.track_nbytes_open_dataset
392 392 1.00 quad_hexagon.QuadHexagon.track_nbytes_open_grid
73.9k 73.9k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
73k 73k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_grid

Benchmarks that have got worse:

Change Before [704d92b] After [ef34b37] Ratio Benchmark (Parameter)
+ 360±10ns 401±20ns 1.11 geometry_kernels.EFTPrimitives.time_two_sum

@cmdupuis3 cmdupuis3 removed the run-benchmark Run ASV benchmark workflow label Oct 5, 2026
@hongyuchen1030

Copy link
Copy Markdown
Contributor

thanks for the work @cmdupuis3 I will look into it in details later this week.

Comment thread uxarray/core/zonal.py
"""Sort points by longitude angle for proper polygon formation.
def _is_pole_point(p):
"""Whether ``p`` lies (numerically) on a pole, where longitude is undefined."""
return p[0] * p[0] + p[1] * p[1] < ERROR_TOLERANCE * ERROR_TOLERANCE

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks only p[0]**2 + p[1]2 < tol2, i.e. that p is near the z-axis. That counts as “on a pole” only if p lies exactly on the unit sphere. Floating-point points generally don’t, and newly constructed points (intersections, interpolated points) may already carry error. The function also has no guard and no documented precondition, so it is easy to misuse elsewhere.

If the intended meaning really is just “x and y are ~0, so longitude is undefined”, state that in the docstring along with the precondition (unit-norm input, and the tolerance it assumes). Keep it private to this module so it isn’t reused for general pole tests.
Add tests with points slightly off the unit sphere and points near each pole.

Comment thread uxarray/core/zonal.py
Comment on lines 118 to +121

while j >= 0 and angles[j] > key_angle:
angles[j + 1] = angles[j]
sorted_points[j + 1] = sorted_points[j]
j -= 1
@njit(cache=True)
def _gca_z_dlon_integral(a, b):
"""Exact value of the line integral of ``z dlon`` along the minor great-circle arc a -> b.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we already have this function implemented

def _small_angle_of_2_vectors(u, v):
, is there any reason we cannot reuse it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existed solution already tried to avoid the catastrophic cancellation as much as possible, while in this solution the following lines

    ta_z = n[0] * a[1] - n[1] * a[0]
    tb_z = n[0] * b[1] - n[1] * b[0]

and (tb_z - ta_z) all shows potential risks of the catastrophic cancellation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider using sth like this

from uxarray.utils.computing import diff_of_products
from uxarray.grid.utils import _small_angle_of_2_vectors

@njit(cache=True)
def _lon_delta(a, b):
    """Signed longitude change from ``a`` to ``b`` in (-pi, pi]."""
    cz_hi, cz_lo = diff_of_products(a[0], b[1], a[1], b[0])
    a_xy = (a[0], a[1], 0.0)
    b_xy = (b[0], b[1], 0.0)
    return np.sign(cz_hi + cz_lo) * _small_angle_of_2_vectors(a_xy, b_xy)

Comment thread uxarray/core/zonal.py
ndarray
Points sorted by longitude angle
@njit(cache=True)
def _lon_delta(a, b):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_lon_delta is just the angle between a and b after both are slid along their meridians to the equator. Something like the following should do the job

@njit(cache=True)
def _lon_delta(a, b):
    """Signed longitude change from a to b in (-pi, pi].
    Precondition: neither a nor b is at a pole."""
    # slide both points to the equator (drop z)
    a_eq = (a[0], a[1], 0.0)
    b_eq = (b[0], b[1], 0.0)
    # magnitude: Kahan angle between the two equator points
    angle = _small_angle_of_2_vectors(a_eq, b_eq)
    # sign: accurate z-component of a_eq x b_eq (positive = eastward)
    s_hi, s_lo = diff_of_products(a[0], b[1], a[1], b[0])
    return np.sign(s_hi + s_lo) * angle

Comment thread uxarray/core/zonal.py
angles[j + 1] = key_angle
sorted_points[j + 1] = key_point
@njit(cache=True)
def _edge_band_integral(a, b, z_min, z_max):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with the mathematical treatment here: the Green's theorem formulation in (λ, z = sin φ), together with the LCL terms, reduces to the GCA area plus LCL area correction in Chen et al. (2026, GMD), so mathematically it is correct. However, several issues remain. First, the floating-point accuracy handling: the cross products, intersection, and angle computations are evaluated naively instead of with the accurate kernels already in uxarray. Second, the spherical geometry and topology handling。

Comment thread uxarray/core/zonal.py
b = face_edges_xyz[e, 1]
z_sum += a[2]

if _is_pole_point(a) or _is_pole_point(b):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the motivation behind the longitude-based winding-number calculation and the additional corrections for north- and south-pole configurations. However, I think we should reconsider this approach and reuse the existing geometric predicates in UXarray.

The incremental-angle algorithm for computing winding numbers is already well established (Weiler, 1994). As discussed in our paper, Chen et al. (2026), Accurate and robust geometric algorithms for regridding on the sphere, for the class of simple, non-self-intersecting faces typically used in geoscientific meshes, winding-number point-in-face testing can be reduced to an equivalent ray-casting even–odd test (Hormann and Agathos, 2001).

In general, we do not recommend implementing winding-number tests using the incremental-angle formulation, since the repeated trigonometric evaluations are computationally expensive and can introduce additional floating-point inaccuracies. Orientation-based ray-casting predicates are generally preferable for these mesh configurations.

I understand that the current implementation attempts to account for the different orientations around the north and south poles. However, the longitude-based winding calculation does not directly distinguish between the two poles, which introduces additional classification and correction logic. In particular, north = z_sum > 0 is not a general geometric criterion for pole containment, since the sum of vertex z-coordinates depends on the number and distribution of vertices and is not invariant under GCA edge subdivision.

More importantly, I don't think we need to introduce another pole-containment calculation here.

In the existing UXarray workflow, face latitude–longitude bounds are already constructed before the overlap-area calculation. Specifically, uxarray/grid/bounds.py already evaluates:

has_north_pole = pole_point_inside_polygon(
    1, face_edges_xyz, face_edges_lonlat
)
has_south_pole = pole_point_inside_polygon(
    -1, face_edges_xyz, face_edges_lonlat
)

Furthermore, _compute_face_band_weights() accesses uxgrid.face_bounds_lat to identify candidate faces before calling _compute_band_overlap_area().

Therefore, the pole-containment predicates have already been evaluated during face-bounds construction. The resulting latitude–longitude bounds also encode relevant polar information, such as latitude bounds reaching ±90° and longitude bounds spanning the full 360°.

I would suggest checking whether we can directly reuse this existing information to determine pole containment. If the current bounds representation does not preserve the distinction between pole containment and merely touching a pole, we could retain the corresponding boolean flags during bounds construction rather than recomputing them.

This would allow us to preserve the proposed Green's-theorem-based area integration while avoiding a redundant, coordinate-dependent winding-number implementation and maintaining consistency with the existing spherical geometry framework.

For a detailed discussion of winding-number algorithms, their relationship to ray casting, and the associated geometric predicates, please refer to 5.6 Face-level: Point-in-face predicate on the spherehttps://egusphere.copernicus.org/preprints/2026/egusphere-2026-636/

Comment thread uxarray/core/zonal.py
r = np.hypot(a[2], u[2])
t0 = np.arctan2(u[2], a[2])

ts = np.empty(6, dtype=np.float64)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would strongly recommend not reinventing the wheel here. UXarray already has an established

def _try_gca_const_lat_intersection(gca_cart, const_z):
and
def gca_const_lat_intersection(gca_cart, const_z):
, so we should reuse it rather than introduce another implementation based on angular parameterization.

There are three main concerns with the current approach.

First, mixing angular and Cartesian representations in geometric calculations introduces unnecessary complexity and potential numerical accuracy issues, particularly when repeatedly converting between the two representations.

Second, as I mentioned previously, we generally try to avoid trigonometric functions in geometric predicates and intersection calculations whenever possible. Functions such as atan2, acos, sin, and cos are computationally expensive and introduce additional numerical errors. In particular, computing the intersection parameter using acos(c / r) is especially sensitive near tangential intersections, where c / r approaches ±1. This can affect not only the accuracy of the intersection coordinates but also the correctness of the intersection classification.

Third, the existing GCA–LCL intersection algorithm was specifically designed to support SIMD-friendly vectorization and parallelization. The proposed implementation introduces multiple trigonometric evaluations and conditional branches into the intersection calculation. Transcendental functions are generally more expensive and harder to vectorize efficiently than basic Cartesian arithmetic, while extensive branching can further reduce SIMD efficiency due to divergent execution paths. Consequently, this implementation risks sacrificing both computational performance and numerical robustness compared with the existing algorithm.

We can preserve the proposed Green's-theorem-based integration while reusing the established geometric intersection routine rather than maintaining two independent implementations of the same operation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

accuracy Related to mathematical accuracy of operations, such as numerical precision or rounding errors bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Claude] Conservative zonal mean: partial-face overlap areas are wrong by ~1-5%

2 participants