Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
(and its subpackages) is included in the PyPI wheel.
- Remove the `coarsen.py` module, as it has been moved to [xcube-resampling](https://github.com/xcube-dev/xcube-resampling)
and is no longer used internally.
- Added footprint-based subsetting for Sentinel-3 OLCI and SLSTR LST using STAC
metadata, improving performance by avoiding full latitude/longitude grid downloads
during subsetting.


## Changes in 0.2.7 (from 2026-03-27)
Expand Down
9,368 changes: 6,901 additions & 2,467 deletions examples/sentinel_3_analysis.ipynb

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion integration/test_sen2_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import xarray as xr

from integration.helpers import assert_dataset_is_chunked
from xarray_eopf.constants import DEFAULT_ENDPOINT_URL
from xarray_eopf.utils import timeit

allowed_open_time = 1000 # seconds
Expand Down
78 changes: 74 additions & 4 deletions tests/amodes/test_sentinel3.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from collections.abc import Sequence
from unittest import TestCase
from unittest.mock import patch

import fsspec
import numpy as np
Expand All @@ -14,7 +15,13 @@

from tests.helpers import make_s3_olci_efr, make_s3_slstr_lst, make_s3_slstr_rbt
from xarray_eopf.amode import AnalysisModeRegistry
from xarray_eopf.amodes.sentinel3 import Sen3Ol1Efr, Sen3Sl1Rbt, Sen3Sl2Lst, register
from xarray_eopf.amodes.sentinel3 import (
Sen3Ol1Efr,
Sen3Sl1Rbt,
Sen3Sl2Lst,
clip_dataset_by_geometry,
register,
)
from xarray_eopf.constants import FloatInt


Expand Down Expand Up @@ -91,7 +98,7 @@ def assert_convert_datatree_ok(
self,
original_dt: xr.DataTree,
expected_var_names: list[str],
expected_size: (int, int),
expected_size: tuple[int, int],
resolution: FloatInt | tuple[FloatInt, FloatInt] | None = None,
bbox: Sequence[float | int] | None = None,
):
Expand All @@ -118,6 +125,12 @@ def assert_convert_datatree_fail(self, original_dt: xr.DataTree):
with pytest.raises(ValueError, match="No variables selected"):
self.mode.convert_datatree(original_dt, includes="bibo")

def assert_convert_datatree_fail_with_include_exclude(
self, original_dt: xr.DataTree
):
with pytest.raises(ValueError, match="No variables selected"):
self.mode.convert_datatree(original_dt, includes=".+", excludes=".+")


class OlciEfrTest(Sen3TestMixin, TestCase):
mode = Sen3Ol1Efr()
Expand Down Expand Up @@ -163,7 +176,7 @@ def test_convert_datatree_bbox(self):
"oa02_radiance",
"oa03_radiance",
],
expected_size=(372, 421),
expected_size=(372, 454),
bbox=[1, 55, 3, 56],
)

Expand All @@ -187,6 +200,21 @@ def test_convert_datatree_raise_warning(self):
def test_convert_datatree_fail(self):
self.assert_convert_datatree_fail(make_s3_olci_efr(size=48))

def test_convert_datatree_fail_include_exclude_overlap(self):
self.assert_convert_datatree_fail_with_include_exclude(
make_s3_olci_efr(size=48)
)

def test_convert_datatree_sets_other_metadata_as_attrs(self):
dt = make_s3_olci_efr(size=100)
dt.attrs["other_metadata"] = {"test_key": "test_val"}
ds = self.mode.convert_datatree(
dt,
includes=["oa01_radiance"],
resolution=0.1,
)
self.assertEqual({"test_key": "test_val"}, ds.attrs)


class SlstrRbtTest(Sen3TestMixin, TestCase):
mode = Sen3Sl1Rbt()
Expand Down Expand Up @@ -260,6 +288,11 @@ def test_convert_datatree_raise_warning(self):
def test_convert_datatree_fail(self):
self.assert_convert_datatree_fail(make_s3_slstr_rbt(size=48))

def test_convert_datatree_fail_include_exclude_overlap(self):
self.assert_convert_datatree_fail_with_include_exclude(
make_s3_slstr_rbt(size=48)
)

def test_get_outer_bbox(self):
bboxs = np.array([[-2, 10, 8, 20], [2, 12, 13, 25]])
expected = [-2, 10, 13, 25]
Expand Down Expand Up @@ -307,9 +340,46 @@ def test_convert_datatree_bbox(self):
self.assert_convert_datatree_ok(
make_s3_slstr_lst(size=1000),
expected_var_names=["lst"],
expected_size=(112, 127),
expected_size=(112, 148),
bbox=[1, 55, 3, 56],
)

def test_convert_datatree_fail(self):
self.assert_convert_datatree_fail(make_s3_slstr_lst(size=48))

def test_convert_datatree_fail_include_exclude_overlap(self):
self.assert_convert_datatree_fail_with_include_exclude(
make_s3_slstr_lst(size=48)
)


class ClipDatasetByGeometryTest(TestCase):
def test_uses_southern_hemisphere_utm_epsg(self):
stac_meta = {
"geometry": {
"coordinates": [
[
[10.0, -20.0],
[11.0, -20.0],
[11.0, -19.0],
[10.0, -19.0],
[10.0, -20.0],
]
]
},
"properties": {"sat:orbit_state": "descending"},
}
dataset = xr.Dataset(
{"band": (("rows", "columns"), np.ones((128, 128), dtype=np.float32))}
)
bbox = [10.1, -19.9, 10.9, -19.1]

with patch(
"xarray_eopf.amodes.sentinel3.pyproj.Transformer.from_crs",
wraps=pyproj.Transformer.from_crs,
) as from_crs:
_ = clip_dataset_by_geometry(stac_meta, dataset, bbox, buffer=5)

self.assertGreaterEqual(from_crs.call_count, 1)
self.assertEqual("EPSG:4326", from_crs.call_args.args[0])
self.assertTrue(str(from_crs.call_args.args[1]).startswith("EPSG:327"))
50 changes: 41 additions & 9 deletions tests/helpers/sentinel3.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,39 @@


def make_s3_olci_efr(size: int = 48) -> xr.DataTree:
ds = make_s3_meas(size, bands=[f"oa{i:02}_radiance" for i in range(1, 22)])

return create_datatree(
{
"measurements": make_s3_meas(
size, bands=[f"oa{i:02}_radiance" for i in range(1, 22)]
),
footprint = derive_footprint(ds)

dt = create_datatree(
{"measurements": ds},
attrs={
"stac_discovery": {
"geometry": {"type": "Polygon", "coordinates": [footprint]},
"properties": {"sat:orbit_state": "descending"},
}
},
)
return dt


def make_s3_slstr_lst(size: int = 48) -> xr.DataTree:
ds = make_s3_meas(size, bands=["lst"])
footprint = derive_footprint(ds)
return create_datatree(
{
"conditions/auxiliary": make_s3_meas(size, bands=["elevation"]),
"conditions/meteorology": make_s3_meas((size, size // 10), bands=["s2m"]),
"conditions/geometry": make_s3_meas(
(size, size // 10), bands=["sat_azimuth_tn", "sat_zenith_tn"]
),
"measurements": make_s3_meas(size, bands=["lst"]),
"measurements": ds,
},
attrs={
"stac_discovery": {
"geometry": {"type": "Polygon", "coordinates": [footprint]},
"properties": {"sat:orbit_state": "ascending"},
}
},
)

Expand Down Expand Up @@ -116,12 +130,30 @@ def make_coords(w: int, h: int, oblique_view=False) -> dict[str, xr.DataArray]:
return {
"latitude": xr.DataArray(lat_final, dims=("rows", "columns")),
"longitude": xr.DataArray(lon_final, dims=("rows", "columns")),
"time_stamps": xr.DataArray(
np.arange(h).astype("datetime64[ns]"), dims=("rows")
),
"time_stamps": xr.DataArray(np.arange(h).astype("datetime64[ns]"), dims="rows"),
}


def derive_footprint(ds: xr.Dataset) -> list[list[float]]:
lon = ds["longitude"]
lat = ds["latitude"]
corners = [
(0, 0),
(0, ds.sizes["columns"] - 1),
(ds.sizes["rows"] - 1, ds.sizes["columns"] - 1),
(ds.sizes["rows"] - 1, 0),
]
footprint = [
[
float(lon.isel(rows=i, columns=j).values),
float(lat.isel(rows=i, columns=j).values),
]
for i, j in corners
]
footprint.append(footprint[0])
return footprint


def create_datatree(
datasets: dict[str, xr.Dataset], attrs: dict[str, Any] | None = None
) -> xr.DataTree:
Expand Down
17 changes: 17 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from unittest import TestCase

import numpy as np
import pytest
import xarray as xr

Expand All @@ -13,6 +14,7 @@
assert_arg_has_length,
assert_arg_is_instance,
assert_arg_is_one_of,
build_footprint_uv_mapping,
get_data_tree_item,
timeit,
)
Expand Down Expand Up @@ -113,3 +115,18 @@ def test_filter(self):
self.assertEqual(
["ernie", "emmie"], list(f.filter(["bibo", "ernie", "bert", "emmie"]))
)


class BuildFootprintUvMappingTest(TestCase):
def test_accepts_closed_ring_points(self):
open_ring = np.array(
[[10.0, 50.0], [12.0, 50.0], [12.0, 52.0], [10.0, 52.0]],
dtype=float,
)
closed_ring = np.vstack([open_ring, open_ring[0]])

open_xy, open_uv = build_footprint_uv_mapping(open_ring)
closed_xy, closed_uv = build_footprint_uv_mapping(closed_ring)

self.assertTrue(np.allclose(open_xy, closed_xy))
self.assertTrue(np.allclose(open_uv, closed_uv))
Loading
Loading