Skip to content

Support Calculus with 2D+ arrays - #1823

Open
Sevans711 wants to merge 19 commits into
mainfrom
sevans/2D+calculus
Open

Sevans711 wants to merge 19 commits into
mainfrom
sevans/2D+calculus

Conversation

@Sevans711

@Sevans711 Sevans711 commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #1723

Overview

Adds support for applying calculus operators to 2D+ arrays (more dims than just the grid dimension), by creating the new _apply_1dfunc_with_grid_core_dim() helper method and using it inside _compute_gradient(). The operators which now support 2D+ arrays are gradient(), curl(), divergence(), and scalardotgradient().

Tests note: the tests which prove that these changes are compatible with dask arrays remain commented out for now; they should be uncommented when #1798 merges. See #1823 (comment) for more details.

While working on this I made numerous miscellaneous improvements related to calculus methods, expanding the scope of this PR as follows.

Small expansions of scope (examples):

  1. Vector calculus notebook style change: hide DataArray data values by default (i.e., xr.set_options(display_expand_data=False)). The corresponding icon still shows/hides, the default state is just "hidden" instead of shown.
  2. Replaced ~ with \~ in one markdown cell (starts with "Pure rotation is incompressible...") which was rendering locally with strikethrough for some text instead of tildes.

Sidenote about the notebook: there are a few cells in the middle which I didn't touch but the diff shows they were edited; this was done by ruff formatting.

