From 92f51a9732ca74ebc102dfa34216583bbf54fe3b Mon Sep 17 00:00:00 2001 From: Vo Date: Thu, 23 Jul 2026 15:49:14 -0700 Subject: [PATCH 1/2] Add dataset validation utilities --- docs/api.rst | 23 ++ docs/getting-started-guide/faqs.rst | 24 ++ tests/test_validation.py | 262 ++++++++++++++++++++ xcdat/__init__.py | 6 + xcdat/validation.py | 367 ++++++++++++++++++++++++++++ 5 files changed, 682 insertions(+) create mode 100644 tests/test_validation.py create mode 100644 xcdat/validation.py diff --git a/docs/api.rst b/docs/api.rst index 987762b3..2f57f3f7 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -25,6 +25,7 @@ Below is a list of top-level API functions that are available in ``xcdat``. decode_time swap_lon_axis compare_datasets + validate_dataset get_dim_coords get_dim_keys get_coords_by_name @@ -39,6 +40,28 @@ Below is a list of top-level API functions that are available in ``xcdat``. create_zonal_grid tutorial.open_dataset +Dataset Validation +------------------ + +``validate_dataset`` inspects CF metadata, axis mappings, and coordinate bounds +without modifying the input dataset. It reports malformed or contradictory +metadata as errors and missing metadata that xCDAT may be able to infer or +generate as warnings. Each issue includes the operations that may be affected. + +.. code-block:: python + + >>> result = xcdat.validate_dataset(ds) + >>> for issue in result.issues: + ... print(issue.severity, issue.variable, issue.problem) + >>> result.raise_for_errors() + +.. autosummary:: + :toctree: generated/ + + ValidationIssue + ValidationResult + DatasetValidationError + Module-level API Functions -------------------------- diff --git a/docs/getting-started-guide/faqs.rst b/docs/getting-started-guide/faqs.rst index 8b361616..a92ac970 100644 --- a/docs/getting-started-guide/faqs.rst +++ b/docs/getting-started-guide/faqs.rst @@ -56,6 +56,30 @@ What CF attributes are interpreted using ``cf_xarray`` mapping tables? .. _Coordinate Names: https://cf-xarray.readthedocs.io/en/latest/coord_axes.html#coordinate-names .. _Bounds Variables: https://cf-xarray.readthedocs.io/en/latest/bounds.html +How can I validate a dataset before using xCDAT operations? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use ``xcdat.validate_dataset()`` to inspect CF metadata, axis mappings, and +coordinate bounds without changing the dataset: + +.. code-block:: python + + >>> result = xcdat.validate_dataset(ds) + >>> for issue in result.issues: + ... print(issue.severity, issue.variable, issue.problem) + +Generic validation treats malformed or contradictory existing metadata as +errors. Missing metadata that xCDAT may be able to infer or generate is reported +as a warning. Raise one exception containing all errors when desired: + +.. code-block:: python + + >>> result.raise_for_errors() + +Each issue identifies the affected variable, likely affected operations, and a +suggested action. Validation only diagnoses problems; users remain responsible +for correcting source metadata or explicitly generating missing bounds. + Handling Bounds --------------- diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 00000000..65373617 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,262 @@ +import numpy as np +import pytest +import xarray as xr +import xarray.testing as xrt + +import xcdat as xc +from xcdat.validation import ( + DatasetValidationError, + ValidationIssue, + ValidationResult, + validate_dataset, +) + + +def _create_rectilinear_dataset(with_bounds: bool = True) -> xr.Dataset: + lat_attrs = { + "axis": "Y", + "standard_name": "latitude", + "units": "degrees_north", + } + lon_attrs = { + "axis": "X", + "standard_name": "longitude", + "units": "degrees_east", + } + if with_bounds: + lat_attrs["bounds"] = "lat_bnds" + lon_attrs["bounds"] = "lon_bnds" + + ds = xr.Dataset( + {"tas": (("lat", "lon"), np.ones((2, 3)))}, + coords={ + "lat": xr.DataArray([-30.0, 30.0], dims="lat", attrs=lat_attrs), + "lon": xr.DataArray([0.0, 120.0, 240.0], dims="lon", attrs=lon_attrs), + }, + ) + if with_bounds: + ds["lat_bnds"] = xr.DataArray([[-60.0, 0.0], [0.0, 60.0]], dims=("lat", "bnds")) + ds["lon_bnds"] = xr.DataArray( + [[-60.0, 60.0], [60.0, 180.0], [180.0, 300.0]], + dims=("lon", "bnds"), + ) + + return ds + + +class TestValidationResult: + def test_returns_structured_empty_result_for_valid_dataset(self): + result = validate_dataset(_create_rectilinear_dataset()) + + assert result == ValidationResult(()) + assert result.errors == () + assert result.warnings == () + assert result.is_valid + result.raise_for_errors() + + def test_warning_only_result_is_valid(self): + result = validate_dataset(_create_rectilinear_dataset(with_bounds=False)) + + assert result.errors == () + assert len(result.warnings) == 2 + assert result.is_valid + result.raise_for_errors() + + def test_raise_for_errors_aggregates_error_diagnostics(self): + issues = ( + ValidationIssue( + code="first-error", + severity="error", + variable="lat", + problem="Latitude metadata is invalid.", + operations=("spatial_average",), + suggestion="Correct the metadata.", + ), + ValidationIssue( + code="second-error", + severity="error", + variable="lon", + problem="Longitude metadata is invalid.", + operations=("horizontal_regrid",), + suggestion="Correct the metadata.", + ), + ) + result = ValidationResult(issues) + + with pytest.raises(DatasetValidationError) as exc_info: + result.raise_for_errors() + + assert exc_info.value.issues == issues + assert "[first-error] lat" in str(exc_info.value) + assert "[second-error] lon" in str(exc_info.value) + + def test_orders_issues_deterministically(self): + result = validate_dataset(_create_rectilinear_dataset(with_bounds=False)) + + issue_keys = [(issue.code, issue.variable) for issue in result.issues] + assert issue_keys == sorted(issue_keys) + + +def test_rejects_non_dataset(): + with pytest.raises(TypeError, match="xarray.Dataset"): + validate_dataset(xr.DataArray([1])) # type: ignore[arg-type] + + +class TestAxisValidation: + def test_warns_for_common_coordinate_name_without_cf_metadata(self): + ds = _create_rectilinear_dataset() + ds.lon.attrs = {"bounds": "lon_bnds"} + + result = validate_dataset(ds) + + issue = next( + issue for issue in result.issues if issue.code == "missing-cf-axis-metadata" + ) + assert issue.variable == "lon" + assert issue.severity == "warning" + assert issue.operations == ("spatial_average", "horizontal_regrid") + + def test_errors_for_conflicting_cf_axis_metadata(self): + ds = xr.Dataset( + {"var": ("coord", [1.0, 2.0])}, + coords={ + "coord": xr.DataArray( + [0.0, 1.0], + dims="coord", + attrs={"axis": "X", "standard_name": "latitude"}, + ) + }, + ) + + result = validate_dataset(ds) + + assert any( + issue.code == "conflicting-axis-metadata" and issue.variable == "coord" + for issue in result.errors + ) + + def test_errors_for_name_fallback_conflict(self): + ds = xr.Dataset( + {"tas": ("lat", [1.0, 2.0])}, + coords={ + "lat": xr.DataArray( + [-30.0, 30.0], + dims="lat", + attrs={"axis": "X"}, + ) + }, + ) + + result = validate_dataset(ds) + + assert any( + issue.code == "conflicting-axis-metadata" and issue.variable == "lat" + for issue in result.errors + ) + + def test_errors_for_multiple_dimension_coordinates_on_one_axis(self): + ds = xr.Dataset( + {"var": (("lat", "tau"), np.ones((2, 3)))}, + coords={ + "lat": xr.DataArray([-30.0, 30.0], dims="lat", attrs={"axis": "Y"}), + "tau": xr.DataArray([0.0, 0.5, 1.0], dims="tau", attrs={"axis": "Y"}), + }, + ) + + result = validate_dataset(ds) + + issue = next(issue for issue in result.errors if issue.code == "ambiguous-axis") + assert issue.variable == "var" + + def test_reports_multiple_ambiguous_axes_for_one_variable(self): + ds = xr.Dataset( + {"var": (("x1", "x2", "y1", "y2"), np.ones((2, 2, 2, 2)))}, + coords={ + "x1": xr.DataArray([0.0, 1.0], dims="x1", attrs={"axis": "X"}), + "x2": xr.DataArray([0.0, 1.0], dims="x2", attrs={"axis": "X"}), + "y1": xr.DataArray([0.0, 1.0], dims="y1", attrs={"axis": "Y"}), + "y2": xr.DataArray([0.0, 1.0], dims="y2", attrs={"axis": "Y"}), + }, + ) + + result = validate_dataset(ds) + + issues = [ + issue + for issue in result.errors + if issue.code == "ambiguous-axis" and issue.variable == "var" + ] + assert len(issues) == 2 + assert "'X' axis" in issues[0].problem + assert "'Y' axis" in issues[1].problem + + +class TestBoundsValidation: + def test_reports_missing_bounds_as_warning(self): + result = validate_dataset(_create_rectilinear_dataset(with_bounds=False)) + + issues = [issue for issue in result.issues if issue.code == "missing-bounds"] + assert {issue.variable for issue in issues} == {"lat", "lon"} + assert all(issue.severity == "warning" for issue in issues) + + def test_errors_if_bounds_attribute_references_missing_variable(self): + ds = _create_rectilinear_dataset().drop_vars("lat_bnds") + + result = validate_dataset(ds) + + assert any( + issue.code == "missing-bounds-variable" and issue.variable == "lat" + for issue in result.errors + ) + + def test_errors_if_bounds_omit_coordinate_dimensions(self): + ds = _create_rectilinear_dataset().drop_vars("lat_bnds") + ds["lat_bnds"] = xr.DataArray(np.ones((2, 2)), dims=("other", "bnds")) + + result = validate_dataset(ds) + + assert any( + issue.code == "bounds-missing-coordinate-dimensions" + and issue.variable == "lat_bnds" + for issue in result.errors + ) + + def test_errors_for_unrelated_bounds_dimensions(self): + ds = _create_rectilinear_dataset().drop_vars("lat_bnds") + ds["lat_bnds"] = xr.DataArray(np.ones((2, 3, 2)), dims=("lat", "time", "bnds")) + + result = validate_dataset(ds) + + assert any( + issue.code == "malformed-bounds-dimensions" and issue.variable == "lat_bnds" + for issue in result.errors + ) + + def test_errors_for_invalid_bounds_vertex_count(self): + ds = _create_rectilinear_dataset().drop_vars("lat_bnds") + ds["lat_bnds"] = xr.DataArray(np.ones((2, 3)), dims=("lat", "vertices")) + + result = validate_dataset(ds) + + assert any( + issue.code == "invalid-bounds-vertex-count" and issue.variable == "lat_bnds" + for issue in result.errors + ) + + +def test_validation_does_not_modify_dataset(): + ds = _create_rectilinear_dataset(with_bounds=False) + original = ds.copy(deep=True) + + validate_dataset(ds) + + xrt.assert_identical(ds, original) + + +def test_validation_api_is_exported_from_top_level(): + assert xc.validate_dataset is validate_dataset + assert xc.ValidationIssue is ValidationIssue + assert xc.ValidationResult is ValidationResult + assert xc.DatasetValidationError is DatasetValidationError + assert not hasattr(xc, "ValidationOperation") + assert not hasattr(xc, "ValidationSeverity") diff --git a/xcdat/__init__.py b/xcdat/__init__.py index 99c2fad6..9a71e7b3 100644 --- a/xcdat/__init__.py +++ b/xcdat/__init__.py @@ -25,6 +25,12 @@ from xcdat.spatial import SpatialAccessor # noqa: F401 from xcdat.temporal import TemporalAccessor # noqa: F401 from xcdat.utils import compare_datasets # noqa: F401 +from xcdat.validation import ( # noqa: F401 + DatasetValidationError, + ValidationIssue, + ValidationResult, + validate_dataset, +) # Initialize xCDAT logger once when the package is imported _setup_xcdat_logger() diff --git a/xcdat/validation.py b/xcdat/validation.py new file mode 100644 index 00000000..4a9ce217 --- /dev/null +++ b/xcdat/validation.py @@ -0,0 +1,367 @@ +"""Dataset validation utilities.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Literal + +import cf_xarray # noqa: F401 +import xarray as xr + +from xcdat.axis import CFAxisKey, CF_ATTR_MAP, VAR_NAME_MAP + +_Severity = Literal["error", "warning"] +_AFFECTED_OPERATIONS = ( + "spatial_average", + "temporal", + "horizontal_regrid", + "vertical_regrid", +) +_AXIS_OPERATIONS: dict[CFAxisKey, tuple[str, ...]] = { + "X": ("spatial_average", "horizontal_regrid"), + "Y": ("spatial_average", "horizontal_regrid"), + "T": ("temporal",), + "Z": ("vertical_regrid",), +} + + +@dataclass(frozen=True) +class ValidationIssue: + """A dataset compatibility issue. + + Parameters + ---------- + code : str + Stable identifier for the detected problem. + severity : {"error", "warning"} + Problem severity. + variable : str + Affected variable or coordinate. + problem : str + Description of the detected problem. + operations : tuple[str, ...] + xCDAT operations that may be affected. + suggestion : str + Suggested user action. + """ + + code: str + severity: _Severity + variable: str + problem: str + operations: tuple[str, ...] + suggestion: str + + +@dataclass(frozen=True) +class ValidationResult: + """Results returned by :func:`validate_dataset`. + + Parameters + ---------- + issues : tuple[ValidationIssue, ...] + Detected validation issues. + """ + + issues: tuple[ValidationIssue, ...] + + @property + def errors(self) -> tuple[ValidationIssue, ...]: + """Return issues classified as errors.""" + return tuple(issue for issue in self.issues if issue.severity == "error") + + @property + def warnings(self) -> tuple[ValidationIssue, ...]: + """Return issues classified as warnings.""" + return tuple(issue for issue in self.issues if issue.severity == "warning") + + @property + def is_valid(self) -> bool: + """Return whether validation found no errors.""" + return not self.errors + + def raise_for_errors(self) -> None: + """Raise an exception containing all validation errors. + + Raises + ------ + DatasetValidationError + If one or more validation errors were found. + """ + if self.errors: + raise DatasetValidationError(self.errors) + + +class DatasetValidationError(ValueError): + """Error raised for invalid xCDAT dataset metadata.""" + + def __init__(self, issues: tuple[ValidationIssue, ...]): + self.issues = issues + details = "\n".join( + f"- [{issue.code}] {issue.variable}: {issue.problem}" for issue in issues + ) + super().__init__(f"Dataset validation failed:\n{details}") + + +class _IssueCollector: + """Collect and merge validation issues.""" + + def __init__(self) -> None: + self._issues: dict[tuple[str, str, str], ValidationIssue] = {} + + def add( + self, + code: str, + severity: _Severity, + variable: str, + problem: str, + operations: Iterable[str], + suggestion: str, + ) -> None: + key = (code, variable, problem) + current = self._issues.get(key) + operation_set = set(operations) + + if current is not None: + operation_set.update(current.operations) + + ordered_operations = tuple( + operation + for operation in _AFFECTED_OPERATIONS + if operation in operation_set + ) + self._issues[key] = ValidationIssue( + code=code, + severity=severity, + variable=variable, + problem=problem, + operations=ordered_operations, + suggestion=suggestion, + ) + + def result(self) -> ValidationResult: + issues = tuple( + sorted( + self._issues.values(), + key=lambda issue: (issue.code, issue.variable, issue.problem), + ) + ) + return ValidationResult(issues) + + +def validate_dataset(ds: xr.Dataset) -> ValidationResult: + """Validate dataset coordinate and bounds metadata. + + Existing malformed or contradictory metadata is reported as an error. + Missing metadata that xCDAT may be able to generate or infer is reported as + a warning. Validation does not modify the dataset or load array data. + + Parameters + ---------- + ds : xr.Dataset + Dataset to validate. + + Returns + ------- + ValidationResult + Structured validation diagnostics. + + Raises + ------ + TypeError + If ``ds`` is not an ``xarray.Dataset``. + """ + if not isinstance(ds, xr.Dataset): + raise TypeError("The 'ds' argument must be an xarray.Dataset.") + + collector = _IssueCollector() + axis_coords = _get_axis_coords(ds) + cf_axis_coords = _get_cf_axis_coords(ds) + + _validate_axis_metadata(ds, axis_coords, cf_axis_coords, collector) + _validate_bounds(ds, axis_coords, collector) + + return collector.result() + + +def _get_cf_axis_coords(ds: xr.Dataset) -> dict[CFAxisKey, set[str]]: + result: dict[CFAxisKey, set[str]] = {axis: set() for axis in CF_ATTR_MAP} + + for axis, attrs in CF_ATTR_MAP.items(): + result[axis].update(str(key) for key in ds.cf.axes.get(attrs["axis"], [])) + result[axis].update( + str(key) for key in ds.cf.coordinates.get(attrs["coordinate"], []) + ) + + return result + + +def _get_axis_coords(ds: xr.Dataset) -> dict[CFAxisKey, set[str]]: + result = _get_cf_axis_coords(ds) + + for axis, names in VAR_NAME_MAP.items(): + result[axis].update(name for name in names if name in ds.coords) + + return result + + +def _validate_axis_metadata( + ds: xr.Dataset, + axis_coords: dict[CFAxisKey, set[str]], + cf_axis_coords: dict[CFAxisKey, set[str]], + collector: _IssueCollector, +) -> None: + conflicting_coords = _validate_axis_conflicts(axis_coords, collector) + + for axis, coords in axis_coords.items(): + for coord in coords.difference(cf_axis_coords[axis]): + if coord in conflicting_coords: + continue + collector.add( + "missing-cf-axis-metadata", + "warning", + coord, + f"Coordinate is inferred by name but is not mapped to the '{axis}' axis " + "by cf_xarray.", + _AXIS_OPERATIONS[axis], + "Set consistent CF 'axis', 'standard_name', and 'units' attributes.", + ) + + for name, data_var in ds.data_vars.items(): + for axis, coords in axis_coords.items(): + dim_coords = [ + coord + for coord in coords + if coord in ds.indexes + and ds[coord].ndim == 1 + and set(ds[coord].dims).issubset(data_var.dims) + ] + if len(dim_coords) > 1: + collector.add( + "ambiguous-axis", + "error", + str(name), + "Data variable has multiple dimension coordinates " + f"{sorted(dim_coords)} mapped to the '{axis}' axis.", + _AXIS_OPERATIONS[axis], + "Correct conflicting coordinate metadata or select one coordinate " + "system before using xCDAT operations.", + ) + + +def _validate_axis_conflicts( + axis_coords: dict[CFAxisKey, set[str]], collector: _IssueCollector +) -> set[str]: + coord_axes: dict[str, list[CFAxisKey]] = {} + for axis, coords in axis_coords.items(): + for coord in coords: + coord_axes.setdefault(coord, []).append(axis) + + conflicting_coords: set[str] = set() + for coord, axes in coord_axes.items(): + if len(axes) > 1: + conflicting_coords.add(coord) + collector.add( + "conflicting-axis-metadata", + "error", + coord, + f"Coordinate is mapped or inferred to multiple axes: {sorted(axes)}.", + (operation for axis in axes for operation in _AXIS_OPERATIONS[axis]), + "Correct the coordinate name or its conflicting CF axis attributes.", + ) + + return conflicting_coords + + +def _validate_bounds( + ds: xr.Dataset, + axis_coords: dict[CFAxisKey, set[str]], + collector: _IssueCollector, +) -> None: + coord_axes = { + coord: axis for axis, coords in axis_coords.items() for coord in coords + } + for coord_name, axis in sorted(coord_axes.items()): + coord = ds[coord_name] + bounds_name = coord.attrs.get("bounds") + operations = _AXIS_OPERATIONS[axis] + + if not isinstance(bounds_name, str) or not bounds_name.strip(): + collector.add( + "missing-bounds", + "warning", + coord_name, + "Coordinate does not reference a bounds variable.", + operations, + "Add bounds explicitly or use 'ds.bounds.add_bounds()' or " + "'ds.bounds.add_missing_bounds()'.", + ) + continue + + bounds_name = bounds_name.strip() + if bounds_name not in ds.variables: + collector.add( + "missing-bounds-variable", + "error", + coord_name, + f"Bounds attribute references missing variable '{bounds_name}'.", + operations, + "Add the referenced bounds variable or correct the 'bounds' attribute.", + ) + continue + + bounds = ds[bounds_name] + missing_dims = set(coord.dims).difference(bounds.dims) + if missing_dims: + collector.add( + "bounds-missing-coordinate-dimensions", + "error", + bounds_name, + "Bounds variable is missing coordinate dimensions " + f"{sorted(str(dim) for dim in missing_dims)}.", + operations, + "Make bounds include every dimension of the related coordinate.", + ) + continue + + mismatched_dims = [ + dim for dim in coord.dims if coord.sizes[dim] != bounds.sizes[dim] + ] + if mismatched_dims: + collector.add( + "bounds-dimension-size-mismatch", + "error", + bounds_name, + "Bounds sizes do not match coordinate sizes for dimensions " + f"{sorted(str(dim) for dim in mismatched_dims)}.", + operations, + "Make coordinate and bounds dimension sizes identical.", + ) + + extra_dims = [dim for dim in bounds.dims if dim not in coord.dims] + if len(extra_dims) != 1: + collector.add( + "malformed-bounds-dimensions", + "error", + bounds_name, + "Bounds variable must have exactly one dimension in addition to its " + f"coordinate dimensions; found {extra_dims}.", + operations, + "Remove unrelated dimensions and retain one bounds vertex dimension.", + ) + continue + + expected_vertices = 2 if coord.ndim == 1 else 4 if coord.ndim == 2 else None + if ( + expected_vertices is not None + and bounds.sizes[extra_dims[0]] != expected_vertices + ): + collector.add( + "invalid-bounds-vertex-count", + "error", + bounds_name, + f"Bounds vertex dimension has size {bounds.sizes[extra_dims[0]]}; " + f"expected {expected_vertices} for a {coord.ndim}-D coordinate.", + operations, + "Provide the CF-compatible number of vertices for the coordinate.", + ) From f93ddc4f703adb179cfc5a4983d2977f13d3e436 Mon Sep 17 00:00:00 2001 From: Vo Date: Thu, 23 Jul 2026 15:58:17 -0700 Subject: [PATCH 2/2] Add validation coverage --- tests/test_validation.py | 55 ++++++++++++++++++++++++++++++++++++++++ xcdat/validation.py | 2 +- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/tests/test_validation.py b/tests/test_validation.py index 65373617..266c8240 100644 --- a/tests/test_validation.py +++ b/tests/test_validation.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + import numpy as np import pytest import xarray as xr @@ -8,6 +10,8 @@ DatasetValidationError, ValidationIssue, ValidationResult, + _IssueCollector, + _validate_bounds, validate_dataset, ) @@ -97,6 +101,31 @@ def test_orders_issues_deterministically(self): assert issue_keys == sorted(issue_keys) +def test_issue_collector_merges_affected_operations_for_duplicate_issue(): + collector = _IssueCollector() + collector.add( + "duplicate", + "warning", + "lat", + "Duplicate problem.", + ("spatial_average",), + "Correct the metadata.", + ) + collector.add( + "duplicate", + "warning", + "lat", + "Duplicate problem.", + ("horizontal_regrid",), + "Correct the metadata.", + ) + + assert collector.result().issues[0].operations == ( + "spatial_average", + "horizontal_regrid", + ) + + def test_rejects_non_dataset(): with pytest.raises(TypeError, match="xarray.Dataset"): validate_dataset(xr.DataArray([1])) # type: ignore[arg-type] @@ -221,6 +250,32 @@ def test_errors_if_bounds_omit_coordinate_dimensions(self): for issue in result.errors ) + def test_errors_if_bounds_dimension_size_differs_from_coordinate(self): + coord = xr.DataArray( + [-30.0, 30.0], + dims="lat", + attrs={"bounds": "lat_bnds"}, + ) + bounds = xr.DataArray(np.ones((3, 2)), dims=("lat", "bnds")) + ds = MagicMock() + ds.__getitem__.side_effect = {"lat": coord, "lat_bnds": bounds}.__getitem__ + ds.variables = {"lat", "lat_bnds"} + collector = _IssueCollector() + axis_coords = { + "X": set(), + "Y": {"lat"}, + "T": set(), + "Z": set(), + } + + _validate_bounds(ds, axis_coords, collector) # type: ignore[arg-type] + + assert any( + issue.code == "bounds-dimension-size-mismatch" + and issue.variable == "lat_bnds" + for issue in collector.result().errors + ) + def test_errors_for_unrelated_bounds_dimensions(self): ds = _create_rectilinear_dataset().drop_vars("lat_bnds") ds["lat_bnds"] = xr.DataArray(np.ones((2, 3, 2)), dims=("lat", "time", "bnds")) diff --git a/xcdat/validation.py b/xcdat/validation.py index 4a9ce217..2f68cf91 100644 --- a/xcdat/validation.py +++ b/xcdat/validation.py @@ -8,7 +8,7 @@ import cf_xarray # noqa: F401 import xarray as xr -from xcdat.axis import CFAxisKey, CF_ATTR_MAP, VAR_NAME_MAP +from xcdat.axis import CF_ATTR_MAP, VAR_NAME_MAP, CFAxisKey _Severity = Literal["error", "warning"] _AFFECTED_OPERATIONS = (