From ead4ed7fbb563b4321148e52a6df3bff0cd3a34b Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Mon, 5 Oct 2026 12:14:01 +0200 Subject: [PATCH 01/13] Initial table-based repr_html of cube dimension metadata #827 --- CHANGELOG.md | 2 + openeo/metadata/__init__.py | 11 +++- openeo/metadata/_jupyter.py | 48 ++++++++++++++++ openeo/rest/_datacube.py | 3 +- openeo/rest/datacube.py | 13 +++++ openeo/rest/vectorcube.py | 13 +++++ tests/metadata/test_jupyter.py | 82 ++++++++++++++++++++++++++++ tests/metadata/test_metadata.py | 22 ++++++++ tests/rest/datacube/test_datacube.py | 27 +++++++++ 9 files changed, 219 insertions(+), 2 deletions(-) create mode 100644 openeo/metadata/_jupyter.py create mode 100644 tests/metadata/test_jupyter.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 47c6ed488..75156924a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Richer (HTML based) representation of cube dimension metadata in Jupyter context ([#827](https://github.com/Open-EO/openeo-python-client/issues/827)) + ### Changed - Ranged downloads (e.g. of batch job results) no longer implement their own retry loop; transient failures are retried by the standard urllib3 retry configuration of the connection's session ([#934](https://github.com/Open-EO/openeo-python-client/issues/934)) diff --git a/openeo/metadata/__init__.py b/openeo/metadata/__init__.py index b203bed06..ce407e71d 100644 --- a/openeo/metadata/__init__.py +++ b/openeo/metadata/__init__.py @@ -135,7 +135,7 @@ def __init__(self, name: str, bands: List[Band]): self.bands = bands @property - def band_names(self) -> List[str]: + def band_names(self) -> List[str | None]: return [b.name for b in self.bands] @property @@ -310,6 +310,15 @@ def __str__(self) -> str: bands = self.band_names if self.has_band_dimension() else "no bands dimension" return f"CubeMetadata({bands} - {self.dimension_names()})" + def _repr_html_(self) -> str: + # Local import to avoid circular import issues and dependency overload + import openeo.metadata._jupyter + + if self._dimensions: + return openeo.metadata._jupyter.repr_html_dimensions(self._dimensions) + else: + return "

No cube (dimension) metadata

