Skip to content

Support Calculus with 2D+ arrays - #1823

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

Sevans711 wants to merge 24 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 improved:

Change Before [6126f0d] After [fab90f6] Ratio Benchmark (Parameter)
- 757k 679k 0.9 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')

Benchmarks that have stayed the same:

Change Before [6126f0d] After [fab90f6] Ratio Benchmark (Parameter)
972±20μs 999±8μs 1.03 connectivity.Connectivity.time_edge_face('120km')
468±20μs 469±20μs 1.00 connectivity.Connectivity.time_edge_face('480km')
3.93±0.03ms 4.00±0.03ms 1.02 connectivity.Connectivity.time_edge_node('120km')
1.26±0.02ms 1.29±0.03ms 1.02 connectivity.Connectivity.time_edge_node('480km')
76.7±10μs 72.1±10μs 0.94 connectivity.Connectivity.time_face_edge('120km')
74.0±10μs 68.5±5μs 0.93 connectivity.Connectivity.time_face_edge('480km')
977±20μs 1.00±0.03ms 1.02 connectivity.Connectivity.time_face_face('120km')
429±20μs 416±40μs 0.97 connectivity.Connectivity.time_face_face('480km')
64.2±5μs 64.6±9μs 1.01 connectivity.Connectivity.time_face_node('120km')
64.2±20μs 63.0±9μs 0.98 connectivity.Connectivity.time_face_node('480km')
482±20μs 483±10μs 1.00 connectivity.Connectivity.time_n_nodes_per_face('120km')
408±30μs 423±10μs 1.04 connectivity.Connectivity.time_n_nodes_per_face('480km')
1.37±0.02ms 1.40±0.04ms 1.03 connectivity.Connectivity.time_node_edge('120km')
490±20μs 494±20μs 1.01 connectivity.Connectivity.time_node_edge('480km')
75.9±0.6ms 76.9±0.8ms 1.01 connectivity.Connectivity.time_node_face('120km')
4.99±0.06ms 5.09±0.1ms 1.02 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')
8.30±0.2ms 8.71±0.1ms 1.05 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.66±0.04ms 3.07±0.5ms ~1.16 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
9.99±0.03ms 10.2±10ms 1.02 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.53±0.01ms 1.56±0.03ms 1.02 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.85M 1.85M 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.21±0.01μs 1.21±0.03μs 1.00 geometry_kernels.AccucrossKernels.time_accucross
2.64±0.03μs 2.62±0.03μs 0.99 geometry_kernels.AccucrossKernels.time_accucross_pair
456±30ns 450±20ns 0.99 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
436±9ns 411±8ns 0.94 geometry_kernels.EFTPrimitives.time_diff_of_products
376±10ns 401±20ns 1.07 geometry_kernels.EFTPrimitives.time_two_prod
381±30ns 381±20ns 1.00 geometry_kernels.EFTPrimitives.time_two_sum
666±10ns 651±30ns 0.98 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
731±30ns 747±20ns 1.02 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
847±30ns 801±20ns 0.95 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
832±20ns 786±10ns 0.95 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
871±9ns 882±40ns 1.01 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
1.05±0.02μs 1.06±0.04μs 1.01 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
50.1±1μs 51.7±0.9μs 1.03 geometry_kernels.OrientPredicates.time_on_minor_arc
51.2±2μs 51.6±0.8μs 1.01 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.09±0ms 3.06±0.01ms 0.99 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.17±0ms 1.16±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_kernel
2.30±0ms 2.28±0ms 0.99 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
148±2μs 148±0.08μs 1.00 geometry_samebody.SameBodyConstLat.time_fp64_kernel
29.6±0.02ms 29.9±0.02ms 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.33±0.03ms 6.34±0.01ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
23.9±0.1ms 23.7±0.06ms 0.99 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
861±3μs 869±8μs 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
924±10ms 915±9ms 0.99 import.Imports.timeraw_import_uxarray
1.99±0.04ms 2.19±0.05ms ~1.10 mpas_ocean.CheckNorm.time_check_norm('480km')
1.14±0.01ms 1.15±0ms 1.01 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
540±10μs 566±20μs 1.05 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
668±30μs 662±20μs 0.99 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
594±6μs 608±4μs 1.02 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
5.37±0.1ms 5.51±0.2ms 1.03 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.76±0.02ms 3.90±0.06ms 1.04 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
96.2±0.3ms 97.8±0.4ms 1.02 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
9.27±0.05ms 9.85±0.4ms 1.06 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
18.6±0.03ms 18.7±0.05ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
969±20μs 970±20μs 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
10.0±0.1ms 9.93±0.04ms 0.99 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
609±10μs 615±20μs 1.01 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
615±6ms 600±3ms 0.98 mpas_ocean.CrossSections.time_const_lat('120km', 1)
301±1ms 303±1ms 1.01 mpas_ocean.CrossSections.time_const_lat('120km', 2)
162±0.6ms 158±2ms 0.98 mpas_ocean.CrossSections.time_const_lat('120km', 4)
544±8ms 543±5ms 1.00 mpas_ocean.CrossSections.time_const_lat('480km', 1)
283±2ms 273±2ms 0.97 mpas_ocean.CrossSections.time_const_lat('480km', 2)
139±0.4ms 142±2ms 1.02 mpas_ocean.CrossSections.time_const_lat('480km', 4)
321M 344M 1.07 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 1)
321M 344M 1.07 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 2)
321M 344M 1.07 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 4)
26.4±0.3ms 26.8±0.2ms 1.02 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
2.89±0.02ms 2.91±0.03ms 1.01 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
14.7±2ms 14.8±0.2ms 1.01 mpas_ocean.FaceAreas.time_face_areas('120km')
4.55±0.4ms 4.27±0.3ms 0.94 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')
320±0.6ms 332±1ms 1.04 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
41.0±0.07ms 42.4±0.7ms 1.03 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
5.00±0.08ms 5.74±0.2ms ~1.15 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
13.4±0.2ms 14.2±0.2ms 1.07 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 363M 1.08 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('120km')
278±9μs 312±20μs ~1.12 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
147±3μs 149±2μs 1.02 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
200±4μs 205±10μs 1.02 mpas_ocean.Integrate.time_integrate('120km')
182±1μs 188±3μs 1.03 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')
185±2ms 188±2ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
183±1ms 191±2ms 1.04 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
184±3ms 188±1ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
12.9±0.09ms 13.3±0.1ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
12.8±0.2ms 13.2±0.3ms 1.03 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
12.8±0.05ms 13.0±0.09ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
247±1ms 259±6ms 1.05 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
512±3ms 519±3ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
13.5±0.01ms 13.4±0.03ms 0.99 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
16.7±0.02ms 16.6±0.09ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
241±0.8ms 245±2ms 1.02 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
501±4ms 504±2ms 1.01 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
13.1±0.2ms 12.9±0.1ms 0.99 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
16.2±0.1ms 16.2±0.2ms 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)
40.0±0.6ms 39.8±0.6ms 0.99 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
18.1±0.04ms 18.3±0.05ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
36.7±0.5ms 36.0±1ms 0.98 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
10.3±0.1ms 10.4±0.05ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
727±20μs 722±6μs 0.99 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
7.18±0.1ms 7.21±0.1ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.83M 5.83M 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')
177k 177k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'numpy')
538k 539k 1.00 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
4.50±0.01s 4.49±0s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
4.63±0.01s 4.62±0.01s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
122±0.9ms 122±0.2ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
123±0.07ms 124±1ms 1.01 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
511±3ms 507±1ms 0.99 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
546±1ms 541±2ms 0.99 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
16.8±0.2ms 16.7±0.03ms 0.99 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
17.0±0.02ms 17.0±0.02ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
4.51±0.02ms 4.51±0.03ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
37.9±0.1ms 37.4±0.2ms 0.99 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
256±10μs 244±4μs 0.96 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
553±10μs 536±8μ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')
413±10μs 397±20μs 0.96 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
368±20μs 384±20μs 1.04 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
390±30μs 386±50μs 0.99 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
324±30μs 370±20μs ~1.14 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
110±1ms 112±1ms 1.02 mpas_ocean.RemapDownsample.time_bilinear_remapping
17.2±0.2ms 17.6±0.07ms 1.02 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
15.8±0.2ms 15.9±0.3ms 1.00 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
1.17±0s 1.17±0.01s 0.99 mpas_ocean.RemapUpsample.time_bilinear_remapping
27.0±0.1ms 26.4±0.06ms 0.98 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
11.5±0.04ms 11.5±0.07ms 1.00 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
8.13±0.2ms 8.04±0.06ms 0.99 mpas_ocean.ZonalAverage.time_zonal_average('120km')
5.12±0.1ms 5.26±0.07ms 1.03 mpas_ocean.ZonalAverage.time_zonal_average('480km')
340M 363M 1.07 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('120km')
1.031639492189105 1.0251371192752619 0.99 nogil_scaling.GILScaling.track_gil_scaling
7.51±0.02ms 7.62±0.1ms 1.02 quad_hexagon.QuadHexagon.time_open_dataset
6.35±0.02ms 6.42±0.1ms 1.01 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
1.11±0.01s 1.12±0s 1.01 to_raster.ToRaster.time_to_raster((10000.0, 10.0))
2.70±0.02s 2.72±0.03s 1.01 to_raster.ToRaster.time_to_raster((10000.0, 100.0))
3.10±0.02s 3.11±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 [6126f0d] After [fab90f6] 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.14 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'))
+ 278M 363M 1.31 import.Imports.track_peakmem_import_uxarray
+ 2.44±0.01ms 2.73±0.03ms 1.12 mpas_ocean.CheckNorm.time_check_norm('120km')
+ 305M 344M 1.13 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 1)
+ 305M 344M 1.13 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 2)
+ 305M 344M 1.13 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 4)
+ 48.6±0.8ms 54.4±0.6ms 1.12 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
+ 1.78±0.01ms 2.50±0.02ms 1.4 mpas_ocean.Gradient.time_gradient('480km')
+ 204k 225k 1.11 mpas_ocean.Gradient.track_peakmem_gradient('480km')
+ 315M 363M 1.15 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
Comment thread uxarray/core/utils.py Outdated
Comment thread uxarray/core/utils.py
Comment thread uxarray/core/gradient.py Outdated
@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!