Small expansions of scope (functionality):

  1. Removed the 15-month-old DeprecationWarning in gradient() for unused kwargs "use_magnitude" and "normalize". (See also: Finish deprecating code with 1+ year old DeprecationWarnings #1707)
  2. Removed support for "units" kwarg in divergence() which was just overriding result.attrs['units'] and was inconsistent with other calculus methods (gradient(), curl(), and scalardotgradient() do not provide a "units" kwarg).
  3. Fixed docstrings of curl() and divergence() to include the metric terms.
  4. Misc. improvements to docstrings of gradient(), curl(), divergence(), and scalardotgradient(), such as improving the consistency between them, improving clarity, and reducing redundancies.
  5. Fix: curl() and divergence() now also include an up-front xr.align(self, other, join="exact") check, similar to what is already in scalardotgradient(), so that misaligned inputs cause a crash (and, the crash happens before the potentially expensive gradient call).

Tiny expansions of scope (functionality):

  1. Removed support for arbitrary kwargs (which were being silently discarded) in gradient(), divergence(), curl(). Inputting unused kwargs will now raise TypeError.
  2. Refactor curl() and divergence() units attrs to use the same values from gradient() results instead of copy-pasting the same logic in 3 places.
  3. Fixed typo in error message of self.divergence(other) if other is not face-centered, and similar typos in error message of self.scalardotgradient(v, q) if v and/or q is not face-centered.
  4. curl() now performs the preprocessing check of "are self and other both face-centered?", just like divergence().
  5. Clarified the meanings of x and y in docstrings of curl() and divergence().
  6. Refactored divergence() and scalardotgradient() to use _compute_gradient() the same way that curl() does (instead of using gradient()), to avoid xarray's tiny overhead of combining data vars into a Dataset.
  7. Refactored divergence to maintain meaningful internal variable names like grad_u_zonal and grad_v_meridional instead of the confusing u = grad_u_zonal and v = grad_v_meridional.
  8. Improved divergence result name and attrs to be more descriptive, and now analogous to curl result name and attrs. Clarified meanings of u, x, v, y, φ, a, in the description in the attrs.
  9. Added {"curl": True} and {"scalar_dot_gradient": True} to attrs of curl and scalar_dot_gradient, analogous to attrs of gradient() and divergence().

Expected Usage

Exactly the same syntax as the operators have currently, but they can now be applied to arrays with more dimensions:

import uxarray as ux

grid_path = "/path/to/grid.nc"
data_path = "/path/to/data_with_extra_dims.nc"

uxds = ux.open_dataset(grid_path, data_path)

s = uxds["scalar_var_with_extra_dims"]
s.gradient()

u = uxds["zonal_var_with_extra_dims"]
v = uxds["meridional_var_with_extra_dims"]

curl = u.curl(v)
div = u.divergence(v)
scalardotgrad = u.scalardotgradient(v, q)

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 (_)
  • If touched any notebook files, cleared the output of all cells before committing
  • [N/A] If added new notebook files, put into appropriate directories and referenced in appropriate files

AI Disclosure

AI Usage: Claude for discussions and some code suggestions; GitHub Copilot's inline code suggestions

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

@Sevans711 Sevans711 added new feature New user-facing functionality improvement Improvements on existing features or infrastructure run-benchmark Run ASV benchmark workflow extra dims Handling of non-grid dimensions, such as time or altitude labels Oct 13, 2026
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

ASV Benchmarking

Benchmark Comparison Results

Benchmarks that have stayed the same:

Change Before [6d6c6da] After [018ec5a] Ratio Benchmark (Parameter)
972±30μs 986±10μs 1.01 connectivity.Connectivity.time_edge_face('120km')
485±10μs 471±20μs 0.97 connectivity.Connectivity.time_edge_face('480km')
3.94±0.05ms 3.97±0.03ms 1.01 connectivity.Connectivity.time_edge_node('120km')
1.29±0.01ms 1.33±0.02ms 1.03 connectivity.Connectivity.time_edge_node('480km')
69.4±3μs 71.9±3μs 1.04 connectivity.Connectivity.time_face_edge('120km')
67.7±9μs 70.7±5μs 1.04 connectivity.Connectivity.time_face_edge('480km')
1.01±0.06ms 981±30μs 0.97 connectivity.Connectivity.time_face_face('120km')
405±20μs 425±20μs 1.05 connectivity.Connectivity.time_face_face('480km')
62.6±10μs 63.8±2μs 1.02 connectivity.Connectivity.time_face_node('120km')
65.0±7μs 62.9±5μs 0.97 connectivity.Connectivity.time_face_node('480km')
476±10μs 490±20μs 1.03 connectivity.Connectivity.time_n_nodes_per_face('120km')
412±20μs 440±20μs 1.07 connectivity.Connectivity.time_n_nodes_per_face('480km')
1.39±0.09ms 1.39±0.01ms 1.00 connectivity.Connectivity.time_node_edge('120km')
500±20μs 494±10μs 0.99 connectivity.Connectivity.time_node_edge('480km')
79.7±1ms 78.5±0.6ms 0.98 connectivity.Connectivity.time_node_face('120km')
5.22±0.04ms 5.19±0.06ms 0.99 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')
420k 420k 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')
26.3k 26.3k 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')
7.54±0.07ms 7.73±0.06ms 1.03 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.38±0.04ms 2.40±0.03ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
9.42±0.08ms 9.47±9ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.31±0.02ms 1.29±0.03ms 0.99 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.86M 1.86M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
1.85M 1.85M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
2.01M 2.01M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
35.6k 35.6k 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.23±0.04μs 1.16±0.03μs 0.94 geometry_kernels.AccucrossKernels.time_accucross
2.67±0.05μs 2.61±0.02μs 0.98 geometry_kernels.AccucrossKernels.time_accucross_pair
381±8ns 400±10ns 1.05 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
411±20ns 405±9ns 0.99 geometry_kernels.EFTPrimitives.time_diff_of_products
355±30ns 355±9ns 1.00 geometry_kernels.EFTPrimitives.time_two_prod
360±0.4ns 351±20ns 0.97 geometry_kernels.EFTPrimitives.time_two_sum
687±20ns 666±10ns 0.97 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
707±20ns 752±10ns 1.06 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
801±20ns 802±10ns 1.00 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
1.21±0.5μs 817±20ns ~0.67 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
906±10ns 927±10ns 1.02 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
1.07±0.01μs 1.08±0.02μs 1.00 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
28.7±0.2μs 29.1±0.1μs 1.01 geometry_kernels.OrientPredicates.time_on_minor_arc
28.3±0.2μs 29.1±0.1μs 1.03 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.07±0.02ms 3.06±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.16±0.01ms 1.16±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_kernel
2.30±0.01ms 2.28±0ms 0.99 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
146±0.3μs 148±2μs 1.01 geometry_samebody.SameBodyConstLat.time_fp64_kernel
29.7±0.2ms 29.9±0.06ms 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.38±0.03ms 6.30±0.03ms 0.99 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
23.8±0.1ms 23.6±0.04ms 0.99 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
861±9μs 861±0.8μs 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
888±9ms 893±5ms 1.01 import.Imports.timeraw_import_uxarray
1.95±0.02ms 2.00±0.01ms 1.03 mpas_ocean.CheckNorm.time_check_norm('120km')
1.54±0.01ms 1.56±0.02ms 1.01 mpas_ocean.CheckNorm.time_check_norm('480km')
762±6μs 790±10μs 1.04 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
393±8μs 394±10μs 1.00 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
512±6μs 528±8μs 1.03 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
415±7μs 417±8μs 1.00 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.44±0.06ms 5.25±0.02ms 0.96 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.83±0.02ms 3.94±0.1ms 1.03 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
97.6±0.9ms 96.6±0.4ms 0.99 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
9.49±0.2ms 9.63±0.3ms 1.02 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
18.6±0.04ms 18.7±0.05ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
961±10μs 961±20μs 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.0±0.05ms 9.97±0.09ms 0.99 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
617±20μs 648±20μs 1.05 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
588±7ms 595±2ms 1.01 mpas_ocean.CrossSections.time_const_lat('120km', 1)
301±4ms 299±1ms 0.99 mpas_ocean.CrossSections.time_const_lat('120km', 2)
153±0.5ms 158±3ms 1.03 mpas_ocean.CrossSections.time_const_lat('120km', 4)
541±3ms 555±5ms 1.03 mpas_ocean.CrossSections.time_const_lat('480km', 1)
277±2ms 274±2ms 0.99 mpas_ocean.CrossSections.time_const_lat('480km', 2)
140±3ms 145±2ms 1.03 mpas_ocean.CrossSections.time_const_lat('480km', 4)
339M 364M 1.07 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 1)
340M 364M 1.07 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 2)
339M 364M 1.07 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 4)
18.3±0.1ms 18.0±0.01ms 0.98 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
2.00±0.01ms 2.03±0.02ms 1.01 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
13.9±0.9ms 14.5±0.3ms 1.05 mpas_ocean.FaceAreas.time_face_areas('120km')
4.36±0.08ms 4.55±0.3ms 1.04 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')
718k 718k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
295±2ms 311±3ms 1.05 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
35.9±0.2ms 37.1±0.5ms 1.03 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.25±0.3ms 5.17±0.3ms 0.99 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
14.0±0.09ms 14.3±0.1ms 1.02 mpas_ocean.Gradient.time_gradient('120km')
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.44M 1.07 mpas_ocean.Gradient.track_peakmem_gradient('120km')
335M 364M 1.09 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('120km')
274±9μs 280±9μs 1.02 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
145±2μs 150±2μs 1.03 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
140±3μs 141±2μs 1.00 mpas_ocean.Integrate.time_integrate('120km')
127±1μs 127±2μ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')
186±0.8ms 192±2ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
186±1ms 194±0.8ms 1.04 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
184±1ms 192±1ms 1.04 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
12.9±0.05ms 13.3±0.09ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
13.0±0.09ms 13.2±0.04ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
12.9±0.09ms 13.2±0.05ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
270±0.2ms 272±0.8ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
558±3ms 559±2ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
14.5±0.02ms 14.6±0.03ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
18.0±0.09ms 18.1±0.07ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
266±0.1ms 266±0.4ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
554±2ms 555±1ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
14.1±0.03ms 14.2±0.06ms 1.01 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
17.6±0.1ms 17.6±0.05ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 5.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)
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)
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)
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)
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)
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)
39.5±0.4ms 39.0±0.2ms 0.99 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
18.3±0.1ms 18.1±0.08ms 0.99 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
36.5±0.4ms 35.6±0.3ms 0.97 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
10.5±0.2ms 10.8±0.1ms 1.02 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
724±10μs 725±5μs 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
7.52±0.1ms 7.49±0.2ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.83M 5.82M 1.00 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')
747k 756k 1.01 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')
177k 177k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'numpy')
538k 546k 1.01 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
4.59±0.01s 4.52±0.02s 0.98 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
4.68±0.01s 4.65±0.01s 0.99 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
124±0.8ms 123±0.4ms 0.99 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
125±0.4ms 125±1ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
511±4ms 513±3ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
543±1ms 542±5ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
16.9±0.1ms 16.8±0.2ms 0.99 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
17.2±0.04ms 17.3±0.07ms 1.01 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
4.56±0.01ms 4.56±0.04ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
37.4±0.1ms 37.3±0.2ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
242±5μs 250±10μs 1.03 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
556±20μs 539±5μs 0.97 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'median')
234k 234k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'mean')
235k 235k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('120km', 'median')
19.5k 19.5k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'mean')
19.5k 19.5k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'median')
350±7μs 355±20μs 1.01 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
329±20μs 351±20μs 1.07 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
326±20μs 346±20μs 1.06 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
308±20μs 337±6μs 1.09 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
109±0.8ms 109±1ms 0.99 mpas_ocean.RemapDownsample.time_bilinear_remapping
17.0±0.2ms 17.3±0.2ms 1.02 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
15.4±0.1ms 15.5±0.09ms 1.00 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.12±0.01s 1.09±0.01s 0.97 mpas_ocean.RemapUpsample.time_bilinear_remapping
27.1±0.2ms 26.6±0.7ms 0.98 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
12.1±0.1ms 11.8±0.2ms 0.97 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
8.12±0.08ms 8.11±0.07ms 1.00 mpas_ocean.ZonalAverage.time_zonal_average('120km')
5.19±0.06ms 5.27±0.04ms 1.02 mpas_ocean.ZonalAverage.time_zonal_average('480km')
340M 363M 1.07 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('120km')
1.0356275249968472 1.0262044402762762 0.99 nogil_scaling.GILScaling.track_gil_scaling
6.77±0.01ms 6.98±0.1ms 1.03 quad_hexagon.QuadHexagon.time_open_dataset
5.72±0.02ms 5.85±0.05ms 1.02 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.5k 73.5k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
71.8k 71.8k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_grid
922±3ms 927±3ms 1.01 to_raster.ToRaster.time_to_raster((10000.0, 10.0))
2.25±0.01s 2.23±0.02s 0.99 to_raster.ToRaster.time_to_raster((10000.0, 100.0))
2.57±0.02s 2.58±0.01s 1.00 to_raster.ToRaster.time_to_raster((100000.0, 100.0))
271M 271M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 10.0))
684M 684M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 100.0))
768M 768M 1.00 to_raster.ToRaster.track_peakmem((100000.0, 100.0))

