uxarray 2026.6.0__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 (98) hide show
  1. {uxarray-2026.6.0 → uxarray-2026.7.0}/PKG-INFO +4 -4
  2. {uxarray-2026.6.0 → uxarray-2026.7.0}/pyproject.toml +4 -3
  3. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/core/api.py +70 -15
  4. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/core/dataarray.py +91 -13
  5. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/core/dataset.py +33 -27
  6. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/core/gradient.py +52 -53
  7. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/core/utils.py +5 -1
  8. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/grid.py +26 -4
  9. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_scrip.py +62 -14
  10. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/utils.py +1 -1
  11. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/plot/accessor.py +36 -3
  12. uxarray-2026.7.0/uxarray/plot/utils.py +72 -0
  13. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/remap/accessor.py +7 -3
  14. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/remap/yac.py +28 -3
  15. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray.egg-info/PKG-INFO +4 -4
  16. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray.egg-info/SOURCES.txt +2 -0
  17. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray.egg-info/requires.txt +2 -3
  18. uxarray-2026.7.0/uxarray.egg-info/scm_file_list.json +385 -0
  19. uxarray-2026.7.0/uxarray.egg-info/scm_version.json +8 -0
  20. uxarray-2026.6.0/uxarray/plot/utils.py +0 -40
  21. {uxarray-2026.6.0 → uxarray-2026.7.0}/.codecov.yml +0 -0
  22. {uxarray-2026.6.0 → uxarray-2026.7.0}/.git_archival.txt +0 -0
  23. {uxarray-2026.6.0 → uxarray-2026.7.0}/.gitattributes +0 -0
  24. {uxarray-2026.6.0 → uxarray-2026.7.0}/CITATION.cff +0 -0
  25. {uxarray-2026.6.0 → uxarray-2026.7.0}/CODE_OF_CONDUCT.md +0 -0
  26. {uxarray-2026.6.0 → uxarray-2026.7.0}/CONTRIBUTING.md +0 -0
  27. {uxarray-2026.6.0 → uxarray-2026.7.0}/INSTALLATION.md +0 -0
  28. {uxarray-2026.6.0 → uxarray-2026.7.0}/LICENSE +0 -0
  29. {uxarray-2026.6.0 → uxarray-2026.7.0}/MANIFEST.in +0 -0
  30. {uxarray-2026.6.0 → uxarray-2026.7.0}/README.md +0 -0
  31. {uxarray-2026.6.0 → uxarray-2026.7.0}/build.sh +0 -0
  32. {uxarray-2026.6.0 → uxarray-2026.7.0}/setup.cfg +0 -0
  33. {uxarray-2026.6.0 → uxarray-2026.7.0}/setup.py +0 -0
  34. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/__init__.py +0 -0
  35. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/constants.py +0 -0
  36. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/conventions/__init__.py +0 -0
  37. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/conventions/descriptors.py +0 -0
  38. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/conventions/ugrid.py +0 -0
  39. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/core/__init__.py +0 -0
  40. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/core/accessors.py +0 -0
  41. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/core/aggregation.py +0 -0
  42. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/core/zonal.py +0 -0
  43. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/cross_sections/__init__.py +0 -0
  44. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/cross_sections/dataarray_accessor.py +0 -0
  45. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/cross_sections/grid_accessor.py +0 -0
  46. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/cross_sections/sample.py +0 -0
  47. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/formatting_html.py +0 -0
  48. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/__init__.py +0 -0
  49. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/arcs.py +0 -0
  50. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/area.py +0 -0
  51. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/bounds.py +0 -0
  52. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/connectivity.py +0 -0
  53. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/coordinates.py +0 -0
  54. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/dual.py +0 -0
  55. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/geometry.py +0 -0
  56. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/integrate.py +0 -0
  57. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/intersections.py +0 -0
  58. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/neighbors.py +0 -0
  59. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/point_in_face.py +0 -0
  60. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/slice.py +0 -0
  61. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/utils.py +0 -0
  62. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/grid/validation.py +0 -0
  63. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/__init__.py +0 -0
  64. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_delaunay.py +0 -0
  65. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_esmf.py +0 -0
  66. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_exodus.py +0 -0
  67. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_fesom2.py +0 -0
  68. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_geopandas.py +0 -0
  69. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_geos.py +0 -0
  70. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_healpix.py +0 -0
  71. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_icon.py +0 -0
  72. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_mpas.py +0 -0
  73. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_structured.py +0 -0
  74. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_topology.py +0 -0
  75. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_ugrid.py +0 -0
  76. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_vertices.py +0 -0
  77. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/io/_voronoi.py +0 -0
  78. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/plot/__init__.py +0 -0
  79. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/plot/constants.py +0 -0
  80. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/plot/matplotlib.py +0 -0
  81. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/remap/__init__.py +0 -0
  82. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/remap/apply_weights.py +0 -0
  83. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/remap/bilinear.py +0 -0
  84. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/remap/inverse_distance_weighted.py +0 -0
  85. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/remap/nearest_neighbor.py +0 -0
  86. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/remap/spatial_coords_remap.py +0 -0
  87. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/remap/structured.py +0 -0
  88. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/remap/utils.py +0 -0
  89. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/remap/weights.py +0 -0
  90. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/subset/__init__.py +0 -0
  91. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/subset/dataarray_accessor.py +0 -0
  92. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/subset/grid_accessor.py +0 -0
  93. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/tutorial/__init__.py +0 -0
  94. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/tutorial/registry.py +0 -0
  95. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/utils/__init__.py +0 -0
  96. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray/utils/computing.py +0 -0
  97. {uxarray-2026.6.0 → uxarray-2026.7.0}/uxarray.egg-info/dependency_links.txt +0 -0
  98. {uxarray-2026.6.0 → 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.6.0
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/"
@@ -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(
@@ -164,6 +164,40 @@ class UxDataArray(xr.DataArray):
164
164
  else:
165
165
  return None
166
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
+
167
201
  def to_geodataframe(
168
202
  self,
169
203
  periodic_elements: str | None = "exclude",
@@ -1483,20 +1517,33 @@ class UxDataArray(xr.DataArray):
1483
1517
  Parameters
1484
1518
  ----------
1485
1519
  scale_by_radius : bool, default=True
1486
- Divide unit-sphere derivatives by ``uxgrid.sphere_radius``.
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.
1487
1526
 
1488
1527
  Returns
1489
1528
  -------
1490
1529
  gradient: UxDataset
1491
- 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``.
1492
1534
 
1493
1535
  Notes
1494
1536
  -----
1495
- 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
1496
1538
  is formed connecting centroids of the neighboring cells. The surface integral is
1497
1539
  approximated using the trapezoidal rule. The sum of the contributions is then
1498
1540
  normalized by the cell volume.
1499
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
+
1500
1547
  Example
1501
1548
  -------
1502
1549
  >>> uxds["var"].gradient()
@@ -1539,7 +1586,10 @@ class UxDataArray(xr.DataArray):
1539
1586
  represent the meridional (v) component, while self represents the
1540
1587
  zonal (u) component.
1541
1588
  scale_by_radius : bool, default=True
1542
- Divide unit-sphere derivatives by ``uxgrid.sphere_radius``.
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).
1543
1593
  **kwargs : dict
1544
1594
  Additional keyword arguments (currently unused, reserved for future extensions).
1545
1595
 
@@ -1547,7 +1597,9 @@ class UxDataArray(xr.DataArray):
1547
1597
  -------
1548
1598
  curl : UxDataArray
1549
1599
  The curl of the vector field (u, v), computed as:
1550
- 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``.
1551
1603
 
1552
1604
  Notes
1553
1605
  -----
@@ -1603,7 +1655,7 @@ class UxDataArray(xr.DataArray):
1603
1655
  u_units = self.attrs.get("units", "")
1604
1656
  has_sphere_radius = "sphere_radius" in self.uxgrid._ds.attrs
1605
1657
  if scale_by_radius and has_sphere_radius:
1606
- curl_units = f"({u_units})/m" if u_units else "1/s"
1658
+ curl_units = f"({u_units})/m" if u_units else "1/m"
1607
1659
  else:
1608
1660
  curl_units = f"({u_units})/rad" if u_units else "1/rad"
1609
1661
 
@@ -1622,16 +1674,26 @@ class UxDataArray(xr.DataArray):
1622
1674
 
1623
1675
  return curl_da
1624
1676
 
1625
- def divergence(self, other: "UxDataArray", **kwargs) -> "UxDataArray":
1677
+ def divergence(
1678
+ self, other: "UxDataArray", scale_by_radius: bool = True, **kwargs
1679
+ ) -> "UxDataArray":
1626
1680
  """
1627
1681
  Computes the divergence of the vector field defined by this UxDataArray and other.
1628
1682
 
1629
1683
  Parameters
1630
1684
  ----------
1631
1685
  other : UxDataArray
1632
- 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).
1633
1694
  **kwargs
1634
- Additional keyword arguments (reserved for future use).
1695
+ Additional keyword arguments. ``units`` may be passed to override the
1696
+ automatically inferred units.
1635
1697
 
1636
1698
  Returns
1637
1699
  -------
@@ -1645,7 +1707,9 @@ class UxDataArray(xr.DataArray):
1645
1707
  the divergence is calculated as div(V) = ∂u/∂x + ∂v/∂y.
1646
1708
 
1647
1709
  The implementation uses edge-centered gradients and face-centered divergence calculation
1648
- 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.
1649
1713
 
1650
1714
  Example
1651
1715
  -------
@@ -1675,8 +1739,8 @@ class UxDataArray(xr.DataArray):
1675
1739
  )
1676
1740
 
1677
1741
  # Compute gradients of both components
1678
- u_gradient = self.gradient()
1679
- 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)
1680
1744
 
1681
1745
  # For divergence: div(V) = ∂u/∂x + ∂v/∂y
1682
1746
  # We use the zonal gradient (∂/∂lon) of u and meridional gradient (∂/∂lat) of v
@@ -1687,10 +1751,24 @@ class UxDataArray(xr.DataArray):
1687
1751
  u, v = xr.align(u, v)
1688
1752
  divergence = u + v
1689
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
+
1690
1768
  divergence.attrs.update(
1691
1769
  {
1692
1770
  "divergence": True,
1693
- "units": "1/s" if "units" not in kwargs else kwargs["units"],
1771
+ "units": div_units,
1694
1772
  }
1695
1773
  )
1696
1774
 
@@ -570,7 +570,13 @@ class UxDataset(xr.Dataset):
570
570
  buf.write("\n".join(lines))
571
571
 
572
572
  def integrate(self, quadrature_rule="triangular", order=4):
573
- """Integrates over all the faces of the givfen mesh.
573
+ """Integrates over all the faces of the given mesh, for every data
574
+ variable in the dataset.
575
+
576
+ Each data variable is integrated independently using
577
+ :meth:`UxDataArray.integrate`, and the results are collected into a new
578
+ :class:`UxDataset`. Variables whose final dimension does not map to the
579
+ grid (i.e. that are not face/node/edge centered) are skipped.
574
580
 
575
581
  Parameters
576
582
  ----------
@@ -581,45 +587,45 @@ class UxDataset(xr.Dataset):
581
587
 
582
588
  Returns
583
589
  -------
584
- Calculated integral : float
590
+ uxds : UxDataset
591
+ Dataset containing the integrated value of each (grid-mapped) data
592
+ variable.
585
593
 
586
594
  Examples
587
595
  --------
588
- Open a UXarray dataset
596
+ Open a UXarray dataset and integrate every data variable
589
597
 
590
598
  >>> import uxarray as ux
591
599
  >>> uxds = ux.open_dataset("grid.ug", "centroid_pressure_data_ug")
592
-
593
- # Compute the integral
594
600
  >>> integral = uxds.integrate()
595
- """
596
601
 
597
- # TODO: Deprecation Warning
598
- warn(
599
- "This method currently only works when there is a single DataArray in this Dataset. For integration of a "
600
- "single data variable, use the UxDataArray.integrate() method instead. This function will be deprecated and "
601
- "replaced with one that can perform a Dataset-wide integration in a future release.",
602
- DeprecationWarning,
603
- )
602
+ Access the integral of a single variable
604
603
 
605
- integral = 0.0
604
+ >>> integral["psi"]
605
+ """
606
+ integrated_vars = {}
607
+ skipped = []
606
608
 
607
- face_areas = self.uxgrid.face_areas.values
609
+ for name, uxda in self.items():
610
+ try:
611
+ integrated_vars[name] = uxda.integrate(
612
+ quadrature_rule=quadrature_rule, order=order
613
+ )
614
+ except ValueError:
615
+ # Variable is not mapped to the grid (or its location is not
616
+ # yet supported for integration); skip it rather than failing
617
+ # the whole dataset-wide integration.
618
+ skipped.append(name)
608
619
 
609
- # TODO: Should we fix this requirement? Shouldn't it be applicable to
610
- # TODO: all variables of dataset or a dataarray instead?
611
- var_key = list(self.keys())
612
- if len(var_key) > 1:
613
- # warning: print message
614
- print(
615
- "WARNING: The dataset has more than one variable, using the first variable for integration"
620
+ if skipped:
621
+ warn(
622
+ "The following variables were skipped during integration because "
623
+ "their final dimension does not map to the grid (n_face, n_node, "
624
+ f"or n_edge) or is not yet supported: {skipped}.",
625
+ UserWarning,
616
626
  )
617
627
 
618
- var_key = var_key[0]
619
- face_vals = self[var_key].to_numpy()
620
- integral = np.dot(face_areas, face_vals)
621
-
622
- return integral
628
+ return UxDataset(integrated_vars, uxgrid=self.uxgrid)
623
629
 
624
630
  def to_array(
625
631
  self,
@@ -295,59 +295,58 @@ def _compute_gradients_on_faces(
295
295
  # Parallel across faces
296
296
  for face_idx in prange(n_face):
297
297
  gradient = np.zeros(3)
298
-
299
- if not _check_face_on_boundary(
300
- face_idx,
301
- face_node_connectivity,
302
- node_edge_connectivity,
303
- edge_face_connectivity,
304
- ): # check face is not on boundary
305
- for node_idx in face_node_connectivity[
306
- face_idx
307
- ]: # take each node on that face
308
- if node_idx != INT_FILL_VALUE:
309
- for edge_idx in node_edge_connectivity[
310
- node_idx
311
- ]: # grab each edge connected to that node
312
- if edge_idx != INT_FILL_VALUE:
313
- if (
314
- face_idx not in edge_face_connectivity[edge_idx]
315
- ): # check if edge connected to original face
316
- face1_idx = edge_face_connectivity[edge_idx][0]
317
- face2_idx = edge_face_connectivity[edge_idx][1]
318
-
319
- face1_coords = face_coords[face1_idx]
320
- face2_coords = face_coords[face2_idx]
321
-
322
- # compute normal pointing outwards from face
323
- cross = np.cross(face1_coords, face2_coords)
324
- norm = np.linalg.norm(cross)
325
- if (
326
- np.dot(cross, face1_coords - face_coords[face_idx])
327
- > 0
328
- ):
329
- normal = cross / norm
330
- else:
331
- normal = -cross / norm
332
-
333
- # compute arc length between the two faces
334
- arc_length = _compute_arc_length(
335
- face_lat[face1_idx],
336
- face_lat[face2_idx],
337
- face_lon[face1_idx],
338
- face_lon[face2_idx],
339
- )
340
-
341
- # compute trapezoidal rule
342
- trapz = (data[face1_idx] + data[face2_idx]) / 2
343
-
344
- # add to the gradient (subtract correction term)
345
- gradient = (
346
- gradient
347
- + (trapz - data[face_idx]) * arc_length * normal
348
- )
349
-
350
- else:
298
+ has_contribution = False
299
+
300
+ for node_idx in face_node_connectivity[face_idx]: # take each node on that face
301
+ if node_idx != INT_FILL_VALUE:
302
+ for edge_idx in node_edge_connectivity[
303
+ node_idx
304
+ ]: # grab each edge connected to that node
305
+ if edge_idx != INT_FILL_VALUE:
306
+ # Skip edges that lack a second face neighbor
307
+ # instead of NaN-ing the entire face. Fixes
308
+ # grids where edge_face_connectivity has
309
+ # spurious INT_FILL_VALUE entries (e.g. SCRIP-
310
+ # derived SE grids like ne120np4). See #1452.
311
+ if INT_FILL_VALUE in edge_face_connectivity[edge_idx]:
312
+ continue
313
+
314
+ if (
315
+ face_idx not in edge_face_connectivity[edge_idx]
316
+ ): # check if edge connected to original face
317
+ face1_idx = edge_face_connectivity[edge_idx][0]
318
+ face2_idx = edge_face_connectivity[edge_idx][1]
319
+
320
+ face1_coords = face_coords[face1_idx]
321
+ face2_coords = face_coords[face2_idx]
322
+
323
+ # compute normal pointing outwards from face
324
+ cross = np.cross(face1_coords, face2_coords)
325
+ norm = np.linalg.norm(cross)
326
+ if np.dot(cross, face1_coords - face_coords[face_idx]) > 0:
327
+ normal = cross / norm
328
+ else:
329
+ normal = -cross / norm
330
+
331
+ # compute arc length between the two faces
332
+ arc_length = _compute_arc_length(
333
+ face_lat[face1_idx],
334
+ face_lat[face2_idx],
335
+ face_lon[face1_idx],
336
+ face_lon[face2_idx],
337
+ )
338
+
339
+ # compute trapezoidal rule
340
+ trapz = (data[face1_idx] + data[face2_idx]) / 2
341
+
342
+ # add to the gradient (subtract correction term)
343
+ gradient = (
344
+ gradient
345
+ + (trapz - data[face_idx]) * arc_length * normal
346
+ )
347
+ has_contribution = True
348
+
349
+ if not has_contribution:
351
350
  gradient = np.full(3, np.nan)
352
351
 
353
352
  node_neighbors = face_node_connectivity[face_idx]
@@ -104,7 +104,11 @@ def match_chunks_to_ugrid(grid_filename_or_obj, chunks):
104
104
  # No need to rename
105
105
  return chunks
106
106
 
107
- ds = _open_dataset_with_fallback(grid_filename_or_obj, chunks=chunks)
107
+ if isinstance(grid_filename_or_obj, xr.Dataset):
108
+ ds = grid_filename_or_obj
109
+ else:
110
+ ds = _open_dataset_with_fallback(grid_filename_or_obj, chunks=chunks)
111
+
108
112
  grid_spec, _, _ = _parse_grid_type(ds)
109
113
 
110
114
  source_dims_dict = _get_source_dims_dict(ds, grid_spec)
@@ -302,7 +302,7 @@ class Grid:
302
302
  # custom source grid spec is provided
303
303
  source_grid_spec = kwargs.get("source_grid_spec", None)
304
304
  grid_ds = dataset
305
- source_dims_dict = {}
305
+ source_dims_dict = kwargs.get("source_dims_dict") or {}
306
306
  else:
307
307
  try:
308
308
  if os.path.isdir(dataset):
@@ -528,9 +528,18 @@ class Grid:
528
528
  if ds is not None:
529
529
  return cls.from_dataset(ds)
530
530
  if lon is not None and lat is not None:
531
- grid_ds = _read_structured_grid(lon, lat, tol)
531
+ # Capture the source dimension names so data variables mapped over
532
+ # (lon, lat) can later be flattened onto ``n_face`` by
533
+ # ``UxDataset.from_xarray`` (see GH #1410). ``xr.DataArray`` inputs
534
+ # carry their dim names; plain arrays fall back to "lon"/"lat".
535
+ lon_name = lon.dims[0] if isinstance(lon, xr.DataArray) else "lon"
536
+ lat_name = lat.dims[0] if isinstance(lat, xr.DataArray) else "lat"
537
+
538
+ grid_ds = _read_structured_grid(np.asarray(lon), np.asarray(lat), tol)
532
539
  return cls.from_dataset(
533
- grid_ds, source_dims_dict=None, source_grid_spec="structured"
540
+ grid_ds,
541
+ source_dims_dict={"n_face": (lon_name, lat_name)},
542
+ source_grid_spec="Structured",
534
543
  )
535
544
  else:
536
545
  raise ValueError(
@@ -1951,8 +1960,21 @@ class Grid:
1951
1960
  -------
1952
1961
  Sum of area of all the faces in the mesh : float
1953
1962
  """
1963
+ # Default parameters match the cached ``face_areas`` property, which also
1964
+ # preserves the equal-area values used for HEALPix grids; reuse it to avoid
1965
+ # recomputation. Any non-default quadrature settings require a fresh
1966
+ # geometric computation so the requested rule/order actually take effect.
1967
+ if (
1968
+ quadrature_rule == "triangular"
1969
+ and order == 4
1970
+ and not latitude_adjusted_area
1971
+ ):
1972
+ return np.sum(self.face_areas.values)
1954
1973
 
1955
- return np.sum(self.face_areas.values)
1974
+ face_areas, _ = self._compute_face_areas(
1975
+ quadrature_rule, order, latitude_adjusted_area
1976
+ )
1977
+ return np.sum(face_areas)
1956
1978
 
1957
1979
  def compute_face_areas(
1958
1980
  self,