Skip to content
Merged
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
24 changes: 24 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: Test Suite

on:
pull_request:
push:
branches: [main, develop]
workflow_dispatch:

permissions:
contents: read

jobs:
unit-tests:
name: Run Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: astral-sh/setup-uv@v6
with:
python-version: "3.12"
- name: Install dependencies
run: uv sync --frozen --all-extras
- name: Run tests
run: uv run pytest tests -v
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- Store dataset properties as netCDF-safe individual attributes while keeping read compatibility with legacy `attrs["properties"]` dict/JSON data. [\#21](https://github.com/mlwp-tools/mxalign/pull/21) @observingClouds
- Added CI test workflow with first unit tests. [\#21](https://github.com/mlwp-tools/mxalign/pull/21) @observingClouds
- Added optional `ifs` dependency group with `cfgrib`, `eccodes`, and `eccodeslib`. [\#21](https://github.com/mlwp-tools/mxalign/pull/21) @observingClouds

## [0.1.0](https://github.com/mlwp-tools/mxalign/releases/tag/v0.1.0)

First release of `mxalign`, an xarray-based package for alignment of meteorological datasets, with the following functionality and configuration:
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ mxalign = "mxalign.cli:main"
earthkit = [
"earthkit-meteo>=0.6.1",
]
ifs = [
"cfgrib>=0.9.15.1",
Comment thread
mpvginde marked this conversation as resolved.
"eccodes>=2.45.0",
"eccodeslib>=2.46.2.19",
]
verification = [
"xskillscore>=0.0.29",
]
Expand All @@ -43,4 +48,5 @@ build-backend = "hatchling.build"
[dependency-groups]
dev = [
"ipykernel>=7.2.0",
"pytest>=8.0.0",
]
4 changes: 2 additions & 2 deletions src/mxalign/interpolations/delaunay.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from .base import BaseInterpolator
from .registry import register_interpolator
from ..properties.properties import Space
from ..properties.utils import properties_from_attrs, set_properties_attrs


@register_interpolator
Expand Down Expand Up @@ -81,8 +82,7 @@ def _interpolate(self, source_dataset):
latitude=self.target_dataset["latitude"],
longitude=self.target_dataset["longitude"],
)
ds_out.attrs["properties"] = source_dataset.attrs["properties"]
return ds_out
return set_properties_attrs(ds_out, properties_from_attrs(source_dataset))


def _build_weight_matrix(
Expand Down
4 changes: 2 additions & 2 deletions src/mxalign/loaders/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from .registry import register_loader
from ..properties.properties import Properties, Space, Time, Uncertainty
from ..properties.validation import validate_dataset
from ..properties.utils import properties_to_attrs
from ..properties.utils import set_properties_attrs


class BaseLoader(ABC):
Expand All @@ -29,7 +29,7 @@ def load(self):
properties = self._get_properties(ds)
validate_dataset(ds, properties)

ds.attrs["properties"] = properties_to_attrs(properties)
ds = set_properties_attrs(ds, properties)

if self.grid_mapping:
ds = self._add_grid_mapping(ds)
Expand Down
45 changes: 34 additions & 11 deletions src/mxalign/properties/utils.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,49 @@
import json

from .properties import Properties, Space, Time, Uncertainty
from .validation import validate_time_dataset, validate_space_dataset

SPACE_ATTR = "properties.space"
TIME_ATTR = "properties.time"
UNCERTAINTY_ATTR = "properties.uncertainty"


def properties_to_attrs(prop: Properties) -> dict:
return {
"space": prop.space.value,
"time": prop.time.value,
"uncertainty": prop.uncertainty.value,
SPACE_ATTR: prop.space.value,
TIME_ATTR: prop.time.value,
UNCERTAINTY_ATTR: prop.uncertainty.value,
}


def properties_from_attrs(ds) -> Properties:
attrs = ds.attrs.get("properties", {})
attrs = ds.attrs
old_attrs = attrs.get("properties", {})
if isinstance(old_attrs, str):
try:
old_attrs = json.loads(old_attrs)
except json.JSONDecodeError:
old_attrs = {}
if not isinstance(old_attrs, dict):
old_attrs = {}

space = attrs.get(SPACE_ATTR, old_attrs.get("space"))
time = attrs.get(TIME_ATTR, old_attrs.get("time"))
uncertainty = attrs.get(UNCERTAINTY_ATTR, old_attrs.get("uncertainty"))

return Properties(
space=Space(attrs["space"]),
time=Time(attrs["time"]),
uncertainty=Uncertainty(attrs.get("uncertainty", Uncertainty.DETERMINISTIC)),
space=Space(space),
time=Time(time),
uncertainty=Uncertainty(uncertainty or Uncertainty.DETERMINISTIC),
)


def set_properties_attrs(ds, prop: Properties):
ds.attrs.update(properties_to_attrs(prop))
ds.attrs.pop("properties", None)
return ds


def update_space_property(ds, prop: Space):
old_props = properties_from_attrs(ds)
new_props = Properties(
Expand All @@ -27,8 +52,7 @@ def update_space_property(ds, prop: Space):
uncertainty=old_props.uncertainty,
)
validate_space_dataset(ds, new_props)
ds.attrs["properties"] = properties_to_attrs(new_props)
return ds
return set_properties_attrs(ds, new_props)


def update_time_property(ds, prop: Time):
Expand All @@ -39,5 +63,4 @@ def update_time_property(ds, prop: Time):
uncertainty=old_props.uncertainty,
)
validate_time_dataset(ds, new_props)
ds.attrs["properties"] = properties_to_attrs(new_props)
return ds
return set_properties_attrs(ds, new_props)
53 changes: 53 additions & 0 deletions tests/test_properties_attrs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import json
import tempfile

import xarray as xr

from mxalign.properties.properties import Properties, Space, Time, Uncertainty
from mxalign.properties.utils import properties_from_attrs, set_properties_attrs


class TestPropertiesAttrs:
def test_properties_are_stored_in_netcdf_compatible_attrs(self):
ds = xr.Dataset()
props = Properties(
space=Space.POINT,
time=Time.OBSERVATION,
uncertainty=Uncertainty.DETERMINISTIC,
)

ds = set_properties_attrs(ds, props)

assert "properties" not in ds.attrs
assert ds.attrs["properties.space"] == "point"
assert ds.attrs["properties.time"] == "observation"
assert ds.attrs["properties.uncertainty"] == "deterministic"

with tempfile.NamedTemporaryFile(suffix=".nc") as tmp:
ds.to_netcdf(tmp.name)
with xr.open_dataset(tmp.name) as ds_loaded:
assert properties_from_attrs(ds_loaded) == props

def test_properties_can_still_be_read_from_legacy_format(self):
ds = xr.Dataset()
ds.attrs["properties"] = {
"space": "point",
"time": "observation",
"uncertainty": "deterministic",
}
assert properties_from_attrs(ds) == Properties(
space=Space.POINT,
time=Time.OBSERVATION,
uncertainty=Uncertainty.DETERMINISTIC,
)

def test_properties_can_be_read_from_legacy_json_string(self):
ds = xr.Dataset()
ds.attrs["properties"] = json.dumps(
{"space": "point", "time": "observation", "uncertainty": "deterministic"}
)
assert properties_from_attrs(ds) == Properties(
space=Space.POINT,
time=Time.OBSERVATION,
uncertainty=Uncertainty.DETERMINISTIC,
)
Loading
Loading