Benchmarks that have got worse:

Change Before [6d6c6da] After [018ec5a] Ratio Benchmark (Parameter)
+ 319M 363M 1.14 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
+ 320M 363M 1.13 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
+ 321M 363M 1.13 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
+ 320M 363M 1.13 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
+ 277M 363M 1.31 import.Imports.track_peakmem_import_uxarray
+ 323M 364M 1.13 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 1)
+ 322M 364M 1.13 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 2)
+ 323M 364M 1.13 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 4)
+ 49.7±0.9ms 55.2±0.5ms 1.11 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
+ 1.81±0.01ms 2.33±0.01ms 1.29 mpas_ocean.Gradient.time_gradient('480km')
+ 204k 225k 1.11 mpas_ocean.Gradient.track_peakmem_gradient('480km')
+ 315M 364M 1.16 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('480km')
+ 323M 363M 1.12 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('480km')

@Sevans711

Copy link
Copy Markdown
Collaborator Author

Re: ASV benchmarks, I think it looks okay. I am not expecting any performance changes, and I'm seeing no changes for the most part. The only exceptions are a small (~20%) slowdown in the 480km gradient benchmark (but no significant slowdown in the 120km gradient benchmark, so I think this might just be noise?), and a small increase to track_peakmem_gradient (~10%) but that also seems to occur randomly on different PRs which aren't touching gradient at all, so I think it's okay to ignore here?

