uxarray 2026.9.0__tar.gz → 2026.10.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 (109) hide show
  1. {uxarray-2026.9.0 → uxarray-2026.10.0}/MANIFEST.in +1 -0
  2. {uxarray-2026.9.0 → uxarray-2026.10.0}/PKG-INFO +28 -25
  3. uxarray-2026.10.0/pyproject.toml +117 -0
  4. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/core/accessors.py +3 -1
  5. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/core/aggregation.py +16 -24
  6. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/core/api.py +33 -10
  7. uxarray-2026.10.0/uxarray/core/arithmetic.py +258 -0
  8. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/core/dataarray.py +233 -102
  9. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/core/dataset.py +83 -40
  10. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/core/gradient.py +6 -5
  11. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/core/utils.py +20 -5
  12. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/core/zonal.py +4 -1
  13. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/cross_sections/sample.py +3 -0
  14. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/errors.py +7 -0
  15. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/angles.py +1 -1
  16. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/arcs.py +23 -13
  17. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/area.py +6 -2
  18. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/bounds.py +6 -5
  19. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/connectivity.py +5 -5
  20. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/coordinates.py +7 -5
  21. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/dual.py +1 -1
  22. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/geometry.py +49 -23
  23. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/grid.py +108 -49
  24. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/integrate.py +8 -4
  25. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/neighbors.py +246 -196
  26. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/point_in_face.py +112 -49
  27. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/slice.py +6 -2
  28. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/utils.py +13 -7
  29. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_esmf.py +32 -6
  30. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_geopandas.py +2 -1
  31. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_healpix.py +3 -0
  32. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_scrip.py +323 -28
  33. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_ugrid.py +61 -2
  34. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/plot/accessor.py +10 -2
  35. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/plot/matplotlib.py +2 -0
  36. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/plot/utils.py +6 -0
  37. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/remap/bilinear.py +1 -1
  38. uxarray-2026.10.0/uxarray/utils/__init__.py +0 -0
  39. uxarray-2026.10.0/uxarray/utils/coords.py +212 -0
  40. uxarray-2026.10.0/uxarray/utils/imports.py +366 -0
  41. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/utils/numba_math.py +10 -0
  42. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray.egg-info/PKG-INFO +28 -25
  43. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray.egg-info/SOURCES.txt +3 -0
  44. uxarray-2026.10.0/uxarray.egg-info/requires.txt +40 -0
  45. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray.egg-info/scm_file_list.json +20 -1
  46. uxarray-2026.10.0/uxarray.egg-info/scm_version.json +8 -0
  47. uxarray-2026.9.0/pyproject.toml +0 -89
  48. uxarray-2026.9.0/uxarray/utils/coords.py +0 -86
  49. uxarray-2026.9.0/uxarray.egg-info/requires.txt +0 -34
  50. uxarray-2026.9.0/uxarray.egg-info/scm_version.json +0 -8
  51. {uxarray-2026.9.0 → uxarray-2026.10.0}/.codecov.yml +0 -0
  52. {uxarray-2026.9.0 → uxarray-2026.10.0}/.git_archival.txt +0 -0
  53. {uxarray-2026.9.0 → uxarray-2026.10.0}/.gitattributes +0 -0
  54. {uxarray-2026.9.0 → uxarray-2026.10.0}/CITATION.cff +0 -0
  55. {uxarray-2026.9.0 → uxarray-2026.10.0}/CODE_OF_CONDUCT.md +0 -0
  56. {uxarray-2026.9.0 → uxarray-2026.10.0}/CONTRIBUTING.md +0 -0
  57. {uxarray-2026.9.0 → uxarray-2026.10.0}/INSTALLATION.md +0 -0
  58. {uxarray-2026.9.0 → uxarray-2026.10.0}/LICENSE +0 -0
  59. {uxarray-2026.9.0 → uxarray-2026.10.0}/README.md +0 -0
  60. {uxarray-2026.9.0 → uxarray-2026.10.0}/build.sh +0 -0
  61. {uxarray-2026.9.0 → uxarray-2026.10.0}/setup.cfg +0 -0
  62. {uxarray-2026.9.0 → uxarray-2026.10.0}/setup.py +0 -0
  63. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/__init__.py +0 -0
  64. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/constants.py +0 -0
  65. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/conventions/__init__.py +0 -0
  66. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/conventions/descriptors.py +0 -0
  67. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/conventions/ugrid.py +0 -0
  68. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/core/__init__.py +0 -0
  69. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/cross_sections/__init__.py +0 -0
  70. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/cross_sections/dataarray_accessor.py +0 -0
  71. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/cross_sections/grid_accessor.py +0 -0
  72. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/formatting_html.py +0 -0
  73. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/__init__.py +0 -0
  74. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/intersections.py +0 -0
  75. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/grid/validation.py +0 -0
  76. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/__init__.py +0 -0
  77. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_delaunay.py +0 -0
  78. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_exodus.py +0 -0
  79. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_fesom2.py +0 -0
  80. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_geos.py +0 -0
  81. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_icon.py +0 -0
  82. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_mpas.py +0 -0
  83. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_structured.py +0 -0
  84. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_topology.py +0 -0
  85. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_vertices.py +0 -0
  86. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/_voronoi.py +0 -0
  87. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/io/utils.py +0 -0
  88. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/plot/__init__.py +0 -0
  89. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/plot/constants.py +0 -0
  90. /uxarray-2026.9.0/uxarray/utils/__init__.py → /uxarray-2026.10.0/uxarray/py.typed +0 -0
  91. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/remap/__init__.py +0 -0
  92. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/remap/accessor.py +0 -0
  93. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/remap/apply_weights.py +0 -0
  94. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/remap/inverse_distance_weighted.py +0 -0
  95. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/remap/nearest_neighbor.py +0 -0
  96. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/remap/spatial_coords_remap.py +0 -0
  97. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/remap/structured.py +0 -0
  98. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/remap/utils.py +0 -0
  99. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/remap/weights.py +0 -0
  100. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/remap/yac.py +0 -0
  101. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/subset/__init__.py +0 -0
  102. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/subset/dataarray_accessor.py +0 -0
  103. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/subset/grid_accessor.py +0 -0
  104. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/tutorial/__init__.py +0 -0
  105. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/tutorial/registry.py +0 -0
  106. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/utils/computing.py +0 -0
  107. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray/utils/warnings.py +0 -0
  108. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray.egg-info/dependency_links.txt +0 -0
  109. {uxarray-2026.9.0 → uxarray-2026.10.0}/uxarray.egg-info/top_level.txt +0 -0
