Source code for pystac.extensions.datacube

"""Implements the :stac-ext:`Datacube Extension <datacube>`."""

from __future__ import annotations

from abc import ABC
from typing import Any, Generic, Literal, TypeVar, cast

import pystac
from pystac.extensions.base import ExtensionManagementMixin, PropertiesExtension
from pystac.extensions.hooks import ExtensionHooks
from pystac.utils import StringEnum, get_required, map_opt

#: Generalized version of :class:`~pystac.Collection`, `:class:`~pystac.Item`,
#: :class:`~pystac.Asset`, or :class:`~pystac.ItemAssetDefinition`
T = TypeVar(
    "T", pystac.Collection, pystac.Item, pystac.Asset, pystac.ItemAssetDefinition
)

SCHEMA_URI = "https://stac-extensions.github.io/datacube/v2.2.0/schema.json"

PREFIX: str = "cube:"
DIMENSIONS_PROP = PREFIX + "dimensions"
VARIABLES_PROP = PREFIX + "variables"

# Dimension properties
DIM_TYPE_PROP = "type"
DIM_DESC_PROP = "description"
DIM_AXIS_PROP = "axis"
DIM_EXTENT_PROP = "extent"
DIM_VALUES_PROP = "values"
DIM_STEP_PROP = "step"
DIM_REF_SYS_PROP = "reference_system"
DIM_UNIT_PROP = "unit"

# Variable properties
VAR_TYPE_PROP = "type"
VAR_DESC_PROP = "description"
VAR_EXTENT_PROP = "extent"
VAR_VALUES_PROP = "values"
VAR_DIMENSIONS_PROP = "dimensions"
VAR_UNIT_PROP = "unit"