Requesting reviews because things mostly look okay. The readthedocs build isn't building right now but it's not this PR's fault, readthedocs just had a random issue. If reviewers can review the non-readthedocs parts for now (i.e. everything except the vector_calculus.ipynb changes) and then check the readthedocs parts in a later review, that would be awesome!

@cmdupuis3 cmdupuis3 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I have two speed enhancements on the cmd/2d+calculus branch.

Other issues:

  1. Results now inherit the input's attrs via apply_ufunc/binary ops (keep_attrs on in current xarray), so gradient/curl/divergence outputs carry the input's long_name/standard_name, which mislabels the derivative.
  2. Unit issues: divergence(units=...) is now ignored. Also, curl and divergence units are missing their parentheses e.g. m/s/m vs. (m/s)/m

Comment thread test/core/test_vector_calculus.py Outdated
# Create two components for vector field (using same data for simplicity in test)
u_component = uxds['t2m']
v_component = uxds['t2m']
v_component = uxds['t2m'].rename("vname")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is kind of strange. Normally u and v are individual variables, so you'd load them like uxds['u'] and uxds['v'].

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, but this is a quick way to get two variables, and there's nothing invalid about a vector field whose two components are equal to each other, right? Also, the only thing I changed here was to add the call to .rename(); getting both components from the "t2m" variable is already on main. I added a comment to clarify this: # (used .rename() to check below the result name/attrs when input names differ)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I guess so. It's just kind of odd, I would think that if t2m is available, u and v would probably be available as well.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Ah, this test isn't on a "real" dataset though, it's just on the "quad-hexagon" tutorial example data, which only has "t2m". It's not a good demonstration of how to use the feature but it should be okay for the test suite, I think!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Okay, yeah if it isn't in the test dataset, we don't need to be worried. Maybe you can make v negative or something though, I sense that having them be exactly equal could lead to some confusing bugs in the future.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I'm still not really understanding why you would prefer v == -u instead of v == u. Both are perfectly valid physically, especially on a local mesh made of just 4 hexagons, right? They both correspond to flows at a 45 degree angle; changing the sign of v just changes the direction from northeast/southwest to northwest/southeast.

