uxarray 2026.4.1__tar.gz → 2026.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. {uxarray-2026.4.1 → uxarray-2026.7.0}/PKG-INFO +4 -4
  2. {uxarray-2026.4.1 → uxarray-2026.7.0}/pyproject.toml +4 -3
  3. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/__init__.py +2 -0
  4. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/core/api.py +70 -15
  5. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/core/dataarray.py +241 -24
  6. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/core/dataset.py +58 -31
  7. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/core/gradient.py +78 -54
  8. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/core/utils.py +5 -1
  9. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/core/zonal.py +230 -71
  10. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/coordinates.py +84 -1
  11. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/geometry.py +6 -6
  12. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/grid.py +29 -4
  13. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_scrip.py +62 -14
  14. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/utils.py +1 -1
  15. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/plot/accessor.py +42 -7
  16. uxarray-2026.7.0/uxarray/plot/utils.py +72 -0
  17. uxarray-2026.7.0/uxarray/remap/__init__.py +13 -0
  18. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/remap/accessor.py +150 -1
  19. uxarray-2026.7.0/uxarray/remap/apply_weights.py +120 -0
  20. uxarray-2026.7.0/uxarray/remap/structured.py +216 -0
  21. uxarray-2026.7.0/uxarray/remap/weights.py +194 -0
  22. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/remap/yac.py +144 -10
  23. uxarray-2026.7.0/uxarray/tutorial/__init__.py +208 -0
  24. uxarray-2026.7.0/uxarray/tutorial/registry.py +102 -0
  25. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray.egg-info/PKG-INFO +4 -4
  26. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray.egg-info/SOURCES.txt +7 -0
  27. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray.egg-info/requires.txt +2 -3
  28. uxarray-2026.7.0/uxarray.egg-info/scm_file_list.json +385 -0
  29. uxarray-2026.7.0/uxarray.egg-info/scm_version.json +8 -0
  30. uxarray-2026.4.1/uxarray/plot/utils.py +0 -40
  31. uxarray-2026.4.1/uxarray/remap/__init__.py +0 -7
  32. {uxarray-2026.4.1 → uxarray-2026.7.0}/.codecov.yml +0 -0
  33. {uxarray-2026.4.1 → uxarray-2026.7.0}/.git_archival.txt +0 -0
  34. {uxarray-2026.4.1 → uxarray-2026.7.0}/.gitattributes +0 -0
  35. {uxarray-2026.4.1 → uxarray-2026.7.0}/CITATION.cff +0 -0
  36. {uxarray-2026.4.1 → uxarray-2026.7.0}/CODE_OF_CONDUCT.md +0 -0
  37. {uxarray-2026.4.1 → uxarray-2026.7.0}/CONTRIBUTING.md +0 -0
  38. {uxarray-2026.4.1 → uxarray-2026.7.0}/INSTALLATION.md +0 -0
  39. {uxarray-2026.4.1 → uxarray-2026.7.0}/LICENSE +0 -0
  40. {uxarray-2026.4.1 → uxarray-2026.7.0}/MANIFEST.in +0 -0
  41. {uxarray-2026.4.1 → uxarray-2026.7.0}/README.md +0 -0
  42. {uxarray-2026.4.1 → uxarray-2026.7.0}/build.sh +0 -0
  43. {uxarray-2026.4.1 → uxarray-2026.7.0}/setup.cfg +0 -0
  44. {uxarray-2026.4.1 → uxarray-2026.7.0}/setup.py +0 -0
  45. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/constants.py +0 -0
  46. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/conventions/__init__.py +0 -0
  47. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/conventions/descriptors.py +0 -0
  48. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/conventions/ugrid.py +0 -0
  49. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/core/__init__.py +0 -0
  50. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/core/accessors.py +0 -0
  51. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/core/aggregation.py +0 -0
  52. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/cross_sections/__init__.py +0 -0
  53. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/cross_sections/dataarray_accessor.py +0 -0
  54. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/cross_sections/grid_accessor.py +0 -0
  55. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/cross_sections/sample.py +0 -0
  56. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/formatting_html.py +0 -0
  57. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/__init__.py +0 -0
  58. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/arcs.py +0 -0
  59. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/area.py +0 -0
  60. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/bounds.py +0 -0
  61. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/connectivity.py +0 -0
  62. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/dual.py +0 -0
  63. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/integrate.py +0 -0
  64. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/intersections.py +0 -0
  65. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/neighbors.py +0 -0
  66. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/point_in_face.py +0 -0
  67. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/slice.py +0 -0
  68. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/utils.py +0 -0
  69. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/grid/validation.py +0 -0
  70. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/__init__.py +0 -0
  71. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_delaunay.py +0 -0
  72. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_esmf.py +0 -0
  73. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_exodus.py +0 -0
  74. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_fesom2.py +0 -0
  75. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_geopandas.py +0 -0
  76. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_geos.py +0 -0
  77. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_healpix.py +0 -0
  78. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_icon.py +0 -0
  79. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_mpas.py +0 -0
  80. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_structured.py +0 -0
  81. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_topology.py +0 -0
  82. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_ugrid.py +0 -0
  83. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_vertices.py +0 -0
  84. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/io/_voronoi.py +0 -0
  85. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/plot/__init__.py +0 -0
  86. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/plot/constants.py +0 -0
  87. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/plot/matplotlib.py +0 -0
  88. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/remap/bilinear.py +0 -0
  89. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/remap/inverse_distance_weighted.py +0 -0
  90. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/remap/nearest_neighbor.py +0 -0
  91. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/remap/spatial_coords_remap.py +0 -0
  92. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/remap/utils.py +0 -0
  93. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/subset/__init__.py +0 -0
  94. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/subset/dataarray_accessor.py +0 -0
  95. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/subset/grid_accessor.py +0 -0
  96. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/utils/__init__.py +0 -0
  97. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray/utils/computing.py +0 -0
  98. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray.egg-info/dependency_links.txt +0 -0
  99. {uxarray-2026.4.1 → uxarray-2026.7.0}/uxarray.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uxarray
