Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: CI Tests

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
name: Python ${{ matrix.python-version }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
os: [ubuntu-latest, windows-latest, macos-latest]

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pytest pytest-cov nbconvert ipykernel

- name: Run tests (offline only)
run: |
pytest tests/ -v --cov=xarray_subset_grid --cov-report=xml --cov-report=term-missing
# Note: online tests (network notebooks + AWS) require --online flag.
# They are skipped here intentionally to keep CI fast and reliable.
- name: Upload coverage report
uses: codecov/codecov-action@v4
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
with:
files: ./coverage.xml
fail_ci_if_error: false
34 changes: 34 additions & 0 deletions docs/api.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
API Reference
=============

Accessor
--------

.. automodule:: xarray_subset_grid.accessor
:members:
:undoc-members:
:show-inheritance:

Grid (Base Class)
-----------------

.. automodule:: xarray_subset_grid.grid
:members:
:undoc-members:
:show-inheritance:

Selector
--------

.. automodule:: xarray_subset_grid.selector
:members:
:undoc-members:
:show-inheritance:

Utilities
---------

.. automodule:: xarray_subset_grid.utils
:members:
:undoc-members:
:show-inheritance:
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ Contents:
installation
design
grids
api
notebooks
contributing
2 changes: 2 additions & 0 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ setuptools_scm = "*"
python-build = "*"
sphinx-autodoc-typehints = "*"
myst-nb = "*"
ipykernel = "*"
nbconvert = "*"

[feature.dev.tasks]
lint = "ruff check tests xarray_subset_grid"
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ dependencies = [
]

optional-dependencies.dev = [
"ipykernel",
"nbconvert",
"pre-commit",
"pytest",
"pytest-cov",
Expand Down
158 changes: 158 additions & 0 deletions tests/test_accessor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""
Tests for the GridDatasetAccessor (xsg accessor).

These tests verify that the accessor is correctly exposed on xr.Dataset
objects and that all its methods behave correctly for both recognized
and unrecognized grid datasets.
"""
import numpy as np
import pytest
import xarray as xr
from pathlib import Path

EXAMPLE_DATA = Path(__file__).parent / "example_data"


# ------------------------------------------------------------------ #
# Fixtures
# ------------------------------------------------------------------ #

@pytest.fixture
def rgrid_ds():
"""Load a real regular-grid NetCDF file for accessor tests."""
path = EXAMPLE_DATA / "AMSEAS-subset.nc"
return xr.open_dataset(path)


@pytest.fixture
def unknown_ds():
"""A minimal dataset that no grid implementation will recognize."""
return xr.Dataset({"foo": (["x"], [1.0, 2.0, 3.0])})


# ------------------------------------------------------------------ #
# Accessor existence tests
# ------------------------------------------------------------------ #

class TestAccessorPresence:
def test_xsg_accessor_is_attached(self, rgrid_ds):
"""The xsg accessor must be available on any xr.Dataset."""
assert hasattr(rgrid_ds, "xsg")

def test_xsg_accessor_on_unknown_ds(self, unknown_ds):
"""The accessor should still be attached even if no grid is recognised."""
assert hasattr(unknown_ds, "xsg")


# ------------------------------------------------------------------ #
# Grid recognition tests
# ------------------------------------------------------------------ #

class TestGridRecognition:
def test_known_grid_is_not_none(self, rgrid_ds):
"""A CF-compliant regular-grid dataset should be recognised."""
assert rgrid_ds.xsg.grid is not None

def test_unknown_grid_is_none(self, unknown_ds):
"""An unstructured / unknown dataset should return None for grid."""
assert unknown_ds.xsg.grid is None


# ------------------------------------------------------------------ #
# Property tests (including the data_vars bug regression)
# ------------------------------------------------------------------ #

class TestAccessorProperties:
def test_grid_vars_returns_set(self, rgrid_ds):
gvars = rgrid_ds.xsg.grid_vars
assert isinstance(gvars, set)

def test_data_vars_returns_set(self, rgrid_ds):
dvars = rgrid_ds.xsg.data_vars
assert isinstance(dvars, set)

def test_data_vars_no_grid_does_not_raise(self, unknown_ds):
"""
Regression test for the data_vars bug.
Before the fix, this raised AttributeError because self._ds was
checked instead of self._grid, causing None.data_vars() to be called.
"""
result = unknown_ds.xsg.data_vars
assert result == set()

def test_grid_vars_no_grid_returns_empty(self, unknown_ds):
assert unknown_ds.xsg.grid_vars == set()

def test_extra_vars_no_grid_returns_empty(self, unknown_ds):
assert unknown_ds.xsg.extra_vars == set()

def test_has_vertical_levels_returns_bool(self, rgrid_ds):
result = rgrid_ds.xsg.has_vertical_levels
assert isinstance(result, bool)

def test_has_vertical_levels_false_on_unknown(self, unknown_ds):
assert unknown_ds.xsg.has_vertical_levels is False


# ------------------------------------------------------------------ #
# Subsetting tests
# ------------------------------------------------------------------ #

class TestSubsetting:
def test_subset_bbox_returns_dataset(self, rgrid_ds):
"""subset_bbox should return an xr.Dataset for a recognised grid."""
# Build bbox dynamically from actual coordinate range
lats = rgrid_ds.cf["latitude"].values
lons = rgrid_ds.cf["longitude"].values
lat_min, lat_max = float(lats.min()), float(lats.max())
lon_min, lon_max = float(lons.min()), float(lons.max())
# Use the centre quarter of the domain
lat_mid = (lat_min + lat_max) / 2
lon_mid = (lon_min + lon_max) / 2
bbox = (lon_min, lat_min, lon_mid, lat_mid)
ds_sub = rgrid_ds.xsg.subset_bbox(bbox)
assert ds_sub is not None
assert isinstance(ds_sub, xr.Dataset)

def test_subset_polygon_returns_dataset(self, rgrid_ds):
"""subset_polygon should return an xr.Dataset for a recognised grid."""
lats = rgrid_ds.cf["latitude"].values
lons = rgrid_ds.cf["longitude"].values
lat_min, lat_max = float(lats.min()), float(lats.max())
lon_min, lon_max = float(lons.min()), float(lons.max())
lat_mid = (lat_min + lat_max) / 2
lon_mid = (lon_min + lon_max) / 2
poly = np.array([
[lon_min, lat_min],
[lon_mid, lat_min],
[lon_mid, lat_mid],
[lon_min, lat_mid],
[lon_min, lat_min],
])
ds_sub = rgrid_ds.xsg.subset_polygon(poly)
assert ds_sub is not None
assert isinstance(ds_sub, xr.Dataset)

def test_subset_bbox_no_grid_returns_none(self, unknown_ds):
result = unknown_ds.xsg.subset_bbox((-80, 30, -70, 40))
assert result is None

def test_subset_polygon_no_grid_returns_none(self, unknown_ds):
poly = np.array([[-80, 30], [-70, 30], [-70, 40], [-80, 40], [-80, 30]])
result = unknown_ds.xsg.subset_polygon(poly)
assert result is None

def test_subset_vars_keeps_grid_vars(self, rgrid_ds):
"""Subsetting to a variable should always retain grid variables."""
data_vars = list(rgrid_ds.xsg.data_vars)
if not data_vars:
pytest.skip("No data vars found in this dataset")
ds_sub = rgrid_ds.xsg.subset_vars([data_vars[0]])
grid_vars = rgrid_ds.xsg.grid_vars
for gvar in grid_vars:
assert gvar in ds_sub

def test_subset_surface_level_no_vertical(self, unknown_ds):
"""Subsetting surface level on dataset with no verticals returns dataset unchanged."""
result = unknown_ds.xsg.subset_surface_level(method="nearest")
assert result is unknown_ds
4 changes: 2 additions & 2 deletions tests/test_grids/test_sgrid.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from pathlib import Path

import zarr
import numpy as np
import pytest
import xarray as xr
Expand Down Expand Up @@ -52,7 +52,7 @@ def test_grid_topology_location_parse():


@pytest.mark.skipif(
zarr__version__ >= 3, reason="zarr3.0.8 doesn't support FSpec AWS (it might soon)"
int(zarr.__version__.split(".")[0]) >= 3, reason="zarr3.0.8 doesn't support FSpec AWS (it might soon)"
)
@pytest.mark.online
def test_polygon_subset():
Expand Down
Loading