Also, the tests we're talking about in this thread just test the output format, not the correctness of the computations (that is covered by the other tests in the file). If we were creating these tests from scratch in this PR I wouldn't mind changing the values, but since the tests already existed, I'd prefer to stick with the existing values to avoid making unnecessary changes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Because if v == u, then f(v) == f(u), etc, so it's harder to tell if f() is working correctly when something goes wrong down the line.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

There is now a negative sign on the v_component in the test_..._output_format tests as requested; for more details see #1823 (comment) below.

Comment thread uxarray/core/utils.py Outdated
*xarrays,
input_core_dims=input_core_dims,
output_core_dims=output_core_dims,
vectorize=True,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This vectorize=True overrides the one this function receives. This should just be vectorize by itself.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The vectorize=True is intentional, as explained in the docstring. Added a comment at this line to clarify that it is intentional, pointing to the docstring for more details.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No, that still doesn't make sense to me. We shouldn't be providing optional arguments in the API if they only have one valid value, unless the plan is to implement it in the future.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fair enough, and it's a style choice so I will go with your preference. Moved the clarification from documentation of the vectorize parameter in the docstring into a note at the bottom of the docstring, and updated the function accordingly.

Comment thread uxarray/core/utils.py
input_core_dims=input_core_dims,
output_core_dims=output_core_dims,
vectorize=True,
**kw_apply_ufunc,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Also, dask= is never set, so it defaults to dask="forbidden". This results in a regression where the calculus operators crash when they're running on chunked data.