3
- Version: 2026.4.1
3
+ Version: 2026.7.0
4
4
  Summary: Xarray extension for unstructured climate and global weather data analysis and visualization.
5
5
  License: Apache License
6
6
  Version 2.0, January 2004
@@ -217,6 +217,7 @@ Classifier: Programming Language :: Python :: 3.10
217
217
  Classifier: Programming Language :: Python :: 3.11
218
218
  Classifier: Programming Language :: Python :: 3.12
219
219
  Classifier: Programming Language :: Python :: 3.13
220
+ Classifier: Programming Language :: Python :: 3.14
220
221
  Requires-Python: >=3.10
221
222
  Description-Content-Type: text/markdown
222
223
  License-File: LICENSE
@@ -226,10 +227,10 @@ Requires-Dist: dask[dataframe]
226
227
  Requires-Dist: datashader
227
228
  Requires-Dist: geoviews
228
229
  Requires-Dist: holoviews
229
- Requires-Dist: matplotlib
230
+ Requires-Dist: matplotlib<3.11
230
231
  Requires-Dist: matplotlib-inline
231
232
  Requires-Dist: netcdf4
232
- Requires-Dist: numba
233
+ Requires-Dist: numba>=0.63
233
234
  Requires-Dist: numpy
234
235
  Requires-Dist: pandas
235
236
  Requires-Dist: pyarrow
@@ -247,7 +248,6 @@ Requires-Dist: pyproj
247
248
  Provides-Extra: complete
248
249
  Requires-Dist: uxarray[dev]; extra == "complete"
249
250
  Provides-Extra: dev
250
- Requires-Dist: pathlib; extra == "dev"
251
251
  Requires-Dist: pre_commit; extra == "dev"
252
252
  Requires-Dist: pytest; extra == "dev"
253
253
  Requires-Dist: pytest-cov; extra == "dev"
@@ -9,6 +9,7 @@ classifiers=[
9
9
  "Programming Language :: Python :: 3.11",
10
10
  "Programming Language :: Python :: 3.12",
11
11
  "Programming Language :: Python :: 3.13",
12
+ "Programming Language :: Python :: 3.14",
12
13
  ]
13
14
 
14
15
  dynamic = ["version"]
