Skip to content

Support Calculus with 2D+ arrays - #1823

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

Sevans711 wants to merge 35 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 [14793cb] After [acfe10c] Ratio Benchmark (Parameter)
- 755k 665k 0.88 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'grid_chunks')

Benchmarks that have stayed the same:

Change Before [14793cb] After [acfe10c] Ratio Benchmark (Parameter)
1.27±0.1ms 1.22±0.04ms 0.96 connectivity.Connectivity.time_edge_face('120km')
457±10μs 458±5μs 1.00 connectivity.Connectivity.time_edge_face('480km')
4.69±0.05ms 4.65±0.3ms 0.99 connectivity.Connectivity.time_edge_node('120km')
1.06±0.03ms 1.05±0.04ms 0.98 connectivity.Connectivity.time_edge_node('480km')
59.1±4μs 58.9±2μs 1.00 connectivity.Connectivity.time_face_edge('120km')
58.4±3μs 58.7±6μs 1.01 connectivity.Connectivity.time_face_edge('480km')
779±20μs 782±10μs 1.00 connectivity.Connectivity.time_face_face('120km')
340±9μs 322±10μs 0.95 connectivity.Connectivity.time_face_face('480km')
54.6±10μs 53.5±7μs 0.98 connectivity.Connectivity.time_face_node('120km')
50.8±5μs 50.9±3μs 1.00 connectivity.Connectivity.time_face_node('480km')
484±30μs 472±40μs 0.98 connectivity.Connectivity.time_n_nodes_per_face('120km')
339±20μs 342±20μs 1.01 connectivity.Connectivity.time_n_nodes_per_face('480km')
1.03±0.04ms 985±30μs 0.96 connectivity.Connectivity.time_node_edge('120km')
431±8μs 423±8μs 0.98 connectivity.Connectivity.time_node_edge('480km')
67.0±0.3ms 66.2±0.5ms 0.99 connectivity.Connectivity.time_node_face('120km')
4.34±0.05ms 4.29±0.02ms 0.99 connectivity.Connectivity.time_node_face('480km')
1.42M 1.42M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_face('120km')
105k 105k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_face('480km')
6.48M 6.48M 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_node('120km')
419k 419k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_edge_node('480km')
2.05k 2.05k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_edge('120km')
2.05k 2.05k 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.18k 2.18k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_node('120km')
2.18k 2.18k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_face_node('480km')
240k 240k 1.00 connectivity.ConnectivityTracemalloc.track_peakmem_n_nodes_per_face('120km')
26k 26k 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.93±0.04ms 7.93±0.1ms 1.00 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
2.53±0.04ms 2.55±0.09ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
9.78±0.2ms 9.83±8ms 1.01 face_bounds.FaceBounds.time_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
1.43±0.05ms 1.48±0.05ms 1.03 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.83M 1.83M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
1.83M 1.83M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
1.99M 1.99M 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
35.3k 35.3k 1.00 face_bounds.FaceBounds.track_peakmem_face_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
1.15±0.04μs 1.08±0.04μs 0.95 geometry_kernels.AccucrossKernels.time_accucross
2.50±0.05μs 2.49±0.04μs 1.00 geometry_kernels.AccucrossKernels.time_accucross_pair
511±20ns 486±10ns 0.95 geometry_kernels.EFTPrimitives.time_acc_sqrt_re
476±20ns 486±20ns 1.02 geometry_kernels.EFTPrimitives.time_diff_of_products
416±20ns 421±30ns 1.01 geometry_kernels.EFTPrimitives.time_two_prod
441±20ns 446±20ns 1.01 geometry_kernels.EFTPrimitives.time_two_sum
792±50ns 701±10ns ~0.89 geometry_kernels.GCAConstLatIntersection.time_accux_constlat_kernel
771±20ns 762±20ns 0.99 geometry_kernels.GCAConstLatIntersection.time_gca_const_lat_intersection
902±30ns 836±20ns 0.93 geometry_kernels.GCAConstLatIntersection.time_try_gca_const_lat_intersection
811±10ns 747±20ns 0.92 geometry_kernels.GCAGCAIntersection.time_accux_gca_kernel
1.03±0.02μs 1.03±0.02μs 1.00 geometry_kernels.GCAGCAIntersection.time_gca_gca_intersection
1.19±0.03μs 1.12±0.02μs 0.94 geometry_kernels.GCAGCAIntersection.time_try_gca_gca_intersection
48.3±0.6μs 51.9±0.9μs 1.08 geometry_kernels.OrientPredicates.time_on_minor_arc
49.4±0.9μs 52.2±2μs 1.06 geometry_kernels.OrientPredicates.time_orient3d_on_sphere
3.05±0ms 3.08±0.02ms 1.01 geometry_samebody.SameBodyConstLat.time_accux_dispatch
1.17±0ms 1.16±0ms 1.00 geometry_samebody.SameBodyConstLat.time_accux_kernel
2.33±0.04ms 2.29±0.01ms 0.98 geometry_samebody.SameBodyConstLat.time_fp64_dispatch
147±0.3μs 147±5μs 1.00 geometry_samebody.SameBodyConstLat.time_fp64_kernel
29.8±0.1ms 29.7±0.07ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_dispatch
6.26±0ms 6.27±0.01ms 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_accux_kernel
23.6±0.01ms 23.8±0.05ms 1.01 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_dispatch
860±1μs 860±0.9μs 1.00 geometry_samebody_gcagca.SameBodyGcaGca.time_fp64_kernel
759±6ms 755±6ms 0.99 import.Imports.timeraw_import_uxarray
1 1 1.00 lazy_grid_construction.LazyGridConstruction.track_computes_isel
2 2 1.00 lazy_grid_construction.LazyGridConstruction.track_computes_open_grid_chunked
98.3±2ms 96.9±1ms 0.99 lazy_grid_construction.OpenGridChunked.time_open_grid('120km')
133±1ms 138±1ms 1.04 lazy_grid_construction.OpenGridChunked.time_open_grid('480km')
1.99M 1.99M 1.00 lazy_grid_construction.OpenGridChunked.track_peakmem_open_grid('120km')
2.3M 2.22M 0.96 lazy_grid_construction.OpenGridChunked.track_peakmem_open_grid('480km')
2.34±0.03ms 2.37±0.02ms 1.01 mpas_ocean.CheckNorm.time_check_norm('120km')
1.83±0.01ms 1.94±0.05ms 1.06 mpas_ocean.CheckNorm.time_check_norm('480km')
1.34±0.03ms 1.39±0.04ms 1.04 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('120km')
521±8μs 510±10μs 0.98 mpas_ocean.ConnectivityConstruction.time_face_face_connectivity('480km')
808±20μs 759±20μs 0.94 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('120km')
572±20μs 574±10μs 1.00 mpas_ocean.ConnectivityConstruction.time_n_nodes_per_face('480km')
4.85±0.1ms 5.08±0.2ms 1.05 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('120km')
3.43±0.09ms 3.43±0.02ms 1.00 mpas_ocean.ConstructFaceLatLon.time_cartesian_averaging('480km')
122±1ms 120±0.5ms 0.98 mpas_ocean.ConstructFaceLatLon.time_welzl('120km')
11.4±0.5ms 11.4±0.7ms 1.00 mpas_ocean.ConstructFaceLatLon.time_welzl('480km')
13.5±0.01ms 13.5±0.04ms 1.00 mpas_ocean.ConstructTreeStructures.time_ball_tree('120km')
791±10μs 799±20μs 1.01 mpas_ocean.ConstructTreeStructures.time_ball_tree('480km')
11.3±0.2ms 11.2±0.06ms 0.99 mpas_ocean.ConstructTreeStructures.time_kd_tree('120km')
628±20μs 631±8μs 1.01 mpas_ocean.ConstructTreeStructures.time_kd_tree('480km')
607±8ms 627±6ms 1.03 mpas_ocean.CrossSections.time_const_lat('120km', 1)
306±3ms 304±2ms 0.99 mpas_ocean.CrossSections.time_const_lat('120km', 2)
159±3ms 157±1ms 0.99 mpas_ocean.CrossSections.time_const_lat('120km', 4)
545±2ms 558±0.4ms 1.02 mpas_ocean.CrossSections.time_const_lat('480km', 1)
276±3ms 279±2ms 1.01 mpas_ocean.CrossSections.time_const_lat('480km', 2)
142±1ms 145±0.8ms 1.03 mpas_ocean.CrossSections.time_const_lat('480km', 4)
25.3±0.2ms 25.1±0.2ms 0.99 mpas_ocean.DualMesh.time_dual_mesh_construction('120km')
2.85±0.01ms 3.01±0.05ms 1.06 mpas_ocean.DualMesh.time_dual_mesh_construction('480km')
17.2±0.9ms 17.6±1ms 1.02 mpas_ocean.FaceAreas.time_face_areas('120km')
4.79±0.3ms 4.34±0.4ms ~0.91 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')
697k 697k 1.00 mpas_ocean.FaceAreas.track_peakmem_face_areas('480km')
128±2ms 125±0.9ms 0.98 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', False)
45.3±0.9ms 45.4±0.8ms 1.00 mpas_ocean.GeoDataFrame.time_to_geodataframe('120km', True)
23.1±0.4ms 23.2±0.2ms 1.00 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', False)
4.35±0.2ms 4.32±0.2ms 0.99 mpas_ocean.GeoDataFrame.time_to_geodataframe('480km', True)
12.0±0.03ms 12.5±0.05ms 1.04 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')
321±6μs 319±5μs 0.99 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('120km')
136±4μs 139±2μs 1.02 mpas_ocean.HoleEdgeIndices.time_construct_hole_edge_indices('480km')
188±10μs 175±6μs 0.93 mpas_ocean.Integrate.time_integrate('120km')
159±8μs 156±3μs 0.98 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')
137±2ms 139±0.8ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'exclude')
136±1ms 137±0.2ms 1.01 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'include')
137±1ms 137±0.8ms 1.00 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('120km', 'split')
9.55±0.02ms 9.72±0.1ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'exclude')
9.55±0.02ms 9.75±0.2ms 1.02 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'include')
9.87±0.2ms 9.67±0.03ms 0.98 mpas_ocean.MatplotlibConversion.time_dataarray_to_polycollection('480km', 'split')
150±0.3ms 151±0.8ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('120km', 1.0)
336±1ms 338±1ms 1.01 mpas_ocean.NeighborhoodBuild.time_build('120km', 5.0)
9.81±0.02ms 9.81±0.01ms 1.00 mpas_ocean.NeighborhoodBuild.time_build('480km', 1.0)
12.4±0.1ms 12.3±0.03ms 0.99 mpas_ocean.NeighborhoodBuild.time_build('480km', 5.0)
147±1ms 147±0.9ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 1.0)
330±0.07ms 331±2ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('120km', 5.0)
9.57±0.02ms 9.56±0.02ms 1.00 mpas_ocean.NeighborhoodBuild.time_query_radius('480km', 1.0)
12.2±0.1ms 12.1±0.1ms 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)
824k 824k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 15.0)
384k 384k 1.00 mpas_ocean.NeighborhoodBuild.track_peakmem_build('480km', 5.0)
37.0±0.4ms 37.0±0.7ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'grid_chunks')
16.6±0.01ms 16.7±0.2ms 1.00 mpas_ocean.NeighborhoodDask.time_mean('120km', 'numpy')
33.8±0.5ms 33.3±0.3ms 0.98 mpas_ocean.NeighborhoodDask.time_mean('120km', 'time_chunks')
9.40±0.2ms 9.47±0.07ms 1.01 mpas_ocean.NeighborhoodDask.time_mean('480km', 'grid_chunks')
594±9μs 587±5μs 0.99 mpas_ocean.NeighborhoodDask.time_mean('480km', 'numpy')
6.72±0.1ms 6.63±0.1ms 0.99 mpas_ocean.NeighborhoodDask.time_mean('480km', 'time_chunks')
5.82M 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.67M 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')
528k 534k 1.01 mpas_ocean.NeighborhoodDask.track_peakmem_mean('480km', 'time_chunks')
4.50±0s 4.52±0.01s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'mean')
4.62±0.01s 4.60±0.02s 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('120km', 'median')
119±0.8ms 119±0.6ms 1.00 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'mean')
121±0.4ms 120±0.9ms 0.99 mpas_ocean.NeighborhoodReduce.time_dataset_reduce('480km', 'median')
514±2ms 507±2ms 0.99 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'mean')
541±2ms 542±2ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('120km', 'median')
16.5±0.1ms 16.7±0.2ms 1.01 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'mean')
17.0±0.2ms 17.0±0.3ms 1.00 mpas_ocean.NeighborhoodReduce.time_neighborhood_reduce('480km', 'median')
4.58±0.03ms 4.51±0.04ms 0.98 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'mean')
37.1±0.04ms 37.1±0.07ms 1.00 mpas_ocean.NeighborhoodReduce.time_reduce('120km', 'median')
236±10μs 200±10μs ~0.85 mpas_ocean.NeighborhoodReduce.time_reduce('480km', 'mean')
504±30μs 472±7μs 0.94 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.6k 19.6k 1.00 mpas_ocean.NeighborhoodReduce.track_peakmem_reduce('480km', 'median')
495±40μs 422±50μs ~0.85 mpas_ocean.PointInPolygon.time_face_search_lonlat('120km')
446±30μs 420±50μs 0.94 mpas_ocean.PointInPolygon.time_face_search_lonlat('480km')
461±40μs 397±40μs ~0.86 mpas_ocean.PointInPolygon.time_face_search_xyz('120km')
408±50μs 389±50μs 0.95 mpas_ocean.PointInPolygon.time_face_search_xyz('480km')
95.7±0.5ms 94.9±1ms 0.99 mpas_ocean.RemapDownsample.time_bilinear_remapping
14.7±0.05ms 14.9±0.1ms 1.01 mpas_ocean.RemapDownsample.time_inverse_distance_weighted_remapping
13.3±0.06ms 13.5±0.2ms 1.01 mpas_ocean.RemapDownsample.time_nearest_neighbor_remapping
768±3ms 773±5ms 1.01 mpas_ocean.RemapUpsample.time_bilinear_remapping
23.4±0.04ms 23.4±0.09ms 1.00 mpas_ocean.RemapUpsample.time_inverse_distance_weighted_remapping
10.4±0.05ms 10.3±0.02ms 0.99 mpas_ocean.RemapUpsample.time_nearest_neighbor_remapping
7.64±0.07ms 7.63±0.04ms 1.00 mpas_ocean.ZonalAverage.time_zonal_average('120km')
4.61±0.05ms 4.65±0.03ms 1.01 mpas_ocean.ZonalAverage.time_zonal_average('480km')
1.0323990676052186 1.0356594163979322 1.00 nogil_scaling.GILScaling.track_gil_scaling
311±3ms 309±1ms 0.99 plots.PlotHealpix.time_default_plot(6)
2.61±0.01s 2.60±0s 1.00 plots.PlotHealpix.time_default_plot(8)
28M 28M 1.00 plots.PlotHealpix.track_peakmem_default_plot(6)
443M 443M 1.00 plots.PlotHealpix.track_peakmem_default_plot(8)
6.85±0.03ms 6.91±0.04ms 1.01 quad_hexagon.QuadHexagon.time_open_dataset
5.90±0.1ms 5.80±0.01ms 0.98 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
70.1k 70.1k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_dataset
69.9k 69.9k 1.00 quad_hexagon.QuadHexagon.track_peakmem_open_grid
1.16±0.01s 1.16±0s 1.00 to_raster.ToRaster.time_to_raster((10000.0, 10.0))
2.84±0.02s 2.83±0.01s 1.00 to_raster.ToRaster.time_to_raster((10000.0, 100.0))
3.25±0.01s 3.24±0.02s 1.00 to_raster.ToRaster.time_to_raster((100000.0, 100.0))
270M 270M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 10.0))
680M 680M 1.00 to_raster.ToRaster.track_peakmem((10000.0, 100.0))
765M 765M 1.00 to_raster.ToRaster.track_peakmem((100000.0, 100.0))