Consider adding this snippet before result:

    xarrays = [arr.to_xarray() for arr in uxarrays]
    if any(x.chunks is not None for x in xarrays):
        # f needs every grid element at once, so grid_dim must be a single chunk;
        # other dims keep their chunks and are processed block-by-block.
        xarrays = [
            x.chunk({grid_dim: -1}) if x.chunks is not None else x for x in xarrays
        ]
        kw_apply_ufunc.setdefault("dask", "parallelized")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed! Didn't handle it originally because using dask="parallelized" requires setting output_dtypes (otherwise there is a crash). Now, the helper function _apply_1dfunc_with_grid_core_dim uses dask="parallelized" by default, and raises a helpful warning if output_dtypes was not provided. Meanwhile, _compute_gradient() now provides output_dtypes when calling the helper; see also my comment on your next point.

Added some tests which would be sensitive to this issue, however they crashed locally on my end due to issue #1798. So, I commented them out, with a TODO comment noting that they should be uncommented once that issue gets fixed (i.e. either as part of #1797, or as part of this PR if 1797 merges into main first). I confirmed locally that copy-pasting the @parallel_njit decorator from 1797, onto the _compute_gradients_on_faces causes these currently-commented-out tests to pass.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I didn't automate chunking into a single chunk along the grid dimension because it feels a bit wrong to me to do that silently? The error that gets raised if n_face is split into multiple chunks looks like this:
ValueError: dimension n_face on 0th function argument to apply_ufunc with dask='parallelized' consists of multiple chunks, but is also a core dimension. To fix, either rechunk into a single array chunk along this dimension, i.e., ``.chunk(dict(n_face=-1))``, or pass ``allow_rechunk=True`` in ``dask_gufunc_kwargs`` but beware that this may significantly increase memory usage.

I think it's okay for that error to get raised all the way to the user, allowing them to rechunk their data before calling the relevant methods? That way if it is extra slow or has much larger memory usage, they will understand it is a dask/chunking issue, not just a uxarray issue.

Comment thread uxarray/core/gradient.py

def _compute_gradient(data, scale_by_radius=True):
from uxarray import UxDataArray
def _compute_gradient(data, scale_by_radius=True, **kw_apply_ufunc):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

kw_apply_ufunc.setdefault("output_dtypes", [np.float64, np.float64]) would help dask to know what the _compute_gradients_on_ types are, which prevents it from needing to compile them with numba on dummy args. As it is, those functions end up as np.float64 anyway, but maybe there's a way to be flexible with the precision.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed, using output_dtype = np.result_type(data.dtype, np.float64). The function uses arithmetic with grid values like face_lon which are already np.float64, so I don't think it's possible to end up with less precision than that.

But, using this command allows to support complex numbers! 😄 I doubt anyone will be using complex numbers anytime soon, but it's very simple to support them here, just needed this command and to pass output_dtype into the numba method so that the numba method's output array buffers get initialized with the correct types. Added some tests to ensure complex numbers are supported and give correct results, too!

@Sevans711
Sevans711 requested a review from cmdupuis3 October 15, 2026 15:50
@Sevans711

Sevans711 commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

