From a090484aa4a34ac996fd00ec8ebc0cc923862597 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:08:51 -0400 Subject: [PATCH 1/3] improve error messages in uxarray/core --- uxarray/core/accessors.py | 2 +- uxarray/core/aggregation.py | 40 ++++---- uxarray/core/api.py | 43 +++++++-- uxarray/core/dataarray.py | 178 +++++++++++++++++++++++------------- uxarray/core/dataset.py | 7 +- uxarray/core/gradient.py | 9 +- uxarray/core/zonal.py | 5 +- 7 files changed, 179 insertions(+), 105 deletions(-) diff --git a/uxarray/core/accessors.py b/uxarray/core/accessors.py index 1642b5524..4d27d917c 100644 --- a/uxarray/core/accessors.py +++ b/uxarray/core/accessors.py @@ -119,7 +119,7 @@ def wrapped(*args, **kwargs): def _process_result(self, result): """Process method results to preserve uxgrid. To be overridden by subclasses.""" - raise NotImplementedError("Subclasses must implement _process_result") + raise NotImplementedError(f"_process_result, for BaseAccessor subclass {type(self).__name__}") # Delegation for common dunder methods def __iter__(self): diff --git a/uxarray/core/aggregation.py b/uxarray/core/aggregation.py index b7ffb5b40..1be0f92d1 100644 --- a/uxarray/core/aggregation.py +++ b/uxarray/core/aggregation.py @@ -24,8 +24,8 @@ def _uxda_grid_aggregate(uxda, destination, aggregation, **kwargs): UxDataArray.""" if destination is None: raise ValueError( - "Attempting to perform a topological aggregation, but no destination was provided." - ) + f"Missing destination (got destination=None) in topological_{aggregation})." + ) # e.g. aggregation will be something like "mean", "min", "any", ... if uxda._node_centered(): # aggregation of a node-centered data variable @@ -35,8 +35,8 @@ def _uxda_grid_aggregate(uxda, destination, aggregation, **kwargs): return _node_to_edge_aggregation(uxda, aggregation, kwargs) else: raise DataCenteringError( - f"Invalid destination for a node-centered data variable. Expected" - f"one of ['face', 'edge' but received {destination}" + f"Node-centered data requires destination='face' or 'edge'; " + f"got destination={destination!r}, during topological_{aggregation}." ) elif uxda._edge_centered(): @@ -44,29 +44,17 @@ def _uxda_grid_aggregate(uxda, destination, aggregation, **kwargs): raise NotImplementedError( "Aggregation of edge-centered data variables is not yet supported." ) - # if destination == "node": - # pass - # elif destination == "face": - # pass - # else: - # raise ValueError("TODO: ) elif uxda._face_centered(): # aggregation of a face-centered data variable raise NotImplementedError( "Aggregation of face-centered data variables is not yet supported." ) - # if destination == "node": - # pass - # elif destination == "edge": - # pass - # else: - # raise ValueError("TODO: ") else: raise DataCenteringError( - "Invalid data mapping. Data variable is expected to be mapped to either the " - "nodes, faces, or edges of the source grid." + f"topological_{aggregation} expected node_centered, edge_centered, or face_centered data; " + f"got data with uxda.data_location={uxda.data_location!r} with dimensions {uxda.dims!r}." ) @@ -76,8 +64,8 @@ def _node_to_face_aggregation(uxda, aggregation, aggregation_func_kwargs): if not uxda._node_centered(): raise DataCenteringError( - f"Data Variable must be mapped to the corner nodes of each face, with dimension " - f"{uxda.uxgrid.n_face}." + f"Expected node_centered data; got data with uxda.data_location={uxda.data_location!r} " + f"with dimensions {uxda.dims!r}, during _node_to_face_aggregation." ) if isinstance(uxda.data, np.ndarray): @@ -91,7 +79,9 @@ def _node_to_face_aggregation(uxda, aggregation, aggregation_func_kwargs): uxda, NUMPY_AGGREGATIONS[aggregation], aggregation_func_kwargs ) else: - raise TypeError + raise TypeError( + f"Expected numpy or dask array; got type(uxda.data)={type(uxda.data)}." + ) return uxarray.core.dataarray.UxDataArray( uxgrid=uxda.uxgrid, @@ -146,8 +136,8 @@ def _node_to_edge_aggregation(uxda, aggregation, aggregation_func_kwargs): if not uxda._node_centered(): raise DataCenteringError( - f"Data Variable must be mapped to the corner nodes of each face, with dimension " - f"{uxda.uxgrid.n_face}." + f"Expected node_centered data; got data with uxda.data_location={uxda.data_location!r} " + f"with dimensions {uxda.dims!r}, during _node_to_edge_aggregation." ) if isinstance(uxda.data, np.ndarray): @@ -161,7 +151,9 @@ def _node_to_edge_aggregation(uxda, aggregation, aggregation_func_kwargs): uxda, NUMPY_AGGREGATIONS[aggregation], aggregation_func_kwargs ) else: - raise TypeError + raise TypeError( + f"Expected numpy or dask array; got type(uxda.data)={type(uxda.data)}." + ) return uxarray.core.dataarray.UxDataArray( uxgrid=uxda.uxgrid, diff --git a/uxarray/core/api.py b/uxarray/core/api.py index fc1ec89c3..311727e0f 100644 --- a/uxarray/core/api.py +++ b/uxarray/core/api.py @@ -106,8 +106,14 @@ def open_grid( if os.path.isfile(nod2d_path) and os.path.isfile(elem2d_path): grid = Grid.from_dataset(grid_filename_or_obj) else: + _missing = [] + if not os.path.isfile(nod2d_path): + _missing.append("'nod2d.out'") + if not os.path.isfile(elem2d_path): + _missing.append("'elem2d.out'") raise FileNotFoundError( - f"The directory '{grid_filename_or_obj}' must contain both 'nod2d.out' and 'elem2d.out'." + "open_grid(directory) expects FESOM2 ASCII dataset with 'nod2d.out' and 'elem2d.out' files, but " + f"got directory={os.path.abspath(grid_filename_or_obj)!r}, which is missing {' and '.join(_missing)}." ) elif isinstance(grid_filename_or_obj, dict): @@ -188,6 +194,12 @@ def open_multigrid( mask_ds = xr.open_dataset(mask_filename) mask_ds_opened = True + # human-readable str telling what was provided to open_multigrid(). Useful for error messages. + if isinstance(grid_filename_or_obj, (str, os.PathLike)): + _provided_input_str = f"file, {os.path.abspath(grid_filename_or_obj)!r}" + else: + _provided_input_str = str(type(grid_filename_or_obj)) + try: active_value_map: Mapping[str, MaskValue] | None = ( mask_active_value if isinstance(mask_active_value, Mapping) else None @@ -236,8 +248,8 @@ def _active_mask_values_for_grid(grid_name: str) -> np.ndarray: if format_type == "single_scrip": if gridnames is not None and "grid" not in gridnames: raise ValueError( - f"Requested grids {gridnames} not found. " - "This file contains a single grid named 'grid'." + f"Requested grids (gridnames={gridnames}) not found in the provided {_provided_input_str}, " + "in open_multigrid(). Only 'grid' is available in single-grid SCRIP files." ) grid_ds_ugrid, source_dims_dict = _read_scrip(grid_ds) return { @@ -249,7 +261,9 @@ def _active_mask_values_for_grid(grid_name: str) -> np.ndarray: } if not grids_dict: - raise GridInvalidError(f"No grids detected in file: {grid_filename_or_obj}") + raise GridInvalidError( + f"Failed to detect any grids in the provided {_provided_input_str}, in open_multigrid()." + ) available_grids = list(grids_dict.keys()) @@ -265,7 +279,8 @@ def _active_mask_values_for_grid(grid_name: str) -> np.ndarray: for name in requested: if name not in grids_dict: raise ValueError( - f"Grid '{name}' not found. Available grids: {available_grids}" + f"open_multigrid() grid '{name}' not found in the provided {_provided_input_str}, " + f"in open_multigrid(). Available grids: {available_grids}" ) grids_to_load.append(name) @@ -293,9 +308,14 @@ def _active_mask_values_for_grid(grid_name: str) -> np.ndarray: active_indices = np.flatnonzero(active_mask) grid = grid.isel(n_face=active_indices) else: + _provided_mask_str = ( + f"file, {os.path.abspath(mask_filename)!r}" + if isinstance(mask_filename, (str, os.PathLike)) + else str(type(mask_filename)) + ) warn( - f"Mask variable '{mask_var}' not found in mask file; " - f"grid '{grid_name}' will be returned without masking." + f"Mask variable {mask_var!r} not found in the provided mask {_provided_mask_str}. " + f"Grid {grid_name!r} will be returned without masking." ) loaded_grids[grid_name] = grid @@ -426,7 +446,8 @@ def open_dataset( if isinstance(grid_filename_or_obj, (str, os.PathLike)): if os.path.isdir(grid_filename_or_obj): raise ValueError( - "ux.open_dataset() with a single directory argument is not supported. " + "ux.open_dataset(arg0) with no other arguments and arg0 a single directory is not supported, " + f"but got arg0 indicating path to directory: {os.path.abspath(grid_filename_or_obj)!r}." "Supply a path to a grid file instead. Directory-based grids (e.g. a " "FESOM2 ASCII grid) are only recognized when a separate data file is " "also provided, i.e. ux.open_dataset(grid_directory, data_file)." @@ -440,8 +461,10 @@ def open_dataset( elif isinstance(grid_filename_or_obj, xr.Dataset): ds = grid_filename_or_obj else: - raise ValueError( - "If filename_or_obj is omitted, grid_filename_or_obj must be a file path or xarray.Dataset." + raise TypeError( + "Expected grid_filename_or_obj to be a file path or xarray.Dataset when filename_or_obj " + f"is not provided, but got type(grid_filename_or_obj)={type(grid_filename_or_obj)}, " + "in ux.open_dataset(grid_filename_or_obj, filename_or_obj=None)." ) uxgrid, _ = _get_grid(ds, chunks, chunk_grid, use_dual, grid_kwargs, **kwargs) diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index f265ad53b..b1af124c5 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -271,8 +271,7 @@ def to_geodataframe( if self.values.ndim > 1: # data is multidimensional, must be a 1D slice raise DimensionError( - f"Data Variable must be 1-dimensional, with shape {self.uxgrid.n_face} " - f"for face-centered data." + f"to_geodataframe() expected 1D data, got {self.ndim}D data with dims={self.dims}." ) if self._face_centered(): @@ -353,8 +352,7 @@ def to_polycollection( # data is multidimensional, must be a 1D slice if self.values.ndim > 1: raise DimensionError( - f"Data Variable must be 1-dimensional, with shape {self.uxgrid.n_face} " - f"for face-centered data." + f"to_polycollection() expected 1D data, got {self.ndim}D data with dims={self.dims}." ) if self._face_centered(): @@ -403,7 +401,11 @@ def to_polycollection( else: return poly_collection else: - raise DataCenteringError("Data variable must be face centered.") + raise DataCenteringError( + f"to_polycollection() expects face_centered data; got {self.data_location} data " + f"(with sizes={dict(**self.sizes)}). Consider running " + "``UxDataArray.topological_mean(destination='face')`` to aggregate the data onto faces." + ) def to_raster( self, @@ -486,7 +488,10 @@ def to_raster( data = _ensure_dimensions(self) if not isinstance(ax, GeoAxes): - raise TypeError("`ax` must be an instance of cartopy.mpl.geoaxes.GeoAxes") + raise TypeError( + f"to_raster(ax) expected `ax` to be an instance of cartopy.mpl.geoaxes.GeoAxes; " + f"got type(ax)={type(ax)}" + ) pixel_ratio_set = pixel_ratio is not None if not pixel_ratio_set: @@ -499,15 +504,15 @@ def to_raster( if pixel_ratio_set and pixel_ratio_input != pixel_ratio: warn( "Pixel ratio mismatch: " - f"{pixel_ratio_input} passed but {pixel_ratio} in pixel_mapping. " - "Using the pixel_mapping attribute.", + f"pixel_ratio (={pixel_ratio_input}) != pixel_mapping['pixel_ratio'] (={pixel_ratio})." + f"Defaulting to pixel_ratio=pixel_mapping['pixel_ratio'] (={pixel_ratio})", stacklevel=2, ) input_ax_attrs = _RasterAxAttrs.from_ax(ax, pixel_ratio=pixel_ratio) pm_ax_attrs = _RasterAxAttrs.from_xr_attrs(pixel_mapping.attrs) if input_ax_attrs != pm_ax_attrs: raise ValueError( - "Pixel mapping incompatible with ax. " + "Provided pixel_mapping values incompatible with ax raster attrs: " + input_ax_attrs._value_comparison_message(pm_ax_attrs) ) pixel_mapping = np.asarray(pixel_mapping, dtype=INT_DTYPE) @@ -635,7 +640,8 @@ def integrate( elif not self._face_centered(): raise DataCenteringError( "Integration of non-face_centered data is not yet supported. " - f"(Got {self.data_location} data with sizes={dict(**self.sizes)})" + f"(Got {self.data_location} data with sizes={dict(**self.sizes)}.) " + "Consider applying .topological_mean('face') to aggregate data onto faces." ) else: @@ -706,7 +712,9 @@ def zonal_mean(self, lat=(-90, 90, 10), conservative: bool = False, **kwargs): """ if not self._face_centered(): raise DataCenteringError( - "Zonal mean computations are currently only supported for face-centered data variables." + "zonal_mean() of non-face_centered data is not currently supported. " + f"(Got {self.data_location} data with sizes={dict(**self.sizes)}.) " + "Consider applying .topological_mean('face') to aggregate data onto faces." ) face_axis = self.dims.index("n_face") @@ -716,7 +724,7 @@ def zonal_mean(self, lat=(-90, 90, 10), conservative: bool = False, **kwargs): if isinstance(lat, tuple): start, end, step = lat if step <= 0: - raise ValueError("Step size must be positive.") + raise ValueError(f"Expected step>0 when lat=(min_lat, max_lat, step); got step={step}") if step < 0.1: warnings.warn( f"Very small step size ({step}°) may lead to performance issues...", @@ -731,8 +739,9 @@ def zonal_mean(self, lat=(-90, 90, 10), conservative: bool = False, **kwargs): elif isinstance(lat, (list, np.ndarray)): latitudes = np.asarray(lat) else: - raise ValueError( - "Invalid value for 'lat' provided. Must be a scalar, tuple (min_lat, max_lat, step), or array-like." + raise TypeError( + "Expected lat to be a scalar, tuple of (min_lat, max_lat, step), or array-like; " + f"got type(lat)={type(lat)}, during .zonal_mean(lat, conservative=False)." ) res = _compute_non_conservative_zonal_mean( @@ -762,9 +771,7 @@ def zonal_mean(self, lat=(-90, 90, 10), conservative: bool = False, **kwargs): if isinstance(lat, tuple): start, end, step = lat if step <= 0: - raise ValueError( - "Step size must be positive for conservative averaging." - ) + raise ValueError(f"Expected step>0 when lat=(min_lat, max_lat, step); got step={step}") if step < 0.1: warnings.warn( f"Very small step size ({step}°) may lead to performance issues...", @@ -777,12 +784,16 @@ def zonal_mean(self, lat=(-90, 90, 10), conservative: bool = False, **kwargs): elif isinstance(lat, (list, np.ndarray)): edges = np.asarray(lat, dtype=float) else: - raise ValueError( - "For conservative averaging, 'lat' must be a tuple (start, end, step) or array-like band edges." + raise TypeError( + "Expected lat to be a tuple of (min_lat, max_lat, step), or array-like of band edges; " + f"got type(lat)={type(lat)}, during .zonal_mean(lat, conservative=True)." ) if edges.ndim != 1 or edges.size < 2: - raise DimensionError("Band edges must be 1D with at least two values") + raise DimensionError( + "Band edges must be 1D with at least two values; " + f"got edges with ndim={edges.ndim}, size={edges.size}." + ) res = _compute_conservative_zonal_mean_bands(self, edges) @@ -855,13 +866,15 @@ def zonal_anomaly(self, lat=(-90, 90, 10), conservative: bool = False): """ if not self._face_centered(): raise DataCenteringError( - "Zonal anomaly is only supported for face-centered data variables." + "zonal_anomaly() of non-face_centered data is not currently supported. " + f"(Got {self.data_location} data with sizes={dict(**self.sizes)}.) " + "Consider applying .topological_mean('face') to aggregate data onto faces." ) if isinstance(lat, tuple): start, end, step = lat if step <= 0: - raise ValueError("Step size must be positive.") + raise ValueError(f"Expected step>0 when lat=(min_lat, max_lat, step); got step={step}") num_points = int(round((end - start) / step)) + 1 edges = np.linspace(start, end, num_points) edges = np.clip(edges, -90, 90) @@ -869,11 +882,15 @@ def zonal_anomaly(self, lat=(-90, 90, 10), conservative: bool = False): edges = np.asarray(lat, dtype=float) else: raise TypeError( - "Invalid value for 'lat'. Must be a tuple (start, end, step) or array-like band edges." + "Expected lat to be a tuple of (min_lat, max_lat, step), or array-like of band edges; " + f"got type(lat)={type(lat)}, during .zonal_anomaly(lat, ...)." ) if edges.ndim != 1 or edges.size < 2: - raise DimensionError("Band edges must be 1D with at least two values.") + raise DimensionError( + "Band edges must be 1D with at least two values; " + f"got edges with ndim={edges.ndim}, size={edges.size}." + ) res = _compute_zonal_anomaly(self, edges, conservative=conservative) @@ -934,11 +951,15 @@ def azimuthal_mean( if not self._face_centered(): raise DataCenteringError( - "Azimuthal mean computations are currently only supported for face-centered data variables." + "azimuthal_mean() of non-face_centered data is not currently supported. " + f"(Got {self.data_location} data with sizes={dict(**self.sizes)}.) " + "Consider applying .topological_mean('face') to aggregate data onto faces." ) if outer_radius <= 0: - raise ValueError("Radius must be a positive scalar.") + raise ValueError( + f"outer_radius must be a positive scalar during azimuthal_mean(); got outer_radius={outer_radius}" + ) kdtree = self.uxgrid._get_scipy_kd_tree() @@ -1647,18 +1668,25 @@ def curl( """ # Input validation if not isinstance(other, UxDataArray): - raise TypeError("other must be a UxDataArray") + raise TypeError(f"UxDataArray.curl(other) expected UxDataArray other; got type(other)={type(other)}") if self.uxgrid != other.uxgrid: - raise GridsMismatchError("Both vector components must be on the same grid") + raise GridsMismatchError( + "Both vector components must be on the same grid " + "during u.curl(v), but got u.uxgrid != v.uxgrid." + ) if self.dims != other.dims: - raise DimensionError("Both vector components must have the same dimensions") + raise DimensionError( + "Both vector components must have the same dimensions during u.curl(v), " + f"but got u.dims={self.dims}, v.dims={other.dims}" + ) if len(self.dims) != 1: raise DimensionError( - "Curl computation currently only supports 1-dimensional data. " - "Use .isel() to select a single time slice or level." + "curl() computation currently only supports 1-dimensional data; " + f"got data.dims={self.dims}. Consider reducing dimensionality along non-grid dimensions, " + "e.g. by applying something like .isel(time=0), .sel(lev=500), or .mean('Time')." ) # Compute gradients of both components @@ -1738,24 +1766,36 @@ def divergence( >>> div_field = u_component.divergence(v_component) """ if not isinstance(other, UxDataArray): - raise TypeError("other must be a UxDataArray") + raise TypeError(f"UxDataArray.divergence(other) expected UxDataArray other; got type(other)={type(other)}") if self.uxgrid != other.uxgrid: - raise GridsMismatchError("Both UxDataArrays must have the same grid") + raise GridsMismatchError( + "Both vector components must be on the same grid " + "during u.divergence(v), but got u.uxgrid != v.uxgrid." + ) if self.dims != other.dims: - raise DimensionError("Both UxDataArrays must have the same dimensions") + raise DimensionError( + "Both vector components must have the same dimensions during u.divergence(v), " + f"but got u.dims={self.dims}, v.dims={other.dims}" + ) if self.ndim > 1: 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')`)." + "divergence() computation currently only supports 1-dimensional data; " + f"got data.dims={self.dims}. Consider reducing dimensionality along non-grid dimensions, " + "e.g. by applying something like .isel(time=0), .sel(lev=500), or .mean('Time')." ) if not (self._face_centered() and other._face_centered()): + _wrong_locs = [] + if not self._face_centered(): + _wrong_locs.append(f"u.data_location={self.data_location}, u.sizes={dict(**self.sizes)}") + if not other._face_centered(): + _wrong_locs.append(f"v.data_location={self.data_location}, v.sizes={dict(**self.sizes)}") raise DataCenteringError( - "Computing the divergence is only supported for face-centered data variables." + "u.divergence(v) is only supported for face_centered data; got " + + ', '.join(_wrong_locs) ) # Compute gradients of both components @@ -1815,26 +1855,41 @@ def scalardotgradient(self, v: "UxDataArray", q: "UxDataArray") -> "UxDataArray" Dot product ``self * dq/dx + v * dq/dy``. """ if not isinstance(v, UxDataArray): - raise TypeError("v must be a UxDataArray") + raise TypeError(f"u.scalardotgradient(v, q) expected UxDataArray v; got type(v)={type(v)}") if not isinstance(q, UxDataArray): - raise TypeError("q must be a UxDataArray") + raise TypeError(f"u.scalardotgradient(v, q) expected UxDataArray q; got type(q)={type(q)}") if self.uxgrid != v.uxgrid or self.uxgrid != q.uxgrid: - raise GridsMismatchError("All UxDataArrays must have the same grid") + raise GridsMismatchError( + "All UxDataArrays must have the same grid during u.scalardotgradient(v, q), " + "but u.uxgrid, v.uxgrid, and q.uxgrid are not all the same." + ) if self.dims != v.dims or self.dims != q.dims: - raise DimensionError("All UxDataArrays must have the same dimensions") + raise DimensionError( + "All UxDataArrays must have the same dimensions during during u.scalardotgradient(v, q), " + f"but got u.dims={u.dims}, v.dims={v.dims}, q.dims={q.dims}." + ) if self.ndim > 1: raise DimensionError( - "Scalar dot gradient currently requires 1D face-centered data. " - "Consider selecting a single slice before computing." + "scalardotgradient() computation currently only supports 1-dimensional data; " + f"got data.dims={self.dims}. Consider reducing dimensionality along non-grid dimensions, " + "e.g. by applying something like .isel(time=0), .sel(lev=500), or .mean('Time')." ) if not (self._face_centered() and v._face_centered() and q._face_centered()): + _wrong_locs = [] + if not self._face_centered(): + _wrong_locs.append(f"u.data_location={self.data_location}, u.sizes={dict(**self.sizes)}") + if not v._face_centered(): + _wrong_locs.append(f"v.data_location={self.data_location}, v.sizes={dict(**self.sizes)}") + if not q._face_centered(): + _wrong_locs.append(f"q.data_location={self.data_location}, q.sizes={dict(**self.sizes)}") raise DataCenteringError( - "Computing the scalar dot gradient is only supported for face-centered data variables." + "u.scalardotgradient(v, q) is only supported for face_centered data; got " + + ', '.join(_wrong_locs) ) # Validate coordinate alignment up-front so a misaligned input fails @@ -1897,13 +1952,11 @@ def difference(self, destination: str | None = "edge"): name = f"{var_name}edge_face_difference" elif destination == "face": raise DataCenteringError( - "Invalid destination 'face' for a face-centered data variable, computing" - "the difference and storing it on each face is not possible" + "difference() for face_centered data does not permit destination='face'." ) elif destination == "node": raise DataCenteringError( - "Support for computing the difference of a face-centered data variable and storing" - "the result on each node not yet supported." + "difference() for face_centered data with destination='node' is not yet supported." ) elif self._node_centered(): @@ -1915,23 +1968,22 @@ def difference(self, destination: str | None = "edge"): name = f"{var_name}edge_node_difference" elif destination == "node": raise DataCenteringError( - "Invalid destination 'node' for a node-centered data variable, computing" - "the difference and storing it on each node is not possible" + "difference() for node_centered data does not permit destination='node'." ) elif destination == "face": raise DataCenteringError( - "Support for computing the difference of a node-centered data variable and storing" - "the result on each face not yet supported." + "difference() for node_centered data with destination='face' is not yet supported." ) elif self._edge_centered(): - raise NotImplementedError( - "Difference for edge centered data variables not yet implemented" - ) + raise NotImplementedError("difference() for edge_centered data") else: - raise DataCenteringError("TODO: ") + raise DataCenteringError( + "Expected face_centered, node_centered, or edge_centered data; " + f"got data at data_location={self.data_location}, in difference()" + ) uxda = UxDataArray( _difference, @@ -2119,12 +2171,13 @@ def from_healpix( """ if not isinstance(da, xr.DataArray): - raise ValueError("`da` must be a xr.DataArray") + raise TypeError(f"UxDataArray.from_healpix(da) expected xr.DataArray da, got type(da)={type(da)}") if face_dim not in da.dims: 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." + f"face_dim={face_dim!r} is not present in the provided array, which has dims {da.dims}. " + "Please set face_dim to the dimension corresponding to the HEALPix face mapping " + "(typically 'cell', but could be something else)." ) # Attach a HEALPix Grid @@ -2157,7 +2210,8 @@ def _slice_from_grid(self, sliced_grid): else: raise DataCenteringError( - "Data variable must be either node, edge, or face centered." + "Expected face_centered, node_centered, or edge_centered data; " + f"got data at data_location={self.data_location}, in _slice_from_grid()" ) return UxDataArray(da_sliced, uxgrid=sliced_grid) @@ -2173,7 +2227,7 @@ def get_dual(self): """ if _check_duplicate_nodes_indices(self.uxgrid): - raise GridInvalidError("Duplicate nodes found, cannot construct dual") + raise GridInvalidError("Duplicate nodes found in UxDataArray's uxgrid; cannot get_dual()") if self.uxgrid.partial_sphere_coverage: warn( diff --git a/uxarray/core/dataset.py b/uxarray/core/dataset.py index 41194dfe2..9be0b0853 100644 --- a/uxarray/core/dataset.py +++ b/uxarray/core/dataset.py @@ -369,8 +369,9 @@ def from_healpix( if face_dim not in ds.dims: 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." + f"face_dim={face_dim!r} is not present in the provided dataset, which has dims {ds.dims}. " + "Please set face_dim to the dimension corresponding to the HEALPix face mapping " + "(typically 'cell', but could be something else)." ) # Attach a HEALPix Grid @@ -708,7 +709,7 @@ def get_dual(self): """ if _check_duplicate_nodes_indices(self.uxgrid): - raise GridInvalidError("Duplicate nodes found, cannot construct dual") + raise GridInvalidError("Duplicate nodes found in UxDataset's uxgrid; cannot get_dual()") if self.uxgrid.partial_sphere_coverage: warn( diff --git a/uxarray/core/gradient.py b/uxarray/core/gradient.py index 30e2537d1..c23755eb2 100644 --- a/uxarray/core/gradient.py +++ b/uxarray/core/gradient.py @@ -98,9 +98,9 @@ def _compute_gradient(data, scale_by_radius=True): if data.ndim > 1: 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')`). " + "divergence() computation currently only supports 1-dimensional data; " + f"got data.dims={data.dims}. Consider reducing dimensionality along non-grid dimensions, " + "e.g. by applying something like .isel(time=0), .sel(lev=500), or .mean('Time')." ) if data._face_centered(): @@ -186,7 +186,8 @@ def _compute_gradient(data, scale_by_radius=True): # ) else: raise DataCenteringError( - "Computing the gradient is only supported for face-centered data variables." + "_compute_gradient(data) is only supported for face_centered data; got " + f"data.data_location={data.data_location}, data.sizes={dict(**data.sizes)}" ) has_sphere_radius = "sphere_radius" in uxgrid._ds.attrs diff --git a/uxarray/core/zonal.py b/uxarray/core/zonal.py index a7ac0e453..956fae32a 100644 --- a/uxarray/core/zonal.py +++ b/uxarray/core/zonal.py @@ -253,7 +253,10 @@ def _compute_face_band_weights(uxgrid, bands): """ bands = np.asarray(bands, dtype=float) if bands.ndim != 1 or bands.size < 2: - raise DimensionError("bands must be 1D with at least two edges") + raise DimensionError( + "bands must be 1D with at least two values; " + f"got bands with ndim={bands.ndim}, size={bands.size}." + ) if np.any(np.diff(bands) < 0): raise ValueError( f"bands must be monotonic non-decreasing; got diff(bands)={np.diff(bands)}" From c2a9afdfa8fa6bb0ddffaf6a6fbb7072523f6079 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:22:04 -0400 Subject: [PATCH 2/3] update test suite error message match strings --- test/core/test_api.py | 4 ++-- test/core/test_vector_calculus.py | 4 ++-- test/grid/integrate/test_zonal.py | 10 +++++----- test/test_plot.py | 2 +- uxarray/core/dataarray.py | 4 ++-- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/test/core/test_api.py b/test/core/test_api.py index e9de19936..64da0d7f2 100644 --- a/test/core/test_api.py +++ b/test/core/test_api.py @@ -75,7 +75,7 @@ def test_open_dataset_single_argument_rejects_directory_grid(tmp_path): """Requires a separate data file for directory-based grids.""" with pytest.raises( - ValueError, match="single directory argument is not supported" + ValueError, match="single directory is not supported" ): ux.open_dataset(tmp_path) @@ -282,5 +282,5 @@ def test_open_multigrid_missing_grid_error(gridpath): """Requesting a missing grid should raise.""" grid_file = gridpath("scrip", "oasis", "grids.nc") - with pytest.raises(ValueError, match="Grid 'land' not found"): + with pytest.raises(ValueError, match="grid 'land' not found in the provided file"): ux.open_multigrid(grid_file, gridnames=["land"]) diff --git a/test/core/test_vector_calculus.py b/test/core/test_vector_calculus.py index aad4c78bc..a866d10bb 100644 --- a/test/core/test_vector_calculus.py +++ b/test/core/test_vector_calculus.py @@ -194,7 +194,7 @@ def test_divergence_input_validation(self, gridpath, datasetpath): u_component = uxds['t2m'] # Test with non-UxDataArray - with pytest.raises(TypeError, match="other must be a UxDataArray"): + with pytest.raises(TypeError, match=r"u.divergence\(v\) expected UxDataArray v; got type\(v\)="): u_component.divergence(np.array([1, 2, 3])) # Test with different grids (create a simple test case) @@ -455,7 +455,7 @@ def test_curl_input_validation(self, gridpath, datasetpath): u_component = uxds['t2m'] # Test with non-UxDataArray - with pytest.raises(TypeError, match="other must be a UxDataArray"): + with pytest.raises(TypeError, match=r"u.curl\(v\) expected UxDataArray v; got type\(v\)="): u_component.curl(np.array([1, 2, 3])) # Test with different grids (create a simple test case) diff --git a/test/grid/integrate/test_zonal.py b/test/grid/integrate/test_zonal.py index 8e12979c4..73b69d72a 100644 --- a/test/grid/integrate/test_zonal.py +++ b/test/grid/integrate/test_zonal.py @@ -201,11 +201,11 @@ def test_conservative_step_size_validation(self, gridpath, datasetpath): uxds = ux.open_dataset(grid_path, data_path) # Test negative step size - with pytest.raises(ValueError, match="Step size must be positive"): + with pytest.raises(ValueError, match="Expected step>0"): uxds["psi"].zonal_mean(lat=(-90, 90, -10), conservative=True) # Test zero step size - with pytest.raises(ValueError, match="Step size must be positive"): + with pytest.raises(ValueError, match="Expected step>0"): uxds["psi"].zonal_mean(lat=(-90, 90, 0), conservative=True) def test_conservative_full_sphere_conservation(self, gridpath, datasetpath): @@ -381,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(DataCenteringError, match="face-centered"): + with pytest.raises(DataCenteringError, match="non-face_centered data is not currently supported"): uxda.zonal_anomaly() def test_invalid_lat_input_raises(self): @@ -390,9 +390,9 @@ def test_invalid_lat_input_raises(self): uxda = ux.UxDataArray( np.zeros(uxgrid.n_face), dims=["n_face"], uxgrid=uxgrid ) - with pytest.raises(ValueError, match="Step size"): + with pytest.raises(ValueError, match="Expected step>0"): uxda.zonal_anomaly(lat=(-90, 90, 0)) - with pytest.raises(ValueError, match="Step size"): + with pytest.raises(ValueError, match="Expected step>0"): uxda.zonal_anomaly(lat=(-90, 90, -1)) with pytest.raises(ValueError): uxda.zonal_anomaly(lat=[42.0]) # too few edges diff --git a/test/test_plot.py b/test/test_plot.py index 24cf9de43..f5d5148c0 100644 --- a/test/test_plot.py +++ b/test/test_plot.py @@ -188,7 +188,7 @@ def test_to_raster_reuse_mapping(gridpath, tmpdir): # Modified pixel mapping raises error pixel_mapping.attrs["ax_shape"] = (2, 3) - with pytest.raises(ValueError, match=r"Pixel mapping incompatible with ax\. shape \(2, 3\) !="): + with pytest.raises(ValueError, match=r"Provided pixel_mapping values incompatible with ax raster attrs: shape \(2, 3\) !="): _ = uxds['bottomDepth'].to_raster( ax=ax, pixel_mapping=pixel_mapping ) diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index b1af124c5..179b8c277 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -1668,7 +1668,7 @@ def curl( """ # Input validation if not isinstance(other, UxDataArray): - raise TypeError(f"UxDataArray.curl(other) expected UxDataArray other; got type(other)={type(other)}") + raise TypeError(f"u.curl(v) expected UxDataArray v; got type(v)={type(other)}") if self.uxgrid != other.uxgrid: raise GridsMismatchError( @@ -1766,7 +1766,7 @@ def divergence( >>> div_field = u_component.divergence(v_component) """ if not isinstance(other, UxDataArray): - raise TypeError(f"UxDataArray.divergence(other) expected UxDataArray other; got type(other)={type(other)}") + raise TypeError(f"u.divergence(v) expected UxDataArray v; got type(v)={type(other)}") if self.uxgrid != other.uxgrid: raise GridsMismatchError( From e729fe634e64183a6cb0e565e1dc67dc26e36f20 Mon Sep 17 00:00:00 2001 From: Sam Evans <47793072+Sevans711@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:23:48 -0400 Subject: [PATCH 3/3] apply ruff formatting --- uxarray/core/accessors.py | 4 ++- uxarray/core/dataarray.py | 62 ++++++++++++++++++++++++++++----------- uxarray/core/dataset.py | 4 ++- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/uxarray/core/accessors.py b/uxarray/core/accessors.py index 4d27d917c..d062e1298 100644 --- a/uxarray/core/accessors.py +++ b/uxarray/core/accessors.py @@ -119,7 +119,9 @@ def wrapped(*args, **kwargs): def _process_result(self, result): """Process method results to preserve uxgrid. To be overridden by subclasses.""" - raise NotImplementedError(f"_process_result, for BaseAccessor subclass {type(self).__name__}") + raise NotImplementedError( + f"_process_result, for BaseAccessor subclass {type(self).__name__}" + ) # Delegation for common dunder methods def __iter__(self): diff --git a/uxarray/core/dataarray.py b/uxarray/core/dataarray.py index 179b8c277..2f6e1a798 100644 --- a/uxarray/core/dataarray.py +++ b/uxarray/core/dataarray.py @@ -724,7 +724,9 @@ def zonal_mean(self, lat=(-90, 90, 10), conservative: bool = False, **kwargs): if isinstance(lat, tuple): start, end, step = lat if step <= 0: - raise ValueError(f"Expected step>0 when lat=(min_lat, max_lat, step); got step={step}") + raise ValueError( + f"Expected step>0 when lat=(min_lat, max_lat, step); got step={step}" + ) if step < 0.1: warnings.warn( f"Very small step size ({step}°) may lead to performance issues...", @@ -771,7 +773,9 @@ def zonal_mean(self, lat=(-90, 90, 10), conservative: bool = False, **kwargs): if isinstance(lat, tuple): start, end, step = lat if step <= 0: - raise ValueError(f"Expected step>0 when lat=(min_lat, max_lat, step); got step={step}") + raise ValueError( + f"Expected step>0 when lat=(min_lat, max_lat, step); got step={step}" + ) if step < 0.1: warnings.warn( f"Very small step size ({step}°) may lead to performance issues...", @@ -874,7 +878,9 @@ def zonal_anomaly(self, lat=(-90, 90, 10), conservative: bool = False): if isinstance(lat, tuple): start, end, step = lat if step <= 0: - raise ValueError(f"Expected step>0 when lat=(min_lat, max_lat, step); got step={step}") + raise ValueError( + f"Expected step>0 when lat=(min_lat, max_lat, step); got step={step}" + ) num_points = int(round((end - start) / step)) + 1 edges = np.linspace(start, end, num_points) edges = np.clip(edges, -90, 90) @@ -1668,7 +1674,9 @@ def curl( """ # Input validation if not isinstance(other, UxDataArray): - raise TypeError(f"u.curl(v) expected UxDataArray v; got type(v)={type(other)}") + raise TypeError( + f"u.curl(v) expected UxDataArray v; got type(v)={type(other)}" + ) if self.uxgrid != other.uxgrid: raise GridsMismatchError( @@ -1766,7 +1774,9 @@ def divergence( >>> div_field = u_component.divergence(v_component) """ if not isinstance(other, UxDataArray): - raise TypeError(f"u.divergence(v) expected UxDataArray v; got type(v)={type(other)}") + raise TypeError( + f"u.divergence(v) expected UxDataArray v; got type(v)={type(other)}" + ) if self.uxgrid != other.uxgrid: raise GridsMismatchError( @@ -1790,12 +1800,16 @@ def divergence( if not (self._face_centered() and other._face_centered()): _wrong_locs = [] if not self._face_centered(): - _wrong_locs.append(f"u.data_location={self.data_location}, u.sizes={dict(**self.sizes)}") + _wrong_locs.append( + f"u.data_location={self.data_location}, u.sizes={dict(**self.sizes)}" + ) if not other._face_centered(): - _wrong_locs.append(f"v.data_location={self.data_location}, v.sizes={dict(**self.sizes)}") + _wrong_locs.append( + f"v.data_location={self.data_location}, v.sizes={dict(**self.sizes)}" + ) raise DataCenteringError( "u.divergence(v) is only supported for face_centered data; got " - + ', '.join(_wrong_locs) + + ", ".join(_wrong_locs) ) # Compute gradients of both components @@ -1855,10 +1869,14 @@ def scalardotgradient(self, v: "UxDataArray", q: "UxDataArray") -> "UxDataArray" Dot product ``self * dq/dx + v * dq/dy``. """ if not isinstance(v, UxDataArray): - raise TypeError(f"u.scalardotgradient(v, q) expected UxDataArray v; got type(v)={type(v)}") + raise TypeError( + f"u.scalardotgradient(v, q) expected UxDataArray v; got type(v)={type(v)}" + ) if not isinstance(q, UxDataArray): - raise TypeError(f"u.scalardotgradient(v, q) expected UxDataArray q; got type(q)={type(q)}") + raise TypeError( + f"u.scalardotgradient(v, q) expected UxDataArray q; got type(q)={type(q)}" + ) if self.uxgrid != v.uxgrid or self.uxgrid != q.uxgrid: raise GridsMismatchError( @@ -1869,7 +1887,7 @@ def scalardotgradient(self, v: "UxDataArray", q: "UxDataArray") -> "UxDataArray" if self.dims != v.dims or self.dims != q.dims: raise DimensionError( "All UxDataArrays must have the same dimensions during during u.scalardotgradient(v, q), " - f"but got u.dims={u.dims}, v.dims={v.dims}, q.dims={q.dims}." + f"but got u.dims={self.dims}, v.dims={v.dims}, q.dims={q.dims}." ) if self.ndim > 1: @@ -1882,14 +1900,20 @@ def scalardotgradient(self, v: "UxDataArray", q: "UxDataArray") -> "UxDataArray" if not (self._face_centered() and v._face_centered() and q._face_centered()): _wrong_locs = [] if not self._face_centered(): - _wrong_locs.append(f"u.data_location={self.data_location}, u.sizes={dict(**self.sizes)}") + _wrong_locs.append( + f"u.data_location={self.data_location}, u.sizes={dict(**self.sizes)}" + ) if not v._face_centered(): - _wrong_locs.append(f"v.data_location={self.data_location}, v.sizes={dict(**self.sizes)}") + _wrong_locs.append( + f"v.data_location={self.data_location}, v.sizes={dict(**self.sizes)}" + ) if not q._face_centered(): - _wrong_locs.append(f"q.data_location={self.data_location}, q.sizes={dict(**self.sizes)}") + _wrong_locs.append( + f"q.data_location={self.data_location}, q.sizes={dict(**self.sizes)}" + ) raise DataCenteringError( "u.scalardotgradient(v, q) is only supported for face_centered data; got " - + ', '.join(_wrong_locs) + + ", ".join(_wrong_locs) ) # Validate coordinate alignment up-front so a misaligned input fails @@ -2171,7 +2195,9 @@ def from_healpix( """ if not isinstance(da, xr.DataArray): - raise TypeError(f"UxDataArray.from_healpix(da) expected xr.DataArray da, got type(da)={type(da)}") + raise TypeError( + f"UxDataArray.from_healpix(da) expected xr.DataArray da, got type(da)={type(da)}" + ) if face_dim not in da.dims: raise DimensionError( @@ -2227,7 +2253,9 @@ def get_dual(self): """ if _check_duplicate_nodes_indices(self.uxgrid): - raise GridInvalidError("Duplicate nodes found in UxDataArray's uxgrid; cannot get_dual()") + raise GridInvalidError( + "Duplicate nodes found in UxDataArray's uxgrid; cannot get_dual()" + ) if self.uxgrid.partial_sphere_coverage: warn( diff --git a/uxarray/core/dataset.py b/uxarray/core/dataset.py index 9be0b0853..c1e542ffd 100644 --- a/uxarray/core/dataset.py +++ b/uxarray/core/dataset.py @@ -709,7 +709,9 @@ def get_dual(self): """ if _check_duplicate_nodes_indices(self.uxgrid): - raise GridInvalidError("Duplicate nodes found in UxDataset's uxgrid; cannot get_dual()") + raise GridInvalidError( + "Duplicate nodes found in UxDataset's uxgrid; cannot get_dual()" + ) if self.uxgrid.partial_sphere_coverage: warn(