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): 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/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..6c5e1db12 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,17 @@ 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 +689,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 +770,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 +837,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 +856,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 +916,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 +1635,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 +1726,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 +1806,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 +1881,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 +1899,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 +1916,7 @@ def difference(self, destination: str | None = "edge"): ) else: - raise ValueError("TODO: ") + raise DataCenteringError("TODO: ") uxda = UxDataArray( _difference, @@ -1983,7 +1988,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 +2042,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 +2107,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 +2141,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..a7ac0e453 100644 --- a/uxarray/core/zonal.py +++ b/uxarray/core/zonal.py @@ -1,6 +1,7 @@ import numpy as np from numba import njit +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 @@ -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..9e5cbab23 --- /dev/null +++ b/uxarray/errors.py @@ -0,0 +1,40 @@ +""" +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 +""" + + +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""" + + +# # # ----- Io-type-specific Errors ----- # # # + + +class YacNotAvailableError(RuntimeError): + """Raised when the YAC backend is requested but unavailable.""" 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..21edb1377 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,16 @@ 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..e17bd4cf4 100644 --- a/uxarray/plot/accessor.py +++ b/uxarray/plot/accessor.py @@ -6,6 +6,7 @@ import pandas as pd import uxarray.plot.utils +from uxarray.errors import DataCenteringError if TYPE_CHECKING: from uxarray.core.dataarray import UxDataArray @@ -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..2ec7ed623 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, YacNotAvailableError from uxarray.remap.structured import ( RectilinearGridSpec, _normalize_rectilinear_target, @@ -28,10 +29,6 @@ ) -class YacNotAvailableError(RuntimeError): - """Raised when the YAC backend is requested but unavailable.""" - - @dataclass class _YacOptions: method: str @@ -327,11 +324,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 +337,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 +373,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 +398,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 +478,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)):