From f843e88718d1a652bf95477f51ad6ed52ea96395 Mon Sep 17 00:00:00 2001 From: Kurt Schwehr Date: Wed, 21 Oct 2020 15:31:18 -0700 Subject: [PATCH] Implement SAR extension. #33 --- pystac/__init__.py | 3 +- pystac/extensions/sar.py | 218 +++++++++++++++++++++++++++++++++++ tests/extensions/test_sar.py | 99 ++++++++++++++++ 3 files changed, 319 insertions(+), 1 deletion(-) create mode 100644 pystac/extensions/sar.py create mode 100644 tests/extensions/test_sar.py diff --git a/pystac/__init__.py b/pystac/__init__.py index 3925acb56..93450eb7d 100644 --- a/pystac/__init__.py +++ b/pystac/__init__.py @@ -34,6 +34,7 @@ class STACError(Exception): import pystac.extensions.label import pystac.extensions.pointcloud import pystac.extensions.projection +import pystac.extensions.sar import pystac.extensions.single_file_stac import pystac.extensions.timestamps import pystac.extensions.version @@ -42,7 +43,7 @@ class STACError(Exception): STAC_EXTENSIONS = extensions.base.RegisteredSTACExtensions([ extensions.eo.EO_EXTENSION_DEFINITION, extensions.label.LABEL_EXTENSION_DEFINITION, extensions.pointcloud.POINTCLOUD_EXTENSION_DEFINITION, - extensions.projection.PROJECTION_EXTENSION_DEFINITION, + extensions.projection.PROJECTION_EXTENSION_DEFINITION, extensions.sar.SAR_EXTENSION_DEFINITION, extensions.single_file_stac.SFS_EXTENSION_DEFINITION, extensions.timestamps.TIMESTAMPS_EXTENSION_DEFINITION, extensions.version.VERSION_EXTENSION_DEFINITION, extensions.view.VIEW_EXTENSION_DEFINITION diff --git a/pystac/extensions/sar.py b/pystac/extensions/sar.py new file mode 100644 index 000000000..052463369 --- /dev/null +++ b/pystac/extensions/sar.py @@ -0,0 +1,218 @@ +"""Implement the STAC Synthetic-Aperture Radar (SAR) Extension. + +https://github.com/radiantearth/stac-spec/tree/dev/extensions/sar +""" + +# TODO(schwehr): Document. + +import enum +from typing import List, Optional, TypeVar + +import pystac +from pystac import Extensions +from pystac import item +from pystac.extensions import base + +# Required +INSTRUMENT_MODE = 'sar:instrument_mode' +FREQUENCY_BAND = 'sar:frequency_band' +POLARIZATIONS = 'sar:polarizations' +PRODUCT_TYPE = 'sar:product_type' + +# Not required +CENTER_FREQUENCY = 'sar:center_frequency' +RESOLUTION_RANGE = 'sar:resolution_range' +RESOLUTION_AZIMUTH = 'sar:resolution_azimuth' +PIXEL_SPACING_RANGE = 'sar:pixel_spacing_range' +PIXEL_SPACING_AZIMUTH = 'sar:pixel_spacing_azimuth' +LOOKS_RANGE = 'sar:looks_range' +LOOKS_AZIMUTH = 'sar:looks_azimuth' +LOOKS_EQUIVALENT_NUMBER = 'sar:looks_equivalent_number' +OBSERVATION_DIRECTION = 'sar:observation_direction' + +SarItemExtType = TypeVar('SarItemExtType') + + +class FrequencyBand(enum.Enum): + P = 'P' + L = 'L' + S = 'S' + C = 'C' + X = 'X' + KU = 'Ku' + K = 'K' + KA = 'Ka' + + +class Polarization(enum.Enum): + HH = 'HH' + VV = 'VV' + HV = 'HV' + VH = 'VH' + + +class ObservationDirection(enum.Enum): + LEFT = 'left' + RIGHT = 'right' + + +class SarItemExt(base.ItemExtension): + """Add sar properties to a STAC Item.""" + def __init__(self, an_item: item.Item) -> None: + self.item = an_item + + def apply(self, + instrument_mode: str, + frequency_band: str, + polarizations: List[Polarization], + product_type: str, + center_frequency: Optional[float] = None, + resolution_range: Optional[float] = None, + resolution_azimuth: Optional[float] = None, + pixel_spacing_range: Optional[float] = None, + pixel_spacing_azimuth: Optional[float] = None, + looks_range: Optional[int] = None, + looks_azimuth: Optional[int] = None, + looks_equivalent_number: Optional[float] = None, + observation_direction: Optional[ObservationDirection] = None): + self.instrument_mode = instrument_mode + self.frequency_band = frequency_band + self.polarizations = polarizations + self.product_type = product_type + if center_frequency: + self.center_frequency = center_frequency + if resolution_range: + self.resolution_range = resolution_range + if resolution_azimuth: + self.resolution_azimuth = resolution_azimuth + if pixel_spacing_range: + self.pixel_spacing_range = pixel_spacing_range + if pixel_spacing_azimuth: + self.pixel_spacing_azimuth = pixel_spacing_azimuth + if looks_range: + self.looks_range = looks_range + if looks_azimuth: + self.looks_azimuth = looks_azimuth + if looks_equivalent_number: + self.looks_equivalent_number = looks_equivalent_number + if observation_direction: + self.observation_direction = observation_direction + + @classmethod + def from_item(cls: SarItemExtType, an_item: item.Item) -> SarItemExtType: + return cls(an_item) + + @classmethod + def _object_links(cls) -> List: + return [] + + @property + def instrument_mode(self) -> str: + return self.item.properties.get(INSTRUMENT_MODE) + + @instrument_mode.setter + def instrument_mode(self, v: str) -> None: + self.item.properties[INSTRUMENT_MODE] = v + + @property + def frequency_band(self) -> FrequencyBand: + return FrequencyBand(self.item.properties.get(FREQUENCY_BAND)) + + @frequency_band.setter + def frequency_band(self, v: FrequencyBand) -> None: + self.item.properties[FREQUENCY_BAND] = v.value + + @property + def polarizations(self) -> List[Polarization]: + return [Polarization(v) for v in self.item.properties.get(POLARIZATIONS)] + + @polarizations.setter + def polarizations(self, values: List[Polarization]) -> None: + if not isinstance(values, list): + raise pystac.STACError(f'polarizations must be a list. Invalid "{values}"') + self.item.properties[POLARIZATIONS] = [v.value for v in values] + + @property + def product_type(self) -> str: + return self.item.properties.get(PRODUCT_TYPE) + + @product_type.setter + def product_type(self, v: str) -> None: + self.item.properties[PRODUCT_TYPE] = v + + @property + def center_frequency(self) -> float: + return self.item.properties.get(CENTER_FREQUENCY) + + @center_frequency.setter + def center_frequency(self, v: float) -> None: + self.item.properties[CENTER_FREQUENCY] = v + + @property + def resolution_range(self) -> float: + return self.item.properties.get(RESOLUTION_RANGE) + + @resolution_range.setter + def resolution_range(self, v: float) -> None: + self.item.properties[RESOLUTION_RANGE] = v + + @property + def resolution_azimuth(self) -> float: + return self.item.properties.get(RESOLUTION_AZIMUTH) + + @resolution_azimuth.setter + def resolution_azimuth(self, v: float) -> None: + self.item.properties[RESOLUTION_AZIMUTH] = v + + @property + def pixel_spacing_range(self) -> float: + return self.item.properties.get(PIXEL_SPACING_RANGE) + + @pixel_spacing_range.setter + def pixel_spacing_range(self, v: float) -> None: + self.item.properties[PIXEL_SPACING_RANGE] = v + + @property + def pixel_spacing_azimuth(self) -> float: + return self.item.properties.get(PIXEL_SPACING_AZIMUTH) + + @pixel_spacing_azimuth.setter + def pixel_spacing_azimuth(self, v: float) -> None: + self.item.properties[PIXEL_SPACING_AZIMUTH] = v + + @property + def looks_range(self) -> int: + return self.item.properties.get(LOOKS_RANGE) + + @looks_range.setter + def looks_range(self, v: int) -> None: + self.item.properties[LOOKS_RANGE] = v + + @property + def looks_azimuth(self) -> int: + return self.item.properties.get(LOOKS_AZIMUTH) + + @looks_azimuth.setter + def looks_azimuth(self, v: int) -> None: + self.item.properties[LOOKS_AZIMUTH] = v + + @property + def looks_equivalent_number(self) -> float: + return self.item.properties.get(LOOKS_EQUIVALENT_NUMBER) + + @looks_equivalent_number.setter + def looks_equivalent_number(self, v: float) -> None: + self.item.properties[LOOKS_EQUIVALENT_NUMBER] = v + + @property + def observation_direction(self) -> ObservationDirection: + return ObservationDirection(self.item.properties.get(OBSERVATION_DIRECTION)) + + @observation_direction.setter + def observation_direction(self, v: ObservationDirection) -> None: + self.item.properties[OBSERVATION_DIRECTION] = v.value + + +SAR_EXTENSION_DEFINITION = base.ExtensionDefinition(Extensions.SAR, [ + base.ExtendedObject(item.Item, SarItemExt), +]) diff --git a/tests/extensions/test_sar.py b/tests/extensions/test_sar.py new file mode 100644 index 000000000..a860b5dbf --- /dev/null +++ b/tests/extensions/test_sar.py @@ -0,0 +1,99 @@ +"""Tests for pystac.extensions.sar.""" + +import datetime +import unittest + +import pystac +from pystac.extensions import sar + + +def make_item() -> pystac.Item: + asset_id = 'my/items/2011' + start = datetime.datetime(2020, 11, 7) + item = pystac.Item(id=asset_id, geometry=None, bbox=None, datetime=start, properties={}) + + item.ext.enable(pystac.Extensions.SAR) + return item + + +class SarItemExtTest(unittest.TestCase): + def setUp(self): + super().setUp() + self.item = make_item() + self.item.ext.enable(pystac.Extensions.SAR) + + def test_stac_extensions(self): + self.assertEqual([pystac.Extensions.SAR], self.item.stac_extensions) + + def test_required(self): + mode = 'Nonesense mode' + frequency_band = sar.FrequencyBand.P + polarizations = [sar.Polarization.HV, sar.Polarization.VH] + product_type = 'Some product' + self.item.ext.sar.apply(mode, frequency_band, polarizations, product_type) + self.assertEqual(mode, self.item.ext.sar.instrument_mode) + self.assertIn(sar.INSTRUMENT_MODE, self.item.properties) + + self.assertEqual(frequency_band, self.item.ext.sar.frequency_band) + self.assertIn(sar.FREQUENCY_BAND, self.item.properties) + + self.assertEqual(polarizations, self.item.ext.sar.polarizations) + self.assertIn(sar.POLARIZATIONS, self.item.properties) + + self.assertEqual(product_type, self.item.ext.sar.product_type) + self.assertIn(sar.PRODUCT_TYPE, self.item.properties) + + self.item.validate() + + def test_all(self): + mode = 'WV' + frequency_band = sar.FrequencyBand.KA + polarizations = [sar.Polarization.VV, sar.Polarization.HH] + product_type = 'Some product' + center_frequency = 1.2 + resolution_range = 3.1 + resolution_azimuth = 4.1 + pixel_spacing_range = 5.1 + pixel_spacing_azimuth = 6.1 + looks_range = 7 + looks_azimuth = 8 + looks_equivalent_number = 9.1 + observation_direction = sar.ObservationDirection.LEFT + + self.item.ext.sar.apply(mode, frequency_band, polarizations, product_type, center_frequency, + resolution_range, resolution_azimuth, pixel_spacing_range, + pixel_spacing_azimuth, looks_range, looks_azimuth, + looks_equivalent_number, observation_direction) + + self.assertEqual(center_frequency, self.item.ext.sar.center_frequency) + self.assertIn(sar.CENTER_FREQUENCY, self.item.properties) + + self.assertEqual(resolution_range, self.item.ext.sar.resolution_range) + self.assertIn(sar.RESOLUTION_RANGE, self.item.properties) + + self.assertEqual(resolution_azimuth, self.item.ext.sar.resolution_azimuth) + self.assertIn(sar.RESOLUTION_AZIMUTH, self.item.properties) + + self.assertEqual(pixel_spacing_range, self.item.ext.sar.pixel_spacing_range) + self.assertIn(sar.PIXEL_SPACING_RANGE, self.item.properties) + + self.assertEqual(pixel_spacing_azimuth, self.item.ext.sar.pixel_spacing_azimuth) + self.assertIn(sar.PIXEL_SPACING_AZIMUTH, self.item.properties) + + self.assertEqual(looks_range, self.item.ext.sar.looks_range) + self.assertIn(sar.LOOKS_RANGE, self.item.properties) + + self.assertEqual(looks_azimuth, self.item.ext.sar.looks_azimuth) + self.assertIn(sar.LOOKS_AZIMUTH, self.item.properties) + + self.assertEqual(looks_equivalent_number, self.item.ext.sar.looks_equivalent_number) + self.assertIn(sar.LOOKS_EQUIVALENT_NUMBER, self.item.properties) + + self.assertEqual(observation_direction, self.item.ext.sar.observation_direction) + self.assertIn(sar.OBSERVATION_DIRECTION, self.item.properties) + + self.item.validate() + + +if __name__ == '__main__': + unittest.main()