Skip to content

Commit 2abd378

Browse files
Merge pull request #13 from EOPF-Sample-Service/sentinel-3
Add support for Sentinel 3 OLCI and SLSTR products
2 parents e39bd9c + 0125e69 commit 2abd378

39 files changed

+263986
-307
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/).
66

7+
## [Unreleased]
8+
9+
### Added
10+
11+
- Add support for the following Sentinel 3 OLCI products: `S03OLCEFR`, `S03OLCERR`, `S03OLCLFR`, `S03OLCLRR` [#9](https://github.com/EOPF-Sample-Service/eopf-stac/issues/9)
12+
- Add support for the following Sentinel 3 SLSTR products: `S03SLSRBT`, `S03SLSLST` [#11](https://github.com/EOPF-Sample-Service/eopf-stac/issues/11)
13+
- Add tests [#10](https://github.com/EOPF-Sample-Service/eopf-stac/issues/10)
14+
715
## [0.8.0] - 2025-04-15
816

917
### Added

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "eopf-stac"
7-
version = "0.8.0"
7+
version = "0.9.0"
88
authors = [
99
{ name="Mario Winkler", email="[email protected]" }
1010
]
@@ -21,6 +21,7 @@ dependencies = [
2121
"requests",
2222
"s3fs",
2323
"pystac",
24+
"stactools-sentinel3",
2425
"stactools-sentinel2",
2526
"stactools-sentinel1",
2627
"geojson",

src/eopf_stac/constants.py renamed to src/eopf_stac/common/constants.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pystac.link import Link
77
from pystac.provider import ProviderRole
88

9-
from eopf_stac.eopf_xarray import EopfXarrayBackendConfig, OpMode
9+
from eopf_stac.common.eopf_xarray import EopfXarrayBackendConfig, OpMode
1010

1111
SUPPORTED_PRODUCT_TYPES_S1 = [
1212
"S01SIWGRH",
@@ -22,7 +22,38 @@
2222
"S01SWVOCN",
2323
]
2424
SUPPORTED_PRODUCT_TYPES_S2 = ["S02MSIL1C", "S02MSIL2A"]
25-
SUPPORTED_PRODUCT_TYPES_S3 = []
25+
26+
SUPPORTED_S3_OLCI_L1_PRODUCT_TYPES = ["S03OLCEFR", "S03OLCERR"]
27+
SUPPORTED_S3_OLCI_L2_PRODUCT_TYPES = ["S03OLCLFR", "S03OLCLRR"]
28+
SUPPORTED_S3_SLSTR_L1_PRODUCT_TYPES = ["S03SLSRBT"]
29+
SUPPORTED_S3_SLSTR_L2_LST_PRODUCT_TYPE = ["S03SLSLST"]
30+
# SUPPORTED_S3_SLSTR_L2_FRP_PRODUCT_TYPE = ["S03SLSFRP"] # conversion error
31+
SUPPORTED_PRODUCT_TYPES_S3 = (
32+
SUPPORTED_S3_OLCI_L1_PRODUCT_TYPES
33+
+ SUPPORTED_S3_OLCI_L2_PRODUCT_TYPES
34+
+ SUPPORTED_S3_SLSTR_L1_PRODUCT_TYPES
35+
+ SUPPORTED_S3_SLSTR_L2_LST_PRODUCT_TYPE
36+
)
37+
38+
# Other Sentinen-3 product types to support
39+
SUPPORTED_S3_SRAL_L1_PRODUCT_TYPES = ["S03AHRL1B"] # sentinel-3-sra-l1b
40+
SUPPORTED_S3_SRAL_L2_PRODUCT_TYPES = ["S03AHRL2H"] # sentinel-3-sra-l2-lan-hy
41+
SUPPORTED_S3_SYN_L2_PRODUCT_TYPES = [
42+
"S03SYNAOD",
43+
"S03SYNSDR",
44+
"S03SYNV10",
45+
"S03SYNVG1",
46+
"S03SYNVGK",
47+
"S03SYNVGP",
48+
] # sentinel-3-syn-l2-aod, sentinel-3-syn-l2, sentinel-3-syn-l2-v10, sentinel-3-syn-l2-vg1, ?, sentinel-3-syn-l2-vgp
49+
50+
51+
# other SRAL listed in [1]
52+
# - S03AHRL1A (SR_1_SRA_A_), S03ALRL1A (SR_1_SRA_A_), S03ALRL1B (SR_1_SRA_BS), S03ALRL2H (SR_2_LAN_HY)
53+
# other SYN listed in [1]: S03SYNMIS
54+
55+
# [1] https://cpm.pages.eopf.copernicus.eu/eopf-cpm/main/PSFD/3-product-types-naming-rules.html
56+
2657
PRODUCT_TYPE_TO_COLLECTION: Final[dict] = {
2758
"S01SIWGRH": "sentinel-1-l1-grd",
2859
"S01SSMGRH": "sentinel-1-l1-grd",
@@ -37,6 +68,13 @@
3768
"S01SWVOCN": "sentinel-1-l2-ocn",
3869
"S02MSIL1C": "sentinel-2-l1c",
3970
"S02MSIL2A": "sentinel-2-l2a",
71+
"S03OLCEFR": "sentinel-3-olci-l1-efr",
72+
"S03OLCERR": "sentinel-3-olci-l1-err",
73+
"S03OLCLFR": "sentinel-3-olci-l2-lfr",
74+
"S03OLCLRR": "sentinel-3-olci-l2-lrr",
75+
"S03SLSRBT": "sentinel-3-slstr-l1-rbt",
76+
"S03SLSFRP": "sentinel-3-slstr-l2-frp",
77+
"S03SLSLST": "sentinel-3-slstr-l2-lst",
4078
}
4179

4280
MEDIA_TYPE_ZARR = "application/vnd+zarr"
@@ -72,6 +110,13 @@
72110
roles=[ProviderRole.HOST, ProviderRole.PROCESSOR],
73111
)
74112

113+
THUMBNAIL_ASSET: pystac.Asset = pystac.Asset(
114+
href="",
115+
title="",
116+
media_type="image/png",
117+
roles=["thumbnail"],
118+
)
119+
75120
PRODUCT_METADATA_PATH: Final[str] = ".zmetadata"
76121
PRODUCT_METADATA_ASSET_KEY: Final[str] = "product_metadata"
77122

@@ -102,3 +147,8 @@ def get_item_asset_product():
102147
roles=[ROLE_DATA, ROLE_METADATA],
103148
extra_fields=deepcopy(PRODUCT_ASSET_EXTRA_FIELDS),
104149
)
150+
151+
152+
PRODUCT_EXTENSION_SCHEMA_URI = "https://stac-extensions.github.io/product/v0.1.0/schema.json"
153+
PROCESSING_EXTENSION_SCHEMA_URI = "https://stac-extensions.github.io/processing/v1.2.0/schema.json"
154+
EOPF_EXTENSION_SCHEMA_URI = "https://cs-si.github.io/eopf-stac-extension/v1.2.0/schema.json"
File renamed without changes.

src/eopf_stac/common/stac.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import os
2+
3+
import pystac
4+
from pystac.extensions.eo import EOExtension
5+
from pystac.extensions.sat import OrbitState, SatExtension
6+
from pystac.extensions.timestamps import TimestampsExtension
7+
from pystac.utils import now_in_utc, str_to_datetime
8+
9+
from eopf_stac.common.constants import (
10+
EOPF_EXTENSION_SCHEMA_URI,
11+
PROCESSING_EXTENSION_SCHEMA_URI,
12+
PRODUCT_EXTENSION_SCHEMA_URI,
13+
)
14+
15+
16+
def validate_metadata(metadata: dict) -> dict:
17+
stac_discovery = metadata.get("metadata", {}).get(".zattrs", {}).get("stac_discovery")
18+
if stac_discovery is None:
19+
raise ValueError("JSON object 'stac_discovery' not found in .zmetadata file")
20+
21+
other_metadata = metadata.get("metadata", {}).get(".zattrs", {}).get("other_metadata")
22+
if other_metadata is None:
23+
raise ValueError("JSON object 'other_metadata' not found in .zmetadata file")
24+
25+
return metadata["metadata"]
26+
27+
28+
def rearrange_bbox(bbox):
29+
longitudes = [bbox[0], bbox[2]]
30+
latitudes = [bbox[1], bbox[3]]
31+
32+
corrected_bbox = [min(longitudes), min(latitudes), max(longitudes), max(latitudes)]
33+
return corrected_bbox
34+
35+
36+
def get_identifier(stac_discovery: dict):
37+
item_id = stac_discovery.get("id")
38+
# CPM workaround for https://gitlab.eopf.copernicus.eu/cpm/eopf-cpm/-/issues/690
39+
if item_id.lower().endswith(".safe") or item_id.lower().endswith(".sen3"):
40+
item_id = os.path.splitext(item_id)[0]
41+
return item_id
42+
43+
44+
def get_datetimes(properties: dict):
45+
datetime = None
46+
start_datetime = None
47+
end_datetime = None
48+
datetime_str = properties.get("datetime")
49+
if datetime_str is not None:
50+
# CPM workaround for https://gitlab.eopf.copernicus.eu/cpm/eopf-cpm/-/issues/643
51+
if datetime_str == "null":
52+
datetime = None
53+
else:
54+
datetime = str_to_datetime(datetime_str)
55+
56+
if datetime is None:
57+
# start_datetime and end_datetime must be supplied
58+
start_datetime_str = properties.get("start_datetime")
59+
if start_datetime_str is not None:
60+
start_datetime = str_to_datetime(start_datetime_str)
61+
datetime = start_datetime
62+
end_datetime_str = properties.get("end_datetime")
63+
if end_datetime_str is not None:
64+
end_datetime = str_to_datetime(end_datetime_str)
65+
66+
return (datetime, start_datetime, end_datetime)
67+
68+
69+
def fill_timestamp_properties(item: pystac.Item, properties: dict) -> None:
70+
created_datetime = properties.get("created")
71+
if created_datetime is None:
72+
created_datetime = now_in_utc()
73+
else:
74+
created_datetime = str_to_datetime(created_datetime)
75+
item.common_metadata.created = created_datetime
76+
item.common_metadata.updated = created_datetime
77+
78+
ts_ext = TimestampsExtension.ext(item, add_if_missing=True)
79+
ts_ext.apply(published=created_datetime)
80+
81+
82+
def fill_sat_properties(item: pystac.Item, properties: dict) -> None:
83+
orbit_state = properties.get("sat:orbit_state")
84+
abs_orbit = properties.get("sat:absolute_orbit")
85+
rel_orbit = properties.get("sat:relative_orbit")
86+
anx_datetime = properties.get("sat:anx_datetime")
87+
platform_international_designator = properties.get("sat:platform_international_designator")
88+
89+
if any_not_none([orbit_state, abs_orbit, rel_orbit, anx_datetime, platform_international_designator]):
90+
sat_ext = SatExtension.ext(item, add_if_missing=True)
91+
if orbit_state:
92+
sat_ext.orbit_state = OrbitState(orbit_state.lower())
93+
if abs_orbit:
94+
sat_ext.absolute_orbit = int(abs_orbit)
95+
if rel_orbit:
96+
sat_ext.relative_orbit = int(rel_orbit)
97+
if anx_datetime:
98+
sat_ext.anx_datetime = str_to_datetime(anx_datetime)
99+
if platform_international_designator:
100+
sat_ext.platform_international_designator = platform_international_designator
101+
102+
103+
def fill_eo_properties(item: pystac.Item, properties: dict) -> None:
104+
cloud_cover = properties.get("eo:cloud_cover")
105+
snow_cover = properties.get("eo:snow_cover")
106+
107+
if any_not_none([cloud_cover, snow_cover]):
108+
eo = EOExtension.ext(item, add_if_missing=True)
109+
if cloud_cover is not None:
110+
eo.cloud_cover = cloud_cover
111+
if snow_cover is not None:
112+
eo.snow_cover = snow_cover
113+
114+
115+
def fill_processing_properties(item: pystac.Item, properties: dict) -> None:
116+
# CPM workaround: following invalid values are ignored:
117+
# "processing:expression": "systematic",
118+
# "processing:facility": "OPE,OPE,OPE",
119+
# "processing:version": "",
120+
121+
proc_expression = properties.get("processing:expression")
122+
proc_lineage = properties.get("processing:lineage")
123+
proc_level = properties.get("processing:level")
124+
proc_facility = properties.get("processing:facility")
125+
proc_datetime = properties.get("processing:datetime")
126+
proc_version = properties.get("processing:version")
127+
proc_software = properties.get("processing:software")
128+
if any_not_none(
129+
[proc_expression, proc_facility, proc_level, proc_lineage, proc_software, proc_datetime, proc_version]
130+
):
131+
item.stac_extensions.append(PROCESSING_EXTENSION_SCHEMA_URI)
132+
if proc_expression is not None and proc_expression != "systematic":
133+
item.properties["processing:expression"] = proc_expression
134+
if proc_software is not None:
135+
item.properties["processing:software"] = proc_software
136+
if proc_datetime is not None:
137+
item.properties["processing:datetime"] = proc_datetime
138+
if is_valid_string(proc_facility) and proc_facility != "OPE,OPE,OPE":
139+
item.properties["processing:facility"] = proc_facility
140+
if is_valid_string(proc_level):
141+
item.properties["processing:level"] = proc_level
142+
if is_valid_string(proc_lineage):
143+
item.properties["processing:lineage"] = proc_lineage
144+
if is_valid_string(proc_version):
145+
item.properties["processing:version"] = proc_version
146+
147+
148+
def fill_product_properties(item: pystac.Item, product_type: str, properties: dict) -> None:
149+
product_timeliness = properties.get("product:timeliness")
150+
product_timeliness_category = properties.get("product:timeliness_category")
151+
product_acquisition_type = properties.get("product:acquisition_type")
152+
if any([product_type, product_acquisition_type, all([product_timeliness, product_timeliness_category])]):
153+
item.stac_extensions.append(PRODUCT_EXTENSION_SCHEMA_URI)
154+
if is_valid_string(product_type):
155+
item.properties["product:type"] = product_type
156+
if is_valid_string(product_acquisition_type):
157+
item.properties["product:acquisition_type"] = product_acquisition_type
158+
if all([is_valid_string(product_timeliness), is_valid_string(product_timeliness_category)]):
159+
# CPM workaround for https://gitlab.eopf.copernicus.eu/cpm/eopf-cpm/-/issues/706
160+
if product_timeliness != "MISSING":
161+
item.properties["product:timeliness"] = product_timeliness
162+
item.properties["product:timeliness_category"] = product_timeliness_category
163+
164+
165+
def fill_eopf_properties(item: pystac.Item, properties: dict) -> None:
166+
"""Fills the item with values of the EOPF STAC extension
167+
See also: https://github.com/CS-SI/eopf-stac-extension
168+
"""
169+
datatake_id = properties.get("eopf:datatake_id")
170+
# CPM workaround for https://gitlab.eopf.copernicus.eu/cpm/eopf-cpm/-/issues/689
171+
if datatake_id is None:
172+
datatake_id = properties.get("eopf:data_take_id")
173+
datastrip_id = properties.get("eopf:datastrip_id")
174+
instrument_mode = properties.get("eopf:instrument_mode")
175+
origin_datetime = properties.get("eopf:origin_datetime")
176+
instrument_configuration_id = properties.get("eopf:instrument_configuration_id")
177+
178+
if any_not_none(
179+
[
180+
datatake_id,
181+
datastrip_id,
182+
instrument_mode,
183+
origin_datetime,
184+
instrument_configuration_id,
185+
]
186+
):
187+
item.stac_extensions.append(EOPF_EXTENSION_SCHEMA_URI)
188+
if is_valid_string(datatake_id):
189+
item.properties["eopf:datatake_id"] = datatake_id
190+
if is_valid_string(instrument_mode):
191+
# CPM workaround
192+
if instrument_mode != "Earth Observation":
193+
item.properties["eopf:instrument_mode"] = instrument_mode
194+
if origin_datetime:
195+
item.properties["eopf:origin_datetime"] = origin_datetime
196+
if is_valid_string(datastrip_id):
197+
item.properties["eopf:datastrip_id"] = datastrip_id
198+
if instrument_configuration_id is not None:
199+
item.properties["eopf:instrument_configuration_id"] = instrument_configuration_id
200+
201+
202+
def is_valid_string(value: str) -> bool:
203+
return value is not None and len(value) > 0
204+
205+
206+
def any_not_none(values: list) -> bool:
207+
for v in values:
208+
if v is not None:
209+
return True

0 commit comments

Comments
 (0)