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/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 b203bed06..4dfdcea7e 100644
--- a/openeo/metadata/__init__.py
+++ b/openeo/metadata/__init__.py
@@ -8,6 +8,7 @@
Dict,
Iterable,
List,
+ Literal,
NamedTuple,
Optional,
Tuple,
@@ -21,7 +22,9 @@
from openeo.api.process import Parameter
from openeo.internal.jupyter import render_component
-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
_log = logging.getLogger(__name__)
@@ -35,6 +38,16 @@ class DimensionAlreadyExistsException(MetadataException):
pass
+# Sentinel object to signal that a value is parameterized/abstract
+# at current time (e.g. client side build time)
+# only to become concrete in different context (e.g. server side run time).
+# Note that this different and more expressive alternative to using a generic `None`,
+# which might trigger fallback/default handling logic that would be undesired.
+# TODO: do this with a class instead of singleton object?
+# TODO: move up higher for wider reuse of this concept?
+_PARAMETERIZED_VALUE = object()
+
+
# TODO: make these dimension classes immutable data classes
class Dimension:
"""Base class for dimensions."""
@@ -69,36 +82,122 @@ 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,
- extent: Union[Tuple[float, float], List[float]],
- crs: Union[str, int, dict] = DEFAULT_CRS,
+ *,
+ # 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: str | int | dict | None = DEFAULT_CRS,
step=None,
):
"""
-
@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)
+ # 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
- self.crs = crs
+ # TODO Rename/proxy "crs" to "reference_system" to better align with STAC datacube extension terminology.
+ self.crs = crs or self.DEFAULT_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)
+
+ 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"]
+
+ 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()
+ 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__(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 +206,36 @@ 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_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:
+ 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):
"""
@@ -135,7 +264,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
@@ -272,28 +401,31 @@ 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?
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) 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:
+ _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
@@ -310,6 +442,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)
@@ -349,9 +490,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)
@@ -381,6 +527,30 @@ 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_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) and d.axis in {"x", "y"}
+ 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
@@ -449,7 +619,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":
@@ -496,15 +669,15 @@ def resample_spatial(
projection: Union[int, str, Parameter, None] = None,
) -> CubeMetadata:
if isinstance(resolution, Parameter):
- normalized_resolution = None, None
+ normalized_resolution = _PARAMETERIZED_VALUE, _PARAMETERIZED_VALUE
else:
normalized_resolution = normalize_resample_resolution(resolution)
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)
@@ -520,8 +693,10 @@ def resample_spatial(
dim: SpatialDimension = dimensions[i]
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),
+ crs=_PARAMETERIZED_VALUE if isinstance(projection, Parameter) else (projection or dim.crs),
step=r if r != 0.0 else dim.step,
)
@@ -530,7 +705,9 @@ 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)]
- dimensions.extend(target.spatial_dimensions)
+ # TODO #955 the spatial extent of the original cube should be reprojected to target projection
+ if target.has_spatial_dimensions():
+ dimensions.extend(target.spatial_dimensions)
return self._clone_and_update(dimensions=dimensions)
@@ -554,6 +731,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)
@@ -595,6 +773,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),
@@ -777,8 +956,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)
@@ -874,7 +1053,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, 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"):
@@ -901,12 +1080,17 @@ 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]),
+ crs=dim.get("reference_system", SpatialDimension.DEFAULT_CRS),
+ )
+ )
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/openeo/metadata/_extents.py b/openeo/metadata/_extents.py
new file mode 100644
index 000000000..1e9a8183d
--- /dev/null
+++ b/openeo/metadata/_extents.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+import typing
+
+
+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>"
+
+ 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:
+ """
+ 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
+
+
+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/metadata/_jupyter.py b/openeo/metadata/_jupyter.py
new file mode 100644
index 000000000..f171d7f25
--- /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, 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])
+ 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""""""
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/_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/openeo/rest/datacube.py b/openeo/rest/datacube.py
index 7dbee3746..623ac032d 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,
@@ -243,6 +256,10 @@ 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:
bands = cls._get_bands(bands, process_id="load_collection")
if isinstance(bands, Parameter):
@@ -440,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,
@@ -450,9 +467,12 @@ def load_stac(
argument_name="spatial_extent",
process_id="load_stac",
)
+ arguments["spatial_extent"] = spatial_extent
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
@@ -492,6 +512,12 @@ 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 spatial_extent:
+ metadata = metadata.filter_bbox(extent=spatial_extent)
+ if metadata and temporal_extent:
+ metadata = metadata.filter_temporal(extent=temporal_extent)
+
return cls(graph=graph, connection=connection, metadata=metadata)
@classmethod
@@ -578,12 +604,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
@@ -693,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
@@ -754,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/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/openeo/util.py b/openeo/util.py
index 33007df3f..613fd929f 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()
@@ -531,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))
@@ -555,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?
@@ -566,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/openeo/utils/datetime.py b/openeo/utils/datetime.py
new file mode 100644
index 000000000..63caf9407
--- /dev/null
+++ b/openeo/utils/datetime.py
@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+import dataclasses
+import datetime
+import sys
+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 (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, *, 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:
+ raise ValueError(f"End instant {end!r} is before start instant {start!r}")
+
+ object.__setattr__(self, "start", start)
+ object.__setattr__(self, "end", end)
+ object.__setattr__(self, "end_inclusive", end_inclusive)
+
+ @classmethod
+ 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]:
+ 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, end_inclusive = other.end, other.end_inclusive
+ elif other.end is None:
+ end, end_inclusive = self.end, self.end_inclusive
+ else:
+ 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 or (end == start and not end_inclusive)):
+ return None
+
+ return DateTimeInterval(start, end, end_inclusive=end_inclusive)
diff --git a/tests/metadata/test_extents.py b/tests/metadata/test_extents.py
new file mode 100644
index 000000000..f627ff5e2
--- /dev/null
+++ b/tests/metadata/test_extents.py
@@ -0,0 +1,85 @@
+import dirty_equals
+import pytest
+
+from openeo.metadata import NumericalInterval, _EmptyExtent, _NonConcreteExtent
+
+
+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_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")]),
+ "| bands | bands | 2 | bands: B2, B3 |
",
+ ),
+ (
+ SpatialDimension(name="y", extent=[50, 52]),
+ "| y | spatial | ? | extent: [50, 52] |
",
+ ),
+ (
+ TemporalDimension(name="t", extent=["2026-10-01", "2026-10-20"]),
+ "| t | temporal | ? | 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"""
+
+ | Dimension | Type | Size | Labels / Extent |
+ {expected_body}
+
+ """
+ )
+
+
+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(
+ """
+
+ | Dimension | Type | Size | Labels / Extent |
+
+ | t | temporal | ? | extent: ['2026-10-01', '2026-11-12'] |
+ | bands | bands | 2 | bands: B2, B3 |
+ | x | spatial | ? | extent: [2, 7] |
+ | y | spatial | ? | extent: [49, 52] |
+
+
+ """
+ )
diff --git a/tests/metadata/test_metadata.py b/tests/metadata/test_metadata.py
index 2f1bd1466..1c98dceef 100644
--- a/tests/metadata/test_metadata.py
+++ b/tests/metadata/test_metadata.py
@@ -8,8 +8,10 @@
import pystac
import pytest
+import openeo.api.process
from openeo.api.process import Parameter
from openeo.metadata import (
+ _PARAMETERIZED_VALUE,
Band,
BandDimension,
CollectionMetadata,
@@ -18,8 +20,12 @@
DimensionAlreadyExistsException,
GeometryDimension,
MetadataException,
+ NumericalInterval,
SpatialDimension,
+ SpatialDimensionsXY,
TemporalDimension,
+ _EmptyExtent,
+ _NonConcreteExtent,
_StacMetadataParser,
metadata_from_stac,
)
@@ -44,1504 +50,1829 @@
)
-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 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_band_minimal():
- band = Band("red")
- assert band.name == "red"
+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_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)
+ 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 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
+
+ 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
-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),
+ @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)),
],
)
- 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"]),
+ 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),
],
)
- 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_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),
+ )
-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),
+ @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)
- # Test band names
- assert bdim.contains_band("B02")
- assert not bdim.contains_band("B05")
+ 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
- # Test indexes
- assert bdim.contains_band(0)
- assert not bdim.contains_band(4)
+ 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)
- # Test common names
- assert bdim.contains_band("blue")
- assert not bdim.contains_band("yellow")
+ 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()
-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"]),
+ @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"),
+ },
+ ),
],
)
- 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)])
+ 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)
- 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"]
+ 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)
- 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"]
+ @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),
+ )
-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"]
+class TestBand:
+ def test_band_minimal(self):
+ band = Band("red")
+ assert band.name == "red"
- 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),
- ],
- )
- ],
- )
- 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]},
},
- "summaries": summaries,
- },
- complain=logs.append,
- )
- assert_same_dimensions(
- dims,
- [
- SpatialDimension(name="x", extent=[-10, 10]),
+ {
+ # 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 '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_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]),
- 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),
- ],
- ),
+ {
+ # 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 == ["Multiple dimensions of type 'bands'"]
-
+ 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,
+ },
+ 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",
- [
- # API 0.4 style
- {
- "properties": {
+ @pytest.mark.parametrize(
+ "spec",
+ [
+ # 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"]},
}
- }
- },
- # 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]
-
-
-def test_collectionmetadata_bands_dimension_no_band_dimensions():
- metadata = CollectionMetadata(
- {
- "cube:dimensions": {
- "x": {"type": "spatial", "axis": "x"},
- }
- }
+ },
+ ],
)
- 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_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"]},
- },
- "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"}]
+ )
+ 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"}]
+ }
},
- },
- {
- # STAC 1.1 bands style
- "cube:dimensions": {
- "x": {"type": "spatial", "axis": "x"},
- "b": {"type": "bands", "values": ["foo", "bar"]},
+ {
+ # API 1.0 style
+ "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 style
+ "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_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_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]
-
-def test_cubemetadata_rename_dimension_invalid_dimension():
- metadata = CubeMetadata(
- dimensions=[
- SpatialDimension(name="x", extent=None),
- BandDimension(name="b", bands=[Band("red"), Band("green")]),
- ]
+ @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"]},
+ },
+ "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": {
+ "bands": [{"name": "foo", "eo:common_name": "F00", "eo:center_wavelength": 0.543}, {"name": "bar"}]
+ },
+ },
+ ],
)
- with pytest.raises(ValueError, match=re.escape("Invalid dimension 'bandzz'. Should be one of ['x', 'b']")):
- _ = metadata.rename_dimension("bandzz", "bands")
+ 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_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_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_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_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_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_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_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")
+ 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_add_band_dimension():
- metadata = CollectionMetadata({"cube:dimensions": {"t": {"type": "temporal"}}})
- new = metadata.add_dimension("layer", "red", "bands")
+ def test_drop_dimension(self):
+ metadata = CollectionMetadata(
+ {
+ "cube:dimensions": {
+ "t": {"type": "temporal"},
+ "b": {"type": "bands", "values": ["red", "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"]
+
+ 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"]
- 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_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_cubemetadata_add_band_dimension():
- metadata = CubeMetadata(dimensions=[TemporalDimension(name="t", extent=None)])
- 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_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")
+ 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"]
+ def test_subclass(self):
+ class MyCubeMetadata(CubeMetadata):
+ def __init__(self, dimensions: List[Dimension], bbox=None):
+ super().__init__(dimensions=dimensions)
+ self.bbox = bbox
-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")
+ 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_cubemetadata_append_band():
- 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"]
+ 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_collectionmetadata_add_temporal_dimension():
- metadata = CollectionMetadata({"cube:dimensions": {"x": {"type": "spatial"}}})
- new = metadata.add_dimension("date", "2020-05-15", "temporal")
+ new = new.filter_bbox((1, 2, 3, 4))
+ assert isinstance(new, MyCubeMetadata)
+ assert orig.bbox is None
+ assert new.bbox == (1, 2, 3, 4)
- assert metadata.dimension_names() == ["x"]
- assert not metadata.has_temporal_dimension()
+ 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)
- 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"]
+ @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_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=_PARAMETERIZED_VALUE),
+ SpatialDimension(name="y", extent=[49, 52], crs=4326, step=_PARAMETERIZED_VALUE),
+ )
+ 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=_PARAMETERIZED_VALUE),
+ SpatialDimension(name="y", extent=[49, 52], crs=32631, step=_PARAMETERIZED_VALUE),
+ )
+ 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=_PARAMETERIZED_VALUE, step=10),
+ SpatialDimension(name="y", extent=[49, 52], crs=_PARAMETERIZED_VALUE, 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=_PARAMETERIZED_VALUE, step=_PARAMETERIZED_VALUE),
+ SpatialDimension(name="y", extent=[49, 52], crs=_PARAMETERIZED_VALUE, step=_PARAMETERIZED_VALUE),
+ )
+ 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_cubemetadata_add_temporal_dimension():
- metadata = CubeMetadata(dimensions=[SpatialDimension(name="x", extent=None)])
- new = metadata.add_dimension("date", "2020-05-15", "temporal")
+ 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 metadata.dimension_names() == ["x"]
- assert not metadata.has_temporal_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.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"]
+ 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_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])
-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")
+ 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 == SpatialDimensionsXY(*xy2)
+ assert result12.band_dimension == b1
+ assert result12.temporal_dimension == t1
+
+ result21 = metadata2.resample_cube_spatial(target=metadata1)
+ 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 == SpatialDimensionsXY(*xy3)
+ assert not result23.has_band_dimension()
+ assert result23.temporal_dimension == t2
+
+ result31 = metadata3.resample_cube_spatial(target=metadata1)
+ assert result31.spatial_dimensions == SpatialDimensionsXY(*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'])"
+ )
-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")
+ 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"""
+ .*
+ .*.*Dimension.*Type.*Size.*
+ .*
+ .*| x | spatial | .*
+ .*| y | spatial | .*
+ .*| t | temporal | .*
+ .*| bands | bands | 2 | bands:\sB2,\sB3 |
+ .*""",
+ 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)
-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", 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),
+ )
- assert orig.dimension_names() == ["t"]
- assert not orig.has_geometry_dimension()
- with pytest.raises(MetadataException):
- orig.geometry_dimension()
+ 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 new.dimension_names() == ["t", "fields"]
- assert new.has_geometry_dimension()
- assert new.geometry_dimension.name == "fields"
- assert new.geometry_dimension.type == "geometry"
+ 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),
+ )
-def test_collectionmetadata_drop_dimension():
- metadata = CollectionMetadata(
- {
- "cube:dimensions": {
- "t": {"type": "temporal"},
- "b": {"type": "bands", "values": ["red", "green"]},
- }
- }
+ @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)
- 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")
+ 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),
+ )
-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
-
- 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_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()
-
-@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", "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]},
+ }
+ ),
+ {"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", "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_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", "extent": ["2024-04-04", "2024-06-06"]},
+ "band": {"type": "bands", "values": ["B01"]},
+ }
+ )
+ assert stac_dict["stac_extensions"] == [StacDummyBuilder._EXT_DATACUBE]
-def test_cube_metadata_repr_with_bands():
- assert repr(CUBE_METADATA_XYTB) == "CubeMetadata(dimension_names=['x', 'y', 't', 'bands'], band_names=['B2', 'B3'])"
+ 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)
-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"]),
- ]
+ @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),
+ ),
+ ),
+ (
+ # 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),
+ ),
+ ),
+ ],
)
- assert repr(metadata) == "CubeMetadata(dimension_names=['x', 't'])"
+ 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
-class TestStacMetadataParser:
def test_band_from_eo_bands_metadata_basic(self):
assert _StacMetadataParser()._band_from_eo_bands_metadata(
{"name": "B04"},
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 16be101a4..2654cec9f 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
@@ -22,7 +23,12 @@
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 (
+ _PARAMETERIZED_VALUE,
+ NumericalInterval,
+ SpatialDimension,
+ _NonConcreteExtent,
+)
from openeo.rest import BandMathException, OpenEoClientException
from openeo.rest._testing import build_capabilities
from openeo.rest.connection import Connection
@@ -30,6 +36,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
@@ -207,6 +214,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() == {
@@ -315,6 +379,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",
[
@@ -349,143 +493,198 @@ 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
+ .*
+ .*.*Dimension.*Type.*Size.*
+ .*
+ .*| x | spatial | .*
+ .*| y | spatial | .*
+ .*| t | temporal | .*
+ .*| bands | bands | 4 | bands:\sB02,\sB03,\sB04,\sB08 |
+ .*
+ .*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_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
+ .*
+ .*.*Dimension.*Type.*Size.*
+ .*
+ .*| x | spatial | .*
+ .*| y | spatial | .*
+ .*| t | temporal | \? | extent:.*2026-09-01.*2026-09-20.* |
+ .*| bands | bands | 2 | bands:\sB03,\sB04 |
+ .*
+ .*Process\ Graph
+ .*
+ .*""",
+ regex_flags=re.DOTALL | re.VERBOSE,
+ )
+ 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-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):
@@ -861,10 +1060,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):
@@ -881,10 +1080,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):
@@ -903,10 +1102,10 @@ def test_resample_spatial_parameter_resolution(s2cube):
},
}
}
- assert cube.metadata.spatial_dimensions == [
- SpatialDimension(name="x", extent=None, crs=32631, step=None),
- SpatialDimension(name="y", extent=None, crs=32631, step=None),
- ]
+ assert cube.metadata.spatial_dimensions == (
+ SpatialDimension(name="x", extent=None, crs=32631, step=_PARAMETERIZED_VALUE),
+ SpatialDimension(name="y", extent=None, crs=32631, step=_PARAMETERIZED_VALUE),
+ )
def test_resample_spatial_parameter_resolution_no_projection(s2cube):
@@ -943,10 +1142,10 @@ def test_resample_spatial_parameter_projection(s2cube):
},
}
}
- assert cube.metadata.spatial_dimensions == [
- SpatialDimension(name="x", extent=None, crs=None, step=10),
- SpatialDimension(name="y", extent=None, crs=None, step=10),
- ]
+ assert cube.metadata.spatial_dimensions == (
+ SpatialDimension(name="x", extent=None, crs=_PARAMETERIZED_VALUE, step=10),
+ SpatialDimension(name="y", extent=None, crs=_PARAMETERIZED_VALUE, step=10),
+ )
def test_resample_spatial_parameter_resolution_and_projection(s2cube):
@@ -966,10 +1165,10 @@ def test_resample_spatial_parameter_resolution_and_projection(s2cube):
},
}
}
- assert cube.metadata.spatial_dimensions == [
- SpatialDimension(name="x", extent=None, crs=None, step=None),
- SpatialDimension(name="y", extent=None, crs=None, step=None),
- ]
+ assert cube.metadata.spatial_dimensions == (
+ SpatialDimension(name="x", extent=None, crs=_PARAMETERIZED_VALUE, step=_PARAMETERIZED_VALUE),
+ SpatialDimension(name="y", extent=None, crs=_PARAMETERIZED_VALUE, step=_PARAMETERIZED_VALUE),
+ )
def test_resample_cube_spatial(s2cube):
@@ -1007,10 +1206,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) == {
@@ -1043,10 +1242,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):
@@ -1056,10 +1255,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):
@@ -1071,17 +1270,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/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/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")
diff --git a/tests/utils/test_datetime.py b/tests/utils/test_datetime.py
new file mode 100644
index 000000000..9cdcd9e94
--- /dev/null
+++ b/tests/utils/test_datetime.py
@@ -0,0 +1,221 @@
+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(
+ ["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", 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),
+ ([{"2026": "09-10"}, {"2026": "11-12"}], None),
+ ({"2026": "09-10"}, None),
+ ],
+ )
+ def test_try_from(self, 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() == (
+ 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
+
+ @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