@rajeeja rajeeja left a comment •

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.

Approving; these calculus changes look good to me. Once #1798 (#1798) is resolved, we should enable the commented Dask regression tests and verify the parallelized path in CI.

@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.

Re (1):

Metadata like data_source or simulation_resolution is still true of a derivative, so that's okay, but standard_name, long_name, valid_min/valid_max/valid_range and actual_range describe the input field, and copied onto a derivative, they would be wrong. gradient() never sets long_name or standard_name, so the gradient of T comes out labelled air_temperature / Temperature. The valid_* attributes cause data loss. CF-aware readers (netCDF4-python by default, ncview, Panoply) mask values outside the valid range. A gradient of about 1e-6 K/m is far below valid_min=150, so the values masked when the file is read back.

There are also no deliberate rules deciding which attributes survive; the result falls out of xarray’s merge. With keep_attrs=True, binary operations keep the attributes that don’t conflict between operands. In curl(U, V) the two standard_names conflict and are dropped. The latitude-correction term, built from U, then adds eastward_wind back. In divergence(U, V) that term is built from V, so the divergence ends up labelled northward_wind. Neither is a correct name for a curl or a divergence.

import netCDF4
import numpy as np
import uxarray as ux

ds = ux.tutorial.open_dataset("outCSne30-vortex")
psi = ds["psi"]
psi.uxgrid.sphere_radius = 6.371e6