Benchmarks that have got worse:

Change Before [14793cb] After [acfe10c] Ratio Benchmark (Parameter)
+ 321M 403M 1.26 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/mpas/QU/oQU480.231010.nc'))
+ 319M 403M 1.26 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/scrip/outCSne8/outCSne8.nc'))
+ 320M 403M 1.26 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/geoflow-small/grid.nc'))
+ 320M 403M 1.26 face_bounds.FaceBoundsColdStartRss.track_peakmem_open_and_bounds(PosixPath('/home/runner/work/uxarray/uxarray/test/meshfiles/ugrid/quad-hexagon/grid.nc'))
+ 295M 402M 1.36 import.Imports.track_peakmem_import_uxarray
+ 338M 403M 1.19 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 1)
+ 338M 403M 1.19 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 2)
+ 338M 403M 1.19 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('120km', 4)
+ 322M 403M 1.25 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 1)
+ 322M 403M 1.25 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 2)
+ 321M 403M 1.25 mpas_ocean.CrossSectionsPeakMem.track_peakmem_const_lat('480km', 4)
+ 1.47±0.02ms 2.18±0.01ms 1.48 mpas_ocean.Gradient.time_gradient('480km')
+ 204k 226k 1.11 mpas_ocean.Gradient.track_peakmem_gradient('480km')
+ 335M 403M 1.2 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('120km')
+ 314M 403M 1.28 mpas_ocean.GradientColdStartRss.track_peakmem_gradient('480km')
+ 340M 403M 1.19 mpas_ocean.ZonalAveragePeakMem.track_peakmem_zonal_average('120km')
+ 323M 403M 1.25 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

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!