" + def _clone_and_update(self, dimensions: Optional[List[Dimension]] = None, **kwargs) -> CubeMetadata: """Create a new instance (of same class) with copied/updated fields.""" cls = type(self) diff --git a/openeo/metadata/_jupyter.py b/openeo/metadata/_jupyter.py new file mode 100644 index 000000000..f428c20be --- /dev/null +++ b/openeo/metadata/_jupyter.py @@ -0,0 +1,48 @@ +""" +HTML rendering helpers for metadata models (e.g. ``CubeMetadata``), used for Jupyter's ``_repr_html_``. + +Kept in a separate module (instead of directly in ``openeo.metadata``) to keep the core metadata +data model free of rendering/presentation concerns. +""" + +from __future__ import annotations + +import html +from typing import List + +from openeo.metadata import ( + BandDimension, + Dimension, + SpatialDimension, + TemporalDimension, +) + + +def repr_html_dimensions(dimensions: List[Dimension]) -> str: + """ + Render a compact HTML table with an overview of cube dimensions + (name, type, size, labels/extent), based on available (client-side) metadata. + """ + + header = ["Dimension", "Type", "Size", "Labels / Extent"] + rows = [] + for dim in dimensions: + row = [dim.name, dim.type] + if isinstance(dim, BandDimension): + labels = ", ".join(str(name) if name else "?" for name in dim.band_names) + row.extend([str(len(dim.bands)), "bands: " + labels]) + elif isinstance(dim, SpatialDimension): + row.extend(["?", f"extent: {repr(dim.extent)}"]) + elif isinstance(dim, TemporalDimension): + row.extend(["?", f"extent: {repr(dim.extent)}"]) + else: + row.extend(["?", ""]) + rows.append("" + "".join(f"{html.escape(c)}" for c in row) + "") + + thead = "" + "".join(f"{html.escape(c)}" for c in header) + "" + tbody = "".join(rows) + + return f""" + {thead} + {tbody} +
""" diff --git a/openeo/rest/_datacube.py b/openeo/rest/_datacube.py index 2af5f40fe..e82d88d42 100644 --- a/openeo/rest/_datacube.py +++ b/openeo/rest/_datacube.py @@ -120,7 +120,8 @@ def _repr_html_(self): "explicit-zoom": True, "height": "400px", } - return render_component("model-builder", data=process, parameters=parameters) + graph_html = render_component("model-builder", data=process, parameters=parameters) + return graph_html class UDF: diff --git a/openeo/rest/datacube.py b/openeo/rest/datacube.py index 7dbee3746..c98661b0b 100644 --- a/openeo/rest/datacube.py +++ b/openeo/rest/datacube.py @@ -114,6 +114,19 @@ def __init__( super().__init__(pgnode=graph, connection=connection) self.metadata: Optional[CubeMetadata] = metadata + def _repr_html_(self) -> str: + # Assumption: upstream _repr_html_ gives process graph representation + html = graph_html = super()._repr_html_() + + if isinstance(self.metadata, CubeMetadata): + metadata_html = self.metadata._repr_html_() + html = f""" +
Metadata{metadata_html}
+
Process Graph{graph_html}
+ """ + + return html + def process( self, process_id: str, diff --git a/openeo/rest/vectorcube.py b/openeo/rest/vectorcube.py index 1a22c4980..372cd8a02 100644 --- a/openeo/rest/vectorcube.py +++ b/openeo/rest/vectorcube.py @@ -52,6 +52,19 @@ def __init__(self, graph: PGNode, connection: Union[Connection, None], metadata: super().__init__(pgnode=graph, connection=connection) self.metadata = metadata + def _repr_html_(self) -> str: + # Assumption: upstream _repr_html_ gives process graph representation + html = graph_html = super()._repr_html_() + + if isinstance(self.metadata, CubeMetadata): + metadata_html = self.metadata._repr_html_() + html = f""" +
Metadata{metadata_html}
+
Process Graph{graph_html}
+ """ + + return html + @classmethod def _build_metadata(cls, add_properties: bool = False) -> CubeMetadata: """Helper to build a (minimal) `CubeMetadata` object.""" diff --git a/tests/metadata/test_jupyter.py b/tests/metadata/test_jupyter.py new file mode 100644 index 000000000..c34223651 --- /dev/null +++ b/tests/metadata/test_jupyter.py @@ -0,0 +1,82 @@ +import re + +import dirty_equals +import pytest + +from openeo.metadata import ( + Band, + BandDimension, + GeometryDimension, + SpatialDimension, + TemporalDimension, +) +from openeo.metadata._jupyter import repr_html_dimensions + + +def _strip_inter_tag_whitespace(html: str) -> str: + """ + Remove whitespace between HTML tags to simplify testing of HTML output. + """ + html = html.strip() + html = re.sub(r">\s+", ">", html) + html = re.sub(r"\s+<", "<", html) + return html + + +def test_strip_inter_tag_whitespace(): + assert ( + _strip_inter_tag_whitespace("\n
hello world\n \n\n
\n") + == "
hello world
" + ) + + +@pytest.mark.parametrize( + ["dimension", "expected_body"], + [ + ( + BandDimension(name="bands", bands=[Band("B2"), Band("B3")]), + "bandsbands2bands: B2, B3", + ), + ( + SpatialDimension(name="y", extent=[50, 52]), + "yspatial?extent: [50, 52]", + ), + ( + TemporalDimension(name="t", extent=["2026-10-01", "2026-10-20"]), + "ttemporal?extent: ['2026-10-01', '2026-10-20']", + ), + ], +) +def test_repr_html_dimensions_simple(dimension, expected_body): + html = repr_html_dimensions([dimension]) + assert _strip_inter_tag_whitespace(html) == _strip_inter_tag_whitespace( + f""" + + + {expected_body} +
DimensionTypeSizeLabels / Extent
+ """ + ) + + +def test_render_dimensions_html_basic(): + dimensions = [ + TemporalDimension(name="t", extent=["2026-10-01", "2026-11-12"]), + BandDimension(name="bands", bands=[Band("B2"), Band("B3")]), + SpatialDimension(name="x", extent=[2, 7]), + SpatialDimension(name="y", extent=[49, 52]), + ] + html = repr_html_dimensions(dimensions) + assert _strip_inter_tag_whitespace(html) == _strip_inter_tag_whitespace( + """ + + + + + + + + +
DimensionTypeSizeLabels / Extent
ttemporal?extent: ['2026-10-01', '2026-11-12']
bandsbands2bands: B2, B3
xspatial?extent: [2, 7]
yspatial?extent: [49, 52]
+ """ + ) diff --git a/tests/metadata/test_metadata.py b/tests/metadata/test_metadata.py index 2f1bd1466..9b6eb6456 100644 --- a/tests/metadata/test_metadata.py +++ b/tests/metadata/test_metadata.py @@ -1541,6 +1541,28 @@ def test_cube_metadata_repr_no_bands(): assert repr(metadata) == "CubeMetadata(dimension_names=['x', 't'])" +def test_cube_metadata_repr_html_empty(): + metadata = CubeMetadata() + html = metadata._repr_html_() + assert "No cube (dimension) metadata" in html + + +def test_cube_metadata_repr_html_with_dimensions(): + html = CUBE_METADATA_XYTB._repr_html_() + assert html == dirty_equals.IsStr( + regex=r""" + .* + .*.*Dimension.*Type.*Size.* + .* + .*.* + .*.* + .*.* + .* + .*""", + regex_flags=re.DOTALL | re.VERBOSE, + ) + + class TestStacMetadataParser: def test_band_from_eo_bands_metadata_basic(self): assert _StacMetadataParser()._band_from_eo_bands_metadata( diff --git a/tests/rest/datacube/test_datacube.py b/tests/rest/datacube/test_datacube.py index 16be101a4..0ad7e3fdb 100644 --- a/tests/rest/datacube/test_datacube.py +++ b/tests/rest/datacube/test_datacube.py @@ -8,6 +8,7 @@ import contextlib import json import pathlib +import re from datetime import date, datetime from unittest import mock @@ -349,6 +350,32 @@ def test_load_stac_resample_spatial(self, dummy_backend, tmp_path, stac_metadata }, } + def test_repr_html_no_metadata(self): + cube = DataCube.load_collection("T3") + html = cube._repr_html_() + assert "openeo-model-builder" in html + assert "etadata" not in html + + def test_repr_html_with_metadata(self, s2cube): + assert s2cube.metadata + html = s2cube._repr_html_() + assert html == dirty_equals.IsStr( + regex=r""" + .*Metadata + .*
xspatial
yspatial
ttemporal
bandsbands2bands:\sB2,\sB3
+ .*.*Dimension.*Type.*Size.* + .* + .*.* + .*.* + .*.* + .* + .* + .*
Process\ Graph + .* + .*""", + regex_flags=re.DOTALL | re.VERBOSE, + ) + def test_filter_temporal_basic_positional_args(s2cube): im = s2cube.filter_temporal("2016-01-01", "2016-03-10") From f9d7fd4043ae16f842c0d7b8374fd11f892897c2 Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Fri, 9 Oct 2026 23:12:24 +0200 Subject: [PATCH 02/13] Handle filter_temporal in CubeMetadata, down to TemporalDimension Trying to keep all this into account: - keep existing public API - support for parameterized/dynamic `filter_temporal` - handle empty temporal extent properly - calculate real temporal intersection - don't require metadata refs #827, #949 --- openeo/dates.py | 2 + openeo/metadata/__init__.py | 46 +++- openeo/metadata/_extents.py | 43 ++++ openeo/metadata/_jupyter.py | 2 +- openeo/rest/datacube.py | 19 +- openeo/util.py | 4 + openeo/utils/datetime.py | 123 +++++++++++ tests/metadata/test_extents.py | 11 + tests/metadata/test_metadata.py | 53 +++++ tests/rest/datacube/test_datacube.py | 289 ++++++++++++++----------- tests/rest/datacube/test_vectorcube.py | 7 + tests/rest/test_connection.py | 18 ++ tests/utils/test_datetime.py | 176 +++++++++++++++ 13 files changed, 654 insertions(+), 139 deletions(-) create mode 100644 openeo/metadata/_extents.py create mode 100644 openeo/utils/datetime.py create mode 100644 tests/metadata/test_extents.py create mode 100644 tests/utils/test_datetime.py diff --git a/openeo/dates.py b/openeo/dates.py index 834c23f90..f1f92f7bb 100644 --- a/openeo/dates.py +++ b/openeo/dates.py @@ -7,6 +7,8 @@ from openeo.util import rfc3339 +# TODO #465 move this to openeo.util.datetime? + def get_temporal_extent( *args, diff --git a/openeo/metadata/__init__.py b/openeo/metadata/__init__.py index ce407e71d..7ed20cbb9 100644 --- a/openeo/metadata/__init__.py +++ b/openeo/metadata/__init__.py @@ -21,7 +21,9 @@ from openeo.api.process import Parameter from openeo.internal.jupyter import render_component +from openeo.metadata._extents import _EmptyExtent, _NonConcreteExtent from openeo.util import Rfc3339, deep_get +from openeo.utils.datetime import DateTimeInterval from openeo.utils.normalize import normalize_resample_resolution, unique _log = logging.getLogger(__name__) @@ -96,9 +98,17 @@ def rename(self, name) -> Dimension: class TemporalDimension(Dimension): - def __init__(self, name: str, extent: Union[Tuple[str, str], List[str]]): + def __init__( + self, + name: str, + *, + extent: Tuple[str | None, str | None] | List[str | None] | _NonConcreteExtent | _EmptyExtent | None, + ): super().__init__(type="temporal", name=name) - self.extent = extent + # TODO: ideally we do some validation/normalization of the extent, + # but this is an existing public API (e.g. used from openeo-python-driver and openeo-geopyspark-driver) + # currently with little wiggle room to change behavior or increase strictness. + self.extent = extent or (None, None) def rename(self, name) -> Dimension: return TemporalDimension(name=name, extent=self.extent) @@ -107,6 +117,26 @@ def rename_labels(self, target, source) -> Dimension: # TODO should we check if the extent has changed with the new labels? return TemporalDimension(name=self.name, extent=self.extent) + def filter_temporal(self, extent: Tuple[str | None, str | None] | List[str | None] | Any) -> TemporalDimension: + """ + Create new TemporalDimension with intersection current and given temporal extents. + """ + if (interval1 := DateTimeInterval.try_from(self.extent)) and (interval2 := DateTimeInterval.try_from(extent)): + if intersection := interval1.intersection(interval2): + new_extent = intersection.as_rfc3339() + else: + new_extent = _EmptyExtent() + elif isinstance(self.extent, _EmptyExtent) or isinstance(extent, _EmptyExtent): + new_extent = _EmptyExtent() + elif extent is None: + new_extent = self.extent + elif self.extent is None: + new_extent = extent + else: + # As lenient fallback, handle as non-concrete extent + new_extent = _NonConcreteExtent() + return TemporalDimension(name=self.name, extent=new_extent) + class Band(NamedTuple): """ @@ -272,7 +302,6 @@ class CubeMetadata: """ def __init__(self, dimensions: Optional[List[Dimension]] = None): - # Original collection metadata (actual cube metadata might be altered through processes) # TODO: for `self._dimensions` we use `None` here to indicate an unknown/unspecified dimension set, # but most usage actually assumes it is a list that can be iterated over. # Can we handle this more consistently and less error-prone? @@ -390,6 +419,16 @@ def get_band_index(self, band: Union[int, str]) -> int: # TODO: eliminate this shortcut for smaller API surface return self.band_dimension.band_index(band) + def filter_temporal(self, extent: Tuple[str | None, str | None] | List[str | None] | Any) -> CubeMetadata: + if not self.has_temporal_dimension(): + _log.warning("filter_temporal on cube metadata without temporal dimension. Returning unchanged metadata.") + return self + return self._clone_and_update( + dimensions=[ + d.filter_temporal(extent=extent) if isinstance(d, TemporalDimension) else d for d in self._dimensions + ] + ) + def filter_bands(self, band_names: List[Union[int, str]]) -> CubeMetadata: """ Create new `CubeMetadata` with filtered band dimension @@ -563,6 +602,7 @@ class CollectionMetadata(CubeMetadata): # Problem: openeo-geopyspark-driver still heavily depends on CollectionMetadata acting as cube metadata. def __init__(self, metadata: dict, dimensions: List[Dimension] = None, _federation: Optional[dict] = None): + # Original collection metadata (actual cube metadata might be altered through processes) self._orig_metadata = metadata if dimensions is None: dimensions = self._parse_dimensions(self._orig_metadata) diff --git a/openeo/metadata/_extents.py b/openeo/metadata/_extents.py new file mode 100644 index 000000000..7c958ecca --- /dev/null +++ b/openeo/metadata/_extents.py @@ -0,0 +1,43 @@ +from __future__ import annotations + + +class _NonConcreteExtent: + """ + Special marker class for non-concrete spatial, temporal, or other extents. + There can be several reasons for an extent to be non-concrete at a given time: + being generally unknown, being undefined or unspecified by the user, + being (unintendedly) unavailable/unresolvable, being dynamic or parameterized, etc. + + The essential aspects this class signals: + - it is known that there is an extent + - but one or more actual bounds are non-concrete in some sense (unknown, dynamic, ...) + + Note that this is different from, so this class should not be used for: + - the absence of an extent (e.g. no limits) or partial open bounds + - an empty extent + """ + + def __repr__(self) -> str: + return "<_NonConcreteExtent>" + + +class _EmptyExtent: + """ + Special marker class for an empty extent (spatial, temporal, or other), like the empty set. + The essential aspects this class signals: there is a known, concrete extent, + but has no coverage: nothing matches or falls within it. + + Typical result of successive filter operations that have no overlap. + + Note that this is different from: + - the absence of an extent (e.g. no limits) or partial open bounds + - a non-concrete extent (which is known to have coverage, but the actual bounds are unknown, dynamic, etc.) + """ + + def __repr__(self) -> str: + return "<_EmptyExtent>" + + def __eq__(self, other) -> bool: + if isinstance(other, _EmptyExtent): + return True + return NotImplemented diff --git a/openeo/metadata/_jupyter.py b/openeo/metadata/_jupyter.py index f428c20be..f171d7f25 100644 --- a/openeo/metadata/_jupyter.py +++ b/openeo/metadata/_jupyter.py @@ -27,7 +27,7 @@ def repr_html_dimensions(dimensions: List[Dimension]) -> str: header = ["Dimension", "Type", "Size", "Labels / Extent"] rows = [] for dim in dimensions: - row = [dim.name, dim.type] + row = [dim.name, str(dim.type)] if isinstance(dim, BandDimension): labels = ", ".join(str(name) if name else "?" for name in dim.band_names) row.extend([str(len(dim.bands)), "bands: " + labels]) diff --git a/openeo/rest/datacube.py b/openeo/rest/datacube.py index c98661b0b..68dedf4bd 100644 --- a/openeo/rest/datacube.py +++ b/openeo/rest/datacube.py @@ -256,6 +256,8 @@ def load_collection( if (connection and fetch_metadata) else None ) + if metadata and temporal_extent: + metadata = metadata.filter_temporal(extent=temporal_extent) if bands is not None: bands = cls._get_bands(bands, process_id="load_collection") if isinstance(bands, Parameter): @@ -465,7 +467,9 @@ def load_stac( ) if temporal_extent: - arguments["temporal_extent"] = DataCube._get_temporal_extent(extent=temporal_extent) + temporal_extent = cls._get_temporal_extent(extent=temporal_extent) + arguments["temporal_extent"] = temporal_extent + bands = cls._get_bands(bands, process_id="load_stac") if bands is not None: arguments["bands"] = bands @@ -505,6 +509,10 @@ def load_stac( f"Failed to extract cube metadata from STAC URL {url!r}. Falling back on no-metadata mode. Exception: {e!r}" ) metadata = None + + if metadata and temporal_extent: + metadata = metadata.filter_temporal(extent=temporal_extent) + return cls(graph=graph, connection=connection, metadata=metadata) @classmethod @@ -591,12 +599,11 @@ def filter_temporal( f" If you want a half-unbounded interval, use something like filter_temporal({args[0]!r}, None) or use explicit keyword arguments." f" If you want the full interval covering all of {args[0]!r}, use something like filter_temporal(extent={args[0]!r})." ) + extent = self._get_temporal_extent(*args, start_date=start_date, end_date=end_date, extent=extent) return self.process( - process_id='filter_temporal', - arguments={ - 'data': THIS, - 'extent': self._get_temporal_extent(*args, start_date=start_date, end_date=end_date, extent=extent) - } + process_id="filter_temporal", + arguments={"data": THIS, "extent": extent}, + metadata=self.metadata.filter_temporal(extent=extent) if self.metadata else None, ) @openeo_process diff --git a/openeo/util.py b/openeo/util.py index 33007df3f..d5c7e2de5 100644 --- a/openeo/util.py +++ b/openeo/util.py @@ -62,6 +62,9 @@ class Rfc3339: Also see https://tools.ietf.org/html/rfc3339#section-5.6 """ + + # TODO #465 move this to openeo.util.datetime + # TODO: currently we hard code timezone 'Z' for simplicity. Add real time zone support? _FMT_DATE = '%Y-%m-%d' _FMT_TIME = '%H:%M:%SZ' @@ -206,6 +209,7 @@ def now_utc(self) -> str: # Default RFC3339 date-time formatter +# TODO #465 move this to openeo.util.datetime rfc3339 = Rfc3339() diff --git a/openeo/utils/datetime.py b/openeo/utils/datetime.py new file mode 100644 index 000000000..d512e32ea --- /dev/null +++ b/openeo/utils/datetime.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import dataclasses +import datetime +from typing import Any, Tuple, Union + +import dateutil.parser + +from openeo.util import rfc3339 + +# Some type aliases for convenience +DateLike = Union[str, datetime.date] +DateTimeLike = Union[str, datetime.datetime, datetime.date] + + +def to_datetime_utc(d: DateTimeLike) -> datetime.datetime: + """Parse/convert to datetime in UTC.""" + if isinstance(d, str): + d = dateutil.parser.parse(d) + elif isinstance(d, datetime.datetime): + pass + elif isinstance(d, datetime.date): + d = datetime.datetime.combine(d, datetime.time.min) + else: + raise ValueError(f"Expected str/datetime, but got {type(d)}") + if d.tzinfo is None: + d = d.replace(tzinfo=datetime.timezone.utc) + else: + d = d.astimezone(datetime.timezone.utc) + return d + + +def to_datetime_naive(d: DateTimeLike) -> datetime.datetime: + """Convert to datetime, assuming UTC where necessary, but return as naive.""" + return to_datetime_utc(d).replace(tzinfo=None) + + +def to_datetime_utc_unless_none(d: DateTimeLike | None) -> datetime.datetime | None: + """Parse/convert to datetime in UTC, but preserve None.""" + return None if d is None else to_datetime_utc(d) + + +@dataclasses.dataclass(frozen=True) +class DateTimeInterval: + """ + Datetime interval, defined by a start and end datetime instant (UTC) + each of which can be ``None`` to indicate being open-ended in that direction (no limit). + + Note on including or excluding the end instant: + this is not a property of the extent itself, but is an aspect of what the extent describes or how it is used: + - when the extent describes the range of a certain data set, the end instant is inclusive + - when the extent is used as search window for a query, the end instant is typically handled as exclusive + """ + + start: Union[datetime.datetime, None] + end: Union[datetime.datetime, None] + + def __init__(self, start: DateTimeLike | None, end: DateTimeLike | None): + start = to_datetime_utc_unless_none(start) + end = to_datetime_utc_unless_none(end) + if start and end and end < start: + raise ValueError(f"End instant {end!r} is before start instant {start!r}") + + object.__setattr__(self, "start", start) + object.__setattr__(self, "end", end) + + @classmethod + def try_from(cls, x: Any) -> DateTimeInterval | None: + """Try to build a TemporalExtent from the given object, or return None if not possible.""" + try: + if isinstance(x, (list, tuple)) and len(x) == 2: + return DateTimeInterval(*x) + elif isinstance(x, dict): + return DateTimeInterval(**x) + except (ValueError, TypeError): + pass + return None + + def as_tuple(self) -> Tuple[datetime.datetime | None, datetime.datetime | None]: + return self.start, self.end + + def as_isoformat(self, **kwargs) -> Tuple[str | None, str | None]: + return ( + self.start.isoformat(**kwargs) if self.start else None, + self.end.isoformat(**kwargs) if self.end else None, + ) + + def as_rfc3339(self) -> Tuple[str | None, str | None]: + return ( + rfc3339.datetime(self.start) if self.start else None, + rfc3339.datetime(self.end) if self.end else None, + ) + + def intersection(self, other: DateTimeInterval) -> DateTimeInterval | None: + """ + Compute the intersection of this interval with another interval. + If the intervals do not overlap, return None. + :param other: + :return: + """ + if not isinstance(other, DateTimeInterval): + raise ValueError(f"Expected DateTimeInterval, but got: {other}") + + # Start with naive start and end calculation + if self.start is None: + start = other.start + elif other.start is None: + start = self.start + else: + start = max(self.start, other.start) + + if self.end is None: + end = other.end + elif other.end is None: + end = self.end + else: + end = min(self.end, other.end) + + # Double check naive calculation on emptiness + if start is not None and end is not None and end < start: + return None + + return DateTimeInterval(start, end) diff --git a/tests/metadata/test_extents.py b/tests/metadata/test_extents.py new file mode 100644 index 000000000..98bf421ba --- /dev/null +++ b/tests/metadata/test_extents.py @@ -0,0 +1,11 @@ +from openeo.metadata import _EmptyExtent + + +class TestEmptyExtent: + + def test_equality(self): + assert _EmptyExtent() == _EmptyExtent() + + def test_none(self): + assert _EmptyExtent() != None + assert None != _EmptyExtent() diff --git a/tests/metadata/test_metadata.py b/tests/metadata/test_metadata.py index 9b6eb6456..45c4c68d0 100644 --- a/tests/metadata/test_metadata.py +++ b/tests/metadata/test_metadata.py @@ -8,6 +8,7 @@ import pystac import pytest +import openeo.api.process from openeo.api.process import Parameter from openeo.metadata import ( Band, @@ -20,6 +21,8 @@ MetadataException, SpatialDimension, TemporalDimension, + _EmptyExtent, + _NonConcreteExtent, _StacMetadataParser, metadata_from_stac, ) @@ -44,6 +47,56 @@ ) +class TestTemporalDimension: + + def test_filter_temporal_basic(self): + tdim1 = TemporalDimension(name="t", extent=("2021-01-01", None)) + tdim2 = tdim1.filter_temporal(extent=("2022-09-01", "2022-10-01")) + assert tdim1.extent == ("2021-01-01", None) + assert tdim2.extent == ("2022-09-01T00:00:00Z", "2022-10-01T00:00:00Z") + + def test_filter_temporal_empty_intersection(self): + tdim1 = TemporalDimension(name="t", extent=("2021-01-01", None)) + tdim2 = tdim1.filter_temporal(extent=("2019-02-02", "2019-02-20")) + assert tdim1.extent == ("2021-01-01", None) + assert isinstance(tdim2.extent, _EmptyExtent) + + def test_filter_temporal_none(self): + tdim1 = TemporalDimension(name="t", extent=("2021-01-01", None)) + tdim2 = tdim1.filter_temporal(extent=None) + assert tdim1.extent == ("2021-01-01", None) + assert tdim2.extent == ("2021-01-01", None) + + def test_filter_temporal_start_from_none(self): + tdim1 = TemporalDimension(name="t", extent=None) + tdim2 = tdim1.filter_temporal(extent=("2019-02-02", "2019-02-20")) + assert tdim1.extent == (None, None) + assert tdim2.extent == ("2019-02-02T00:00:00Z", "2019-02-20T00:00:00Z") + + def test_filter_temporal_multiple_empty_intersection(self): + tdim1 = TemporalDimension(name="t", extent=("2021-01-01", None)) + tdim2 = tdim1.filter_temporal(extent=("2022-02-02", "2022-02-22")) + tdim3 = tdim2.filter_temporal(extent=("2023-03-03", "2023-03-23")) + tdim4 = tdim3.filter_temporal(extent=("2024-04-04", "2024-04-24")) + assert tdim1.extent == ("2021-01-01", None) + assert tdim2.extent == ("2022-02-02T00:00:00Z", "2022-02-22T00:00:00Z") + assert isinstance(tdim3.extent, _EmptyExtent) + assert isinstance(tdim4.extent, _EmptyExtent) + + def test_filter_temporal_half_open(self): + tdim1 = TemporalDimension(name="t", extent=("2021-01-01", None)) + tdim2 = tdim1.filter_temporal(extent=("2022-09-01", None)) + assert tdim1.extent == ("2021-01-01", None) + assert tdim2.extent == ("2022-09-01T00:00:00Z", None) + + def test_filter_temporal_parameterized(self): + tdim1 = TemporalDimension(name="t", extent=("2021-01-01", None)) + extent = openeo.api.process.Parameter.temporal_interval(name="extent") + tdim2 = tdim1.filter_temporal(extent=extent) + assert tdim1.extent == ("2021-01-01", None) + assert isinstance(tdim2.extent, _NonConcreteExtent) + + class TestCollectionMetadata: def test_repr_html_basic(self): diff --git a/tests/rest/datacube/test_datacube.py b/tests/rest/datacube/test_datacube.py index 0ad7e3fdb..597d1d6b4 100644 --- a/tests/rest/datacube/test_datacube.py +++ b/tests/rest/datacube/test_datacube.py @@ -31,6 +31,7 @@ from openeo.rest.models.general import ValidationResponse from openeo.testing.stac import StacDummyBuilder from openeo.util import dict_no_none +from openeo.utils.datetime import DateTimeInterval from .. import get_download_graph from .conftest import API_URL @@ -376,143 +377,173 @@ def test_repr_html_with_metadata(self, s2cube): regex_flags=re.DOTALL | re.VERBOSE, ) + def test_load_collection_extent_repr_html(self, connection): + cube = connection.load_collection( + "S2", + spatial_extent={"west": 3.1, "south": 51.1, "east": 3.2, "north": 51.2}, + temporal_extent=("2026-09-01", "2026-09-20"), + bands=["B03", "B04"], + ) + assert cube.metadata + html = cube._repr_html_() + assert html == dirty_equals.IsStr( + regex=r""" + .*Metadata + .*
xspatial
yspatial
ttemporal
bandsbands4bands:\sB02,\sB03,\sB04,\sB08
+ .*.*Dimension.*Type.*Size.* + .* + .*.* + .*.* + .* + .* + .* + .*
Process\ Graph + .* + .*""", + regex_flags=re.DOTALL | re.VERBOSE, + ) -def test_filter_temporal_basic_positional_args(s2cube): - im = s2cube.filter_temporal("2016-01-01", "2016-03-10") - graph = _get_leaf_node(im) - assert graph["process_id"] == "filter_temporal" - assert graph["arguments"]["extent"] == ["2016-01-01", "2016-03-10"] - - -def test_filter_temporal_basic_start_end(s2cube): - im = s2cube.filter_temporal(start_date="2016-01-01", end_date="2016-03-10") - graph = _get_leaf_node(im) - assert graph["process_id"] == "filter_temporal" - assert graph["arguments"]["extent"] == ["2016-01-01", "2016-03-10"] - - -def test_filter_temporal_basic_extent(s2cube): - im = s2cube.filter_temporal(extent=("2016-01-01", "2016-03-10")) - graph = _get_leaf_node(im) - assert graph["process_id"] == "filter_temporal" - assert graph["arguments"]["extent"] == ["2016-01-01", "2016-03-10"] - - -@pytest.mark.parametrize( - "args,kwargs,extent", - [ - ((), {}, [None, None]), - (("2016-01-01", "2016-03-10"), {}, ["2016-01-01", "2016-03-10"]), - ((("2016-01-01", "2016-03-10"),), {}, ["2016-01-01", "2016-03-10"]), - ((["2016-01-01", "2016-03-10"],), {}, ["2016-01-01", "2016-03-10"]), - ((date(2016, 1, 1), date(2016, 3, 10)), {}, ["2016-01-01", "2016-03-10"]), - ( - (datetime(2016, 1, 1, 12, 34), datetime(2016, 3, 10, 23, 45)), - {}, - ["2016-01-01T12:34:00Z", "2016-03-10T23:45:00Z"], - ), - ((), {"start_date": "2016-01-01", "end_date": "2016-03-10"}, ["2016-01-01", "2016-03-10"]), - ((), {"start_date": "2016-01-01"}, ["2016-01-01", None]), - ((), {"end_date": "2016-03-10"}, [None, "2016-03-10"]), - ((), {"start_date": date(2016, 1, 1), "end_date": date(2016, 3, 10)}, ["2016-01-01", "2016-03-10"]), - ( - (), - {"start_date": datetime(2016, 1, 1, 12, 34), "end_date": datetime(2016, 3, 10, 23, 45)}, - ["2016-01-01T12:34:00Z", "2016-03-10T23:45:00Z"], - ), - ((), {"extent": ("2016-01-01", "2016-03-10")}, ["2016-01-01", "2016-03-10"]), - ((), {"extent": ("2016-01-01", None)}, ["2016-01-01", None]), - ((), {"extent": (None, "2016-03-10")}, [None, "2016-03-10"]), - ((), {"extent": (date(2016, 1, 1), date(2016, 3, 10))}, ["2016-01-01", "2016-03-10"]), - ( - (), - {"extent": (datetime(2016, 1, 1, 12, 34), datetime(2016, 3, 10, 23, 45))}, - ["2016-01-01T12:34:00Z", "2016-03-10T23:45:00Z"], - ), - ], -) -def test_filter_temporal_generic(s2cube, args, kwargs, extent): - im = s2cube.filter_temporal(*args, **kwargs) - graph = _get_leaf_node(im) - assert graph["process_id"] == "filter_temporal" - assert graph["arguments"]["extent"] == extent - + def test_filter_temporal_basic_positional_args(self, s2cube): + cube = s2cube.filter_temporal("2016-01-01", "2016-03-10") + graph = _get_leaf_node(cube) + assert graph["process_id"] == "filter_temporal" + assert graph["arguments"]["extent"] == ["2016-01-01", "2016-03-10"] + assert cube.metadata.temporal_dimension.extent == ("2016-01-01T00:00:00Z", "2016-03-10T00:00:00Z") + + def test_filter_temporal_basic_start_end(self, s2cube): + cube = s2cube.filter_temporal(start_date="2016-01-01", end_date="2016-03-10") + graph = _get_leaf_node(cube) + assert graph["process_id"] == "filter_temporal" + assert graph["arguments"]["extent"] == ["2016-01-01", "2016-03-10"] + assert cube.metadata.temporal_dimension.extent == ("2016-01-01T00:00:00Z", "2016-03-10T00:00:00Z") + + def test_filter_temporal_basic_extent(self, s2cube): + cube = s2cube.filter_temporal(extent=("2016-01-01", "2016-03-10")) + graph = _get_leaf_node(cube) + assert graph["process_id"] == "filter_temporal" + assert graph["arguments"]["extent"] == ["2016-01-01", "2016-03-10"] + assert cube.metadata.temporal_dimension.extent == ("2016-01-01T00:00:00Z", "2016-03-10T00:00:00Z") -@pytest.mark.parametrize( - ["extent", "expected"], - [ - (["2016", None], ["2016-01-01", None]), - (["2016-03", None], ["2016-03-01", None]), - (["2016", "2017"], ["2016-01-01", "2017-01-01"]), - (["2016-03", "2016-04"], ["2016-03-01", "2016-04-01"]), - (["2016-03", "2018-12"], ["2016-03-01", "2018-12-01"]), - (["2016", "2016-01"], ["2016-01-01", "2016-01-01"]), - (["2016", "2016-04"], ["2016-01-01", "2016-04-01"]), - ([None, "2016-04"], [None, "2016-04-01"]), - ], -) -def test_filter_temporal_extent_tuple_with_shorthand_dates(s2cube: DataCube, extent, expected): - """Verify it supports shorthand dates that represent years or months.""" - im = s2cube.filter_temporal(extent=extent) - graph = _get_leaf_node(im) - assert graph["process_id"] == "filter_temporal" - assert graph["arguments"]["extent"] == expected + @pytest.mark.parametrize( + ["args", "kwargs", "extent"], + [ + ((), {}, [None, None]), + (("2016-01-01", "2016-03-10"), {}, ["2016-01-01", "2016-03-10"]), + ((("2016-01-01", "2016-03-10"),), {}, ["2016-01-01", "2016-03-10"]), + ((["2016-01-01", "2016-03-10"],), {}, ["2016-01-01", "2016-03-10"]), + ((date(2016, 1, 1), date(2016, 3, 10)), {}, ["2016-01-01", "2016-03-10"]), + ( + (datetime(2016, 1, 1, 12, 34), datetime(2016, 3, 10, 23, 45)), + {}, + ["2016-01-01T12:34:00Z", "2016-03-10T23:45:00Z"], + ), + ((), {"start_date": "2016-01-01", "end_date": "2016-03-10"}, ["2016-01-01", "2016-03-10"]), + ((), {"start_date": "2016-01-01"}, ["2016-01-01", None]), + ((), {"end_date": "2016-03-10"}, [None, "2016-03-10"]), + ((), {"start_date": date(2016, 1, 1), "end_date": date(2016, 3, 10)}, ["2016-01-01", "2016-03-10"]), + ( + (), + {"start_date": datetime(2016, 1, 1, 12, 34), "end_date": datetime(2016, 3, 10, 23, 45)}, + ["2016-01-01T12:34:00Z", "2016-03-10T23:45:00Z"], + ), + ((), {"extent": ("2016-01-01", "2016-03-10")}, ["2016-01-01", "2016-03-10"]), + ((), {"extent": ("2016-01-01", None)}, ["2016-01-01", None]), + ((), {"extent": (None, "2016-03-10")}, [None, "2016-03-10"]), + ((), {"extent": (date(2016, 1, 1), date(2016, 3, 10))}, ["2016-01-01", "2016-03-10"]), + ( + (), + {"extent": (datetime(2016, 1, 1, 12, 34), datetime(2016, 3, 10, 23, 45))}, + ["2016-01-01T12:34:00Z", "2016-03-10T23:45:00Z"], + ), + ], + ) + def test_filter_temporal_generic(self, s2cube, args, kwargs, extent): + cube = s2cube.filter_temporal(*args, **kwargs) + graph = _get_leaf_node(cube) + assert graph["process_id"] == "filter_temporal" + assert graph["arguments"]["extent"] == extent + assert cube.metadata.temporal_dimension.extent == DateTimeInterval(*extent).as_rfc3339() -@pytest.mark.parametrize( - ["extent", "expected"], - [ - ("2016", ["2016-01-01", "2017-01-01"]), - ("2016-01", ["2016-01-01", "2016-02-01"]), - ("2016-04", ["2016-04-01", "2016-05-01"]), - ("2016-12", ["2016-12-01", "2017-01-01"]), - ("2016-04-11", ["2016-12-01", "2017-01-01"]), - ], -) -def test_filter_temporal_extent_single_date_string(s2cube: DataCube, extent, expected): - """Verify it supports single string extent.""" - im = s2cube.filter_temporal(extent=extent) - graph = _get_leaf_node(im) - assert graph["process_id"] == "filter_temporal" - assert graph["arguments"]["extent"] == expected + @pytest.mark.parametrize( + ["extent", "expected"], + [ + (["2016", None], ["2016-01-01", None]), + (["2016-03", None], ["2016-03-01", None]), + (["2016", "2017"], ["2016-01-01", "2017-01-01"]), + (["2016-03", "2016-04"], ["2016-03-01", "2016-04-01"]), + (["2016-03", "2018-12"], ["2016-03-01", "2018-12-01"]), + (["2016", "2016-01"], ["2016-01-01", "2016-01-01"]), + (["2016", "2016-04"], ["2016-01-01", "2016-04-01"]), + ([None, "2016-04"], [None, "2016-04-01"]), + ], + ) + def test_filter_temporal_extent_tuple_with_shorthand_dates(self, s2cube: DataCube, extent, expected): + """Verify it supports shorthand dates that represent years or months.""" + cube = s2cube.filter_temporal(extent=extent) + graph = _get_leaf_node(cube) + assert graph["process_id"] == "filter_temporal" + assert graph["arguments"]["extent"] == expected + assert cube.metadata.temporal_dimension.extent == DateTimeInterval(*expected).as_rfc3339() + @pytest.mark.parametrize( + ["extent", "expected"], + [ + ("2016", ["2016-01-01", "2017-01-01"]), + ("2016-01", ["2016-01-01", "2016-02-01"]), + ("2016-04", ["2016-04-01", "2016-05-01"]), + ("2016-12", ["2016-12-01", "2017-01-01"]), + ("2016-04-11", ["2016-04-11", "2016-04-12"]), + ], + ) + def test_filter_temporal_extent_single_date_string(self, s2cube: DataCube, extent, expected): + """Verify it supports single string extent.""" + cube = s2cube.filter_temporal(extent=extent) + graph = _get_leaf_node(cube) + assert graph["process_id"] == "filter_temporal" + assert graph["arguments"]["extent"] == expected + assert cube.metadata.temporal_dimension.extent == DateTimeInterval(*expected).as_rfc3339() -@pytest.mark.parametrize( - "extent,expected", - [ - (("2016-01-01", None), ["2016-01-01", None]), - (("2016-01-01", "2016-03-10"), ["2016-01-01", "2016-03-10"]), - ((None, "2016-03-10"), [None, "2016-03-10"]), - (["2016-01-01", None], ["2016-01-01", None]), - ], -) -def test_load_collection_filter_temporal(connection, api_version, extent, expected): - cube: DataCube = connection.load_collection("S2", temporal_extent=extent) - flat_graph = cube.flat_graph() - assert flat_graph["loadcollection1"]["arguments"]["temporal_extent"] == expected + @pytest.mark.parametrize( + ["extent", "expected"], + [ + (("2016-01-01", None), ["2016-01-01", None]), + (("2016-01-01", "2016-03-10"), ["2016-01-01", "2016-03-10"]), + ((None, "2016-03-10"), [None, "2016-03-10"]), + (["2016-01-01", None], ["2016-01-01", None]), + ], + ) + def test_load_collection_filter_temporal(self, connection, api_version, extent, expected): + cube: DataCube = connection.load_collection("S2", temporal_extent=extent) + flat_graph = cube.flat_graph() + assert flat_graph["loadcollection1"]["arguments"]["temporal_extent"] == expected + assert cube.metadata.temporal_dimension.extent == DateTimeInterval(*expected).as_rfc3339() -@pytest.mark.parametrize( - "extent,expected", - [ - # Test that the simplest/shortest syntax works: temporal_extent="2016" - ("2016", ["2016-01-01", "2017-01-01"]), - ("2016-02", ["2016-02-01", "2016-03-01"]), - ("2016-02-03", ["2016-02-03", "2016-02-04"]), - # Test date abbreviations using tuples for the extent - (["2016", None], ["2016-01-01", None]), - (["2016-02", None], ["2016-02-01", None]), - (["2016", "2017"], ["2016-01-01", "2017-01-01"]), - (["2016-02", "2016-08"], ["2016-02-01", "2016-08-01"]), - ([None, "2016"], [None, "2016-01-01"]), - ([None, "2016-02"], [None, "2016-02-01"]), - ], -) -def test_load_collection_temporal_extent_with_shorthand_date_strings(connection, api_version, extent, expected): - """Verify it supports abbreviated date strings.""" - cube: DataCube = connection.load_collection("S2", temporal_extent=extent) - flat_graph = cube.flat_graph() - assert flat_graph["loadcollection1"]["arguments"]["temporal_extent"] == expected + @pytest.mark.parametrize( + ["extent", "expected"], + [ + # Test that the simplest/shortest syntax works: temporal_extent="2016" + ("2016", ["2016-01-01", "2017-01-01"]), + ("2016-02", ["2016-02-01", "2016-03-01"]), + ("2016-02-03", ["2016-02-03", "2016-02-04"]), + # Test date abbreviations using tuples for the extent + (["2016", None], ["2016-01-01", None]), + (["2016-02", None], ["2016-02-01", None]), + (["2016", "2017"], ["2016-01-01", "2017-01-01"]), + (["2016-02", "2016-08"], ["2016-02-01", "2016-08-01"]), + ([None, "2016"], [None, "2016-01-01"]), + ([None, "2016-02"], [None, "2016-02-01"]), + ], + ) + def test_load_collection_temporal_extent_with_shorthand_date_strings( + self, connection, api_version, extent, expected + ): + """Verify it supports abbreviated date strings.""" + cube: DataCube = connection.load_collection("S2", temporal_extent=extent) + flat_graph = cube.flat_graph() + assert flat_graph["loadcollection1"]["arguments"]["temporal_extent"] == expected + assert cube.metadata.temporal_dimension.extent == DateTimeInterval(*expected).as_rfc3339() def test_load_collection_bands_name(connection, api_version, test_data): diff --git a/tests/rest/datacube/test_vectorcube.py b/tests/rest/datacube/test_vectorcube.py index 53cc0afea..44679c8e9 100644 --- a/tests/rest/datacube/test_vectorcube.py +++ b/tests/rest/datacube/test_vectorcube.py @@ -871,3 +871,10 @@ def test_vector_cube_validate(vector_cube, dummy_backend): ] assert isinstance(result, ValidationResponse) assert result == [{"code": "OfflineRequired", "message": "Turn off your smartphone"}] + + +def test_vector_cube_temporal_extent_metadata(connection): + raster_cube = connection.load_collection("S2", temporal_extent=("2026-09-01", "2026-09-20"), bands=["B03"]) + polygon = shapely.geometry.box(3, 51, 3.01, 51.01) + vector_cube = raster_cube.aggregate_spatial(geometries=polygon, reducer="mean") + assert vector_cube.metadata.temporal_dimension.extent == ("2026-09-01T00:00:00Z", "2026-09-20T00:00:00Z") diff --git a/tests/rest/test_connection.py b/tests/rest/test_connection.py index bc479c079..499a48916 100644 --- a/tests/rest/test_connection.py +++ b/tests/rest/test_connection.py @@ -27,6 +27,7 @@ BandDimension, CubeMetadata, TemporalDimension, + _EmptyExtent, ) from openeo.rest import ( CapabilitiesException, @@ -3879,6 +3880,23 @@ def test_load_stac_temporal_extent_from_udf(self, dummy_backend, udf_factory): }, } + @pytest.mark.parametrize( + ["extent", "expected"], + [ + (["2026-02-01", "2026-02-20"], ("2026-02-01T00:00:00Z", "2026-02-20T00:00:00Z")), + (["2024-09-01", "2025-05-05"], ("2025-01-01T00:00:00Z", "2025-05-05T00:00:00Z")), + (["2020-01-01", "2020-01-10"], _EmptyExtent()), + ], + ) + def test_load_stac_temporal_extent_metadata(self, dummy_backend, build_stac_ref, extent, expected): + stac_ref = build_stac_ref( + StacDummyBuilder.collection( + cube_dimensions={"t": {"type": "temporal", "extent": ["2025-01-01", "2026-06-06"]}} + ) + ) + cube = dummy_backend.connection.load_stac(stac_ref, temporal_extent=extent) + assert cube.metadata.temporal_dimension.extent == expected + @pytest.mark.parametrize( "data", diff --git a/tests/utils/test_datetime.py b/tests/utils/test_datetime.py new file mode 100644 index 000000000..3c23f82a9 --- /dev/null +++ b/tests/utils/test_datetime.py @@ -0,0 +1,176 @@ +import datetime + +import pytest + +from openeo.utils.datetime import ( + DateTimeInterval, + to_datetime_naive, + to_datetime_utc, + to_datetime_utc_unless_none, +) + + +@pytest.mark.parametrize( + ["obj", "expected"], + [ + ("2025-07-24", (2025, 7, 24, 0, 0, 0)), + ("2025-07-24T12:34:56", (2025, 7, 24, 12, 34, 56)), + ("2025-07-24T12:34:56.123", (2025, 7, 24, 12, 34, 56, 123000)), + ("2025-07-24T12:34:56Z", (2025, 7, 24, 12, 34, 56)), + ("2025-07-24T12:34:56+03", (2025, 7, 24, 9, 34, 56)), + ("2025-07-24T12:34:56+03:30", (2025, 7, 24, 9, 4, 56)), + ("2025-07-24T12:34:56-03", (2025, 7, 24, 15, 34, 56)), + ("2025-07-24T12:34:56-03:30", (2025, 7, 24, 16, 4, 56)), + (datetime.date(2025, 7, 24), (2025, 7, 24, 0, 0, 0)), + (datetime.datetime(2025, 7, 24, 12, 34, 56), (2025, 7, 24, 12, 34, 56)), + ( + datetime.datetime(2025, 7, 24, 12, 34, 56, tzinfo=datetime.timezone(offset=datetime.timedelta(hours=+2))), + (2025, 7, 24, 10, 34, 56), + ), + ( + datetime.datetime( + 2025, 7, 24, 12, 34, 56, tzinfo=datetime.timezone(offset=datetime.timedelta(hours=-4, minutes=-30)) + ), + (2025, 7, 24, 17, 4, 56), + ), + ], +) +def test_to_datetime_utc(obj, expected): + actual = to_datetime_utc(obj) + assert actual == datetime.datetime(*expected, tzinfo=datetime.timezone.utc) + assert actual.tzinfo == datetime.timezone.utc + + +@pytest.mark.parametrize( + ["obj", "expected"], + [ + ("2025-07-24", (2025, 7, 24, 0, 0, 0)), + ("2025-07-24T12:34:56", (2025, 7, 24, 12, 34, 56)), + ("2025-07-24T12:34:56.123", (2025, 7, 24, 12, 34, 56, 123000)), + ("2025-07-24T12:34:56Z", (2025, 7, 24, 12, 34, 56)), + ("2025-07-24T12:34:56+03", (2025, 7, 24, 9, 34, 56)), + ("2025-07-24T12:34:56+03:30", (2025, 7, 24, 9, 4, 56)), + ("2025-07-24T12:34:56-03", (2025, 7, 24, 15, 34, 56)), + ("2025-07-24T12:34:56-03:30", (2025, 7, 24, 16, 4, 56)), + (datetime.date(2025, 7, 24), (2025, 7, 24, 0, 0, 0)), + (datetime.datetime(2025, 7, 24, 12, 34, 56), (2025, 7, 24, 12, 34, 56)), + ( + datetime.datetime(2025, 7, 24, 12, 34, 56, tzinfo=datetime.timezone(offset=datetime.timedelta(hours=+2))), + (2025, 7, 24, 10, 34, 56), + ), + ( + datetime.datetime( + 2025, 7, 24, 12, 34, 56, tzinfo=datetime.timezone(offset=datetime.timedelta(hours=-4, minutes=-30)) + ), + (2025, 7, 24, 17, 4, 56), + ), + ], +) +def test_to_datetime_naive(obj, expected): + actual = to_datetime_naive(obj) + assert actual == datetime.datetime(*expected, tzinfo=None) + assert actual.tzinfo is None + + +@pytest.mark.parametrize( + ["obj", "expected"], + [ + (None, None), + ("2025-07-24", datetime.datetime(2025, 7, 24, 0, 0, 0, tzinfo=datetime.timezone.utc)), + ( + datetime.datetime(2025, 7, 24, 12, 34, 56), + datetime.datetime(2025, 7, 24, 12, 34, 56, tzinfo=datetime.timezone.utc), + ), + ], +) +def test_to_datetime_utc_unless_none(obj, expected): + assert to_datetime_utc_unless_none(obj) == expected + + +class TestDateTimeInterval: + def test_init_basic_date(self): + interval = DateTimeInterval("2026-09-10", "2026-11-12") + assert interval.start == datetime.datetime(2026, 9, 10, 0, 0, 0, tzinfo=datetime.timezone.utc) + assert interval.end == datetime.datetime(2026, 11, 12, 0, 0, 0, tzinfo=datetime.timezone.utc) + + def test_init_basic_invalid(self): + with pytest.raises(ValueError): + _ = DateTimeInterval("2026-11-12", "2026-09-10") + + def test_init_basic_datetime(self): + interval = DateTimeInterval("2026-09-10T12:34:45", "2026-11-12T11:22:33") + assert interval.start == datetime.datetime(2026, 9, 10, 12, 34, 45, tzinfo=datetime.timezone.utc) + assert interval.end == datetime.datetime(2026, 11, 12, 11, 22, 33, tzinfo=datetime.timezone.utc) + + def test_init_basic_datetime_z(self): + interval = DateTimeInterval("2026-09-10T12:34:45Z", "2026-11-12T11:22:33Z") + assert interval.start == datetime.datetime(2026, 9, 10, 12, 34, 45, tzinfo=datetime.timezone.utc) + assert interval.end == datetime.datetime(2026, 11, 12, 11, 22, 33, tzinfo=datetime.timezone.utc) + + def test_init_basic_datetime_timezone(self): + interval = DateTimeInterval("2026-09-10T11:11:11+02:00", "2026-11-12T22:22:22-1:30") + assert interval.start == datetime.datetime(2026, 9, 10, 9, 11, 11, tzinfo=datetime.timezone.utc) + assert interval.end == datetime.datetime(2026, 11, 12, 23, 52, 22, tzinfo=datetime.timezone.utc) + + @pytest.mark.parametrize( + ["given", "expected"], + [ + (["2026-09-10", "2026-11-12"], DateTimeInterval("2026-09-10", "2026-11-12")), + (("2026-09-10", "2026-11-12"), DateTimeInterval("2026-09-10", "2026-11-12")), + ({"start": "2026-09-10", "end": "2026-11-12"}, DateTimeInterval("2026-09-10", "2026-11-12")), + ("foobar", None), + (["2026-09-10"], None), + (["2026-09-10", "2026-11-12", "2026-12-01"], None), + ({"color": "green"}, None), + ({"start": "2026-09-10"}, None), + ], + ) + def test_try_from(self, given, expected): + assert DateTimeInterval.try_from(given) == expected + + def test_as_tuple(self): + interval = DateTimeInterval("2026-09-10", "2026-11-12T11:22:33Z") + assert interval.as_tuple() == ( + datetime.datetime(2026, 9, 10, 0, 0, 0, tzinfo=datetime.timezone.utc), + datetime.datetime(2026, 11, 12, 11, 22, 33, tzinfo=datetime.timezone.utc), + ) + + def test_as_isoformat(self): + interval = DateTimeInterval("2026-09-10", "2026-11-12T11:22:33Z") + assert interval.as_isoformat() == ("2026-09-10T00:00:00+00:00", "2026-11-12T11:22:33+00:00") + + def test_as_rfc3339(self): + interval = DateTimeInterval("2026-09-10", "2026-11-12T11:22:33Z") + assert interval.as_rfc3339() == ("2026-09-10T00:00:00Z", "2026-11-12T11:22:33Z") + + @pytest.mark.parametrize( + ["interval1", "interval2", "expected"], + [ + # Closed intervals + (("2026-09-10", "2026-11-12"), ("2025-05-05", "2026-06-06"), None), + (("2026-09-10", "2026-11-12"), ("2026-06-06", "2026-10-10"), DateTimeInterval("2026-09-10", "2026-10-10")), + (("2026-09-10", "2026-11-12"), ("2026-10-01", "2026-10-10"), DateTimeInterval("2026-10-01", "2026-10-10")), + (("2026-09-10", "2026-11-12"), ("2026-10-10", "2027-07-07"), DateTimeInterval("2026-10-10", "2026-11-12")), + (("2026-09-10", "2026-11-12"), ("2027-07-07", "2027-07-27"), None), + # Half-open intervals + (("2026-09-10", None), ("2025-05-05", "2026-06-06"), None), + (("2026-09-10", None), ("2026-06-06", "2026-10-10"), DateTimeInterval("2026-09-10", "2026-10-10")), + (("2026-09-10", None), ("2026-10-01", "2026-10-10"), DateTimeInterval("2026-10-01", "2026-10-10")), + ((None, "2026-11-12"), ("2025-05-05", "2026-06-06"), DateTimeInterval("2025-05-05", "2026-06-06")), + ((None, "2026-11-12"), ("2026-10-10", "2027-07-07"), DateTimeInterval("2026-10-10", "2026-11-12")), + ((None, "2026-11-12"), ("2027-07-07", "2027-07-27"), None), + # More half-open intervals + (("2026-06-06", None), ("2025-05-05", None), DateTimeInterval("2026-06-06", None)), + (("2026-06-06", None), (None, "2025-05-05"), None), + ((None, "2026-06-06"), ("2025-05-05", None), DateTimeInterval("2025-05-05", "2026-06-06")), + ((None, "2026-06-06"), (None, "2025-05-05"), DateTimeInterval(None, "2025-05-05")), + (("2026-06-06", None), (None, None), DateTimeInterval("2026-06-06", None)), + ((None, "2026-06-06"), (None, None), DateTimeInterval(None, "2026-06-06")), + ((None, None), (None, None), DateTimeInterval(None, None)), + ], + ) + def test_intersection(self, interval1, interval2, expected): + interval1 = DateTimeInterval(*interval1) + interval2 = DateTimeInterval(*interval2) + assert interval1.intersection(interval2) == expected + assert interval2.intersection(interval1) == expected From e28a735f766d7366929b85829a812f4984ad3c85 Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Mon, 12 Oct 2026 18:36:16 +0200 Subject: [PATCH 03/13] Handle `filter_temporal` end-exclusiveness in DateTimeInterval #955 --- openeo/metadata/__init__.py | 12 +++++- openeo/utils/datetime.py | 43 +++++++++++---------- tests/metadata/test_metadata.py | 10 +++++ tests/rest/datacube/test_datacube.py | 1 - tests/utils/test_datetime.py | 57 +++++++++++++++++++++++++--- 5 files changed, 96 insertions(+), 27 deletions(-) diff --git a/openeo/metadata/__init__.py b/openeo/metadata/__init__.py index 7ed20cbb9..e3dc44c6f 100644 --- a/openeo/metadata/__init__.py +++ b/openeo/metadata/__init__.py @@ -121,7 +121,17 @@ def filter_temporal(self, extent: Tuple[str | None, str | None] | List[str | Non """ Create new TemporalDimension with intersection current and given temporal extents. """ - if (interval1 := DateTimeInterval.try_from(self.extent)) and (interval2 := DateTimeInterval.try_from(extent)): + if (interval1 := DateTimeInterval.try_from_tuple(self.extent)) and ( + interval2 := DateTimeInterval.try_from_tuple(extent, end_inclusive=False) + ): + # Note on "end-inclusiveness" aspect in this intersection calculation: + # the current extent of the dimension is end-inclusive by definition (a data extent), + # while the `filter_temporal` extent is end-exclusive by definition (a filter extent). + # The inclusivity is taken into account while determining the intersection, + # but the end result is handled as (coerced into) an end-inclusive data extent regardless. + # While this might introduce a (sub-)second imprecision, it is typically + # multiple orders of magnitude smaller than the actual error (hours/days) + # of this best-effort, client-side approximation/simulation. if intersection := interval1.intersection(interval2): new_extent = intersection.as_rfc3339() else: diff --git a/openeo/utils/datetime.py b/openeo/utils/datetime.py index d512e32ea..63caf9407 100644 --- a/openeo/utils/datetime.py +++ b/openeo/utils/datetime.py @@ -2,6 +2,7 @@ import dataclasses import datetime +import sys from typing import Any, Tuple, Union import dateutil.parser @@ -46,16 +47,21 @@ class DateTimeInterval: Datetime interval, defined by a start and end datetime instant (UTC) each of which can be ``None`` to indicate being open-ended in that direction (no limit). - Note on including or excluding the end instant: - this is not a property of the extent itself, but is an aspect of what the extent describes or how it is used: - - when the extent describes the range of a certain data set, the end instant is inclusive - - when the extent is used as search window for a query, the end instant is typically handled as exclusive + Note on including or excluding the end instant (attribute ``end_inclusive``): + this aspect is usually linked to what the extent describes or how it is used: + - when the extent describes the known time range of a certain data set (e.g. a STAC collection), + the end instant is inclusive + - when the extent is used as search window for a query/filter operation, + some standards (e.g. openEO ``filter_temporal``) prescribe the end instant as exclusive, + as this simplifies some aspects of the query logic and specification. + The ``end_inclusive`` attribute can be toggled to indicate the intended semantics where this matters. """ start: Union[datetime.datetime, None] end: Union[datetime.datetime, None] + end_inclusive: bool = dataclasses.field(default=True, **({"kw_only": True} if sys.version_info >= (3, 10) else {})) - def __init__(self, start: DateTimeLike | None, end: DateTimeLike | None): + def __init__(self, start: DateTimeLike | None, end: DateTimeLike | None, *, end_inclusive: bool = True): start = to_datetime_utc_unless_none(start) end = to_datetime_utc_unless_none(end) if start and end and end < start: @@ -63,17 +69,16 @@ def __init__(self, start: DateTimeLike | None, end: DateTimeLike | None): object.__setattr__(self, "start", start) object.__setattr__(self, "end", end) + object.__setattr__(self, "end_inclusive", end_inclusive) @classmethod - def try_from(cls, x: Any) -> DateTimeInterval | None: - """Try to build a TemporalExtent from the given object, or return None if not possible.""" - try: - if isinstance(x, (list, tuple)) and len(x) == 2: - return DateTimeInterval(*x) - elif isinstance(x, dict): - return DateTimeInterval(**x) - except (ValueError, TypeError): - pass + def try_from_tuple(cls, x: Any, **kwargs: Any) -> DateTimeInterval | None: + """Try to build a TemporalExtent from the given tuple/list, or return None if not possible.""" + if isinstance(x, (list, tuple)) and len(x) == 2: + try: + return DateTimeInterval(*x, **kwargs) + except (ValueError, TypeError): + pass return None def as_tuple(self) -> Tuple[datetime.datetime | None, datetime.datetime | None]: @@ -110,14 +115,14 @@ def intersection(self, other: DateTimeInterval) -> DateTimeInterval | None: start = max(self.start, other.start) if self.end is None: - end = other.end + end, end_inclusive = other.end, other.end_inclusive elif other.end is None: - end = self.end + end, end_inclusive = self.end, self.end_inclusive else: - end = min(self.end, other.end) + end, end_inclusive = min((self.end, self.end_inclusive), (other.end, other.end_inclusive)) # Double check naive calculation on emptiness - if start is not None and end is not None and end < start: + if start is not None and end is not None and (end < start or (end == start and not end_inclusive)): return None - return DateTimeInterval(start, end) + return DateTimeInterval(start, end, end_inclusive=end_inclusive) diff --git a/tests/metadata/test_metadata.py b/tests/metadata/test_metadata.py index 45c4c68d0..c310f92e0 100644 --- a/tests/metadata/test_metadata.py +++ b/tests/metadata/test_metadata.py @@ -61,6 +61,16 @@ def test_filter_temporal_empty_intersection(self): assert tdim1.extent == ("2021-01-01", None) assert isinstance(tdim2.extent, _EmptyExtent) + def test_filter_temporal_end_inclusive(self): + """`filter_temporal` is end-exclusive""" + tdim1 = TemporalDimension(name="t", extent=("2026-03-01", "2026-04-01")) + # `filter_temporal` with extent just before: no match + tdim2 = tdim1.filter_temporal(extent=("2026-02-01", "2026-03-01")) + assert tdim2.extent == _EmptyExtent() + # `filter_temporal` with extent just after: single instant match + tdim3 = tdim1.filter_temporal(extent=("2026-04-01", "2026-05-01")) + assert tdim3.extent == ("2026-04-01T00:00:00Z", "2026-04-01T00:00:00Z") + def test_filter_temporal_none(self): tdim1 = TemporalDimension(name="t", extent=("2021-01-01", None)) tdim2 = tdim1.filter_temporal(extent=None) diff --git a/tests/rest/datacube/test_datacube.py b/tests/rest/datacube/test_datacube.py index 597d1d6b4..ee5b415b6 100644 --- a/tests/rest/datacube/test_datacube.py +++ b/tests/rest/datacube/test_datacube.py @@ -473,7 +473,6 @@ def test_filter_temporal_generic(self, s2cube, args, kwargs, extent): (["2016", "2017"], ["2016-01-01", "2017-01-01"]), (["2016-03", "2016-04"], ["2016-03-01", "2016-04-01"]), (["2016-03", "2018-12"], ["2016-03-01", "2018-12-01"]), - (["2016", "2016-01"], ["2016-01-01", "2016-01-01"]), (["2016", "2016-04"], ["2016-01-01", "2016-04-01"]), ([None, "2016-04"], [None, "2016-04-01"]), ], diff --git a/tests/utils/test_datetime.py b/tests/utils/test_datetime.py index 3c23f82a9..9cdcd9e94 100644 --- a/tests/utils/test_datetime.py +++ b/tests/utils/test_datetime.py @@ -112,22 +112,43 @@ def test_init_basic_datetime_timezone(self): assert interval.start == datetime.datetime(2026, 9, 10, 9, 11, 11, tzinfo=datetime.timezone.utc) assert interval.end == datetime.datetime(2026, 11, 12, 23, 52, 22, tzinfo=datetime.timezone.utc) + @pytest.mark.parametrize( + ["interval", "expected"], + [ + (DateTimeInterval("2026-09-10", "2026-09-20"), True), + (DateTimeInterval("2026-09-10T00:00:00", "2026-09-20T00:00:00"), True), + (DateTimeInterval("2026-09-10T01:00:00+01:00", "2026-09-20T00:00:00"), True), + (DateTimeInterval("2026-09-10T01:00:00", "2026-09-20T00:00:00"), False), + ], + ) + def test_equality(self, interval, expected): + ref = DateTimeInterval("2026-09-10", "2026-09-20") + assert (ref == interval) == expected + assert (interval == ref) == expected + + def test_equality_end_inclusive(self): + d1 = "2026-09-10" + d2 = "2026-09-20" + assert DateTimeInterval(d1, d2) == DateTimeInterval(d1, d2, end_inclusive=True) + assert DateTimeInterval(d1, d2) != DateTimeInterval(d1, d2, end_inclusive=False) + assert DateTimeInterval(d1, d2, end_inclusive=False) == DateTimeInterval(d1, d2, end_inclusive=False) + @pytest.mark.parametrize( ["given", "expected"], [ - (["2026-09-10", "2026-11-12"], DateTimeInterval("2026-09-10", "2026-11-12")), (("2026-09-10", "2026-11-12"), DateTimeInterval("2026-09-10", "2026-11-12")), - ({"start": "2026-09-10", "end": "2026-11-12"}, DateTimeInterval("2026-09-10", "2026-11-12")), + (("2026-09-10", None), DateTimeInterval("2026-09-10", None)), + (["2026-09-10", "2026-11-12"], DateTimeInterval("2026-09-10", "2026-11-12")), + (None, None), ("foobar", None), (["2026-09-10"], None), (["2026-09-10", "2026-11-12", "2026-12-01"], None), - ({"color": "green"}, None), - ({"start": "2026-09-10"}, None), + ([{"2026": "09-10"}, {"2026": "11-12"}], None), + ({"2026": "09-10"}, None), ], ) def test_try_from(self, given, expected): - assert DateTimeInterval.try_from(given) == expected - + assert DateTimeInterval.try_from_tuple(given) == expected def test_as_tuple(self): interval = DateTimeInterval("2026-09-10", "2026-11-12T11:22:33Z") assert interval.as_tuple() == ( @@ -174,3 +195,27 @@ def test_intersection(self, interval1, interval2, expected): interval2 = DateTimeInterval(*interval2) assert interval1.intersection(interval2) == expected assert interval2.intersection(interval1) == expected + + @pytest.mark.parametrize( + ["interval1", "interval2", "expected"], + [ + ( + DateTimeInterval("2026-08-20", "2026-09-01"), + DateTimeInterval("2026-09-01", "2026-09-30"), + DateTimeInterval("2026-09-01", "2026-09-01"), + ), + ( + DateTimeInterval("2026-08-20", "2026-09-01", end_inclusive=False), + DateTimeInterval("2026-09-01", "2026-09-30"), + None, + ), + ( + DateTimeInterval("2026-08-20", "2026-09-01", end_inclusive=False), + DateTimeInterval("2026-08-25", "2026-09-30"), + DateTimeInterval("2026-08-25", "2026-09-01", end_inclusive=False), + ), + ], + ) + def test_intersection_end_inclusive(self, interval1, interval2, expected): + assert interval1.intersection(interval2) == expected + assert interval2.intersection(interval1) == expected From deccd7a750bcc85e44ef93d42baec3c23b100645 Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Thu, 15 Oct 2026 13:21:08 +0200 Subject: [PATCH 04/13] Add `axis` attribute to SpatialDimension related to #955, #827 --- openeo/metadata/__init__.py | 45 +++++++++++++++++++++++---------- tests/metadata/test_metadata.py | 15 +++++++++++ 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/openeo/metadata/__init__.py b/openeo/metadata/__init__.py index e3dc44c6f..7ab031ceb 100644 --- a/openeo/metadata/__init__.py +++ b/openeo/metadata/__init__.py @@ -8,6 +8,7 @@ Dict, Iterable, List, + Literal, NamedTuple, Optional, Tuple, @@ -71,30 +72,43 @@ def rename_labels(self, target, source) -> Dimension: class SpatialDimension(Dimension): - # TODO: align better with STAC datacube extension: e.g. support "axis" (x or y) + """ + Spatial dimension metadata, roughly modeled after + "Horizontal Spatial Raster Dimension Object" of the STAC datacube extension, + which prescribes these required aspects: + - name: The name of the dimension + - axis: Axis of the spatial raster dimension ("x" or "y"). + - extent: lower and upper bounds of the dimension as two-element array. + """ DEFAULT_CRS = 4326 def __init__( self, name: str, + *, + # TODO: axis should be required (without None as default), but currently optional (with fallback guess) for backwards compatibility. + axis: Literal["x", "y"] | None = None, extent: Union[Tuple[float, float], List[float]], crs: Union[str, int, dict] = DEFAULT_CRS, step=None, ): """ - @param name: + @param axis: Axis of the spatial raster dimension ("x" or "y") @param extent: @param crs: @param step: The space between the values. Use null for irregularly spaced steps. """ super().__init__(type="spatial", name=name) + # TODO: eliminate guessing of axis from name, and make axis a properly required argument (without None as default). + self.axis: Literal["x", "y"] = axis or name self.extent = extent + # TODO Rename/proxy "crs" to "reference_system" to better align with STAC datacube extension terminology. self.crs = crs self.step = step def rename(self, name) -> Dimension: - return SpatialDimension(name=name, extent=self.extent, crs=self.crs, step=self.step) + return SpatialDimension(name=name, axis=self.axis, extent=self.extent, crs=self.crs, step=self.step) class TemporalDimension(Dimension): @@ -507,7 +521,10 @@ def add_dimension(self, name: str, label: Union[str, float], type: Optional[str] if type == "bands": dim = BandDimension(name=name, bands=[Band(name=label)]) elif type == "spatial": - dim = SpatialDimension(name=name, extent=[label, label]) + # From `add_dimension` spec: the axis is the first option that does not exist yet from x, y, and z + current_axes = {d.axis for d in self._dimensions if isinstance(d, SpatialDimension)} + next_axis = next((axis for axis in ["x", "y", "z"] if axis not in current_axes), None) + dim = SpatialDimension(name=name, axis=next_axis, extent=[label, label]) elif type == "temporal": dim = TemporalDimension(name=name, extent=[label, label]) elif type == "geometry": @@ -561,8 +578,8 @@ def resample_spatial( if self._dimensions is None: # Best-effort fallback to work with dimensions = [ - SpatialDimension(name="x", extent=[None, None]), - SpatialDimension(name="y", extent=[None, None]), + SpatialDimension(name="x", axis="x", extent=[None, None]), + SpatialDimension(name="y", axis="y", extent=[None, None]), ] else: # Make sure to work with a copy (to edit in-place) @@ -578,6 +595,7 @@ def resample_spatial( dim: SpatialDimension = dimensions[i] dimensions[i] = SpatialDimension( name=dim.name, + axis=dim.axis, extent=dim.extent, crs=None if isinstance(projection, Parameter) else (projection or dim.crs), step=r if r != 0.0 else dim.step, @@ -654,6 +672,7 @@ def _parse_dimensions(cls, spec: dict, complain: Callable[[str], None] = _log.wa dimensions.append( SpatialDimension( name=name, + axis=info.get("axis"), extent=info.get("extent"), crs=info.get("reference_system", SpatialDimension.DEFAULT_CRS), step=info.get("step", None), @@ -836,8 +855,8 @@ def dimensions_from_stac_object(self, stac_object: pystac.STACObject) -> List[Di return self._parse_declared_dimensions(stac_object=stac_object, bands=bands) dimensions: List[Dimension] = [ - SpatialDimension(name="x", extent=[None, None]), - SpatialDimension(name="y", extent=[None, None]), + SpatialDimension(name="x", axis="x", extent=[None, None]), + SpatialDimension(name="y", axis="y", extent=[None, None]), BandDimension(name="bands", bands=list(bands)), ] temporal_dimension = self.get_temporal_dimension(stac_object) @@ -933,7 +952,7 @@ def _parse_cube_dimensions_from_pystac_extension( extent = self._safe_extent_from_pystac_cube_dim(dim) if dim_type == pystac.extensions.datacube.DimensionType.SPATIAL: - dimensions.append(SpatialDimension(name=name, extent=extent)) + dimensions.append(SpatialDimension(name=name, axis=dim.axis, extent=extent)) elif dim_type == pystac.extensions.datacube.DimensionType.TEMPORAL: dimensions.append(TemporalDimension(name=name, extent=extent)) elif dim_type in ("bands", "spectral"): @@ -960,12 +979,12 @@ def _parse_cube_dimensions_from_raw_dict(self, stac_object: pystac.STACObject, b continue dim_type = dim.get("type") - extent = dim.get("extent", [None, None]) - if dim_type == "spatial": - dimensions.append(SpatialDimension(name=name, extent=extent)) + dimensions.append( + SpatialDimension(name=name, axis=dim.get("axis"), extent=dim.get("extent", [None, None])) + ) elif dim_type == "temporal": - dimensions.append(TemporalDimension(name=name, extent=extent)) + dimensions.append(TemporalDimension(name=name, extent=dim.get("extent", [None, None]))) elif dim_type in ("bands", "spectral"): dimensions.append(BandDimension(name=name, bands=list(bands))) elif dim_type == "geometries": diff --git a/tests/metadata/test_metadata.py b/tests/metadata/test_metadata.py index c310f92e0..49a5a537c 100644 --- a/tests/metadata/test_metadata.py +++ b/tests/metadata/test_metadata.py @@ -107,6 +107,21 @@ def test_filter_temporal_parameterized(self): assert isinstance(tdim2.extent, _NonConcreteExtent) +class TestSpatialDimension: + def test_legacy_without_axis(self): + dim = SpatialDimension(name="x", extent=[2, 6]) + assert dim.name == "x" + assert dim.axis == "x" + assert dim.extent == [2, 6] + assert dim.crs == 4326 + + def test_basic(self): + dim = SpatialDimension(name="lon", axis="x", extent=[2, 6], crs=4326) + assert dim.name == "lon" + assert dim.axis == "x" + assert dim.extent == [2, 6] + assert dim.crs == 4326 + class TestCollectionMetadata: def test_repr_html_basic(self): From e2cc888d4b62c8f1025a43994c93aa25c59f968b Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Thu, 15 Oct 2026 15:24:37 +0200 Subject: [PATCH 05/13] Reorganise test_metadata.py (re)group test functions in relevant test classes for better overview and easier future evolution refs #949 #827 #955 --- tests/metadata/test_metadata.py | 2743 +++++++++++++++---------------- 1 file changed, 1335 insertions(+), 1408 deletions(-) diff --git a/tests/metadata/test_metadata.py b/tests/metadata/test_metadata.py index 49a5a537c..98eecd644 100644 --- a/tests/metadata/test_metadata.py +++ b/tests/metadata/test_metadata.py @@ -47,8 +47,11 @@ ) -class TestTemporalDimension: +def assert_same_dimensions(dims1: List[Dimension], dims2: List[Dimension]): + assert sorted(dims1, key=lambda d: d.name) == sorted(dims2, key=lambda d: d.name) + +class TestTemporalDimension: def test_filter_temporal_basic(self): tdim1 = TemporalDimension(name="t", extent=("2021-01-01", None)) tdim2 = tdim1.filter_temporal(extent=("2022-09-01", "2022-10-01")) @@ -122,1526 +125,1450 @@ def test_basic(self): assert dim.extent == [2, 6] assert dim.crs == 4326 -class TestCollectionMetadata: - - def test_repr_html_basic(self): - metadata = CollectionMetadata({"hello": "world"}) - assert metadata._repr_html_() == dirty_equals.IsStr( - regex=r'.*.*"hello":\s*"world".*.*', regex_flags=re.DOTALL - ) - - -def test_metadata_get(): - metadata = CollectionMetadata({"foo": "bar", "very": {"deeply": {"nested": {"path": {"to": "somewhere"}}}}}) - assert metadata.get("foo") == "bar" - assert metadata.get("very", "deeply", "nested", "path", "to") == "somewhere" - assert metadata.get("invalid", "key") is None - assert metadata.get("invalid", "key", default="nope") == "nope" - - -def test_metadata_extent(): - metadata = CollectionMetadata({"extent": {"spatial": {"xmin": 4, "xmax": 10}}}) - assert metadata.extent == {"spatial": {"xmin": 4, "xmax": 10}} - - -def test_band_minimal(): - band = Band("red") - assert band.name == "red" - - -def test_band_dimension(): - bdim = BandDimension( - name="spectral", - bands=[ - Band("B02", "blue", 0.490), - Band("B03", "green", 0.560), - Band("B04", "red", 0.665), - ], - ) - assert bdim.band_names == ["B02", "B03", "B04"] - assert bdim.common_names == ["blue", "green", "red"] - - -def test_band_dimension_band_index(): - bdim = BandDimension( - name="spectral", - bands=[ - Band("B02", "blue", 0.490), - Band("B03", "green", 0.560), - Band("B04", "red", 0.665), - Band("B08", "nir", 0.842), - ], - ) - assert bdim.band_index(0) == 0 - assert bdim.band_index(2) == 2 - with pytest.raises(ValueError, match="Invalid band name/index"): - bdim.band_index(-1) - with pytest.raises(ValueError, match="Invalid band name/index"): - bdim.band_index(4) - assert bdim.band_index("B02") == 0 - assert bdim.band_index("B03") == 1 - assert bdim.band_index("B08") == 3 - assert bdim.band_index("blue") == 0 - assert bdim.band_index("green") == 1 - assert bdim.band_index("red") == 2 - assert bdim.band_index("nir") == 3 - with pytest.raises(ValueError, match="Invalid band name/index"): - bdim.band_index("B05") - with pytest.raises(ValueError, match="Invalid band name/index"): - bdim.band_index("yellow") - - -def test_band_dimension_band_index_with_aliases(): - bdim = BandDimension( - name="spectral", - bands=[ - Band("B02", "blue", 0.490, aliases=["sky"]), - Band("B03", "green", 0.560, aliases=["grass", "apple"]), - Band("B04", "red", 0.560, aliases=["apple"]), - ], - ) - assert bdim.band_index("sky") == 0 - assert bdim.band_index("grass") == 1 - - with pytest.raises(ValueError, match=r"Multiple alias matches for band 'apple': \['B03', 'B04'\]"): - bdim.band_index("apple") - - -def test_band_dimension_contains_band(): - bdim = BandDimension( - name="spectral", - bands=[ - Band("B02", "blue", 0.490), - Band("B03", "green", 0.560), - Band("B04", "red", 0.665), - ], - ) - - # Test band names - assert bdim.contains_band("B02") - assert not bdim.contains_band("B05") - # Test indexes - assert bdim.contains_band(0) - assert not bdim.contains_band(4) +class TestBand: + def test_band_minimal(self): + band = Band("red") + assert band.name == "red" - # Test common names - assert bdim.contains_band("blue") - assert not bdim.contains_band("yellow") - -def test_band_dimension_band_name(): - bdim = BandDimension( - name="spectral", - bands=[ - Band("B02", "blue", 0.490), - Band("B03", "green", 0.560), - ], - ) - assert bdim.band_name("B02") == "B02" - assert bdim.band_name("B03") == "B03" - with pytest.raises(ValueError, match="Invalid band name/index"): - bdim.band_name("B04") - - assert bdim.band_name("blue") == "blue" - assert bdim.band_name("blue", allow_common=False) == "B02" - assert bdim.band_name("green") == "green" - assert bdim.band_name("green", allow_common=False) == "B03" - with pytest.raises(ValueError, match="Invalid band name/index"): - bdim.band_name("red") - - assert bdim.band_name(0) == "B02" - assert bdim.band_name(1) == "B03" - with pytest.raises(ValueError, match="Invalid band name/index"): - bdim.band_name(2) - - -def test_band_dimension_band_name_with_aliases(): - bdim = BandDimension( - name="spectral", - bands=[ - Band("B02", "blue", 0.490, aliases=["sky"]), - Band("B03", "green", 0.560, aliases=["grass", "apple"]), - Band("B04", "red", 0.560, aliases=["apple"]), - ], - ) - assert bdim.band_name("sky") == "B02" - assert bdim.band_name("grass") == "B03" - - with pytest.raises(ValueError, match=r"Multiple alias matches for band 'apple': \['B03', 'B04'\]"): - bdim.band_name("apple") - - -def test_band_dimension_filter_bands(): - b02 = Band("B02", "blue", 0.490) - b03 = Band("B03", "green", 0.560) - b04 = Band("B04", "red", 0.665) - bdim = BandDimension(name="bs", bands=[b02, b03, b04]) - assert bdim.filter_bands(["B03", "B04"]) == BandDimension(name="bs", bands=[b03, b04]) - assert bdim.filter_bands(["B04", "blue"]) == BandDimension(name="bs", bands=[b04, b02]) - assert bdim.filter_bands(["green", 2]) == BandDimension(name="bs", bands=[b03, b04]) - - -def test_band_dimension_rename_labels(): - b02 = Band("B02", "blue", 0.490) - b03 = Band("B03", "green", 0.560) - b04 = Band("B04", "red", 0.665) - bdim = BandDimension(name="bs", bands=[b02, b03, b04]) - - metadata = CollectionMetadata({}, dimensions=[bdim]) - newdim = metadata.rename_labels("bs", target=["1", "2", "3"]).band_dimension - assert metadata.band_dimension.band_names == ["B02", "B03", "B04"] - assert newdim.band_names == ["1", "2", "3"] - - metadata = CubeMetadata(dimensions=[bdim]) - newdim = metadata.rename_labels("bs", target=["1", "2", "3"]).band_dimension - assert metadata.band_dimension.band_names == ["B02", "B03", "B04"] - assert newdim.band_names == ["1", "2", "3"] - - -def test_band_dimension_set_labels(): - bdim = BandDimension(name="bs", bands=[Band("some_name", None, None)]) - - metadata = CollectionMetadata({}, dimensions=[bdim]) - newdim = metadata.rename_labels("bs", target=["1", "2", "3"]).band_dimension - assert metadata.band_dimension.band_names == ["some_name"] - assert newdim.band_names == ["1", "2", "3"] - - metadata = CubeMetadata(dimensions=[bdim]) - newdim = metadata.rename_labels("bs", target=["1", "2", "3"]).band_dimension - assert metadata.band_dimension.band_names == ["some_name"] - assert newdim.band_names == ["1", "2", "3"] - - -def test_band_dimension_rename_labels_with_source(): - b02 = Band("B02", "blue", 0.490) - b03 = Band("B03", "green", 0.560) - b04 = Band("B04", "red", 0.665) - bdim = BandDimension(name="bs", bands=[b02, b03, b04]) - - metadata = CollectionMetadata({}, dimensions=[bdim]) - newdim = metadata.rename_labels("bs", target=["2"], source=["B03"]).band_dimension - assert metadata.band_dimension.band_names == ["B02", "B03", "B04"] - assert newdim.band_names == ["B02", "2", "B04"] - - metadata = CubeMetadata(dimensions=[bdim]) - newdim = metadata.rename_labels("bs", target=["2"], source=["B03"]).band_dimension - assert metadata.band_dimension.band_names == ["B02", "B03", "B04"] - assert newdim.band_names == ["B02", "2", "B04"] +class TestBandDimension: + def test_basic(self): + bdim = BandDimension( + name="spectral", + bands=[ + Band("B02", "blue", 0.490), + Band("B03", "green", 0.560), + Band("B04", "red", 0.665), + ], + ) + assert bdim.band_names == ["B02", "B03", "B04"] + assert bdim.common_names == ["blue", "green", "red"] + + def test_band_index(self): + bdim = BandDimension( + name="spectral", + bands=[ + Band("B02", "blue", 0.490), + Band("B03", "green", 0.560), + Band("B04", "red", 0.665), + Band("B08", "nir", 0.842), + ], + ) + assert bdim.band_index(0) == 0 + assert bdim.band_index(2) == 2 + with pytest.raises(ValueError, match="Invalid band name/index"): + bdim.band_index(-1) + with pytest.raises(ValueError, match="Invalid band name/index"): + bdim.band_index(4) + assert bdim.band_index("B02") == 0 + assert bdim.band_index("B03") == 1 + assert bdim.band_index("B08") == 3 + assert bdim.band_index("blue") == 0 + assert bdim.band_index("green") == 1 + assert bdim.band_index("red") == 2 + assert bdim.band_index("nir") == 3 + with pytest.raises(ValueError, match="Invalid band name/index"): + bdim.band_index("B05") + with pytest.raises(ValueError, match="Invalid band name/index"): + bdim.band_index("yellow") + + def test_band_index_with_aliases(self): + bdim = BandDimension( + name="spectral", + bands=[ + Band("B02", "blue", 0.490, aliases=["sky"]), + Band("B03", "green", 0.560, aliases=["grass", "apple"]), + Band("B04", "red", 0.560, aliases=["apple"]), + ], + ) + assert bdim.band_index("sky") == 0 + assert bdim.band_index("grass") == 1 + + with pytest.raises(ValueError, match=r"Multiple alias matches for band 'apple': \['B03', 'B04'\]"): + bdim.band_index("apple") + + def test_contains_band(self): + bdim = BandDimension( + name="spectral", + bands=[ + Band("B02", "blue", 0.490), + Band("B03", "green", 0.560), + Band("B04", "red", 0.665), + ], + ) -def test_band_dimension_rename_labels_with_source_mismatch(): - b02 = Band("B02", "blue", 0.490) - b03 = Band("B03", "green", 0.560) - bdim = BandDimension(name="bs", bands=[b02, b03]) + # Test band names + assert bdim.contains_band("B02") + assert not bdim.contains_band("B05") - metadata = CollectionMetadata({}, dimensions=[bdim]) - with pytest.raises(ValueError, match="should have same number of labels, but got"): - _ = metadata.rename_labels("bs", target=["2", "3"], source=["B03"]) + # Test indexes + assert bdim.contains_band(0) + assert not bdim.contains_band(4) - metadata = CubeMetadata(dimensions=[bdim]) - with pytest.raises(ValueError, match="should have same number of labels, but got"): - _ = metadata.rename_labels("bs", target=["2", "3"], source=["B03"]) + # Test common names + assert bdim.contains_band("blue") + assert not bdim.contains_band("yellow") + def test_band_name(self): + bdim = BandDimension( + name="spectral", + bands=[ + Band("B02", "blue", 0.490), + Band("B03", "green", 0.560), + ], + ) + assert bdim.band_name("B02") == "B02" + assert bdim.band_name("B03") == "B03" + with pytest.raises(ValueError, match="Invalid band name/index"): + bdim.band_name("B04") + + assert bdim.band_name("blue") == "blue" + assert bdim.band_name("blue", allow_common=False) == "B02" + assert bdim.band_name("green") == "green" + assert bdim.band_name("green", allow_common=False) == "B03" + with pytest.raises(ValueError, match="Invalid band name/index"): + bdim.band_name("red") + + assert bdim.band_name(0) == "B02" + assert bdim.band_name(1) == "B03" + with pytest.raises(ValueError, match="Invalid band name/index"): + bdim.band_name(2) + + def test_band_name_with_aliases(self): + bdim = BandDimension( + name="spectral", + bands=[ + Band("B02", "blue", 0.490, aliases=["sky"]), + Band("B03", "green", 0.560, aliases=["grass", "apple"]), + Band("B04", "red", 0.560, aliases=["apple"]), + ], + ) + assert bdim.band_name("sky") == "B02" + assert bdim.band_name("grass") == "B03" + + with pytest.raises(ValueError, match=r"Multiple alias matches for band 'apple': \['B03', 'B04'\]"): + bdim.band_name("apple") + + def test_filter_bands(self): + b02 = Band("B02", "blue", 0.490) + b03 = Band("B03", "green", 0.560) + b04 = Band("B04", "red", 0.665) + bdim = BandDimension(name="bs", bands=[b02, b03, b04]) + assert bdim.filter_bands(["B03", "B04"]) == BandDimension(name="bs", bands=[b03, b04]) + assert bdim.filter_bands(["B04", "blue"]) == BandDimension(name="bs", bands=[b04, b02]) + assert bdim.filter_bands(["green", 2]) == BandDimension(name="bs", bands=[b03, b04]) + + def test_append_band(self): + bdim1 = BandDimension( + name="spectral", + bands=[ + Band("B02", "blue", 0.490), + Band("B04", "red", 0.665), + ], + ) + bdim2 = bdim1.append_band(Band("B08", "nir", 0.842)) + bdim3 = bdim1.append_band("B03") + assert bdim1.band_names == ["B02", "B04"] + assert bdim1.common_names == ["blue", "red"] + assert bdim2.band_names == ["B02", "B04", "B08"] + assert bdim2.common_names == ["blue", "red", "nir"] + assert bdim3.band_names == ["B02", "B04", "B03"] + assert bdim3.common_names == ["blue", "red", None] -def test_band_dimension_append_band(): - bdim1 = BandDimension( - name="spectral", - bands=[ - Band("B02", "blue", 0.490), - Band("B04", "red", 0.665), - ], - ) - bdim2 = bdim1.append_band(Band("B08", "nir", 0.842)) - bdim3 = bdim1.append_band("B03") - assert bdim1.band_names == ["B02", "B04"] - assert bdim1.common_names == ["blue", "red"] - assert bdim2.band_names == ["B02", "B04", "B08"] - assert bdim2.common_names == ["blue", "red", "nir"] - assert bdim3.band_names == ["B02", "B04", "B03"] - assert bdim3.common_names == ["blue", "red", None] +class TestCollectionMetadata: + def test_repr_html_basic(self): + metadata = CollectionMetadata({"hello": "world"}) + assert metadata._repr_html_() == dirty_equals.IsStr( + regex=r'.*.*"hello":\s*"world".*.*', regex_flags=re.DOTALL + ) -def assert_same_dimensions(dims1: List[Dimension], dims2: List[Dimension]): - assert sorted(dims1, key=lambda d: d.name) == sorted(dims2, key=lambda d: d.name) + def test_metadata_get(self): + metadata = CollectionMetadata({"foo": "bar", "very": {"deeply": {"nested": {"path": {"to": "somewhere"}}}}}) + assert metadata.get("foo") == "bar" + assert metadata.get("very", "deeply", "nested", "path", "to") == "somewhere" + assert metadata.get("invalid", "key") is None + assert metadata.get("invalid", "key", default="nope") == "nope" + + def test_metadata_extent(self): + metadata = CollectionMetadata({"extent": {"spatial": {"xmin": 4, "xmax": 10}}}) + assert metadata.extent == {"spatial": {"xmin": 4, "xmax": 10}} + + def test_band_dimension_rename_labels(self): + b02 = Band("B02", "blue", 0.490) + b03 = Band("B03", "green", 0.560) + b04 = Band("B04", "red", 0.665) + bdim = BandDimension(name="bs", bands=[b02, b03, b04]) + + metadata = CollectionMetadata({}, dimensions=[bdim]) + newdim = metadata.rename_labels("bs", target=["1", "2", "3"]).band_dimension + assert metadata.band_dimension.band_names == ["B02", "B03", "B04"] + assert newdim.band_names == ["1", "2", "3"] + + def test_band_dimension_set_labels(self): + bdim = BandDimension(name="bs", bands=[Band("some_name", None, None)]) + + metadata = CollectionMetadata({}, dimensions=[bdim]) + newdim = metadata.rename_labels("bs", target=["1", "2", "3"]).band_dimension + assert metadata.band_dimension.band_names == ["some_name"] + assert newdim.band_names == ["1", "2", "3"] + + def test_band_dimension_rename_labels_with_source(self): + b02 = Band("B02", "blue", 0.490) + b03 = Band("B03", "green", 0.560) + b04 = Band("B04", "red", 0.665) + bdim = BandDimension(name="bs", bands=[b02, b03, b04]) + + metadata = CollectionMetadata({}, dimensions=[bdim]) + newdim = metadata.rename_labels("bs", target=["2"], source=["B03"]).band_dimension + assert metadata.band_dimension.band_names == ["B02", "B03", "B04"] + assert newdim.band_names == ["B02", "2", "B04"] + + def test_band_dimension_rename_labels_with_source_mismatch(self): + b02 = Band("B02", "blue", 0.490) + b03 = Band("B03", "green", 0.560) + bdim = BandDimension(name="bs", bands=[b02, b03]) + + metadata = CollectionMetadata({}, dimensions=[bdim]) + with pytest.raises(ValueError, match="should have same number of labels, but got"): + _ = metadata.rename_labels("bs", target=["2", "3"], source=["B03"]) + + def test_parse_dimensions_cube_dimensions_empty(self): + dims = CollectionMetadata._parse_dimensions({}) + assert_same_dimensions(dims, []) + + def test_parse_dimensions_cube_dimensions_spatial_xyt(self): + dims = CollectionMetadata._parse_dimensions( + { + "cube:dimensions": { + "xx": {"type": "spatial", "extent": [-10, 10]}, + "yy": {"type": "spatial", "extent": [-56, 83], "reference_system": 123}, + "tt": {"type": "temporal", "extent": ["2020-02-20", None]}, + } + } + ) + assert_same_dimensions( + dims, + [ + SpatialDimension(name="xx", extent=[-10, 10]), + SpatialDimension(name="yy", extent=[-56, 83], crs=123), + TemporalDimension(name="tt", extent=["2020-02-20", None]), + ], + ) + def test_parse_dimensions_cube_dimensions_spatial_xyt_bands(self): + dims = CollectionMetadata._parse_dimensions( + { + "cube:dimensions": { + "x": {"type": "spatial", "extent": [-10, 10]}, + "y": {"type": "spatial", "extent": [-56, 83], "reference_system": 123}, + "t": {"type": "temporal", "extent": ["2020-02-20", None]}, + "spectral": {"type": "bands", "values": ["red", "green", "blue"]}, + } + } + ) + assert_same_dimensions( + dims, + [ + SpatialDimension(name="x", extent=[-10, 10]), + SpatialDimension(name="y", extent=[-56, 83], crs=123), + TemporalDimension(name="t", extent=["2020-02-20", None]), + BandDimension( + name="spectral", + bands=[ + Band("red", None, None), + Band("green", None, None), + Band("blue", None, None), + ], + ), + ], + ) -def test_get_dimensions_cube_dimensions_empty(): - dims = CollectionMetadata._parse_dimensions({}) - assert_same_dimensions(dims, []) + def test_parse_dimensions_cube_dimensions_non_standard_type(self): + logs = [] + dims = CollectionMetadata._parse_dimensions( + { + "cube:dimensions": { + "bar": {"type": "foo"}, + }, + }, + complain=logs.append, + ) + assert_same_dimensions(dims, [Dimension(type="foo", name="bar")]) + assert logs == ["Unknown dimension type 'foo'"] + def test_parse_dimensions_cube_dimensions_no_band_names(self): + logs = [] + dims = CollectionMetadata._parse_dimensions( + { + "cube:dimensions": { + "spectral": {"type": "bands"}, + }, + }, + complain=logs.append, + ) + assert_same_dimensions(dims, [BandDimension(name="spectral", bands=[])]) + assert logs == ["No band names in dimension 'spectral'"] -def test_get_dimensions_cube_dimensions_spatial_xyt(): - dims = CollectionMetadata._parse_dimensions( - { - "cube:dimensions": { - "xx": {"type": "spatial", "extent": [-10, 10]}, - "yy": {"type": "spatial", "extent": [-56, 83], "reference_system": 123}, - "tt": {"type": "temporal", "extent": ["2020-02-20", None]}, - } - } - ) - assert_same_dimensions( - dims, + @pytest.mark.parametrize( + "summaries", [ - SpatialDimension(name="xx", extent=[-10, 10]), - SpatialDimension(name="yy", extent=[-56, 83], crs=123), - TemporalDimension(name="tt", extent=["2020-02-20", None]), + { + # Pre-STAC-1.1-style eo:bands + "eo:bands": [ + {"name": "r", "common_name": "red", "center_wavelength": 5}, + {"name": "g", "center_wavelength": 8}, + {"name": "b", "common_name": "blue"}, + ] + }, + { + # STAC 1.1-style bands + "bands": [ + {"name": "r", "eo:common_name": "red", "eo:center_wavelength": 5}, + {"name": "g", "eo:center_wavelength": 8}, + {"name": "b", "eo:common_name": "blue"}, + ] + }, ], ) - - -def test_get_dimensions_cube_dimensions_spatial_xyt_bands(): - dims = CollectionMetadata._parse_dimensions( - { - "cube:dimensions": { - "x": {"type": "spatial", "extent": [-10, 10]}, - "y": {"type": "spatial", "extent": [-56, 83], "reference_system": 123}, - "t": {"type": "temporal", "extent": ["2020-02-20", None]}, - "spectral": {"type": "bands", "values": ["red", "green", "blue"]}, + def test_parse_dimensions_cube_dimensions_eo_bands(self, summaries): + dims = CollectionMetadata._parse_dimensions( + { + "cube:dimensions": { + "x": {"type": "spatial", "extent": [-10, 10]}, + "y": {"type": "spatial", "extent": [-56, 83], "reference_system": 123}, + "t": {"type": "temporal", "extent": ["2020-02-20", None]}, + "spectral": {"type": "bands", "values": ["r", "g", "b"]}, + }, + "summaries": summaries, } - } - ) - assert_same_dimensions( - dims, + ) + assert_same_dimensions( + dims, + [ + SpatialDimension(name="x", extent=[-10, 10]), + SpatialDimension(name="y", extent=[-56, 83], crs=123), + TemporalDimension(name="t", extent=["2020-02-20", None]), + BandDimension( + name="spectral", + bands=[ + Band("r", "red", 5), + Band("g", None, 8), + Band("b", "blue", None), + ], + ), + ], + ) + + @pytest.mark.parametrize( + "summaries", [ - SpatialDimension(name="x", extent=[-10, 10]), - SpatialDimension(name="y", extent=[-56, 83], crs=123), - TemporalDimension(name="t", extent=["2020-02-20", None]), - BandDimension( - name="spectral", - bands=[ - Band("red", None, None), - Band("green", None, None), - Band("blue", None, None), - ], - ), + { + # Pre-STAC-1.1-style eo:bands + "eo:bands": [ + {"name": "y", "common_name": "yellow", "center_wavelength": 5}, + {"name": "c", "center_wavelength": 8}, + {"name": "m", "common_name": "magenta"}, + ] + }, + { + # STAC 1.1-style bands + "bands": [ + {"name": "y", "eo:common_name": "yellow", "eo:center_wavelength": 5}, + {"name": "c", "eo:center_wavelength": 8}, + {"name": "m", "eo:common_name": "magenta"}, + ] + }, ], ) - - -def test_get_dimensions_cube_dimensions_non_standard_type(): - logs = [] - dims = CollectionMetadata._parse_dimensions( - { - "cube:dimensions": { - "bar": {"type": "foo"}, + def test_parse_dimensions_cube_dimensions_eo_bands_mismatch(self, summaries): + logs = [] + dims = CollectionMetadata._parse_dimensions( + { + "cube:dimensions": { + "x": {"type": "spatial", "extent": [-10, 10]}, + "spectral": {"type": "bands", "values": ["r", "g", "b"]}, + }, + "summaries": summaries, }, - }, - complain=logs.append, - ) - assert_same_dimensions(dims, [Dimension(type="foo", name="bar")]) - assert logs == ["Unknown dimension type 'foo'"] - + complain=logs.append, + ) + assert_same_dimensions( + dims, + [ + SpatialDimension(name="x", extent=[-10, 10]), + BandDimension( + name="spectral", + bands=[ + Band("r", None, None), + Band("g", None, None), + Band("b", None, None), + ], + ), + ], + ) + assert logs == ["Band name mismatch: ['r', 'g', 'b'] != ['y', 'c', 'm']"] -def test_get_dimensions_cube_dimensions_no_band_names(): - logs = [] - dims = CollectionMetadata._parse_dimensions( - { - "cube:dimensions": { - "spectral": {"type": "bands"}, + @pytest.mark.parametrize( + "summaries", + [ + { + # Pre-STAC-1.1-style eo:bands + "eo:bands": [ + {"name": "y", "common_name": "yellow", "center_wavelength": 5}, + {"name": "c", "center_wavelength": 8}, + {"name": "m", "common_name": "magenta"}, + ] }, - }, - complain=logs.append, - ) - assert_same_dimensions(dims, [BandDimension(name="spectral", bands=[])]) - assert logs == ["No band names in dimension 'spectral'"] - - -@pytest.mark.parametrize( - "summaries", - [ - { - # Pre-STAC-1.1-style eo:bands - "eo:bands": [ - {"name": "r", "common_name": "red", "center_wavelength": 5}, - {"name": "g", "center_wavelength": 8}, - {"name": "b", "common_name": "blue"}, - ] - }, - { - # STAC 1.1-style bands - "bands": [ - {"name": "r", "eo:common_name": "red", "eo:center_wavelength": 5}, - {"name": "g", "eo:center_wavelength": 8}, - {"name": "b", "eo:common_name": "blue"}, - ] - }, - ], -) -def test_get_dimensions_cube_dimensions_eo_bands(summaries): - dims = CollectionMetadata._parse_dimensions( - { - "cube:dimensions": { - "x": {"type": "spatial", "extent": [-10, 10]}, - "y": {"type": "spatial", "extent": [-56, 83], "reference_system": 123}, - "t": {"type": "temporal", "extent": ["2020-02-20", None]}, - "spectral": {"type": "bands", "values": ["r", "g", "b"]}, + { + # STAC 1.1-style bands + "bands": [ + {"name": "y", "eo:common_name": "yellow", "eo:center_wavelength": 5}, + {"name": "c", "eo:center_wavelength": 8}, + {"name": "m", "eo:common_name": "magenta"}, + ] }, - "summaries": summaries, - } - ) - assert_same_dimensions( - dims, - [ - SpatialDimension(name="x", extent=[-10, 10]), - SpatialDimension(name="y", extent=[-56, 83], crs=123), - TemporalDimension(name="t", extent=["2020-02-20", None]), - BandDimension( - name="spectral", - bands=[ - Band("r", "red", 5), - Band("g", None, 8), - Band("b", "blue", None), - ], - ), ], ) - - -@pytest.mark.parametrize( - "summaries", - [ - { - # Pre-STAC-1.1-style eo:bands - "eo:bands": [ - {"name": "y", "common_name": "yellow", "center_wavelength": 5}, - {"name": "c", "center_wavelength": 8}, - {"name": "m", "common_name": "magenta"}, - ] - }, - { - # STAC 1.1-style bands - "bands": [ - {"name": "y", "eo:common_name": "yellow", "eo:center_wavelength": 5}, - {"name": "c", "eo:center_wavelength": 8}, - {"name": "m", "eo:common_name": "magenta"}, - ] - }, - ], -) -def test_get_dimensions_cube_dimensions_eo_bands_mismatch(summaries): - logs = [] - dims = CollectionMetadata._parse_dimensions( - { - "cube:dimensions": { - "x": {"type": "spatial", "extent": [-10, 10]}, - "spectral": {"type": "bands", "values": ["r", "g", "b"]}, + def test_parse_dimensions_eo_bands_only(self, summaries): + logs = [] + dims = CollectionMetadata._parse_dimensions( + { + "summaries": { + "eo:bands": [ + {"name": "y", "common_name": "yellow", "center_wavelength": 5}, + {"name": "c", "center_wavelength": 8}, + {"name": "m", "common_name": "magenta"}, + ] + } }, - "summaries": summaries, - }, - complain=logs.append, - ) - assert_same_dimensions( - dims, + complain=logs.append, + ) + assert_same_dimensions( + dims, + [ + BandDimension( + name="bands", + bands=[ + Band("y", "yellow", 5), + Band("c", None, 8), + Band("m", "magenta", None), + ], + ) + ], + ) + assert logs == ["No cube:dimensions metadata", "Assuming name 'bands' for anonymous band dimension."] + + @pytest.mark.parametrize( + "summaries", [ - SpatialDimension(name="x", extent=[-10, 10]), - BandDimension( - name="spectral", - bands=[ - Band("r", None, None), - Band("g", None, None), - Band("b", None, None), - ], - ), - ], - ) - assert logs == ["Band name mismatch: ['r', 'g', 'b'] != ['y', 'c', 'm']"] - - -@pytest.mark.parametrize( - "summaries", - [ - { - # Pre-STAC-1.1-style eo:bands - "eo:bands": [ - {"name": "y", "common_name": "yellow", "center_wavelength": 5}, - {"name": "c", "center_wavelength": 8}, - {"name": "m", "common_name": "magenta"}, - ] - }, - { - # STAC 1.1-style bands - "bands": [ - {"name": "y", "eo:common_name": "yellow", "eo:center_wavelength": 5}, - {"name": "c", "eo:center_wavelength": 8}, - {"name": "m", "eo:common_name": "magenta"}, - ] - }, - ], -) -def test_get_dimensions_eo_bands_only(summaries): - logs = [] - dims = CollectionMetadata._parse_dimensions( - { - "summaries": { + { + # Pre-STAC-1.1-style eo:bands "eo:bands": [ {"name": "y", "common_name": "yellow", "center_wavelength": 5}, {"name": "c", "center_wavelength": 8}, {"name": "m", "common_name": "magenta"}, ] - } - }, - complain=logs.append, - ) - assert_same_dimensions( - dims, - [ - BandDimension( - name="bands", - bands=[ - Band("y", "yellow", 5), - Band("c", None, 8), - Band("m", "magenta", None), - ], - ) + }, + { + # STAC 1.1-style bands + "bands": [ + {"name": "y", "eo:common_name": "yellow", "eo:center_wavelength": 5}, + {"name": "c", "eo:center_wavelength": 8}, + {"name": "m", "eo:common_name": "magenta"}, + ] + }, ], ) - assert logs == ["No cube:dimensions metadata", "Assuming name 'bands' for anonymous band dimension."] - - -@pytest.mark.parametrize( - "summaries", - [ - { - # Pre-STAC-1.1-style eo:bands - "eo:bands": [ - {"name": "y", "common_name": "yellow", "center_wavelength": 5}, - {"name": "c", "center_wavelength": 8}, - {"name": "m", "common_name": "magenta"}, - ] - }, - { - # STAC 1.1-style bands - "bands": [ - {"name": "y", "eo:common_name": "yellow", "eo:center_wavelength": 5}, - {"name": "c", "eo:center_wavelength": 8}, - {"name": "m", "eo:common_name": "magenta"}, - ] - }, - ], -) -def test_get_dimensions_no_band_dimension_with_eo_bands(summaries): - logs = [] - dims = CollectionMetadata._parse_dimensions( - { - "cube:dimensions": { - "x": {"type": "spatial", "extent": [-10, 10]}, + def test_parse_dimensions_no_band_dimension_with_eo_bands(self, summaries): + logs = [] + dims = CollectionMetadata._parse_dimensions( + { + "cube:dimensions": { + "x": {"type": "spatial", "extent": [-10, 10]}, + }, + "summaries": summaries, }, - "summaries": summaries, - }, - complain=logs.append, - ) - assert_same_dimensions( - dims, + complain=logs.append, + ) + assert_same_dimensions( + dims, + [ + SpatialDimension(name="x", extent=[-10, 10]), + ], + ) + assert logs == ["No 'bands' dimension in 'cube:dimensions' while having 'eo:bands' or 'raster:bands'"] + + @pytest.mark.parametrize( + "summaries", [ - SpatialDimension(name="x", extent=[-10, 10]), + { + # Pre-STAC-1.1-style eo:bands + "eo:bands": [ + {"name": "zu", "common_name": "foo"}, + ] + }, + { + # STAC 1.1-style bands + "bands": [ + {"name": "zu", "eo:common_name": "foo"}, + ] + }, ], ) - assert logs == ["No 'bands' dimension in 'cube:dimensions' while having 'eo:bands' or 'raster:bands'"] - - -@pytest.mark.parametrize( - "summaries", - [ - { - # Pre-STAC-1.1-style eo:bands - "eo:bands": [ - {"name": "zu", "common_name": "foo"}, - ] - }, - { - # STAC 1.1-style bands - "bands": [ - {"name": "zu", "eo:common_name": "foo"}, - ] - }, - ], -) -def test_get_dimensions_multiple_band_dimensions_with_eo_bands(summaries): - logs = [] - dims = CollectionMetadata._parse_dimensions( - { - "cube:dimensions": { - "x": {"type": "spatial", "extent": [-10, 10]}, - "spectral": {"type": "bands", "values": ["alpha", "beta"]}, - "bands": {"type": "bands", "values": ["r", "g", "b"]}, + def test_parse_dimensions_multiple_band_dimensions_with_eo_bands(self, summaries): + logs = [] + dims = CollectionMetadata._parse_dimensions( + { + "cube:dimensions": { + "x": {"type": "spatial", "extent": [-10, 10]}, + "spectral": {"type": "bands", "values": ["alpha", "beta"]}, + "bands": {"type": "bands", "values": ["r", "g", "b"]}, + }, + "summaries": summaries, }, - "summaries": summaries, - }, - complain=logs.append, - ) - assert_same_dimensions( - dims, + complain=logs.append, + ) + assert_same_dimensions( + dims, + [ + SpatialDimension(name="x", extent=[-10, 10]), + BandDimension( + name="spectral", + bands=[ + Band("alpha", None, None), + Band("beta", None, None), + ], + ), + BandDimension( + name="bands", + bands=[ + Band("r", None, None), + Band("g", None, None), + Band("b", None, None), + ], + ), + ], + ) + assert logs == ["Multiple dimensions of type 'bands'"] + + @pytest.mark.parametrize( + "spec", [ - SpatialDimension(name="x", extent=[-10, 10]), - BandDimension( - name="spectral", - bands=[ - Band("alpha", None, None), - Band("beta", None, None), - ], - ), - BandDimension( - name="bands", - bands=[ - Band("r", None, None), - Band("g", None, None), - Band("b", None, None), - ], - ), + # API 0.4 style + { + "properties": { + "cube:dimensions": { + "x": {"type": "spatial", "axis": "x"}, + "b": {"type": "bands", "values": ["foo", "bar"]}, + } + } + }, + # API 1.0 style + { + "cube:dimensions": { + "x": {"type": "spatial", "axis": "x"}, + "b": {"type": "bands", "values": ["foo", "bar"]}, + } + }, ], ) - assert logs == ["Multiple dimensions of type 'bands'"] - - -@pytest.mark.parametrize( - "spec", - [ - # API 0.4 style - { - "properties": { + def test_bands_dimension_cube_dimensions(self, spec): + metadata = CollectionMetadata(spec) + assert metadata.band_dimension.name == "b" + assert metadata.bands == [Band("foo", None, None), Band("bar", None, None)] + assert metadata.band_names == ["foo", "bar"] + assert metadata.band_common_names == [None, None] + + def test_bands_dimension_no_band_dimensions(self): + metadata = CollectionMetadata( + { "cube:dimensions": { "x": {"type": "spatial", "axis": "x"}, - "b": {"type": "bands", "values": ["foo", "bar"]}, } } - }, - # API 1.0 style - {"cube:dimensions": {"x": {"type": "spatial", "axis": "x"}, "b": {"type": "bands", "values": ["foo", "bar"]}}}, - ], -) -def test_collectionmetadata_bands_dimension_cube_dimensions(spec): - metadata = CollectionMetadata(spec) - assert metadata.band_dimension.name == "b" - assert metadata.bands == [Band("foo", None, None), Band("bar", None, None)] - assert metadata.band_names == ["foo", "bar"] - assert metadata.band_common_names == [None, None] - - -def test_cubemetdata_bands_dimension(): - metadata = CubeMetadata( - dimensions=[ - SpatialDimension(name="x", extent=None), - BandDimension(name="b", bands=[Band("foo"), Band("bar")]), - ] - ) - assert metadata.band_dimension.name == "b" - assert metadata.bands == [Band("foo", None, None), Band("bar", None, None)] - assert metadata.band_names == ["foo", "bar"] - assert metadata.band_common_names == [None, None] - + ) + with pytest.raises(MetadataException, match="No band dimension"): + metadata.band_dimension + with pytest.raises(MetadataException, match="No band dimension"): + metadata.bands + with pytest.raises(MetadataException, match="No band dimension"): + metadata.band_common_names + with pytest.raises(MetadataException, match="No band dimension"): + metadata.get_band_index("red") + with pytest.raises(MetadataException, match="No band dimension"): + metadata.filter_bands(["red"]) -def test_collectionmetadata_bands_dimension_no_band_dimensions(): - metadata = CollectionMetadata( - { - "cube:dimensions": { - "x": {"type": "spatial", "axis": "x"}, - } - } + @pytest.mark.parametrize( + "spec", + [ + { + # API 0.4 style + "properties": { + "eo:bands": [{"name": "foo", "common_name": "F00", "center_wavelength": 0.543}, {"name": "bar"}] + } + }, + { + # API 1.0 style + "summaries": { + "eo:bands": [{"name": "foo", "common_name": "F00", "center_wavelength": 0.543}, {"name": "bar"}] + } + }, + { + # STAC 1.1 style + "summaries": { + "bands": [{"name": "foo", "eo:common_name": "F00", "eo:center_wavelength": 0.543}, {"name": "bar"}] + } + }, + ], ) - with pytest.raises(MetadataException, match="No band dimension"): - metadata.band_dimension - with pytest.raises(MetadataException, match="No band dimension"): - metadata.bands - with pytest.raises(MetadataException, match="No band dimension"): - metadata.band_common_names - with pytest.raises(MetadataException, match="No band dimension"): - metadata.get_band_index("red") - with pytest.raises(MetadataException, match="No band dimension"): - metadata.filter_bands(["red"]) - - -def test_cubemetadata_bands_dimension_no_band_dimensions(): - metadata = CubeMetadata(dimensions=[SpatialDimension(name="x", extent=None)]) - with pytest.raises(MetadataException, match="No band dimension"): - metadata.band_dimension - with pytest.raises(MetadataException, match="No band dimension"): - metadata.bands - with pytest.raises(MetadataException, match="No band dimension"): - metadata.band_common_names - with pytest.raises(MetadataException, match="No band dimension"): - metadata.get_band_index("red") - with pytest.raises(MetadataException, match="No band dimension"): - metadata.filter_bands(["red"]) - - -@pytest.mark.parametrize( - "spec", - [ - { - # API 0.4 style - "properties": { - "eo:bands": [{"name": "foo", "common_name": "F00", "center_wavelength": 0.543}, {"name": "bar"}] - } - }, - { - # API 1.0 style - "summaries": { - "eo:bands": [{"name": "foo", "common_name": "F00", "center_wavelength": 0.543}, {"name": "bar"}] - } - }, - { - # STAC 1.1 style - "summaries": { - "bands": [{"name": "foo", "eo:common_name": "F00", "eo:center_wavelength": 0.543}, {"name": "bar"}] - } - }, - ], -) -def test_metadata_bands_dimension_eo_bands(spec): - metadata = CollectionMetadata(spec) - assert metadata.band_dimension.name == "bands" - assert metadata.bands == [Band("foo", "F00", 0.543), Band("bar", None, None)] - assert metadata.band_names == ["foo", "bar"] - assert metadata.band_common_names == ["F00", None] - - -@pytest.mark.parametrize( - "spec", - [ - { - # API 0.4 style - "properties": { + def test_bands_dimension_eo_bands(self, spec): + metadata = CollectionMetadata(spec) + assert metadata.band_dimension.name == "bands" + assert metadata.bands == [Band("foo", "F00", 0.543), Band("bar", None, None)] + assert metadata.band_names == ["foo", "bar"] + assert metadata.band_common_names == ["F00", None] + + @pytest.mark.parametrize( + "spec", + [ + { + # API 0.4 style + "properties": { + "cube:dimensions": { + "x": {"type": "spatial", "axis": "x"}, + "b": {"type": "bands", "values": ["foo", "bar"]}, + }, + "eo:bands": [{"name": "foo", "common_name": "F00", "center_wavelength": 0.543}, {"name": "bar"}], + } + }, + { + # API 1.0 style "cube:dimensions": { "x": {"type": "spatial", "axis": "x"}, "b": {"type": "bands", "values": ["foo", "bar"]}, }, - "eo:bands": [{"name": "foo", "common_name": "F00", "center_wavelength": 0.543}, {"name": "bar"}], - } - }, - { - # API 1.0 style - "cube:dimensions": { - "x": {"type": "spatial", "axis": "x"}, - "b": {"type": "bands", "values": ["foo", "bar"]}, - }, - "summaries": { - "eo:bands": [{"name": "foo", "common_name": "F00", "center_wavelength": 0.543}, {"name": "bar"}] - }, - }, - { - # STAC 1.1 bands style - "cube:dimensions": { - "x": {"type": "spatial", "axis": "x"}, - "b": {"type": "bands", "values": ["foo", "bar"]}, + "summaries": { + "eo:bands": [{"name": "foo", "common_name": "F00", "center_wavelength": 0.543}, {"name": "bar"}] + }, }, - "summaries": { - "bands": [{"name": "foo", "eo:common_name": "F00", "eo:center_wavelength": 0.543}, {"name": "bar"}] + { + # STAC 1.1 bands style + "cube:dimensions": { + "x": {"type": "spatial", "axis": "x"}, + "b": {"type": "bands", "values": ["foo", "bar"]}, + }, + "summaries": { + "bands": [{"name": "foo", "eo:common_name": "F00", "eo:center_wavelength": 0.543}, {"name": "bar"}] + }, }, - }, - ], -) -def test_metadata_bands_dimension(spec): - metadata = CollectionMetadata(spec) - assert metadata.band_dimension.name == "b" - assert metadata.bands == [Band("foo", "F00", 0.543), Band("bar", None, None)] - assert metadata.band_names == ["foo", "bar"] - assert metadata.band_common_names == ["F00", None] - - -def test_cubemetadata_rename_labels(): - metadata = CubeMetadata( - dimensions=[ - SpatialDimension(name="x", extent=None), - BandDimension(name="b", bands=[Band("red"), Band("green")]), - ] - ) - renamed = metadata.rename_labels(dimension="b", target=["raspberry", "lime"]) - assert isinstance(renamed, CubeMetadata) - assert renamed.dimension_names() == ["x", "b"] - assert renamed.band_names == ["raspberry", "lime"] - assert renamed.bands == [Band("raspberry"), Band("lime")] - - -def test_cubemetadata_rename_labels_invalid_dimension(): - metadata = CubeMetadata( - dimensions=[ - SpatialDimension(name="x", extent=None), - BandDimension(name="b", bands=[Band("red"), Band("green")]), - ] + ], ) - with pytest.raises(ValueError, match=re.escape("Invalid dimension 'tempus'. Should be one of ['x', 'b']")): - _ = metadata.rename_labels(dimension="tempus", target=["1999"]) + def test_bands_dimension(self, spec): + metadata = CollectionMetadata(spec) + assert metadata.band_dimension.name == "b" + assert metadata.bands == [Band("foo", "F00", 0.543), Band("bar", None, None)] + assert metadata.band_names == ["foo", "bar"] + assert metadata.band_common_names == ["F00", None] + + def test_reduce_dimension(self): + metadata = CollectionMetadata( + {"cube:dimensions": {"x": {"type": "spatial"}, "b": {"type": "bands", "values": ["red", "green"]}}} + ) + reduced = metadata.reduce_dimension("b") + assert set(metadata.dimension_names()) == {"x", "b"} + assert set(reduced.dimension_names()) == {"x"} + def test_reduce_dimension_invalid_name(self): + metadata = CollectionMetadata( + {"cube:dimensions": {"x": {"type": "spatial"}, "b": {"type": "bands", "values": ["red", "green"]}}} + ) + with pytest.raises(ValueError): + metadata.reduce_dimension("y") -def test_cubemetadata_rename_dimension(): - metadata = CubeMetadata( - dimensions=[ - SpatialDimension(name="x", extent=None), - BandDimension(name="b", bands=[Band("red"), Band("green")]), - ] - ) - renamed = metadata.rename_dimension("b", "bands") - assert renamed.dimension_names() == ["x", "bands"] - assert renamed.band_dimension.name == "bands" - assert renamed.band_names == ["red", "green"] + def test_add_band_dimension(self): + metadata = CollectionMetadata({"cube:dimensions": {"t": {"type": "temporal"}}}) + new = metadata.add_dimension("layer", "red", "bands") + assert metadata.dimension_names() == ["t"] + assert not metadata.has_band_dimension() -def test_cubemetadata_rename_dimension_invalid_dimension(): - metadata = CubeMetadata( - dimensions=[ - SpatialDimension(name="x", extent=None), - BandDimension(name="b", bands=[Band("red"), Band("green")]), - ] - ) - with pytest.raises(ValueError, match=re.escape("Invalid dimension 'bandzz'. Should be one of ['x', 'b']")): - _ = metadata.rename_dimension("bandzz", "bands") + assert new.has_band_dimension() + assert new.dimension_names() == ["t", "layer"] + assert new.band_dimension.name == "layer" + assert new.band_names == ["red"] + def test_add_band_dimension_duplicate(self): + metadata = CollectionMetadata({"cube:dimensions": {"t": {"type": "temporal"}}}) + metadata = metadata.add_dimension("layer", "red", "bands") + with pytest.raises(DimensionAlreadyExistsException, match="Dimension with name 'layer' already exists"): + _ = metadata.add_dimension("layer", "red", "bands") -def test_collectionmetadata_reduce_dimension(): - metadata = CollectionMetadata( - {"cube:dimensions": {"x": {"type": "spatial"}, "b": {"type": "bands", "values": ["red", "green"]}}} - ) - reduced = metadata.reduce_dimension("b") - assert set(metadata.dimension_names()) == {"x", "b"} - assert set(reduced.dimension_names()) == {"x"} + def test_add_temporal_dimension(self): + metadata = CollectionMetadata({"cube:dimensions": {"x": {"type": "spatial"}}}) + new = metadata.add_dimension("date", "2020-05-15", "temporal") + assert metadata.dimension_names() == ["x"] + assert not metadata.has_temporal_dimension() -def test_cubemetadata_reduce_dimension(): - metadata = CubeMetadata( - dimensions=[ - SpatialDimension(name="x", extent=None), - BandDimension(name="b", bands=[Band("red"), Band("green")]), - ] - ) - reduced = metadata.reduce_dimension("b") - assert set(metadata.dimension_names()) == {"x", "b"} - assert set(reduced.dimension_names()) == {"x"} + assert new.has_temporal_dimension() + assert new.dimension_names() == ["x", "date"] + assert new.temporal_dimension.name == "date" + assert new.temporal_dimension.extent == ["2020-05-15", "2020-05-15"] + def test_add_temporal_dimension_duplicate(self): + metadata = CollectionMetadata({"cube:dimensions": {"x": {"type": "spatial"}}}) + metadata = metadata.add_dimension("date", "2020-05-15", "temporal") + with pytest.raises(DimensionAlreadyExistsException, match="Dimension with name 'date' already exists"): + _ = metadata.add_dimension("date", "2020-05-15", "temporal") -def test_collectionmetadata_reduce_dimension_invalid_name(): - metadata = CollectionMetadata( - {"cube:dimensions": {"x": {"type": "spatial"}, "b": {"type": "bands", "values": ["red", "green"]}}} - ) - with pytest.raises(ValueError): - metadata.reduce_dimension("y") + def test_drop_dimension(self): + metadata = CollectionMetadata( + { + "cube:dimensions": { + "t": {"type": "temporal"}, + "b": {"type": "bands", "values": ["red", "green"]}, + } + } + ) + new = metadata.drop_dimension("t") + assert metadata.dimension_names() == ["t", "b"] + assert new.dimension_names() == ["b"] + assert new.band_dimension.band_names == ["red", "green"] + + new = metadata.drop_dimension("b") + assert metadata.dimension_names() == ["t", "b"] + assert new.dimension_names() == ["t"] + assert new.temporal_dimension.name == "t" + + with pytest.raises(ValueError): + metadata.drop_dimension("x") + + +class TestCubeMetadata: + def test_band_dimension_rename_labels(self): + b02 = Band("B02", "blue", 0.490) + b03 = Band("B03", "green", 0.560) + b04 = Band("B04", "red", 0.665) + bdim = BandDimension(name="bs", bands=[b02, b03, b04]) + + metadata = CubeMetadata(dimensions=[bdim]) + newdim = metadata.rename_labels("bs", target=["1", "2", "3"]).band_dimension + assert metadata.band_dimension.band_names == ["B02", "B03", "B04"] + assert newdim.band_names == ["1", "2", "3"] + + def test_band_dimension_set_labels(self): + bdim = BandDimension(name="bs", bands=[Band("some_name", None, None)]) + + metadata = CubeMetadata(dimensions=[bdim]) + newdim = metadata.rename_labels("bs", target=["1", "2", "3"]).band_dimension + assert metadata.band_dimension.band_names == ["some_name"] + assert newdim.band_names == ["1", "2", "3"] + + def test_band_dimension_rename_labels_with_source(self): + b02 = Band("B02", "blue", 0.490) + b03 = Band("B03", "green", 0.560) + b04 = Band("B04", "red", 0.665) + bdim = BandDimension(name="bs", bands=[b02, b03, b04]) + + metadata = CubeMetadata(dimensions=[bdim]) + newdim = metadata.rename_labels("bs", target=["2"], source=["B03"]).band_dimension + assert metadata.band_dimension.band_names == ["B02", "B03", "B04"] + assert newdim.band_names == ["B02", "2", "B04"] + + def test_band_dimension_rename_labels_with_source_mismatch(self): + b02 = Band("B02", "blue", 0.490) + b03 = Band("B03", "green", 0.560) + bdim = BandDimension(name="bs", bands=[b02, b03]) + + metadata = CubeMetadata(dimensions=[bdim]) + with pytest.raises(ValueError, match="should have same number of labels, but got"): + _ = metadata.rename_labels("bs", target=["2", "3"], source=["B03"]) + + def test_cubemetdata_bands_dimension(self): + metadata = CubeMetadata( + dimensions=[ + SpatialDimension(name="x", extent=None), + BandDimension(name="b", bands=[Band("foo"), Band("bar")]), + ] + ) + assert metadata.band_dimension.name == "b" + assert metadata.bands == [Band("foo", None, None), Band("bar", None, None)] + assert metadata.band_names == ["foo", "bar"] + assert metadata.band_common_names == [None, None] + + def test_bands_dimension_no_band_dimensions(self): + metadata = CubeMetadata(dimensions=[SpatialDimension(name="x", extent=None)]) + with pytest.raises(MetadataException, match="No band dimension"): + metadata.band_dimension + with pytest.raises(MetadataException, match="No band dimension"): + metadata.bands + with pytest.raises(MetadataException, match="No band dimension"): + metadata.band_common_names + with pytest.raises(MetadataException, match="No band dimension"): + metadata.get_band_index("red") + with pytest.raises(MetadataException, match="No band dimension"): + metadata.filter_bands(["red"]) + + def test_rename_labels(self): + metadata = CubeMetadata( + dimensions=[ + SpatialDimension(name="x", extent=None), + BandDimension(name="b", bands=[Band("red"), Band("green")]), + ] + ) + renamed = metadata.rename_labels(dimension="b", target=["raspberry", "lime"]) + assert isinstance(renamed, CubeMetadata) + assert renamed.dimension_names() == ["x", "b"] + assert renamed.band_names == ["raspberry", "lime"] + assert renamed.bands == [Band("raspberry"), Band("lime")] + + def test_rename_labels_invalid_dimension(self): + metadata = CubeMetadata( + dimensions=[ + SpatialDimension(name="x", extent=None), + BandDimension(name="b", bands=[Band("red"), Band("green")]), + ] + ) + with pytest.raises(ValueError, match=re.escape("Invalid dimension 'tempus'. Should be one of ['x', 'b']")): + _ = metadata.rename_labels(dimension="tempus", target=["1999"]) + + def test_rename_dimension(self): + metadata = CubeMetadata( + dimensions=[ + SpatialDimension(name="x", extent=None), + BandDimension(name="b", bands=[Band("red"), Band("green")]), + ] + ) + renamed = metadata.rename_dimension("b", "bands") + assert renamed.dimension_names() == ["x", "bands"] + assert renamed.band_dimension.name == "bands" + assert renamed.band_names == ["red", "green"] + + def test_rename_dimension_invalid_dimension(self): + metadata = CubeMetadata( + dimensions=[ + SpatialDimension(name="x", extent=None), + BandDimension(name="b", bands=[Band("red"), Band("green")]), + ] + ) + with pytest.raises(ValueError, match=re.escape("Invalid dimension 'bandzz'. Should be one of ['x', 'b']")): + _ = metadata.rename_dimension("bandzz", "bands") + + def test_reduce_dimension(self): + metadata = CubeMetadata( + dimensions=[ + SpatialDimension(name="x", extent=None), + BandDimension(name="b", bands=[Band("red"), Band("green")]), + ] + ) + reduced = metadata.reduce_dimension("b") + assert set(metadata.dimension_names()) == {"x", "b"} + assert set(reduced.dimension_names()) == {"x"} + + def test_reduce_dimension_invalid_name(self): + metadata = CubeMetadata( + dimensions=[ + SpatialDimension(name="x", extent=None), + BandDimension(name="b", bands=[Band("red"), Band("green")]), + ] + ) + with pytest.raises(ValueError): + metadata.reduce_dimension("y") + + def test_add_band_dimension(self): + metadata = CubeMetadata(dimensions=[TemporalDimension(name="t", extent=None)]) + new = metadata.add_dimension("layer", "red", "bands") + + assert metadata.dimension_names() == ["t"] + assert not metadata.has_band_dimension() + + assert new.has_band_dimension() + assert new.dimension_names() == ["t", "layer"] + assert new.band_dimension.name == "layer" + assert new.band_names == ["red"] + + def test_add_band_dimension_duplicate(self): + metadata = CubeMetadata(dimensions=[TemporalDimension(name="t", extent=None)]) + metadata = metadata.add_dimension("layer", "red", "bands") + with pytest.raises(DimensionAlreadyExistsException, match="Dimension with name 'layer' already exists"): + _ = metadata.add_dimension("layer", "red", "bands") + + def test_append_band(self): + metadata1 = CubeMetadata( + dimensions=[ + BandDimension(name="b", bands=[Band("red"), Band("green")]), + ] + ) + metadata2 = metadata1.append_band(Band("blue")) + metadata3 = metadata1.append_band("pink") + assert metadata1.band_names == ["red", "green"] + assert metadata2.band_names == ["red", "green", "blue"] + assert metadata3.band_names == ["red", "green", "pink"] -def test_cubemetadata_reduce_dimension_invalid_name(): - metadata = CubeMetadata( - dimensions=[ - SpatialDimension(name="x", extent=None), - BandDimension(name="b", bands=[Band("red"), Band("green")]), - ] - ) - with pytest.raises(ValueError): - metadata.reduce_dimension("y") + def test_add_temporal_dimension(self): + metadata = CubeMetadata(dimensions=[SpatialDimension(name="x", extent=None)]) + new = metadata.add_dimension("date", "2020-05-15", "temporal") + assert metadata.dimension_names() == ["x"] + assert not metadata.has_temporal_dimension() -def test_collectionmetadata_add_band_dimension(): - metadata = CollectionMetadata({"cube:dimensions": {"t": {"type": "temporal"}}}) - new = metadata.add_dimension("layer", "red", "bands") + assert new.has_temporal_dimension() + assert new.dimension_names() == ["x", "date"] + assert new.temporal_dimension.name == "date" + assert new.temporal_dimension.extent == ["2020-05-15", "2020-05-15"] + + def test_add_temporal_dimension_duplicate(self): + metadata = CubeMetadata(dimensions=[SpatialDimension(name="x", extent=None)]) + metadata = metadata.add_dimension("date", "2020-05-15", "temporal") + with pytest.raises(DimensionAlreadyExistsException, match="Dimension with name 'date' already exists"): + _ = metadata.add_dimension("date", "2020-05-15", "temporal") + + def test_add_dimension_geometry(self): + orig = CubeMetadata(dimensions=[TemporalDimension(name="t", extent=None)]) + new = orig.add_dimension(name="fields", label="Mol", type="geometry") + + assert orig.dimension_names() == ["t"] + assert not orig.has_geometry_dimension() + with pytest.raises(MetadataException): + _ = orig.geometry_dimension + + assert new.dimension_names() == ["t", "fields"] + assert new.has_geometry_dimension() + assert new.geometry_dimension.name == "fields" + assert new.geometry_dimension.type == "geometry" + + def test_drop_dimension(self): + metadata = CubeMetadata( + dimensions=[ + TemporalDimension(name="t", extent=None), + BandDimension(name="b", bands=[Band("red"), Band("green")]), + ] + ) - assert metadata.dimension_names() == ["t"] - assert not metadata.has_band_dimension() + new = metadata.drop_dimension("t") + assert metadata.dimension_names() == ["t", "b"] + assert new.dimension_names() == ["b"] + assert new.band_dimension.band_names == ["red", "green"] - assert new.has_band_dimension() - assert new.dimension_names() == ["t", "layer"] - assert new.band_dimension.name == "layer" - assert new.band_names == ["red"] + new = metadata.drop_dimension("b") + assert metadata.dimension_names() == ["t", "b"] + assert new.dimension_names() == ["t"] + assert new.temporal_dimension.name == "t" + with pytest.raises(ValueError): + metadata.drop_dimension("x") -def test_cubemetadata_add_band_dimension(): - metadata = CubeMetadata(dimensions=[TemporalDimension(name="t", extent=None)]) - new = metadata.add_dimension("layer", "red", "bands") + def test_ensure_band_dimension_add_bands(self): + metadata = CubeMetadata( + dimensions=[ + TemporalDimension(name="t", extent=None), + ] + ) + new = metadata._ensure_band_dimension(bands=["red", "green"], warning="ensure_band_dimension at work") + assert new.has_band_dimension() + assert new.dimension_names() == ["t", "bands"] + assert new.band_names == ["red", "green"] + + def test_ensure_band_dimension_add_name_and_bands(self): + metadata = CubeMetadata( + dimensions=[ + TemporalDimension(name="t", extent=None), + ] + ) + new = metadata._ensure_band_dimension( + name="bandzz", bands=["red", "green"], warning="ensure_band_dimension at work" + ) + assert new.has_band_dimension() + assert new.dimension_names() == ["t", "bandzz"] + assert new.band_names == ["red", "green"] + + def test_ensure_band_dimension_override_bands(self): + metadata = CubeMetadata( + dimensions=[ + TemporalDimension(name="t", extent=None), + BandDimension(name="bands", bands=[Band("red"), Band("green")]), + ] + ) + new = metadata._ensure_band_dimension(bands=["tomato", "lettuce"], warning="ensure_band_dimension at work") + assert new.has_band_dimension() + assert new.dimension_names() == ["t", "bands"] + assert new.band_names == ["tomato", "lettuce"] + + def test_ensure_band_dimension_override_name_and_bands(self): + metadata = CubeMetadata( + dimensions=[ + TemporalDimension(name="t", extent=None), + BandDimension(name="bands", bands=[Band("red"), Band("green")]), + ] + ) + new = metadata._ensure_band_dimension( + name="bandzz", bands=["tomato", "lettuce"], warning="ensure_band_dimension at work" + ) + assert new.has_band_dimension() + assert new.dimension_names() == ["t", "bandzz"] + assert new.band_names == ["tomato", "lettuce"] - assert metadata.dimension_names() == ["t"] - assert not metadata.has_band_dimension() + def test_subclass(self): + class MyCubeMetadata(CubeMetadata): + def __init__(self, dimensions: List[Dimension], bbox=None): + super().__init__(dimensions=dimensions) + self.bbox = bbox - assert new.has_band_dimension() - assert new.dimension_names() == ["t", "layer"] - assert new.band_dimension.name == "layer" - assert new.band_names == ["red"] + def _clone_and_update(self, dimensions: List[Dimension] = None, bbox=None, **kwargs) -> MyCubeMetadata: + return super()._clone_and_update(dimensions=dimensions, bbox=bbox or self.bbox, **kwargs) + def filter_bbox(self, bbox): + return self._clone_and_update(bbox=bbox) -def test_collectionmetadata_add_band_dimension_duplicate(): - metadata = CollectionMetadata({"cube:dimensions": {"t": {"type": "temporal"}}}) - metadata = metadata.add_dimension("layer", "red", "bands") - with pytest.raises(DimensionAlreadyExistsException, match="Dimension with name 'layer' already exists"): - _ = metadata.add_dimension("layer", "red", "bands") + orig = MyCubeMetadata([SpatialDimension(name="x", extent=None)]) + assert orig.bbox is None + new = orig.add_dimension(name="layer", label="red", type="bands") + assert isinstance(new, MyCubeMetadata) + assert orig.bbox is None + assert new.bbox is None -def test_cubemetadata_add_band_dimension_duplicate(): - metadata = CubeMetadata(dimensions=[TemporalDimension(name="t", extent=None)]) - metadata = metadata.add_dimension("layer", "red", "bands") - with pytest.raises(DimensionAlreadyExistsException, match="Dimension with name 'layer' already exists"): - _ = metadata.add_dimension("layer", "red", "bands") + new = new.filter_bbox((1, 2, 3, 4)) + assert isinstance(new, MyCubeMetadata) + assert orig.bbox is None + assert new.bbox == (1, 2, 3, 4) + new = new.add_dimension(name="time", label="2020", type="time") + assert isinstance(new, MyCubeMetadata) + assert orig.bbox is None + assert new.bbox == (1, 2, 3, 4) -def test_cubemetadata_append_band(): - metadata1 = CubeMetadata( - dimensions=[ - BandDimension(name="b", bands=[Band("red"), Band("green")]), - ] + @pytest.mark.parametrize( + ["kwargs", "expected_x", "expected_y"], + [ + ({}, {"crs": 4326, "step": 0.1}, {"crs": 4326, "step": 0.1}), + ({"resolution": 2}, {"crs": 4326, "step": 2}, {"crs": 4326, "step": 2}), + ({"resolution": [0.5, 2]}, {"crs": 4326, "step": 0.5}, {"crs": 4326, "step": 2}), + ({"projection": 32631}, {"crs": 32631, "step": 0.1}, {"crs": 32631, "step": 0.1}), + ({"resolution": 10, "projection": 32631}, {"crs": 32631, "step": 10}, {"crs": 32631, "step": 10}), + ({"resolution": [11, 22], "projection": 32631}, {"crs": 32631, "step": 11}, {"crs": 32631, "step": 22}), + ], ) - metadata2 = metadata1.append_band(Band("blue")) - metadata3 = metadata1.append_band("pink") - assert metadata1.band_names == ["red", "green"] - assert metadata2.band_names == ["red", "green", "blue"] - assert metadata3.band_names == ["red", "green", "pink"] - - -def test_collectionmetadata_add_temporal_dimension(): - metadata = CollectionMetadata({"cube:dimensions": {"x": {"type": "spatial"}}}) - new = metadata.add_dimension("date", "2020-05-15", "temporal") - - assert metadata.dimension_names() == ["x"] - assert not metadata.has_temporal_dimension() - - assert new.has_temporal_dimension() - assert new.dimension_names() == ["x", "date"] - assert new.temporal_dimension.name == "date" - assert new.temporal_dimension.extent == ["2020-05-15", "2020-05-15"] - - -def test_cubemetadata_add_temporal_dimension(): - metadata = CubeMetadata(dimensions=[SpatialDimension(name="x", extent=None)]) - new = metadata.add_dimension("date", "2020-05-15", "temporal") - - assert metadata.dimension_names() == ["x"] - assert not metadata.has_temporal_dimension() - - assert new.has_temporal_dimension() - assert new.dimension_names() == ["x", "date"] - assert new.temporal_dimension.name == "date" - assert new.temporal_dimension.extent == ["2020-05-15", "2020-05-15"] - - -def test_collectionmetadata_add_temporal_dimension_duplicate(): - metadata = CollectionMetadata({"cube:dimensions": {"x": {"type": "spatial"}}}) - metadata = metadata.add_dimension("date", "2020-05-15", "temporal") - with pytest.raises(DimensionAlreadyExistsException, match="Dimension with name 'date' already exists"): - _ = metadata.add_dimension("date", "2020-05-15", "temporal") - - -def test_cubemetadata_add_temporal_dimension_duplicate(): - metadata = CubeMetadata(dimensions=[SpatialDimension(name="x", extent=None)]) - metadata = metadata.add_dimension("date", "2020-05-15", "temporal") - with pytest.raises(DimensionAlreadyExistsException, match="Dimension with name 'date' already exists"): - _ = metadata.add_dimension("date", "2020-05-15", "temporal") + @pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) + def test_resample_spatial(self, cube_metadata, kwargs, expected_x, expected_y): + metadata = cube_metadata.resample_spatial(**kwargs) + assert isinstance(metadata, CubeMetadata) + assert metadata.spatial_dimensions == [ + SpatialDimension(name="x", extent=[2, 7], **expected_x), + SpatialDimension(name="y", extent=[49, 52], **expected_y), + ] + assert metadata.temporal_dimension == cube_metadata.temporal_dimension + assert metadata.band_dimension == cube_metadata.band_dimension + + @pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) + def test_resample_spatial_parameter_resolution_only(self, cube_metadata): + """When resolution is a Parameter, step should remain unchanged and crs should stay as-is.""" + param = Parameter.number("res", description="The spatial resolution.") + metadata = cube_metadata.resample_spatial(resolution=param) + assert isinstance(metadata, CubeMetadata) + # step must be set to None after parametrized resample_spatial. + assert metadata.spatial_dimensions == [ + SpatialDimension(name="x", extent=[2, 7], crs=4326, step=None), + SpatialDimension(name="y", extent=[49, 52], crs=4326, step=None), + ] + assert metadata.temporal_dimension == cube_metadata.temporal_dimension + assert metadata.band_dimension == cube_metadata.band_dimension + + @pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) + def test_resample_spatial_parameter_resolution_with_projection(self, cube_metadata): + """When resolution is a Parameter but projection is concrete, crs should be updated.""" + param = Parameter.number("res", description="The spatial resolution.") + metadata = cube_metadata.resample_spatial(resolution=param, projection=32631) + assert isinstance(metadata, CubeMetadata) + assert metadata.spatial_dimensions == [ + SpatialDimension(name="x", extent=[2, 7], crs=32631, step=None), + SpatialDimension(name="y", extent=[49, 52], crs=32631, step=None), + ] + assert metadata.temporal_dimension == cube_metadata.temporal_dimension + assert metadata.band_dimension == cube_metadata.band_dimension + + @pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) + def test_resample_spatial_parameter_projection_only(self, cube_metadata): + """When projection is a Parameter, crs should be set to None.""" + param = Parameter.integer("proj", description="The target projection.") + metadata = cube_metadata.resample_spatial(resolution=10, projection=param) + assert isinstance(metadata, CubeMetadata) + assert metadata.spatial_dimensions == [ + SpatialDimension(name="x", extent=[2, 7], crs=None, step=10), + SpatialDimension(name="y", extent=[49, 52], crs=None, step=10), + ] + assert metadata.temporal_dimension == cube_metadata.temporal_dimension + assert metadata.band_dimension == cube_metadata.band_dimension + + @pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) + def test_resample_spatial_parameter_resolution_and_projection(self, cube_metadata): + """When both resolution and projection are Parameters, step and crs should both be None.""" + res_param = Parameter.number("res", description="The spatial resolution.") + proj_param = Parameter.integer("proj", description="The target projection.") + metadata = cube_metadata.resample_spatial(resolution=res_param, projection=proj_param) + assert isinstance(metadata, CubeMetadata) + assert metadata.spatial_dimensions == [ + SpatialDimension(name="x", extent=[2, 7], crs=None, step=None), + SpatialDimension(name="y", extent=[49, 52], crs=None, step=None), + ] + assert metadata.temporal_dimension == cube_metadata.temporal_dimension + assert metadata.band_dimension == cube_metadata.band_dimension + @pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) + def test_resample_cube_spatial(self, cube_metadata): + metadata1 = cube_metadata.resample_spatial(resolution=(11, 22), projection=32631) + metadata2 = cube_metadata.resample_spatial(resolution=0.5) -def test_cube_metadata_add_dimension_geometry(): - orig = CubeMetadata(dimensions=[TemporalDimension(name="t", extent=None)]) - new = orig.add_dimension(name="fields", label="Mol", type="geometry") + assert metadata1.spatial_dimensions == [ + SpatialDimension(name="x", extent=[2, 7], crs=32631, step=11), + SpatialDimension(name="y", extent=[49, 52], crs=32631, step=22), + ] + assert metadata2.spatial_dimensions == [ + SpatialDimension(name="x", extent=[2, 7], crs=4326, step=0.5), + SpatialDimension(name="y", extent=[49, 52], crs=4326, step=0.5), + ] - assert orig.dimension_names() == ["t"] - assert not orig.has_geometry_dimension() - with pytest.raises(MetadataException): - orig.geometry_dimension() + metadata12 = metadata1.resample_cube_spatial(target=metadata2) + assert metadata12.spatial_dimensions == [ + SpatialDimension(name="x", extent=[2, 7], crs=4326, step=0.5), + SpatialDimension(name="y", extent=[49, 52], crs=4326, step=0.5), + ] - assert new.dimension_names() == ["t", "fields"] - assert new.has_geometry_dimension() - assert new.geometry_dimension.name == "fields" - assert new.geometry_dimension.type == "geometry" + metadata21 = metadata2.resample_cube_spatial(target=metadata1) + assert metadata21.spatial_dimensions == [ + SpatialDimension(name="x", extent=[2, 7], crs=32631, step=11), + SpatialDimension(name="y", extent=[49, 52], crs=32631, step=22), + ] -def test_collectionmetadata_drop_dimension(): - metadata = CollectionMetadata( - { - "cube:dimensions": { - "t": {"type": "temporal"}, - "b": {"type": "bands", "values": ["red", "green"]}, - } - } - ) + def test_resample_cube_spatial_preserve_non_spatial(self): + xy1 = [ + SpatialDimension(name="x", extent=[12, 17], crs=4326, step=0.1), + SpatialDimension(name="y", extent=[41, 51], crs=4326, step=0.1), + ] + t1 = TemporalDimension(name="t", extent=["2024-01-01", "2024-10-01"]) + b1 = BandDimension(name="bands", bands=[Band("B1"), Band("B11")]) + metadata1 = CubeMetadata(dimensions=xy1 + [t1, b1]) - new = metadata.drop_dimension("t") - assert metadata.dimension_names() == ["t", "b"] - assert new.dimension_names() == ["b"] - assert new.band_dimension.band_names == ["red", "green"] + xy2 = [ + SpatialDimension(name="x", extent=[22, 27], crs=4326, step=0.1), + SpatialDimension(name="y", extent=[42, 52], crs=4326, step=0.1), + ] + t2 = TemporalDimension(name="t", extent=["2024-02-02", "2024-12-02"]) + metadata2 = CubeMetadata(dimensions=xy2 + [t2]) - new = metadata.drop_dimension("b") - assert metadata.dimension_names() == ["t", "b"] - assert new.dimension_names() == ["t"] - assert new.temporal_dimension.name == "t" + xy3 = [ + SpatialDimension(name="x", extent=[32, 37], crs=4326, step=0.1), + SpatialDimension(name="y", extent=[43, 53], crs=4326, step=0.1), + ] + b3 = BandDimension(name="bands", bands=[Band("B3"), Band("B33")]) + metadata3 = CubeMetadata(dimensions=xy3 + [b3]) + + result12 = metadata1.resample_cube_spatial(target=metadata2) + assert result12.spatial_dimensions == xy2 + assert result12.band_dimension == b1 + assert result12.temporal_dimension == t1 + + result21 = metadata2.resample_cube_spatial(target=metadata1) + assert result21.spatial_dimensions == xy1 + assert not result21.has_band_dimension() + assert result21.temporal_dimension == t2 + + result23 = metadata2.resample_cube_spatial(target=metadata3) + assert result23.spatial_dimensions == xy3 + assert not result23.has_band_dimension() + assert result23.temporal_dimension == t2 + + result31 = metadata3.resample_cube_spatial(target=metadata1) + assert result31.spatial_dimensions == xy1 + assert result31.band_dimension == b3 + assert not result31.has_temporal_dimension() + + def test_repr_empty(self): + metadata = CubeMetadata() + assert repr(metadata) == "CubeMetadata(dimensions=None)" + + def test_repr_with_bands(self): + assert ( + repr(CUBE_METADATA_XYTB) + == "CubeMetadata(dimension_names=['x', 'y', 't', 'bands'], band_names=['B2', 'B3'])" + ) - with pytest.raises(ValueError): - metadata.drop_dimension("x") + def test_repr_no_bands(self): + metadata = CubeMetadata( + dimensions=[ + SpatialDimension(name="x", extent=[2, 7], crs=4326, step=0.1), + TemporalDimension(name="t", extent=["2024-09-01", "2024-12-01"]), + ] + ) + assert repr(metadata) == "CubeMetadata(dimension_names=['x', 't'])" + + def test_repr_html_empty(self): + metadata = CubeMetadata() + html = metadata._repr_html_() + assert "No cube (dimension) metadata" in html + + def test_repr_html_with_dimensions(self): + html = CUBE_METADATA_XYTB._repr_html_() + assert html == dirty_equals.IsStr( + regex=r""" + .*
xspatial
yspatial
ttemporal\?extent:.*2026-09-01.*2026-09-20.*
bandsbands2bands:\sB03,\sB04
+ .*.*Dimension.*Type.*Size.* + .* + .*.* + .*.* + .*.* + .* + .*""", + regex_flags=re.DOTALL | re.VERBOSE, + ) -def test_cubemetadata_drop_dimension(): - metadata = CubeMetadata( - dimensions=[ - TemporalDimension(name="t", extent=None), - BandDimension(name="b", bands=[Band("red"), Band("green")]), - ] +class TestStacMetadataParser: + @pytest.mark.parametrize( + "test_stac, expected", + [ + ( + StacDummyBuilder.collection(summaries={"eo:bands": [{"name": "B01"}, {"name": "B02"}]}), + ["B01", "B02"], + ), + ( + StacDummyBuilder.collection(summaries={"bands": [{"name": "B01"}, {"name": "B02"}]}), + ["B01", "B02"], + ), + # TODO: test asset handling in collection? + ( + StacDummyBuilder.catalog(), + [], + ), + ( + StacDummyBuilder.item( + properties={"datetime": "2020-05-22T00:00:00Z", "eo:bands": [{"name": "SCL"}, {"name": "B08"}]} + ), + ["SCL", "B08"], + ), + ( + StacDummyBuilder.item( + properties={"datetime": "2020-05-22T00:00:00Z", "bands": [{"name": "SCL"}, {"name": "B08"}]} + ), + ["SCL", "B08"], + ), + # TODO: test asset handling in item? + ], ) + def test_metadata_from_stac_bands(self, tmp_path, test_stac, expected): + path = tmp_path / "stac.json" + # TODO #738 real request mocking of STAC resources compatible with pystac? + path.write_text(json.dumps(test_stac)) + metadata = metadata_from_stac(str(path)) + assert metadata.band_names == expected + + def test_metadata_from_stac_stac_1_1_common_bands_without_datacube_extension(self, tmp_path): + stac_dict = StacDummyBuilder.collection( + stac_version="1.1.0", + bands=[ + {"name": "red", "eo:common_name": "red", "eo:center_wavelength": 0.665}, + {"name": "nir", "eo:common_name": "nir", "eo:center_wavelength": 0.842}, + ], + ) + assert "stac_extensions" not in stac_dict + assert "cube:dimensions" not in stac_dict - new = metadata.drop_dimension("t") - assert metadata.dimension_names() == ["t", "b"] - assert new.dimension_names() == ["b"] - assert new.band_dimension.band_names == ["red", "green"] - - new = metadata.drop_dimension("b") - assert metadata.dimension_names() == ["t", "b"] - assert new.dimension_names() == ["t"] - assert new.temporal_dimension.name == "t" - - with pytest.raises(ValueError): - metadata.drop_dimension("x") + path = tmp_path / "stac.json" + # TODO #738 real request mocking of STAC resources compatible with pystac? + path.write_text(json.dumps(stac_dict)) + metadata = metadata_from_stac(str(path)) + assert metadata.dimension_names() == ["x", "y", "bands", "t"] + assert metadata.band_names == ["red", "nir"] + assert metadata.band_dimension.bands[0].common_name == "red" + assert metadata.band_dimension.bands[1].wavelength_um == 0.842 -def test_cubemetadata_ensure_band_dimension_add_bands(): - metadata = CubeMetadata( - dimensions=[ - TemporalDimension(name="t", extent=None), - ] + @pytest.mark.parametrize( + ["eo_extension_is_declared", "expected_warnings"], + [ + ( + False, + [ + "Deriving band listing from unordered `item_assets`", + "Using 'eo:bands' metadata, but STAC extension eo was not declared.", + ], + ), + (True, ["Deriving band listing from unordered `item_assets`"]), + ], ) - new = metadata._ensure_band_dimension(bands=["red", "green"], warning="ensure_band_dimension at work") - assert new.has_band_dimension() - assert new.dimension_names() == ["t", "bands"] - assert new.band_names == ["red", "green"] - - -def test_cubemetadata_ensure_band_dimension_add_name_and_bands(): - metadata = CubeMetadata( - dimensions=[ - TemporalDimension(name="t", extent=None), + def test_metadata_from_stac_collection_bands_from_item_assets( + self, test_data, tmp_path, eo_extension_is_declared, caplog, expected_warnings + ): + stac_data = test_data.load_json("stac/collections/agera5_daily01.json") + stac_data["stac_extensions"] = [ + ext + for ext in stac_data["stac_extensions"] + if (not ext.startswith("https://stac-extensions.github.io/eo/") or eo_extension_is_declared) ] - ) - new = metadata._ensure_band_dimension( - name="bandzz", bands=["red", "green"], warning="ensure_band_dimension at work" - ) - assert new.has_band_dimension() - assert new.dimension_names() == ["t", "bandzz"] - assert new.band_names == ["red", "green"] - - -def test_cubemetadata_ensure_band_dimension_override_bands(): - metadata = CubeMetadata( - dimensions=[ - TemporalDimension(name="t", extent=None), - BandDimension(name="bands", bands=[Band("red"), Band("green")]), + assert ( + any(ext.startswith("https://stac-extensions.github.io/eo/") for ext in stac_data["stac_extensions"]) + == eo_extension_is_declared + ) + path = tmp_path / "stac.json" + path.write_text(json.dumps(stac_data)) + + metadata = metadata_from_stac(str(path)) + assert sorted(metadata.band_names) == [ + "2m_temperature_max", + "2m_temperature_min", + "dewpoint_temperature_mean", + "vapour_pressure", ] - ) - new = metadata._ensure_band_dimension(bands=["tomato", "lettuce"], warning="ensure_band_dimension at work") - assert new.has_band_dimension() - assert new.dimension_names() == ["t", "bands"] - assert new.band_names == ["tomato", "lettuce"] + assert caplog.messages == expected_warnings -def test_cubemetadata_ensure_band_dimension_override_name_and_bands(): - metadata = CubeMetadata( - dimensions=[ - TemporalDimension(name="t", extent=None), - BandDimension(name="bands", bands=[Band("red"), Band("green")]), - ] - ) - new = metadata._ensure_band_dimension( - name="bandzz", bands=["tomato", "lettuce"], warning="ensure_band_dimension at work" - ) - assert new.has_band_dimension() - assert new.dimension_names() == ["t", "bandzz"] - assert new.band_names == ["tomato", "lettuce"] - - -def test_collectionmetadata_subclass(): - class MyCollectionMetadata(CollectionMetadata): - def __init__(self, metadata: dict, dimensions: List[Dimension] = None, bbox=None): - super().__init__(metadata=metadata, dimensions=dimensions) - self.bbox = bbox - - def _clone_and_update( - self, metadata: dict = None, dimensions: List[Dimension] = None, bbox=None, **kwargs - ) -> MyCollectionMetadata: - return super()._clone_and_update(metadata=metadata, dimensions=dimensions, bbox=bbox or self.bbox, **kwargs) - - def filter_bbox(self, bbox): - return self._clone_and_update(bbox=bbox) - - orig = MyCollectionMetadata({"cube:dimensions": {"x": {"type": "spatial"}}}) - assert orig.bbox is None - - new = orig.add_dimension(name="layer", label="red", type="bands") - assert isinstance(new, MyCollectionMetadata) - assert orig.bbox is None - assert new.bbox is None - - new = new.filter_bbox((1, 2, 3, 4)) - assert isinstance(new, MyCollectionMetadata) - assert orig.bbox is None - assert new.bbox == (1, 2, 3, 4) - - new = new.add_dimension(name="time", label="2020", type="time") - assert isinstance(new, MyCollectionMetadata) - assert orig.bbox is None - assert new.bbox == (1, 2, 3, 4) - - -def test_cubemetadata_subclass(): - class MyCubeMetadata(CubeMetadata): - def __init__(self, dimensions: List[Dimension], bbox=None): - super().__init__(dimensions=dimensions) - self.bbox = bbox - - def _clone_and_update(self, dimensions: List[Dimension] = None, bbox=None, **kwargs) -> MyCubeMetadata: - return super()._clone_and_update(dimensions=dimensions, bbox=bbox or self.bbox, **kwargs) - - def filter_bbox(self, bbox): - return self._clone_and_update(bbox=bbox) - - orig = MyCubeMetadata([SpatialDimension(name="x", extent=None)]) - assert orig.bbox is None - - new = orig.add_dimension(name="layer", label="red", type="bands") - assert isinstance(new, MyCubeMetadata) - assert orig.bbox is None - assert new.bbox is None - - new = new.filter_bbox((1, 2, 3, 4)) - assert isinstance(new, MyCubeMetadata) - assert orig.bbox is None - assert new.bbox == (1, 2, 3, 4) - - new = new.add_dimension(name="time", label="2020", type="time") - assert isinstance(new, MyCubeMetadata) - assert orig.bbox is None - assert new.bbox == (1, 2, 3, 4) - - -@pytest.mark.parametrize( - "test_stac, expected", - [ - ( - StacDummyBuilder.collection(summaries={"eo:bands": [{"name": "B01"}, {"name": "B02"}]}), - ["B01", "B02"], - ), - ( - StacDummyBuilder.collection(summaries={"bands": [{"name": "B01"}, {"name": "B02"}]}), - ["B01", "B02"], - ), - # TODO: test asset handling in collection? - ( - StacDummyBuilder.catalog(), - [], - ), - ( - StacDummyBuilder.item( - properties={"datetime": "2020-05-22T00:00:00Z", "eo:bands": [{"name": "SCL"}, {"name": "B08"}]} - ), - ["SCL", "B08"], - ), - ( - StacDummyBuilder.item( - properties={"datetime": "2020-05-22T00:00:00Z", "bands": [{"name": "SCL"}, {"name": "B08"}]} - ), - ["SCL", "B08"], - ), - # TODO: test asset handling in item? - ], -) -def test_metadata_from_stac_bands(tmp_path, test_stac, expected): - path = tmp_path / "stac.json" - # TODO #738 real request mocking of STAC resources compatible with pystac? - path.write_text(json.dumps(test_stac)) - metadata = metadata_from_stac(str(path)) - assert metadata.band_names == expected - - -def test_metadata_from_stac_stac_1_1_common_bands_without_datacube_extension(tmp_path): - stac_dict = StacDummyBuilder.collection( - stac_version="1.1.0", - bands=[ - {"name": "red", "eo:common_name": "red", "eo:center_wavelength": 0.665}, - {"name": "nir", "eo:common_name": "nir", "eo:center_wavelength": 0.842}, + @pytest.mark.parametrize( + ["stac_dict", "expected"], + [ + ( + StacDummyBuilder.item(), + ("t", ["2024-03-08", "2024-03-08"]), + ), + ( + StacDummyBuilder.item( + properties={ + "datetime": "2024-03-08T00:00:00Z", + "start_datetime": "2024-04-04T00:00:00Z", + "end_datetime": "2024-06-06T00:00:00Z", + } + ), + ("t", ["2024-04-04T00:00:00Z", "2024-06-06T00:00:00Z"]), + ), + ( + StacDummyBuilder.item( + cube_dimensions={"t": {"type": "temporal", "extent": ["2024-04-04", "2024-06-06"]}} + ), + ("t", ["2024-04-04", "2024-06-06"]), + ), + ( + StacDummyBuilder.item( + cube_dimensions={"datezz": {"type": "temporal", "extent": ["2024-04-04", "2024-06-06"]}} + ), + ("datezz", ["2024-04-04", "2024-06-06"]), + ), + ( + StacDummyBuilder.collection(), + ("t", ["2024-01-01T00:00:00Z", "2024-05-05T00:00:00Z"]), + ), + ( + StacDummyBuilder.collection( + cube_dimensions={"t": {"type": "temporal", "extent": ["2024-04-04", "2024-06-06"]}} + ), + ("t", ["2024-04-04", "2024-06-06"]), + ), + ( + StacDummyBuilder.catalog(), + None, + ), + ( + # Note: a catalog is not supposed to have datacube extension enabled, but we should not choke on that + StacDummyBuilder.catalog(stac_extensions=[StacDummyBuilder._EXT_DATACUBE]), + None, + ), ], ) - assert "stac_extensions" not in stac_dict - assert "cube:dimensions" not in stac_dict + def test_metadata_from_stac_temporal_dimension(self, tmp_path, stac_dict, expected): + path = tmp_path / "stac.json" + # TODO #738 real request mocking of STAC resources compatible with pystac? + path.write_text(json.dumps(stac_dict)) + metadata = metadata_from_stac(str(path)) + if expected: + dim = metadata.temporal_dimension + assert isinstance(dim, TemporalDimension) + assert (dim.name, dim.extent) == expected + else: + assert not metadata.has_temporal_dimension() - path = tmp_path / "stac.json" - # TODO #738 real request mocking of STAC resources compatible with pystac? - path.write_text(json.dumps(stac_dict)) - metadata = metadata_from_stac(str(path)) - - assert metadata.dimension_names() == ["x", "y", "bands", "t"] - assert metadata.band_names == ["red", "nir"] - assert metadata.band_dimension.bands[0].common_name == "red" - assert metadata.band_dimension.bands[1].wavelength_um == 0.842 - - -@pytest.mark.parametrize( - ["eo_extension_is_declared", "expected_warnings"], - [ - ( - False, - [ - "Deriving band listing from unordered `item_assets`", - "Using 'eo:bands' metadata, but STAC extension eo was not declared.", - ], - ), - (True, ["Deriving band listing from unordered `item_assets`"]), - ], -) -def test_metadata_from_stac_collection_bands_from_item_assets( - test_data, tmp_path, eo_extension_is_declared, caplog, expected_warnings -): - stac_data = test_data.load_json("stac/collections/agera5_daily01.json") - stac_data["stac_extensions"] = [ - ext - for ext in stac_data["stac_extensions"] - if (not ext.startswith("https://stac-extensions.github.io/eo/") or eo_extension_is_declared) - ] - assert ( - any(ext.startswith("https://stac-extensions.github.io/eo/") for ext in stac_data["stac_extensions"]) - == eo_extension_is_declared - ) - path = tmp_path / "stac.json" - path.write_text(json.dumps(stac_data)) - - metadata = metadata_from_stac(str(path)) - assert sorted(metadata.band_names) == [ - "2m_temperature_max", - "2m_temperature_min", - "dewpoint_temperature_mean", - "vapour_pressure", - ] - - assert caplog.messages == expected_warnings - - -@pytest.mark.parametrize( - ["stac_dict", "expected"], - [ - ( - StacDummyBuilder.item(), - ("t", ["2024-03-08", "2024-03-08"]), - ), - ( - StacDummyBuilder.item( - properties={ - "datetime": "2024-03-08T00:00:00Z", - "start_datetime": "2024-04-04T00:00:00Z", - "end_datetime": "2024-06-06T00:00:00Z", - } + @pytest.mark.parametrize( + ["stac_dict", "expected_dims"], + [ + ( + # No cube:dimensions -> fall back to openEO default naming convention + StacDummyBuilder.collection(summaries={"eo:bands": [{"name": "B01"}]}), + {"t", "bands", "y", "x"}, ), - ("t", ["2024-04-04T00:00:00Z", "2024-06-06T00:00:00Z"]), - ), - ( - StacDummyBuilder.item(cube_dimensions={"t": {"type": "temporal", "extent": ["2024-04-04", "2024-06-06"]}}), - ("t", ["2024-04-04", "2024-06-06"]), - ), - ( - StacDummyBuilder.item( - cube_dimensions={"datezz": {"type": "temporal", "extent": ["2024-04-04", "2024-06-06"]}} - ), - ("datezz", ["2024-04-04", "2024-06-06"]), - ), - ( - StacDummyBuilder.collection(), - ("t", ["2024-01-01T00:00:00Z", "2024-05-05T00:00:00Z"]), - ), - ( - StacDummyBuilder.collection( - cube_dimensions={"t": {"type": "temporal", "extent": ["2024-04-04", "2024-06-06"]}} - ), - ("t", ["2024-04-04", "2024-06-06"]), - ), - ( - StacDummyBuilder.catalog(), - None, - ), - ( - # Note: a catalog is not supposed to have datacube extension enabled, but we should not choke on that - StacDummyBuilder.catalog(stac_extensions=[StacDummyBuilder._EXT_DATACUBE]), - None, - ), - ], -) -def test_metadata_from_stac_temporal_dimension(tmp_path, stac_dict, expected): - path = tmp_path / "stac.json" - # TODO #738 real request mocking of STAC resources compatible with pystac? - path.write_text(json.dumps(stac_dict)) - metadata = metadata_from_stac(str(path)) - if expected: - dim = metadata.temporal_dimension - assert isinstance(dim, TemporalDimension) - assert (dim.name, dim.extent) == expected - else: - assert not metadata.has_temporal_dimension() - - - -@pytest.mark.parametrize( - ["stac_dict", "expected_dims"], - [ - ( - # No cube:dimensions -> fall back to openEO default naming convention - StacDummyBuilder.collection(summaries={"eo:bands": [{"name": "B01"}]}), - {"t", "bands", "y", "x"}, - ), - ( - # No cube:dimensions (item) -> fall back to openEO default naming convention - StacDummyBuilder.item( - properties={"datetime": "2020-05-22T00:00:00Z", "eo:bands": [{"name": "B01"}]} - ), - {"t", "bands", "y", "x"}, - ), - ( - # cube:dimensions present -> use the dimension names as suggested by cube:dimensions keys - StacDummyBuilder.collection( - cube_dimensions={ - "time": {"type": "temporal", "axis": "t", "extent": ["2024-04-04", "2024-06-06"]}, - "band": {"type": "bands", "axis": "bands", "values": ["B01"]}, - "y": {"type": "spatial", "axis": "y", "extent": [0, 1]}, - "x": {"type": "spatial", "axis": "x", "extent": [0, 1]}, - } + ( + # No cube:dimensions (item) -> fall back to openEO default naming convention + StacDummyBuilder.item(properties={"datetime": "2020-05-22T00:00:00Z", "eo:bands": [{"name": "B01"}]}), + {"t", "bands", "y", "x"}, ), - {"time", "band", "y", "x"}, - ), - ( - # cube:dimensions present without band dimension -> don't inject an openEO "bands" dimension - StacDummyBuilder.collection( - summaries={"eo:bands": [{"name": "B01"}]}, - cube_dimensions={ - "time": {"type": "temporal", "axis": "t", "extent": ["2024-04-04", "2024-06-06"]}, - "y": {"type": "spatial", "axis": "y", "extent": [0, 1]}, - "x": {"type": "spatial", "axis": "x", "extent": [0, 1]}, - }, + ( + # cube:dimensions present -> use the dimension names as suggested by cube:dimensions keys + StacDummyBuilder.collection( + cube_dimensions={ + "time": {"type": "temporal", "axis": "t", "extent": ["2024-04-04", "2024-06-06"]}, + "band": {"type": "bands", "axis": "bands", "values": ["B01"]}, + "y": {"type": "spatial", "axis": "y", "extent": [0, 1]}, + "x": {"type": "spatial", "axis": "x", "extent": [0, 1]}, + } + ), + {"time", "band", "y", "x"}, ), - {"time", "y", "x"}, - ), - ], -) -def test_metadata_from_stac_dimension_policy_cube_dimensions_vs_default(tmp_path, stac_dict, expected_dims): - # Dimension name resolution policy (STAC cube:dimensions vs openEO defaults) - path = tmp_path / "stac.json" - # TODO #738 real request mocking of STAC resources compatible with pystac? - path.write_text(json.dumps(stac_dict)) - metadata = metadata_from_stac(str(path)) - - got = tuple(metadata.dimension_names() or ()) - - # Order-insensitive check: names only - assert set(got) == expected_dims - - # Ensure the policy logic is exercised correctly: - # cube:dimensions can be located at root (collection) or in properties (item) - cube_dims = stac_dict.get("cube:dimensions") or (stac_dict.get("properties") or {}).get("cube:dimensions") - if cube_dims is None: - assert set(got) == {"t", "bands", "y", "x"} - else: - assert set(got) == set(cube_dims.keys()) - - -def test_metadata_from_stac_cube_dimensions_geometries_not_treated_as_band_dimension(tmp_path): - """ - A "geometries" typed cube:dimensions entry (vector cube dimension) should not be - silently converted into a BandDimension, even when the STAC datacube extension - is declared (which routes parsing through pystac's extension API). - """ - stac_dict = StacDummyBuilder.collection( - cube_dimensions={ - "geom": {"type": "geometries"}, - "time": {"type": "temporal", "axis": "t", "extent": ["2024-04-04", "2024-06-06"]}, - "band": {"type": "bands", "axis": "bands", "values": ["B01"]}, - } + ( + # cube:dimensions present without band dimension -> don't inject an openEO "bands" dimension + StacDummyBuilder.collection( + summaries={"eo:bands": [{"name": "B01"}]}, + cube_dimensions={ + "time": {"type": "temporal", "axis": "t", "extent": ["2024-04-04", "2024-06-06"]}, + "y": {"type": "spatial", "axis": "y", "extent": [0, 1]}, + "x": {"type": "spatial", "axis": "x", "extent": [0, 1]}, + }, + ), + {"time", "y", "x"}, + ), + ], ) - assert stac_dict["stac_extensions"] == [StacDummyBuilder._EXT_DATACUBE] - - path = tmp_path / "stac.json" - path.write_text(json.dumps(stac_dict)) - metadata = metadata_from_stac(str(path)) - - assert metadata.dimension_names() == ["geom", "time", "band"] - assert metadata.has_geometry_dimension() - assert isinstance(metadata.geometry_dimension, GeometryDimension) - - -@pytest.mark.parametrize( - ["kwargs", "expected_x", "expected_y"], - [ - ({}, {"crs": 4326, "step": 0.1}, {"crs": 4326, "step": 0.1}), - ({"resolution": 2}, {"crs": 4326, "step": 2}, {"crs": 4326, "step": 2}), - ({"resolution": [0.5, 2]}, {"crs": 4326, "step": 0.5}, {"crs": 4326, "step": 2}), - ({"projection": 32631}, {"crs": 32631, "step": 0.1}, {"crs": 32631, "step": 0.1}), - ({"resolution": 10, "projection": 32631}, {"crs": 32631, "step": 10}, {"crs": 32631, "step": 10}), - ({"resolution": [11, 22], "projection": 32631}, {"crs": 32631, "step": 11}, {"crs": 32631, "step": 22}), - ], -) -@pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) -def test_metadata_resample_spatial(cube_metadata, kwargs, expected_x, expected_y): - metadata = cube_metadata.resample_spatial(**kwargs) - assert isinstance(metadata, CubeMetadata) - assert metadata.spatial_dimensions == [ - SpatialDimension(name="x", extent=[2, 7], **expected_x), - SpatialDimension(name="y", extent=[49, 52], **expected_y), - ] - assert metadata.temporal_dimension == cube_metadata.temporal_dimension - assert metadata.band_dimension == cube_metadata.band_dimension - - -@pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) -def test_metadata_resample_spatial_parameter_resolution_only(cube_metadata): - """When resolution is a Parameter, step should remain unchanged and crs should stay as-is.""" - param = Parameter.number("res", description="The spatial resolution.") - metadata = cube_metadata.resample_spatial(resolution=param) - assert isinstance(metadata, CubeMetadata) - # step must be set to None after parametrized resample_spatial. - assert metadata.spatial_dimensions == [ - SpatialDimension(name="x", extent=[2, 7], crs=4326, step=None), - SpatialDimension(name="y", extent=[49, 52], crs=4326, step=None), - ] - assert metadata.temporal_dimension == cube_metadata.temporal_dimension - assert metadata.band_dimension == cube_metadata.band_dimension - - -@pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) -def test_metadata_resample_spatial_parameter_resolution_with_projection(cube_metadata): - """When resolution is a Parameter but projection is concrete, crs should be updated.""" - param = Parameter.number("res", description="The spatial resolution.") - metadata = cube_metadata.resample_spatial(resolution=param, projection=32631) - assert isinstance(metadata, CubeMetadata) - assert metadata.spatial_dimensions == [ - SpatialDimension(name="x", extent=[2, 7], crs=32631, step=None), - SpatialDimension(name="y", extent=[49, 52], crs=32631, step=None), - ] - assert metadata.temporal_dimension == cube_metadata.temporal_dimension - assert metadata.band_dimension == cube_metadata.band_dimension - - -@pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) -def test_metadata_resample_spatial_parameter_projection_only(cube_metadata): - """When projection is a Parameter, crs should be set to None.""" - param = Parameter.integer("proj", description="The target projection.") - metadata = cube_metadata.resample_spatial(resolution=10, projection=param) - assert isinstance(metadata, CubeMetadata) - assert metadata.spatial_dimensions == [ - SpatialDimension(name="x", extent=[2, 7], crs=None, step=10), - SpatialDimension(name="y", extent=[49, 52], crs=None, step=10), - ] - assert metadata.temporal_dimension == cube_metadata.temporal_dimension - assert metadata.band_dimension == cube_metadata.band_dimension - - -@pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) -def test_metadata_resample_spatial_parameter_resolution_and_projection(cube_metadata): - """When both resolution and projection are Parameters, step and crs should both be None.""" - res_param = Parameter.number("res", description="The spatial resolution.") - proj_param = Parameter.integer("proj", description="The target projection.") - metadata = cube_metadata.resample_spatial(resolution=res_param, projection=proj_param) - assert isinstance(metadata, CubeMetadata) - assert metadata.spatial_dimensions == [ - SpatialDimension(name="x", extent=[2, 7], crs=None, step=None), - SpatialDimension(name="y", extent=[49, 52], crs=None, step=None), - ] - assert metadata.temporal_dimension == cube_metadata.temporal_dimension - assert metadata.band_dimension == cube_metadata.band_dimension - - -@pytest.mark.parametrize("cube_metadata", [CUBE_METADATA_XYTB, CUBE_METADATA_TBXY]) -def test_metadata_resample_cube_spatial(cube_metadata): - metadata1 = cube_metadata.resample_spatial(resolution=(11, 22), projection=32631) - metadata2 = cube_metadata.resample_spatial(resolution=0.5) - - assert metadata1.spatial_dimensions == [ - SpatialDimension(name="x", extent=[2, 7], crs=32631, step=11), - SpatialDimension(name="y", extent=[49, 52], crs=32631, step=22), - ] - assert metadata2.spatial_dimensions == [ - SpatialDimension(name="x", extent=[2, 7], crs=4326, step=0.5), - SpatialDimension(name="y", extent=[49, 52], crs=4326, step=0.5), - ] - - metadata12 = metadata1.resample_cube_spatial(target=metadata2) - assert metadata12.spatial_dimensions == [ - SpatialDimension(name="x", extent=[2, 7], crs=4326, step=0.5), - SpatialDimension(name="y", extent=[49, 52], crs=4326, step=0.5), - ] - - metadata21 = metadata2.resample_cube_spatial(target=metadata1) - assert metadata21.spatial_dimensions == [ - SpatialDimension(name="x", extent=[2, 7], crs=32631, step=11), - SpatialDimension(name="y", extent=[49, 52], crs=32631, step=22), - ] - - -def test_metadata_resample_cube_spatial_preserve_non_spatial(): - xy1 = [ - SpatialDimension(name="x", extent=[12, 17], crs=4326, step=0.1), - SpatialDimension(name="y", extent=[41, 51], crs=4326, step=0.1), - ] - t1 = TemporalDimension(name="t", extent=["2024-01-01", "2024-10-01"]) - b1 = BandDimension(name="bands", bands=[Band("B1"), Band("B11")]) - metadata1 = CubeMetadata(dimensions=xy1 + [t1, b1]) - - xy2 = [ - SpatialDimension(name="x", extent=[22, 27], crs=4326, step=0.1), - SpatialDimension(name="y", extent=[42, 52], crs=4326, step=0.1), - ] - t2 = TemporalDimension(name="t", extent=["2024-02-02", "2024-12-02"]) - metadata2 = CubeMetadata(dimensions=xy2 + [t2]) - - xy3 = [ - SpatialDimension(name="x", extent=[32, 37], crs=4326, step=0.1), - SpatialDimension(name="y", extent=[43, 53], crs=4326, step=0.1), - ] - b3 = BandDimension(name="bands", bands=[Band("B3"), Band("B33")]) - metadata3 = CubeMetadata(dimensions=xy3 + [b3]) - - result12 = metadata1.resample_cube_spatial(target=metadata2) - assert result12.spatial_dimensions == xy2 - assert result12.band_dimension == b1 - assert result12.temporal_dimension == t1 - - result21 = metadata2.resample_cube_spatial(target=metadata1) - assert result21.spatial_dimensions == xy1 - assert not result21.has_band_dimension() - assert result21.temporal_dimension == t2 - - result23 = metadata2.resample_cube_spatial(target=metadata3) - assert result23.spatial_dimensions == xy3 - assert not result23.has_band_dimension() - assert result23.temporal_dimension == t2 - - result31 = metadata3.resample_cube_spatial(target=metadata1) - assert result31.spatial_dimensions == xy1 - assert result31.band_dimension == b3 - assert not result31.has_temporal_dimension() - - -def test_cube_metadata_repr_empty(): - metadata = CubeMetadata() - assert repr(metadata) == "CubeMetadata(dimensions=None)" - - -def test_cube_metadata_repr_with_bands(): - assert repr(CUBE_METADATA_XYTB) == "CubeMetadata(dimension_names=['x', 'y', 't', 'bands'], band_names=['B2', 'B3'])" - + def test_metadata_from_stac_dimension_policy_cube_dimensions_vs_default(self, tmp_path, stac_dict, expected_dims): + # Dimension name resolution policy (STAC cube:dimensions vs openEO defaults) + path = tmp_path / "stac.json" + # TODO #738 real request mocking of STAC resources compatible with pystac? + path.write_text(json.dumps(stac_dict)) + metadata = metadata_from_stac(str(path)) + + got = tuple(metadata.dimension_names() or ()) + + # Order-insensitive check: names only + assert set(got) == expected_dims + + # Ensure the policy logic is exercised correctly: + # cube:dimensions can be located at root (collection) or in properties (item) + cube_dims = stac_dict.get("cube:dimensions") or (stac_dict.get("properties") or {}).get("cube:dimensions") + if cube_dims is None: + assert set(got) == {"t", "bands", "y", "x"} + else: + assert set(got) == set(cube_dims.keys()) + + def test_metadata_from_stac_cube_dimensions_geometries_not_treated_as_band_dimension(self, tmp_path): + """ + A "geometries" typed cube:dimensions entry (vector cube dimension) should not be + silently converted into a BandDimension, even when the STAC datacube extension + is declared (which routes parsing through pystac's extension API). + """ + stac_dict = StacDummyBuilder.collection( + cube_dimensions={ + "geom": {"type": "geometries"}, + "time": {"type": "temporal", "axis": "t", "extent": ["2024-04-04", "2024-06-06"]}, + "band": {"type": "bands", "axis": "bands", "values": ["B01"]}, + } + ) + assert stac_dict["stac_extensions"] == [StacDummyBuilder._EXT_DATACUBE] -def test_cube_metadata_repr_no_bands(): - metadata = CubeMetadata( - dimensions=[ - SpatialDimension(name="x", extent=[2, 7], crs=4326, step=0.1), - TemporalDimension(name="t", extent=["2024-09-01", "2024-12-01"]), - ] - ) - assert repr(metadata) == "CubeMetadata(dimension_names=['x', 't'])" - - -def test_cube_metadata_repr_html_empty(): - metadata = CubeMetadata() - html = metadata._repr_html_() - assert "No cube (dimension) metadata" in html - - -def test_cube_metadata_repr_html_with_dimensions(): - html = CUBE_METADATA_XYTB._repr_html_() - assert html == dirty_equals.IsStr( - regex=r""" - .*
xspatial
yspatial
ttemporal
bandsbands2bands:\sB2,\sB3
- .*.*Dimension.*Type.*Size.* - .* - .*.* - .*.* - .*.* - .* - .*""", - regex_flags=re.DOTALL | re.VERBOSE, - ) + path = tmp_path / "stac.json" + path.write_text(json.dumps(stac_dict)) + metadata = metadata_from_stac(str(path)) + assert metadata.dimension_names() == ["geom", "time", "band"] + assert metadata.has_geometry_dimension() + assert isinstance(metadata.geometry_dimension, GeometryDimension) -class TestStacMetadataParser: def test_band_from_eo_bands_metadata_basic(self): assert _StacMetadataParser()._band_from_eo_bands_metadata( {"name": "B04"}, From 6e2c80892b6fbba21a99a2ce306cb71e097aace7 Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Fri, 16 Oct 2026 15:58:41 +0200 Subject: [PATCH 06/13] Basic handling of filter_bbox in CubeMetadata, down to SpatialDimension refs #827, #949 --- openeo/metadata/__init__.py | 119 ++++++++++--- openeo/metadata/_extents.py | 51 ++++++ openeo/rest/datacube.py | 27 ++- openeo/util.py | 20 ++- tests/metadata/test_extents.py | 78 ++++++++- tests/metadata/test_metadata.py | 251 ++++++++++++++++++++++++--- tests/rest/datacube/conftest.py | 7 +- tests/rest/datacube/test_datacube.py | 40 ++--- tests/test_util.py | 16 ++ 9 files changed, 527 insertions(+), 82 deletions(-) diff --git a/openeo/metadata/__init__.py b/openeo/metadata/__init__.py index 7ab031ceb..27d294f52 100644 --- a/openeo/metadata/__init__.py +++ b/openeo/metadata/__init__.py @@ -22,8 +22,8 @@ from openeo.api.process import Parameter from openeo.internal.jupyter import render_component -from openeo.metadata._extents import _EmptyExtent, _NonConcreteExtent -from openeo.util import Rfc3339, deep_get +from openeo.metadata._extents import NumericalInterval, _EmptyExtent, _NonConcreteExtent +from openeo.util import BBoxDict, Rfc3339, deep_get from openeo.utils.datetime import DateTimeInterval from openeo.utils.normalize import normalize_resample_resolution, unique @@ -80,6 +80,7 @@ class SpatialDimension(Dimension): - axis: Axis of the spatial raster dimension ("x" or "y"). - extent: lower and upper bounds of the dimension as two-element array. """ + DEFAULT_CRS = 4326 def __init__( @@ -88,7 +89,7 @@ def __init__( *, # TODO: axis should be required (without None as default), but currently optional (with fallback guess) for backwards compatibility. axis: Literal["x", "y"] | None = None, - extent: Union[Tuple[float, float], List[float]], + extent: Tuple[float, float] | List[float] | NumericalInterval | _NonConcreteExtent | _EmptyExtent, crs: Union[str, int, dict] = DEFAULT_CRS, step=None, ): @@ -96,7 +97,9 @@ def __init__( @param name: @param axis: Axis of the spatial raster dimension ("x" or "y") @param extent: - @param crs: + @param crs: The spatial reference system for the data and datacube metadata (extent, values and step), + specified as numerical EPSG code, WKT2 (ISO 19162) string or PROJJSON object. + Defaults to EPSG code 4326. @param step: The space between the values. Use null for irregularly spaced steps. """ super().__init__(type="spatial", name=name) @@ -110,6 +113,63 @@ def __init__( def rename(self, name) -> Dimension: return SpatialDimension(name=name, axis=self.axis, extent=self.extent, crs=self.crs, step=self.step) + def filter_extent( + self, + extent: ( + Tuple[float | int, float | int] + | List[float | int] + | NumericalInterval + | _EmptyExtent + | _NonConcreteExtent + | None + ), + ): + if (this_extent := NumericalInterval.try_from(self.extent)) and ( + other_extent := NumericalInterval.try_from(extent) + ): + extent = this_extent.intersection(other_extent) + elif isinstance(self.extent, _EmptyExtent) or isinstance(extent, _EmptyExtent): + extent = _EmptyExtent() + elif extent is None: + extent = self.extent + else: + # As lenient fallback, handle as non-concrete extent + extent = _NonConcreteExtent() + return SpatialDimension(name=self.name, axis=self.axis, extent=extent, crs=self.crs, step=self.step) + + +class SpatialDimensionsXY(NamedTuple): + """ + Simple container class for a pair of spatial dimensions (x and y). + """ + + x: SpatialDimension + y: SpatialDimension + + def filter_bbox(self, extent: Any) -> SpatialDimensionsXY: + # TODO: support more types and forms of the extent (geometries, etc.) ... e.g. using the BBoxDict factories? + if bbox := BBoxDict.try_from_dict(extent): + bbox_crs = bbox.get("crs", SpatialDimension.DEFAULT_CRS) + if bbox_crs == self.x.crs and bbox_crs == self.y.crs: + # No reprojection needed + extent_x = bbox["west"], bbox["east"] + extent_y = bbox["south"], bbox["north"] + else: + # TODO: reproject + extent_x, extent_y = _NonConcreteExtent(), _NonConcreteExtent() + elif isinstance(extent, _EmptyExtent): + extent_x, extent_y = _EmptyExtent(), _EmptyExtent() + elif isinstance(extent, _NonConcreteExtent): + extent_x, extent_y = _NonConcreteExtent(), _NonConcreteExtent() + else: + # As lenient fallback, handle as non-concrete extent + extent_x, extent_y = _NonConcreteExtent(), _NonConcreteExtent() + + return SpatialDimensionsXY( + x=self.x.filter_extent(extent=extent_x), + y=self.y.filter_extent(extent=extent_y), + ) + class TemporalDimension(Dimension): def __init__( @@ -330,23 +390,24 @@ def __init__(self, dimensions: Optional[List[Dimension]] = None): # but most usage actually assumes it is a list that can be iterated over. # Can we handle this more consistently and less error-prone? self._dimensions: Union[List[Dimension], None] = dimensions - self._band_dimension = None - self._temporal_dimension = None + self._band_dimension: BandDimension | None = None + self._temporal_dimension: TemporalDimension | None = None + self._spatial_dimensions: SpatialDimensionsXY | None = None if dimensions is not None: - for dim in self._dimensions: - # TODO: here we blindly pick last bands or temporal dimension if multiple. Let user choose? - # TODO: add spatial dimension handling? - if dim.type == "bands": - if isinstance(dim, BandDimension): - self._band_dimension = dim - else: - raise MetadataException("Invalid band dimension {d!r}".format(d=dim)) - if dim.type == "temporal": - if isinstance(dim, TemporalDimension): - self._temporal_dimension = dim - else: - raise MetadataException("Invalid temporal dimension {d!r}".format(d=dim)) + if band_dims := [d for d in dimensions if isinstance(d, BandDimension)]: + if len(band_dims) > 1: + _log.warning(f"Multiple band dimensions, arbitrarily picking the first one: {band_dims=}") + self._band_dimension = band_dims[0] + if temporal_dims := [d for d in dimensions if isinstance(d, TemporalDimension)]: + if len(temporal_dims) > 1: + _log.warning(f"Multiple temporal dimensions, arbitrarily picking the first one: {temporal_dims=}") + self._temporal_dimension = temporal_dims[0] + if spatial_dims := sorted((d for d in dimensions if isinstance(d, SpatialDimension)), key=lambda d: d.axis): + if [d.axis for d in spatial_dims] == ["x", "y"]: + self._spatial_dimensions = SpatialDimensionsXY(x=spatial_dims[0], y=spatial_dims[1]) + else: + _log.warning(f"Failed to detect x and y spatial dimensions: {spatial_dims=}") def __eq__(self, o: Any) -> bool: return isinstance(o, type(self)) and self._dimensions == o._dimensions @@ -411,9 +472,14 @@ def temporal_dimension(self) -> TemporalDimension: raise MetadataException("No temporal dimension") return self._temporal_dimension + def has_spatial_dimensions(self) -> bool: + return isinstance(self._spatial_dimensions, SpatialDimensionsXY) + @property - def spatial_dimensions(self) -> List[SpatialDimension]: - return [d for d in self._dimensions if isinstance(d, SpatialDimension)] + def spatial_dimensions(self) -> SpatialDimensionsXY: + if not self.has_spatial_dimensions(): + raise MetadataException("No spatial dimensions") + return self._spatial_dimensions def has_geometry_dimension(self): return any(isinstance(d, GeometryDimension) for d in self._dimensions) @@ -453,6 +519,17 @@ def filter_temporal(self, extent: Tuple[str | None, str | None] | List[str | Non ] ) + def filter_bbox(self, extent: Any) -> CubeMetadata: + if self._dimensions and isinstance(self._spatial_dimensions, SpatialDimensionsXY): + spatial_dimensions: SpatialDimensionsXY = self._spatial_dimensions.filter_bbox(extent=extent) + dimensions = [ + getattr(spatial_dimensions, d.axis) if isinstance(d, SpatialDimension) else d for d in self._dimensions + ] + return self._clone_and_update(dimensions=dimensions) + + _log.warning("filter_bbox on cube metadata without spatial dimension. Returning unchanged metadata.") + return self + def filter_bands(self, band_names: List[Union[int, str]]) -> CubeMetadata: """ Create new `CubeMetadata` with filtered band dimension diff --git a/openeo/metadata/_extents.py b/openeo/metadata/_extents.py index 7c958ecca..1e9a8183d 100644 --- a/openeo/metadata/_extents.py +++ b/openeo/metadata/_extents.py @@ -1,5 +1,7 @@ from __future__ import annotations +import typing + class _NonConcreteExtent: """ @@ -20,6 +22,14 @@ class _NonConcreteExtent: def __repr__(self) -> str: return "<_NonConcreteExtent>" + def __eq__(self, value: object, /) -> bool: + if isinstance(value, _NonConcreteExtent): + # Non-concrete extents are not equal to each other + # even not to themselves (because they are non-concrete). + return False + # Still let third-party equality tooling hook in (e.g. dirty_equals.IsInstance) + return NotImplemented + class _EmptyExtent: """ @@ -41,3 +51,44 @@ def __eq__(self, other) -> bool: if isinstance(other, _EmptyExtent): return True return NotImplemented + + +class NumericalInterval(typing.NamedTuple): + """ + Simple numerical interval defined by a start and end point + each of type float/int; + or None to indicate being open-ended in that direction (no limit). + """ + + start: float | int | None + end: float | int | None + + @classmethod + def try_from(cls, x: typing.Any) -> NumericalInterval | None: + if isinstance(x, (list, tuple)) and len(x) == 2 and all(isinstance(v, (int, float)) or v is None for v in x): + return cls(*x) + return None + + def intersection(self, other: NumericalInterval) -> NumericalInterval | _EmptyExtent: + """ + Compute the intersection of this interval with another interval. + If the intervals do not overlap, return an _EmptyExtent. + """ + if self.start is None: + start = other.start + elif other.start is None: + start = self.start + else: + start = max(self.start, other.start) + + if self.end is None: + end = other.end + elif other.end is None: + end = self.end + else: + end = min(self.end, other.end) + + if start is not None and end is not None and end < start: + return _EmptyExtent() + + return NumericalInterval(start, end) diff --git a/openeo/rest/datacube.py b/openeo/rest/datacube.py index 68dedf4bd..623ac032d 100644 --- a/openeo/rest/datacube.py +++ b/openeo/rest/datacube.py @@ -256,6 +256,8 @@ def load_collection( if (connection and fetch_metadata) else None ) + if metadata and spatial_extent: + metadata = metadata.filter_bbox(extent=spatial_extent) if metadata and temporal_extent: metadata = metadata.filter_temporal(extent=temporal_extent) if bands is not None: @@ -455,7 +457,7 @@ def load_stac( """ arguments: dict = {"url": url} if spatial_extent: - arguments["spatial_extent"] = _get_geometry_argument( + spatial_extent = _get_geometry_argument( argument=spatial_extent, valid_geojson_types=["Polygon", "MultiPolygon", "Feature", "FeatureCollection"], connection=connection, @@ -465,6 +467,7 @@ def load_stac( argument_name="spatial_extent", process_id="load_stac", ) + arguments["spatial_extent"] = spatial_extent if temporal_extent: temporal_extent = cls._get_temporal_extent(extent=temporal_extent) @@ -510,6 +513,8 @@ def load_stac( ) metadata = None + if metadata and spatial_extent: + metadata = metadata.filter_bbox(extent=spatial_extent) if metadata and temporal_extent: metadata = metadata.filter_temporal(extent=temporal_extent) @@ -713,12 +718,14 @@ def filter_bbox( extent = {'west': west, 'east': east, 'north': north, 'south': south} extent.update(dict_no_none(crs=crs, base=base, height=height)) + metadata = self.metadata.filter_bbox(extent=extent) if self.metadata else None return self.process( - process_id='filter_bbox', + process_id="filter_bbox", arguments={ - 'data': THIS, - 'extent': extent - } + "data": THIS, + "extent": extent, + }, + metadata=metadata, ) @openeo_process @@ -774,12 +781,14 @@ def filter_spatial( "FeatureCollection", ] geometries = _get_geometry_argument(geometries, valid_geojson_types=valid_geojson_types, connection=self.connection, crs=None) + metadata = self.metadata.filter_bbox(extent=geometries) if self.metadata else None return self.process( - process_id='filter_spatial', + process_id="filter_spatial", arguments={ - 'data': THIS, - 'geometries': geometries - } + "data": THIS, + "geometries": geometries, + }, + metadata=metadata, ) @openeo_process diff --git a/openeo/util.py b/openeo/util.py index d5c7e2de5..613fd929f 100644 --- a/openeo/util.py +++ b/openeo/util.py @@ -535,7 +535,15 @@ class BBoxDict(dict): .. versionadded:: 0.10.1 """ - def __init__(self, *, west: float, south: float, east: float, north: float, crs: Optional[Union[str, int]] = None): + def __init__( + self, + *, + west: float, + south: float, + east: float, + north: float, + crs: int | str | dict | None = None, + ): super().__init__(west=west, south=south, east=east, north=north) if crs is not None: self.update(crs=normalize_crs(crs)) @@ -559,6 +567,8 @@ def from_any(cls, x: Any, *, crs: Optional[str] = None) -> BBoxDict: @classmethod def from_dict(cls, data: dict) -> BBoxDict: """Build from dictionary with at least keys "west", "south", "east", and "north".""" + if not isinstance(data, dict): + raise InvalidBBoxException(f"Expected dict, but got {type(data).__name__}.") expected_fields = {"west", "south", "east", "north"} # TODO: also support upper case fields? # TODO: optional support for parameterized bbox fields? @@ -570,6 +580,14 @@ def from_dict(cls, data: dict) -> BBoxDict: raise InvalidBBoxException(f"Non-numerical bbox fields {invalid}.") return cls(west=data["west"], south=data["south"], east=data["east"], north=data["north"], crs=data.get("crs")) + @classmethod + def try_from_dict(cls, data: dict | Any) -> BBoxDict | None: + """Try to build from dictionary with at least keys "west", "south", "east", and "north".""" + try: + return cls.from_dict(data) + except ValueError: + return None + @classmethod def from_sequence(cls, seq: Union[list, tuple], crs: Optional[str] = None) -> BBoxDict: """Build from sequence of 4 bounds (west, south, east and north).""" diff --git a/tests/metadata/test_extents.py b/tests/metadata/test_extents.py index 98bf421ba..f627ff5e2 100644 --- a/tests/metadata/test_extents.py +++ b/tests/metadata/test_extents.py @@ -1,11 +1,85 @@ -from openeo.metadata import _EmptyExtent +import dirty_equals +import pytest +from openeo.metadata import NumericalInterval, _EmptyExtent, _NonConcreteExtent -class TestEmptyExtent: +class TestNonConcreteExtent: + def test_equality(self): + assert _NonConcreteExtent() != _NonConcreteExtent() + + def test_self_inequality(self): + extent = _NonConcreteExtent() + assert extent != extent + + def test_dirty_equals_isinstance(self): + extent = _NonConcreteExtent() + assert extent == dirty_equals.IsInstance(_NonConcreteExtent) + + +class TestEmptyExtent: def test_equality(self): assert _EmptyExtent() == _EmptyExtent() def test_none(self): assert _EmptyExtent() != None assert None != _EmptyExtent() + + +class TestNumericalInterval: + def test_basic(self): + interval = NumericalInterval(3, 5.5) + assert interval.start == 3 + assert interval.end == 5.5 + + @pytest.mark.parametrize(["start", "end"], [(None, 5), (3, None), (None, None)]) + def test_unbounded(self, start, end): + interval = NumericalInterval(start, end) + assert interval.start == start + assert interval.end == end + + def test_equality(self): + assert NumericalInterval(1, 2) == NumericalInterval(1, 2) + assert NumericalInterval(1, 2) != NumericalInterval(1, 3) + assert NumericalInterval(1, 2) != NumericalInterval(0, 2) + assert NumericalInterval(None, 2) != NumericalInterval(0, 2) + assert NumericalInterval(None, None) == NumericalInterval(None, None) + + def test_try_from(self): + assert NumericalInterval.try_from([1, 2]) == NumericalInterval(1, 2) + assert NumericalInterval.try_from((None, 5)) == NumericalInterval(None, 5) + assert NumericalInterval.try_from([3.5, None]) == NumericalInterval(3.5, None) + assert NumericalInterval.try_from([None, None]) == NumericalInterval(None, None) + assert NumericalInterval.try_from([1]) is None + assert NumericalInterval.try_from([1, 2, 3]) is None + assert NumericalInterval.try_from("not a list") is None + assert NumericalInterval.try_from([1, "not a number"]) is None + + @pytest.mark.parametrize( + ["interval1", "interval2", "expected"], + [ + # Closed interval cases + ((3, 5), (1, 2), _EmptyExtent()), + ((3, 5), (1, 3.5), NumericalInterval(3, 3.5)), + ((3, 5), (3.5, 4.5), NumericalInterval(3.5, 4.5)), + ((3, 5), (4.5, 8), NumericalInterval(4.5, 5)), + ((3, 5), (6, 8), _EmptyExtent()), + # Half-open interval cases + ((None, 5), (1, 2), NumericalInterval(1, 2)), + ((None, 5), (1, 7), NumericalInterval(1, 5)), + ((None, 5), (6, 7), _EmptyExtent()), + ((3, None), (1, 2), _EmptyExtent()), + ((3, None), (1, 4), NumericalInterval(3, 4)), + ((3, None), (5, 7), NumericalInterval(5, 7)), + # Unbounded interval cases + ((None, None), (1, 2), NumericalInterval(1, 2)), + ((None, None), (None, 2), NumericalInterval(None, 2)), + ((None, None), (3, None), NumericalInterval(3, None)), + ((None, None), (None, None), NumericalInterval(None, None)), + ], + ) + def test_intersection(self, interval1, interval2, expected): + interval1 = NumericalInterval(*interval1) + interval2 = NumericalInterval(*interval2) + assert interval1.intersection(interval2) == expected + assert interval2.intersection(interval1) == expected diff --git a/tests/metadata/test_metadata.py b/tests/metadata/test_metadata.py index 98eecd644..107ba4bb6 100644 --- a/tests/metadata/test_metadata.py +++ b/tests/metadata/test_metadata.py @@ -19,7 +19,9 @@ DimensionAlreadyExistsException, GeometryDimension, MetadataException, + NumericalInterval, SpatialDimension, + SpatialDimensionsXY, TemporalDimension, _EmptyExtent, _NonConcreteExtent, @@ -125,6 +127,149 @@ def test_basic(self): assert dim.extent == [2, 6] assert dim.crs == 4326 + @pytest.mark.parametrize( + ["extent", "expected"], + [ + ((1, 2), _EmptyExtent()), + ([3, 6], NumericalInterval(5, 6)), + ((3, 6), NumericalInterval(5, 6)), + (NumericalInterval(3, 6), NumericalInterval(5, 6)), + ((3, 16), NumericalInterval(5, 8)), + ((6, 12), NumericalInterval(6, 8)), + ((12, 15), _EmptyExtent()), + ((None, 6), NumericalInterval(5, 6)), + ], + ) + def test_filter_extent_basic(self, extent, expected): + dim1 = SpatialDimension(name="x", axis="x", extent=(5, 8), crs=4326) + dim2 = dim1.filter_extent(extent=extent) + assert dim1.extent == (5, 8) + assert dim2.name == "x" + assert dim2.axis == "x" + assert dim2.extent == expected + assert dim2.crs == 4326 + + @pytest.mark.parametrize( + ["interval1", "interval2", "expected"], + [ + # Empty extent handling + ((5, 8), _EmptyExtent(), _EmptyExtent), + (_EmptyExtent(), (5, 8), _EmptyExtent), + (_EmptyExtent(), _EmptyExtent(), _EmptyExtent), + # Non-concrete extent handling + ((5, 8), _NonConcreteExtent(), _NonConcreteExtent), + (_NonConcreteExtent(), (5, 8), _NonConcreteExtent), + (_NonConcreteExtent(), _NonConcreteExtent(), _NonConcreteExtent), + # Mixed + (_EmptyExtent(), _NonConcreteExtent(), _EmptyExtent), + (_NonConcreteExtent(), _EmptyExtent(), _EmptyExtent), + ], + ) + def test_filter_extent_empty_or_non_concrete(self, interval1, interval2, expected): + dim1 = SpatialDimension(name="x", axis="x", extent=interval1, crs=4326) + dim2 = dim1.filter_extent(extent=interval2) + assert isinstance(dim2.extent, expected) + + def test_filter_extent_none(self): + dim1 = SpatialDimension(name="x", axis="x", extent=(5, 8), crs=4326) + dim2 = dim1.filter_extent(extent=None) + assert dim2.extent == NumericalInterval(5, 8) + + def test_filter_extent_multiple(self): + dim1 = SpatialDimension(name="x", axis="x", extent=(-90, 90), crs=4326) + dim2 = dim1.filter_extent(extent=(-30, 60)) + dim3 = dim2.filter_extent(extent=(-60, 30)) + dim4 = dim3.filter_extent(extent=(10, 20)) + dim5 = dim4.filter_extent(extent=(30, 40)) + dim6 = dim5.filter_extent(extent=(50, 60)) + assert dim1.extent == (-90, 90) + assert dim2.extent == (-30, 60) + assert dim3.extent == (-30, 30) + assert dim4.extent == (10, 20) + assert isinstance(dim5.extent, _EmptyExtent) + assert isinstance(dim6.extent, _EmptyExtent) + + def test_filter_extent_parameterized(self): + dim1 = SpatialDimension(name="x", axis="x", extent=(-90, 90), crs=4326) + extent = openeo.api.process.Parameter.array(name="extent", description="extent") + dim2 = dim1.filter_extent(extent=extent) + assert dim1.extent == (-90, 90) + assert isinstance(dim2.extent, _NonConcreteExtent) + + +class TestSpatialDimensionsXY: + def test_basic(self): + dim_x = SpatialDimension(name="x", axis="x", extent=(3, 6), crs=4326) + dim_y = SpatialDimension(name="y", axis="y", extent=(49, 52), crs=4326) + dim_xy = SpatialDimensionsXY(dim_x, dim_y) + assert (dim_xy.x.name, dim_xy.x.axis, dim_xy.x.extent) == ("x", "x", (3, 6)) + assert (dim_xy.y.name, dim_xy.y.axis, dim_xy.y.extent) == ("y", "y", (49, 52)) + + def test_eq_tuple(self): + dim_x = SpatialDimension(name="x", axis="x", extent=(3, 6), crs=4326) + dim_y = SpatialDimension(name="y", axis="y", extent=(49, 52), crs=4326) + dim_xy = SpatialDimensionsXY(dim_x, dim_y) + assert dim_xy == ( + SpatialDimension(name="x", axis="x", extent=(3, 6), crs=4326), + SpatialDimension(name="y", axis="y", extent=(49, 52), crs=4326), + ) + + @pytest.mark.parametrize( + ["extent", "expected"], + [ + ({"west": 4, "south": 50, "east": 5, "north": 51, "crs": 4326}, ((4, 5), (50, 51))), + ({"west": 1, "south": 40, "east": 4, "north": 50, "crs": 4326}, ((2, 4), (49, 50))), + ({"west": 5, "south": 50, "east": 8, "north": 60, "crs": 4326}, ((5, 7), (50, 52))), + ], + ) + def test_filter_bbox_simple(self, extent, expected): + """Simple case: all in same CRS (EPSG:4326)""" + dim_xy = SpatialDimensionsXY( + SpatialDimension(name="x", axis="x", extent=(2, 7), crs=4326), + SpatialDimension(name="y", axis="y", extent=(49, 52), crs=4326), + ) + dim_xy_filtered = dim_xy.filter_bbox(extent=extent) + + assert (dim_xy.x.extent, dim_xy.y.extent) == ((2, 7), (49, 52)) + assert (dim_xy_filtered.x.extent, dim_xy_filtered.y.extent) == expected + + def test_filter_bbox_empty(self): + dim_xy = SpatialDimensionsXY( + SpatialDimension(name="x", axis="x", extent=(2, 7), crs=4326), + SpatialDimension(name="y", axis="y", extent=(49, 52), crs=4326), + ) + extent = _EmptyExtent() + dim_xy_filtered = dim_xy.filter_bbox(extent=extent) + + assert (dim_xy.x.extent, dim_xy.y.extent) == ((2, 7), (49, 52)) + assert dim_xy_filtered.x.extent == _EmptyExtent() + assert dim_xy_filtered.y.extent == _EmptyExtent() + + @pytest.mark.parametrize( + ["extent"], + [ + (openeo.api.process.Parameter.spatial_extent(name="roi"),), + ( + { + "west": openeo.api.process.Parameter.number(name="west"), + "south": openeo.api.process.Parameter.number(name="south"), + "east": openeo.api.process.Parameter.number(name="east"), + "north": openeo.api.process.Parameter.number(name="north"), + }, + ), + ], + ) + def test_filter_bbox_non_concrete_extent(self, extent): + dim_xy = SpatialDimensionsXY( + SpatialDimension(name="x", axis="x", extent=(2, 7), crs=4326), + SpatialDimension(name="y", axis="y", extent=(49, 52), crs=4326), + ) + dim_xy_filtered = dim_xy.filter_bbox(extent=extent) + + assert (dim_xy.x.extent, dim_xy.y.extent) == ((2, 7), (49, 52)) + assert isinstance(dim_xy_filtered.x.extent, _NonConcreteExtent) + assert isinstance(dim_xy_filtered.y.extent, _NonConcreteExtent) + class TestBand: def test_band_minimal(self): @@ -1156,10 +1301,10 @@ def filter_bbox(self, bbox): def test_resample_spatial(self, cube_metadata, kwargs, expected_x, expected_y): metadata = cube_metadata.resample_spatial(**kwargs) assert isinstance(metadata, CubeMetadata) - assert metadata.spatial_dimensions == [ + assert metadata.spatial_dimensions == ( SpatialDimension(name="x", extent=[2, 7], **expected_x), SpatialDimension(name="y", extent=[49, 52], **expected_y), - ] + ) assert metadata.temporal_dimension == cube_metadata.temporal_dimension assert metadata.band_dimension == cube_metadata.band_dimension @@ -1170,10 +1315,10 @@ def test_resample_spatial_parameter_resolution_only(self, cube_metadata): metadata = cube_metadata.resample_spatial(resolution=param) assert isinstance(metadata, CubeMetadata) # step must be set to None after parametrized resample_spatial. - assert metadata.spatial_dimensions == [ - SpatialDimension(name="x", extent=[2, 7], crs=4326, step=None), - SpatialDimension(name="y", extent=[49, 52], crs=4326, step=None), - ] + assert metadata.spatial_dimensions == ( + SpatialDimension(name="x", extent=[2, 7], crs=4326), + SpatialDimension(name="y", extent=[49, 52], crs=4326), + ) assert metadata.temporal_dimension == cube_metadata.temporal_dimension assert metadata.band_dimension == cube_metadata.band_dimension @@ -1183,10 +1328,10 @@ def test_resample_spatial_parameter_resolution_with_projection(self, cube_metada param = Parameter.number("res", description="The spatial resolution.") metadata = cube_metadata.resample_spatial(resolution=param, projection=32631) assert isinstance(metadata, CubeMetadata) - assert metadata.spatial_dimensions == [ - SpatialDimension(name="x", extent=[2, 7], crs=32631, step=None), - SpatialDimension(name="y", extent=[49, 52], crs=32631, step=None), - ] + assert metadata.spatial_dimensions == ( + SpatialDimension(name="x", extent=[2, 7], crs=32631), + SpatialDimension(name="y", extent=[49, 52], crs=32631), + ) assert metadata.temporal_dimension == cube_metadata.temporal_dimension assert metadata.band_dimension == cube_metadata.band_dimension @@ -1196,10 +1341,10 @@ def test_resample_spatial_parameter_projection_only(self, cube_metadata): param = Parameter.integer("proj", description="The target projection.") metadata = cube_metadata.resample_spatial(resolution=10, projection=param) assert isinstance(metadata, CubeMetadata) - assert metadata.spatial_dimensions == [ + assert metadata.spatial_dimensions == ( SpatialDimension(name="x", extent=[2, 7], crs=None, step=10), SpatialDimension(name="y", extent=[49, 52], crs=None, step=10), - ] + ) assert metadata.temporal_dimension == cube_metadata.temporal_dimension assert metadata.band_dimension == cube_metadata.band_dimension @@ -1210,10 +1355,10 @@ def test_resample_spatial_parameter_resolution_and_projection(self, cube_metadat proj_param = Parameter.integer("proj", description="The target projection.") metadata = cube_metadata.resample_spatial(resolution=res_param, projection=proj_param) assert isinstance(metadata, CubeMetadata) - assert metadata.spatial_dimensions == [ - SpatialDimension(name="x", extent=[2, 7], crs=None, step=None), - SpatialDimension(name="y", extent=[49, 52], crs=None, step=None), - ] + assert metadata.spatial_dimensions == ( + SpatialDimension(name="x", extent=[2, 7], crs=None), + SpatialDimension(name="y", extent=[49, 52], crs=None), + ) assert metadata.temporal_dimension == cube_metadata.temporal_dimension assert metadata.band_dimension == cube_metadata.band_dimension @@ -1222,26 +1367,26 @@ def test_resample_cube_spatial(self, cube_metadata): metadata1 = cube_metadata.resample_spatial(resolution=(11, 22), projection=32631) metadata2 = cube_metadata.resample_spatial(resolution=0.5) - assert metadata1.spatial_dimensions == [ + assert metadata1.spatial_dimensions == ( SpatialDimension(name="x", extent=[2, 7], crs=32631, step=11), SpatialDimension(name="y", extent=[49, 52], crs=32631, step=22), - ] - assert metadata2.spatial_dimensions == [ + ) + assert metadata2.spatial_dimensions == ( SpatialDimension(name="x", extent=[2, 7], crs=4326, step=0.5), SpatialDimension(name="y", extent=[49, 52], crs=4326, step=0.5), - ] + ) metadata12 = metadata1.resample_cube_spatial(target=metadata2) - assert metadata12.spatial_dimensions == [ + assert metadata12.spatial_dimensions == ( SpatialDimension(name="x", extent=[2, 7], crs=4326, step=0.5), SpatialDimension(name="y", extent=[49, 52], crs=4326, step=0.5), - ] + ) metadata21 = metadata2.resample_cube_spatial(target=metadata1) - assert metadata21.spatial_dimensions == [ + assert metadata21.spatial_dimensions == ( SpatialDimension(name="x", extent=[2, 7], crs=32631, step=11), SpatialDimension(name="y", extent=[49, 52], crs=32631, step=22), - ] + ) def test_resample_cube_spatial_preserve_non_spatial(self): xy1 = [ @@ -1267,22 +1412,22 @@ def test_resample_cube_spatial_preserve_non_spatial(self): metadata3 = CubeMetadata(dimensions=xy3 + [b3]) result12 = metadata1.resample_cube_spatial(target=metadata2) - assert result12.spatial_dimensions == xy2 + assert result12.spatial_dimensions == SpatialDimensionsXY(*xy2) assert result12.band_dimension == b1 assert result12.temporal_dimension == t1 result21 = metadata2.resample_cube_spatial(target=metadata1) - assert result21.spatial_dimensions == xy1 + assert result21.spatial_dimensions == SpatialDimensionsXY(*xy1) assert not result21.has_band_dimension() assert result21.temporal_dimension == t2 result23 = metadata2.resample_cube_spatial(target=metadata3) - assert result23.spatial_dimensions == xy3 + assert result23.spatial_dimensions == SpatialDimensionsXY(*xy3) assert not result23.has_band_dimension() assert result23.temporal_dimension == t2 result31 = metadata3.resample_cube_spatial(target=metadata1) - assert result31.spatial_dimensions == xy1 + assert result31.spatial_dimensions == SpatialDimensionsXY(*xy1) assert result31.band_dimension == b3 assert not result31.has_temporal_dimension() @@ -1325,6 +1470,56 @@ def test_repr_html_with_dimensions(self): regex_flags=re.DOTALL | re.VERBOSE, ) + @pytest.mark.parametrize( + ["spatial_extent", "expected"], + [ + ({"west": 3, "east": 5, "south": 46, "north": 51}, ((3, 5), (49, 51))), + ({"west": 3, "east": 5, "south": 46, "north": 51, "crs": 4326}, ((3, 5), (49, 51))), + ({"west": 3, "east": 5, "south": 46, "north": 51, "crs": "EPSG:4326"}, ((3, 5), (49, 51))), + ], + ) + def test_filter_bbox_basic(self, spatial_extent, expected): + metadata1 = CubeMetadata( + dimensions=[ + SpatialDimension(name="x", axis="x", extent=(2, 7), crs=4326), + SpatialDimension(name="y", axis="y", extent=(49, 52), crs=4326), + TemporalDimension(name="t", extent=("2021-01-01", None)), + ] + ) + metadata2 = metadata1.filter_bbox(extent=spatial_extent) + + assert metadata1.spatial_dimensions == ( + SpatialDimension(name="x", axis="x", extent=(2, 7), crs=4326), + SpatialDimension(name="y", axis="y", extent=(49, 52), crs=4326), + ) + expected_x, expected_y = expected + assert metadata2.spatial_dimensions == ( + SpatialDimension(name="x", axis="x", extent=expected_x, crs=4326), + SpatialDimension(name="y", axis="y", extent=expected_y, crs=4326), + ) + + def test_filter_bbox_parameterized(self): + metadata1 = CubeMetadata( + dimensions=[ + SpatialDimension(name="x", axis="x", extent=(2, 7), crs=4326), + SpatialDimension(name="y", axis="y", extent=(49, 52), crs=4326), + TemporalDimension(name="t", extent=("2021-01-01", None)), + ] + ) + spatial_extent = openeo.api.process.Parameter.spatial_extent() + metadata2 = metadata1.filter_bbox(extent=spatial_extent) + + assert metadata1.spatial_dimensions == ( + SpatialDimension(name="x", axis="x", extent=(2, 7), crs=4326), + SpatialDimension(name="y", axis="y", extent=(49, 52), crs=4326), + ) + assert metadata2.spatial_dimensions == ( + SpatialDimension(name="x", axis="x", extent=dirty_equals.IsInstance(_NonConcreteExtent), crs=4326), + SpatialDimension(name="y", axis="y", extent=dirty_equals.IsInstance(_NonConcreteExtent), crs=4326), + ) + + + class TestStacMetadataParser: @pytest.mark.parametrize( diff --git a/tests/rest/datacube/conftest.py b/tests/rest/datacube/conftest.py index ddf0c1170..e187a1584 100644 --- a/tests/rest/datacube/conftest.py +++ b/tests/rest/datacube/conftest.py @@ -67,7 +67,12 @@ def setup_collection_metadata(requests_mock, cid: str, bands: List[str]): requests_mock.get( API_URL + f"/collections/{cid}", json={ - "cube:dimensions": {"bands": {"type": "bands", "values": bands}}, + "cube:dimensions": { + "x": {"type": "spatial", "axis": "x", "extent": [-180, 180], "reference_system": 4326}, + "y": {"type": "spatial", "axis": "y", "extent": [-90, 90], "reference_system": 4326}, + "t": {"type": "temporal", "extent": ["2020-02-20T02:20:22Z", None]}, + "bands": {"type": "bands", "values": bands}, + }, "summaries": {"eo:bands": [{"name": b} for b in bands]}, }, ) diff --git a/tests/rest/datacube/test_datacube.py b/tests/rest/datacube/test_datacube.py index ee5b415b6..a3537c205 100644 --- a/tests/rest/datacube/test_datacube.py +++ b/tests/rest/datacube/test_datacube.py @@ -918,10 +918,10 @@ def test_resample_spatial(s2cube): } } - assert cube.metadata.spatial_dimensions == [ + assert cube.metadata.spatial_dimensions == ( SpatialDimension(name="x", extent=None, crs=4578, step=2.0), SpatialDimension(name="y", extent=None, crs=4578, step=3.0), - ] + ) def test_resample_spatial_no_metadata(s2cube_without_metadata): @@ -938,10 +938,10 @@ def test_resample_spatial_no_metadata(s2cube_without_metadata): }, } } - assert cube.metadata.spatial_dimensions == [ + assert cube.metadata.spatial_dimensions == ( SpatialDimension(name="x", extent=[None, None], crs=4578, step=3.0), SpatialDimension(name="y", extent=[None, None], crs=4578, step=5.0), - ] + ) def test_resample_spatial_parameter_resolution(s2cube): @@ -960,10 +960,10 @@ def test_resample_spatial_parameter_resolution(s2cube): }, } } - assert cube.metadata.spatial_dimensions == [ + assert cube.metadata.spatial_dimensions == ( SpatialDimension(name="x", extent=None, crs=32631, step=None), SpatialDimension(name="y", extent=None, crs=32631, step=None), - ] + ) def test_resample_spatial_parameter_resolution_no_projection(s2cube): @@ -1000,10 +1000,10 @@ def test_resample_spatial_parameter_projection(s2cube): }, } } - assert cube.metadata.spatial_dimensions == [ + assert cube.metadata.spatial_dimensions == ( SpatialDimension(name="x", extent=None, crs=None, step=10), SpatialDimension(name="y", extent=None, crs=None, step=10), - ] + ) def test_resample_spatial_parameter_resolution_and_projection(s2cube): @@ -1023,10 +1023,10 @@ def test_resample_spatial_parameter_resolution_and_projection(s2cube): }, } } - assert cube.metadata.spatial_dimensions == [ + assert cube.metadata.spatial_dimensions == ( SpatialDimension(name="x", extent=None, crs=None, step=None), SpatialDimension(name="y", extent=None, crs=None, step=None), - ] + ) def test_resample_cube_spatial(s2cube): @@ -1064,10 +1064,10 @@ def test_resample_cube_spatial(s2cube): "process_id": "resample_cube_spatial", }, } - assert cube12.metadata.spatial_dimensions == [ + assert cube12.metadata.spatial_dimensions == ( SpatialDimension(name="x", extent=None, crs=32631, step=10), SpatialDimension(name="y", extent=None, crs=32631, step=10), - ] + ) cube21 = cube2.resample_cube_spatial(target=cube1) assert get_download_graph(cube21, drop_load_collection=True, drop_save_result=True) == { @@ -1100,10 +1100,10 @@ def test_resample_cube_spatial(s2cube): "process_id": "resample_cube_spatial", }, } - assert cube21.metadata.spatial_dimensions == [ + assert cube21.metadata.spatial_dimensions == ( SpatialDimension(name="x", extent=None, crs=4578, step=2.0), SpatialDimension(name="y", extent=None, crs=4578, step=3.0), - ] + ) def test_resample_cube_spatial_no_source_metadata(s2cube, s2cube_without_metadata): @@ -1113,10 +1113,10 @@ def test_resample_cube_spatial_no_source_metadata(s2cube, s2cube_without_metadat assert target.metadata is not None result = cube.resample_cube_spatial(target=target) - assert result.metadata.spatial_dimensions == [ + assert result.metadata.spatial_dimensions == ( SpatialDimension(name="x", extent=None, crs=32631, step=10), SpatialDimension(name="y", extent=None, crs=32631, step=10), - ] + ) def test_resample_cube_spatial_preserve_non_spatial(s2cube): @@ -1128,17 +1128,17 @@ def test_resample_cube_spatial_preserve_non_spatial(s2cube): result = cube_xyt.resample_cube_spatial(target=cube_xytb) assert result.metadata.dimension_names() == ["t", "x", "y"] - assert result.metadata.spatial_dimensions == [ + assert result.metadata.spatial_dimensions == ( SpatialDimension(name="x", extent=None, crs=32631, step=11), SpatialDimension(name="y", extent=None, crs=32631, step=11), - ] + ) result = cube_xyb.resample_cube_spatial(target=cube_xyt) assert result.metadata.dimension_names() == ["bands", "x", "y"] - assert result.metadata.spatial_dimensions == [ + assert result.metadata.spatial_dimensions == ( SpatialDimension(name="x", extent=None, crs=32631, step=22), SpatialDimension(name="y", extent=None, crs=32631, step=22), - ] + ) def test_resample_cube_spatial_no_target_metadata(s2cube, s2cube_without_metadata): diff --git a/tests/test_util.py b/tests/test_util.py index 35b2e3e2c..d967595e7 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -790,6 +790,22 @@ def test_to_bbox_dict_from_dict_missing_field(self): with pytest.raises(InvalidBBoxException, match=re.escape("Missing bbox fields ['north', 'south', 'west']")): to_bbox_dict({"east": 3}) + def test_try_from_dict(self): + assert BBoxDict.try_from_dict({}) is None + assert BBoxDict.try_from_dict({"foo": "bar"}) is None + assert BBoxDict.try_from_dict([1, 2, 3, 4]) is None + assert BBoxDict.try_from_dict((1, 2, 3, 4)) is None + assert BBoxDict.try_from_dict("Belgium") is None + assert BBoxDict.try_from_dict(None) is None + assert BBoxDict.try_from_dict(object()) is None + + assert BBoxDict.try_from_dict({"west": 1, "south": 2, "east": 3, "north": 4}) == { + "west": 1, + "south": 2, + "east": 3, + "north": 4, + } + def test_to_bbox_dict_multiple_crs(self): with pytest.raises(InvalidBBoxException, match="Two CRS values specified: EPSG:32631 and 4326"): _ = to_bbox_dict({"west": 1, "south": 2, "east": 3, "north": 4, "crs": 4326}, crs="EPSG:32631") From 17d48f2865ebbcf2623e2c167e10cc172916ba56 Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Mon, 19 Oct 2026 12:05:54 +0200 Subject: [PATCH 07/13] Exclude antimeridian handling from metadata filter_bbox for now refs #827, #949 --- openeo/metadata/__init__.py | 4 ++++ tests/metadata/test_metadata.py | 39 +++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/openeo/metadata/__init__.py b/openeo/metadata/__init__.py index 27d294f52..4692a116b 100644 --- a/openeo/metadata/__init__.py +++ b/openeo/metadata/__init__.py @@ -154,6 +154,10 @@ def filter_bbox(self, extent: Any) -> SpatialDimensionsXY: # No reprojection needed extent_x = bbox["west"], bbox["east"] extent_y = bbox["south"], bbox["north"] + + if bbox_crs == 4326 and bbox["west"] > bbox["east"]: + # TODO: proper detection and handling of antimeridian crossing + extent_x, extent_y = _NonConcreteExtent(), _NonConcreteExtent() else: # TODO: reproject extent_x, extent_y = _NonConcreteExtent(), _NonConcreteExtent() diff --git a/tests/metadata/test_metadata.py b/tests/metadata/test_metadata.py index 107ba4bb6..f0e551973 100644 --- a/tests/metadata/test_metadata.py +++ b/tests/metadata/test_metadata.py @@ -270,6 +270,25 @@ def test_filter_bbox_non_concrete_extent(self, extent): assert isinstance(dim_xy_filtered.x.extent, _NonConcreteExtent) assert isinstance(dim_xy_filtered.y.extent, _NonConcreteExtent) + @pytest.mark.parametrize( + ["extent"], + [ + ({"west": 171, "south": 40, "east": -172, "north": 50, "crs": 4326},), + ], + ) + def test_filter_bbox_antimeridian(self, extent): + # TODO: test proper detection and handling of antimeridian crossing + dim_xy = SpatialDimensionsXY( + SpatialDimension(name="x", axis="x", extent=(-180, 180), crs=4326), + SpatialDimension(name="y", axis="y", extent=(-70, 70), crs=4326), + ) + dim_xy_filtered = dim_xy.filter_bbox(extent=extent) + + assert (dim_xy_filtered.x.extent, dim_xy_filtered.y.extent) == ( + dirty_equals.IsInstance(_NonConcreteExtent), + dirty_equals.IsInstance(_NonConcreteExtent), + ) + class TestBand: def test_band_minimal(self): @@ -1518,7 +1537,27 @@ def test_filter_bbox_parameterized(self): SpatialDimension(name="y", axis="y", extent=dirty_equals.IsInstance(_NonConcreteExtent), crs=4326), ) + @pytest.mark.parametrize( + ["spatial_extent"], + [ + ({"west": 170, "south": 40, "east": -170, "north": 50},), + ], + ) + def test_filter_bbox_antimeridian(self, spatial_extent): + # TODO: test proper detection and handling of antimeridian crossing + metadata1 = CubeMetadata( + dimensions=[ + SpatialDimension(name="x", axis="x", extent=(-180, 180), crs=4326), + SpatialDimension(name="y", axis="y", extent=(-70, 70), crs=4326), + TemporalDimension(name="t", extent=("2021-01-01", None)), + ] + ) + metadata2 = metadata1.filter_bbox(extent=spatial_extent) + assert metadata2.spatial_dimensions == ( + SpatialDimension(name="x", axis="x", extent=dirty_equals.IsInstance(_NonConcreteExtent), crs=4326), + SpatialDimension(name="y", axis="y", extent=dirty_equals.IsInstance(_NonConcreteExtent), crs=4326), + ) class TestStacMetadataParser: From 9545d61de9add3123924ee89b7c9c6f07f19cbe9 Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Mon, 19 Oct 2026 16:40:01 +0200 Subject: [PATCH 08/13] minor #867 fixup --- tests/metadata/test_metadata.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/metadata/test_metadata.py b/tests/metadata/test_metadata.py index f0e551973..bc3f9b7d1 100644 --- a/tests/metadata/test_metadata.py +++ b/tests/metadata/test_metadata.py @@ -1738,8 +1738,8 @@ def test_metadata_from_stac_temporal_dimension(self, tmp_path, stac_dict, expect # cube:dimensions present -> use the dimension names as suggested by cube:dimensions keys StacDummyBuilder.collection( cube_dimensions={ - "time": {"type": "temporal", "axis": "t", "extent": ["2024-04-04", "2024-06-06"]}, - "band": {"type": "bands", "axis": "bands", "values": ["B01"]}, + "time": {"type": "temporal", "extent": ["2024-04-04", "2024-06-06"]}, + "band": {"type": "bands", "values": ["B01"]}, "y": {"type": "spatial", "axis": "y", "extent": [0, 1]}, "x": {"type": "spatial", "axis": "x", "extent": [0, 1]}, } @@ -1751,7 +1751,7 @@ def test_metadata_from_stac_temporal_dimension(self, tmp_path, stac_dict, expect StacDummyBuilder.collection( summaries={"eo:bands": [{"name": "B01"}]}, cube_dimensions={ - "time": {"type": "temporal", "axis": "t", "extent": ["2024-04-04", "2024-06-06"]}, + "time": {"type": "temporal", "extent": ["2024-04-04", "2024-06-06"]}, "y": {"type": "spatial", "axis": "y", "extent": [0, 1]}, "x": {"type": "spatial", "axis": "x", "extent": [0, 1]}, }, @@ -1789,8 +1789,8 @@ def test_metadata_from_stac_cube_dimensions_geometries_not_treated_as_band_dimen stac_dict = StacDummyBuilder.collection( cube_dimensions={ "geom": {"type": "geometries"}, - "time": {"type": "temporal", "axis": "t", "extent": ["2024-04-04", "2024-06-06"]}, - "band": {"type": "bands", "axis": "bands", "values": ["B01"]}, + "time": {"type": "temporal", "extent": ["2024-04-04", "2024-06-06"]}, + "band": {"type": "bands", "values": ["B01"]}, } ) assert stac_dict["stac_extensions"] == [StacDummyBuilder._EXT_DATACUBE] From 868cdc88c8c675bc98ea2a1514d26a16fadcf94c Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Mon, 19 Oct 2026 17:19:30 +0200 Subject: [PATCH 09/13] Track SpatialDimension.crs from _StacMetadataParser ref #827 #955 --- openeo/metadata/__init__.py | 12 ++- openeo/rest/_testing.py | 6 +- tests/metadata/test_metadata.py | 47 +++++++++ tests/rest/datacube/test_datacube.py | 139 ++++++++++++++++++++++++++- 4 files changed, 199 insertions(+), 5 deletions(-) diff --git a/openeo/metadata/__init__.py b/openeo/metadata/__init__.py index 4692a116b..a274f5e2b 100644 --- a/openeo/metadata/__init__.py +++ b/openeo/metadata/__init__.py @@ -105,6 +105,7 @@ def __init__( super().__init__(type="spatial", name=name) # TODO: eliminate guessing of axis from name, and make axis a properly required argument (without None as default). self.axis: Literal["x", "y"] = axis or name + # TODO: normalize extent to NumericalInterval when appropriate? self.extent = extent # TODO Rename/proxy "crs" to "reference_system" to better align with STAC datacube extension terminology. self.crs = crs @@ -677,6 +678,7 @@ def resample_spatial( dimensions[i] = SpatialDimension( name=dim.name, axis=dim.axis, + # TODO #955 extent should be reprojected to new projection when necessary extent=dim.extent, crs=None if isinstance(projection, Parameter) else (projection or dim.crs), step=r if r != 0.0 else dim.step, @@ -687,6 +689,7 @@ def resample_spatial( def resample_cube_spatial(self, target: CubeMetadata) -> CubeMetadata: # Replace spatial dimensions with ones from target, but keep other dimensions dimensions = [d for d in (self._dimensions or []) if not isinstance(d, SpatialDimension)] + # TODO #955 the spatial extent of the original cube should be reprojected to target projection dimensions.extend(target.spatial_dimensions) return self._clone_and_update(dimensions=dimensions) @@ -1033,7 +1036,7 @@ def _parse_cube_dimensions_from_pystac_extension( extent = self._safe_extent_from_pystac_cube_dim(dim) if dim_type == pystac.extensions.datacube.DimensionType.SPATIAL: - dimensions.append(SpatialDimension(name=name, axis=dim.axis, extent=extent)) + dimensions.append(SpatialDimension(name=name, axis=dim.axis, extent=extent, crs=dim.reference_system)) elif dim_type == pystac.extensions.datacube.DimensionType.TEMPORAL: dimensions.append(TemporalDimension(name=name, extent=extent)) elif dim_type in ("bands", "spectral"): @@ -1062,7 +1065,12 @@ def _parse_cube_dimensions_from_raw_dict(self, stac_object: pystac.STACObject, b dim_type = dim.get("type") if dim_type == "spatial": dimensions.append( - SpatialDimension(name=name, axis=dim.get("axis"), extent=dim.get("extent", [None, None])) + SpatialDimension( + name=name, + axis=dim.get("axis"), + extent=dim.get("extent", [None, None]), + crs=dim.get("reference_system", SpatialDimension.DEFAULT_CRS), + ) ) elif dim_type == "temporal": dimensions.append(TemporalDimension(name=name, extent=dim.get("extent", [None, None]))) diff --git a/openeo/rest/_testing.py b/openeo/rest/_testing.py index 3084fb201..f8b4aa9bb 100644 --- a/openeo/rest/_testing.py +++ b/openeo/rest/_testing.py @@ -161,12 +161,14 @@ def setup_collection( *, temporal: Union[bool, Tuple[str, str]] = True, bands: Sequence[str] = ("B1", "B2", "B3"), + spatial_x: Dict | None = None, + spatial_y: Dict | None = None, ): # TODO: also mock `/collections` overview # TODO: option to override cube_dimensions as a whole, or override dimension names cube_dimensions = { - "x": {"type": "spatial"}, - "y": {"type": "spatial"}, + "x": {"type": "spatial", "axis": "x", **(spatial_x or {})}, + "y": {"type": "spatial", "axis": "y", **(spatial_y or {})}, } if temporal: diff --git a/tests/metadata/test_metadata.py b/tests/metadata/test_metadata.py index bc3f9b7d1..76d8a23eb 100644 --- a/tests/metadata/test_metadata.py +++ b/tests/metadata/test_metadata.py @@ -1803,6 +1803,53 @@ def test_metadata_from_stac_cube_dimensions_geometries_not_treated_as_band_dimen assert metadata.has_geometry_dimension() assert isinstance(metadata.geometry_dimension, GeometryDimension) + @pytest.mark.parametrize( + ["stac_dict", "expected"], + [ + ( + StacDummyBuilder.collection( + cube_dimensions={ + "t": {"type": "temporal", "extent": ["2024-04-04", "2024-06-06"]}, + "band": {"type": "bands", "values": ["B01"]}, + "x": {"type": "spatial", "axis": "x", "extent": [-180, 180], "reference_system": 4326}, + "y": {"type": "spatial", "axis": "y", "extent": [-70, 70], "reference_system": 4326}, + } + ), + ( + SpatialDimension(name="x", axis="x", extent=[-180, 180], crs=4326), + SpatialDimension(name="y", axis="y", extent=[-70, 70], crs=4326), + ), + ), + ( + StacDummyBuilder.collection( + cube_dimensions={ + "t": {"type": "temporal", "extent": ["2024-04-04", "2024-06-06"]}, + "band": {"type": "bands", "values": ["B01"]}, + "x": {"type": "spatial", "axis": "x", "extent": [100_000, 900_000], "reference_system": 32631}, + "y": { + "type": "spatial", + "axis": "y", + "extent": [3_000_000, 7_000_000], + "reference_system": 32631, + }, + } + ), + ( + SpatialDimension(name="x", axis="x", extent=[100_000, 900_000], crs=32631), + SpatialDimension(name="y", axis="y", extent=[3_000_000, 7_000_000], crs=32631), + ), + ), + ], + ) + def test_metadata_from_stac_spatial_dimensions(self, tmp_path, stac_dict, expected): + # Dimension name resolution policy (STAC cube:dimensions vs openEO defaults) + path = tmp_path / "stac.json" + # TODO #738 real request mocking of STAC resources compatible with pystac? + path.write_text(json.dumps(stac_dict)) + + metadata = metadata_from_stac(str(path)) + assert metadata.spatial_dimensions == expected + def test_band_from_eo_bands_metadata_basic(self): assert _StacMetadataParser()._band_from_eo_bands_metadata( {"name": "B04"}, diff --git a/tests/rest/datacube/test_datacube.py b/tests/rest/datacube/test_datacube.py index a3537c205..c975ad235 100644 --- a/tests/rest/datacube/test_datacube.py +++ b/tests/rest/datacube/test_datacube.py @@ -23,7 +23,7 @@ from openeo import collection_property from openeo.api.process import Parameter from openeo.internal.graph_building import PGNode -from openeo.metadata import SpatialDimension +from openeo.metadata import NumericalInterval, SpatialDimension, _NonConcreteExtent from openeo.rest import BandMathException, OpenEoClientException from openeo.rest._testing import build_capabilities from openeo.rest.connection import Connection @@ -209,6 +209,63 @@ def test_load_collection_spatial_extent_parameter(self, dummy_backend): "temporal_extent": None, } + @pytest.mark.parametrize( + ["source_extents", "spatial_extent", "expected_metadata"], + [ + ( + # Everything EPSG:4326: Source metadata, requested spatial extent, ... + { + "x": {"extent": [-180, 180], "reference_system": 4326}, + "y": {"extent": [-70, 70], "reference_system": 4326}, + }, + {"west": 1, "south": 2, "east": 3, "north": 4}, + { + "x": {"extent": NumericalInterval(1, 3), "crs": 4326}, + "y": {"extent": NumericalInterval(2, 4), "crs": 4326}, + }, + ), + ( + # Source in UTM (EPSG:32631), requested spatial extent in EPSG:4326 + { + "x": {"extent": [100_000, 900_000], "reference_system": 32631}, + "y": {"extent": [3_000_000, 7_000_000], "reference_system": 32631}, + }, + {"west": 3, "south": 51, "east": 3.5, "north": 51.3, "crs": 4326}, + { + # TODO: handle spatial extent intersection with reprojection + "x": {"extent": dirty_equals.IsInstance(_NonConcreteExtent), "crs": 32631}, + "y": {"extent": dirty_equals.IsInstance(_NonConcreteExtent), "crs": 32631}, + }, + ), + ( + # Source and requested spatial extent in UTM (EPSG:32631) + { + "x": {"extent": [100_000, 900_000], "reference_system": 32631}, + "y": {"extent": [3_000_000, 7_000_000], "reference_system": 32631}, + }, + {"west": 500_000, "south": 5_649_820, "east": 535_080, "north": 5_683_300, "crs": 32631}, + { + "x": {"extent": NumericalInterval(500_000, 535_080), "crs": 32631}, + "y": {"extent": NumericalInterval(5_649_820, 5_683_300), "crs": 32631}, + }, + ), + ], + ) + def test_load_collection_spatial_extent_cube_metadata( + self, dummy_backend, source_extents: dict, spatial_extent, expected_metadata + ): + dummy_backend.setup_collection(collection_id="S2", spatial_x=source_extents["x"], spatial_y=source_extents["y"]) + cube = DataCube.load_collection("S2", spatial_extent=spatial_extent, connection=dummy_backend.connection) + + assert cube.metadata.spatial_dimensions == ( + SpatialDimension( + name="x", axis="x", extent=expected_metadata["x"]["extent"], crs=expected_metadata["x"].get("crs") + ), + SpatialDimension( + name="y", axis="y", extent=expected_metadata["y"]["extent"], crs=expected_metadata["y"].get("crs") + ), + ) + def test_load_collection_connectionless_save_result(self): cube = DataCube.load_collection("T3").save_result(format="GTiff") assert cube.flat_graph() == { @@ -317,6 +374,86 @@ def test_load_stac_spatial_extent_parameter(self, dummy_backend): "spatial_extent": {"from_parameter": "zpatial_extent"}, } + @pytest.mark.parametrize( + ["source_extents", "spatial_extent", "expected_metadata"], + [ + ( + # Everything EPSG:4326: Source metadata, requested spatial extent, ... + { + "x": {"extent": [-180, 180], "reference_system": 4326}, + "y": {"extent": [-70, 70], "reference_system": 4326}, + }, + {"west": 1, "south": 2, "east": 3, "north": 4}, + { + "x": {"extent": NumericalInterval(1, 3), "crs": 4326}, + "y": {"extent": NumericalInterval(2, 4), "crs": 4326}, + }, + ), + ( + # Source in UTM (EPSG:32631), requested spatial extent in EPSG:4326 + { + "x": {"extent": [100_000, 900_000], "reference_system": 32631}, + "y": {"extent": [3_000_000, 7_000_000], "reference_system": 32631}, + }, + {"west": 3, "south": 51, "east": 3.5, "north": 51.3, "crs": 4326}, + { + # TODO: handle spatial extent intersection with reprojection + "x": {"extent": dirty_equals.IsInstance(_NonConcreteExtent), "crs": 32631}, + "y": {"extent": dirty_equals.IsInstance(_NonConcreteExtent), "crs": 32631}, + }, + ), + ( + # Source and requested spatial extent in UTM (EPSG:32631) + { + "x": {"extent": [100_000, 900_000], "reference_system": 32631}, + "y": {"extent": [3_000_000, 7_000_000], "reference_system": 32631}, + }, + {"west": 500_000, "south": 5_649_820, "east": 535_080, "north": 5_683_300, "crs": 32631}, + { + "x": {"extent": NumericalInterval(500_000, 535_080), "crs": 32631}, + "y": {"extent": NumericalInterval(5_649_820, 5_683_300), "crs": 32631}, + }, + ), + ], + ) + def test_load_stac_spatial_extent_cube_metadata( + self, dummy_backend, tmp_path, source_extents: dict, spatial_extent, expected_metadata + ): + stac_path = tmp_path / "stac.json" + stac_metadata = StacDummyBuilder.collection( + cube_dimensions={ + "x": { + "type": "spatial", + "axis": "x", + "extent": source_extents["x"].get("extent", [-180, 180]), + "reference_system": source_extents["x"].get("reference_system", 4326), + }, + "y": { + "type": "spatial", + "axis": "y", + "extent": source_extents["y"].get("extent", [-70, 70]), + "reference_system": source_extents["y"].get("reference_system", 4326), + }, + "t": {"type": "temporal", "extent": ["2020-02-20T02:20:22Z", None]}, + "bands": {"type": "bands", "values": ["B03", "B04"]}, + } + ) + # TODO #738 real request mocking of STAC resources compatible with pystac? + stac_path.write_text(json.dumps(stac_metadata)) + + cube = DataCube.load_stac( + url=str(stac_path), spatial_extent=spatial_extent, connection=dummy_backend.connection + ) + + assert cube.metadata.spatial_dimensions == ( + SpatialDimension( + name="x", axis="x", extent=expected_metadata["x"]["extent"], crs=expected_metadata["x"].get("crs") + ), + SpatialDimension( + name="y", axis="y", extent=expected_metadata["y"]["extent"], crs=expected_metadata["y"].get("crs") + ), + ) + @pytest.mark.parametrize( "stac_metadata", [ From 0a956ccfcbb7f925ef0651db21e4d82f719d218f Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Tue, 20 Oct 2026 16:22:22 +0200 Subject: [PATCH 10/13] fixup! Track SpatialDimension.crs from _StacMetadataParser --- openeo/metadata/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openeo/metadata/__init__.py b/openeo/metadata/__init__.py index a274f5e2b..572c5434c 100644 --- a/openeo/metadata/__init__.py +++ b/openeo/metadata/__init__.py @@ -690,7 +690,8 @@ def resample_cube_spatial(self, target: CubeMetadata) -> CubeMetadata: # Replace spatial dimensions with ones from target, but keep other dimensions dimensions = [d for d in (self._dimensions or []) if not isinstance(d, SpatialDimension)] # TODO #955 the spatial extent of the original cube should be reprojected to target projection - dimensions.extend(target.spatial_dimensions) + if target.has_spatial_dimensions(): + dimensions.extend(target.spatial_dimensions) return self._clone_and_update(dimensions=dimensions) From 029f169fd85b08f9090f21cf859ba19d06c25129 Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Tue, 20 Oct 2026 17:26:25 +0200 Subject: [PATCH 11/13] Ignore "z" spatial dimension in CubeMetadata ref #827 #955 --- openeo/metadata/__init__.py | 5 ++++- tests/metadata/test_metadata.py | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/openeo/metadata/__init__.py b/openeo/metadata/__init__.py index 572c5434c..fba5c6e1e 100644 --- a/openeo/metadata/__init__.py +++ b/openeo/metadata/__init__.py @@ -408,7 +408,10 @@ def __init__(self, dimensions: Optional[List[Dimension]] = None): if len(temporal_dims) > 1: _log.warning(f"Multiple temporal dimensions, arbitrarily picking the first one: {temporal_dims=}") self._temporal_dimension = temporal_dims[0] - if spatial_dims := sorted((d for d in dimensions if isinstance(d, SpatialDimension)), key=lambda d: d.axis): + if spatial_dims := sorted( + (d for d in dimensions if isinstance(d, SpatialDimension) and d.axis in {"x", "y"}), + key=lambda d: d.axis, + ): if [d.axis for d in spatial_dims] == ["x", "y"]: self._spatial_dimensions = SpatialDimensionsXY(x=spatial_dims[0], y=spatial_dims[1]) else: diff --git a/tests/metadata/test_metadata.py b/tests/metadata/test_metadata.py index 76d8a23eb..bb4dd9574 100644 --- a/tests/metadata/test_metadata.py +++ b/tests/metadata/test_metadata.py @@ -1839,6 +1839,21 @@ def test_metadata_from_stac_cube_dimensions_geometries_not_treated_as_band_dimen SpatialDimension(name="y", axis="y", extent=[3_000_000, 7_000_000], crs=32631), ), ), + ( + # Exotic use case with a vertical spatial "z" dimension + StacDummyBuilder.collection( + cube_dimensions={ + "t": {"type": "temporal", "extent": ["2024-04-04", "2024-06-06"]}, + "x": {"type": "spatial", "axis": "x", "extent": [-180, 180], "reference_system": 4326}, + "y": {"type": "spatial", "axis": "y", "extent": [-70, 70], "reference_system": 4326}, + "z": {"type": "spatial", "axis": "z", "extent": [0, 10], "reference_system": 4326}, + } + ), + ( + SpatialDimension(name="x", axis="x", extent=[-180, 180], crs=4326), + SpatialDimension(name="y", axis="y", extent=[-70, 70], crs=4326), + ), + ), ], ) def test_metadata_from_stac_spatial_dimensions(self, tmp_path, stac_dict, expected): From 1d1e0cd17e8f0a743f60b45faf74abe956b3886c Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Tue, 20 Oct 2026 17:37:46 +0200 Subject: [PATCH 12/13] Potential fix for pull request finding 'filter_bbox crashes on x/y/z cubes with z dimension' Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- openeo/metadata/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openeo/metadata/__init__.py b/openeo/metadata/__init__.py index fba5c6e1e..41c2fdeb2 100644 --- a/openeo/metadata/__init__.py +++ b/openeo/metadata/__init__.py @@ -531,7 +531,10 @@ def filter_bbox(self, extent: Any) -> CubeMetadata: if self._dimensions and isinstance(self._spatial_dimensions, SpatialDimensionsXY): spatial_dimensions: SpatialDimensionsXY = self._spatial_dimensions.filter_bbox(extent=extent) dimensions = [ - getattr(spatial_dimensions, d.axis) if isinstance(d, SpatialDimension) else d for d in self._dimensions + getattr(spatial_dimensions, d.axis) + if isinstance(d, SpatialDimension) and d.axis in {"x", "y"} + else d + for d in self._dimensions ] return self._clone_and_update(dimensions=dimensions) From 140eba7ed6b326a38288a9e3123ae2875209ff50 Mon Sep 17 00:00:00 2001 From: Stefaan Lippens Date: Tue, 20 Oct 2026 18:08:36 +0200 Subject: [PATCH 13/13] Improve defaul crs handling in SpatialDimension ref #827 #955 --- openeo/metadata/__init__.py | 4 ++-- tests/metadata/test_metadata.py | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/openeo/metadata/__init__.py b/openeo/metadata/__init__.py index 41c2fdeb2..8c9d70ee4 100644 --- a/openeo/metadata/__init__.py +++ b/openeo/metadata/__init__.py @@ -90,7 +90,7 @@ def __init__( # TODO: axis should be required (without None as default), but currently optional (with fallback guess) for backwards compatibility. axis: Literal["x", "y"] | None = None, extent: Tuple[float, float] | List[float] | NumericalInterval | _NonConcreteExtent | _EmptyExtent, - crs: Union[str, int, dict] = DEFAULT_CRS, + crs: str | int | dict | None = DEFAULT_CRS, step=None, ): """ @@ -108,7 +108,7 @@ def __init__( # TODO: normalize extent to NumericalInterval when appropriate? self.extent = extent # TODO Rename/proxy "crs" to "reference_system" to better align with STAC datacube extension terminology. - self.crs = crs + self.crs = crs or self.DEFAULT_CRS self.step = step def rename(self, name) -> Dimension: diff --git a/tests/metadata/test_metadata.py b/tests/metadata/test_metadata.py index bb4dd9574..8bba515a1 100644 --- a/tests/metadata/test_metadata.py +++ b/tests/metadata/test_metadata.py @@ -127,6 +127,13 @@ def test_basic(self): assert dim.extent == [2, 6] assert dim.crs == 4326 + def test_crs_default_handling(self): + n, a, e = "x", "x", [1, 2] + assert SpatialDimension(name=n, axis=a, extent=e).crs == 4326 + assert SpatialDimension(name=n, axis=a, extent=e, crs=None).crs == 4326 + assert SpatialDimension(name=n, axis=a, extent=e, crs=4326).crs == 4326 + assert SpatialDimension(name=n, axis=a, extent=e, crs=32631).crs == 32631 + @pytest.mark.parametrize( ["extent", "expected"], [
xspatial
yspatial
ttemporal
bandsbands2bands:\sB2,\sB3