@@ -2,6 +2,7 @@ include LICENSE
2
2
  include README.md
3
3
 
4
4
  recursive-include uxarray *.py
5
+ include uxarray/py.typed
5
6
  recursive-exclude * __pycache__
6
7
  recursive-exclude * *.py[co]
7
8
  exclude .gitignore
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: uxarray
3
- Version: 2026.9.0
3
+ Version: 2026.10.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
@@ -220,38 +220,41 @@ Classifier: Programming Language :: Python :: 3.14
220
220
  Requires-Python: >=3.11
221
221
  Description-Content-Type: text/markdown
222
222
  License-File: LICENSE
223
- Requires-Dist: antimeridian
224
- Requires-Dist: cartopy
225
- Requires-Dist: dask[dataframe]
226
- Requires-Dist: datashader
227
- Requires-Dist: geoviews
228
- Requires-Dist: holoviews
229
- Requires-Dist: matplotlib<3.11
230
- Requires-Dist: matplotlib-inline
231
- Requires-Dist: netcdf4
223
+ Requires-Dist: antimeridian>=0.3.0
224
+ Requires-Dist: dask[dataframe]>=2024.12.0
225
+ Requires-Dist: netcdf4>=1.7.0
232
226
  Requires-Dist: numba>=0.63
233
- Requires-Dist: numpy
234
- Requires-Dist: pandas
235
- Requires-Dist: pyarrow
236
- Requires-Dist: requests
237
- Requires-Dist: scikit-learn
227
+ Requires-Dist: numpy>=2.0
228
+ Requires-Dist: pandas>=2.2.2
229
+ Requires-Dist: scikit-learn>=1.4.2
238
230
  Requires-Dist: scipy
239
- Requires-Dist: shapely
240
- Requires-Dist: spatialpandas
241
- Requires-Dist: geopandas>=1.0
242
- Requires-Dist: xarray
243
- Requires-Dist: hvplot
244
- Requires-Dist: healpix
245
- Requires-Dist: polars
246
- Requires-Dist: pyproj
231
+ Requires-Dist: shapely>=2.0.6
232
+ Requires-Dist: xarray>=2024.10.0
233
+ Requires-Dist: polars>=0.20.4
234
+ Provides-Extra: all
235
+ Requires-Dist: uxarray[dev,geo,viz]; extra == "all"
247
236
  Provides-Extra: complete
248
- Requires-Dist: uxarray[dev]; extra == "complete"
237
+ Requires-Dist: uxarray[dev,geo,viz]; extra == "complete"
249
238
  Provides-Extra: dev
239
+ Requires-Dist: asv; extra == "dev"
240
+ Requires-Dist: pooch; extra == "dev"
250
241
  Requires-Dist: pre_commit; extra == "dev"
251
242
  Requires-Dist: pytest; extra == "dev"
252
243
  Requires-Dist: pytest-cov; extra == "dev"
253
244
  Requires-Dist: ruff; extra == "dev"
254
- Requires-Dist: asv; extra == "dev"
245
+ Provides-Extra: geo
246
+ Requires-Dist: cartopy>=0.23; extra == "geo"
247
+ Requires-Dist: geopandas>=1.0; extra == "geo"
248
+ Requires-Dist: healpix>=2024.2; extra == "geo"
249
+ Requires-Dist: pyproj; extra == "geo"
250
+ Requires-Dist: spatialpandas>=0.4.10; extra == "geo"
251
+ Provides-Extra: viz
252
+ Requires-Dist: cartopy<0.26,>=0.23; extra == "viz"
253
+ Requires-Dist: datashader; extra == "viz"
254
+ Requires-Dist: matplotlib<3.11,>=3.8.4; extra == "viz"
255
+ Requires-Dist: geoviews>=1.11.0; extra == "viz"
256
+ Requires-Dist: holoviews>=1.19.0; extra == "viz"
257
+ Requires-Dist: hvplot>=0.8.3; extra == "viz"
255
258
  Dynamic: license-file