u = psi.rename("U").assign_attrs(
    units="m/s", standard_name="eastward_wind", long_name="Zonal wind",
    data_source="vortex test case",
)
v = psi.rename("V").assign_attrs(
    units="m/s", standard_name="northward_wind", long_name="Meridional wind",
    data_source="vortex test case",
)
T = psi.rename("T").assign_attrs(
    units="K", standard_name="air_temperature", long_name="Temperature",
    valid_min=150.0, valid_max=350.0,
)

# 1. gradient() never sets long_name/standard_name, so it keeps T's:
print(T.gradient()["zonal_gradient"].attrs)

# 2. divergence(U, V) ends up labelled with V's standard_name; curl(U, V) with U's
print(u.divergence(v).attrs["standard_name"])  # northward_wind
print(u.curl(v).attrs["standard_name"])        # eastward_wind

# 3. T's valid range is inherited by its gradient, so a CF-aware reader
#    treats every gradient value (~1e-6 K/m) as out of range:
T.gradient()["zonal_gradient"].to_netcdf("dTdx.nc")
with netCDF4.Dataset("dTdx.nc") as nc:
    x = nc["zonal_gradient"][:]
print(f"{np.ma.count_masked(x)} of {x.size} values masked on read")

also fix missing gradient long_name and description, and set gradient=True in gradient output attrs, similarly to curl and divergence.
@Sevans711