[docs] class DimensionType(StringEnum): """Dimension object types for spatial and temporal Dimension Objects.""" SPATIAL = "spatial" GEOMETRIES = "geometries" TEMPORAL = "temporal"
[docs] class HorizontalSpatialDimensionAxis(StringEnum): """Allowed values for ``axis`` field of :class:`HorizontalSpatialDimension` object.""" X = "x" Y = "y"
[docs] class VerticalSpatialDimensionAxis(StringEnum): """Allowed values for ``axis`` field of :class:`VerticalSpatialDimension` object.""" Z = "z"
[docs] class Dimension(ABC): """Object representing a dimension of the datacube. The fields contained in Dimension Object vary by ``type``. See the :stac-ext:`Datacube Dimension Object <datacube#dimension-object>` docs for details. """ properties: dict[str, Any] def __init__(self, properties: dict[str, Any]) -> None: self.properties = properties @property def dim_type(self) -> DimensionType | str: """The type of the dimension. Must be ``"spatial"`` for :stac-ext:`Horizontal Spatial Dimension Objects <datacube#horizontal-spatial-raster-dimension-object>` or :stac-ext:`Vertical Spatial Dimension Objects <datacube#vertical-spatial-dimension-object>`, ``geometries`` for :stac-ext:`Spatial Vector Dimension Objects <datacube#spatial-vector-dimension-object>` ``"temporal"`` for :stac-ext:`Temporal Dimension Objects <datacube#temporal-dimension-object>`. May be an arbitrary string for :stac-ext:`Additional Dimension Objects <datacube#additional-dimension-object>`.""" return get_required( self.properties.get(DIM_TYPE_PROP), "cube:dimension", DIM_TYPE_PROP ) @dim_type.setter def dim_type(self, v: DimensionType | str) -> None: self.properties[DIM_TYPE_PROP] = v @property def description(self) -> str | None: """Detailed multi-line description to explain the dimension. `CommonMark 0.29 <http://commonmark.org/>`__ syntax MAY be used for rich text representation.""" return self.properties.get(DIM_DESC_PROP) @description.setter def description(self, v: str | None) -> None: if v is None: self.properties.pop(DIM_DESC_PROP, None) else: self.properties[DIM_DESC_PROP] = v
[docs] def to_dict(self) -> dict[str, Any]: return self.properties
[docs] @staticmethod def from_dict(d: dict[str, Any]) -> Dimension: dim_type: str = get_required( d.get(DIM_TYPE_PROP), "cube_dimension", DIM_TYPE_PROP ) if dim_type == DimensionType.SPATIAL: axis: str = get_required( d.get(DIM_AXIS_PROP), "cube_dimension", DIM_AXIS_PROP ) if axis == "z": return VerticalSpatialDimension(d) else: return HorizontalSpatialDimension(d) elif dim_type == DimensionType.GEOMETRIES: return VectorSpatialDimension(d) elif dim_type == DimensionType.TEMPORAL: # The v1.0.0 spec says that AdditionalDimensions can have # type 'temporal', but it is unclear how to differentiate that # from a temporal dimension. Just key off of type for now. # See https://github.com/stac-extensions/datacube/issues/5 return TemporalDimension(d) else: return AdditionalDimension(d)
[docs] class SpatialDimension(Dimension): @property def extent(self) -> list[float]: """Extent (lower and upper bounds) of the dimension as two-dimensional array. Open intervals with ``None`` are not allowed.""" return get_required( self.properties.get(DIM_EXTENT_PROP), "cube:dimension", DIM_EXTENT_PROP ) @extent.setter def extent(self, v: list[float]) -> None: self.properties[DIM_EXTENT_PROP] = v @property def values(self) -> list[float] | None: """Optional set of all potential values.""" return self.properties.get(DIM_VALUES_PROP) @values.setter def values(self, v: list[float] | None) -> None: if v is None: self.properties.pop(DIM_VALUES_PROP, None) else: self.properties[DIM_VALUES_PROP] = v @property def step(self) -> float | None: """The space between the values. Use ``None`` for irregularly spaced steps.""" return self.properties.get(DIM_STEP_PROP) @step.setter def step(self, v: float | None) -> None: self.properties[DIM_STEP_PROP] = v
[docs] def clear_step(self) -> None: """Setting step to None sets it to the null value, which means irregularly spaced steps. Use clear_step to remove it from the properties.""" self.properties.pop(DIM_STEP_PROP, None)
@property def reference_system(self) -> str | float | dict[str, Any] | None: """The spatial reference system for the data, specified as `numerical EPSG code <http://www.epsg-registry.org/>`__, `WKT2 (ISO 19162) string <http://docs.opengeospatial.org/is/18-010r7/18-010r7.html>`__ or `PROJJSON object <https://proj.org/specifications/projjson.html>`__. Defaults to EPSG code 4326.""" return self.properties.get(DIM_REF_SYS_PROP) @reference_system.setter def reference_system(self, v: str | float | dict[str, Any] | None) -> None: if v is None: self.properties.pop(DIM_REF_SYS_PROP, None) else: self.properties[DIM_REF_SYS_PROP] = v
[docs] class HorizontalSpatialDimension(SpatialDimension): @property def axis(self) -> HorizontalSpatialDimensionAxis: """Axis of the spatial dimension. Must be one of ``"x"`` or ``"y"``.""" return get_required( self.properties.get(DIM_AXIS_PROP), "cube:dimension", DIM_AXIS_PROP ) @axis.setter def axis(self, v: HorizontalSpatialDimensionAxis) -> None: self.properties[DIM_AXIS_PROP] = v
[docs] class VerticalSpatialDimension(SpatialDimension): @property def axis(self) -> VerticalSpatialDimensionAxis: """Axis of the spatial dimension. Must be ``"z"``.""" return get_required( self.properties.get(DIM_AXIS_PROP), "cube:dimension", DIM_AXIS_PROP ) @axis.setter def axis(self, v: VerticalSpatialDimensionAxis) -> None: self.properties[DIM_AXIS_PROP] = v @property def unit(self) -> str | None: """The unit of measurement for the data, preferably compliant to `UDUNITS-2 <https://ncics.org/portfolio/other-resources/udunits2/>`__ units (singular).""" return self.properties.get(DIM_UNIT_PROP) @unit.setter def unit(self, v: str | None) -> None: if v is None: self.properties.pop(DIM_UNIT_PROP, None) else: self.properties[DIM_UNIT_PROP] = v
[docs] class VectorSpatialDimension(Dimension): @property def axes(self) -> list[str] | None: """Axes of the vector dimension as an ordered set of `x`, `y` and `z`.""" return self.properties.get("axes") @axes.setter def axes(self, v: list[str]) -> None: if v is None: self.properties.pop("axes", None) else: self.properties["axes"] = v @property def bbox(self) -> list[float]: """A single bounding box of the geometries as defined for STAC Collections but not nested.""" return get_required(self.properties.get("bbox"), "cube:bbox", "bbox") @bbox.setter def bbox(self, v: list[float]) -> None: self.properties["bbox"] = v @property def values(self) -> list[str] | None: """Optionally, a representation of the geometries. This could be a list of WKT strings or other identifiers.""" return self.properties.get(DIM_VALUES_PROP) @values.setter def values(self, v: list[str] | None) -> None: if v is None: self.properties.pop(DIM_VALUES_PROP, None) else: self.properties[DIM_VALUES_PROP] = v @property def geometry_types(self) -> list[str] | None: """A set of geometry types. If not present, mixed geometry types must be assumed.""" return self.properties.get("geometry_types") @geometry_types.setter def geometry_types(self, v: list[str] | None) -> None: if v is None: self.properties.pop("geometry_types", None) else: self.properties["geometry_types"] = v @property def reference_system(self) -> str | float | dict[str, Any] | None: """The reference system for the data.""" return self.properties.get(DIM_REF_SYS_PROP) @reference_system.setter def reference_system(self, v: str | float | dict[str, Any] | None) -> None: if v is None: self.properties.pop(DIM_REF_SYS_PROP, None) else: self.properties[DIM_REF_SYS_PROP] = v
[docs] class TemporalDimension(Dimension): @property def extent(self) -> list[str | None] | None: """Extent (lower and upper bounds) of the dimension as two-dimensional array. The dates and/or times must be strings compliant to `ISO 8601 <https://en.wikipedia.org/wiki/ISO_8601>`__. ``None`` is allowed for open date ranges.""" return self.properties.get(DIM_EXTENT_PROP) @extent.setter def extent(self, v: list[str | None] | None) -> None: if v is None: self.properties.pop(DIM_EXTENT_PROP, None) else: self.properties[DIM_EXTENT_PROP] = v @property def values(self) -> list[str] | None: """If the dimension consists of set of specific values they can be listed here. The dates and/or times must be strings compliant to `ISO 8601 <https://en.wikipedia.org/wiki/ISO_8601>`__.""" return self.properties.get(DIM_VALUES_PROP) @values.setter def values(self, v: list[str] | None) -> None: if v is None: self.properties.pop(DIM_VALUES_PROP, None) else: self.properties[DIM_VALUES_PROP] = v @property def step(self) -> str | None: """The space between the temporal instances as `ISO 8601 duration <https://en.wikipedia.org/wiki/ISO_8601#Durations>`__, e.g. P1D. Use null for irregularly spaced steps.""" return self.properties.get(DIM_STEP_PROP) @step.setter def step(self, v: str | None) -> None: self.properties[DIM_STEP_PROP] = v
[docs] def clear_step(self) -> None: """Setting step to None sets it to the null value, which means irregularly spaced steps. Use clear_step to remove it from the properties.""" self.properties.pop(DIM_STEP_PROP, None)
[docs] class AdditionalDimension(Dimension): @property def extent(self) -> list[float | None] | None: """If the dimension consists of `ordinal <https://en.wikipedia.org/wiki/Level_of_measurement#Ordinal_scale>`__ values, the extent (lower and upper bounds) of the values as two-dimensional array. Use null for open intervals.""" return self.properties.get(DIM_EXTENT_PROP) @extent.setter def extent(self, v: list[float | None] | None) -> None: if v is None: self.properties.pop(DIM_EXTENT_PROP, None) else: self.properties[DIM_EXTENT_PROP] = v @property def values(self) -> list[str] | list[float] | None: """A set of all potential values, especially useful for `nominal <https://en.wikipedia.org/wiki/Level_of_measurement#Nominal_level>`__ values.""" return self.properties.get(DIM_VALUES_PROP) @values.setter def values(self, v: list[str] | list[float] | None) -> None: if v is None: self.properties.pop(DIM_VALUES_PROP, None) else: self.properties[DIM_VALUES_PROP] = v @property def step(self) -> float | None: """If the dimension consists of `interval <https://en.wikipedia.org/wiki/Level_of_measurement#Interval_scale>`__ values, the space between the values. Use null for irregularly spaced steps.""" return self.properties.get(DIM_STEP_PROP) @step.setter def step(self, v: float | None) -> None: self.properties[DIM_STEP_PROP] = v
[docs] def clear_step(self) -> None: """Setting step to None sets it to the null value, which means irregularly spaced steps. Use clear_step to remove it from the properties.""" self.properties.pop(DIM_STEP_PROP, None)
@property def unit(self) -> str | None: """The unit of measurement for the data, preferably compliant to `UDUNITS-2 units <https://ncics.org/portfolio/other-resources/udunits2/>`__ (singular).""" return self.properties.get(DIM_UNIT_PROP) @unit.setter def unit(self, v: str | None) -> None: if v is None: self.properties.pop(DIM_UNIT_PROP, None) else: self.properties[DIM_UNIT_PROP] = v @property def reference_system(self) -> str | float | dict[str, Any] | None: """The reference system for the data.""" return self.properties.get(DIM_REF_SYS_PROP) @reference_system.setter def reference_system(self, v: str | float | dict[str, Any] | None) -> None: if v is None: self.properties.pop(DIM_REF_SYS_PROP, None) else: self.properties[DIM_REF_SYS_PROP] = v
[docs] class VariableType(StringEnum): """Variable object types""" DATA = "data" AUXILIARY = "auxiliary"
[docs] class Variable: """Object representing a variable in the datacube. The dimensions field lists zero or more :stac-ext:`Datacube Dimension Object <datacube#dimension-object>` instances. See the :stac-ext:`Datacube Variable Object <datacube#variable-object>` docs for details. """ properties: dict[str, Any] def __init__(self, properties: dict[str, Any]) -> None: self.properties = properties @property def dimensions(self) -> list[str]: """The dimensions of the variable. Should refer to keys in the ``cube:dimensions`` object or be an empty list if the variable has no dimensions """ return get_required( self.properties.get(VAR_DIMENSIONS_PROP), "cube:variable", VAR_DIMENSIONS_PROP, ) @dimensions.setter def dimensions(self, v: list[str]) -> None: self.properties[VAR_DIMENSIONS_PROP] = v @property def var_type(self) -> VariableType | str: """Type of the variable, either ``data`` or ``auxiliary``""" return get_required( self.properties.get(VAR_TYPE_PROP), "cube:variable", VAR_TYPE_PROP ) @var_type.setter def var_type(self, v: VariableType | str) -> None: self.properties[VAR_TYPE_PROP] = v @property def description(self) -> str | None: """Detailed multi-line description to explain the variable. `CommonMark 0.29 <http://commonmark.org/>`__ syntax MAY be used for rich text representation.""" return self.properties.get(VAR_DESC_PROP) @description.setter def description(self, v: str | None) -> None: if v is None: self.properties.pop(VAR_DESC_PROP, None) else: self.properties[VAR_DESC_PROP] = v @property def extent(self) -> list[float | str | None]: """If the variable consists of `ordinal values <https://en.wikipedia.org/wiki/Level_of_measurement#Ordinal_scale>`, the extent (lower and upper bounds) of the values as two-dimensional array. Use ``None`` for open intervals""" return get_required( self.properties.get(VAR_EXTENT_PROP), "cube:variable", VAR_EXTENT_PROP ) @extent.setter def extent(self, v: list[float | str | None]) -> None: self.properties[VAR_EXTENT_PROP] = v @property def values(self) -> list[float | str] | None: """A set of all potential values, especially useful for `nominal values <https://en.wikipedia.org/wiki/Level_of_measurement#Nominal_level>`.""" return self.properties.get(VAR_VALUES_PROP) @values.setter def values(self, v: list[float | str] | None) -> None: if v is None: self.properties.pop(VAR_VALUES_PROP) else: self.properties[VAR_VALUES_PROP] = v @property def unit(self) -> str | None: """The unit of measurement for the data, preferably compliant to `UDUNITS-2 <https://ncics.org/portfolio/other-resources/udunits2/>` units (singular)""" return self.properties.get(VAR_UNIT_PROP) @unit.setter def unit(self, v: str | None) -> None: if v is None: self.properties.pop(VAR_UNIT_PROP) else: self.properties[VAR_UNIT_PROP] = v
[docs] @staticmethod def from_dict(d: dict[str, Any]) -> Variable: return Variable(d)
[docs] def to_dict(self) -> dict[str, Any]: return self.properties
[docs] class DatacubeExtension( Generic[T], PropertiesExtension, ExtensionManagementMixin[pystac.Item | pystac.Collection], ): """An abstract class that can be used to extend the properties of a :class:`~pystac.Collection`, :class:`~pystac.Item`, or :class:`~pystac.Asset` with properties from the :stac-ext:`Datacube Extension <datacube>`. This class is generic over the type of STAC Object to be extended (e.g. :class:`~pystac.Item`, :class:`~pystac.Asset`). To create a concrete instance of :class:`DatacubeExtension`, use the :meth:`DatacubeExtension.ext` method. For example: .. code-block:: python >>> item: pystac.Item = ... >>> dc_ext = DatacubeExtension.ext(item) """ name: Literal["cube"] = "cube"
[docs] def apply( self, dimensions: dict[str, Dimension], variables: dict[str, Variable] | None = None, ) -> None: """Applies Datacube Extension properties to the extended :class:`~pystac.Collection`, :class:`~pystac.Item` or :class:`~pystac.Asset`. Args: dimensions : Dictionary mapping dimension name to :class:`Dimension` objects. variables : Dictionary mapping variable name to a :class:`Variable` object. """ self.dimensions = dimensions self.variables = variables
@property def dimensions(self) -> dict[str, Dimension]: """A dictionary where each key is the name of a dimension and each value is a :class:`~Dimension` object. """ result = get_required( self._get_property(DIMENSIONS_PROP, dict[str, Any]), self, DIMENSIONS_PROP ) return {k: Dimension.from_dict(v) for k, v in result.items()} @dimensions.setter def dimensions(self, v: dict[str, Dimension]) -> None: self._set_property(DIMENSIONS_PROP, {k: dim.to_dict() for k, dim in v.items()}) @property def variables(self) -> dict[str, Variable] | None: """A dictionary where each key is the name of a variable and each value is a :class:`~Variable` object. """ result = self._get_property(VARIABLES_PROP, dict[str, Any]) if result is None: return None return {k: Variable.from_dict(v) for k, v in result.items()} @variables.setter def variables(self, v: dict[str, Variable] | None) -> None: self._set_property( VARIABLES_PROP, map_opt( lambda variables: {k: var.to_dict() for k, var in variables.items()}, v ), )
[docs] @classmethod def get_schema_uri(cls) -> str: return SCHEMA_URI
[docs] @classmethod def ext(cls, obj: T, add_if_missing: bool = False) -> DatacubeExtension[T]: """Extends the given STAC Object with properties from the :stac-ext:`Datacube Extension <datacube>`. This extension can be applied to instances of :class:`~pystac.Collection`, :class:`~pystac.Item` or :class:`~pystac.Asset`. Raises: pystac.ExtensionTypeError : If an invalid object type is passed. """ if isinstance(obj, pystac.Collection): cls.ensure_has_extension(obj, add_if_missing) return cast(DatacubeExtension[T], CollectionDatacubeExtension(obj)) if isinstance(obj, pystac.Item): cls.ensure_has_extension(obj, add_if_missing) return cast(DatacubeExtension[T], ItemDatacubeExtension(obj)) elif isinstance(obj, pystac.Asset): cls.ensure_owner_has_extension(obj, add_if_missing) return cast(DatacubeExtension[T], AssetDatacubeExtension(obj)) elif isinstance(obj, pystac.ItemAssetDefinition): cls.ensure_owner_has_extension(obj, add_if_missing) return cast(DatacubeExtension[T], ItemAssetsDatacubeExtension(obj)) else: raise pystac.ExtensionTypeError(cls._ext_error_message(obj))
[docs] class CollectionDatacubeExtension(DatacubeExtension[pystac.Collection]): """A concrete implementation of :class:`DatacubeExtension` on an :class:`~pystac.Collection` that extends the properties of the Item to include properties defined in the :stac-ext:`Datacube Extension <datacube>`. This class should generally not be instantiated directly. Instead, call :meth:`DatacubeExtension.ext` on an :class:`~pystac.Collection` to extend it. """ collection: pystac.Collection properties: dict[str, Any] def __init__(self, collection: pystac.Collection): self.collection = collection self.properties = collection.extra_fields def __repr__(self) -> str: return f"<CollectionDatacubeExtension Item id={self.collection.id}>"
[docs] class ItemDatacubeExtension(DatacubeExtension[pystac.Item]): """A concrete implementation of :class:`DatacubeExtension` on an :class:`~pystac.Item` that extends the properties of the Item to include properties defined in the :stac-ext:`Datacube Extension <datacube>`. This class should generally not be instantiated directly. Instead, call :meth:`DatacubeExtension.ext` on an :class:`~pystac.Item` to extend it. """ item: pystac.Item properties: dict[str, Any] def __init__(self, item: pystac.Item): self.item = item self.properties = item.properties def __repr__(self) -> str: return f"<ItemDatacubeExtension Item id={self.item.id}>"
[docs] class AssetDatacubeExtension(DatacubeExtension[pystac.Asset]): """A concrete implementation of :class:`DatacubeExtension` on an :class:`~pystac.Asset` that extends the Asset fields to include properties defined in the :stac-ext:`Datacube Extension <datacube>`. This class should generally not be instantiated directly. Instead, call :meth:`DatacubeExtension.ext` on an :class:`~pystac.Asset` to extend it. """ asset_href: str properties: dict[str, Any] additional_read_properties: list[dict[str, Any]] | None def __init__(self, asset: pystac.Asset): self.asset_href = asset.href self.properties = asset.extra_fields if asset.owner and isinstance(asset.owner, pystac.Item): self.additional_read_properties = [asset.owner.properties] else: self.additional_read_properties = None def __repr__(self) -> str: return f"<AssetDatacubeExtension Item id={self.asset_href}>"
[docs] class ItemAssetsDatacubeExtension(DatacubeExtension[pystac.ItemAssetDefinition]): properties: dict[str, Any] asset_defn: pystac.ItemAssetDefinition def __init__(self, item_asset: pystac.ItemAssetDefinition): self.asset_defn = item_asset self.properties = item_asset.properties
[docs] class DatacubeExtensionHooks(ExtensionHooks): schema_uri: str = SCHEMA_URI prev_extension_ids = { "datacube", "https://stac-extensions.github.io/datacube/v1.0.0/schema.json", "https://stac-extensions.github.io/datacube/v2.0.0/schema.json", "https://stac-extensions.github.io/datacube/v2.1.0/schema.json", } stac_object_types = { pystac.STACObjectType.COLLECTION, pystac.STACObjectType.ITEM, }
DATACUBE_EXTENSION_HOOKS: ExtensionHooks = DatacubeExtensionHooks()