256
259
 
257
260
 
@@ -0,0 +1,117 @@
1
+ [project]
2
+ classifiers=[
3
+ "Operating System :: OS Independent",
4
+ "Intended Audience :: Science/Research",
5
+ "Topic :: Scientific/Engineering",
6
+ "Programming Language :: Python",
7
+ "Programming Language :: Python :: 3",
8
+ "Programming Language :: Python :: 3.11",
9
+ "Programming Language :: Python :: 3.12",
10
+ "Programming Language :: Python :: 3.13",
11
+ "Programming Language :: Python :: 3.14",
12
+ ]
13
+
14
+ dynamic = ["version"]
15
+ keywords = ["unstructured grids", "xarray", "ugrid", "mpas", "exodus", "scrip", "fesom", "icon", "esmf"]
16
+ description = "Xarray extension for unstructured climate and global weather data analysis and visualization."
17
+ license = {file = "LICENSE"}
18
+ name = "uxarray"
19
+ readme = "README.md"
20
+ requires-python = ">=3.11"
21
+
22
+ # minimal dependencies start
23
+ # Lower bounds are added only for a known failure with an older release; the
24
+ # comment on each says what breaks. The "Minimum Dependencies" CI workflow
25
+ # tests the oldest versions these bounds allow and opens an issue when they
26
+ # fail, which is how new bounds get found.
27
+ dependencies = [
28
+ "antimeridian>=0.3.0", # fix_polygon(fix_winding=...) is new in 0.3.0.
29
+ "dask[dataframe]>=2024.12.0", # older dd.from_dask_array() fails on empty arrays (empty SCRIP grids).
30
+ "netcdf4>=1.7.0", # older releases are built against numpy 1 and fail to import under numpy 2.
31
+ "numba>=0.63", # 0.63 is the first release supporting Python 3.14 (3.10 <= py < 3.15). See #1561.
32
+ "numpy>=2.0", # np.asin() is new in 2.0.
33
+ "pandas>=2.2.2", # older releases without a numpy<2 cap fail to import under numpy 2.
34
+ "scikit-learn>=1.4.2", # older releases without a numpy<2 cap fail to import under numpy 2.
35
+ "scipy",
36
+ "shapely>=2.0.6", # 2.0 API (shapely.polygons()); older 2.0.x fail in create_collection under numpy 2.
37
+ "xarray>=2024.10.0", # older releases break resample() and dask-backed weighted_mean().
38
+ "polars>=0.20.4", # DataFrame.with_row_index() is new in 0.20.4.
39
+ ]
40
+ # minimal dependencies end
41
+
42
+ [project.optional-dependencies]
43
+ all = ["uxarray[dev,geo,viz]"]
44
+ complete = ["uxarray[dev,geo,viz]"] # alias to "all"
45
+ dev = [
46
+ 'asv',
47
+ 'pooch',
48
+ 'pre_commit',
49
+ 'pytest',
50
+ 'pytest-cov',
51
+ 'ruff',
52
+ ]
53
+ geo = [
54
+ "cartopy>=0.23", # in both geo and viz because it is needed by to_geodataframe() and by plotting routines.
55
+ # (0.23 is the first release that works with numpy 2.)
56
+ "geopandas>=1.0", # 1.0 is the first release requiring pyogrio, the file-IO backend read_file needs. See #1693.
57
+ "healpix>=2024.2", # older releases are built against numpy 1 and fail to import under numpy 2.
58
+ "pyproj",
59
+ "spatialpandas>=0.4.10", # PolygonArray.from_exterior_coords() is new in 0.4.10.
60
+ ]
61
+ viz = [
62
+ "cartopy>=0.23,<0.26", # cartopy 0.26.0 breaks some geoviews plots; see issue #1780.
63
+ # (cartopy pin intentionally here but not in "geo" extras because geoviews is only in "viz".)
64
+ "datashader",
65
+ "matplotlib>=3.8.4,<3.11", # 3.11.0 breaks some plots (see issue #1542); older releases without a numpy<2 cap fail under numpy 2.
66
+ "geoviews>=1.11.0", # older releases use np.NaN, removed in numpy 2. No upper pin; newer should support any cartopy; see holoviz/geoviews#884.
67
+ "holoviews>=1.19.0", # older releases import panel.depends.param_value_if_widget, which current panel removed.
68
+ "hvplot>=0.8.3", # older releases use param's .params(), removed in param 2.
69
+ ]
70
+
71
+ [project.urls]
72
+ Documentation = "https://uxarray.readthedocs.io/"
73
+ Source = "https://github.com/UXARRAY/uxarray"
74
+ Tracker = "https://github.com/UXARRAY/uxarray/issues"
75
+ # please keep [project.urls] immediately after [project.optional-dependencies],
76
+ # or, if rearranging sections, make sure to edit installation.rst accordingly.
77
+
78
+ [build-system]
79
+ build-backend = "setuptools.build_meta"
80
+ requires = [
81
+ "setuptools>=60",
82
+ "setuptools-scm>=8",
83
+ ]
84
+
85
+ [tool.setuptools]
86
+ packages = ["uxarray"]
87
+
88
+ [tool.setuptools.package-data]
89
+ # PEP 561 marker. setuptools >= 69.0 adds py.typed to a wheel on its own
90
+ # (_IMPLICIT_DATA_FILES in setuptools/command/build_py.py), but the build
91
+ # requirement below is setuptools >= 60, and on 68.2.2 the marker is dropped
92
+ # from the wheel unless it is declared here. Keep this until that floor is
93
+ # raised past 69.
94
+ uxarray = ["py.typed"]
95
+
96
+ [tool.setuptools_scm]
97
+ fallback_version = "9999"
98
+
99
+ [tool.ruff]
100
+ extend-exclude = ["test","benchmarks"]
101
+
102
+ [tool.ruff.lint]
103
+ extend-select = ["I"]
104
+ extend-ignore = ["E731"]
105
+
106
+ [tool.ruff.lint.isort]
107
+ known-first-party = ["uxarray"]
108
+
109
+ [tool.ruff.lint.per-file-ignores]
110
+ "docs/*" = ["E402", "F401"]
111
+ "uxarray/plot/*" = ["E402", "F401"]
112
+
113
+ [tool.ruff.format]
114
+ docstring-code-format = true
115
+
116
+ [tool.pytest.ini_options]
117
+ testpaths = ["test"] # (intentionally excludes test_optional_deps)
@@ -119,7 +119,9 @@ class BaseAccessor:
119
119
 
