From 0e659c01b70bf8b2e938e4dee879b27abfcc104e Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:44:51 -0400 Subject: [PATCH 1/4] add custom errors, e.g. DataCenteringError Adds DataCenteringError, DimensionError, GridInvalidError, and GridsMismatchError. Replaces existing errors with one of these where appropriate AND where it will not break backwards compatibility. Kept notes (will include in PR writeup) about places where it would break compatibility, and did not change those places yet. --- uxarray/core/aggregation.py | 9 +-- uxarray/core/api.py | 3 +- uxarray/core/dataarray.py | 69 ++++++++++---------- uxarray/core/dataset.py | 3 +- uxarray/core/gradient.py | 5 +- uxarray/core/utils.py | 3 +- uxarray/core/zonal.py | 5 +- uxarray/cross_sections/dataarray_accessor.py | 3 +- uxarray/errors.py | 28 ++++++++ uxarray/grid/coordinates.py | 5 +- uxarray/grid/grid.py | 13 ++-- uxarray/grid/intersections.py | 3 +- uxarray/io/_delaunay.py | 5 +- uxarray/io/_esmf.py | 5 +- uxarray/io/_scrip.py | 9 +-- uxarray/plot/accessor.py | 5 +- uxarray/plot/matplotlib.py | 10 +-- uxarray/remap/apply_weights.py | 13 ++-- uxarray/remap/bilinear.py | 4 +- uxarray/remap/spatial_coords_remap.py | 5 +- uxarray/remap/structured.py | 10 +-- uxarray/remap/utils.py | 9 +-- uxarray/remap/weights.py | 7 +- uxarray/remap/yac.py | 17 ++--- uxarray/subset/dataarray_accessor.py | 14 ++-- uxarray/subset/grid_accessor.py | 6 +- uxarray/utils/computing.py | 6 +- 27 files changed, 169 insertions(+), 105 deletions(-) create mode 100644 uxarray/errors.py diff --git a/uxarray/core/aggregation.py b/uxarray/core/aggregation.py index c9d3dd6a7..b78bd6dbe 100644 --- a/uxarray/core/aggregation.py +++ b/uxarray/core/aggregation.py @@ -1,6 +1,7 @@ import numpy as np import uxarray.core.dataarray +from uxarray.errors import DataCenteringError from uxarray.grid.connectivity import get_face_node_partitions NUMPY_AGGREGATIONS = { @@ -32,7 +33,7 @@ def _uxda_grid_aggregate(uxda, destination, aggregation, **kwargs): elif destination == "edge": return _node_to_edge_aggregation(uxda, aggregation, kwargs) else: - raise ValueError( + raise DataCenteringError( f"Invalid destination for a node-centered data variable. Expected" f"one of ['face', 'edge' but received {destination}" ) @@ -62,7 +63,7 @@ def _uxda_grid_aggregate(uxda, destination, aggregation, **kwargs): # raise ValueError("TODO: ") else: - raise ValueError( + raise DataCenteringError( "Invalid data mapping. Data variable is expected to be mapped to either the " "nodes, faces, or edges of the source grid." ) @@ -73,7 +74,7 @@ def _node_to_face_aggregation(uxda, aggregation, aggregation_func_kwargs): import dask.array as da if not uxda._node_centered(): - raise ValueError( + raise DataCenteringError( f"Data Variable must be mapped to the corner nodes of each face, with dimension " f"{uxda.uxgrid.n_face}." ) @@ -141,7 +142,7 @@ def _node_to_edge_aggregation(uxda, aggregation, aggregation_func_kwargs): import dask.array as da if not uxda._node_centered(): - raise ValueError( + raise DataCenteringError( f"Data Variable must be mapped to the corner nodes of each face, with dimension " f"{uxda.uxgrid.n_face}." ) diff --git a/uxarray/core/api.py b/uxarray/core/api.py index fbc009803..fc1ec89c3 100644 --- a/uxarray/core/api.py +++ b/uxarray/core/api.py @@ -14,6 +14,7 @@ _open_dataset_with_fallback, match_chunks_to_ugrid, ) +from uxarray.errors import GridInvalidError from uxarray.grid import Grid from uxarray.io._scrip import ( _detect_multigrid, @@ -248,7 +249,7 @@ def _active_mask_values_for_grid(grid_name: str) -> np.ndarray: } if not grids_dict: - raise ValueError(f"No grids detected in file: {grid_filename_or_obj}") + raise GridInvalidError(f"No grids detected in file: {grid_filename_or_obj}") available_grids = list(grids_dict.keys()) diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index a2b55a796..81cee69b5 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -27,6 +27,7 @@ _compute_zonal_anomaly, ) from uxarray.cross_sections import UxDataArrayCrossSectionAccessor +from uxarray.errors import DataCenteringError, DimensionError, GridsMismatchError from uxarray.formatting_html import array_repr from uxarray.grid import Grid from uxarray.grid.dual import construct_dual @@ -247,7 +248,7 @@ def to_geodataframe( if self.values.ndim > 1: # data is multidimensional, must be a 1D slice - raise ValueError( + raise DimensionError( f"Data Variable must be 1-dimensional, with shape {self.uxgrid.n_face} " f"for face-centered data." ) @@ -290,19 +291,19 @@ def to_geodataframe( gdf[var_name] = _data elif self.values.size == self.uxgrid.n_node: - raise ValueError( + raise DataCenteringError( f"Data Variable with size {self.values.size} does not match the number of faces " f"({self.uxgrid.n_face}. Current size matches the number of nodes. Consider running " f"``UxDataArray.topological_mean(destination='face') to aggregate the data onto the faces." ) elif self.values.size == self.uxgrid.n_edge: - raise ValueError( + raise DataCenteringError( f"Data Variable with size {self.values.size} does not match the number of faces " f"({self.uxgrid.n_face}. Current size matches the number of edges." ) else: # data is not mapped to - raise ValueError( + raise DimensionError( f"Data Variable with size {self.values.size} does not match the number of faces " f"({self.uxgrid.n_face}." ) @@ -340,7 +341,7 @@ def to_polycollection( """ # data is multidimensional, must be a 1D slice if self.values.ndim > 1: - raise ValueError( + raise DimensionError( f"Data Variable must be 1-dimensional, with shape {self.uxgrid.n_face} " f"for face-centered data." ) @@ -391,7 +392,7 @@ def to_polycollection( else: return poly_collection else: - raise ValueError("Data variable must be face centered.") + raise DataCenteringError("Data variable must be face centered.") def to_raster( self, @@ -616,13 +617,13 @@ def integrate( integral = np.einsum("i,...i", face_areas, self.values) elif self.values.shape[-1] == self.uxgrid.n_node: - raise ValueError("Integrating data mapped to each node not yet supported.") + raise DataCenteringError("Integrating data mapped to each node not yet supported.") elif self.values.shape[-1] == self.uxgrid.n_edge: - raise ValueError("Integrating data mapped to each edge not yet supported.") + raise DataCenteringError("Integrating data mapped to each edge not yet supported.") else: - raise ValueError( + raise DimensionError( f"The final dimension of the data variable does not match the number of nodes, edges, " f"or faces. Expected one of " f"{self.uxgrid.n_node}, {self.uxgrid.n_edge}, or {self.uxgrid.n_face}, " @@ -684,7 +685,7 @@ def zonal_mean(self, lat=(-90, 90, 10), conservative: bool = False, **kwargs): physical analysis. Non-conservative averaging samples at latitude lines. """ if not self._face_centered(): - raise ValueError( + raise DataCenteringError( "Zonal mean computations are currently only supported for face-centered data variables." ) @@ -765,7 +766,7 @@ def zonal_mean(self, lat=(-90, 90, 10), conservative: bool = False, **kwargs): ) if edges.ndim != 1 or edges.size < 2: - raise ValueError("Band edges must be 1D with at least two values") + raise DimensionError("Band edges must be 1D with at least two values") res = _compute_conservative_zonal_mean_bands(self, edges) @@ -832,7 +833,7 @@ def zonal_anomaly(self, lat=(-90, 90, 10), conservative: bool = False): >>> uxds["var"].zonal_anomaly(lat=(-60, 60, 5), conservative=True) """ if not self._face_centered(): - raise ValueError( + raise DataCenteringError( "Zonal anomaly is only supported for face-centered data variables." ) @@ -851,7 +852,7 @@ def zonal_anomaly(self, lat=(-90, 90, 10), conservative: bool = False): ) if edges.ndim != 1 or edges.size < 2: - raise ValueError("Band edges must be 1D with at least two values.") + raise DimensionError("Band edges must be 1D with at least two values.") res = _compute_zonal_anomaly(self, edges, conservative=conservative) @@ -911,7 +912,7 @@ def azimuthal_mean( from uxarray.grid.coordinates import _lonlat_rad_to_xyz if not self._face_centered(): - raise ValueError( + raise DataCenteringError( "Azimuthal mean computations are currently only supported for face-centered data variables." ) @@ -1630,13 +1631,13 @@ def curl( raise TypeError("other must be a UxDataArray") if self.uxgrid != other.uxgrid: - raise ValueError("Both vector components must be on the same grid") + raise GridsMismatchError("Both vector components must be on the same grid") if self.dims != other.dims: - raise ValueError("Both vector components must have the same dimensions") + raise DimensionError("Both vector components must have the same dimensions") if len(self.dims) != 1: - raise ValueError( + raise DimensionError( "Curl computation currently only supports 1-dimensional data. " "Use .isel() to select a single time slice or level." ) @@ -1721,20 +1722,20 @@ def divergence( raise TypeError("other must be a UxDataArray") if self.uxgrid != other.uxgrid: - raise ValueError("Both UxDataArrays must have the same grid") + raise GridsMismatchError("Both UxDataArrays must have the same grid") if self.dims != other.dims: - raise ValueError("Both UxDataArrays must have the same dimensions") + raise DimensionError("Both UxDataArrays must have the same dimensions") if self.ndim > 1: - raise ValueError( + raise DimensionError( "Divergence currently requires 1D face-centered data. Consider " "reducing the dimension by selecting data across leading dimensions (e.g., `.isel(time=0)`, " "`.sel(lev=500)`, or `.mean('time')`)." ) if not (self._face_centered() and other._face_centered()): - raise ValueError( + raise DataCenteringError( "Computing the divergence is only supported for face-centered data variables." ) @@ -1801,19 +1802,19 @@ def scalardotgradient(self, v: "UxDataArray", q: "UxDataArray") -> "UxDataArray" raise TypeError("q must be a UxDataArray") if self.uxgrid != v.uxgrid or self.uxgrid != q.uxgrid: - raise ValueError("All UxDataArrays must have the same grid") + raise GridsMismatchError("All UxDataArrays must have the same grid") if self.dims != v.dims or self.dims != q.dims: - raise ValueError("All UxDataArrays must have the same dimensions") + raise DimensionError("All UxDataArrays must have the same dimensions") if self.ndim > 1: - raise ValueError( + raise DimensionError( "Scalar dot gradient currently requires 1D face-centered data. " "Consider selecting a single slice before computing." ) if not (self._face_centered() and v._face_centered() and q._face_centered()): - raise ValueError( + raise DataCenteringError( "Computing the scalar dot gradient is only supported for face-centered data variables." ) @@ -1876,12 +1877,12 @@ def difference(self, destination: str | None = "edge"): dims[-1] = "n_edge" name = f"{var_name}edge_face_difference" elif destination == "face": - raise ValueError( + raise DataCenteringError( "Invalid destination 'face' for a face-centered data variable, computing" "the difference and storing it on each face is not possible" ) elif destination == "node": - raise ValueError( + raise DataCenteringError( "Support for computing the difference of a face-centered data variable and storing" "the result on each node not yet supported." ) @@ -1894,13 +1895,13 @@ def difference(self, destination: str | None = "edge"): dims[-1] = "n_edge" name = f"{var_name}edge_node_difference" elif destination == "node": - raise ValueError( + raise DataCenteringError( "Invalid destination 'node' for a node-centered data variable, computing" "the difference and storing it on each node is not possible" ) elif destination == "face": - raise ValueError( + raise DataCenteringError( "Support for computing the difference of a node-centered data variable and storing" "the result on each face not yet supported." ) @@ -1911,7 +1912,7 @@ def difference(self, destination: str | None = "edge"): ) else: - raise ValueError("TODO: ") + raise DataCenteringError("TODO: ") uxda = UxDataArray( _difference, @@ -1983,7 +1984,7 @@ def isel( Raises ------ - ValueError + DimensionError (subclass of ValueError) If more than one grid dimension is selected and `ignore_grid=False`. """ from uxarray.core.utils import _validate_indexers @@ -2037,7 +2038,7 @@ def isel( # e.g. "Dimensions {'level'} do not exist. Expected one of ('n_face', 'time', 'lev')" # Let's just append the available dimensions. original_error_msg = str(e) - raise ValueError( + raise DimensionError( f"{original_error_msg}. Available dimensions: {self.dims}" ) from e else: @@ -2102,7 +2103,7 @@ def from_healpix( raise ValueError("`da` must be a xr.DataArray") if face_dim not in da.dims: - raise ValueError( + raise DimensionError( f"The provided face dimension '{face_dim}' is present in the provided healpix data array." f"Please set 'face_dim' to the dimension corresponding to the healpix face dimension." ) @@ -2136,7 +2137,7 @@ def _slice_from_grid(self, sliced_grid): ) else: - raise ValueError( + raise DataCenteringError( "Data variable must be either node, edge, or face centered." ) diff --git a/uxarray/core/dataset.py b/uxarray/core/dataset.py index 4331dd30d..25a16a13a 100644 --- a/uxarray/core/dataset.py +++ b/uxarray/core/dataset.py @@ -15,6 +15,7 @@ import uxarray from uxarray.core.dataarray import UxDataArray from uxarray.core.utils import _map_dims_to_ugrid, _open_dataset_with_fallback +from uxarray.errors import DimensionError from uxarray.formatting_html import dataset_repr from uxarray.grid import Grid from uxarray.grid.dual import construct_dual @@ -347,7 +348,7 @@ def from_healpix( ds = _open_dataset_with_fallback(ds, **kwargs) if face_dim not in ds.dims: - raise ValueError( + raise DimensionError( f"The provided face dimension '{face_dim}' is not present in the provided healpix dataset." f"Please set 'face_dim' to the dimension corresponding to the healpix face dimension." ) diff --git a/uxarray/core/gradient.py b/uxarray/core/gradient.py index c3a4c8298..30e2537d1 100644 --- a/uxarray/core/gradient.py +++ b/uxarray/core/gradient.py @@ -4,6 +4,7 @@ from numba import njit, prange from uxarray.constants import INT_FILL_VALUE +from uxarray.errors import DataCenteringError, DimensionError from uxarray.grid.area import calculate_face_area @@ -96,7 +97,7 @@ def _compute_gradient(data, scale_by_radius=True): uxgrid = data.uxgrid if data.ndim > 1: - raise ValueError( + raise DimensionError( "Gradient currently requires 1D face-centered data. Consider " "reducing the dimension by selecting data across leading dimensions (e.g., `.isel(time=0)`, " "`.sel(lev=500)`, or `.mean('time')`). " @@ -184,7 +185,7 @@ def _compute_gradient(data, scale_by_radius=True): # normal_lon, # ) else: - raise ValueError( + raise DataCenteringError( "Computing the gradient is only supported for face-centered data variables." ) diff --git a/uxarray/core/utils.py b/uxarray/core/utils.py index 1a05926aa..bfa6c509b 100644 --- a/uxarray/core/utils.py +++ b/uxarray/core/utils.py @@ -1,5 +1,6 @@ import xarray as xr +from uxarray.errors import DimensionError from uxarray.io.utils import _get_source_dims_dict, _parse_grid_type @@ -141,7 +142,7 @@ def _validate_indexers(indexers, indexers_kwargs, func_name, ignore_grid): } if not ignore_grid and len(grid_dims) > 1: - raise ValueError( + raise DimensionError( f"Only one grid dimension can be sliced at a time; got {sorted(grid_dims)}." ) diff --git a/uxarray/core/zonal.py b/uxarray/core/zonal.py index 24a957c26..9904069e4 100644 --- a/uxarray/core/zonal.py +++ b/uxarray/core/zonal.py @@ -2,6 +2,7 @@ from numba import njit from uxarray.grid.area import calculate_face_area +from uxarray.errors import DimensionError from uxarray.grid.geometry import _unique_points from uxarray.grid.integrate import _zonal_face_weights, _zonal_face_weights_robust from uxarray.grid.intersections import ( @@ -252,7 +253,7 @@ def _compute_face_band_weights(uxgrid, bands): """ bands = np.asarray(bands, dtype=float) if bands.ndim != 1 or bands.size < 2: - raise ValueError("bands must be 1D with at least two edges") + raise DimensionError("bands must be 1D with at least two edges") if np.any(np.diff(bands) < 0): raise ValueError( f"bands must be monotonic non-decreasing; got diff(bands)={np.diff(bands)}" @@ -393,7 +394,7 @@ def _compute_zonal_anomaly(uxda, bands, conservative=False): bands = np.asarray(bands, dtype=float) if bands.ndim != 1 or bands.size < 2: - raise ValueError("Band edges must be 1D with at least two values.") + raise DimensionError("Band edges must be 1D with at least two values.") if np.any(np.diff(bands) < 0): raise ValueError( "Band edges must be monotonic non-decreasing; got " diff --git a/uxarray/cross_sections/dataarray_accessor.py b/uxarray/cross_sections/dataarray_accessor.py index b27e5c2b0..be39a9af4 100644 --- a/uxarray/cross_sections/dataarray_accessor.py +++ b/uxarray/cross_sections/dataarray_accessor.py @@ -6,6 +6,7 @@ import xarray as xr from uxarray.constants import INT_DTYPE +from uxarray.errors import DataCenteringError from .sample import ( _fill_numba, @@ -81,7 +82,7 @@ def __call__( """ if not self.uxda._face_centered(): - raise ValueError("Data variable must be face-centered") + raise DataCenteringError("Data variable must be face-centered") if steps < 2: raise ValueError("steps must be at least 2") diff --git a/uxarray/errors.py b/uxarray/errors.py new file mode 100644 index 000000000..29771f42b --- /dev/null +++ b/uxarray/errors.py @@ -0,0 +1,28 @@ +""" +File Purpose: custom error types in uxarray + +Defining custom error types helps with: + - quickly presenting relevant info + - cleaner error handling within uxarray + - cleaner error handling for packages using uxarray + +For more details, see: https://github.com/UXARRAY/uxarray/issues/1556 +""" + + +class DataCenteringError(ValueError): + """data centering issue, such as expecting node-centered data but got edge-centered.""" + + +class DimensionError(ValueError): + """issue with dimension(s), such as wrong size(s), name(s), or number of dimensions.""" + + +class GridInvalidError(ValueError): + """data does not correspond to a valid uxarray.Grid. + E.g., unrecognized format, duplicate nodes, or some faces with area < 0. + """ + + +class GridsMismatchError(ValueError): + """attempted to perform an operation involving two incompatible uxarray.Grid objects""" diff --git a/uxarray/grid/coordinates.py b/uxarray/grid/coordinates.py index b1775e786..9443e3769 100644 --- a/uxarray/grid/coordinates.py +++ b/uxarray/grid/coordinates.py @@ -7,6 +7,7 @@ from uxarray.constants import ERROR_TOLERANCE from uxarray.conventions import ugrid +from uxarray.errors import DimensionError from uxarray.grid.utils import _small_angle_of_2_vectors @@ -790,7 +791,7 @@ def prepare_points(points, normalize): if normalize: x, y, z = _normalize_xyz(x, y, z) else: - raise ValueError( + raise DimensionError( "Points must be a sequence of length 2 (longitude, latitude) or 3 (x, y, z coordinates)." ) @@ -828,7 +829,7 @@ def points_atleast_2d_xyz(points): elif points.shape[1] == 3: points_xyz = points else: - raise ValueError( + raise DimensionError( "Points are neither Cartesian (shape N x 3) nor Spherical (shape N x 2)." ) diff --git a/uxarray/grid/grid.py b/uxarray/grid/grid.py index 2c753f85a..277d004ee 100644 --- a/uxarray/grid/grid.py +++ b/uxarray/grid/grid.py @@ -16,6 +16,7 @@ # Import the utility function for opening datasets with fallback from uxarray.core.utils import _open_dataset_with_fallback from uxarray.cross_sections import GridCrossSectionAccessor +from uxarray.errors import DataCenteringError, GridInvalidError from uxarray.formatting_html import grid_repr from uxarray.grid.area import get_all_face_area_from_coords from uxarray.grid.bounds import _populate_face_bounds @@ -161,7 +162,7 @@ def __init__( # check if inputted dataset is a minimum representable 2D UGRID unstructured grid if source_grid_spec != "HEALPix": if not _validate_minimum_ugrid(grid_ds): - raise ValueError( + raise GridInvalidError( "Grid unable to be represented in the UGRID conventions. Representing an unstructured grid requires " "at least the following variables: ['node_lon'," "'node_lat', and 'face_node_connectivity']" @@ -297,7 +298,7 @@ def from_dataset(cls, dataset, use_dual: bool | None = False, **kwargs): "Use ux.Grid.from_geodataframe(= len(points)): @@ -77,7 +78,7 @@ def _regional_delaunay_from_points(points, boundary_points=None): if boundary_points is not None: boundary_points = np.asarray(boundary_points) if boundary_points.ndim != 1: - raise ValueError( + raise DimensionError( "boundary_points must be a 1D array-like of point indices." ) if np.any(boundary_points < 0) or np.any(boundary_points >= len(points)): diff --git a/uxarray/io/_esmf.py b/uxarray/io/_esmf.py index 0b8f2131f..195f0c91e 100644 --- a/uxarray/io/_esmf.py +++ b/uxarray/io/_esmf.py @@ -5,6 +5,7 @@ from uxarray.constants import INT_DTYPE, INT_FILL_VALUE from uxarray.conventions import ugrid +from uxarray.errors import GridInvalidError def _esmf_to_ugrid_dims(in_ds): @@ -138,7 +139,7 @@ def _encode_esmf(ds: xr.Dataset) -> xr.Dataset: if attr in out_ds["nodeCoords"].attrs: del out_ds["nodeCoords"].attrs[attr] else: - raise ValueError("Input dataset must contain 'node_lon' and 'node_lat'.") + raise GridInvalidError("Input dataset must contain 'node_lon' and 'node_lat'.") # Face Node Connectivity (elementConn) if "face_node_connectivity" in ds: @@ -153,7 +154,7 @@ def _encode_esmf(ds: xr.Dataset) -> xr.Dataset: ) out_ds["elementConn"].encoding = {"dtype": np.int32} else: - raise ValueError("Input dataset must contain 'face_node_connectivity'.") + raise GridInvalidError("Input dataset must contain 'face_node_connectivity'.") # Number of nodes per face (numElementConn) if "n_nodes_per_face" in ds: diff --git a/uxarray/io/_scrip.py b/uxarray/io/_scrip.py index ca4c513fb..5239854e6 100644 --- a/uxarray/io/_scrip.py +++ b/uxarray/io/_scrip.py @@ -6,6 +6,7 @@ from uxarray.constants import INT_DTYPE, INT_FILL_VALUE from uxarray.conventions import ugrid +from uxarray.errors import DimensionError, GridInvalidError from uxarray.grid.connectivity import _replace_fill_values @@ -422,7 +423,7 @@ def _resolve_cell_dims( cell_dims = [dim for dim in data_dims if dim != corner_dim] if not cell_dims: - raise ValueError("Unable to determine cell dimensions for grid variable.") + raise DimensionError("Unable to determine cell dimensions for grid variable.") return cell_dims @@ -436,7 +437,7 @@ def _stack_cell_dims( if not dims_in_array: if new_dim in data_array.dims: return data_array - raise ValueError( + raise DimensionError( f"Unable to stack dimensions {cell_dims}; none are present in {data_array.dims}" ) @@ -475,14 +476,14 @@ def _extract_single_grid( """ if "corner_lat" not in metadata or "corner_lon" not in metadata: - raise ValueError(f"Grid '{grid_name}' is missing corner variables.") + raise GridInvalidError(f"Grid '{grid_name}' is missing corner variables.") corner_lat = ds[metadata["corner_lat"]] corner_lon = ds[metadata["corner_lon"]] dims = list(corner_lat.dims) if len(dims) < 2: - raise ValueError(f"Corner variable for grid '{grid_name}' must be at least 2D.") + raise DimensionError(f"Corner variable for grid '{grid_name}' must be at least 2D.") corner_dim = metadata.get("corner_dim", dims[-1]) cell_dims = _resolve_cell_dims(metadata, dims, corner_dim) diff --git a/uxarray/plot/accessor.py b/uxarray/plot/accessor.py index 0cf0e2bc4..7199b4183 100644 --- a/uxarray/plot/accessor.py +++ b/uxarray/plot/accessor.py @@ -5,6 +5,7 @@ import pandas as pd +from uxarray.errors import DataCenteringError import uxarray.plot.utils if TYPE_CHECKING: @@ -505,7 +506,7 @@ def points(self, backend=None, *args, **kwargs): Raises ------ - ValueError + DataCenteringError (subclass of ValueError) If the data is not mapped to the nodes, edges, or faces. """ @@ -521,7 +522,7 @@ def points(self, backend=None, *args, **kwargs): elif data_mapping == "edges": lon, lat = uxgrid.edge_lon.values, uxgrid.edge_lat.values else: - raise ValueError( + raise DataCenteringError( "Data is not mapped to the nodes, edges, or faces of the grid." ) diff --git a/uxarray/plot/matplotlib.py b/uxarray/plot/matplotlib.py index 5e43a30af..186b7d65f 100644 --- a/uxarray/plot/matplotlib.py +++ b/uxarray/plot/matplotlib.py @@ -4,6 +4,8 @@ import numpy as np +from uxarray.errors import DimensionError + if TYPE_CHECKING: from cartopy.mpl.geoaxes import GeoAxes @@ -17,23 +19,23 @@ def _ensure_dimensions(data: UxDataArray) -> UxDataArray: Raises ------ - ValueError + DimensionError (subclass of ValueError) If the DataArray has more or fewer than one dimension. - ValueError + DimensionError (subclass of ValueError) If the sole dimension is not named "n_face". """ # Allow extra singleton dimensions as long as there's exactly one non-singleton dim non_trivial_dims = [dim for dim, size in zip(data.dims, data.shape) if size != 1] if len(non_trivial_dims) != 1: - raise ValueError( + raise DimensionError( "Expected data with a single dimension (other axes may be length 1), " f"but got dims {data.dims} with shape {data.shape}" ) sole_dim = non_trivial_dims[0] if sole_dim != "n_face": - raise ValueError(f"Expected dimension 'n_face', but got '{sole_dim}'") + raise DimensionError(f"Expected dimension 'n_face', but got '{sole_dim}'") # Squeeze any singleton axes to ensure we return a true 1D array over n_face return data.squeeze() diff --git a/uxarray/remap/apply_weights.py b/uxarray/remap/apply_weights.py index 9502ae228..1c4e6efa7 100644 --- a/uxarray/remap/apply_weights.py +++ b/uxarray/remap/apply_weights.py @@ -6,6 +6,7 @@ import xarray as xr import uxarray.core.dataarray +from uxarray.errors import DimensionError from .utils import ( LABEL_TO_COORD, @@ -25,21 +26,21 @@ def _get_source_dim( spatial_dims = [dim for dim in da.dims if dim in SPATIAL_DIMS] if len(spatial_dims) > 1: - raise ValueError( + raise DimensionError( f"Weight application does not support variables with multiple " f"spatial dimensions. Got {spatial_dims!r} for variable {da.name!r}." ) if source_dim is not None: if source_dim not in SPATIAL_DIMS: - raise ValueError( + raise DimensionError( f"source_dim {source_dim!r} is not a spatial dimension. " f"Expected one of {sorted(SPATIAL_DIMS)}." ) if source_dim not in da.dims: return None if da.sizes[source_dim] != weights.source_size: - raise ValueError( + raise DimensionError( f"Variable {da.name!r} dimension {source_dim!r} has size " f"{da.sizes[source_dim]}, expected {weights.source_size}." ) @@ -69,7 +70,7 @@ def _apply_weights( destination_size = destination_grid.sizes[destination_dim] if destination_size != weights_obj.destination_size: - raise ValueError( + raise DimensionError( f"Destination grid size for {destination_dim!r} is {destination_size}, " f"but weights target size is {weights_obj.destination_size}." ) @@ -107,10 +108,10 @@ def _apply_weights( if not remapped_any: if is_da: - raise ValueError( + raise DimensionError( f"No spatial dimension matched the weight source size {weights_obj.source_size}." ) - raise ValueError( + raise DimensionError( "No dataset variables matched the supplied weight source size." ) diff --git a/uxarray/remap/bilinear.py b/uxarray/remap/bilinear.py index 7f221f2ae..f1fbb9bbb 100644 --- a/uxarray/remap/bilinear.py +++ b/uxarray/remap/bilinear.py @@ -6,6 +6,8 @@ import xarray as xr from numba import njit, prange +from uxarray.errors import DataCenteringError + if TYPE_CHECKING: from uxarray.core.dataarray import UxDataArray from uxarray.core.dataset import UxDataset @@ -62,7 +64,7 @@ def _bilinear( for src_dim in dims_to_remap: if src_dim != "n_face": - raise ValueError( + raise DataCenteringError( "Bilinear remapping is not supported for non-face centered variables" ) diff --git a/uxarray/remap/spatial_coords_remap.py b/uxarray/remap/spatial_coords_remap.py index 70f32867f..05f6f5e8e 100644 --- a/uxarray/remap/spatial_coords_remap.py +++ b/uxarray/remap/spatial_coords_remap.py @@ -4,6 +4,7 @@ import xarray as xr from uxarray.core.dataarray import UxDataArray +from uxarray.errors import DimensionError from uxarray.grid.grid import Grid COORD_TYPES = { @@ -278,7 +279,7 @@ def construct_output_coords(self) -> Dict[str, xr.DataArray]: # Get the dimension that `source` is defined on source_dims = list(self.source.dims) if len(source_dims) == 0: - raise ValueError("Source data has no dimensions") + raise DimensionError("Source data has no dimensions") # Find the primary spatial dimension (should be n_face, n_node, or n_edge) source_spatial_dim = None @@ -288,7 +289,7 @@ def construct_output_coords(self) -> Dict[str, xr.DataArray]: break if source_spatial_dim is None: - raise ValueError( + raise DimensionError( f"Could not identify spatial dimension in `source` dims: {source_dims}" ) diff --git a/uxarray/remap/structured.py b/uxarray/remap/structured.py index bc036a11d..13064cd63 100644 --- a/uxarray/remap/structured.py +++ b/uxarray/remap/structured.py @@ -5,6 +5,8 @@ import numpy as np import xarray as xr +from uxarray.errors import DimensionError + @dataclass(frozen=True) class RectilinearGridSpec: @@ -65,7 +67,7 @@ def _as_1d_coord(coord, default_name: str) -> xr.DataArray: if isinstance(coord, xr.DataArray): values = np.asarray(coord.values, dtype=np.float64) if coord.ndim != 1: - raise ValueError( + raise DimensionError( f"Rectilinear {default_name!r} coordinate must be 1-D, " f"got {coord.ndim}-D." ) @@ -75,7 +77,7 @@ def _as_1d_coord(coord, default_name: str) -> xr.DataArray: else: values = np.asarray(coord, dtype=np.float64) if values.ndim != 1: - raise ValueError( + raise DimensionError( f"Rectilinear {default_name!r} coordinate must be 1-D, " f"got {values.ndim}-D." ) @@ -84,7 +86,7 @@ def _as_1d_coord(coord, default_name: str) -> xr.DataArray: attrs = {} if values.size < 2: - raise ValueError( + raise DimensionError( f"Rectilinear {default_name!r} coordinate must contain at least two values." ) return xr.DataArray(values, dims=(dim,), name=name, attrs=attrs) @@ -148,7 +150,7 @@ def _reshape_array_to_rectilinear( axis = da.get_axis_num("n_face") if da.sizes["n_face"] != spec.size: - raise ValueError( + raise DimensionError( "Cannot reshape remapped data to the requested rectilinear grid. " f"Expected {spec.size} face values, got {da.sizes['n_face']}." ) diff --git a/uxarray/remap/utils.py b/uxarray/remap/utils.py index cefcb606f..9f0aee6ee 100644 --- a/uxarray/remap/utils.py +++ b/uxarray/remap/utils.py @@ -1,6 +1,7 @@ import numpy as np import uxarray.core.dataset +from uxarray.errors import DimensionError # To preserve old names for remapping KDTREE_DIM_MAP: dict[str, str] = { @@ -48,11 +49,11 @@ def _assert_dimension(dim): Raises ------ - ValueError + DimensionError (subclass of ValueError) If `dim` is not a key in `LABEL_TO_COORD`. """ if dim not in LABEL_TO_COORD: - raise ValueError(f"Invalid spatial dimension: {dim!r}") + raise DimensionError(f"Invalid spatial dimension: {dim!r}") def _construct_remapped_ds(source, remapped_vars, destination_grid, remap_to): @@ -143,7 +144,7 @@ def _get_remap_dims(ds): Raises ------ - ValueError + DimensionError (subclass of ValueError) If no spatial dimensions are detected in the dataset. """ dims_to_remap: set[str] = set() @@ -151,7 +152,7 @@ def _get_remap_dims(ds): dims_to_remap |= set(da.dims) & SPATIAL_DIMS if not dims_to_remap: - raise ValueError( + raise DimensionError( "No spatial dimensions (n_node, n_edge, or n_face) found in source to remap." ) diff --git a/uxarray/remap/weights.py b/uxarray/remap/weights.py index 9b0c233bf..1ba32140e 100644 --- a/uxarray/remap/weights.py +++ b/uxarray/remap/weights.py @@ -10,6 +10,7 @@ from scipy import sparse from uxarray.core.utils import _open_dataset_with_fallback +from uxarray.errors import DimensionError # LRU-bounded cache for loaded remap operators. _WEIGHTS_CACHE_MAXSIZE = 32 @@ -118,7 +119,7 @@ def from_file(cls, filename_or_obj: str | PathLike[str] | xr.Dataset): ).ravel() if not (row.size == col.size == values.size): - raise ValueError( + raise DimensionError( "Remap weights require row, col, and weight arrays of equal length." ) @@ -147,10 +148,10 @@ def _apply(self, values: np.ndarray) -> np.ndarray: values = np.asarray(values) if values.ndim == 0: - raise ValueError("Remap weights require at least a 1-D input array.") + raise DimensionError("Remap weights require at least a 1-D input array.") if values.shape[-1] != self.source_size: - raise ValueError( + raise DimensionError( f"Expected trailing dimension of size {self.source_size}, " f"got {values.shape[-1]}." ) diff --git a/uxarray/remap/yac.py b/uxarray/remap/yac.py index 956ff175b..cfbb722d3 100644 --- a/uxarray/remap/yac.py +++ b/uxarray/remap/yac.py @@ -13,6 +13,7 @@ import xarray as xr import uxarray.core.dataarray +from uxarray.errors import DataCenteringError, DimensionError from uxarray.remap.structured import ( RectilinearGridSpec, _normalize_rectilinear_target, @@ -327,11 +328,11 @@ def apply( if values.ndim == 1: values = values.reshape(1, -1) elif values.ndim != 2: - raise ValueError( + raise DimensionError( f"YAC remap expects a 1-D or 2-D array, got {values.ndim}-D input." ) if values.shape[1] != self._src_size: - raise ValueError( + raise DimensionError( f"YAC remap expects {self._src_size} values, got {values.shape[1]}." ) @@ -340,12 +341,12 @@ def apply( if frac_mask.ndim == 1: frac_mask = frac_mask.reshape(1, -1) elif frac_mask.ndim != 2: - raise ValueError( + raise DimensionError( "YAC fractional mask expects a 1-D or 2-D array, " f"got {frac_mask.ndim}-D input." ) if frac_mask.shape != values.shape: - raise ValueError( + raise DimensionError( "YAC fractional mask must match remap input shape. " f"Got mask shape {frac_mask.shape} and value shape {values.shape}." ) @@ -376,7 +377,7 @@ def _prepare_frac_mask(frac_mask, da_t, src_values, src_dim: str) -> np.ndarray: frac_mask_values = np.asarray(frac_mask) if frac_mask_values.shape != src_values.shape: - raise ValueError( + raise DimensionError( "YAC fractional mask must match the remapped source variable shape. " f"Got mask shape {frac_mask_values.shape} and source shape {src_values.shape}." ) @@ -401,14 +402,14 @@ def _yac_remap(source, destination_grid, remap_to: str, yac_method: str, yac_kwa if options.method == "conservative": if destination_dim != "n_face": - raise ValueError( + raise ValueError( # not DataCenteringError; the issue here is the remap_to kwarg. "YAC conservative remapping requires the destination to be " "face-centered (remap_to='faces'). " f"Got remap_to={remap_to!r} which maps to dimension {destination_dim!r}." ) non_face_src = dims_to_remap - {"n_face"} if non_face_src: - raise ValueError( + raise DataCenteringError( "YAC conservative remapping requires all source data to be " f"face-centered (dimension 'n_face'). " f"Found non-face source dimension(s): {non_face_src}. " @@ -481,7 +482,7 @@ def _yac_remap_to_rectilinear(source, lon, lat, yac_method: str, yac_kwargs): if options.method == "conservative": non_face_src = dims_to_remap - {"n_face"} if non_face_src: - raise ValueError( + raise DataCenteringError( "YAC conservative remapping to a rectilinear grid requires all " "source data to be face-centered (dimension 'n_face'). " f"Found non-face source dimension(s): {non_face_src}. " diff --git a/uxarray/subset/dataarray_accessor.py b/uxarray/subset/dataarray_accessor.py index f240b0958..497683fe5 100644 --- a/uxarray/subset/dataarray_accessor.py +++ b/uxarray/subset/dataarray_accessor.py @@ -2,6 +2,8 @@ import numpy as np +from uxarray.errors import DataCenteringError + class DataArraySubsetAccessor: """Accessor for performing unstructured grid subsetting with a data @@ -147,8 +149,10 @@ def constant_latitude( Raises ------ + DataCenteringError (subclass of ValueError) + If the data variable is not face-centered. ValueError - If no intersections are found at the specified longitude or the data variable is not face-centered. + If no intersections are found at the specified longitude. Examples -------- @@ -157,7 +161,7 @@ def constant_latitude( """ if not self.uxda._face_centered(): - raise ValueError( + raise DataCenteringError( "Cross sections are only supported for face-centered data variables." ) @@ -201,8 +205,10 @@ def constant_longitude( Raises ------ + DataCenteringError (subclass of ValueError) + If the data variable is not face-centered. ValueError - If no intersections are found at the specified longitude or the data variable is not face-centered. + If no intersections are found at the specified longitude. Examples -------- @@ -210,7 +216,7 @@ def constant_longitude( >>> cross_section = uxda.cross_section.constant_longitude(lon=0.0) """ if not self.uxda._face_centered(): - raise ValueError( + raise DataCenteringError( "Cross sections are only supported for face-centered data variables." ) diff --git a/uxarray/subset/grid_accessor.py b/uxarray/subset/grid_accessor.py index 30e263e2c..b4bc521a6 100644 --- a/uxarray/subset/grid_accessor.py +++ b/uxarray/subset/grid_accessor.py @@ -4,6 +4,8 @@ import numpy as np +from uxarray.errors import DimensionError + if TYPE_CHECKING: from uxarray.grid import Grid @@ -394,7 +396,7 @@ def constant_longitude_interval( def _get_tree(self, coords, tree_type): """Internal helper for obtaining the desired KDTree or BallTree.""" if coords.ndim > 1: - raise ValueError("Coordinates must be one-dimensional") + raise DimensionError("Coordinates must be one-dimensional") if len(coords) == 2: # Spherical coordinates @@ -403,7 +405,7 @@ def _get_tree(self, coords, tree_type): # Cartesian coordinates tree = self.uxgrid.get_kd_tree(tree_type) else: - raise ValueError("Unsupported coordinates provided.") + raise DimensionError("Unsupported coordinates provided.") return tree diff --git a/uxarray/utils/computing.py b/uxarray/utils/computing.py index ca5ca5183..15f0f9f4b 100644 --- a/uxarray/utils/computing.py +++ b/uxarray/utils/computing.py @@ -2,6 +2,8 @@ import numpy as np +from uxarray.errors import DimensionError + def _fmms(a, b, c, d): """ @@ -90,7 +92,7 @@ def dot_fma(v1, v2): Raises ------ - ValueError + DimensionError (subclass of ValueError) If the input vectors `v1` and `v2` are not of the same length. Examples @@ -104,7 +106,7 @@ def dot_fma(v1, v2): DALI-LP2A Laboratory, University of Perpignan, France. """ if len(v1) != len(v2): - raise ValueError("Input vectors must be of the same length") + raise DimensionError("Input vectors must be of the same length") s, c = _two_prod_fma(v1[0], v2[0]) for i in range(1, len(v1)): From dce93ef4c3b8e39872f15ac52b395fae44b19a0e Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:51:26 -0400 Subject: [PATCH 2/4] fix: ruff formatting of previous commit --- uxarray/core/dataarray.py | 8 ++++++-- uxarray/core/zonal.py | 2 +- uxarray/io/_scrip.py | 4 +++- uxarray/plot/accessor.py | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index 81cee69b5..6c5e1db12 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -617,10 +617,14 @@ def integrate( integral = np.einsum("i,...i", face_areas, self.values) elif self.values.shape[-1] == self.uxgrid.n_node: - raise DataCenteringError("Integrating data mapped to each node not yet supported.") + raise DataCenteringError( + "Integrating data mapped to each node not yet supported." + ) elif self.values.shape[-1] == self.uxgrid.n_edge: - raise DataCenteringError("Integrating data mapped to each edge not yet supported.") + raise DataCenteringError( + "Integrating data mapped to each edge not yet supported." + ) else: raise DimensionError( diff --git a/uxarray/core/zonal.py b/uxarray/core/zonal.py index 9904069e4..a7ac0e453 100644 --- a/uxarray/core/zonal.py +++ b/uxarray/core/zonal.py @@ -1,8 +1,8 @@ import numpy as np from numba import njit -from uxarray.grid.area import calculate_face_area from uxarray.errors import DimensionError +from uxarray.grid.area import calculate_face_area from uxarray.grid.geometry import _unique_points from uxarray.grid.integrate import _zonal_face_weights, _zonal_face_weights_robust from uxarray.grid.intersections import ( diff --git a/uxarray/io/_scrip.py b/uxarray/io/_scrip.py index 5239854e6..21edb1377 100644 --- a/uxarray/io/_scrip.py +++ b/uxarray/io/_scrip.py @@ -483,7 +483,9 @@ def _extract_single_grid( dims = list(corner_lat.dims) if len(dims) < 2: - raise DimensionError(f"Corner variable for grid '{grid_name}' must be at least 2D.") + raise DimensionError( + f"Corner variable for grid '{grid_name}' must be at least 2D." + ) corner_dim = metadata.get("corner_dim", dims[-1]) cell_dims = _resolve_cell_dims(metadata, dims, corner_dim) diff --git a/uxarray/plot/accessor.py b/uxarray/plot/accessor.py index 7199b4183..e17bd4cf4 100644 --- a/uxarray/plot/accessor.py +++ b/uxarray/plot/accessor.py @@ -5,8 +5,8 @@ import pandas as pd -from uxarray.errors import DataCenteringError import uxarray.plot.utils +from uxarray.errors import DataCenteringError if TYPE_CHECKING: from uxarray.core.dataarray import UxDataArray From 73bba0c14b46003e02f944caf8e597d7c53f7dea Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:51:47 -0400 Subject: [PATCH 3/4] move YacNotAvailableError to errors.py --- test/test_remap_yac.py | 3 ++- uxarray/errors.py | 14 +++++++++++++- uxarray/remap/yac.py | 6 +----- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/test/test_remap_yac.py b/test/test_remap_yac.py index 3efcd7c11..891952801 100644 --- a/test/test_remap_yac.py +++ b/test/test_remap_yac.py @@ -2,7 +2,8 @@ import pytest import uxarray as ux -from uxarray.remap.yac import YacNotAvailableError, _import_yac +from uxarray.errors import YacNotAvailableError +from uxarray.remap.yac import _import_yac try: diff --git a/uxarray/errors.py b/uxarray/errors.py index 29771f42b..9e5cbab23 100644 --- a/uxarray/errors.py +++ b/uxarray/errors.py @@ -1,11 +1,16 @@ """ -File Purpose: custom error types in uxarray +File Purpose: all custom error types in uxarray Defining custom error types helps with: - quickly presenting relevant info - cleaner error handling within uxarray - cleaner error handling for packages using uxarray +Defining all such custom error types in this file, instead of throughout the codebase, + helps with maintainability, discoverability, and convenience, + as the import syntax will always be "import uxarray.errors.CustomError", + and help(uxarray.errors) will list all custom error types in one place. + For more details, see: https://github.com/UXARRAY/uxarray/issues/1556 """ @@ -26,3 +31,10 @@ class GridInvalidError(ValueError): class GridsMismatchError(ValueError): """attempted to perform an operation involving two incompatible uxarray.Grid objects""" + + +# # # ----- Io-type-specific Errors ----- # # # + + +class YacNotAvailableError(RuntimeError): + """Raised when the YAC backend is requested but unavailable.""" diff --git a/uxarray/remap/yac.py b/uxarray/remap/yac.py index cfbb722d3..2ec7ed623 100644 --- a/uxarray/remap/yac.py +++ b/uxarray/remap/yac.py @@ -13,7 +13,7 @@ import xarray as xr import uxarray.core.dataarray -from uxarray.errors import DataCenteringError, DimensionError +from uxarray.errors import DataCenteringError, DimensionError, YacNotAvailableError from uxarray.remap.structured import ( RectilinearGridSpec, _normalize_rectilinear_target, @@ -29,10 +29,6 @@ ) -class YacNotAvailableError(RuntimeError): - """Raised when the YAC backend is requested but unavailable.""" - - @dataclass class _YacOptions: method: str From 066664963ba47469b1a73088214747a7f2ec882b Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:17:41 -0400 Subject: [PATCH 4/4] test catching custom error types spot check that custom error types are working: changes two existing tests to catch custom error types. Does not change many other tests, because it is also desirable to confirm that these changes are actually backwards-compatible, by not messing with the test suite too much. --- test/core/test_dataarray.py | 3 ++- test/grid/integrate/test_zonal.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/test/core/test_dataarray.py b/test/core/test_dataarray.py index b961a5a37..3ad3376ae 100644 --- a/test/core/test_dataarray.py +++ b/test/core/test_dataarray.py @@ -1,5 +1,6 @@ import numpy as np import uxarray as ux +from uxarray.errors import DimensionError from uxarray.grid.geometry import _build_polygon_shells, _build_corrected_polygon_shells from uxarray.core.dataset import UxDataset, UxDataArray import pytest @@ -124,7 +125,7 @@ def test_isel_invalid_dim(gridpath, datasetpath): uxda = UxDataArray(data, dims=["time", "n_face"], uxgrid=uxds.uxgrid) with pytest.raises( - ValueError, + DimensionError, match=r"Dimensions \{'invalid_dim'\} do not exist\..*Available dimensions: \('time', 'n_face'\)", ): uxda.isel(invalid_dim=0) diff --git a/test/grid/integrate/test_zonal.py b/test/grid/integrate/test_zonal.py index 75c3921a7..8e12979c4 100644 --- a/test/grid/integrate/test_zonal.py +++ b/test/grid/integrate/test_zonal.py @@ -8,6 +8,7 @@ import uxarray as ux from uxarray.constants import ERROR_TOLERANCE +from uxarray.errors import DataCenteringError class TestZonalCSne30: @@ -380,7 +381,7 @@ def test_non_face_centered_raises(self, gridpath, datasetpath): uxda = ux.UxDataArray( np.zeros(uxgrid.n_node), dims=["n_node"], uxgrid=uxgrid ) - with pytest.raises(ValueError, match="face-centered"): + with pytest.raises(DataCenteringError, match="face-centered"): uxda.zonal_anomaly() def test_invalid_lat_input_raises(self):