The readthedocs build is now ready for review too! The animation at the end misses the last frame but that's a minor issue and it has been fixed upstream (see holoviz/holoviews#7113, holoviz/panel#8860); I think it's okay to keep as-is for now and allow it to resolve itself when the next panel release comes out (it looks like panel releases have been occurring every couple of months or so, and the last one was in August).

Regarding @cmdupuis3's point (1): I can't reproduce the inputs' .attrs["long_name"] attr being inherited; I see the correct "long_name" in the results. I can see the inputs' other attrs (such as "standard_name" or even "long name" (space, not underscore)) being propagated into the result, but to be honest that is what I would expect, especially if keep_attrs=True. (Imagine an unrelated attr like "simulation_resolution" or "data_source"; I don't think calculus methods should delete that?) Can you expand on this a bit more if it still feels like a bug to you?

Regarding point (2): the divergence(units=...) being ignored is intentional (see point (4) in original post), but the fact that it was being silently ignored is not intentional. Swapped DeprecationWarning to FutureWarning because DeprecationWarnings are often hidden by default (as discussed in #1573). The missing parentheses (e.g. "(m/s)/m" not "m/s/m") was due to an inconsistency between curl/divergence units (which always put parentheses on everything, e.g. "(K)/m") and gradient units (which never put parentheses on anything, e.g. "K/s/m"). I've updated this so that parentheses are added only when there is a "/" in the input units, e.g. "m/s" --> "(m/s)/m", while "K" --> "K/m". Updated the test_..._output_format() tests to include checks for units which previously failed but now pass.

Regarding the speedups on the cmd/2d+calculus branch, could we scope those to another PR? This PR already has a lot going on (extra tests and updates to user guide notebook due to adding a new feature, and numerous small expansions of scope like bugfixes and docstring improvements). I appreciate your speedup work here and definitely want to see it merged soon, you've touched on one point I've wanted for a long time (curl/divergence should only compute the gradient components they actually use) which is a "free win", and one point I considered briefly but intentionally skipped while drafting this PR (handle all grid computations only once during gradient() even when looping across multiple dims). The latter might be a tradeoff between memory usage and speed, or might be "free" too, depending on the implementation details, which would add another piece of complexity if bundled with this PR.

I believe all points have been addressed and this is now ready for re-review!

@rajeeja

rajeeja commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

Nice feature — one helper routing the grid dim through apply_ufunc as the core dimension, and all four operators pick up extra dims for free. Good design.

Before I dig in, please close out Chris's remaining points. On the u == v test, his argument holds: if v == u then f(v) == f(u), so a swapped-argument bug would never show. v = -u is a one-line change and settles it. And on the dask fix, the tests that would prove it are currently commented out for #1798 — worth noting in the PR description so the gap is visible to whoever merges.

@Sevans711

Copy link
Copy Markdown
Collaborator Author

Nice feature — one helper routing the grid dim through apply_ufunc as the core dimension, and all four operators pick up extra dims for free. Good design.

Before I dig in, please close out Chris's remaining points. On the u == v test, his argument holds: if v == u then f(v) == f(u), so a swapped-argument bug would never show. v = -u is a one-line change and settles it. And on the dask fix, the tests that would prove it are currently commented out for 1798 — worth noting in the PR description so the gap is visible to whoever merges.

@rajeeja thank you, and, done!

I still don't personally understand your reasoning for the sign flip; the relevant tests are completely insensitive to the actual values of the data. Nothing in the tests in question (the test_..._output_format) actually checks the values of the results. A swapped argument bug would indeed also already show, such as in the results' names, because I had already added the .rename("vname") call for the v_component. But, because the tests do not care about the values, and now that two reviewers have requested it, I have edited the code to follow your preferences here!

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

Labels

extra dims Handling of non-grid dimensions, such as time or altitude improvement Improvements on existing features or infrastructure new feature New user-facing functionality run-benchmark Run ASV benchmark workflow

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Calculus methods shouldn't require 1D

3 participants