Copy link
Copy Markdown
Collaborator Author

Re (1), sure, I agree those are confusing cases. I now see what you mean about long_name; it was being handled properly by curl and divergence, but not gradient (this is now fixed; _compute_gradient now adds "long_name" and "description" attrs to the data_vars in the gradient output UxDataset). Also, clarifying: these issues already exist on main, for the most part; this is an expansion of scope for this PR. But, it's not too much work to do something reasonable here.

I've added uxarray/conventions/attrs.py with the following convention for the list of attrs that should be considered "value-describing" and thus dropped by default during operations which change values, like calculus operations. This list is:

_VALUE_DESCRIBING_ATTRS = [
    # What the quantity is
    "standard_name", "long_name", "units", "units_metadata",
    # Valid/observed values (may cause masking on netcdf read-back)
    "valid_min", "valid_max", "valid_range", "actual_range",
    # Missing-data sentinels
    "_FillValue", "missing_value",
    # Packing / scaling
    "scale_factor", "add_offset", "_Unsigned",
    # Categorical flags
    "flag_values", "flag_masks", "flag_meanings",
    # Provenance tied to the input's values
    "cell_methods", "ancillary_variables", "standard_error_multiplier",
    # might also consider "description" and "comment",
    # but not including those here yet.
]

Kept a leading underscore on this list to emphasize it might be changed at any time, it's an internal implementation detail; hoping this can avoid blocking this PR on a detailed discussion of exactly which names belong in this list, since it will be very easy to change later if needed.

I also added a helper function to drop value describing attrs, and used it in appropriate places. Tested in test_gradient_output_format, test_curl_output_format, and test_divergence_output_format, with exhaustive checks in the first case, and spot checks in all three cases.

@Sevans711

Copy link
Copy Markdown
Collaborator Author

As a sidenote, netcdf writers seem to complain when attrs' values are booleans, so I'm not sure if {"divergence": True}, {"curl": True}, {"divergence": True} is a great convention for calculus operators' results' attrs... but that seems to be the convention on main right now, so if there is a desire to swap it, it might make sense to push to another PR?

@cmdupuis3

Copy link
Copy Markdown
Collaborator

@Sevans711 Yeah, there could definitely be more issues there. CF-compliant attributes are a giant cluster, so even what you see here may be deceptively oversimplified.

  1. NetCDF can't store booleans, so you'll need to fix "gradient": True etc so we can save to NetCDF correctly.

  2. scalardotgradient still incorrectly carries U and V’s value attributes

  3. _drop_value_describing_attrs is doing a deep copy, including the grid object, so you'll want obj.copy(deep=False)

@Sevans711

Copy link
Copy Markdown
Collaborator Author

Yeah, there could definitely be more issues there. CF-compliant attributes are a giant cluster, so even what you see here may be deceptively oversimplified.

To confirm, we're not trying to solve all of them as part of this PR, though, right? This feels like another PR's worth of work, or possibly multiple PRs if the goal is to make it apply across all operations which alter results' values throughout all of uxarray.

  1. NetCDF can't store booleans, so you'll need to fix "gradient": True etc so we can save to NetCDF correctly.

Fixed by updating naming scheme to {"derivative": methodname} instead of {methodname: True} (for methodname in "gradient", "curl", "divergence", "scalardotgradient").

  1. scalardotgradient still incorrectly carries U and V’s value attributes

Fixed by adding a _drop_value_describing_attrs call inside scalardotgradient, and creating a test_scalardotgradient_output_format regression test which would have crashed before this fix.

  1. _drop_value_describing_attrs is doing a deep copy, including the grid object, so you'll want obj.copy(deep=False)

Fixed, great catch, I previously suspected deep=False was the default, but that is wrong, the default is deep=True!

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