@@ -27,10 +28,10 @@ dependencies = [
27
28
  "datashader",
28
29
  "geoviews",
29
30
  "holoviews",
30
- "matplotlib",
31
+ "matplotlib<3.11", # matplotlib 3.11.0 breaks some plots but <3.11 is fine; see issue #1542.
31
32
  "matplotlib-inline",
32
33
  "netcdf4",
33
- "numba",
34
+ "numba>=0.63", # 0.63 is the first release supporting Python 3.14 (3.10 <= py < 3.15). See #1561.
34
35
  "numpy",
35
36
  "pandas",
36
37
  "pyarrow",
@@ -50,7 +51,7 @@ dependencies = [
50
51
 
51
52
  [project.optional-dependencies]
52
53
  complete = ["uxarray[dev]"]
53
- dev = ['pathlib', 'pre_commit', 'pytest', 'pytest-cov', 'ruff', 'asv']
54
+ dev = ['pre_commit', 'pytest', 'pytest-cov', 'ruff', 'asv']
54
55
 
55
56
  [project.urls]
56
57
  Documentation = "https://uxarray.readthedocs.io/"
@@ -1,3 +1,4 @@
1
+ from . import tutorial
1
2
  from .constants import INT_DTYPE, INT_FILL_VALUE
2
3
  from .core.api import (
3
4
  concat,
@@ -30,6 +31,7 @@ __all__ = (
30
31
  "open_multigrid",
31
32
  "list_grid_names",
32
33
  "concat",
34
+ "tutorial",
33
35
  "UxDataset",
34
36
  "UxDataArray",
35
37
  "INT_DTYPE",
@@ -353,7 +353,7 @@ def list_grid_names(
353
353
 
354
354
  def open_dataset(
355
355
  grid_filename_or_obj: str | os.PathLike[Any] | dict | Dataset,
356
- filename_or_obj: str | os.PathLike[Any],
356
+ filename_or_obj: str | os.PathLike[Any] | Dataset | None = None,
357
357
  chunks=None,
358
358
  chunk_grid: bool = True,
359
359
  use_dual: bool | None = False,
@@ -364,14 +364,22 @@ def open_dataset(
364
364
 
365
365
  Parameters
366
366
  ----------
367
- grid_filename_or_obj : str | os.PathLike[Any] | dict | xr.dataset
368
- Strings and Path objects are interpreted as a path to a grid file. Xarray Datasets assume that
369
- each member variable is in the UGRID conventions and will be used to create a ``ux.Grid``. Similarly, a dictionary
370
- containing UGRID variables can be used to create a ``ux.Grid``
371
- filename_or_obj : str | os.PathLike[Any]
367
+ grid_filename_or_obj : str | os.PathLike[Any] | dict | xr.Dataset
368
+ Grid information for the ``UxDataset``. Strings and Path objects are interpreted as a path to a grid
369
+ file. Xarray Datasets assume that each member variable is in the UGRID conventions and will be used to
370
+ create a ``ux.Grid``. Similarly, a dictionary containing UGRID variables can be used to create a
371
+ ``ux.Grid``. A path to a directory containing grid files (e.g. a FESOM2 ASCII grid) is also accepted,
372
+ but only when ``filename_or_obj`` is provided; directory input is not supported in the single-argument
373
+ form below.
374
+ filename_or_obj : str | os.PathLike[Any] | xr.Dataset, optional
372
375
  String or Path object as a path to a netCDF file or an OpenDAP URL that
373
- stores the actual data set. It is the same ``filename_or_obj`` in
374
- ``xarray.open_dataset``.
376
+ stores the actual data set, or an already-open ``xarray.Dataset``. It
377
+ is the same ``filename_or_obj`` in ``xarray.open_dataset``. If omitted,
378
+ ``grid_filename_or_obj`` is also used as the data source, allowing a
379
+ combined grid-and-data file or ``xarray.Dataset`` to be opened with a
380
+ single argument. In this single-argument form the file is used to build
381
+ the grid and also treated as data; all variables it contains, including
382
+ grid variables, will be included as data.
375
383
  chunks : int, dict, 'auto' or None, default: None
376
384
  If provided, used to load the grid into dask arrays.
377
385
 
@@ -400,29 +408,76 @@ def open_dataset(
400
408
  uxds : uxarray.UxDataset
401
409
  Dataset with linked `uxgrid` property of type `Grid`.
402
410
 
411
+ Notes
412
+ -----
413
+ The grid need not be a complete or fully UGRID-compliant mesh, nor represent
414
+ a spherical/Earth-science domain. A partial grid can be opened; operations
415
+ requiring information absent from the file will raise when used, but
416
+ unrelated functionality remains available.
417
+
403
418
  Examples
404
419
  --------
405
420
  Open a dataset with a grid file and data file
406
421
 
407
422
  >>> import uxarray as ux
408
423
  >>> ux_ds = ux.open_dataset("grid_file.nc", "data_file.nc")
424
+
425
+ Open a dataset stored in a single combined grid-and-data file
426
+
427
+ >>> ux_ds = ux.open_dataset("combined_file.nc")
409
428
  """
429
+ import xarray as xr
430
+
410
431
  if grid_kwargs is None:
411
432
  grid_kwargs = {}
412
433
 
413
- # Construct a Grid, validate parameters, and correct chunks
414
- uxgrid, corrected_chunks = _get_grid(
415
- grid_filename_or_obj, chunks, chunk_grid, use_dual, grid_kwargs, **kwargs
416
- )
434
+ if filename_or_obj is None:
435
+ if isinstance(grid_filename_or_obj, (str, os.PathLike)):
436
+ if os.path.isdir(grid_filename_or_obj):
437
+ raise ValueError(
438
+ "ux.open_dataset() with a single directory argument is not supported. "
439
+ "Supply a path to a grid file instead. Directory-based grids (e.g. a "
440
+ "FESOM2 ASCII grid) are only recognized when a separate data file is "
441
+ "also provided, i.e. ux.open_dataset(grid_directory, data_file)."
442
+ )
417
443
 
418
- # Load the data as a Xarray Dataset
419
- ds = _open_dataset_with_fallback(filename_or_obj, chunks=corrected_chunks, **kwargs)
444
+ ds = _open_dataset_with_fallback(
445
+ grid_filename_or_obj,
446
+ chunks=match_chunks_to_ugrid(grid_filename_or_obj, chunks),
447
+ **kwargs,
448
+ )
449
+ elif isinstance(grid_filename_or_obj, xr.Dataset):
450
+ ds = grid_filename_or_obj
451
+ else:
452
+ raise ValueError(
453
+ "If filename_or_obj is omitted, grid_filename_or_obj must be a file path or xarray.Dataset."
454
+ )
455
+
456
+ uxgrid, _ = _get_grid(ds, chunks, chunk_grid, use_dual, grid_kwargs, **kwargs)
457
+ filename_or_obj = grid_filename_or_obj
458
+ else:
459
+ # Construct a Grid, validate parameters, and correct chunks
460
+ uxgrid, corrected_chunks = _get_grid(
461
+ grid_filename_or_obj, chunks, chunk_grid, use_dual, grid_kwargs, **kwargs
462
+ )
463
+
464
+ # Load the data as a Xarray Dataset
465
+ if isinstance(filename_or_obj, xr.Dataset):
466
+ ds = filename_or_obj
467
+ else:
468
+ ds = _open_dataset_with_fallback(
469
+ filename_or_obj, chunks=corrected_chunks, **kwargs
470
+ )
420
471
 
421
472
  # Map original dimensions to the UGRID conventions
422
473
  ds = _map_dims_to_ugrid(ds, uxgrid._source_dims_dict, uxgrid)
423
474
 
424
475
  # Create a UXarray Dataset by linking the Xarray Dataset with a UXarray Grid
425
- return UxDataset(ds, uxgrid=uxgrid, source_datasets=str(filename_or_obj))
476
+ source_datasets = (
477
+ None if isinstance(filename_or_obj, xr.Dataset) else str(filename_or_obj)
478
+ )
479
+
480
+ return UxDataset(ds, uxgrid=uxgrid, source_datasets=source_datasets)
426
481
 
427
482
 
428
483
  def open_mfdataset(
@@ -24,6 +24,7 @@ from uxarray.core.utils import _map_dims_to_ugrid
24
24
  from uxarray.core.zonal import (
25
25
  _compute_conservative_zonal_mean_bands,
26
26
  _compute_non_conservative_zonal_mean,
27
+ _compute_zonal_anomaly,
27
28
  )
28
29
  from uxarray.cross_sections import UxDataArrayCrossSectionAccessor
29
30
  from uxarray.formatting_html import array_repr
@@ -163,6 +164,40 @@ class UxDataArray(xr.DataArray):
163
164
  else:
164
165
  return None
165
166
 
167
+ @property
168
+ def data_location(self):
169
+ """Returns where on the grid the data variable is stored.
170
+
171
+ The location is inferred from the grid dimension present in the data
172
+ variable, using UGRID-style names:
173
+
174
+ - ``"face_centered"`` if the data contains the ``n_face`` dimension
175
+ - ``"node_centered"`` if the data contains the ``n_node`` dimension
176
+ - ``"edge_centered"`` if the data contains the ``n_edge`` dimension
177
+ - ``None`` if the data is not mapped to the grid
178
+
179
+ Notes
180
+ -----
181
+ Additional locations described in the UGRID/spectral-element ecosystem
182
+ (e.g. ``"face_average"``, ``"edge_orthogonal"``, ``"edge_parallel"``,
183
+ ``"cgll"``, ``"dgll"``) cannot be inferred from a data variable's
184
+ dimensions alone and are not currently distinguished here.
185
+
186
+ Returns
187
+ -------
188
+ str or None
189
+ One of ``"face_centered"``, ``"node_centered"``,
190
+ ``"edge_centered"``, or ``None``.
191
+ """
192
+ if self._face_centered():
193
+ return "face_centered"
194
+ elif self._node_centered():
195
+ return "node_centered"
196
+ elif self._edge_centered():
197
+ return "edge_centered"
198
+ else:
199
+ return None
200
+
166
201
  def to_geodataframe(
167
202
  self,
168
203
  periodic_elements: str | None = "exclude",
@@ -465,12 +500,7 @@ class UxDataArray(xr.DataArray):
465
500
  else:
466
501
 
467
502
  def _is_default_extent() -> bool:
468
- # Default extents are indicated by xlim/ylim being (0, 1)
469
- # when autoscale is still on (no extent has been explicitly set)
470
- if not ax.get_autoscale_on():
471
- return False
472
- xlim, ylim = ax.get_xlim(), ax.get_ylim()
473
- return np.allclose(xlim, (0.0, 1.0)) and np.allclose(ylim, (0.0, 1.0))
503
+ return ax.get_autoscale_on()
474
504
 
475
505
  if _is_default_extent():
476
506
  try:
@@ -772,6 +802,70 @@ class UxDataArray(xr.DataArray):
772
802
  """Alias of zonal_mean; prefer `zonal_mean` for primary API."""
773
803
  return self.zonal_mean(lat=lat, conservative=conservative, **kwargs)
774
804
 
805
+ def zonal_anomaly(self, lat=(-90, 90, 10), conservative: bool = False):
806
+ """Compute the zonal anomaly: each face value minus the mean of its latitude band.
807
+
808
+ Returns a new ``UxDataArray`` with the same dimensions as the input,
809
+ where each face holds its original value minus the zonal mean of the
810
+ latitude band it belongs to.
811
+
812
+ Parameters
813
+ ----------
814
+ lat : tuple or array-like, default=(-90, 90, 10)
815
+ Latitude band specification:
816
+ - tuple (start, end, step): band edges via np.linspace(start, end, n)
817
+ - array-like: explicit band edges in degrees
818
+ conservative : bool, default=False
819
+ If True, uses area-weighted band means and blends across bands for
820
+ faces that straddle a band boundary, reusing the face-band weight
821
+ matrix computed for zonal_mean so no geometry is duplicated.
822
+ If False, assigns each face to a band by its centroid latitude.
823
+
824
+ Returns
825
+ -------
826
+ UxDataArray
827
+ Same dimensions as input with per-face band mean subtracted.
828
+
829
+ Examples
830
+ --------
831
+ >>> uxds["var"].zonal_anomaly()
832
+ >>> uxds["var"].zonal_anomaly(lat=(-60, 60, 5), conservative=True)
833
+ """
834
+ if not self._face_centered():
835
+ raise ValueError(
836
+ "Zonal anomaly is only supported for face-centered data variables."
837
+ )
838
+
839
+ if isinstance(lat, tuple):
840
+ start, end, step = lat
841
+ if step <= 0:
842
+ raise ValueError("Step size must be positive.")
843
+ num_points = int(round((end - start) / step)) + 1
844
+ edges = np.linspace(start, end, num_points)
845
+ edges = np.clip(edges, -90, 90)
846
+ elif isinstance(lat, (list, np.ndarray)):
847
+ edges = np.asarray(lat, dtype=float)
848
+ else:
849
+ raise ValueError(
850
+ "Invalid value for 'lat'. Must be a tuple (start, end, step) or array-like band edges."
851
+ )
852
+
853
+ if edges.ndim != 1 or edges.size < 2:
854
+ raise ValueError("Band edges must be 1D with at least two values.")
855
+
856
+ res = _compute_zonal_anomaly(self, edges, conservative=conservative)
857
+
858
+ return UxDataArray(
859
+ res,
860
+ dims=self.dims,
861
+ coords=self.coords,
862
+ name=self.name + "_zonal_anomaly"
863
+ if self.name is not None
864
+ else "zonal_anomaly",
865
+ attrs={"zonal_anomaly": True, "conservative": conservative},
866
+ uxgrid=self.uxgrid,
867
+ )
868
+
775
869
  def azimuthal_mean(
776
870
  self,
777
871
  center_coord,
@@ -1416,22 +1510,40 @@ class UxDataArray(xr.DataArray):
1416
1510
  """
1417
1511
  return _uxda_grid_aggregate(self, destination, "any", **kwargs)
1418
1512
 
1419
- def gradient(self, **kwargs) -> UxDataset:
1513
+ def gradient(self, scale_by_radius: bool = True, **kwargs) -> UxDataset:
1420
1514
  """
1421
1515
  Computes the gradient of a data variable.
1422
1516
 
1517
+ Parameters
1518
+ ----------
1519
+ scale_by_radius : bool, default=True
1520
+ Divide unit-sphere derivatives by ``uxgrid.sphere_radius`` so the
1521
+ result carries physical, per-meter units (``[data units]/m``). When
1522
+ ``False`` the result is left on the unit sphere with per-radian units
1523
+ (``[data units]/rad``). If ``True`` but the grid has no
1524
+ ``sphere_radius`` attribute, the result falls back to unit-sphere
1525
+ output and a ``UserWarning`` is emitted.
1526
+
1423
1527
  Returns
1424
1528
  -------
1425
1529
  gradient: UxDataset
1426
- Dataset containing the zonal and merdional components of the gradient.
1530
+ Dataset containing the zonal and meridional components of the gradient.
1531
+ With the default ``scale_by_radius=True`` the components are in
1532
+ ``[data units]/m``; with ``scale_by_radius=False`` they are in
1533
+ ``[data units]/rad``.
1427
1534
 
1428
1535
  Notes
1429
1536
  -----
1430
- The Green-Gauss theorm is utilized, where a closed control volume around each cell
1537
+ The Green-Gauss theorem is utilized, where a closed control volume around each cell
1431
1538
  is formed connecting centroids of the neighboring cells. The surface integral is
1432
1539
  approximated using the trapezoidal rule. The sum of the contributions is then
1433
1540
  normalized by the cell volume.
1434
1541
 
1542
+ By default the raw unit-sphere (per-radian) gradient is divided by
1543
+ ``uxgrid.sphere_radius`` to yield physical per-meter values. For Earth
1544
+ (radius ~6.37e6 m) this scales magnitudes down by ~1/6.37e6 relative to
1545
+ the unit-sphere result.
1546
+
1435
1547
  Example
1436
1548
  -------
1437
1549
  >>> uxds["var"].gradient()
@@ -1446,7 +1558,9 @@ class UxDataArray(xr.DataArray):
1446
1558
  )
1447
1559
 
1448
1560
  # Compute the zonal and meridional gradient components of the stored data variable
1449
- grad_zonal_da, grad_meridional_da = _compute_gradient(self)
1561
+ grad_zonal_da, grad_meridional_da = _compute_gradient(
1562
+ self, scale_by_radius=scale_by_radius
1563
+ )
1450
1564
 
1451
1565
  # Create a dataset containing both gradient components
1452
1566
  return UxDataset(
@@ -1459,7 +1573,9 @@ class UxDataArray(xr.DataArray):
1459
1573
  coords=self.coords,
1460
1574
  )
1461
1575
 
1462
- def curl(self, other: "UxDataArray", **kwargs) -> "UxDataArray":
1576
+ def curl(
1577
+ self, other: "UxDataArray", scale_by_radius: bool = True, **kwargs
1578
+ ) -> "UxDataArray":
1463
1579
  """
1464
1580
  Computes the curl of a vector field.
1465
1581
 
@@ -1469,6 +1585,11 @@ class UxDataArray(xr.DataArray):
1469
1585
  The second component of the vector field. This UxDataArray should
1470
1586
  represent the meridional (v) component, while self represents the
1471
1587
  zonal (u) component.
1588
+ scale_by_radius : bool, default=True
1589
+ Divide unit-sphere derivatives by ``uxgrid.sphere_radius`` so the
1590
+ result carries physical, per-meter units (e.g. ``1/s`` for a velocity
1591
+ field). When ``False`` the result is left on the unit sphere
1592
+ (per radian).
1472
1593
  **kwargs : dict
1473
1594
  Additional keyword arguments (currently unused, reserved for future extensions).
1474
1595
 
@@ -1476,7 +1597,9 @@ class UxDataArray(xr.DataArray):
1476
1597
  -------
1477
1598
  curl : UxDataArray
1478
1599
  The curl of the vector field (u, v), computed as:
1479
- curl = ∂v/∂x - ∂u/∂y
1600
+ curl = ∂v/∂x - ∂u/∂y. With the default ``scale_by_radius=True`` the
1601
+ result is in ``([u units])/m`` (``1/s`` for velocity); with
1602
+ ``scale_by_radius=False`` it is in ``([u units])/rad``.
1480
1603
 
1481
1604
  Notes
1482
1605
  -----
@@ -1519,21 +1642,30 @@ class UxDataArray(xr.DataArray):
1519
1642
  )
1520
1643
 
1521
1644
  # Compute gradients of both components
1522
- grad_u_zonal, grad_u_meridional = _compute_gradient(self)
1523
- grad_v_zonal, grad_v_meridional = _compute_gradient(other)
1645
+ grad_u_zonal, grad_u_meridional = _compute_gradient(
1646
+ self, scale_by_radius=scale_by_radius
1647
+ )
1648
+ grad_v_zonal, grad_v_meridional = _compute_gradient(
1649
+ other, scale_by_radius=scale_by_radius
1650
+ )
1524
1651
 
1525
1652
  # Compute curl = ∂v/∂x - ∂u/∂y
1526
1653
  curl_values = grad_v_zonal.values - grad_u_meridional.values
1527
1654
 
1655
+ u_units = self.attrs.get("units", "")
1656
+ has_sphere_radius = "sphere_radius" in self.uxgrid._ds.attrs
1657
+ if scale_by_radius and has_sphere_radius:
1658
+ curl_units = f"({u_units})/m" if u_units else "1/m"
1659
+ else:
1660
+ curl_units = f"({u_units})/rad" if u_units else "1/rad"
1661
+
1528
1662
  # Create the result UxDataArray
1529
1663
  curl_da = UxDataArray(
1530
1664
  curl_values,
1531
1665
  dims=self.dims,
1532
1666
  attrs={
1533
1667
  "long_name": f"Curl of ({self.name}, {other.name})",
1534
- "units": "1/s"
1535
- if "units" not in self.attrs
1536
- else f"({self.attrs.get('units', '1')})/m",
1668
+ "units": curl_units,
1537
1669
  "description": "Curl of vector field computed as ∂v/∂x - ∂u/∂y",
1538
1670
  },
1539
1671
  uxgrid=self.uxgrid,
@@ -1542,16 +1674,26 @@ class UxDataArray(xr.DataArray):
1542
1674
 
1543
1675
  return curl_da
1544
1676
 
1545
- def divergence(self, other: "UxDataArray", **kwargs) -> "UxDataArray":
1677
+ def divergence(
1678
+ self, other: "UxDataArray", scale_by_radius: bool = True, **kwargs
1679
+ ) -> "UxDataArray":
1546
1680
  """
1547
1681
  Computes the divergence of the vector field defined by this UxDataArray and other.
1548
1682
 
1549
1683
  Parameters
1550
1684
  ----------
1551
1685
  other : UxDataArray
1552
- The second component of the vector field. This UxDataArray represents the first component.
1686
+ The second (meridional, v) component of the vector field; ``self`` is
1687
+ the first (zonal, u) component.
1688
+ scale_by_radius : bool, default=True
1689
+ Divide unit-sphere derivatives by ``uxgrid.sphere_radius``. When
1690
+ ``True`` (and the grid has a ``sphere_radius`` attribute) the result
1691
+ carries physical, per-meter units (e.g. ``1/s`` for a velocity
1692
+ field); when ``False`` the result is left on the unit sphere
1693
+ (per radian).
1553
1694
  **kwargs
1554
- Additional keyword arguments (reserved for future use).
1695
+ Additional keyword arguments. ``units`` may be passed to override the
1696
+ automatically inferred units.
1555
1697
 
1556
1698
  Returns
1557
1699
  -------
@@ -1565,7 +1707,9 @@ class UxDataArray(xr.DataArray):
1565
1707
  the divergence is calculated as div(V) = ∂u/∂x + ∂v/∂y.
1566
1708
 
1567
1709
  The implementation uses edge-centered gradients and face-centered divergence calculation
1568
- following the discrete divergence theorem.
1710
+ following the discrete divergence theorem. By default the underlying
1711
+ gradients are divided by ``uxgrid.sphere_radius``; pass
1712
+ ``scale_by_radius=False`` for per-radian (unit-sphere) output.
1569
1713
 
1570
1714
  Example
1571
1715
  -------
@@ -1595,8 +1739,8 @@ class UxDataArray(xr.DataArray):
1595
1739
  )
1596
1740
 
1597
1741
  # Compute gradients of both components
1598
- u_gradient = self.gradient()
1599
- v_gradient = other.gradient()
1742
+ u_gradient = self.gradient(scale_by_radius=scale_by_radius)
1743
+ v_gradient = other.gradient(scale_by_radius=scale_by_radius)
1600
1744
 
1601
1745
  # For divergence: div(V) = ∂u/∂x + ∂v/∂y
1602
1746
  # We use the zonal gradient (∂/∂lon) of u and meridional gradient (∂/∂lat) of v
@@ -1607,10 +1751,24 @@ class UxDataArray(xr.DataArray):
1607
1751
  u, v = xr.align(u, v)
1608
1752
  divergence = u + v
1609
1753
  divergence.name = "divergence"
1754
+
1755
+ # Infer units consistently with gradient()/curl(): a divergence is a
1756
+ # spatial derivative of the input field, so it carries an extra 1/length
1757
+ # factor (per meter when scaled by radius, otherwise per radian).
1758
+ if "units" in kwargs:
1759
+ div_units = kwargs["units"]
1760
+ else:
1761
+ u_units = self.attrs.get("units", "")
1762
+ has_sphere_radius = "sphere_radius" in self.uxgrid._ds.attrs
1763
+ if scale_by_radius and has_sphere_radius:
1764
+ div_units = f"({u_units})/m" if u_units else "1/m"
1765
+ else:
1766
+ div_units = f"({u_units})/rad" if u_units else "1/rad"
1767
+
1610
1768
  divergence.attrs.update(
1611
1769
  {
1612
1770
  "divergence": True,
1613
- "units": "1/s" if "units" not in kwargs else kwargs["units"],
1771
+ "units": div_units,
1614
1772
  }
1615
1773
  )
1616
1774
 
@@ -1619,6 +1777,65 @@ class UxDataArray(xr.DataArray):
1619
1777
 
1620
1778
  return divergence_da
1621
1779
 
1780
+ def scalardotgradient(self, v: "UxDataArray", q: "UxDataArray") -> "UxDataArray":
1781
+ """
1782
+ Compute the dot product between a vector field and the gradient of a scalar field.
1783
+
1784
+ Parameters
1785
+ ----------
1786
+ v : UxDataArray
1787
+ The meridional component of the vector field. ``self`` is treated as
1788
+ the zonal component.
1789
+ q : UxDataArray
1790
+ Scalar field whose gradient is dotted with the vector field.
1791
+
1792
+ Returns
1793
+ -------
1794
+ scalar_dot_gradient : UxDataArray
1795
+ Dot product ``self * dq/dx + v * dq/dy``.
1796
+ """
1797
+ if not isinstance(v, UxDataArray):
1798
+ raise TypeError("v must be a UxDataArray")
1799
+
1800
+ if not isinstance(q, UxDataArray):
1801
+ raise TypeError("q must be a UxDataArray")
1802
+
1803
+ if self.uxgrid != v.uxgrid or self.uxgrid != q.uxgrid:
1804
+ raise ValueError("All UxDataArrays must have the same grid")
1805
+
1806
+ if self.dims != v.dims or self.dims != q.dims:
1807
+ raise ValueError("All UxDataArrays must have the same dimensions")
1808
+
1809
+ if self.ndim > 1:
1810
+ raise ValueError(
1811
+ "Scalar dot gradient currently requires 1D face-centered data. "
1812
+ "Consider selecting a single slice before computing."
1813
+ )
1814
+
1815
+ if not (self._face_centered() and v._face_centered() and q._face_centered()):
1816
+ raise ValueError(
1817
+ "Computing the scalar dot gradient is only supported for face-centered data variables."
1818
+ )
1819
+
1820
+ # Validate coordinate alignment up-front so a misaligned input fails
1821
+ # before the (potentially expensive) gradient call.
1822
+ u_aligned, v_aligned, q_aligned = xr.align(self, v, q, join="exact", copy=False)
1823
+
1824
+ q_gradient = q_aligned.gradient()
1825
+ q_zonal = q_gradient["zonal_gradient"]
1826
+ q_meridional = q_gradient["meridional_gradient"]
1827
+
1828
+ scalar_dot_gradient = (u_aligned * q_zonal) + (v_aligned * q_meridional)
1829
+ scalar_dot_gradient.name = "scalar_dot_gradient"
1830
+ scalar_dot_gradient.attrs.update(
1831
+ {
1832
+ "long_name": "scalar dot gradient",
1833
+ "description": "Dot product u * (dq/dx) + v * (dq/dy).",
1834
+ }
1835
+ )
1836
+
1837
+ return UxDataArray(scalar_dot_gradient, uxgrid=self.uxgrid)
1838
+
1622
1839
  def difference(self, destination: str | None = "edge"):
1623
1840
  """Computes the absolute difference of a data variable.
1624
1841