120
120
  def _process_result(self, result):
121
121
  """Process method results to preserve uxgrid. To be overridden by subclasses."""
122
- raise NotImplementedError("Subclasses must implement _process_result")
122
+ raise NotImplementedError(
123
+ f"_process_result, for BaseAccessor subclass {type(self)}"
124
+ )
123
125
 
124
126
  # Delegation for common dunder methods
125
127
  def __iter__(self):
@@ -25,8 +25,8 @@ def _uxda_grid_aggregate(uxda, destination, aggregation, **kwargs):
25
25
  UxDataArray."""
26
26
  if destination is None:
27
27
  raise ValueError(
28
- "Attempting to perform a topological aggregation, but no destination was provided."
29
- )
28
+ f"Missing destination (got destination=None) in topological_{aggregation})."
29
+ ) # e.g. aggregation will be something like "mean", "min", "any", ...
30
30
 
31
31
  if uxda._node_centered():
32
32
  # aggregation of a node-centered data variable
@@ -36,8 +36,8 @@ def _uxda_grid_aggregate(uxda, destination, aggregation, **kwargs):
36
36
  return _node_to_edge_aggregation(uxda, aggregation, kwargs)
37
37
  else:
38
38
  raise DataCenteringError(
39
- f"Invalid destination for a node-centered data variable. Expected"
40
- f"one of ['face', 'edge' but received {destination}"
39
+ f"Node-centered data requires destination='face' or 'edge'; "
40
+ f"got destination={destination!r}, during topological_{aggregation}."
41
41
  )
42
42
 
43
43
  elif uxda._edge_centered():
@@ -45,29 +45,17 @@ def _uxda_grid_aggregate(uxda, destination, aggregation, **kwargs):
45
45
  raise NotImplementedError(
46
46
  "Aggregation of edge-centered data variables is not yet supported."
47
47
  )
48
- # if destination == "node":
49
- # pass
50
- # elif destination == "face":
51
- # pass
52
- # else:
53
- # raise ValueError("TODO: )
54
48
 
55
49
  elif uxda._face_centered():
56
50
  # aggregation of a face-centered data variable
57
51
  raise NotImplementedError(
58
52
  "Aggregation of face-centered data variables is not yet supported."
59
53
  )
60
- # if destination == "node":
61
- # pass
62
- # elif destination == "edge":
63
- # pass
64
- # else:
65
- # raise ValueError("TODO: ")
66
54
 
67
55
  else:
68
56
  raise DataCenteringError(
69
- "Invalid data mapping. Data variable is expected to be mapped to either the "
70
- "nodes, faces, or edges of the source grid."
57
+ f"topological_{aggregation} expected data mapped to nodes, edges, or faces; "
58
+ f"got array with array.data_mapping={uxda.data_mapping!r} and array.dims={uxda.dims!r}."
71
59
  )
72
60
 
73
61
 
@@ -77,8 +65,8 @@ def _node_to_face_aggregation(uxda, aggregation, aggregation_func_kwargs):
77
65
 
78
66
  if not uxda._node_centered():
79
67
  raise DataCenteringError(
80
- f"Data Variable must be mapped to the corner nodes of each face, with dimension "
81
- f"{uxda.uxgrid.n_face}."
68
+ f"Expected node-centered data; got array with array.data_mapping={uxda.data_mapping!r} "
69
+ f"and array.dims={uxda.dims!r}, during node-to-face aggregation."
82
70
  )
83
71
 
84
72
  if isinstance(uxda.data, np.ndarray):
@@ -92,7 +80,9 @@ def _node_to_face_aggregation(uxda, aggregation, aggregation_func_kwargs):
92
80
  uxda, NUMPY_AGGREGATIONS[aggregation], aggregation_func_kwargs
93
81
  )
94
82
  else:
95
- raise TypeError
83
+ raise TypeError(
84
+ f"Expected numpy or dask array; got type(uxda.data)={type(uxda.data)}."
85
+ )
96
86
 
97
87
 
98
88
  def _node_to_face_kernel(
@@ -206,8 +196,8 @@ def _node_to_edge_aggregation(uxda, aggregation, aggregation_func_kwargs):
206
196
 
207
197
  if not uxda._node_centered():
208
198
  raise DataCenteringError(
209
- f"Data Variable must be mapped to the corner nodes of each face, with dimension "
210
- f"{uxda.uxgrid.n_face}."
199
+ f"Expected node-centered data; got array with array.data_mapping={uxda.data_mapping!r} "
200
+ f"and array.dims={uxda.dims!r}, during node-to-edge aggregation."
211
201
  )
212
202
 
213
203
  aggregation_func = NUMPY_AGGREGATIONS[aggregation]
@@ -223,7 +213,9 @@ def _node_to_edge_aggregation(uxda, aggregation, aggregation_func_kwargs):
223
213
  uxda, aggregation_func, aggregation_func_kwargs
224
214
  )
225
215
  else:
226
- raise TypeError
216
+ raise TypeError(
217
+ f"Expected numpy or dask array; got type(uxda.data)={type(uxda.data)}."
218
+ )
227
219
 
228
220
 
229
221
  def _node_to_edge_kernel(
@@ -108,8 +108,14 @@ def open_grid(
108
108
  if os.path.isfile(nod2d_path) and os.path.isfile(elem2d_path):
109
109
  grid = Grid.from_dataset(grid_filename_or_obj)
110
110
  else:
111
+ _missing = []
112
+ if not os.path.isfile(nod2d_path):
113
+ _missing.append("'nod2d.out'")
114
+ if not os.path.isfile(elem2d_path):
115
+ _missing.append("'elem2d.out'")
111
116
  raise FileNotFoundError(
112
- f"The directory '{grid_filename_or_obj}' must contain both 'nod2d.out' and 'elem2d.out'."
117
+ "open_grid(directory) expects FESOM2 ASCII dataset with 'nod2d.out' and 'elem2d.out' files, but "
118
+ f"got directory={os.path.abspath(grid_filename_or_obj)!r}, which is missing {' and '.join(_missing)}."
113
119
  )
114
120
 
115
121
  elif isinstance(grid_filename_or_obj, dict):
@@ -190,6 +196,12 @@ def open_multigrid(
190
196
  mask_ds = xr.open_dataset(mask_filename)
191
197
  mask_ds_opened = True
192
198
 
199
+ # human-readable str telling what was provided to open_multigrid(). Useful for error messages.
200
+ if isinstance(grid_filename_or_obj, (str, os.PathLike)):
201
+ _provided_input_str = f"file, {os.path.abspath(grid_filename_or_obj)!r}"
202
+ else:
203
+ _provided_input_str = type(grid_filename_or_obj).__name__
204
+
193
205
  try:
194
206
  active_value_map: Mapping[str, MaskValue] | None = (
195
207
  mask_active_value if isinstance(mask_active_value, Mapping) else None
@@ -238,8 +250,8 @@ def open_multigrid(
238
250
  if format_type == "single_scrip":
239
251
  if gridnames is not None and "grid" not in gridnames:
240
252
  raise ValueError(
241
- f"Requested grids {gridnames} not found. "
242
- "This file contains a single grid named 'grid'."
253
+ f"Requested grids (gridnames={gridnames}) not found in the provided {_provided_input_str}, "
254
+ "in open_multigrid(). Only 'grid' is available in single-grid SCRIP files."
243
255
  )
244
256
  grid_ds_ugrid, source_dims_dict = _read_scrip(grid_ds)
245
257
  return {
@@ -251,7 +263,9 @@ def open_multigrid(
251
263
  }
252
264
 
253
265
  if not grids_dict:
254
- raise GridInvalidError(f"No grids detected in file: {grid_filename_or_obj}")
266
+ raise GridInvalidError(
267
+ f"Failed to detect any grids in the provided {_provided_input_str}, in open_multigrid()."
268
+ )
255
269
 
256
270
  available_grids = list(grids_dict.keys())
257
271
 
@@ -267,7 +281,8 @@ def open_multigrid(
267
281
  for name in requested:
268
282
  if name not in grids_dict:
269
283
  raise ValueError(
270
- f"Grid '{name}' not found. Available grids: {available_grids}"
284
+ f"open_multigrid() grid '{name}' not found in the provided {_provided_input_str}, "
285
+ f"in open_multigrid(). Available grids: {available_grids}"
271
286
  )
272
287
  grids_to_load.append(name)
273
288
 
@@ -295,9 +310,14 @@ def open_multigrid(
295
310
  active_indices = np.flatnonzero(active_mask)
296
311
  grid = grid.isel(n_face=active_indices)
297
312
  else:
313
+ _provided_mask_str = (
314
+ f"file, {os.path.abspath(mask_filename)!r}"
315
+ if isinstance(mask_filename, (str, os.PathLike))
316
+ else type(mask_filename).__name__
317
+ )
298
318
  warn(
299
- f"Mask variable '{mask_var}' not found in mask file; "
300
- f"grid '{grid_name}' will be returned without masking."
319
+ f"Mask variable {mask_var!r} not found in the provided mask {_provided_mask_str}. "
320
+ f"Grid {grid_name!r} will be returned without masking."
301
321
  )
302
322
 
303
323
  loaded_grids[grid_name] = grid
@@ -428,7 +448,8 @@ def open_dataset(
428
448
  if isinstance(grid_filename_or_obj, (str, os.PathLike)):
429
449
  if os.path.isdir(grid_filename_or_obj):
430
450
  raise ValueError(
431
- "ux.open_dataset() with a single directory argument is not supported. "
451
+ "ux.open_dataset(arg0) with no other arguments and arg0 a single directory is not supported, "
452
+ f"but got arg0 indicating path to directory: {os.path.abspath(grid_filename_or_obj)!r}."
432
453
  "Supply a path to a grid file instead. Directory-based grids (e.g. a "
433
454
  "FESOM2 ASCII grid) are only recognized when a separate data file is "
434
455
  "also provided, i.e. ux.open_dataset(grid_directory, data_file)."
@@ -442,8 +463,10 @@ def open_dataset(
442
463
  elif isinstance(grid_filename_or_obj, xr.Dataset):
443
464
  ds = grid_filename_or_obj
444
465
  else:
445
- raise ValueError(
446
- "If filename_or_obj is omitted, grid_filename_or_obj must be a file path or xarray.Dataset."
466
+ raise TypeError(
467
+ "Expected grid_filename_or_obj to be a file path or xarray.Dataset when filename_or_obj "
468
+ f"is not provided, but got type(grid_filename_or_obj)={type(grid_filename_or_obj)}, "
469
+ "in ux.open_dataset(grid_filename_or_obj, filename_or_obj=None)."
447
470
  )
448
471
 
449
472
  uxgrid, _ = _get_grid(ds, chunks, chunk_grid, use_dual, grid_kwargs, **kwargs)
@@ -0,0 +1,258 @@
1
+ """
2
+ Purpose: ensures arithmetic for uxarray objects behaves as expected,
3
+ e.g. checks for compatible uxgrids (if more than one input has a uxgrid),
4
+ and ensure appropriate result types (e.g. xr.DataArray + UxDataArray --> UxDataArray).
5
+
6
+ Includes solutions for issues #1685, #1695, #1718:
7
+ (1685) Uxarray types dropped during ufuncs
8
+ Solved by appropriately defining __array_ufunc__.
9
+ (1695) Uxarray types dropped during math with xarrays, depending on order of operations
10
+ Solved by explicitly overriding dunder methods like __mul__,
11
+ so Python's default behavior for dunder methods and subclasses triggers
12
+ (which just requires `uxarray_obj.__mul__ is not xarray_obj.__mul__`)
13
+ and dispatches to uxarray's methods instead of xarray's methods.
14
+ (The overridden methods just call super()'s method, because they go to _binary_op,
15
+ which goes to uxarray's _binary_op(), which handles types appropriately.
16
+ _binary_op() already handles types appropriately because it uses
17
+ _replace() for DataArrays, which UxDataArray already overrides, and it uses
18
+ _calculate_binary_op() for Datasets, which UxDataset overrides.)
19
+ (1718) Math with multiple uxarray objects with different grids silently succeeds,
20
+ when data dims are otherwise compatible
21
+ Solved by comparing uxgrids during _binary_op() if both inputs have a uxgrid,
22
+ and by comparing uxgrids of all inputs during __array_ufunc__,
23
+ if multiple inputs to __array_ufunc__ have a "uxgrid" attr.
24
+ (The former fixes, e.g., objA + objB; the latter fixes, e.g., np.add(objA, objB))
25
+
26
+ Note:
27
+ Grids with n_face==1 are intentionally excluded from automatic comparisons,
28
+ because including them would breaks workflows operating on single faces, like:
29
+ arr.isel(n_face=0) < arr.isel(n_face=7)
30
+ The downside of not fixing this, though, is that the result's grid will match n_face==0,
31
+ silently dropping the n_face==7 information (as in issue #1718).
32
+ If isel() is ever updated to drop n_face dimension from arr.isel(n_face=0) instead,
33
+ should update the relevant code inside _binary_op and __array_ufunc__ below.
34
+ """
35
+
36
+ import operator
37
+
38
+ import xarray as xr
39
+
40
+ from uxarray.errors import GridsMismatchError
41
+
42
+
43
+ class UxSupportsArithmetic:
44
+ """Handles arithmetic overrides for uxarray objects as needed,
45
+ to ensure they behave as expected. E.g.:
46
+
47
+ - check for compatible uxgrids (if more than one input has a uxgrid),
48
+ - ensure appropriate result types (e.g. xr.DataArray + UxDataArray --> UxDataArray).
49
+
50
+
51
+ Expects to be placed in inheritance order *before* xarray classes,
52
+ e.g. UxDataArray(UxSupportsArithmetic, xr.DataArray),
53
+ not xr.DataArray(UxSupportsArithmetic, UxDataArray),
54
+ so that super() will resolve to methods from xarray.
55
+ """
56
+
57
+ # If strict type-checking gets turned on, it might complain about super() calls here.
58
+ # The fix would probably be to define a typing.Protocol parent class to inherit from.
59
+
60
+ __slots__ = ()
61
+
62
+ @property
63
+ def uxgrid(self):
64
+ """(UxSupportsArithmetic expects subclasses to implement uxgrid)"""
65
+ raise NotImplementedError(f"{type(self).__name__}.uxgrid")
66
+ # below, checks for "has a uxgrid" are written as `isinstance(obj, UxSupportsArithmetic)`,
67
+ # rather than checking for a uxgrid attribute, because the latter would be too permissive,
68
+ # e.g. hasattr(xr.DataArray(7, coords={"uxgrid": "anything"}), "uxgrid") is True.
69
+
70
+ def _binary_op(self, other, f, reflexive=False, **kw_super):
71
+ """returns f(self, other) (or f(other, self), if `reflexive`) for f a binary operation,
72
+ such as adding or multiplying. Like super()._binary_op, except that
73
+ if `other` has a uxgrid, first ensure it is compatible with self.uxgrid.
74
+ """
75
+ self._raise_if_grids_incompatible_during(other, f)
76
+ return super()._binary_op(other, f, reflexive=reflexive, **kw_super)
77
+
78
+ def _inplace_binary_op(self, other, f):
79
+ """returns f(self, other) for f a binary in-place operation,
80
+ such as A+=B. Like super()._inplace_binary_op, except that
81
+ if `other` has a uxgrid, first ensure it is compatible with self.uxgrid.
82
+ """
83
+ self._raise_if_grids_incompatible_during(other, f)
84
+ return super()._inplace_binary_op(other, f)
85
+
86
+ def _raise_if_grids_incompatible_during(self, other, f):
87
+ """raise GridsMismatchError if other has a uxgrid which is incompatible with self.
88
+ f is only used for error message, as f.__name__.
89
+
90
+ Grids are considered "compatible" here if they compare as equal,
91
+ OR if either grid has n_face==1 (to avoid breaking "scalar-like" workflows,
92
+ because isel(n_face=int) maybe should provide scalar, but currently doesn't).
93
+ """
94
+ if isinstance(other, UxSupportsArithmetic):
95
+ if (
96
+ (self.uxgrid.n_face > 1)
97
+ and (other.uxgrid.n_face > 1)
98
+ and (self.uxgrid != other.uxgrid)
99
+ ):
100
+ raise GridsMismatchError(
101
+ f"A.uxgrid != B.uxgrid during binary operation {f.__name__!r}, "
102
+ f"with type(A)={type(self).__name__}, type(B)={type(other).__name__}. "
103
+ f"(Got A.uxgrid.sizes={self.uxgrid.sizes}; B.uxgrid.sizes={other.uxgrid.sizes}.)"
104
+ )
105
+
106
+ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
107
+ """Like super().__array_ufunc__, except that if multiple inputs have a uxgrid,
108
+ ensure they are all compatible, and if any outputs are xr.DataArray or xr.Dataset,
109
+ convert to the appropriate uxarray type (UxDataArray or UxDataset).
110
+
111
+ Grids are considered "compatible" here if they compare as equal,
112
+ OR if they have n_face==1 (to avoid breaking "scalar-like" workflows,
113
+ because isel(n_face=int) maybe should provide scalar, but currently doesn't).
114
+ """
115
+ from uxarray.core.dataarray import UxDataArray
116
+ from uxarray.core.dataset import UxDataset
117
+
118
+ uxgrids = [
119
+ (i, obj.uxgrid)
120
+ for i, obj in enumerate(inputs)
121
+ if isinstance(obj, UxSupportsArithmetic)
122
+ ]
123
+
124
+ # when checking for equality, and when deciding which grid to attach to result,
125
+ # will pretend that any grids with n_face==1 are scalars (see above).
126
+ nonscalar_grids = [(i, grid) for i, grid in uxgrids if grid.n_face > 1]
127
+
128
+ if len(nonscalar_grids) == 0:
129
+ # no nonscalar grids; keep first grid.
130
+ # At least one input is guaranteed to have a uxgrid, otherwise numpy
131
+ # would not have delegated to this method during the ufunc call.
132
+ j_ref, grid_ref = uxgrids[0]
133
+ else:
134
+ # At least one nonscalar grid; output grid should match it.
135
+ j_ref, grid_ref = nonscalar_grids[0]
136
+ # check that all nonscalar uxgrids are compatible:
137
+ if len(nonscalar_grids) > 1:
138
+ for j, grid in nonscalar_grids[1:]:
139
+ if grid != grid_ref:
140
+ raise GridsMismatchError(
141
+ f"Multiple inputs to {ufunc.__name__!r} have incompatible uxgrids. "
142
+ f"Got inputs[{j}].uxgrid != inputs[{j_ref}].uxgrid, for inputs "
143
+ f"with types: type(inputs[{j}])={type(inputs[j])}, "
144
+ f"type(inputs[{j_ref}])={type(inputs[j_ref])}."
145
+ )
146
+ result = super().__array_ufunc__(ufunc, method, *inputs, **kwargs)
147
+
148
+ # convert any xr.DataArray / xr.Dataset outputs to appropriate uxarray type:
149
+ _was_tuple = isinstance(result, tuple)
150
+ result = list(result) if _was_tuple else [result]
151
+ for i, obj in enumerate(result):
152
+ if isinstance(obj, xr.DataArray):
153
+ result[i] = UxDataArray(obj, uxgrid=grid_ref)
154
+ elif isinstance(obj, xr.Dataset):
155
+ result[i] = UxDataset(obj, uxgrid=grid_ref)
156
+ result = tuple(result) if _was_tuple else result[0]
157
+ return result
158
+
159
+ # shadow dunder methods to ensure that, in case of ops with both uxarray and xarray,
160
+ # Python's default behavior for dunder methods and subclasses triggers.
161
+ # E.g., shadowing __add__ & __radd__ ensures that xarr + uxarr goes to uxarr.__radd__(xarr),
162
+ # instead of xarr.__add__(uxarr), so the result can actually be a UxAarray.
163
+ # (Just calling super()'s method is sufficient because it goes to self._binary_op,
164
+ # which goes to uxarray's _binary_op because self will be the uxarray object.)
165
+
166
+ def __add__(self, other):
167
+ return super().__add__(other)
168
+
169
+ def __radd__(self, other):
170
+ return super().__radd__(other)
171
+
172
+ def __sub__(self, other):
173
+ return super().__sub__(other)
174
+
175
+ def __rsub__(self, other):
176
+ return super().__rsub__(other)
177
+
178
+ def __mul__(self, other):
179
+ return super().__mul__(other)
180
+
181
+ def __rmul__(self, other):
182
+ return super().__rmul__(other)
183
+
184
+ def __pow__(self, other):
185
+ return super().__pow__(other)
186
+
187
+ def __rpow__(self, other):
188
+ return super().__rpow__(other)
189
+
190
+ def __truediv__(self, other):
191
+ return super().__truediv__(other)
192
+
193
+ def __rtruediv__(self, other):
194
+ return super().__rtruediv__(other)
195
+
196
+ def __floordiv__(self, other):
197
+ return super().__floordiv__(other)
198
+
199
+ def __rfloordiv__(self, other):
200
+ return super().__rfloordiv__(other)
201
+
202
+ def __mod__(self, other):
203
+ return super().__mod__(other)
204
+
205
+ def __rmod__(self, other):
206
+ return super().__rmod__(other)
207
+
208
+ def __and__(self, other):
209
+ return super().__and__(other)
210
+
211
+ def __rand__(self, other):
212
+ return super().__rand__(other)
213
+
214
+ def __or__(self, other):
215
+ return super().__or__(other)
216
+
217
+ def __ror__(self, other):
218
+ return super().__ror__(other)
219
+
220
+ def __xor__(self, other):
221
+ return super().__xor__(other)
222
+
223
+ def __rxor__(self, other):
224
+ return super().__rxor__(other)
225
+
226
+ def __lshift__(self, other):
227
+ return super().__lshift__(other)
228
+
229
+ def __rlshift__(self, other):
230
+ # (this isn't in super() for some reason, so use _binary_op directly)
231
+ return self._binary_op(other, operator.lshift, reflexive=True)
232
+
233
+ def __rshift__(self, other):
234
+ return super().__rshift__(other)
235
+
236
+ def __rrshift__(self, other):
237
+ # (this isn't in super() for some reason, so use _binary_op directly)
238
+ return self._binary_op(other, operator.rshift, reflexive=True)
239
+
240
+ # comparisons
241
+
242
+ def __lt__(self, other):
243
+ return super().__lt__(other)
244
+
245
+ def __le__(self, other):
246
+ return super().__le__(other)
247
+
248
+ def __gt__(self, other):
249
+ return super().__gt__(other)
250
+
251
+ def __ge__(self, other):
252
+ return super().__ge__(other)
253
+
254
+ def __eq__(self, other):
255
+ return super().__eq__(other)
256
+
257
+ def __ne__(self, other):
258
+ return super().__ne__(other)