@cmdupuis3

Copy link
Copy Markdown
Collaborator

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.

@Sevans711 Yeah, we're not trying to solve all of them, but we should definitely avoid making it worse in the code this PR touches.

@cmdupuis3
cmdupuis3 self-requested a review October 21, 2026 21:00

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

import uxarray as ux

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

# explicitly unnamed inputs
anon = psi.rename(None)
print(anon.curl(anon).name)                     # curl_None_None
print(anon.divergence(anon).name)               # divergence_None_None
print(anon.scalardotgradient(anon, anon).name)  # scalardotgradient_None_None_None
print(anon.gradient()["zonal_gradient"].attrs["long_name"])  # Zonal gradient of None

# the common way it happens: xarray drops the name when combining
# two differently named arrays
u, v = psi.rename("u"), psi.rename("v")
u_total = u + 2 * v.rename("v2")
print(u_total.name)                             # None
print(u_total.divergence(v).name)               # divergence_None_v
print(u_total.divergence(v).attrs["long_name"]) # Divergence of (None, v)

This snippet produces nonsense names. curl_None_None is produced on main, but this branch is causing that to happen with divergence and scalardotgradient.

Comment thread uxarray/core/gradient.py
Comment thread uxarray/core/utils.py
Comment thread uxarray/core/gradient.py Outdated
grad_units = f"{base_units}/m" if base_units else "1/m"
else:
grad_units = f"{base_units}/rad" if base_units else "1/rad"
base_units = data.attrs.get("units", "1")

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.

