diff --git a/CHANGELOG.md b/CHANGELOG.md index 740071adb..41eeeb16c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - Remove unused pystac.validation import ([#1583](https://github.com/stac-utils/pystac/pull/1583)) +- Added Processing extension ([#1588](https://github.com/stac-utils/pystac/pull/1589)) ## [v1.14.1] - 2025-09-18 diff --git a/docs/api/extensions/processing.rst b/docs/api/extensions/processing.rst new file mode 100644 index 000000000..8ee0084eb --- /dev/null +++ b/docs/api/extensions/processing.rst @@ -0,0 +1,6 @@ +pystac.extensions.processing +============================ + +.. automodule:: pystac.extensions.processing + :members: + :undoc-members: diff --git a/pystac/extensions/processing.py b/pystac/extensions/processing.py new file mode 100644 index 000000000..0984000c9 --- /dev/null +++ b/pystac/extensions/processing.py @@ -0,0 +1,490 @@ +"""Implements the :stac-ext:`Processing ` STAC Extension. + +https://github.com/stac-extensions/processing +""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import datetime +from typing import ( + Any, + Generic, + Literal, + TypeVar, + cast, +) + +import pystac +from pystac.extensions import item_assets +from pystac.extensions.base import ( + ExtensionManagementMixin, + PropertiesExtension, + SummariesExtension, +) +from pystac.extensions.hooks import ExtensionHooks +from pystac.summaries import RangeSummary +from pystac.utils import StringEnum, datetime_to_str, map_opt, str_to_datetime + +T = TypeVar( + "T", + pystac.Item, + pystac.Asset, + item_assets.AssetDefinition, + pystac.ItemAssetDefinition, +) + +SCHEMA_URI: str = "https://stac-extensions.github.io/processing/v1.2.0/schema.json" +SCHEMA_URIS: list[str] = [ + SCHEMA_URI, +] +PREFIX: str = "processing:" + +# Field names +LEVEL_PROP: str = PREFIX + "level" +DATETIME_PROP: str = PREFIX + "datetime" +EXPRESSION_PROP: str = PREFIX + "expression" +LINEAGE_PROP: str = PREFIX + "lineage" +FACILITY_PROP: str = PREFIX + "facility" +VERSION_PROP: str = PREFIX + "version" +SOFTWARE_PROP: str = PREFIX + "software" + + +class ProcessingLevel(StringEnum): + RAW = "RAW" + """Data in their original packets, as received from the instrument.""" + L0 = "L0" + """Reconstructed unprocessed instrument data at full space time resolution with all + available supplemental information to be used in subsequent processing + (e.g., ephemeris, health and safety) appended.""" + L1 = "L1" + """Unpacked, reformatted level 0 data, with all supplemental information to be used + in subsequent processing appended. Optional radiometric and geometric correction + applied to produce parameters in physical units. Data generally presented as full + time/space resolution.""" + L1A = "L1A" + """Unpacked, reformatted level 0 data, with all supplemental information to be used + in subsequent processing appended. Optional radiometric and geometric correction + applied to produce parameters in physical units. Data generally presented as full + time/space resolution.""" + L1B = "L1B" + """Unpacked, reformatted level 0 data, with all supplemental information to be used + in subsequent processing appended. Optional radiometric and geometric correction + applied to produce parameters in physical units. Data generally presented as full + time/space resolution.""" + L1C = "L1C" + """Unpacked, reformatted level 0 data, with all supplemental information to be used + in subsequent processing appended. Optional radiometric and geometric correction + applied to produce parameters in physical units. Data generally presented as full + time/space resolution.""" + L2 = "L2" + """Retrieved environmental variables (e.g., ocean wave height, soil-moisture, + ice concentration) at the same resolution and location as the level 1 source + data.""" + L2A = "L2A" + """Retrieved environmental variables (e.g., ocean wave height, soil-moisture, + ice concentration) at the same resolution and location as the level 1 source + data.""" + L3 = "L3" + """Data or retrieved environmental variables which have been spatially and/or + temporally re-sampled (i.e., derived from level 1 or 2 products). Such + re-sampling may include averaging and compositing.""" + L4 = "L4" + """Model output or results from analyses of lower level data (i.e., variables that + are not directly measured by the instruments, but are derived from these + measurements)""" + + +class ProcessingRelType(StringEnum): + """A list of rel types defined in the Processing Extension. + + See the :stac-ext:`Processing Extension Relation types + ` documentation + for details.""" + + EXPRESSION = "processing-expression" + """A processing chain (or script) that describes how the data has been processed.""" + + EXECUTION = "processing-execution" + """URL to any resource representing the processing execution + (e.g. OGC Process API).""" + + SOFTWARE = "processing-software" + """URL to any resource that identifies the software and versions used for processing + the data, e.g. a Pipfile.lock (Python) or package-lock.json (NodeJS).""" + + VALIDATION = "processing-validation" + """URL to any kind of validation that has been applied after processing, e.g. a + validation report or a script used for validation.""" + + +class ProcessingExtension( + Generic[T], + PropertiesExtension, + ExtensionManagementMixin[pystac.Item | pystac.Collection], +): + """An abstract class that can be used to extend the properties of an + :class:`~pystac.Item` or :class:`~pystac.Asset` with properties from the + :stac-ext:`Processing Extension `. This class is generic over the type + of STAC Object to be extended (e.g. :class:`~pystac.Item`, + :class:`~pystac.Collection`). + + To create a concrete instance of :class:`ProcessingExtension`, use the + :meth:`ProcessingExtension.ext` method. For example: + + .. code-block:: python + + >>> item: pystac.Item = ... + >>> proc_ext = ProcessingExtension.ext(item) + + """ + + name: Literal["processing"] = "processing" + + def __init__(self, item: pystac.Item) -> None: + self.item = item + self.properties = item.properties + + def __repr__(self) -> str: + return f"" + + def apply( + self, + level: ProcessingLevel | None = None, + datetime: datetime | None = None, + expression: str | None = None, + lineage: str | None = None, + facility: str | None = None, + version: str | None = None, + software: dict[str, str] | None = None, + ) -> None: + """Applies the processing extension properties to the extended Item. + + Args: + level: The processing level of the product. This should be the short name, + as one of the available options under the `ProcessingLevel` enum. + datetime: The datetime when the product was processed. Might be already + specified in the common STAC metadata. + expression: The expression used to obtain the processed product, like + `gdal-calc` or `rio-calc`. + lineage: Free text information about the how observations were processed or + models that were used to create the resource being described. + facility: The name of the facility that produced the data, like ESA. + version: The version of the primary processing software or processing chain + that produced the data, like the processing baseline for the Sentinel + missions. + software: A dictionary describing one or more applications or libraries that + were involved during the production of the data for provenance purposes. + """ + self.level = level + self.datetime = datetime + self.expression = expression + self.lineage = lineage + self.facility = facility + self.version = version + self.software = software + + @property + def level(self) -> ProcessingLevel | None: + """Get or sets the processing level as the name commonly used to refer to the + processing level to make it easier to search for product level across + collections or items. This property is expected to be a `ProcessingLevel`""" + return map_opt( + lambda x: ProcessingLevel(x), self._get_property(LEVEL_PROP, str) + ) + + @level.setter + def level(self, v: ProcessingLevel | None) -> None: + self._set_property(LEVEL_PROP, map_opt(lambda x: x.value, v)) + + @property + def datetime(self) -> datetime | None: + """Gets or set the processing date and time of the corresponding data formatted + according to RFC 3339, section 5.6, in UTC. The time of the processing can be + specified as a global field in processing:datetime, but it can also be specified + directly and individually via the created properties of the target asset as + specified in the STAC Common metadata. See more at + https://github.com/stac-extensions/processing?tab=readme-ov-file#processing-date-time""" + return map_opt(str_to_datetime, self._get_property(DATETIME_PROP, str)) + + @datetime.setter + def datetime(self, v: datetime | None) -> None: + self._set_property(DATETIME_PROP, map_opt(datetime_to_str, v)) + + @property + def expression(self) -> dict[str, str | Any] | None: + """Gets or sets an expression or processing chain that describes how the data + has been processed. Alternatively, you can also link to a processing chain with + the relation type processing-expression. + .. code-block:: python + >>> proc_ext.expression = "(b4-b1)/(b4+b1)" + """ + return self._get_property(EXPRESSION_PROP, dict[str, str | Any]) + + @expression.setter + def expression(self, v: str | Any | None) -> None: + if v is None: + self._set_property(EXPRESSION_PROP, v) + else: + if isinstance(v, str): + exp_format = "string" + elif isinstance(v, object): + exp_format = "object" + else: + raise ValueError( + "The provided expression is not a valid type (string or object)" + ) + + expression = { + "format": exp_format, + "expression": v, + } + + self._set_property(EXPRESSION_PROP, expression) + + @property + def lineage(self) -> str | None: + """Gets or sets the lineage provided as free text information about how + observations were processed or models that were used to create the resource + being described NASA ISO. For example, GRD Post Processing for "GRD" product of + Sentinel-1 satellites. CommonMark 0.29 syntax MAY be used for rich text + representation.""" + return self._get_property(LINEAGE_PROP, str) + + @lineage.setter + def lineage(self, v: str | None) -> None: + self._set_property(LINEAGE_PROP, v) + + @property + def facility(self) -> str | None: + """Gets or sets the name of the facility that produced the data. For example, + Copernicus S1 Core Ground Segment - DPA for product of Sentinel-1 satellites.""" + return self._get_property(FACILITY_PROP, str) + + @facility.setter + def facility(self, v: str | None) -> None: + self._set_property(FACILITY_PROP, v) + + @property + def version(self) -> str | None: + """Gets or sets The version of the primary processing software or processing + chain that produced the data. For example, this could be the processing baseline + for the Sentinel missions.""" + return self._get_property(VERSION_PROP, str) + + @version.setter + def version(self, v: str | None) -> None: + self._set_property(VERSION_PROP, v) + + @property + def software(self) -> dict[str, str] | None: + """Gets or sets the processing software as a dictionary with name/version for + key/value describing one or more applications or libraries that were involved + during the production of the data for provenance purposes. + + They are mostly informative and important to be complete for reproducibility + purposes. Thus, the values in the object can not just be version numbers, but + also be e.g. tag names, commit hashes or similar. For example, you could expose + a simplified version of the Pipfile.lock (Python) or package-lock.json (NodeJS). + If you need more information, you could also link to such files via the relation + type processing-software. + .. code-block:: python + >>> proc_ext.software = { + "Sentinel-1 IPF": "002.71" + } + """ + return self._get_property(SOFTWARE_PROP, dict[str, str]) + + @software.setter + def software(self, v: dict[str, str] | None) -> None: + self._set_property(SOFTWARE_PROP, v) + + @classmethod + def get_schema_uri(cls) -> str: + return SCHEMA_URI + + @classmethod + def ext(cls, obj: T, add_if_missing: bool = False) -> ProcessingExtension[T]: + import pystac.errors + + if isinstance(obj, pystac.Item): + if not add_if_missing and cls.get_schema_uri() not in obj.stac_extensions: + raise pystac.errors.ExtensionNotImplemented( + f"{cls.__name__} not implemented for Item id={getattr(obj, 'id', None)}" + ) + cls.ensure_has_extension(obj, add_if_missing) + return cast(ProcessingExtension[pystac.Item], ItemProcessingExtension(obj)) + elif isinstance(obj, pystac.Asset): + owner = obj.owner if hasattr(obj, "owner") else None + if owner and isinstance(owner, pystac.Item): + if ( + not add_if_missing + and cls.get_schema_uri() not in owner.stac_extensions + ): + raise pystac.errors.ExtensionNotImplemented( + f"{cls.__name__} not implemented for Asset href={getattr(obj, 'href', None)} (owner Item id={getattr(owner, 'id', None)})" + ) + cls.ensure_has_extension(owner, add_if_missing) if owner else None + return cast( + ProcessingExtension[pystac.Asset], AssetProcessingExtension(obj) + ) + else: + raise pystac.ExtensionTypeError(cls._ext_error_message(obj)) + + @classmethod + def summaries( + cls, obj: pystac.Collection, add_if_missing: bool = False + ) -> SummariesProcessingExtension: + """Returns the extended summaries object for the given collection.""" + cls.ensure_has_extension(obj, add_if_missing) + return SummariesProcessingExtension(obj) + + +class ItemProcessingExtension(ProcessingExtension[pystac.Item]): + item: pystac.Item + properties: dict[str, Any] + + def __init__(self, item: pystac.Item) -> None: + self.item = item + self.properties = item.properties + + def __repr__(self) -> str: + return f"" + + +class AssetProcessingExtension(ProcessingExtension[pystac.Asset]): + """A concrete implementation of :class:`ProcessingExtension` on an + :class:`~pystac.Asset` that extends the Asset fields to include properties defined + in the :stac-ext:`Processing Extension `. + + This class should generally not be instantiated directly. Instead, call + :meth:`ProcessingExtension.ext` on an :class:`~pystac.Asset` to extend it. + """ + + asset_href: str + """The ``href`` value of the :class:`~pystac.Asset` being extended.""" + + properties: dict[str, Any] + """The :class:`~pystac.Asset` fields, including extension properties.""" + + additional_read_properties: Iterable[dict[str, Any]] | None = None + """If present, this will be a list containing 1 dictionary representing the + properties of the owning :class:`~pystac.Item`.""" + + 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] + + def __repr__(self) -> str: + return f"" + + +class ItemAssetsProcessingExtension(ProcessingExtension[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 + + +class SummariesProcessingExtension(SummariesExtension): + """A concrete implementation of :class:`~pystac.extensions.base.SummariesExtension` + that extends the ``summaries`` field of a :class:`~pystac.Collection` to include + properties defined in the :stac-ext:`Processing Extension `. + """ + + @property + def level(self) -> list[ProcessingLevel] | None: + """Get or sets the summary of :attr:`ProcessingExtension.level` values + for this Collection. + """ + + return self.summaries.get_list(LEVEL_PROP) + + @level.setter + def level(self, v: list[ProcessingLevel] | None) -> None: + self._set_summary(LEVEL_PROP, v) + + @property + def datetime(self) -> RangeSummary[datetime] | None: + """Get or sets the summary of :attr:`ProcessingExtension.datetime` values + for this Collection. + """ + + return self.summaries.get_range(DATETIME_PROP) + + @datetime.setter + def datetime(self, v: RangeSummary[datetime] | None) -> None: + self._set_summary(DATETIME_PROP, v) + + @property + def expression(self) -> list[dict[str, str | Any]] | None: + """Get or sets the summary of :attr:`ProcessingExtension.expression` values + for this Collection. + """ + + return self.summaries.get_list(EXPRESSION_PROP) + + @expression.setter + def expression(self, v: list[dict[str, str | Any]] | None) -> None: + self._set_summary(EXPRESSION_PROP, v) + + @property + def lineage(self) -> RangeSummary[str] | None: + """Get or sets the summary of :attr:`ProcessingExtension.lineage` values + for this Collection. + """ + + return self.summaries.get_range(LINEAGE_PROP) + + @lineage.setter + def lineage(self, v: RangeSummary[str] | None) -> None: + self._set_summary(LINEAGE_PROP, v) + + @property + def facility(self) -> RangeSummary[str] | None: + """Get or sets the summary of :attr:`ProcessingExtension.facility` values + for this Collection. + """ + + return self.summaries.get_range(FACILITY_PROP) + + @facility.setter + def facility(self, v: RangeSummary[str] | None) -> None: + self._set_summary(FACILITY_PROP, v) + + @property + def version(self) -> RangeSummary[str] | None: + """Get or sets the summary of :attr:`ProcessingExtension.version` values + for this Collection. + """ + + return self.summaries.get_range(VERSION_PROP) + + @version.setter + def version(self, v: RangeSummary[str] | None) -> None: + self._set_summary(VERSION_PROP, v) + + @property + def software(self) -> list[dict[str, str]] | None: + """Get or sets the summary of :attr:`ProcessingExtension.software` values + for this Collection. + """ + + return self.summaries.get_list(SOFTWARE_PROP) + + @software.setter + def software(self, v: list[dict[str, str]] | None) -> None: + self._set_summary(SOFTWARE_PROP, v) + + +class ProcessingExtensionHooks(ExtensionHooks): + schema_uri: str = SCHEMA_URI + prev_extension_ids = {"processing"} + stac_object_types = {pystac.STACObjectType.ITEM} + + +PROCESSING_EXTENSION_HOOKS: ExtensionHooks = ProcessingExtensionHooks() diff --git a/tests/data-files/processing/collection.json b/tests/data-files/processing/collection.json new file mode 100644 index 000000000..43e4cad41 --- /dev/null +++ b/tests/data-files/processing/collection.json @@ -0,0 +1,120 @@ +{ + "stac_version": "1.0.0", + "stac_extensions": [ + "https://stac-extensions.github.io/processing/v1.2.0/schema.json" + ], + "type": "Collection", + "id": "Sentinel2-L2A", + "title": "Sentinel-2 MSI: MultiSpectral Instrument, Level-2A", + "description": "Sentinel-2 is a wide-swath, high-resolution, multi-spectral imaging mission.", + "license": "proprietary", + "providers": [ + { + "name": "European Union/ESA/Copernicus", + "roles": [ + "producer", + "licensor" + ], + "url": "https://sentinel.esa.int/web/sentinel/user-guides/sentinel-2-msi", + "processing:lineage": "Generation of Level-1C User Product", + "processing:level": "L1", + "processing:facility": "Copernicus S2 Processing and Archiving Facility", + "processing:version": "02.06" + }, + { + "name": "Processing Corp.", + "roles": [ + "processor" + ], + "processing:lineage": "Generation of Level-2A User Product", + "processing:level": "L2A", + "processing:software": { + "Sentinel-2 Toolbox": "8.0.0" + } + }, + { + "name": "Storage Provider, Inc.", + "roles": [ + "host" + ] + } + ], + "extent": { + "spatial": { + "bbox": [ + [ + -180, + -56, + 180, + 83 + ] + ] + }, + "temporal": { + "interval": [ + [ + "2015-06-23T00:00:00Z", + null + ] + ] + } + }, + "summaries": { + "datetime": { + "minimum": "2015-06-23T00:00:00Z", + "maximum": "2019-07-10T13:44:56Z" + }, + "platform": [ + "sentinel-2a", + "sentinel-2b" + ], + "constellation": [ + "sentinel-2" + ], + "instruments": [ + "msi" + ], + "gsd": [ + 10, + 30, + 60 + ], + "processing:level": [ + "L1", + "L2" + ] + }, + "links": [ + { + "rel": "items", + "type": "application/geo+json", + "href": "https://planetarycomputer.microsoft.com/api/stac/v1/collections/sentinel-1-grd/items" + }, + { + "rel": "parent", + "type": "application/json", + "href": "https://planetarycomputer.microsoft.com/api/stac/v1/" + }, + { + "rel": "root", + "type": "application/json", + "href": "https://planetarycomputer.microsoft.com/api/stac/v1/" + }, + { + "rel": "self", + "type": "application/json", + "href": "https://planetarycomputer.microsoft.com/api/stac/v1/collections/sentinel-1-grd" + }, + { + "rel": "license", + "href": "https://sentinel.esa.int/documents/247904/690755/Sentinel_Data_Legal_Notice", + "title": "Copernicus Sentinel data terms" + }, + { + "rel": "describedby", + "href": "https://planetarycomputer.microsoft.com/dataset/sentinel-1-grd", + "title": "Human readable dataset overview and reference", + "type": "text/html" + } + ] +} \ No newline at end of file diff --git a/tests/data-files/processing/item.json b/tests/data-files/processing/item.json new file mode 100644 index 000000000..3ca8e3f7c --- /dev/null +++ b/tests/data-files/processing/item.json @@ -0,0 +1,165 @@ +{ + "stac_version": "1.0.0", + "stac_extensions": [ + "https://stac-extensions.github.io/sat/v1.0.0/schema.json", + "https://stac-extensions.github.io/sar/v1.0.0/schema.json", + "https://stac-extensions.github.io/processing/v1.2.0/schema.json" + ], + "id": "S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF", + "properties": { + "datetime": "2016-08-22T18:28:23.368922Z", + "start_datetime": "2016-08-22T18:28:23.368922Z", + "end_datetime": "2016-08-22T18:28:48.368201Z", + "created": "2016-08-23T00:38:22Z", + "platform": "sentinel-1a", + "constellation": "sentinel-1", + "mission": "sentinel-1", + "instruments": [ + "c-sar" + ], + "sat:absolute_orbit": 12717, + "sat:orbit_state": "ascending", + "sat:relative_orbit": 45, + "sat:anx_datetime": "2016-08-22T18:24:52.513706Z", + "sar:instrument_mode": "IW", + "sar:frequency_band": "C", + "sar:polarizations": [ + "VV", + "VH" + ], + "sar:product_type": "GRD", + "processing:lineage": "GRD Post Processing", + "processing:level": "L1", + "processing:facility": "Copernicus S1 Core Ground Segment - DPA", + "processing:software": { + "Sentinel-1 IPF": "002.71" + }, + "processing:datetime": "2016-08-23T00:30:33Z" + }, + "links": [ + { + "title": "GRD Post Processing (90AF)", + "rel": "processing-execution", + "href": "https://api.example.com/processing/s1-grd-l1c/jobs/90AF", + "type": "application/json" + } + ], + "assets": { + "manifest": { + "type": "text/xml", + "roles": [ + "metadata" + ], + "title": "SAFE Manifest", + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/manifest.safe", + "created": "2016-08-23T00:30:33Z" + }, + "quick-look": { + "type": "image/png", + "roles": [ + "overview" + ], + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/preview/quick-look.png" + }, + "annotation-vv-iw": { + "type": "text/xml", + "roles": [ + "metadata" + ], + "title": "Annotation VV IW", + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/annotation/s1a-iw-grd-vv-20160822t182823-20160822t182848-012717-013ffe-001.xml", + "sar:polarizations": [ + "VV" + ] + }, + "amplitude-vv-iw": { + "type": "image/tiff; application=geotiff", + "roles": [ + "data" + ], + "title": "IW VV Amplitude pixel values", + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/annotation/s1a-iw-grd-vv-20160822t182823-20160822t182848-012717-013ffe-001.tiff", + "sar:polarizations": [ + "VV" + ] + }, + "annotation-vh-iw": { + "type": "text/xml", + "roles": [ + "metadata" + ], + "title": "Annotation VH IW", + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/annotation/s1a-iw-grd-vh-20160822t182823-20160822t182848-012717-013ffe-002.xml", + "sar:polarizations": [ + "VH" + ] + }, + "amplitude-vh-iw": { + "type": "image/tiff; application=geotiff", + "roles": [ + "data" + ], + "title": "IW VH Amplitude pixel values", + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/annotation/s1a-iw-grd-vh-20160822t182823-20160822t182848-012717-013ffe-002.tiff", + "sar:polarizations": [ + "VH" + ] + }, + "calibration-vv-iw": { + "type": "text/xml", + "roles": [ + "data" + ], + "title": "Calibration VV IW", + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/annotation/calibration/calibration-s1a-iw-grd-vv-20160822t182823-20160822t182848-012717-013ffe-001.xml", + "sar:polarizations": [ + "VV" + ] + }, + "calibration-vh-iw": { + "type": "text/xml", + "roles": [ + "data" + ], + "title": "Calibration VH IW", + "href": "data/S1A_IW_GRDH_1SDV_20160822T182823_20160822T182848_012717_013FFE_90AF.SAFE/annotation/calibration/calibration-s1a-iw-grd-vh-20160822t182823-20160822t182848-012717-013ffe-002.xml", + "sar:polarizations": [ + "VH" + ] + } + }, + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [ + -5.730959, + 14.953436 + ], + [ + -3.431006, + 15.388663 + ], + [ + -3.136116, + 13.880572 + ], + [ + -5.419919, + 13.441674 + ], + [ + -5.730959, + 14.953436 + ] + ] + ] + }, + "bbox": [ + -5.730959, + 13.441674, + -3.136116, + 15.388663 + ] +} \ No newline at end of file diff --git a/tests/extensions/test_processing.py b/tests/extensions/test_processing.py new file mode 100644 index 000000000..a8132598e --- /dev/null +++ b/tests/extensions/test_processing.py @@ -0,0 +1,147 @@ +"""Tests for pystac.extensions.sar.""" + +from datetime import datetime, timezone +from random import choice +from string import ascii_letters + +import pytest + +import pystac +from pystac import ExtensionTypeError +from pystac.extensions import processing +from pystac.extensions.processing import ( + ProcessingExtension, + ProcessingLevel, +) +from tests.utils import TestCases + + +@pytest.fixture +def item() -> pystac.Item: + asset_id = "my/items/2011" + start = datetime(2020, 11, 7) + item = pystac.Item( + id=asset_id, geometry=None, bbox=None, datetime=start, properties={} + ) + ProcessingExtension.add_to(item) + return item + + +@pytest.fixture +def sentinel_item() -> pystac.Item: + return pystac.Item.from_file(TestCases.get_path("data-files/processing/item.json")) + + +@pytest.fixture +def collection() -> pystac.Collection: + return pystac.Collection.from_file( + TestCases.get_path("data-files/processing/collection.json") + ) + + +def test_stac_extensions(item: pystac.Item) -> None: + assert ProcessingExtension.has_extension(item) + + +@pytest.mark.vcr() +def test_required(item: pystac.Item) -> None: + # None of the properties are required + + ProcessingExtension.ext(item).apply() + item.validate() + + +@pytest.mark.vcr() +def test_all(item: pystac.Item) -> None: + processing_level = ProcessingLevel.L1 + processing_datetime = datetime.now(timezone.utc) + processing_expression = "b1+b2" + processing_lineage = "GRD Post Processing" + processing_facility = "Copernicus S1 Core Ground Segment - DPA" + processing_version = "002.71" + processing_software = {"Sentinel-1 IPF": "002.71"} + + ProcessingExtension.ext(item).apply( + processing_level, + processing_datetime, + processing_expression, + processing_lineage, + processing_facility, + processing_version, + processing_software, + ) + + assert processing_level == ProcessingExtension.ext(item).level + assert processing.LEVEL_PROP in item.properties + + assert processing_datetime == ProcessingExtension.ext(item).datetime + assert processing.DATETIME_PROP in item.properties + + assert ( + processing_expression == ProcessingExtension.ext(item).expression["expression"] + ) + assert "string" == ProcessingExtension.ext(item).expression["format"] + assert processing.EXPRESSION_PROP in item.properties + + assert processing_lineage == ProcessingExtension.ext(item).lineage + assert processing.LINEAGE_PROP in item.properties + + assert processing_software == ProcessingExtension.ext(item).software + assert processing.SOFTWARE_PROP in item.properties + + assert processing_facility == ProcessingExtension.ext(item).facility + assert processing.FACILITY_PROP in item.properties + + assert processing_version == ProcessingExtension.ext(item).version + assert processing.VERSION_PROP in item.properties + + item.validate() + + +def test_should_return_none_when_nothing_is_set( + item: pystac.Item, +) -> None: + extension = ProcessingExtension.ext(item) + extension.apply() + + assert extension.level is None + assert extension.datetime is None + assert extension.expression is None + assert extension.software is None + assert extension.facility is None + assert extension.version is None + + +def test_extension_not_implemented(sentinel_item: pystac.Item) -> None: + # Should raise exception if Item does not include extension URI + sentinel_item.stac_extensions.remove(ProcessingExtension.get_schema_uri()) + + with pytest.raises(pystac.ExtensionNotImplemented): + _ = ProcessingExtension.ext(sentinel_item) + + # Should raise exception if owning Item does not include extension URI + asset = sentinel_item.assets["quick-look"] + + with pytest.raises(pystac.ExtensionNotImplemented): + _ = ProcessingExtension.ext(asset) + + # Should succeed if Asset has no owner + ownerless_asset = pystac.Asset.from_dict(asset.to_dict()) + _ = ProcessingExtension.ext(ownerless_asset) + + +def test_item_ext_add_to(sentinel_item: pystac.Item) -> None: + sentinel_item.stac_extensions.remove(ProcessingExtension.get_schema_uri()) + assert ProcessingExtension.get_schema_uri() not in sentinel_item.stac_extensions + + _ = ProcessingExtension.ext(sentinel_item, add_if_missing=True) + + assert ProcessingExtension.get_schema_uri() in sentinel_item.stac_extensions + + +def test_should_raise_exception_when_passing_invalid_extension_object() -> None: + with pytest.raises( + ExtensionTypeError, + match=r"^ProcessingExtension does not apply to type 'object'$", + ): + ProcessingExtension.ext(object()) # type: ignore