diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fdd3dac9..63b6b518b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Timestamps Extension summaries ([#513](https://github.com/stac-utils/pystac/pull/513)) - Define equality and `__repr__` of `RangeSummary` instances based on `to_dict` representation ([#513](https://github.com/stac-utils/pystac/pull/513)) +- Sat Extension summaries ([#509](https://github.com/stac-utils/pystac/pull/509)) ### Changed @@ -24,6 +25,8 @@ - `Link` constructor classes (e.g. `Link.from_dict`, `Link.canonical`, etc.) now return the calling class instead of always returning the `Link` class ([#512](https://github.com/stac-utils/pystac/pull/512)) +- Sat extension now includes all fields defined in v1.0.0 + ([#509](https://github.com/stac-utils/pystac/pull/509)) ### Removed diff --git a/docs/api.rst b/docs/api.rst index f191cf6f9..e84c0ae39 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -499,6 +499,45 @@ RasterExtension :show-inheritance: :inherited-members: +Satellite Extension +------------------- + +OrbitState +~~~~~~~~~~ + +.. autoclass:: pystac.extensions.sat.OrbitState + :members: + :show-inheritance: + :undoc-members: + +SatExtension +~~~~~~~~~~~~ + +.. autoclass:: pystac.extensions.sat.SatExtension + :members: + :show-inheritance: + +ItemSatExtension +~~~~~~~~~~~~~~~~ + +.. autoclass:: pystac.extensions.sat.ItemSatExtension + :members: + :show-inheritance: + +AssetSatExtension +~~~~~~~~~~~~~~~~~ + +.. autoclass:: pystac.extensions.sat.AssetSatExtension + :members: + :show-inheritance: + +SummariesSatExtension +~~~~~~~~~~~~~~~~~~~~~ + +.. autoclass:: pystac.extensions.sat.SummariesSatExtension + :members: + :show-inheritance: + Scientific Extension -------------------- diff --git a/pystac/extensions/sat.py b/pystac/extensions/sat.py index c775f56c2..db08b8ee5 100644 --- a/pystac/extensions/sat.py +++ b/pystac/extensions/sat.py @@ -4,25 +4,34 @@ """ import enum -from typing import Generic, Optional, Set, TypeVar, cast +from datetime import datetime as Datetime +from pystac.summaries import RangeSummary +from typing import Dict, Any, List, Generic, Iterable, Optional, Set, TypeVar, cast import pystac from pystac.extensions.base import ( ExtensionManagementMixin, PropertiesExtension, + SummariesExtension, ) from pystac.extensions.hooks import ExtensionHooks -from pystac.utils import map_opt +from pystac.utils import str_to_datetime, datetime_to_str, map_opt T = TypeVar("T", pystac.Item, pystac.Asset) SCHEMA_URI = "https://stac-extensions.github.io/sat/v1.0.0/schema.json" -ORBIT_STATE: str = "sat:orbit_state" -RELATIVE_ORBIT: str = "sat:relative_orbit" +PREFIX: str = "sat:" +PLATFORM_INTERNATIONAL_DESIGNATOR_PROP: str = ( + PREFIX + "platform_international_designator" +) +ABSOLUTE_ORBIT_PROP: str = PREFIX + "absolute_orbit" +ORBIT_STATE_PROP: str = PREFIX + "orbit_state" +RELATIVE_ORBIT_PROP: str = PREFIX + "relative_orbit" +ANX_DATETIME_PROP: str = PREFIX + "anx_datetime" -class OrbitState(enum.Enum): +class OrbitState(str, enum.Enum): ASCENDING = "ascending" DESCENDING = "descending" GEOSTATIONARY = "geostationary" @@ -31,25 +40,31 @@ class OrbitState(enum.Enum): class SatExtension( Generic[T], PropertiesExtension, ExtensionManagementMixin[pystac.Item] ): - """SatItemExt extends Item to add sat properties to a STAC Item. + """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:`Satellite Extension `. This class is generic over the type of + STAC Object to be extended (e.g. :class:`~pystac.Item`, + :class:`~pystac.Collection`). - Args: - item : The item to be extended. + To create a concrete instance of :class:`SatExtension`, use the + :meth:`SatExtension.ext` method. For example: - Attributes: - item : The item that is being extended. + .. code-block:: python - Note: - Using SatItemExt to directly wrap an item will add the 'sat' - extension ID to the item's stac_extensions. + >>> item: pystac.Item = ... + >>> sat_ext = SatExtension.ext(item) """ def apply( self, orbit_state: Optional[OrbitState] = None, relative_orbit: Optional[int] = None, + absolute_orbit: Optional[int] = None, + platform_international_designator: Optional[str] = None, + anx_datetime: Optional[Datetime] = None, ) -> None: - """Applies ext extension properties to the extended Item. + """Applies ext extension properties to the extended :class:`~pystac.Item` or + class:`~pystac.Asset`. Must specify at least one of orbit_state or relative_orbit in order for the sat extension to properties to be valid. @@ -62,34 +77,58 @@ def apply( the time of acquisition. """ + self.platform_international_designator = platform_international_designator self.orbit_state = orbit_state + self.absolute_orbit = absolute_orbit self.relative_orbit = relative_orbit + self.anx_datetime = anx_datetime @property - def orbit_state(self) -> Optional[OrbitState]: - """Get or sets an orbit state of the item. + def platform_international_designator(self) -> Optional[str]: + """Gets or sets the International Designator, also known as COSPAR ID, and + NSSDCA ID.""" + return self._get_property(PLATFORM_INTERNATIONAL_DESIGNATOR_PROP, str) - Returns: - OrbitState or None - """ - return map_opt(lambda x: OrbitState(x), self._get_property(ORBIT_STATE, str)) + @platform_international_designator.setter + def platform_international_designator(self, v: Optional[str]) -> None: + self._set_property(PLATFORM_INTERNATIONAL_DESIGNATOR_PROP, v) + + @property + def orbit_state(self) -> Optional[OrbitState]: + """Get or sets an orbit state of the object.""" + return map_opt( + lambda x: OrbitState(x), self._get_property(ORBIT_STATE_PROP, str) + ) @orbit_state.setter def orbit_state(self, v: Optional[OrbitState]) -> None: - self._set_property(ORBIT_STATE, map_opt(lambda x: x.value, v)) + self._set_property(ORBIT_STATE_PROP, map_opt(lambda x: x.value, v)) @property - def relative_orbit(self) -> Optional[int]: - """Get or sets a relative orbit number of the item. + def absolute_orbit(self) -> Optional[int]: + """Get or sets a absolute orbit number of the item.""" + return self._get_property(ABSOLUTE_ORBIT_PROP, int) - Returns: - int or None - """ - return self._get_property(RELATIVE_ORBIT, int) + @absolute_orbit.setter + def absolute_orbit(self, v: Optional[int]) -> None: + self._set_property(ABSOLUTE_ORBIT_PROP, v) + + @property + def relative_orbit(self) -> Optional[int]: + """Get or sets a relative orbit number of the item.""" + return self._get_property(RELATIVE_ORBIT_PROP, int) @relative_orbit.setter def relative_orbit(self, v: Optional[int]) -> None: - self._set_property(RELATIVE_ORBIT, v) + self._set_property(RELATIVE_ORBIT_PROP, v) + + @property + def anx_datetime(self) -> Optional[Datetime]: + return map_opt(str_to_datetime, self._get_property(ANX_DATETIME_PROP, str)) + + @anx_datetime.setter + def anx_datetime(self, v: Optional[Datetime]) -> None: + self._set_property(ANX_DATETIME_PROP, map_opt(datetime_to_str, v)) @classmethod def get_schema_uri(cls) -> str: @@ -97,6 +136,16 @@ def get_schema_uri(cls) -> str: @classmethod def ext(cls, obj: T, add_if_missing: bool = False) -> "SatExtension[T]": + """Extends the given STAC Object with properties from the :stac-ext:`Satellite + Extension `. + + This extension can be applied to instances of :class:`~pystac.Item` or + :class:`~pystac.Asset`. + + Raises: + + pystac.ExtensionTypeError : If an invalid object type is passed. + """ if isinstance(obj, pystac.Item): if add_if_missing: cls.add_to(obj) @@ -112,8 +161,28 @@ def ext(cls, obj: T, add_if_missing: bool = False) -> "SatExtension[T]": f"Satellite extension does not apply to type '{type(obj).__name__}'" ) + @staticmethod + def summaries(obj: pystac.Collection) -> "SummariesSatExtension": + """Returns the extended summaries object for the given collection.""" + return SummariesSatExtension(obj) + class ItemSatExtension(SatExtension[pystac.Item]): + """A concrete implementation of :class:`SatExtension` on an :class:`~pystac.Item` + that extends the properties of the Item to include properties defined in the + :stac-ext:`Satellite Extension `. + + This class should generally not be instantiated directly. Instead, call + :meth:`SatExtension.ext` on an :class:`~pystac.Item` to + extend it. + """ + + item: pystac.Item + """The :class:`~pystac.Item` being extended.""" + + properties: Dict[str, Any] + """The :class:`~pystac.Item` properties, including extension properties.""" + def __init__(self, item: pystac.Item): self.item = item self.properties = item.properties @@ -123,6 +192,25 @@ def __repr__(self) -> str: class AssetSatExtension(SatExtension[pystac.Asset]): + """A concrete implementation of :class:`SatExtension` on an :class:`~pystac.Asset` + that extends the properties of the Asset to include properties defined in the + :stac-ext:`Satellite Extension `. + + This class should generally not be instantiated directly. Instead, call + :meth:`SatExtension.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: Optional[Iterable[Dict[str, Any]]] = 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.properties @@ -133,6 +221,75 @@ def __repr__(self) -> str: return "".format(self.asset_href) +class SummariesSatExtension(SummariesExtension): + """A concrete implementation of :class:`~SummariesExtension` that extends + the ``summaries`` field of a :class:`~pystac.Collection` to include properties + defined in the :stac-ext:`Satellite Extension `. + """ + + @property + def platform_international_designator(self) -> Optional[List[str]]: + """Get or sets the summary of + :attr:`SatExtension.platform_international_designator` values for this + Collection. + """ + + return self.summaries.get_list(PLATFORM_INTERNATIONAL_DESIGNATOR_PROP) + + @platform_international_designator.setter + def platform_international_designator(self, v: Optional[List[str]]) -> None: + self._set_summary(PLATFORM_INTERNATIONAL_DESIGNATOR_PROP, v) + + @property + def orbit_state(self) -> Optional[List[OrbitState]]: + """Get or sets the summary of :attr:`SatExtension.orbit_state` values + for this Collection. + """ + + return self.summaries.get_list(ORBIT_STATE_PROP) + + @orbit_state.setter + def orbit_state(self, v: Optional[List[OrbitState]]) -> None: + self._set_summary(ORBIT_STATE_PROP, v) + + @property + def absolute_orbit(self) -> Optional[RangeSummary[int]]: + return self.summaries.get_range(ABSOLUTE_ORBIT_PROP) + + @absolute_orbit.setter + def absolute_orbit(self, v: Optional[RangeSummary[int]]) -> None: + self._set_summary(ABSOLUTE_ORBIT_PROP, v) + + @property + def relative_orbit(self) -> Optional[RangeSummary[int]]: + return self.summaries.get_range(RELATIVE_ORBIT_PROP) + + @relative_orbit.setter + def relative_orbit(self, v: Optional[RangeSummary[int]]) -> None: + self._set_summary(RELATIVE_ORBIT_PROP, v) + + @property + def anx_datetime(self) -> Optional[RangeSummary[Datetime]]: + return map_opt( + lambda s: RangeSummary( + str_to_datetime(s.minimum), str_to_datetime(s.maximum) + ), + self.summaries.get_range(ANX_DATETIME_PROP), + ) + + @anx_datetime.setter + def anx_datetime(self, v: Optional[RangeSummary[Datetime]]) -> None: + self._set_summary( + ANX_DATETIME_PROP, + map_opt( + lambda s: RangeSummary( + datetime_to_str(s.minimum), datetime_to_str(s.maximum) + ), + v, + ), + ) + + class SatExtensionHooks(ExtensionHooks): schema_uri: str = SCHEMA_URI prev_extension_ids: Set[str] = set(["sat"]) diff --git a/tests/extensions/test_sat.py b/tests/extensions/test_sat.py index 686ec4e4d..850a43f3c 100644 --- a/tests/extensions/test_sat.py +++ b/tests/extensions/test_sat.py @@ -1,13 +1,15 @@ """Tests for pystac.extensions.sat.""" import datetime +from pystac.summaries import RangeSummary from typing import Any, Dict import unittest import pystac +from pystac.utils import str_to_datetime, datetime_to_str from pystac import ExtensionTypeError from pystac.extensions import sat -from pystac.extensions.sat import SatExtension +from pystac.extensions.sat import OrbitState, SatExtension from tests.utils import TestCases @@ -32,6 +34,21 @@ def setUp(self) -> None: def test_stac_extensions(self) -> None: self.assertTrue(SatExtension.has_extension(self.item)) + def test_item_repr(self) -> None: + sat_item_ext = SatExtension.ext(self.item) + self.assertEqual( + f"", sat_item_ext.__repr__() + ) + + def test_asset_repr(self) -> None: + item = pystac.Item.from_file(self.sentinel_example_uri) + asset = item.assets["measurement_iw1_vh"] + sat_asset_ext = SatExtension.ext(asset) + + self.assertEqual( + f"", sat_asset_ext.__repr__() + ) + def test_no_args_fails(self) -> None: SatExtension.ext(self.item).apply() with self.assertRaises(pystac.STACValidationError): @@ -41,16 +58,45 @@ def test_orbit_state(self) -> None: orbit_state = sat.OrbitState.ASCENDING SatExtension.ext(self.item).apply(orbit_state) self.assertEqual(orbit_state, SatExtension.ext(self.item).orbit_state) - self.assertNotIn(sat.RELATIVE_ORBIT, self.item.properties) - self.assertFalse(SatExtension.ext(self.item).relative_orbit) + self.assertNotIn(sat.RELATIVE_ORBIT_PROP, self.item.properties) + self.assertIsNone(SatExtension.ext(self.item).relative_orbit) self.item.validate() def test_relative_orbit(self) -> None: relative_orbit = 1234 SatExtension.ext(self.item).apply(None, relative_orbit) self.assertEqual(relative_orbit, SatExtension.ext(self.item).relative_orbit) - self.assertNotIn(sat.ORBIT_STATE, self.item.properties) - self.assertFalse(SatExtension.ext(self.item).orbit_state) + self.assertNotIn(sat.ORBIT_STATE_PROP, self.item.properties) + self.assertIsNone(SatExtension.ext(self.item).orbit_state) + self.item.validate() + + def test_absolute_orbit(self) -> None: + absolute_orbit = 1234 + SatExtension.ext(self.item).apply(absolute_orbit=absolute_orbit) + self.assertEqual(absolute_orbit, SatExtension.ext(self.item).absolute_orbit) + self.assertNotIn(sat.RELATIVE_ORBIT_PROP, self.item.properties) + self.assertIsNone(SatExtension.ext(self.item).relative_orbit) + self.item.validate() + + def test_anx_datetime(self) -> None: + anx_datetime = str_to_datetime("2020-01-01T00:00:00Z") + SatExtension.ext(self.item).apply(anx_datetime=anx_datetime) + self.assertEqual(anx_datetime, SatExtension.ext(self.item).anx_datetime) + self.assertNotIn(sat.RELATIVE_ORBIT_PROP, self.item.properties) + self.assertIsNone(SatExtension.ext(self.item).relative_orbit) + self.item.validate() + + def test_platform_international_designator(self) -> None: + platform_international_designator = "2018-080A" + SatExtension.ext(self.item).apply( + platform_international_designator=platform_international_designator + ) + self.assertEqual( + platform_international_designator, + SatExtension.ext(self.item).platform_international_designator, + ) + self.assertNotIn(sat.ORBIT_STATE_PROP, self.item.properties) + self.assertIsNone(SatExtension.ext(self.item).orbit_state) self.item.validate() def test_relative_orbit_no_negative(self) -> None: @@ -104,8 +150,8 @@ def test_to_from_dict(self) -> None: relative_orbit = 1002 SatExtension.ext(self.item).apply(orbit_state, relative_orbit) d = self.item.to_dict() - self.assertEqual(orbit_state.value, d["properties"][sat.ORBIT_STATE]) - self.assertEqual(relative_orbit, d["properties"][sat.RELATIVE_ORBIT]) + self.assertEqual(orbit_state.value, d["properties"][sat.ORBIT_STATE_PROP]) + self.assertEqual(relative_orbit, d["properties"][sat.RELATIVE_ORBIT_PROP]) item = pystac.Item.from_dict(d) self.assertEqual(orbit_state, SatExtension.ext(item).orbit_state) @@ -171,3 +217,115 @@ def test_should_raise_exception_when_passing_invalid_extension_object( SatExtension.ext, object(), ) + + +class SatSummariesTest(unittest.TestCase): + def setUp(self) -> None: + self.maxDiff = None + + @staticmethod + def collection() -> pystac.Collection: + return pystac.Collection.from_file( + TestCases.get_path("data-files/collections/multi-extent.json") + ) + + def test_platform_international_designation(self) -> None: + collection = self.collection() + summaries_ext = SatExtension.summaries(collection) + platform_international_designator_list = ["2018-080A"] + + summaries_ext.platform_international_designator = ["2018-080A"] + + self.assertEqual( + summaries_ext.platform_international_designator, + platform_international_designator_list, + ) + + summaries_dict = collection.to_dict()["summaries"] + + self.assertEqual( + summaries_dict["sat:platform_international_designator"], + platform_international_designator_list, + ) + + def test_orbit_state(self) -> None: + collection = self.collection() + summaries_ext = SatExtension.summaries(collection) + orbit_state_list = [OrbitState.ASCENDING] + + summaries_ext.orbit_state = orbit_state_list + + self.assertEqual( + summaries_ext.orbit_state, + orbit_state_list, + ) + + summaries_dict = collection.to_dict()["summaries"] + + self.assertEqual( + summaries_dict["sat:orbit_state"], + orbit_state_list, + ) + + def test_absolute_orbit(self) -> None: + collection = self.collection() + summaries_ext = SatExtension.summaries(collection) + absolute_orbit_range = RangeSummary(2000, 3000) + + summaries_ext.absolute_orbit = absolute_orbit_range + + self.assertEqual( + summaries_ext.absolute_orbit, + absolute_orbit_range, + ) + + summaries_dict = collection.to_dict()["summaries"] + + self.assertEqual( + summaries_dict["sat:absolute_orbit"], + absolute_orbit_range.to_dict(), + ) + + def test_relative_orbit(self) -> None: + collection = self.collection() + summaries_ext = SatExtension.summaries(collection) + relative_orbit_range = RangeSummary(50, 100) + + summaries_ext.relative_orbit = relative_orbit_range + + self.assertEqual( + summaries_ext.relative_orbit, + relative_orbit_range, + ) + + summaries_dict = collection.to_dict()["summaries"] + + self.assertEqual( + summaries_dict["sat:relative_orbit"], + relative_orbit_range.to_dict(), + ) + + def test_anx_datetime(self) -> None: + collection = self.collection() + summaries_ext = SatExtension.summaries(collection) + anx_datetime_range = RangeSummary( + str_to_datetime("2020-01-01T00:00:00.000Z"), + str_to_datetime("2020-01-02T00:00:00.000Z"), + ) + + summaries_ext.anx_datetime = anx_datetime_range + + self.assertEqual( + summaries_ext.anx_datetime, + anx_datetime_range, + ) + + summaries_dict = collection.to_dict()["summaries"] + + self.assertDictEqual( + summaries_dict["sat:anx_datetime"], + { + "minimum": datetime_to_str(anx_datetime_range.minimum), + "maximum": datetime_to_str(anx_datetime_range.maximum), + }, + )