From 5ef7de64b2f589d838e23ada582183fd8d652f75 Mon Sep 17 00:00:00 2001 From: jankovicgd Date: Mon, 3 Mar 2025 18:56:11 +0100 Subject: [PATCH 1/2] feat: added odata implementation --- src/eodm/extract.py | 20 +++++++- src/eodm/odata.py | 113 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 src/eodm/odata.py diff --git a/src/eodm/extract.py b/src/eodm/extract.py index ce2add9..8e6709b 100644 --- a/src/eodm/extract.py +++ b/src/eodm/extract.py @@ -3,6 +3,7 @@ import pystac_client from pystac import Collection, Item +from .odata import ODataClient, ODataCollection, ODataQuery from .opensearch import OpenSearchClient, OpenSearchFeature @@ -67,7 +68,7 @@ def extract_opensearch_features( Args: url (str): Link to OpenSearch API endpoint - productTypes (list[str]): List of productTypes to search for + product_types (list[str]): List of productTypes to search for Yields: Iterator[OpenSearchFeature]: OpenSearch Features @@ -83,3 +84,20 @@ def extract_opensearch_features( if limit and i >= limit: break yield feature + + +def extract_odata_products( + url: str, + collections: list[ODataCollection], +): + """Extracts OData Products from an OData API + + Args: + url (str): Link to OData API endpoint + collections (list[ODataCollection]): List of collections to search for + """ + client = ODataClient(url) + for collection in collections: + query = ODataQuery(collection=collection.value) + for product in client.search(query): + yield product diff --git a/src/eodm/odata.py b/src/eodm/odata.py new file mode 100644 index 0000000..7886bed --- /dev/null +++ b/src/eodm/odata.py @@ -0,0 +1,113 @@ +from datetime import datetime +from enum import Enum +from typing import Annotated, Iterator + +import httpx +from geojson_pydantic.geometries import Geometry +from pydantic import BaseModel, Field + + +class ODataCollection(Enum): + SENTINEL_1 = "SENTINEL-1" + SENTINEL_2 = "SENTINEL-2" + SENTINEL_3 = "SENTINEL-3" + SENTINEL_5P = "SENTINEL-5P" + SENTINEL_6 = "SENTINEL-6" + SENTINEL_1_RTC = "SENTINEL-1-RTC" + GLOBAL_MOSAICS = "GLOBAL-MOSAICS" + SMOS = "SMOS" + ENVISAT = "ENVISAT" + LANDSAT_5 = "LANDSAT-5" + LANDSAT_7 = "LANDSAT-7" + LANDSAT_8 = "LANDSAT-8" + COP_DEM = "COP-DEM" + TERRAAQUA = "TERRAAQUA" + S2GLC = "S2GLC" + CCM = "CCM" + + +class ODataChecksum(BaseModel): + value: Annotated[str, Field(alias="Value")] + algorithm: Annotated[str, Field(alias="Algorithm")] + checksum_date: Annotated[str, Field(alias="ChecksumDate")] + + +class ODataContentDate(BaseModel): + start: Annotated[str, Field(alias="Start")] + end: Annotated[str, Field(alias="End")] + + +class ODataProduct(BaseModel): + media_content_type: Annotated[str, Field(alias="@odata.mediaContentType")] + id: Annotated[str, Field(alias="Id")] + name: Annotated[str, Field(alias="Name")] + content_type: Annotated[str, Field(alias="ContentType")] + content_length: Annotated[int, Field(alias="ContentLength")] + origin_date: Annotated[str, Field(alias="OriginDate")] + publication_date: Annotated[str, Field(alias="PublicationDate")] + modification_date: Annotated[str, Field(alias="ModificationDate")] + online: Annotated[bool, Field(alias="Online")] + eviction_date: Annotated[str, Field(alias="EvictionDate")] + s3_path: Annotated[str, Field(alias="S3Path")] + checksum: Annotated[list[ODataChecksum], Field(alias="Checksum")] + footprint: Annotated[Geometry | None, Field(alias="Footprint")] + geo_footprint: Annotated[Geometry | None, Field(alias="GeoFootprint")] + + +class ODataResult(BaseModel): + odata_context: Annotated[str, Field(alias="@odata.context")] + value: list[ODataProduct] + odata_nextlink: Annotated[str, Field(alias="@odata.nextLink")] + + +class ODataQuery: + def __init__( + self, + collection: str | None = None, + name: str | None = None, + top: int = 20, + publication_date: tuple[datetime, datetime] | None = None, + sensing_date: tuple[datetime, datetime] | None = None, + intersect_geometry: Geometry | None = None, + ): + self.collection = collection + self.name = name + self.top = top + self.publication_date = publication_date + self.sensing_date = sensing_date + self.intersect_geometry = intersect_geometry + + def to_params(self) -> dict: + query = [] + if self.collection: + query.append(f"Collection/Name eq '{self.collection}'") + if self.name: + query.append("Name eq '{self.name}'") + if self.publication_date: + query.append( + f"PublicationDate ge {self.publication_date[0].isoformat()} and PublicationDate le {self.publication_date[1].isoformat()}" + ) + if self.sensing_date: + query.append( + f"ContentDate/Start ge {self.sensing_date[0].isoformat()} and ContentDate/Start le {self.sensing_date[1].isoformat()}" + ) + if self.intersect_geometry: + query.append( + f"OData.CSC.Intersects(area=geography'SRID=4326;{self.intersect_geometry.wkt})" + ) + + return { + "$filter": " and ".join(query), + "$top": self.top, + } + + +class ODataClient: + def __init__(self, url: str): + self.url = url + + def search(self, query: ODataQuery) -> Iterator[ODataProduct]: + response = httpx.get(self.url, params=query.to_params()) + + product_collection = ODataResult.model_validate(response.json()) + yield from product_collection.value From e6e1d01a5540a2fbd198ceca4c43b78454495107 Mon Sep 17 00:00:00 2001 From: jankovicgd Date: Tue, 4 Mar 2025 11:12:05 +0100 Subject: [PATCH 2/2] fix: added annotation for odataproduct --- src/eodm/extract.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/eodm/extract.py b/src/eodm/extract.py index 8e6709b..81b12f9 100644 --- a/src/eodm/extract.py +++ b/src/eodm/extract.py @@ -3,7 +3,7 @@ import pystac_client from pystac import Collection, Item -from .odata import ODataClient, ODataCollection, ODataQuery +from .odata import ODataClient, ODataCollection, ODataProduct, ODataQuery from .opensearch import OpenSearchClient, OpenSearchFeature @@ -89,7 +89,7 @@ def extract_opensearch_features( def extract_odata_products( url: str, collections: list[ODataCollection], -): +) -> Iterator[ODataProduct]: """Extracts OData Products from an OData API Args: