# Copyright 2025, 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-3 OLCI products
.. WARNING:
Not georeferenced NetCDF files are badly opened by GDAL and therefore by rasterio!
-> use xr.open_dataset that manages that correctly
"""
import logging
from typing import Union
import numpy as np
import rasterio
import xarray as xr
from rasterio.enums import Resampling
from sertit import rasters_rio
from sertit.types import AnyPathStrType, AnyPathType
from eoreader import EOREADER_NAME, cache, utils
from eoreader.bands import (
BLUE,
CA,
GREEN,
GREEN_1,
NARROW_NIR,
NIR,
RED,
VRE_1,
VRE_2,
VRE_3,
WV,
YELLOW,
BandNames,
Oa01,
Oa02,
Oa09,
Oa10,
Oa13,
Oa14,
Oa15,
Oa18,
Oa19,
Oa21,
SpectralBand,
)
from eoreader.exceptions import InvalidTypeError
from eoreader.products import S3DataType, S3Product, S3ProductType
from eoreader.stac import CENTER_WV, DESCRIPTION, FWHM, GSD, ID, NAME
LOGGER = logging.getLogger(EOREADER_NAME)
# FROM SNAP:
# https://github.com/senbox-org/s3tbx/blob/197c9a471002eb2ec1fbd54e9a31bfc963446645/s3tbx-rad2refl/src/main/java/org/esa/s3tbx/processor/rad2refl/Rad2ReflConstants.java#L97
# Not used for now
OLCI_SOLAR_FLUXES_DEFAULT = {
Oa01: 1714.9084,
Oa02: 1872.3961,
CA: 1926.6102,
BLUE: 1930.2483,
GREEN_1: 1804.2762,
GREEN: 1651.5836,
YELLOW: 1531.4067,
RED: 1475.615,
Oa09: 1408.9949,
Oa10: 1265.5425,
VRE_1: 1255.4227,
VRE_2: 1178.0286,
Oa13: 955.07043,
Oa14: 914.18945,
Oa15: 882.8275,
VRE_3: 882.8275,
NIR: 882.8275,
NARROW_NIR: 882.8275,
Oa18: 882.8275,
Oa19: 882.8275,
WV: 882.8275,
Oa21: 882.8275,
}
[docs]
class S3OlciProduct(S3Product):
"""
Class of Sentinel-3 OLCI Products
"""
[docs]
def __init__(
self,
product_path: AnyPathStrType,
archive_path: AnyPathStrType = None,
output_path: AnyPathStrType = None,
remove_tmp: bool = False,
**kwargs,
) -> None:
"""
https://sentinels.copernicus.eu/web/sentinel/technical-guides/sentinel-3-slstr/level-1/observation-mode-desc
Note that the name of each netCDF file provides information about its content.
"""
super().__init__(
product_path, archive_path, output_path, remove_tmp, **kwargs
) # Order is important here, gcps NEED to be after this
def _get_preprocessed_band_path(
self,
band: Union[BandNames, str],
pixel_size: Union[float, tuple, list] = None,
writable=True,
) -> AnyPathType:
"""
Create the pre-processed band path
Args:
band (band: Union[BandNames, str]): Wanted band (quality flags accepted)
pixel_size (Union[float, tuple, list]): Resolution of the wanted UTM band
writable (bool): Do we need to write the pre-processed band?
Returns:
AnyPathType: Pre-processed band path
"""
res_str = self._pixel_size_to_str(pixel_size)
band_str = band.name if isinstance(band, BandNames) else band
return self._get_band_folder(writable=writable).joinpath(
f"{self.condensed_name}_{band_str}_{res_str}.tif"
)
def _set_preprocess_members(self):
"""Set pre-process members"""
# Radiance bands
self._radiance_file = "{band}_radiance.nc"
self._radiance_subds = "{band}_radiance"
# Geocoding
self._geo_file = "geo_coordinates.nc"
self._lat_nc_name = "latitude"
self._lon_nc_name = "longitude"
self._alt_nc_name = "altitude"
# Tie geocoding
self._tie_geo_file = "tie_geo_coordinates.nc"
self._tie_lat_nc_name = "latitude"
self._tie_lon_nc_name = "longitude"
# Mean Sun angles
self._geom_file = "tie_geometries.nc"
self._saa_name = "SAA"
self._sza_name = "SZA"
# Rad 2 Refl
self._misc_file = "instrument_data.nc"
self._solar_flux_name = "solar_flux"
self._det_index = "detector_index"
def _pre_init(self, **kwargs) -> None:
"""
Function used to pre_init the products
(setting needs_extraction and so on)
"""
self.needs_extraction = False
# Pre init done by the super class
super()._pre_init(**kwargs)
def _set_pixel_size(self) -> None:
"""
Set product default pixel size (in meters)
"""
self.pixel_size = 300.0
def _set_product_type(self) -> None:
"""
Set products type
More on spectral bands `here <https://sentinel.esa.int/web/sentinel/user-guides/sentinel-3-olci/resolutions/radiometric>`_.
"""
# Product type
if self.name[7] != "1":
raise InvalidTypeError("Only L1 products are used for Sentinel-3 data.")
self.product_type = S3ProductType.OLCI_EFR
self._data_type = S3DataType.EFR
def _map_bands(self) -> None:
"""
Map bands
"""
# Bands
olci_bands = {
Oa01: SpectralBand(
eoreader_name=Oa01,
**{
NAME: "Oa01",
ID: 1,
GSD: self.pixel_size,
CENTER_WV: 400,
FWHM: 15,
DESCRIPTION: "Aerosol correction, improved water constituent retrieval",
},
),
Oa02: SpectralBand(
eoreader_name=Oa02,
**{
NAME: "Oa02",
ID: 2,
GSD: self.pixel_size,
CENTER_WV: 412.5,
FWHM: 10,
DESCRIPTION: "Yellow substance and detrital pigments (turbidity)",
},
),
CA: SpectralBand(
eoreader_name=CA,
**{
NAME: "Oa03",
ID: 3,
GSD: self.pixel_size,
CENTER_WV: 442.5,
FWHM: 10,
DESCRIPTION: "Chlorophyll absorption maximum, biogeochemistry, vegetation",
},
),
BLUE: SpectralBand(
eoreader_name=BLUE,
**{
NAME: "Oa04",
ID: 4,
GSD: self.pixel_size,
CENTER_WV: 490,
FWHM: 10,
DESCRIPTION: "High Chlorophyll",
},
),
GREEN_1: SpectralBand(
eoreader_name=GREEN_1,
**{
NAME: "Oa05",
ID: 5,
GSD: self.pixel_size,
CENTER_WV: 510,
FWHM: 10,
DESCRIPTION: "Chlorophyll, sediment, turbidity, red tide",
},
),
GREEN: SpectralBand(
eoreader_name=GREEN,
**{
NAME: "Oa06",
ID: 6,
GSD: self.pixel_size,
CENTER_WV: 560,
FWHM: 10,
DESCRIPTION: "Chlorophyll reference (Chlorophyll minimum)",
},
),
YELLOW: SpectralBand(
eoreader_name=YELLOW,
**{
NAME: "Oa07",
ID: 7,
GSD: self.pixel_size,
CENTER_WV: 620,
FWHM: 10,
DESCRIPTION: "Sediment loading",
},
),
RED: SpectralBand(
eoreader_name=RED,
**{
NAME: "Oa08",
ID: 8,
GSD: self.pixel_size,
CENTER_WV: 665,
FWHM: 10,
DESCRIPTION: "Chlorophyll (2nd Chlorophyll absorption maximum), sediment, yellow substance / vegetation",
},
),
Oa09: SpectralBand(
eoreader_name=Oa09,
**{
NAME: "Oa09",
ID: 9,
GSD: self.pixel_size,
CENTER_WV: 673.75,
FWHM: 7.5,
DESCRIPTION: "For improved fluorescence retrieval and to better account for smile together with the bands 665 and 680 nm",
},
),
Oa10: SpectralBand(
eoreader_name=Oa10,
**{
NAME: "Oa10",
ID: 10,
GSD: self.pixel_size,
CENTER_WV: 681.25,
FWHM: 7.5,
DESCRIPTION: "Chlorophyll fluorescence peak, red edge",
},
),
VRE_1: SpectralBand(
eoreader_name=VRE_1,
**{
NAME: "Oa11",
ID: 11,
GSD: self.pixel_size,
CENTER_WV: 708.75,
FWHM: 10,
DESCRIPTION: "Chlorophyll fluorescence baseline, red edge transition",
},
),
VRE_2: SpectralBand(
eoreader_name=VRE_2,
**{
NAME: "Oa12",
ID: 12,
GSD: self.pixel_size,
CENTER_WV: 753.75,
FWHM: 7.5,
DESCRIPTION: "O2 absorption/clouds, vegetation",
},
),
Oa13: SpectralBand(
eoreader_name=Oa13,
**{
NAME: "Oa13",
ID: 13,
GSD: self.pixel_size,
CENTER_WV: 761.25,
FWHM: 2.5,
DESCRIPTION: "O2 absorption band/aerosol correction.",
},
),
Oa14: SpectralBand(
eoreader_name=Oa14,
**{
NAME: "Oa14",
ID: 14,
GSD: self.pixel_size,
CENTER_WV: 764.375,
FWHM: 3.75,
DESCRIPTION: "Atmospheric correction",
},
),
Oa15: SpectralBand(
eoreader_name=Oa15,
**{
NAME: "Oa15",
ID: 15,
GSD: self.pixel_size,
CENTER_WV: 767.5,
FWHM: 2.5,
DESCRIPTION: "O2A used for cloud top pressure, fluorescence over land",
},
),
VRE_3: SpectralBand(
eoreader_name=VRE_3,
**{
NAME: "Oa16",
ID: 16,
GSD: self.pixel_size,
CENTER_WV: 778.75,
FWHM: 15,
DESCRIPTION: "Atmos. corr./aerosol corr.",
},
),
NIR: SpectralBand(
eoreader_name=NIR,
**{
NAME: "Oa17",
ID: 17,
GSD: self.pixel_size,
CENTER_WV: 865,
FWHM: 20,
DESCRIPTION: "Atmospheric correction/aerosol correction, clouds, pixel co-registration",
},
),
NARROW_NIR: SpectralBand(
eoreader_name=NARROW_NIR,
**{
NAME: "Oa17",
ID: 17,
GSD: self.pixel_size,
CENTER_WV: 865,
FWHM: 20,
DESCRIPTION: "Atmospheric correction/aerosol correction, clouds, pixel co-registration",
},
),
Oa18: SpectralBand(
eoreader_name=Oa18,
**{
NAME: "Oa18",
ID: 18,
GSD: self.pixel_size,
CENTER_WV: 885,
FWHM: 10,
DESCRIPTION: "Water vapour absorption reference band. Common reference band with SLSTR instrument. Vegetation monitoring",
},
),
Oa19: SpectralBand(
eoreader_name=Oa19,
**{
NAME: "Oa19",
ID: 19,
GSD: self.pixel_size,
CENTER_WV: 900,
FWHM: 10,
DESCRIPTION: "Water vapour absorption/vegetation monitoring (maximum reflectance)",
},
),
WV: SpectralBand(
eoreader_name=WV,
**{
NAME: "Oa20",
ID: 20,
GSD: self.pixel_size,
CENTER_WV: 940,
FWHM: 20,
DESCRIPTION: "Water vapour absorption, Atmospheric correction/aerosol correction",
},
),
Oa21: SpectralBand(
eoreader_name=Oa21,
**{
NAME: "Oa21",
ID: 21,
GSD: self.pixel_size,
CENTER_WV: 1020,
FWHM: 40,
DESCRIPTION: "Atmospheric correction/aerosol correction",
},
),
}
self.bands.map_bands(olci_bands)
[docs]
def get_raw_band_paths(self, **kwargs) -> dict:
"""
Return the raw band paths.
Args:
kwargs: Additional arguments
Returns:
dict: Dictionary containing the path of each queried band
"""
raw_band_paths = {}
for band in self.get_existing_bands():
# Get band filename and subdataset
filename = self._replace(self._radiance_file, band=self.bands[band].name)
if self.is_archived:
raw_path = self._get_archived_path(f".*{filename}")
else:
try:
raw_path = next(self.path.glob(f"*{filename}"))
except StopIteration as exc:
raise FileNotFoundError(
f"Non existing file {filename} in {self.path}"
) from exc
raw_band_paths[band] = raw_path
return raw_band_paths
def _preprocess(
self,
band: Union[BandNames, str],
pixel_size: float = None,
to_reflectance: bool = True,
subdataset: str = None,
**kwargs,
) -> AnyPathType:
"""
Pre-process S3 OLCI bands:
- Convert radiance to reflectance
- Geocode
Args:
band (Union[BandNames, str]): Band to preprocess (quality flags or others are accepted)
pixel_size (float): Pixl size
to_reflectance (bool): Convert band to reflectance
subdataset (str): Subdataset
kwargs: Other arguments used to load bands
Returns:
dict: Dictionary containing {band: path}
"""
band_str = band if isinstance(band, str) else band.name
pp_path = self._get_preprocessed_band_path(
band, pixel_size=pixel_size, writable=False
)
if not pp_path.is_file():
pp_path = self._get_preprocessed_band_path(
band, pixel_size=pixel_size, writable=True
)
# Get band regex
if isinstance(band, BandNames):
band_name = self.bands[band].name
if not subdataset:
subdataset = self._replace(self._radiance_subds, band=band_name)
filename = self._replace(self._radiance_file, band=band_name)
else:
filename = band
# Get raw band
band_arr = self._read_nc(
filename, subdataset, dtype=kwargs.get("dtype", np.float32)
)
# Convert radiance to reflectances if needed
# Convert first pixel by pixel before reprojection!
if to_reflectance:
LOGGER.debug(f"Converting {band_str} to reflectance")
band_arr = self._rad_2_refl(band_arr, band)
# Geocode
LOGGER.debug(f"Geocoding {band_str}")
band_arr = self._geocode(band_arr, pixel_size=pixel_size, **kwargs)
# Write on disk
band_arr = utils.write_path_in_attrs(band_arr, pp_path)
utils.write(band_arr, pp_path, dtype=kwargs.get("dtype", np.float32))
return pp_path
def _rad_2_refl(
self, band_arr: xr.DataArray, band: BandNames = None
) -> xr.DataArray:
"""
Convert radiance to reflectance
The Level 1 product provides measurements of top of atmosphere (ToA) radiances (mW/m2/sr/nm). These
values can be converted to normalised reflectance for better comparison or merging of data with different
sun angles as follows:
reflectance = π* (ToA radiance / solar irradiance / cos(solar zenith angle))
where the solar irradiance at ToA is given in the ‘solar_flux’ dataset (instrument_data.nc file) for each
detector and each channel, and the solar zenith angle is given at Tie Points in the ‘SZA’ dataset
(tie_geometry.nc file). The appropriate instrument detector is given at each pixel by the ‘detector_index’
dataset (instrument_data.nc file).
In https://sentinel.esa.int/documents/247904/4598069/Sentinel-3-OLCI-Land-Handbook.pdf/455f8c88-520f-da18-d744-f5cda41d2d91
Args:
band_arr (xr.DataArray): Band array
band (BandNames): Spectral Band (for SLSTR only)
Returns:
dict: Dictionary containing {band: path}
"""
rad_2_refl_path, rad_2_refl_exists = self._get_out_path(
f"rad_2_refl_{band.name}.npy"
)
if not rad_2_refl_exists:
# Open SZA array (resampled to band_arr size)
sza_path, sza_exists = self._get_out_path("sza.tif")
# May have been created before (don't recreate it)
if not sza_exists and not sza_path.is_file():
sza_nc = self._read_nc(self._geom_file, self._sza_name)
utils.write(sza_nc, sza_path)
with rasterio.open(str(sza_path)) as ds_sza:
# Values can be easily interpolated at pixels from Tie Points by linear interpolation using the image column coordinate.
sza, _ = rasters_rio.read(
ds_sza,
size=(band_arr.rio.width, band_arr.rio.height),
resampling=Resampling.bilinear,
masked=False,
)
sza_rad = sza.astype(np.float32) * np.pi / 180.0
# Open solar flux (resampled to band_arr size)
e0 = self._compute_e0(band)
# Compute rad_2_refl coeff
rad_2_refl_coeff = (np.pi / e0 / np.cos(sza_rad)).astype(np.float32)
# Write on disk
np.save(str(rad_2_refl_path), rad_2_refl_coeff)
else:
# Open rad_2_refl_coeff (resampled to band_arr size)
rad_2_refl_coeff = utils.load_np(rad_2_refl_path, self._tmp_process)
return band_arr * rad_2_refl_coeff
def _compute_e0(self, band: BandNames = None) -> np.ndarray:
"""
Compute the solar spectral flux in mW / (m^2 * sr * nm)
Args:
band (BandNames): Spectral Band (for SLSTR only)
Returns:
np.ndarray: Solar Flux
"""
# Do not convert to int here as we want to keep the nans
det_idx = self._read_nc(self._misc_file, self._det_index).data
e0_det = self._read_nc(self._misc_file, self._solar_flux_name).data
# Get band slice and open corresponding e0 for the detectors
band_slice = int(self.bands[band].name[-2:]) - 1
e0_det = np.squeeze(e0_det[0, band_slice, :])
# Create e0
e0 = det_idx
not_nan_idx = ~np.isnan(det_idx)
e0[not_nan_idx] = e0_det[det_idx[not_nan_idx].astype(int)]
return e0
def _manage_invalid_pixels(
self,
band_arr: xr.DataArray,
band: BandNames,
pixel_size: float = None,
**kwargs,
) -> xr.DataArray:
"""
Manage invalid pixels (Nodata, saturated, defective...) for OLCI data.
See there:
https:sentinel.esa.int/documents/247904/1872756/Sentinel-3-OLCI-Product-Data-Format-Specification-OLCI-Level-1
QUALITY FLAGS (From end to start of the 32 bits):
| Bit | Flag |
|----|----------------------|
| 0 | saturated21 |
| 1 | saturated20 |
| 2 | saturated19 |
| 3 | saturated18 |
| 4 | saturated17 |
| 5 | saturated16 |
| 6 | saturated15 |
| 7 | saturated14 |
| 8 | saturated13 |
| 9 | saturated12 |
| 10 | saturated11 |
| 11 | saturated10 |
| 12 | saturated09 |
| 13 | saturated08 |
| 14 | saturated07 |
| 15 | saturated06 |
| 16 | saturated05 |
| 17 | saturated04 |
| 18 | saturated03 |
| 19 | saturated02 |
| 20 | saturated01 |
| 21 | dubious |
| 22 | sun-glint_risk |
| 23 | duplicated |
| 24 | cosmetic |
| 25 | invalid |
| 26 | straylight_risk |
| 27 | bright |
| 28 | tidal_region |
| 29 | fresh_inland_water |
| 30 | coastline |
| 31 | land |
Args:
band_arr (xr.DataArray): Band array
band (BandNames): Band name as an SpectralBandNames
kwargs: Other arguments used to load bands
Returns:
xr.DataArray: Cleaned band array
"""
# Bit ids
band_bit_id = {
Oa01: 20, # Band 1
Oa02: 19, # Band 2
CA: 18, # Band 3
BLUE: 17, # Band 4
GREEN_1: 16, # Band 5
GREEN: 15, # Band 6
YELLOW: 14, # Band 7
RED: 13, # Band 8
Oa09: 12, # Band 9
Oa10: 11, # Band 10
VRE_1: 10, # Band 11
VRE_2: 9, # Band 12
Oa13: 8, # Band 13
Oa14: 7, # Band 14
Oa15: 6, # Band 15
VRE_3: 5, # Band 16
NIR: 4, # Band 17
NARROW_NIR: 4, # Band 17
Oa18: 3, # Band 18
Oa19: 2, # Band 19
WV: 1, # Band 20
Oa21: 0, # Band 21
}
invalid_id = 25
sat_band_id = band_bit_id[band]
# Open quality flags
# NOT OPTIMIZED, MAYBE CHECK INVALID PIXELS ON NOT GEOCODED DATA
qual_regex = "qualityFlags"
subds = "quality_flags"
qual_flags_path = self._preprocess(
qual_regex,
subdataset=subds,
pixel_size=pixel_size,
to_reflectance=False,
dtype=np.uint32,
)
# Open flag file
qual_arr = utils.read(
qual_flags_path,
size=(band_arr.rio.width, band_arr.rio.height),
resampling=Resampling.nearest, # Nearest to keep the flags
masked=False,
as_type=np.uint32,
**kwargs,
)
invalid, sat = utils.read_bit_array(qual_arr, [invalid_id, sat_band_id])
# Get nodata mask
no_data = np.where(np.isnan(band_arr.data), self._mask_true, self._mask_false)
# Combine masks
mask = no_data | invalid | sat
return self._set_nodata_mask(band_arr, mask)
def _has_cloud_band(self, band: BandNames) -> bool:
"""
Does this product has the specified cloud band?
-> OLCI does not provide any cloud mask
"""
return False
def _open_clouds(
self,
bands: list,
pixel_size: float = None,
size: Union[list, tuple] = None,
**kwargs,
) -> dict:
"""
Does nothing for OLCI data
Args:
bands (list): List of the wanted bands
pixel_size (int): Band pixel size in meters
size (Union[tuple, list]): Size of the array (width, height). Not used if pixel_size is provided.
kwargs: Additional arguments
Returns:
dict: Dictionary {band_name, band_xarray}
"""
if bands:
LOGGER.warning("Sentinel-3 OLCI L1B does not provide any cloud file")
return {}
[docs]
@cache
def get_mean_sun_angles(self) -> (float, float):
"""
Get Mean Sun angles (Azimuth and Zenith angles)
.. code-block:: python
>>> from eoreader.reader import Reader
>>> path = "S3B_SL_1_RBT____20191115T233722_20191115T234022_20191117T031722_0179_032_144_3420_LN2_O_NT_003.SEN3"
>>> prod = Reader().open(path)
>>> prod.get_mean_sun_angles()
(78.55043955912154, 31.172127033319388)
Returns:
(float, float): Mean Azimuth and Zenith angle
"""
# Open sun azimuth and zenith files
sun_az = self._read_nc(self._geom_file, self._saa_name)
sun_ze = self._read_nc(self._geom_file, self._sza_name)
return float(sun_az.mean().data) % 360, float(sun_ze.mean().data)