Suggested change
base_units = data.attrs.get("units", "1")
base_units = str(data.attrs.get("units") or "").strip() or "1"

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.

What does this fix? The .strip() is surprising to me and feels out of place here (I can't think of a reason a user would have "units" attr start/end with whitespace, so if they did, I would want to respect that, rather than strip it without understanding the reasoning). Also, I think the original is more readable. This feels like a Claude-ism to make the code unnecessarily more complicated…?

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.

Think about what the old code covers; if you have a unit attribute "", that was originally handled by the else, but now you get /m instead of 1/m.

@Sevans711 Sevans711 Oct 27, 2026 •

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, thank you for clarifying, I was missing the edge case of arr.attrs["units"]="" (or " " or " \t\n " or any other combinations of whitespace). I do agree in case of empty string or just whitespace, the result should be something like "1/m" not "/m". The main reason I missed this was the lack of regression tests and comments about it, so I added regression tests and a relevant comment.

However, I think the way the old code handled other edge cases like arr.attrs["units"]=False or arr.attrs["units"]=0 was extremely misleading; both of those would be converted to "1/m". I'm here for supporting the simple "units is an empty string" case as a natural extension of units being formed from a combination of strings, but I don't think uxarray should be silently converting units=False into units of 1/m. Since there were no regression tests or documentation about cases like that, my guess is that they were an accidental consequence rather than something intentional.

I also added regression tests corresponding to the cases of units attr being None, False, 0. In these cases I'm asserting the original object should be converted to a string, so the result becomes something like "None/m", "False/m", or "0/m" (none of which are particularly meaningful, but at least they are clearly wrong instead of silently assuming those values for units should be treated as equivalent to "1").

Comment thread uxarray/core/utils.py Outdated
Comment thread uxarray/core/dataarray.py
Comment thread uxarray/core/dataarray.py Outdated
Comment thread test/core/test_vector_calculus.py Outdated
@Sevans711

Copy link
Copy Markdown
Collaborator Author

@cmdupuis3 Thank you for the review and for finding all of my small typos! Fixed all the typos as you suggested.

This snippet produces nonsense names. curl_None_None is produced on main, but this branch is causing that to happen with divergence and scalardotgradient.

I'm not sure why you called them "nonsense"; None is a marker of something being missing and, as you say, this follows the pattern that currently exists on main for curl. Something like "curl_unnamedarray_unnamedarray" feels a bit too verbose to me for the name itself. Although, in long_name where there is more room to describe things, maybe it makes sense? For now, I've decided to update the code to keep None in the actual array name, but use "unnamed_array" in the long_name. Is this change good enough to address your concerns?

In general I would guess users will often see name and long_name simultaneously when displaying the array. In that view you can easily discover that None in the name means the corresponding input had no name. For example:

Screenshot 2026-10-23 at 9 42 25 AM

Aside from this, and one remaining point on the inline comment (#1823 (comment), re: automatically rechunking along the grid dimension), I believe everything has been addressed!

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

cmdupuis3 commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

@Sevans711 I think just saying divergence is more elegant than saying divergence_None_None. None isn't very informative and ends up being clutter at that point.

@Sevans711

Copy link
Copy Markdown
Collaborator Author

I think just saying divergence is more elegant than saying divergence_None_None. None isn't very informative and ends up being clutter at that point.

True, I agree it's less cluttered to use "divergence", and that None isn't super informative here.

How should we handle divergence_None_v or divergence_u_None? Should these keep the None placeholder? Should they also just be "divergence"? (I would want to avoid divergence_v or divergence_u due to the ambiguity.)

Since the names aren't consistent on main (which is currently is always naming divergence "divergence" and is always naming curl "curl_{u.name}_{v.name}", even if those names are None), I think it makes sense to get at least 1 more opinion on this before proceeding here. I'll ping people separately to see if anyone has thoughts / strong feelings about it.

@cmdupuis3

Copy link
Copy Markdown
Collaborator

@Sevans711 Yeah, it does look a bit complicated. u.divergence(v) is one of those weird numerical python idioms that make my eyeballs hurt.

@Sevans711

Copy link
Copy Markdown
Collaborator Author

Yeah, it does look a bit complicated. u.divergence(v) is one of those weird numerical python idioms that make my eyeballs hurt.

Honestly, something like u.curl(v) really isn't my favorite syntax either… it's really not a python idiom as much as it is a uxarray idiom. I usually think of attributes similarly to ownership, so I still read this mentally as "u's curl of v", like "the curl of v belonging to u."

Another perfectly valid Python syntax could have been something like ux.curl(u, v) or g.curl(dim="component") with g an array with u and v stacked along the "component" dimension (whose coords could be something like ["zonal", "meridional"]).

But, of course, syntax changes like that aren't in scope of this PR and we probably wouldn't want to touch them at this point since the feature already exists!

the "standard" way to name results in other packages seems to be either:
- result.name = inputs' name if all inputs have same name, else None
- result.name = name of operation which was performed, regardless of inputs' names.

This change uses the second option since calculus operations significantly change the meaning of the values.
@Sevans711

Copy link
Copy Markdown
Collaborator Author

From internal discussion with @erogluorhan, it sounds like the best way forward here is for this PR to update curl's name to behave like divergence's name and scalardotgradient's name on main, instead of the other way around. I.e. the name can just be the operator name ("curl", "divergence", "scalardotgradient") rather than incorporating the names of the inputs. I've updated the code and tests accordingly.

My understanding is that the main deciding factor here was to consider what other software packages do. For operations on multiple arrays, other packages seem to either (1) use a name based solely on the operation performed without incorporating the inputs' names at all (matching the decision made above), or (2) use the same exact name as the inputs if all inputs have the same name, else None (but matching inputs' names doesn't make sense here because calculus operations significantly change the meaning of the values).

I kept the inputs' name information in the result's attrs["long_name"] for now, because it feels nice to be able to see that info, and I don't think it hurts to include? E.g., if you're much later in a workflow and start to lose track of which variable is which, having informative attrs may help to avoid mistakes.

With that, I think the only outstanding point is the lingering comment (#1823 (comment)) about the units attr, which I believe I've addressed above? Otherwise, I think this is ready for re-review (@cmdupuis3)!

@cmdupuis3

Copy link
Copy Markdown
Collaborator

More units issues:

  1. If a grid's sphere_radius is 1.0, you get /m again. Probably need a type guard.
  2. Whitespace around units is still getting through, that was the point of .strip

@Sevans711

Copy link
Copy Markdown
Collaborator Author

More units issues:

  1. If a grid's sphere_radius is 1.0, you get /m again. Probably need a type guard.
  2. Whitespace around units is still getting through, that was the point of .strip

@cmdupuis3 thank you for your reply.

For (1), I'm not sure what you mean. Do you mean that if the sphere_radius is 1.0, but scale_by_radius=True, then we're returning units of "{base_units}/m"? I think this matches the behavior on main; if so, could it be pushed to a different PR instead of blocking this one? I'd be happy to open a separate issue report once I understand it, to make sure we don't lose it, if that helps.

For (2), as I mentioned in the original comment thread above, #1823 (comment):

The .strip() is surprising to me and feels out of place here (I can't think of a reason a user would have "units" attr start/end with whitespace, so if they did, I would want to respect that, rather than strip it without understanding the reasoning).

Can you give an example where (2) is clearly a bug? I'm pretty sure that currently, units like units=" " are being converted to "1/m" or "1/rad", while units like units=" s " would be converted to something like " s /m" or " s /rad". Both of these seem correct to me. When units isn't pure-whitespace, I don't understand why the leading/trailing whitespace exists so I don't want to eagerly trim it.

@cmdupuis3

Copy link
Copy Markdown
Collaborator

@Sevans711

(1) Try this:

import numpy as np
import uxarray as ux

uxgrid = ux.open_grid("test/meshfiles/mpas/QU/oQU480.231010.nc")
uxda = ux.UxDataArray(
    np.zeros(uxgrid.n_face), dims=["n_face"], attrs={"units": "K"}, uxgrid=uxgrid
)

uxgrid.sphere_radius = 1.0
print(uxda.gradient()["zonal_gradient"].attrs["units"])  # K/m, expected K/rad

(2) Preserving the whitespace in " s " is an issue. We should have one standard format for all outputs IMO. You can bet that without sanitizing these strings, there will eventually be 20 different formats mixed together in one file. These units can get propagated very far downstream, and I would imagine the unstripped whitespace could e.g. wreck a lot of downstream parsers if that's what they're doing.

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