# Copyright 2026, SERTIT-ICube - France, https://sertit.unistra.fr/
# This file is part of eoreader project
# https://github.com/sertit/eoreader
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Sentinel-1 products"""
import logging
from datetime import datetime
from enum import unique
import geopandas as gpd
from lxml import etree
from sertit import path
from sertit.misc import ListEnum
from eoreader import DATETIME_FMT, EOREADER_NAME, cache
from eoreader.exceptions import InvalidProductError
from eoreader.products import SarProduct, SarProductType
from eoreader.products.product import OrbitDirection
from eoreader.utils import qck_wrapper
LOGGER = logging.getLogger(EOREADER_NAME)
[docs]
@unique
class S1ProductType(ListEnum):
"""
S1 products types. Take a look here:
https://sentinel.esa.int/web/sentinel/missions/sentinel-1/data-products
"""
RAW = "RAW"
"""Raw products (lvl 0): **not used by EOReader**"""
SLC = "SLC"
"""Single Look Complex (SLC, lvl 1)"""
GRD = "GRD"
"""Ground Range Detected (GRD, lvl 1, phase lost)"""
OCN = "OCN"
"""Ocean products (lvl 2): **not used by EOReader**"""
[docs]
@unique
class S1SensorMode(ListEnum):
"""
S1 sensor mode. Take a look here:
https://sentinel.esa.int/web/sentinel/user-guides/sentinel-1-sar/acquisition-modes
The primary conflict-free modes are IW, with VV+VH polarisation over land,
and WV, with VV polarisation, over open ocean.
EW mode is primarily used for wide area coastal monitoring including ship traffic, oil spill and sea-ice monitoring.
SM mode is only used for small islands and on request for extraordinary events such as emergency management.
"""
SM = "SM"
"""Stripmap (SM)"""
IW = "IW"
"""Interferometric Wide swath (IW)"""
EW = "EW"
"""Extra-Wide swath (EW)"""
WV = "WV"
"""Wave (WV) -> single polarisation only (HH or VV)"""
[docs]
@unique
class S1ResolutionClass(ListEnum):
"""
S1 resolution class:
https://sentinel.esa.int/web/sentinel/user-guides/sentinel-1-sar/resolutions/level-1-ground-range-detected
"""
FR = "F"
"""Full Resolution (FR)"""
HR = "H"
"""High Resolution (HR)"""
MR = "M"
"""Medium Resolution (MR)"""
NONE = "_"
"""No specified resolution for other product type than GRD"""
[docs]
class S1Product(SarProduct):
"""
Class for Sentinel-1 Products
You can use directly the .zip file
"""
def _set_pixel_size(self) -> None:
"""
Set product default pixel size (in meters)
See here
`here <https://sentinel.esa.int/web/sentinel/user-guides/sentinel-1-sar/resolutions/level-1-ground-range-detected>`_
for more information
"""
if self.product_type == S1ProductType.GRD:
res_class = S1ResolutionClass.from_value(self.split_name[2][-1])
else:
res_class = S1ResolutionClass.NONE
# Using the az resolution between rg and az
default_res = {
S1SensorMode.SM: {
S1ResolutionClass.NONE: 9.0,
S1ResolutionClass.FR: 9.0,
S1ResolutionClass.HR: 23.0,
S1ResolutionClass.MR: 84.0,
},
S1SensorMode.IW: {
S1ResolutionClass.NONE: 20.0,
S1ResolutionClass.HR: 20.0,
S1ResolutionClass.MR: 87.0,
},
S1SensorMode.EW: {
S1ResolutionClass.NONE: 50.0,
S1ResolutionClass.HR: 50.0,
S1ResolutionClass.MR: 87.0,
},
S1SensorMode.WV: {
S1ResolutionClass.NONE: 51.0,
S1ResolutionClass.MR: 51.0,
},
}
# Pixel sizes are squared -> no issue between rg and az
default_pix_size = {
S1SensorMode.SM: {
S1ResolutionClass.NONE: 3.5,
S1ResolutionClass.FR: 3.5,
S1ResolutionClass.HR: 10.0,
S1ResolutionClass.MR: 40.0,
},
S1SensorMode.IW: {
S1ResolutionClass.NONE: 20.0,
S1ResolutionClass.HR: 20.0,
S1ResolutionClass.MR: 40.0,
},
S1SensorMode.EW: {
S1ResolutionClass.NONE: 25.0,
S1ResolutionClass.HR: 25.0,
S1ResolutionClass.MR: 40.0,
},
S1SensorMode.WV: {
S1ResolutionClass.NONE: 25.0,
S1ResolutionClass.MR: 25.0,
},
}
try:
def_pixel_size = default_pix_size[self.sensor_mode][res_class]
def_res = default_res[self.sensor_mode][res_class]
except KeyError as exc:
raise InvalidProductError(
f"Unknown sensor mode: {self.sensor_mode}"
) from exc
self.pixel_size = def_pixel_size
self.resolution = def_res
def _set_instrument(self) -> None:
"""
Set instrument
Sentinel-1: https://sentinels.copernicus.eu/web/sentinel/missions/sentinel-1/instrument-payload
"""
self.instrument = "SAR C-band"
def _pre_init(self, **kwargs) -> None:
"""
Function used to pre_init the products
(setting needs_extraction and so on)
"""
# Private attributes
self._raw_band_regex = "*-{!l}-*.tiff" # Just get the SLC-iw1 image for now
self._band_folder = self.path.joinpath("measurement")
self.snap_filename = ""
# Its original filename is its name
self._use_filename = True
# Zipped and SNAP can process its archive
self.needs_extraction = False
# Pre init done by the super class
super()._pre_init(**kwargs)
# Check if COG in name (S1 needs always SNAP)
if "_COG" in self.filename and not self._has_snap_x_or_higher(10):
raise NotImplementedError(
"S1 COG products are only handled by SNAP 10.0 or higher. "
"Please upgrade your software to process this product."
)
def _post_init(self, **kwargs) -> None:
"""
Function used to post_init the products
(setting product-type, band names and so on)
"""
# Post init done by the super class
super()._post_init(**kwargs)
[docs]
@cache
def wgs84_extent(self) -> gpd.GeoDataFrame:
"""
Get the WGS84 extent of the file before any reprojection.
This is useful when the SAR pre-process has not been done yet.
.. code-block:: python
>>> from eoreader.reader import Reader
>>> path = r"S1A_IW_GRDH_1SDV_20191215T060906_20191215T060931_030355_0378F7_3696.zip"
>>> prod = Reader().open(path)
>>> prod.wgs84_extent()
Name ... geometry
0 Sentinel-1 Image Overlay ... POLYGON ((0.85336 42.24660, -2.32032 42.65493,...
[1 rows x 12 columns]
Returns:
gpd.GeoDataFrame: WGS84 extent as a gpd.GeoDataFrame
"""
try:
# Open the map-overlay file
extent_wgs84 = self._read_vector("*preview/map-overlay.kml")
if extent_wgs84.empty:
raise ValueError(
"Something went wrong when reading the 'map-overlay.kml' file"
)
except Exception:
# Sometimes, map-overlay.kml of S1 products cannot be read properly
extent_wgs84 = self._fallback_wgs84_extent("preview/map-overlay.kml")
return extent_wgs84
def _set_product_type(self) -> None:
"""Set products type"""
# Get MTD XML file
root, _ = self.read_mtd()
# Open identifier
prod_type = root.findtext(".//productType")
if not prod_type:
raise InvalidProductError("mode not found in metadata!")
self.product_type = S1ProductType.from_value(prod_type)
if self.product_type == S1ProductType.GRD:
self.sar_prod_type = SarProductType.GRD
elif self.product_type == S1ProductType.SLC:
self.sar_prod_type = SarProductType.CPLX
else:
raise NotImplementedError(
f"{self.product_type.value} product type is not available for {self.name}"
)
def _set_sensor_mode(self) -> None:
"""
Get products type from S1 products name (could check the metadata too)
"""
# Get MTD XML file
root, _ = self.read_mtd()
# Open identifier
mode = root.findtext(".//mode")
if not mode:
raise InvalidProductError("mode not found in metadata!")
# Mono swath SM
if mode in ["S1", "S2", "S3", "S4", "S5", "S6"]:
mode = "SM"
# Get sensor mode
self.sensor_mode = S1SensorMode.from_value(mode)
if not self.sensor_mode:
raise InvalidProductError(
f"Invalid {self.constellation.value} name: {self.name}"
)
[docs]
def get_datetime(self, as_datetime: bool = False) -> str | datetime:
"""
Get the product's acquisition datetime, with format :code:`YYYYMMDDTHHMMSS` <-> :code:`%Y%m%dT%H%M%S`
.. code-block:: python
>>> from eoreader.reader import Reader
>>> path = r"S1A_IW_GRDH_1SDV_20191215T060906_20191215T060931_030355_0378F7_3696.zip"
>>> prod = Reader().open(path)
>>> prod.get_datetime(as_datetime=True)
datetime.datetime(2019, 12, 15, 6, 9, 6)
>>> prod.get_datetime(as_datetime=False)
'20191215T060906'
Args:
as_datetime (bool): Return the date as a datetime.datetime. If false, returns a string.
Returns:
str | dt.datetime: Its acquisition datetime
"""
if self.datetime is None:
# Get MTD XML file
root, _ = self.read_mtd()
# Open identifier
acq_date = root.findtext(".//startTime")
if not acq_date:
raise InvalidProductError("startTime not found in metadata!")
# Convert to datetime
date = datetime.strptime(acq_date, "%Y-%m-%dT%H:%M:%S.%f")
else:
date = self.datetime
if not as_datetime:
date = date.strftime(DATETIME_FMT)
return date
def _get_name_constellation_specific(self) -> str:
"""
Set product real name from metadata
Returns:
str: True name of the product (from metadata)
"""
try:
pdf_file = self._glob("*.pdf")
except FileNotFoundError:
# The name is not in the classic metadata, but can be found in the product-preview
try:
mtd_from_path = "preview/product-preview.html"
mtd_archived = r"preview.*product-preview\.html"
root = self._read_mtd_html(mtd_from_path, mtd_archived)
# Open identifier
name = root.findtext(".//head/title")
if not name:
raise InvalidProductError("title not found in metadata!")
LOGGER.warning(
"Product filename is not a valid Sentinel-1 name, and the retrieved name is missing the unique ID."
)
except InvalidProductError as exc:
raise InvalidProductError(
"product-preview.html not found in the product, the name will be the filename (which is not a valid Sentinel-1 name)"
) from exc
else:
name = path.get_filename(pdf_file)
if ".SAFE" in name:
name = name.split(".SAFE")[0]
return name
@cache
def _read_mtd(self) -> (etree._Element, dict):
"""
Read metadata and outputs the metadata XML root and its namespaces as a dict
.. code-block:: python
>>> from eoreader.reader import Reader
>>> path = r"S1A_IW_GRDH_1SDV_20191215T060906_20191215T060931_030355_0378F7_3696.zip"
>>> prod = Reader().open(path)
>>> prod.read_mtd()
(<Element product at 0x1832895d788>, {})
Returns:
(etree._Element, dict): Metadata XML root and its namespaces
"""
mtd_from_path = "annotation/*.xml"
# When archived, other XML (in calibration folder) can be found
mtd_archived = r"annotation/(?!rfi)(?!cal)(?!noise).*\.xml"
return self._read_mtd_xml(mtd_from_path, mtd_archived)
[docs]
@qck_wrapper
def get_quicklook_path(self) -> str:
"""
Get quicklook path if existing.
Returns:
str: Quicklook path
"""
return self._glob("preview/quick-look.png")
[docs]
@cache
def get_orbit_direction(self) -> OrbitDirection:
"""
Get cloud cover as given in the metadata
.. code-block:: python
>>> from eoreader.reader import Reader
>>> path = r"S2A_MSIL1C_20200824T110631_N0209_R137_T30TTK_20200824T150432.SAFE.zip"
>>> prod = Reader().open(path)
>>> prod.get_orbit_direction().value
"DESCENDING"
Returns:
OrbitDirection: Orbit direction (ASCENDING/DESCENDING)
"""
# Get MTD XML file
root, _ = self.read_mtd()
# Get the orbit direction
try:
od = OrbitDirection.from_value(root.findtext(".//pass").upper())
except TypeError as exc:
raise InvalidProductError("pass not found in metadata!") from exc
return od