[Git][debian-gis-team/xarray-eopf][master] 5 commits: New upstream version 0.3.0
Antonio Valentino (@antonio.valentino)
gitlab at salsa.debian.org
Wed Jul 29 22:12:25 BST 2026
Antonio Valentino pushed to branch master at Debian GIS Project / xarray-eopf
Commits:
df45a2b4 by Antonio Valentino at 2026-07-29T20:55:20+00:00
New upstream version 0.3.0
- - - - -
978a14d2 by Antonio Valentino at 2026-07-29T20:56:50+00:00
Update upstream source from tag 'upstream/0.3.0'
Update to upstream version '0.3.0'
with Debian dir b0bddcca66c3a8b89120c73c2cb42a534996bcb9
- - - - -
77223332 by Antonio Valentino at 2026-07-29T20:57:52+00:00
New upstream release
- - - - -
17f162ad by Antonio Valentino at 2026-07-29T21:01:33+00:00
Refresh all patches
- - - - -
d69a15b0 by Antonio Valentino at 2026-07-29T21:02:19+00:00
Set distribution to unstable
- - - - -
24 changed files:
- CHANGES.md
- debian/changelog
- debian/patches/0001-zarr3-compat.patch
- docs/examples/introduction.ipynb
- docs/examples/sentinel_1_analysis.ipynb
- docs/examples/sentinel_1_native.ipynb
- docs/guide.md
- docs/index.md
- examples/introduction.ipynb
- examples/sentinel_1_analysis.ipynb
- examples/sentinel_1_native.ipynb
- integration/test_data/dem_small.zarr.zip → integration/test_data/dem_grd.zarr.zip
- + integration/test_data/dem_slc.zarr.zip
- integration/test_sen1_analysis.py
- integration/test_sen1_native.py
- mkdocs.yml
- tests/amodes/test_sentinel1.py
- tests/amodes/test_sentinel3.py
- tests/helpers/__init__.py
- tests/helpers/sentinel1.py
- xarray_eopf/amodes/sentinel1.py
- xarray_eopf/amodes/sentinel3.py
- xarray_eopf/backend.py
- xarray_eopf/version.py
Changes:
=====================================
CHANGES.md
=====================================
@@ -1,9 +1,17 @@
+## Changes in 0.3.0 (from 2026-07-22)
+
+- Sentinel-1 GRD analysis mode is now fully lazy, enabling seamless execution on
+ local and distributed Dask clusters.
+- Add support for Sentinel-1 Level-1 SLC analysis mode.
+
+
## Changes in 0.2.9 (from 2026-06-03)
- Added support for Sentinel-1 Level-2 OCN analysis mode.
- Fixed an issue in Sentinel-1 GRD analysis mode that could produce NaN values along
the edges of the bounding box.
+
## Changes in 0.2.8 (from 2026-05-08)
- Fix package discovery in `pyproject.toml` to ensure only `xarray_eopf`
=====================================
debian/changelog
=====================================
@@ -1,3 +1,11 @@
+xarray-eopf (0.3.0-1) unstable; urgency=medium
+
+ * New upstream release.
+ * debian/patches:
+ - Reresh all patches.
+
+ -- Antonio Valentino <antonio.valentino at tiscali.it> Wed, 29 Jul 2026 21:01:58 +0000
+
xarray-eopf (0.2.9-1) unstable; urgency=medium
* New upstream release.
=====================================
debian/patches/0001-zarr3-compat.patch
=====================================
@@ -39,10 +39,10 @@ index ca7ac97..e7928f3 100644
)
fs: fsspec.AbstractFileSystem = fsspec.filesystem("local")
diff --git a/tests/amodes/test_sentinel3.py b/tests/amodes/test_sentinel3.py
-index 135176b..87e1924 100644
+index 7435c6a..1f5ebc3 100644
--- a/tests/amodes/test_sentinel3.py
+++ b/tests/amodes/test_sentinel3.py
-@@ -139,7 +139,7 @@ class OlciEfrTest(Sen3TestMixin, TestCase):
+@@ -145,7 +145,7 @@ class OlciEfrTest(Sen3TestMixin, TestCase):
self.assertTrue(self.mode.is_valid_source("data/S3A_OL_1_EFR_20240201.zarr"))
self.assertTrue(
self.mode.is_valid_source(
@@ -51,7 +51,7 @@ index 135176b..87e1924 100644
)
)
fs: fsspec.AbstractFileSystem = fsspec.filesystem("local")
-@@ -223,7 +223,7 @@ class SlstrRbtTest(Sen3TestMixin, TestCase):
+@@ -229,7 +229,7 @@ class SlstrRbtTest(Sen3TestMixin, TestCase):
self.assertTrue(self.mode.is_valid_source("data/S3A_SL_1_RBT_20240201.zarr"))
self.assertTrue(
self.mode.is_valid_source(
@@ -60,7 +60,7 @@ index 135176b..87e1924 100644
)
)
-@@ -310,7 +310,7 @@ class SlstrLstTest(Sen3TestMixin, TestCase):
+@@ -316,7 +316,7 @@ class SlstrLstTest(Sen3TestMixin, TestCase):
self.assertTrue(self.mode.is_valid_source("data/S3A_SL_2_LST_20240201.zarr"))
self.assertTrue(
self.mode.is_valid_source(
@@ -102,7 +102,7 @@ index e454c91..69ae224 100644
(
f"S2A_{self.product_type}_" in root_path
diff --git a/xarray_eopf/amodes/sentinel3.py b/xarray_eopf/amodes/sentinel3.py
-index 26ffd63..4cd6546 100644
+index e3d2fe6..c0294b5 100644
--- a/xarray_eopf/amodes/sentinel3.py
+++ b/xarray_eopf/amodes/sentinel3.py
@@ -41,7 +41,7 @@ class Sen3(AnalysisMode, ABC):
=====================================
docs/examples/introduction.ipynb
=====================================
@@ -4,9 +4,7 @@
"cell_type": "markdown",
"id": "c04818c2-3b78-4522-8f7d-aedda2a57c5d",
"metadata": {},
- "source": [
- "# Intoduction to the xarray EOPF backend"
- ]
+ "source": "# Introduction to the xarray EOPF backend"
},
{
"cell_type": "markdown",
@@ -37,7 +35,7 @@
"source": [
"## Install the xarray-eopf Backend\n",
"\n",
- "The backend is implemented as an **xarray plugin** and can be installed using either `pip` or `conda/mamba` from the conda-forge channel.\n",
+ "The backend is implemented as a **xarray plugin** and can be installed using either `pip` or `conda/mamba` from the conda-forge channel.\n",
"\n",
"- 📦 **PyPI:** [xarray-eopf on PyPI](https://pypi.org/project/xarray-eopf/) `pip install xarray-eopf`\n",
"- 🐍 **Conda (conda-forge):** [xarray-eopf on Anaconda](https://anaconda.org/conda-forge/xarray-eopf) `conda install -c conda-forge xarray-eopf`\n",
@@ -89,7 +87,7 @@
"source": [
"## Main Features of the xarray-eopf Backend\n",
"\n",
- "The xarray-eopf backend for EOPF data products can be selecterd by setting `engine=\"eopf-zarr\"` in `xarray.open_dataset(..)` and `xarray.open_datatree(..)` method. All data access is *lazy*, meaning that data is only loaded when required—for example, during plotting or when writing to storage. It supports two modes of operation:\n",
+ "The xarray-eopf backend for EOPF data products can be selected by setting `engine=\"eopf-zarr\"` in `xarray.open_dataset(..)` and `xarray.open_datatree(..)` method. All data access is *lazy*, meaning that data is only loaded when required—for example, during plotting or when writing to storage. It supports two modes of operation:\n",
"\n",
"- **Analysis Mode** *(default)*\n",
"- **Native Mode**\n",
=====================================
docs/examples/sentinel_1_analysis.ipynb
=====================================
The diff for this file was not included because it is too large.
=====================================
docs/examples/sentinel_1_native.ipynb
=====================================
The diff for this file was not included because it is too large.
=====================================
docs/guide.md
=====================================
@@ -3,8 +3,8 @@ namely _analysis mode_ (the default) and _native mode_, which are described in
the following.
An introductory example notebook is available at :
-- [Docs - Intoduction to the xarray EOPF backend](https://eopf-sample-service.github.io/xarray-eopf/examples/introduction/)
-- [Notebook Gallery - Intoduction to the xarray EOPF backend](https://eopf-sample-service.github.io/eopf-sample-notebooks/introduction/)
+- [Docs - Introduction to the xarray EOPF backend](https://eopf-sample-service.github.io/xarray-eopf/examples/introduction/)
+- [Notebook Gallery - Introduction to the xarray EOPF backend](https://eopf-sample-service.github.io/eopf-sample-notebooks/introduction/)
---
@@ -58,12 +58,12 @@ Additional parameters specific to each Sentinel mission are described below.
Processing workflows differ significantly across Sentinel-1 product types. Therefore,
each product family is documented in its own dedicated section.
-> **Note:** Support for SLC products is planned for a future release.
-
##### Sentinel-1 Level-1 GRD
> Note: Support for Sentinel-1 GRD products in analysis mode is
-> currently experimental and undergoing validation.
+> currently experimental and undergoing validation. Some conversion parameters
+> are missing in the new EOPF product, which are currently estimated. Newer EOPF
+> product version will include these parameters.
Sentinel-1 Level-1 GRD data is provided in radar geometry, defined by the coordinates
(`azimuth_time`, `ground_range`). To transform this data into an
@@ -110,12 +110,80 @@ Sentinel-1 Level-1 GRD data is provided in radar geometry, defined by the coordi
`nearest`, `bilinear`.
- `footprint_scale_factor`: Defines how radar pixels contribute to the output grid.
Default: `(3.0, 3.0)`, accounting for resolution differences (e.g., ~10 m GRD
- vs. ~30 m DEM).
+ vs. ~30 m DEM).
+- `cache_uri`: Temporary path used to store intermediate results from the
+ backward geocoding step in the Sentinel-1 processing workflow. The cache is
+ automatically removed when the Python process exits. If None, a temporary
+ directory with a unique UUID-based name is created.
Examples:
- [Docs – Sentinel-1 Analysis Mode](https://eopf-sample-service.github.io/xarray-eopf/examples/sentinel_1_analysis/)
+##### Sentinel-1 Level-1 SLC
+
+> Note: Support for Sentinel-1 SLC products in analysis mode is
+> currently experimental and undergoing validation.
+
+Sentinel-1 Level-1 SLC data is provided in radar geometry, defined by the coordinates
+(`azimuth_time`, `slant_range_time`) and organized in bursts and swaths. To transform
+this data into an **analysis-ready dataset**, the following processing steps are applied:
+
+1. **Radiometric Calibration:** For each burst complex SLC measurements are
+ converted into `beta0` backscatter values using the `beta_nought` calibration
+ lookup table (LUT).
+2. **Burst and Swath Merging:** Valid burst regions are extracted using burst metadata,
+ merged along azimuth time, aligned across swaths, and then merged along slant range
+ to produce one continuous acquisition grid per selected polarization.
+3. **Geometric Terrain Correction (GTC):** Using a Digital Elevation Model (DEM),
+ the processor performs **inverse geocoding** by solving the zero-Doppler equation
+ based on satellite orbit information and terrain elevation. This step maps the
+ data from radar geometry to a georeferenced grid.
+4. **Radiometric Terrain Correction (RTC):** (Optional) RTC compensates for
+ terrain-induced radiometric distortions such as foreshortening, layover,
+ and slope-dependent brightness variations.
+
+📖 [D. Small, *Flattening Gamma: Radiometric Terrain Correction for SAR Imagery*](https://ieeexplore.ieee.org/document/5752845)
+
+**Supported Products:**
+
+- Sentinel-1 Level-1 SLC
+
+**Supported Variables**
+
+- **Polarization bands**:
+ `vv`, `vh`, `hh`, `hv` *(each SLC product contains only a subset of these bands)*
+
+**Specific Sentinel-1 Level-1 SLC parameters `**kwargs`:**
+
+- `crs`: Coordinate reference system of the output dataset. Can be provided as a
+ `str` or a `pyproj.CRS` object. If a string is given, it will be parsed using
+ [`pyproj.crs.CRS.from_string`](https://pyproj4.github.io/pyproj/dev/api/crs/crs.html#pyproj.crs.CRS.from_string).
+ If not specified, [EPSG:4326](https://epsg.io/4326) is used.
+- `resolution`: Target resolution for all spatial variables expressed in the units
+ of the specified `crs`. If not specified, the resolution is derived from the DEM
+ (see `dem` below).
+- `dem`: Digital Elevation Model (DEM) as a CF-compliant `xarray.DataArray` used for
+ terrain correction. If provided, the parameters `crs`, `bbox`, and `resolution` are
+ ignored, and the target grid is derived from the DEM. If not provided, the
+ [CopDEM COG (30 m)](https://browser.stac.dataspace.copernicus.eu/collections/cop-dem-glo-30-dged-cog) in geographic coordinates
+ is retrieved automatically via the CDSE STAC API. This requires
+ [CDSE S3 credentials](https://documentation.dataspace.copernicus.eu/APIs/S3.html#generate-secrets).
+- `apply_rtc`: Enable or disable radiometric terrain correction (RTC). Default is `True`.
+- `interp_methods`: Interpolation method used during GTC and RTC. Supported methods:
+ `nearest`, `bilinear`.
+- `footprint_scale_factor`: Defines how radar pixels contribute to the output grid.
+ Default: `(3.0, 15.0)`, reflecting the different scaling used for azimuth and
+ slant-range processing of SLC data.
+- `cache_uri`: Temporary path used to store intermediate results from the
+ backward geocoding step in the Sentinel-1 processing workflow. The cache is
+ automatically removed when the Python process exits. If `None`, a temporary
+ directory with a unique UUID-based name is created.
+
+Examples:
+
+- [Docs – Sentinel-1 Analysis Mode](https://eopf-sample-service.github.io/xarray-eopf/examples/sentinel_1_analysis/)
+
##### Sentinel-1 Level-2 OCN
Sentinel-1 Level-2 OCN products are geolocated datasets provided on their
@@ -182,7 +250,7 @@ Sentinel-2 provides multi-spectral imagery at different native resolutions:
The analysis mode enables resampling between these different resolutions, bringing
bands from multiple resolutions onto the same grid using [affine transformation via xcube-resampling](https://xcube-dev.github.io/xcube-resampling/guide/#1-affine-transformation).
-**Suported Products:**
+**Supported Products:**
- [Sentinel-2 Level-1C](https://stac.browser.user.eopf.eodc.eu/collections/sentinel-2-l1c)
- [Sentinel-2 Level-2A](https://stac.browser.user.eopf.eodc.eu/collections/sentinel-2-l2a)
@@ -254,7 +322,7 @@ For OLCI products, no additional terrain correction is required, as it is alread
incorporated in the Level-1 data. See the [OLCI Level-1 product description](https://sentiwiki.copernicus.eu/web/olci-products#OLCIProducts-L1BProducts-ObservationModeS3-OLCI-Products-L1B-OM)
for details.
-**Suported Products:**
+**Supported Products:**
- [Sentinel-3 OLCI Level-1 EFR](https://stac.browser.user.eopf.eodc.eu/collections/sentinel-3-olci-l1-efr)
- [Sentinel-3 OLCI Level-1 ERR](https://stac.browser.user.eopf.eodc.eu/collections/sentinel-3-olci-l1-err)
=====================================
docs/index.md
=====================================
@@ -55,7 +55,7 @@ datatree = xr.open_datatree(url_or_path, engine="eopf-zarr", op_mode="native")
More information on the two modes are given in the [User Guide](guide.md)
Data variables will always be represented as chunked Dask arrays for
-efficient out-of core computations and visualisations.
+efficient out-of core computations and visualizations.
The package has minimal core dependencies: `xarray`, `zarr`, and `dask`.
Packages for accessing remote filesystems are optional, e.g., you will need `s3fs`
@@ -64,5 +64,5 @@ if you need to access EOPF data products in S3-compatible remote object storages
## License
The package is open source and released under the
-[Apache 2.0 license](https://www.apache.org/licenses/LICENSE-2.0.html) license. :heart:
+[Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0.html) license. :heart:
=====================================
examples/introduction.ipynb
=====================================
The diff for this file was not included because it is too large.
=====================================
examples/sentinel_1_analysis.ipynb
=====================================
The diff for this file was not included because it is too large.
=====================================
examples/sentinel_1_native.ipynb
=====================================
The diff for this file was not included because it is too large.
=====================================
integration/test_data/dem_small.zarr.zip → integration/test_data/dem_grd.zarr.zip
=====================================
=====================================
integration/test_data/dem_slc.zarr.zip
=====================================
Binary files /dev/null and b/integration/test_data/dem_slc.zarr.zip differ
=====================================
integration/test_sen1_analysis.py
=====================================
@@ -15,22 +15,17 @@ allowed_open_time = 1000 # seconds
show_chunking = False
-class Sentinel2AnalysisTest(TestCase):
+class Sentinel1AnalysisTest(TestCase):
def test_open_dataset_sen1_grd(self):
- dem_path = Path(__file__).resolve().parent / "test_data" / "dem_small.zarr.zip"
+ dem_path = Path(__file__).resolve().parent / "test_data" / "dem_grd.zarr.zip"
store = zarr.ZipStore(str(dem_path), mode="r")
- dem = xr.open_zarr(
- store,
- group="dem_small.zarr",
- consolidated=False, # also important
- chunks={},
- )
+ dem = xr.open_zarr(store, group="dem_small.zarr", consolidated=False, chunks={})
dem = dem.dem
url = (
- "https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:202603-"
- "s01siwgrh-global/19/products/cpm_v262/S1A_IW_GRDH_1SDV_20260319"
- "T102725_20260319T102758_063695_0801D3_2EC6.zarr"
+ "https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:202606"
+ "-s01siwgrh-global/23/products/cpm_v270/S1D_IW_GRDH_1SDV_20260623"
+ "T225558_20260623T225623_003369_005EC5_B4C2.zarr"
)
with timeit("open " + url) as result:
# noinspection PyTypeChecker
@@ -50,6 +45,35 @@ class Sentinel2AnalysisTest(TestCase):
for var_name in ds.data_vars:
self.assertEqual((541, 1081), ds[var_name].shape, msg=var_name)
+ def test_open_dataset_sen1_slc(self):
+ dem_path = Path(__file__).resolve().parent / "test_data" / "dem_slc.zarr.zip"
+ store = zarr.ZipStore(str(dem_path), mode="r")
+ dem = xr.open_zarr(store, group="dem_small.zarr", consolidated=False, chunks={})
+ dem = dem.dem
+
+ url = (
+ "https://data.eodc.eu/collections/EOPF_ZARR/products/cpm_v270/"
+ "S01SIWSLC/2026/05/31/S1D_IW_SLC__1SDV_20260531T171503_"
+ "20260531T171530_003031_00539E_C573.zarr"
+ )
+ with timeit("open " + url) as result:
+ # noinspection PyTypeChecker
+ ds = xr.open_dataset(
+ url,
+ engine="eopf-zarr",
+ op_mode="analysis",
+ dem=dem,
+ chunks={},
+ )
+ self.assertTrue(result.time_delta < allowed_open_time)
+
+ self.assertIn("gamma0_vv", ds)
+ self.assertIn("gamma0_vh", ds)
+
+ assert_dataset_is_chunked(self, ds, verbose=show_chunking)
+ for var_name in ds.data_vars:
+ self.assertEqual((361, 361), ds[var_name].shape, msg=var_name)
+
def test_open_datatree_sen1_onc(self):
url = (
"https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:202507-s01siwocn"
=====================================
integration/test_sen1_native.py
=====================================
@@ -10,19 +10,19 @@ import xarray as xr
class Sentinel1NativeTest(TestCase):
def test_open_datatree_sen1_grd(self):
path = (
- "https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:202603-"
- "s01siwgrh-global/19/products/cpm_v262/S1A_IW_GRDH_1SDV_20260319"
- "T102725_20260319T102758_063695_0801D3_2EC6.zarr"
+ "https://objects.eodc.eu/e05ab01a9d56408d82ac32d69a5aae2a:202606"
+ "-s01siwgrh-global/23/products/cpm_v270/S1D_IW_GRDH_1SDV_20260623"
+ "T225558_20260623T225623_003369_005EC5_B4C2.zarr"
)
# noinspection PyTypeChecker
dt = xr.open_datatree(path, engine="eopf-zarr", op_mode="native")
self.assertEqual(25, len(dt.groups))
self.assertIn(
- "/S01SIWGRD_20260319T102725_0033_A364_2EC6_0801D3_VH/measurements",
+ "/S01SIWGRD_20260623T225558_0025_D019_B4C2_005EC5_VH/measurements",
dt.groups,
)
- ds = dt.S01SIWGRD_20260319T102725_0033_A364_2EC6_0801D3_VH.measurements
- self.assertEqual({"azimuth_time": 22290, "ground_range": 25223}, ds.sizes)
+ ds = dt.S01SIWGRD_20260623T225558_0025_D019_B4C2_005EC5_VH.measurements
+ self.assertEqual({"azimuth_time": 16802, "ground_range": 25319}, ds.sizes)
def test_open_datatree_sen1_slc(self):
path = (
=====================================
mkdocs.yml
=====================================
@@ -2,7 +2,7 @@ site_name: EOPF xarray backend
repo_url: https://github.com/EOPF-Sample-Service/xarray-eopf
repo_name: EOPF-Sample-Service/xarray-eopf
-copyright: Copyright © 2025 EOPF Sample Service Team
+copyright: Copyright © 2026 EOPF Sample Service Team
nav:
- Overview: index.md
=====================================
tests/amodes/test_sentinel1.py
=====================================
@@ -3,6 +3,7 @@
# https://opensource.org/license/apache-2-0.
import os
+import uuid
from types import SimpleNamespace
from unittest import TestCase
from unittest.mock import patch
@@ -14,18 +15,23 @@ import pytest
import xarray as xr
from xcube_resampling.gridmapping import GridMapping
-from tests.helpers import make_s1_grd_datatree, make_s1_ocn_datatree
+from tests.helpers import (
+ make_s1_grd_datatree,
+ make_s1_ocn_datatree,
+ make_s1_slc_datatree,
+)
from xarray_eopf.amode import AnalysisModeRegistry
from xarray_eopf.amodes import sentinel1 as sen1
-from xarray_eopf.amodes.sentinel1 import Sen1GRD, Sen1OCN, register
+from xarray_eopf.amodes.sentinel1 import Sen1GRD, Sen1OCN, Sen1SLC, register
class Sentinel1AnalysisModeTest(TestCase):
def test_register(self):
registry = AnalysisModeRegistry()
register(registry)
- self.assertEqual(2, len(list(registry.keys())))
+ self.assertEqual(3, len(list(registry.keys())))
self.assertIn(Sen1GRD.product_type, registry.keys())
+ self.assertIn(Sen1SLC.product_type, registry.keys())
self.assertIn(Sen1OCN.product_type, registry.keys())
@@ -53,9 +59,41 @@ class Sen1TestMixin:
class Sen1GRDTest(Sen1TestMixin, TestCase):
- mode = Sen1GRD()
- dem = xr.DataArray(np.ones((2, 2), dtype="float32"), dims=("lat", "lon"))
- dt = make_s1_grd_datatree()
+
+ def setUp(self):
+ sen1._REGISTERED_CACHE_URIS.clear()
+ self.mode = Sen1GRD()
+ self.dem = xr.DataArray(
+ np.ones((2, 2), dtype="float32"),
+ dims=("lat", "lon"),
+ coords={"lat": [0.0, 1.0], "lon": [0.0, 1.0]},
+ )
+ self.dt = make_s1_grd_datatree()
+ self.expected_vv = xr.Dataset(
+ {"vv": xr.DataArray(np.ones((2, 2)), dims=("lat", "lon"))}
+ )
+ self.expected_beta0_vv = xr.Dataset(
+ {
+ "beta0_vv": xr.DataArray(
+ np.ones((2, 2)),
+ dims=("lat", "lon"),
+ coords={"lat": [0.0, 1.0], "lon": [0.0, 1.0]},
+ )
+ },
+ )
+ self.src_loc = xr.Dataset(
+ {
+ "azimuth_time": (
+ ("lat", "lon"),
+ np.zeros((2, 2), dtype="datetime64[ns]"),
+ ),
+ "ground_range": (("lat", "lon"), np.zeros((2, 2))),
+ "gamma_area": (("lat", "lon"), np.ones((2, 2))),
+ }
+ )
+
+ def tearDown(self):
+ sen1._REGISTERED_CACHE_URIS.clear()
def test_is_valid_source_ok(self):
self.assertTrue(self.mode.is_valid_source("data/S1A_IW_GRDH_20240201.zarr"))
@@ -66,66 +104,104 @@ class Sen1GRDTest(Sen1TestMixin, TestCase):
self.assertFalse(self.mode.is_valid_source(dict()))
def test_get_grid_parameters(self):
- params = self.mode._get_grid_parameters(self.dt, (2.0, 3.0))
+ params = sen1._get_grid_parameters(self.dt, (2.0, 3.0))
- self.assertEqual(1.0e-4, params["slr0"])
- self.assertEqual(30.0, params["spacing_slr"])
- self.assertAlmostEqual(30.0 * 2.0 / sen1._SPEED_OF_LIGHT, params["d_slr"])
+ self.assertEqual(0.0, params["range0"])
+ self.assertEqual(10.0, params["spacing_range"])
+ self.assertEqual(20.0, params["spacing_az"])
+ self.assertEqual(10.0, params["d_range"])
+ self.assertEqual(30.0, params["d_range_scale"])
self.assertEqual(np.datetime64("2024-01-01T00:00:00"), params["az0"])
- self.assertEqual(1.0, params["d_az"])
- self.assertEqual(40.0, params["spacing_az"])
+ self.assertEqual(0.5, params["d_az"])
+ self.assertEqual(30.0, params["spacing_range_scale"])
+ self.assertEqual(40.0, params["spacing_az_scale"])
+ self.assertEqual(
+ np.datetime64("2024-01-01T00:00:00.250000000"), params["az0_scale"]
+ )
def test_get_applicable_params(self: TestCase):
- dem = xr.DataArray(np.ones((2, 2)), dims=("lat", "lon"))
+
self.assertEqual({}, self.mode.get_applicable_params())
self.assertEqual(
{
"resolution": 10,
"bbox": [1, 3, 4, 5],
"crs": pyproj.CRS.from_string("EPSG:4326"),
- "dem": dem,
+ "dem": self.dem,
"interp_methods": "nearest",
"footprint_scale_factor": (2.0, 3.0),
"apply_rtc": False,
+ "cache_uri": "file:///tmp/cache",
},
self.mode.get_applicable_params(
resolution=10,
bbox=[1, 3, 4, 5],
crs="EPSG:4326",
- dem=dem,
+ dem=self.dem,
interp_methods="nearest",
footprint_scale_factor=(2.0, 3.0),
apply_rtc=False,
+ cache_uri="file:///tmp/cache",
),
)
+ with pytest.raises(
+ TypeError, match="resolution argument must contain exactly two"
+ ):
+ self.mode.get_applicable_params(resolution=(1, "x"))
with pytest.raises(TypeError, match="interp_methods"):
self.mode.get_applicable_params(interp_methods="cubic")
with pytest.raises(TypeError, match="footprint_scale_factor"):
self.mode.get_applicable_params(footprint_scale_factor=(1.0, "x"))
+ with pytest.raises(TypeError, match="apply_rtc"):
+ self.mode.get_applicable_params(apply_rtc="yes")
+ with pytest.raises(TypeError, match="cache_uri"):
+ self.mode.get_applicable_params(cache_uri=123)
def test_convert_datatree(self):
- expected = xr.Dataset(
- {"vv": xr.DataArray(np.ones((2, 2)), dims=("lat", "lon"))}
- )
-
- with patch.object(sen1, "terrain_correct", return_value=expected) as mocked:
+ with patch.object(
+ self.mode, "_terrain_correct", return_value=self.expected_vv
+ ) as mocked:
out = self.mode.convert_datatree(self.dt, includes=["vv"], dem=self.dem)
- self.assertIs(out, expected)
+ self.assertIs(out, self.expected_vv)
args, kwargs = mocked.call_args
- self.assertEqual(["beta0_vv"], list(args[0].data_vars))
- self.assertIs(args[3], self.dem)
+ self.assertIs(args[0], self.dt)
+ self.assertEqual(["beta0_vv"], list(args[1].data_vars))
+ self.assertIs(args[2], self.dem)
self.assertEqual("bilinear", kwargs["interp_method"])
self.assertTrue(kwargs["apply_rtc"])
- self.assertIn("slr0", kwargs["grid_params"])
+
+ def test_convert_datatree_updates_footprint_scale_factor(self):
+ with patch.object(self.mode, "_terrain_correct", return_value=self.expected_vv):
+ self.mode.convert_datatree(
+ self.dt,
+ includes=["vv"],
+ dem=self.dem,
+ footprint_scale_factor=(2.0, 4.0),
+ )
+ self.assertEqual((2.0, 4.0), self.mode.footprint_scale_factor)
+
+ def test_convert_datatree_with_cache_uri_uses_fs(self):
+ fs = SimpleNamespace()
+ with (
+ patch.object(
+ sen1.fsspec, "url_to_fs", return_value=(fs, "/cache")
+ ) as url_to_fs,
+ patch.object(self.mode, "_terrain_correct", return_value=self.expected_vv),
+ ):
+ _ = self.mode.convert_datatree(
+ self.dt, includes=["vv"], dem=self.dem, cache_uri="file:///cache/"
+ )
+ url_to_fs.assert_called_once_with("file:///cache")
+ self.assertEqual("file:///cache", self.mode.cache_uri)
+ self.assertEqual(["file:///cache"], sen1._REGISTERED_CACHE_URIS)
def test_convert_datatree_uses_get_dem(self):
- expected = xr.Dataset(
- {"vv": xr.DataArray(np.ones((2, 2)), dims=("lat", "lon"))}
- )
with patch.object(sen1, "get_dem", return_value=self.dem) as get_dem_mock:
- with patch.object(sen1, "terrain_correct", return_value=expected):
+ with patch.object(
+ self.mode, "_terrain_correct", return_value=self.expected_beta0_vv
+ ):
_ = self.mode.convert_datatree(self.dt, includes=["vv"])
get_dem_mock.assert_called_once()
@@ -136,6 +212,440 @@ class Sen1GRDTest(Sen1TestMixin, TestCase):
with pytest.raises(ValueError, match="No valid variable names"):
self.mode.convert_datatree(self.dt, includes="bibo", dem=self.dem)
+ def test_open_data_fails_for_duplicate_measurement_groups(self):
+ azimuth_time = np.array(
+ ["2024-01-01T00:00:00", "2024-01-01T00:00:01"], dtype="datetime64[ns]"
+ )
+ ground_range = np.array([0.0, 10.0])
+ invalid_dt = xr.DataTree.from_dict(
+ {
+ "S1A_IW_GRDH_TEST_VV/measurements": xr.Dataset(
+ {
+ "grd": xr.DataArray(
+ np.ones((2, 2), dtype="float32"),
+ dims=("azimuth_time", "ground_range"),
+ coords={
+ "azimuth_time": azimuth_time,
+ "ground_range": ground_range,
+ },
+ )
+ }
+ ),
+ "S1A_IW_GRDH_TEST_VV_DUP/measurements": xr.Dataset(
+ {
+ "grd": xr.DataArray(
+ np.ones((2, 2), dtype="float32"),
+ dims=("azimuth_time", "ground_range"),
+ coords={
+ "azimuth_time": azimuth_time,
+ "ground_range": ground_range,
+ },
+ )
+ }
+ ),
+ }
+ )
+
+ with pytest.raises(
+ ValueError,
+ match="Expected exactly one measurement group for polarization 'VV'",
+ ):
+ self.mode._open_data(invalid_dt, includes=["vv"])
+
+ def test_convert_datatree_warns_when_processing_full_product(self):
+ with (
+ self.assertWarns(UserWarning) as cm,
+ patch.object(sen1, "get_dem", return_value=self.dem),
+ patch.object(self.mode, "_terrain_correct", return_value=self.expected_vv),
+ ):
+ self.mode.convert_datatree(self.dt, includes=["vv"])
+ self.assertIn("No bounding box specified", str(cm.warning))
+
+ def test_terrain_correct_with_rtc_nearest(self):
+ with (
+ patch.object(sen1, "get_source_location", return_value=self.src_loc),
+ patch.object(sen1, "geocode_data", return_value=self.expected_beta0_vv),
+ patch.object(
+ sen1,
+ "apply_gamma_weights",
+ return_value=xr.ones_like(self.src_loc.gamma_area),
+ ) as gamma_mock,
+ patch.object(sen1, "assign_grid_mapping", side_effect=lambda ds: ds),
+ patch.object(xr.Dataset, "to_zarr", return_value=None),
+ patch.object(sen1.xr, "open_zarr", return_value=self.src_loc),
+ ):
+ self.mode.cache_uri = f"tmp_{uuid.uuid4().hex}"
+ out = self.mode._terrain_correct(
+ self.dt,
+ self.expected_beta0_vv,
+ self.dem,
+ apply_rtc=True,
+ interp_method="nearest",
+ )
+ gamma_mock.assert_called_once()
+ self.assertIn("gamma0_vv", out.data_vars)
+
+ def test_terrain_correct_with_rtc_bilinear(self):
+ with (
+ patch.object(sen1, "get_source_location", return_value=self.src_loc),
+ patch.object(sen1, "geocode_data", return_value=self.expected_beta0_vv),
+ patch.object(
+ sen1,
+ "apply_gamma_weights",
+ return_value=xr.ones_like(self.src_loc.gamma_area),
+ ) as gamma_mock,
+ patch.object(sen1, "assign_grid_mapping", side_effect=lambda ds: ds),
+ patch.object(xr.Dataset, "to_zarr", return_value=None),
+ patch.object(sen1.xr, "open_zarr", return_value=self.src_loc),
+ ):
+ self.mode.cache_uri = f"tmp_{uuid.uuid4().hex}"
+ out = self.mode._terrain_correct(
+ self.dt,
+ self.expected_beta0_vv,
+ self.dem,
+ apply_rtc=True,
+ interp_method="bilinear",
+ )
+ gamma_mock.assert_called_once()
+ self.assertIn("gamma0_vv", out.data_vars)
+
+ def test_terrain_correct_without_rtc(self):
+ with (
+ patch.object(sen1, "get_source_location", return_value=self.src_loc),
+ patch.object(sen1, "geocode_data", return_value=self.expected_beta0_vv),
+ patch.object(sen1, "apply_gamma_weights") as gamma_mock,
+ patch.object(sen1, "assign_grid_mapping", side_effect=lambda ds: ds),
+ patch.object(xr.Dataset, "to_zarr", return_value=None),
+ patch.object(sen1.xr, "open_zarr", return_value=self.src_loc),
+ ):
+ self.mode.cache_uri = f"tmp_{uuid.uuid4().hex}"
+ out = self.mode._terrain_correct(
+ self.dt,
+ self.expected_beta0_vv,
+ self.dem,
+ apply_rtc=False,
+ )
+ gamma_mock.assert_not_called()
+ self.assertIn("beta0_vv", out.data_vars)
+
+ def test_cleanup_registered_cache_uris_removes_all_caches(self):
+ sen1._register_cache_uri("file:///tmp/cache-a")
+ sen1._register_cache_uri("file:///tmp/cache-b")
+
+ fs = SimpleNamespace(exists=lambda path: True, rm=lambda path, recursive: None)
+ with patch.object(
+ sen1.fsspec,
+ "url_to_fs",
+ return_value=(fs, "/tmp/fake-cache"),
+ ) as url_to_fs:
+ sen1._cleanup_registered_cache_uris()
+
+ self.assertEqual(2, url_to_fs.call_count)
+ url_to_fs.assert_any_call("file:///tmp/cache-a")
+ url_to_fs.assert_any_call("file:///tmp/cache-b")
+ self.assertEqual(
+ ["file:///tmp/cache-a", "file:///tmp/cache-b"],
+ sen1._REGISTERED_CACHE_URIS,
+ )
+
+
+class Sen1SLCTest(Sen1TestMixin, TestCase):
+
+ def setUp(self):
+ sen1._REGISTERED_CACHE_URIS.clear()
+ self.mode = Sen1SLC()
+ self.dt = make_s1_slc_datatree()
+ self.dem = xr.DataArray(
+ np.ones((2, 2), dtype="float32"),
+ dims=("lat", "lon"),
+ coords={"lat": [0.0, 1.0], "lon": [0.0, 1.0]},
+ )
+ self.expected_beta0 = xr.Dataset(
+ {
+ "beta0_vv": xr.DataArray(
+ np.ones((2, 2)),
+ dims=("lat", "lon"),
+ coords={"lat": [0.0, 1.0], "lon": [0.0, 1.0]},
+ )
+ }
+ )
+ self.src_loc = xr.Dataset(
+ {
+ "azimuth_time": (
+ ("lat", "lon"),
+ np.zeros((2, 2), dtype="datetime64[ns]"),
+ ),
+ "slant_range_time": (("lat", "lon"), np.zeros((2, 2))),
+ "gamma_area": (("lat", "lon"), np.ones((2, 2))),
+ }
+ )
+
+ def tearDown(self):
+ sen1._REGISTERED_CACHE_URIS.clear()
+
+ @staticmethod
+ def _make_slc_dataset(
+ azimuth_time: np.ndarray, slant_range_time: np.ndarray
+ ) -> xr.Dataset:
+ return xr.Dataset(
+ {
+ "beta0_vv": xr.DataArray(
+ np.ones((len(azimuth_time), len(slant_range_time))),
+ dims=("azimuth_time", "slant_range_time"),
+ coords={
+ "azimuth_time": azimuth_time,
+ "slant_range_time": slant_range_time,
+ },
+ )
+ }
+ )
+
+ @staticmethod
+ def _as_object_array(*datasets: xr.Dataset) -> np.ndarray:
+ out = np.empty(len(datasets), dtype=object)
+ for idx, dataset in enumerate(datasets):
+ out[idx] = dataset
+ return out
+
+ def test_is_valid_source_ok(self):
+ self.assertTrue(self.mode.is_valid_source("data/S1A_IW_SLC_20240201.zarr"))
+ self.assertTrue(self.mode.is_valid_source("S1D_SM_SLC_TEST"))
+
+ def test_is_not_valid_source(self):
+ self.assertFalse(self.mode.is_valid_source("data/S1A_IW_GRDH_20240201.zarr"))
+ self.assertFalse(self.mode.is_valid_source(dict()))
+
+ def test_get_groups(self):
+ groups = self.mode._get_groups(self.dt)
+ self.assertEqual(2, len(groups))
+ self.assertEqual(2, len(groups[0]))
+ self.assertEqual(3, len(groups[0][0]))
+ self.assertEqual(1, len(groups[0][0][0]))
+ self.assertEqual("S1A_IW_SLC_TEST_VV_IW1_0", groups[0][0][0][0])
+ self.assertEqual(["VH", "VV"], [str(mode) for mode in groups[1]])
+
+ def test_get_grid_parameters(self):
+ params = sen1._get_grid_parameters(
+ self.dt, (2.0, 3.0), range_coord="slant_range_time"
+ )
+ self.assertEqual(0.0, params["range0"])
+ self.assertEqual(1.0, params["d_range"])
+ self.assertEqual(10.0, params["spacing_range"])
+ self.assertEqual(3.0, params["d_range_scale"])
+ self.assertEqual(30.0, params["spacing_range_scale"])
+ self.assertEqual(1.0, params["range0_scale"])
+
+ def test_calibrate_burst_and_extract_valid_region(self):
+ burst = self.dt["S1A_IW_SLC_TEST_VV_IW1_0"]
+ beta0 = self.mode._calibrate_burst(burst)
+ trimmed = self.mode._extract_valid_region(beta0, burst)
+ self.assertEqual(("azimuth_time", "slant_range_time"), trimmed.slc.dims)
+ self.assertEqual(2, trimmed.sizes["azimuth_time"])
+ self.assertEqual(2, trimmed.sizes["slant_range_time"])
+
+ def test_open_data(self):
+ out = self.mode._open_data(self.dt, includes=["vv", "vh"])
+ self.assertCountEqual(["beta0_vv", "beta0_vh"], out.data_vars)
+ self.assertNotIn("line", out.coords)
+ self.assertNotIn("pixel", out.coords)
+ self.assertEqual({"azimuth_time": 2, "slant_range_time": 4}, out.sizes)
+
+ def test_open_data_accepts_string_include(self):
+ out = self.mode._open_data(self.dt, includes="vv")
+ self.assertEqual(["beta0_vv"], list(out.data_vars))
+
+ def test_open_data_fails_when_no_variables_match(self):
+ with pytest.raises(
+ ValueError, match="No valid variable names found in dataset"
+ ):
+ self.mode._open_data(self.dt, includes="does_not_exist")
+
+ def test_convert_datatree(self):
+ with patch.object(
+ self.mode, "_terrain_correct", return_value=self.expected_beta0
+ ) as mocked:
+ out = self.mode.convert_datatree(self.dt, includes=["vv"], dem=self.dem)
+ self.assertIs(out, self.expected_beta0)
+ args, kwargs = mocked.call_args
+ self.assertIs(args[0], self.dt)
+ self.assertEqual(["beta0_vv"], list(args[1].data_vars))
+ self.assertIs(args[2], self.dem)
+ self.assertEqual("bilinear", kwargs["interp_method"])
+
+ def test_terrain_correct_uses_slant_range_path(self):
+ with (
+ patch.object(
+ sen1, "get_source_location", return_value=self.src_loc
+ ) as src_mock,
+ patch.object(sen1, "geocode_data", return_value=self.expected_beta0),
+ patch.object(
+ sen1,
+ "apply_gamma_weights",
+ return_value=xr.ones_like(self.src_loc.gamma_area),
+ ) as gamma_mock,
+ patch.object(sen1, "assign_grid_mapping", side_effect=lambda ds: ds),
+ patch.object(xr.Dataset, "to_zarr", return_value=None),
+ patch.object(sen1.xr, "open_zarr", return_value=self.src_loc),
+ ):
+ self.mode.cache_uri = f"tmp_{uuid.uuid4().hex}"
+ out = self.mode._terrain_correct(
+ self.dt,
+ self.expected_beta0,
+ self.dem,
+ apply_rtc=True,
+ interp_method="nearest",
+ )
+ self.assertIn("gamma0_vv", out.data_vars)
+ self.assertIsNone(src_mock.call_args.kwargs["time_slr_gcp"])
+ self.assertEqual("slant_range_time", src_mock.call_args.kwargs["range_coord"])
+ self.assertEqual("slant_range_time", gamma_mock.call_args.kwargs["range_coord"])
+
+ def test_merge_bursts_warns_on_irregular_spacing(self):
+ ds0 = self._make_slc_dataset(
+ np.array(
+ [
+ "2024-01-01T00:00:00",
+ "2024-01-01T00:00:01",
+ "2024-01-01T00:00:02",
+ ],
+ dtype="datetime64[ns]",
+ ),
+ np.array([0.0, 1.0]),
+ )
+ ds1 = self._make_slc_dataset(
+ np.array(
+ [
+ "2024-01-01T00:00:01.800000000",
+ "2024-01-01T00:00:03.800000000",
+ "2024-01-01T00:00:05.800000000",
+ ],
+ dtype="datetime64[ns]",
+ ),
+ np.array([0.0, 1.0]),
+ )
+ with pytest.warns(UserWarning, match="Azimuth time spacing is not regular"):
+ out = self.mode._merge_bursts(self._as_object_array(ds0, ds1))
+ self.assertEqual(5, out.sizes["azimuth_time"])
+
+ def test_merge_bursts_raises_without_overlap(self):
+ ds0 = self._make_slc_dataset(
+ np.array(
+ ["2024-01-01T00:00:00", "2024-01-01T00:00:01"],
+ dtype="datetime64[ns]",
+ ),
+ np.array([0.0, 1.0]),
+ )
+ ds1 = self._make_slc_dataset(
+ np.array(
+ ["2024-01-01T00:00:10", "2024-01-01T00:00:11"],
+ dtype="datetime64[ns]",
+ ),
+ np.array([0.0, 1.0]),
+ )
+ with pytest.raises(ValueError, match="No overlap found"):
+ self.mode._merge_bursts(self._as_object_array(ds0, ds1))
+
+ def test_align_azimuth_warns_on_irregular_spacing(self):
+ ds0 = self._make_slc_dataset(
+ np.array(
+ [
+ "2024-01-01T00:00:00",
+ "2024-01-01T00:00:01",
+ "2024-01-01T00:00:02",
+ "2024-01-01T00:00:03",
+ ],
+ dtype="datetime64[ns]",
+ ),
+ np.array([0.0, 1.0]),
+ )
+ ds1 = self._make_slc_dataset(
+ np.array(
+ [
+ "2024-01-01T00:00:00",
+ "2024-01-01T00:00:01.100000000",
+ "2024-01-01T00:00:02.100000000",
+ "2024-01-01T00:00:03.100000000",
+ ],
+ dtype="datetime64[ns]",
+ ),
+ np.array([0.0, 1.0]),
+ )
+ with pytest.warns(UserWarning, match="Azimuth spacing is irregular"):
+ out = self.mode._align_azimuth(self._as_object_array(ds0, ds1))
+ self.assertEqual(4, out[0].sizes["azimuth_time"])
+ self.assertTrue(
+ np.array_equal(out[0].azimuth_time.values, out[1].azimuth_time.values)
+ )
+
+ def test_align_azimuth_warns_on_size_difference(self):
+ ds0 = self._make_slc_dataset(
+ np.array(
+ [
+ "2024-01-01T00:00:00",
+ "2024-01-01T00:00:01",
+ "2024-01-01T00:00:02",
+ "2024-01-01T00:00:03",
+ "2024-01-01T00:00:04",
+ ],
+ dtype="datetime64[ns]",
+ ),
+ np.array([0.0, 1.0]),
+ )
+ ds1 = self._make_slc_dataset(
+ np.array(
+ [
+ "2024-01-01T00:00:01",
+ "2024-01-01T00:00:02",
+ "2024-01-01T00:00:03.300000000",
+ "2024-01-01T00:00:04.300000000",
+ ],
+ dtype="datetime64[ns]",
+ ),
+ np.array([0.0, 1.0]),
+ )
+ with pytest.warns(
+ UserWarning, match="Aligned swaths have different azimuth sizes"
+ ):
+ out = self.mode._align_azimuth(self._as_object_array(ds0, ds1))
+ self.assertEqual(3, out[0].sizes["azimuth_time"])
+
+ def test_merge_swaths_warns_on_irregular_spacing(self):
+ ds0 = self._make_slc_dataset(
+ np.array(
+ ["2024-01-01T00:00:00", "2024-01-01T00:00:01"],
+ dtype="datetime64[ns]",
+ ),
+ np.array([0.0, 1.0, 2.0]),
+ )
+ ds1 = self._make_slc_dataset(
+ np.array(
+ ["2024-01-01T00:00:00", "2024-01-01T00:00:01"],
+ dtype="datetime64[ns]",
+ ),
+ np.array([1.8, 3.8, 5.8]),
+ )
+ with pytest.warns(UserWarning, match="Slant range spacing is irregular"):
+ out = self.mode._merge_swaths([ds0, ds1])
+ self.assertEqual(5, out.sizes["slant_range_time"])
+
+ def test_merge_swaths_raises_without_overlap(self):
+ ds0 = self._make_slc_dataset(
+ np.array(
+ ["2024-01-01T00:00:00", "2024-01-01T00:00:01"],
+ dtype="datetime64[ns]",
+ ),
+ np.array([0.0, 1.0]),
+ )
+ ds1 = self._make_slc_dataset(
+ np.array(
+ ["2024-01-01T00:00:00", "2024-01-01T00:00:01"],
+ dtype="datetime64[ns]",
+ ),
+ np.array([10.0, 11.0]),
+ )
+ with pytest.raises(ValueError, match="No overlap found"):
+ self.mode._merge_swaths([ds0, ds1])
+
class Sen1OCNTest(Sen1TestMixin, TestCase):
mode = Sen1OCN()
@@ -167,6 +677,10 @@ class Sen1OCNTest(Sen1TestMixin, TestCase):
agg_methods="nearest",
),
)
+ with pytest.raises(
+ TypeError, match="resolution argument must contain exactly two"
+ ):
+ self.mode.get_applicable_params(resolution=(1, "x"))
with pytest.raises(TypeError):
self.mode.get_applicable_params(interp_methods="cubic")
@@ -227,48 +741,100 @@ class Sen1OCNTest(Sen1TestMixin, TestCase):
class Sentinel1FunctionsTest(TestCase):
- def test_gridparams_iter(self):
- params = sen1.GridParams(
- slr0=0.0,
- d_slr=1.0,
- spacing_slr=2.0,
+
+ def setUp(self):
+ self.gm_dem_params = {
+ "crs": "EPSG:4326",
+ "xy_var_names": ("lat", "lon"),
+ }
+ self.grid_params = sen1.GridParams(
+ range0=0.0,
+ range0_scale=0.0,
+ d_range=1.0,
+ d_range_scale=1.0,
+ spacing_range=1.0,
+ spacing_range_scale=1.0,
az0=np.datetime64("2024-01-01T00:00:00"),
+ az0_scale=np.datetime64("2024-01-01T00:00:00"),
d_az=1.0,
- spacing_az=3.0,
+ d_az_scale=1.0,
+ spacing_az=2.0,
+ spacing_az_scale=2.0,
)
- self.assertEqual(
- ["slr0", "d_slr", "spacing_slr", "az0", "d_az", "spacing_az"],
- list(iter(params)),
+ self.dem = xr.DataArray(
+ np.ones((2, 2), dtype="float64"),
+ dims=("lat", "lon"),
+ coords={
+ "lat": [0.0, 0.1],
+ "lon": [0.0, 0.1],
+ "spatial_ref": xr.DataArray(
+ 0, attrs=pyproj.CRS.from_epsg(4326).to_cf()
+ ),
+ },
)
-
- def test_acquisition_getitem_raises_for_missing_gamma_area(self):
- acquisition = sen1.Acquisition(
- azimuth_time=xr.DataArray(np.array([0]), dims=("lat",)),
- distance=xr.DataArray(np.ones((3, 1, 1)), dims=("axis", "lat", "lon")),
- velocity=xr.DataArray(np.ones((3, 1, 1)), dims=("axis", "lat", "lon")),
- slant_range_time=xr.DataArray(np.array([0.0]), dims=("lon",)),
- gamma_area=None,
+ self.dem_ecef = xr.DataArray(
+ np.ones((3, 2, 2), dtype="float64"),
+ dims=("axis", "lat", "lon"),
+ coords={"axis": ["x", "y", "z"], "lat": [0.0, 0.1], "lon": [0.0, 0.1]},
+ )
+ self.posvel_coeff = xr.DataArray(
+ np.zeros((2, 3)),
+ dims=("degree", "axis"),
+ coords={"degree": [1, 0], "axis": ["x", "y", "z"]},
+ attrs={"epoch": np.datetime64("2024-01-01T00:00:00")},
+ )
+ self.gr_coeff = xr.DataArray(
+ np.zeros((2, 9)),
+ dims=("azimuth_time", "degree"),
+ coords={
+ "degree": np.arange(8, -1, -1),
+ "azimuth_time": np.array(
+ ["2024-01-01T00:00:00", "2024-01-01T00:00:02"],
+ dtype="datetime64[ns]",
+ ),
+ },
+ attrs=dict(mean=1, std=1),
+ )
+ self.time_slr = xr.DataArray(
+ np.ones((2, 2), dtype="float64"),
+ dims=("azimuth_time", "ground_range"),
+ coords={"azimuth_time": [0, 1], "ground_range": [0, 1]},
+ )
+ self.sat_position = xr.DataArray(
+ np.ones((2, 3), dtype="float64"),
+ dims=("azimuth_time", "axis"),
+ coords={"azimuth_time": [0, 1], "axis": ["x", "y", "z"]},
)
- with pytest.raises(KeyError, match="gamma_area"):
- _ = acquisition["gamma_area"]
- def test_acquisition_getitem_returns_existing_field(self):
- distance = xr.DataArray(np.ones((3, 1, 1)), dims=("axis", "lat", "lon"))
- acquisition = sen1.Acquisition(
- azimuth_time=xr.DataArray(np.array([0]), dims=("lat",)),
- distance=distance,
- velocity=xr.DataArray(np.ones((3, 1, 1)), dims=("axis", "lat", "lon")),
- slant_range_time=xr.DataArray(np.array([0.0]), dims=("lon",)),
- gamma_area=None,
+ def test_gridparams_iter(self):
+ self.assertEqual(
+ [
+ "range0",
+ "range0_scale",
+ "d_range",
+ "d_range_scale",
+ "spacing_range",
+ "spacing_range_scale",
+ "az0",
+ "az0_scale",
+ "d_az",
+ "d_az_scale",
+ "spacing_az",
+ "spacing_az_scale",
+ ],
+ list(iter(self.grid_params)),
)
- self.assertIs(acquisition["distance"], distance)
+
+ def test_gridparams_contains(self):
+ self.assertIn("range0", self.grid_params)
+ self.assertNotIn("gr0", self.grid_params)
def test_get_dem_requires_credentials(self):
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(ValueError, match="Missing AWS credentials"):
sen1.get_dem([0, 50, 1, 51])
- def test_get_dem_resolution_required_if_crs_given(self):
+ def test_get_dem_with_projected_crs_uses_inferred_resolution(self):
with patch.dict(
os.environ,
{"AWS_ACCESS_KEY_ID": "k", "AWS_SECRET_ACCESS_KEY": "s"},
@@ -277,6 +843,18 @@ class Sentinel1FunctionsTest(TestCase):
with (
patch.object(sen1.pystac_client.Client, "open") as client_open,
patch.object(sen1.rioxarray, "open_rasterio") as open_rasterio,
+ patch.object(sen1, "transform_resolution", return_value=30.0) as tr,
+ patch.object(
+ sen1,
+ "resample_in_space",
+ return_value=SimpleNamespace(
+ dem=xr.DataArray(
+ np.ones((2, 2), dtype="float32"),
+ dims=("lat", "lon"),
+ coords={"lat": [1.0, 0.0], "lon": [0.0, 1.0]},
+ )
+ ),
+ ) as resample,
):
fake_item = SimpleNamespace(assets={"data": SimpleNamespace(href="x")})
search = SimpleNamespace(items=lambda: [fake_item])
@@ -287,10 +865,12 @@ class Sentinel1FunctionsTest(TestCase):
coords={"band": [1], "y": [3, 2, 1, 0], "x": [0, 1, 2, 3]},
)
- with pytest.raises(
- ValueError, match="Resolution must be provided if CRS is not None"
- ):
- sen1.get_dem([0, 0, 1, 1], crs=pyproj.CRS.from_epsg(32632))
+ out = sen1.get_dem([0, 0, 900, 900], crs=pyproj.CRS.from_epsg(32632))
+
+ tr.assert_called_once()
+ resample.assert_called_once()
+ self.assertIsInstance(out, xr.DataArray)
+ self.assertEqual((2, 2), out.shape)
def test_get_dem_reprojects_bbox_and_resamples(self):
with patch.dict(
@@ -392,7 +972,10 @@ class Sentinel1FunctionsTest(TestCase):
coords={"lat": [0, 1, 2, 3], "lon": [0, 1, 2, 3]},
)
gm_dem = GridMapping.from_dataset(dem.to_dataset(name="dem"))
- out = sen1.convert_dem_to_ecef(dem, gm_dem)
+ out = sen1.convert_dem_to_ecef(
+ dem,
+ {"crs": gm_dem.crs.to_wkt(), "xy_var_names": gm_dem.xy_var_names},
+ )
self.assertEqual(("axis", "lat", "lon"), out.dims)
self.assertEqual(3, out.sizes["axis"])
@@ -419,27 +1002,127 @@ class Sentinel1FunctionsTest(TestCase):
deriv = sen1.poly_derivative(coeff)
self.assertEqual(coeff.sizes["degree"] - 1, deriv.sizes["degree"])
- def test_zero_doppler_and_prime(self):
- dem_ecef = xr.DataArray(
- np.ones((3, 2, 2), dtype="float64"),
- dims=("axis", "lat", "lon"),
- coords={"axis": ["x", "y", "z"], "lat": [0.0, 1.0], "lon": [0.0, 1.0]},
+ def test_get_source_location_and_assign_grid_mapping(self):
+ dem = xr.DataArray(
+ np.ones((2, 2), dtype="float64"),
+ dims=("lat", "lon"),
+ coords={"lat": [0.0, 1.0], "lon": [0.0, 1.0]},
)
- time_orbit = xr.DataArray(np.zeros((2, 2)), dims=("lat", "lon"))
- pos_coeff = xr.DataArray(
- np.zeros((2, 3)),
- dims=("degree", "axis"),
- coords={"degree": [1, 0], "axis": ["x", "y", "z"]},
+ time_slr = xr.DataArray(
+ np.ones((2, 2), dtype="float64"),
+ dims=("azimuth_time", "ground_range"),
+ coords={"azimuth_time": [0, 1], "ground_range": [0, 1]},
)
- vel_coeff = xr.DataArray(
- np.zeros((2, 3)),
- dims=("degree", "axis"),
- coords={"degree": [1, 0], "axis": ["x", "y", "z"]},
+ sat_position = xr.DataArray(
+ np.ones((2, 3), dtype="float64"),
+ dims=("azimuth_time", "axis"),
+ coords={"azimuth_time": [0, 1], "axis": ["x", "y", "z"]},
+ )
+ with (
+ patch.object(sen1, "fit_ground_range", return_value=self.gr_coeff),
+ patch.object(sen1, "fit_position", return_value=self.posvel_coeff),
+ patch.object(sen1, "backward_geocode") as bg,
+ ):
+ bg.return_value = xr.Dataset(
+ {
+ "azimuth_time": xr.DataArray(
+ np.zeros((2, 2), dtype="datetime64[ns]"), dims=("lat", "lon")
+ ),
+ "ground_range": xr.DataArray(np.zeros((2, 2)), dims=("lat", "lon")),
+ "gamma_area": xr.DataArray(np.ones((2, 2)), dims=("lat", "lon")),
+ }
+ )
+ gm_dem = GridMapping.from_dataset(dem.to_dataset(name="dem"))
+ out = sen1.get_source_location(
+ dem,
+ time_slr,
+ sat_position,
+ self.grid_params,
+ gm_dem,
+ True,
+ )
+ self.assertIn("spatial_ref", out.coords)
+ out = sen1.assign_grid_mapping(
+ xr.Dataset({"a": xr.DataArray([1], dims=("x",))})
+ )
+ self.assertEqual("spatial_ref", out["a"].attrs["grid_mapping"])
+
+ def test_get_source_location_without_rtc(self):
+
+ gm_dem = GridMapping.from_dataset(self.dem.to_dataset(name="dem"))
+ with (
+ patch.object(
+ sen1,
+ "backward_geocode",
+ return_value=xr.Dataset(
+ {
+ "azimuth_time": xr.DataArray(
+ np.zeros((2, 2), dtype="datetime64[ns]"),
+ dims=("lat", "lon"),
+ ),
+ "ground_range": xr.DataArray(
+ np.zeros((2, 2)), dims=("lat", "lon")
+ ),
+ }
+ ),
+ ) as bg,
+ patch.object(sen1, "fit_ground_range", return_value=self.gr_coeff),
+ patch.object(sen1, "fit_position", return_value=self.posvel_coeff),
+ ):
+ out = sen1.get_source_location(
+ self.dem,
+ self.time_slr,
+ self.sat_position,
+ self.grid_params,
+ gm_dem,
+ False,
+ )
+ bg.assert_called_once()
+ self.assertNotIn("gamma_area", out.data_vars)
+
+ def test_compute_indexing_and_sample_array_errors(self):
+ data = xr.Dataset(
+ {
+ "a": xr.DataArray(
+ np.arange(9).reshape(3, 3), dims=("azimuth_time", "ground_range")
+ )
+ }
+ )
+ az_idx = xr.DataArray(
+ da.from_array(np.array([[0.2, 1.2], [0.2, 1.2]]), chunks=(2, 2)),
+ dims=("lat", "lon"),
+ )
+ gr_idx = xr.DataArray(
+ da.from_array(np.array([[0.2, 1.2], [0.2, 1.2]]), chunks=(2, 2)),
+ dims=("lat", "lon"),
+ )
+ indexing = sen1._compute_indexing(data, az_idx, gr_idx)
+ np.testing.assert_array_equal(
+ indexing.ij_bboxes,
+ np.array([[[0]], [[0]], [[3]], [[3]]], dtype=np.int32),
+ )
+
+ arr = np.array([[1.0, 2.0], [3.0, 4.0]])
+ nearest = sen1._sample_array_at_indices(
+ arr, np.array([[0.6]]), np.array([[1.4]]), "nearest"
+ )
+ np.testing.assert_array_equal(nearest, np.array([[4.0]]))
+ bilinear = sen1._sample_array_at_indices(
+ arr, np.array([[0.5]]), np.array([[0.5]]), "bilinear"
+ )
+ np.testing.assert_allclose(bilinear, np.array([[2.5]]))
+ with pytest.raises(NotImplementedError, match="interp_methods"):
+ sen1._sample_array_at_indices(
+ np.zeros((2, 2)), np.zeros((2, 2)), np.zeros((2, 2)), "cubic"
+ )
+
+ def test_zero_doppler_and_prime(self):
+ time_orbit = xr.DataArray(np.zeros((2, 2)), dims=("lat", "lon"))
+ f, payload = sen1.zero_doppler(
+ self.dem_ecef, self.posvel_coeff, self.posvel_coeff, time_orbit
)
- vel_coeff.loc[{"degree": 0, "axis": "x"}] = 1.0
- f, payload = sen1.zero_doppler(dem_ecef, pos_coeff, vel_coeff, time_orbit)
self.assertEqual(("lat", "lon"), f.dims)
- fp = sen1.zero_doppler_prime(vel_coeff, time_orbit, payload)
+ fp = sen1.zero_doppler_prime(self.posvel_coeff, time_orbit, payload)
self.assertEqual(("lat", "lon"), fp.dims)
def test_secant_and_newton(self):
@@ -482,40 +1165,28 @@ class Sentinel1FunctionsTest(TestCase):
self.assertEqual(0, k)
def test_backward_geocode_invalid_method(self):
- dem_ecef = xr.DataArray(
- np.ones((3, 1, 1), dtype="float64"),
- dims=("axis", "lat", "lon"),
- coords={"axis": ["x", "y", "z"], "lat": [0.0], "lon": [0.0]},
- )
- coeff = xr.DataArray(
- np.zeros((2, 3)),
- dims=("degree", "axis"),
- coords={"degree": [1, 0], "axis": ["x", "y", "z"]},
- )
with pytest.raises(ValueError, match="method needs to be either"):
- sen1.backward_geocode(dem_ecef, coeff, coeff, method="x")
+ sen1.backward_geocode(
+ self.dem,
+ pos_coeff=self.posvel_coeff,
+ vel_coeff=self.posvel_coeff,
+ gr_coeff=self.gr_coeff,
+ gm_dem_params=self.gm_dem_params,
+ grid_params=self.grid_params,
+ method="x",
+ )
def test_backward_geocode_secant_and_newton_paths(self):
- dem_ecef = xr.DataArray(
- np.ones((3, 1, 1), dtype="float64"),
- dims=("axis", "lat", "lon"),
- coords={"axis": ["x", "y", "z"], "lat": [0.0], "lon": [0.0]},
- )
- coeff = xr.DataArray(
- np.zeros((2, 3)),
- dims=("degree", "axis"),
- coords={"degree": [1, 0], "axis": ["x", "y", "z"]},
- )
payload = (
- xr.DataArray(np.ones((3, 1, 1)), dims=("axis", "lat", "lon")),
- xr.DataArray(np.ones((3, 1, 1)), dims=("axis", "lat", "lon")),
+ xr.DataArray(np.ones((3, 2, 2)), dims=("axis", "lat", "lon")),
+ xr.DataArray(np.ones((3, 2, 2)), dims=("axis", "lat", "lon")),
)
with (
patch.object(
sen1,
"secant",
return_value=(
- xr.DataArray([[0.0]], dims=("lat", "lon")),
+ xr.DataArray([[0.0, 0.0], [0.0, 0.0]], dims=("lat", "lon")),
None,
None,
0,
@@ -526,96 +1197,35 @@ class Sentinel1FunctionsTest(TestCase):
sen1,
"newton",
return_value=(
- xr.DataArray([[0.0]], dims=("lat", "lon")),
+ xr.DataArray([[0.0, 0.0], [0.0, 0.0]], dims=("lat", "lon")),
None,
0,
payload,
),
),
):
- out_secant = sen1.backward_geocode(dem_ecef, coeff, coeff, method="secant")
- out_newton = sen1.backward_geocode(dem_ecef, coeff, coeff, method="newton")
- self.assertEqual(3, len(out_secant))
- self.assertEqual(3, len(out_newton))
-
- def test_simulate_acquisition_without_rtc(self):
- dem_ecef = xr.DataArray(
- np.ones((3, 2, 2), dtype="float64"),
- dims=("axis", "lat", "lon"),
- coords={"axis": ["x", "y", "z"], "lat": [0.0, 1.0], "lon": [0.0, 1.0]},
- )
- gm_dem = GridMapping.from_dataset(dem_ecef.to_dataset(name="dem"))
- sat_position = xr.DataArray(
- np.zeros((2, 3)),
- dims=("azimuth_time", "axis"),
- coords={
- "azimuth_time": np.array(
- [
- np.datetime64("2024-01-01T00:00:00"),
- np.datetime64("2024-01-01T00:00:01"),
- ]
- ),
- "axis": ["x", "y", "z"],
- },
- )
- dist = xr.DataArray(
- np.ones((3, 2, 2), dtype="float64"),
- dims=("axis", "lat", "lon"),
- coords={"axis": ["x", "y", "z"], "lat": [0.0, 1.0], "lon": [0.0, 1.0]},
- )
- vel = dist.copy()
- t = xr.DataArray(np.zeros((2, 2), dtype="float64"), dims=("lat", "lon"))
- with patch.object(sen1, "backward_geocode", return_value=(t, dist, vel)):
- acq = sen1.simulate_acquisition(
- dem_ecef, gm_dem, sat_position, apply_rtc=False
+ out_secant = sen1.backward_geocode(
+ self.dem,
+ pos_coeff=self.posvel_coeff,
+ vel_coeff=self.posvel_coeff,
+ gr_coeff=self.gr_coeff,
+ grid_params=self.grid_params,
+ gm_dem_params=self.gm_dem_params,
+ method="secant",
)
- self.assertIn("slant_range_time", acq)
- self.assertNotIn("gamma_area", acq)
-
- def test_simulate_acquisition_with_rtc(self):
- dem_ecef = xr.DataArray(
- np.ones((3, 2, 2), dtype="float64"),
- dims=("axis", "lat", "lon"),
- coords={"axis": ["x", "y", "z"], "lat": [0.0, 1.0], "lon": [0.0, 1.0]},
- )
- gm_dem = GridMapping.from_dataset(dem_ecef.to_dataset(name="dem"))
- sat_position = xr.DataArray(
- np.zeros((2, 3)),
- dims=("azimuth_time", "axis"),
- coords={
- "azimuth_time": np.array(
- [
- np.datetime64("2024-01-01T00:00:00"),
- np.datetime64("2024-01-01T00:00:01"),
- ]
- ),
- "axis": ["x", "y", "z"],
- },
- )
- dist = xr.DataArray(
- np.ones((3, 2, 2), dtype="float64"),
- dims=("axis", "lat", "lon"),
- coords={"axis": ["x", "y", "z"], "lat": [0.0, 1.0], "lon": [0.0, 1.0]},
- )
- vel = dist.copy()
- t = xr.DataArray(np.zeros((2, 2), dtype="float64"), dims=("lat", "lon"))
- gamma = xr.DataArray(np.ones((2, 2)), dims=("lat", "lon"))
- with (
- patch.object(sen1, "backward_geocode", return_value=(t, dist, vel)),
- patch.object(sen1, "compute_gamma_area", return_value=gamma),
- ):
- acq = sen1.simulate_acquisition(
- dem_ecef, gm_dem, sat_position, apply_rtc=True
+ out_newton = sen1.backward_geocode(
+ self.dem,
+ pos_coeff=self.posvel_coeff,
+ vel_coeff=self.posvel_coeff,
+ gr_coeff=self.gr_coeff,
+ grid_params=self.grid_params,
+ gm_dem_params=self.gm_dem_params,
+ method="newton",
)
- self.assertIn("gamma_area", acq)
+ self.assertEqual(3, len(out_secant))
+ self.assertEqual(3, len(out_newton))
def test_compute_gamma_area_clips_negative(self):
- dem_ecef = xr.DataArray(
- np.ones((3, 2, 2), dtype="float64"),
- dims=("axis", "lat", "lon"),
- coords={"axis": ["x", "y", "z"], "lat": [0.0, 1.0], "lon": [0.0, 1.0]},
- )
- gm_dem = GridMapping.from_dataset(dem_ecef.to_dataset(name="dem"))
area = xr.DataArray(
np.array(
[
@@ -625,7 +1235,7 @@ class Sentinel1FunctionsTest(TestCase):
]
),
dims=("axis", "lat", "lon"),
- coords=dem_ecef.coords,
+ coords=self.dem_ecef.coords,
)
direction = xr.DataArray(
np.array(
@@ -636,10 +1246,12 @@ class Sentinel1FunctionsTest(TestCase):
]
),
dims=("axis", "lat", "lon"),
- coords=dem_ecef.coords,
+ coords=self.dem_ecef.coords,
)
with patch.object(sen1, "compute_dem_area", return_value=area):
- gamma = sen1.compute_gamma_area(dem_ecef, gm_dem, direction)
+ gamma = sen1.compute_gamma_area(
+ self.dem_ecef, self.gm_dem_params, direction
+ )
self.assertTrue(np.all(gamma.values >= 0))
self.assertEqual(0.0, float(gamma.values[0, 1]))
@@ -651,79 +1263,83 @@ class Sentinel1FunctionsTest(TestCase):
y = 20.0 + lat2d
z = 30.0 + lon2d + lat2d
dem_ecef = xr.DataArray(
- da.from_array(
- np.stack([x, y, z], axis=0).astype("float32"), chunks=(3, 2, 2)
- ),
+ np.stack([x, y, z], axis=0).astype("float32"),
dims=("axis", "lat", "lon"),
coords={"axis": ["x", "y", "z"], "lat": lat, "lon": lon},
)
- gm_dem = GridMapping.from_dataset(dem_ecef.to_dataset(name="dem"))
- area = sen1.compute_dem_area(dem_ecef, gm_dem)
+ area = sen1.compute_dem_area(dem_ecef, self.gm_dem_params)
self.assertEqual(("axis", "lat", "lon"), area.dims)
def test_sum_weights_and_gamma_weight_helpers(self):
- acq = xr.Dataset(
+ scr_indices = xr.Dataset(
{
"gamma_area": xr.DataArray([[1.0, 2.0]], dims=("lat", "lon")),
"az_idx": xr.DataArray([[0.2, 0.8]], dims=("lat", "lon")),
- "slr_idx": xr.DataArray([[1.2, 1.8]], dims=("lat", "lon")),
+ "gr_idx": xr.DataArray([[1.2, 1.8]], dims=("lat", "lon")),
}
)
reduced = xr.DataArray(
[[3.0]],
- dims=("slr_idx", "az_idx"),
- coords={"slr_idx": [1], "az_idx": [1]},
+ dims=("gr_idx", "az_idx"),
+ coords={"gr_idx": [1], "az_idx": [1]},
)
with patch.object(sen1.flox.xarray, "xarray_reduce", return_value=reduced):
- summed = sen1.sum_weights(acq.gamma_area, acq.az_idx, acq.slr_idx)
+ summed = sen1.sum_weights(
+ scr_indices.gamma_area, scr_indices.az_idx, scr_indices.gr_idx
+ )
self.assertEqual(("lat", "lon"), summed.dims)
self.assertEqual((1, 2), summed.data.shape)
with patch.object(
- sen1, "sum_weights", return_value=xr.zeros_like(acq.gamma_area)
+ sen1, "sum_weights", return_value=xr.zeros_like(scr_indices.gamma_area)
) as sw:
- _ = sen1.gamma_weights_bilinear(acq)
+ _ = sen1.gamma_weights_bilinear(scr_indices)
self.assertEqual(4, sw.call_count)
with patch.object(
- sen1, "sum_weights", return_value=xr.zeros_like(acq.gamma_area)
+ sen1, "sum_weights", return_value=xr.zeros_like(scr_indices.gamma_area)
) as sw:
- _ = sen1.gamma_weights_nearest(acq)
+ _ = sen1.gamma_weights_nearest(scr_indices)
sw.assert_called_once()
def test_apply_gamma_weights(self):
- azimuth_time = xr.DataArray(
- np.array(
- ["2024-01-01T00:00:00", "2024-01-01T00:00:01"], dtype="datetime64[ns]"
- ),
- dims=("lat",),
- )
- slant_range_time = xr.DataArray(np.array([0.0, 2.0]), dims=("lon",))
- distance = xr.DataArray(
- np.ones((3, 2, 2), dtype="float64"),
- dims=("axis", "lat", "lon"),
- coords={"axis": ["x", "y", "z"]},
+ params = sen1.GridParams(
+ range0=0.0,
+ range0_scale=0.0,
+ d_range=1.0,
+ d_range_scale=1.0,
+ spacing_range=1.0,
+ spacing_range_scale=1.0,
+ az0=np.datetime64("2024-01-01T00:00:00"),
+ az0_scale=np.datetime64("2024-01-01T00:00:00"),
+ d_az=1.0,
+ d_az_scale=1.0,
+ spacing_az=2.0,
+ spacing_az_scale=2.0,
)
- acq = sen1.Acquisition(
- azimuth_time=azimuth_time,
- distance=distance,
- velocity=distance,
- slant_range_time=slant_range_time,
- gamma_area=xr.DataArray(np.ones((2, 2)), dims=("lat", "lon")),
+ src_loc = xr.Dataset(
+ {
+ "azimuth_time": xr.DataArray(
+ np.array(
+ [
+ ["2024-01-01T00:00:00", "2024-01-01T00:00:01"],
+ ["2024-01-01T00:00:00", "2024-01-01T00:00:01"],
+ ],
+ dtype="datetime64[ns]",
+ ),
+ dims=("lat", "lon"),
+ ),
+ "ground_range": xr.DataArray(
+ np.array([[0.0, 1.0], [0.0, 1.0]]), dims=("lat", "lon")
+ ),
+ "gamma_area": xr.DataArray(np.ones((2, 2)), dims=("lat", "lon")),
+ }
)
def passthrough(ds):
return ds.gamma_area * 2
- params = sen1.GridParams(
- az0=azimuth_time.values[0],
- d_az=1.0,
- slr0=0.0,
- d_slr=1.0,
- spacing_slr=2.0,
- spacing_az=2.0,
- )
- out = sen1.apply_gamma_weights(acq, passthrough, params)
- self.assertTrue(np.allclose(out.values, 0.5))
+ out = sen1.apply_gamma_weights(src_loc, passthrough, params)
+ self.assertTrue(np.allclose(out.values, 1.0))
def test_fit_ground_range(self):
time_slr_gcp = xr.DataArray(
@@ -745,188 +1361,51 @@ class Sentinel1FunctionsTest(TestCase):
data = xr.Dataset(
{
"vv": xr.DataArray(
- np.ones((2, 3)), dims=("azimuth_time", "ground_range")
+ da.ones((2, 3), chunks=(2, 3)),
+ dims=("azimuth_time", "ground_range"),
)
},
- coords={"azimuth_time": [0, 1], "ground_range": [0, 1, 2]},
+ coords={
+ "azimuth_time": np.array(
+ ["2023-12-31T23:59:50", "2024-01-01T00:00:20"],
+ dtype="datetime64[ns]",
+ ),
+ "ground_range": [0, 3, 6],
+ },
)
time_az = xr.DataArray(
- da.from_array(np.array([[0, 0], [1, 1]]), chunks=(2, 2)),
+ da.from_array(
+ np.array(
+ [
+ ["2024-01-01T00:00:00", "2024-01-01T00:00:02"],
+ ["2024-01-01T00:00:04", "2024-01-01T00:00:06"],
+ ],
+ dtype="datetime64[ns]",
+ ),
+ chunks=(2, 2),
+ ),
dims=("lat", "lon"),
)
- time_slr = xr.DataArray(
+ ground_range = xr.DataArray(
da.from_array(np.array([[2, 3], [2, 3]]), chunks=(2, 2)),
dims=("lat", "lon"),
)
- time_slr_gcp = xr.DataArray(
- np.array([[1.0, 2.0, 3.0], [1.0, 2.0, 3.0]]),
- dims=("azimuth_time", "ground_range"),
- coords={"azimuth_time": [0, 1], "ground_range": [0, 1, 2]},
+ src_loc = xr.Dataset({"azimuth_time": time_az, "ground_range": ground_range})
+ grid_params = sen1.GridParams(
+ range0=0.0,
+ range0_scale=0.0,
+ d_range=3.0,
+ d_range_scale=3.0,
+ spacing_range=3.0,
+ spacing_range_scale=3.0,
+ az0=np.datetime64("2023-12-31T23:59:50"),
+ az0_scale=np.datetime64("2023-12-31T23:59:50"),
+ d_az=20.0,
+ d_az_scale=20.0,
+ spacing_az=3.0,
+ spacing_az_scale=3.0,
)
- out = sen1.geocode_data(data, time_az, time_slr, time_slr_gcp, "nearest")
+ out = sen1.geocode_data(data, src_loc, grid_params, "nearest")
self.assertIn("vv", out.data_vars)
np.testing.assert_allclose(out.vv.values, np.ones((2, 2), dtype=float))
-
- def test_terrain_correct_paths(self):
- data = xr.Dataset(
- {"vv": xr.DataArray(np.ones((2, 2)), dims=("azimuth_time", "ground_range"))}
- )
- time_slr_gcp = xr.DataArray(
- np.ones((2, 2)),
- dims=("azimuth_time", "ground_range"),
- coords={"azimuth_time": [0, 1], "ground_range": [0, 1]},
- )
- sat_position = xr.DataArray(
- np.ones((2, 3)),
- dims=("azimuth_time", "axis"),
- coords={"azimuth_time": [0, 1], "axis": ["x", "y", "z"]},
- )
- dem = xr.DataArray(
- np.ones((2, 2)),
- dims=("lat", "lon"),
- coords={"lat": [0, 1], "lon": [0, 1]},
- )
- distance = xr.DataArray(
- np.ones((3, 2, 2), dtype="float64"),
- dims=("axis", "lat", "lon"),
- coords={"axis": ["x", "y", "z"]},
- )
- acquisition = sen1.Acquisition(
- azimuth_time=xr.DataArray(np.array([0, 1]), dims=("lat",)),
- slant_range_time=xr.DataArray(np.array([0.0, 1.0]), dims=("lon",)),
- distance=distance,
- velocity=distance,
- gamma_area=xr.DataArray(np.ones((2, 2)), dims=("lat", "lon")),
- )
- geocoded = xr.Dataset(
- {"vv": xr.DataArray(np.full((2, 2), 2.0), dims=("lat", "lon"))}
- )
- with (
- patch.object(sen1, "convert_dem_to_ecef", return_value=dem),
- patch.object(
- sen1,
- "fit_position",
- return_value=xr.DataArray(
- np.ones((2, 3)),
- dims=("degree", "axis"),
- coords={"degree": [1, 0], "axis": ["x", "y", "z"]},
- ),
- ),
- patch.object(
- sen1,
- "poly_derivative",
- return_value=xr.DataArray(
- np.ones((1, 3)),
- dims=("degree", "axis"),
- coords={"degree": [0], "axis": ["x", "y", "z"]},
- ),
- ),
- patch.object(sen1, "simulate_acquisition", return_value=acquisition),
- patch.object(sen1, "geocode_data", return_value=geocoded),
- ):
- out = sen1.terrain_correct(
- data, time_slr_gcp, sat_position, dem, apply_rtc=False
- )
- self.assertIn("vv", out)
- self.assertEqual(2, out.vv.shape[0])
- self.assertEqual(2, out.vv.values[0, 0])
- with pytest.raises(ValueError, match="grid parameters required for RTC"):
- sen1.terrain_correct(
- data, time_slr_gcp, sat_position, dem, apply_rtc=True
- )
-
- with (
- patch.object(sen1, "convert_dem_to_ecef", return_value=dem),
- patch.object(
- sen1,
- "fit_position",
- return_value=xr.DataArray(
- np.ones((2, 3)),
- dims=("degree", "axis"),
- coords={"degree": [1, 0], "axis": ["x", "y", "z"]},
- ),
- ),
- patch.object(
- sen1,
- "poly_derivative",
- return_value=xr.DataArray(
- np.ones((1, 3)),
- dims=("degree", "axis"),
- coords={"degree": [0], "axis": ["x", "y", "z"]},
- ),
- ),
- patch.object(sen1, "simulate_acquisition", return_value=acquisition),
- patch.object(sen1, "geocode_data", return_value=geocoded),
- patch.object(
- sen1,
- "apply_gamma_weights",
- return_value=xr.DataArray(np.ones((2, 2)), dims=("lat", "lon")),
- ) as agw,
- ):
- out = sen1.terrain_correct(
- data,
- time_slr_gcp,
- sat_position,
- dem,
- apply_rtc=True,
- grid_params=sen1.GridParams(
- slr0=0.0,
- d_slr=1.0,
- spacing_slr=1.0,
- az0=np.datetime64("2024-01-01T00:00:00"),
- d_az=1.0,
- spacing_az=1.0,
- ),
- interp_method="bilinear",
- )
- self.assertIn("vv", out)
- args, _ = agw.call_args
- self.assertIs(args[1], sen1.gamma_weights_bilinear)
-
- with (
- patch.object(sen1, "convert_dem_to_ecef", return_value=dem),
- patch.object(
- sen1,
- "fit_position",
- return_value=xr.DataArray(
- np.ones((2, 3)),
- dims=("degree", "axis"),
- coords={"degree": [1, 0], "axis": ["x", "y", "z"]},
- ),
- ),
- patch.object(
- sen1,
- "poly_derivative",
- return_value=xr.DataArray(
- np.ones((1, 3)),
- dims=("degree", "axis"),
- coords={"degree": [0], "axis": ["x", "y", "z"]},
- ),
- ),
- patch.object(sen1, "simulate_acquisition", return_value=acquisition),
- patch.object(sen1, "geocode_data", return_value=geocoded),
- patch.object(
- sen1,
- "apply_gamma_weights",
- return_value=xr.DataArray(np.ones((2, 2)), dims=("lat", "lon")),
- ) as agw,
- ):
- _ = sen1.terrain_correct(
- data,
- time_slr_gcp,
- sat_position,
- dem,
- apply_rtc=True,
- grid_params=sen1.GridParams(
- slr0=0.0,
- d_slr=1.0,
- spacing_slr=1.0,
- az0=np.datetime64("2024-01-01T00:00:00"),
- d_az=1.0,
- spacing_az=1.0,
- ),
- interp_method="nearest",
- )
- args, _ = agw.call_args
- self.assertIs(args[1], sen1.gamma_weights_nearest)
=====================================
tests/amodes/test_sentinel3.py
=====================================
@@ -49,6 +49,12 @@ class Sen3TestMixin:
agg_methods={"scl": "mode"},
),
)
+ with pytest.raises(
+ TypeError, match="resolution argument must contain exactly two"
+ ):
+ self.mode.get_applicable_params(resolution=(1, "x"))
+ with pytest.raises(TypeError):
+ self.mode.get_applicable_params(interp_methods=["nearest"])
def test_process_metadata(self: TestCase):
self.assertEqual({}, self.mode.process_metadata(xr.DataTree()))
=====================================
tests/helpers/__init__.py
=====================================
@@ -2,13 +2,14 @@
# Permissions are hereby granted under the terms of the Apache 2.0 License:
# https://opensource.org/license/apache-2-0.
-from .sentinel1 import make_s1_grd_datatree, make_s1_ocn_datatree
+from .sentinel1 import make_s1_grd_datatree, make_s1_ocn_datatree, make_s1_slc_datatree
from .sentinel2 import make_s2_msi, make_s2_msi_l1c, make_s2_msi_l2a
from .sentinel3 import make_s3_olci_efr, make_s3_slstr_lst, make_s3_slstr_rbt
__all__ = [
"make_s1_grd_datatree",
"make_s1_ocn_datatree",
+ "make_s1_slc_datatree",
"make_s2_msi",
"make_s2_msi_l1c",
"make_s2_msi_l2a",
=====================================
tests/helpers/sentinel1.py
=====================================
@@ -86,6 +86,145 @@ def make_s1_grd_datatree() -> xr.DataTree:
return dt
+def _make_slc_group(
+ azimuth_time: np.ndarray,
+ slant_range_time: np.ndarray,
+ scale: float,
+) -> xr.Dataset:
+ values = np.arange(
+ 1, len(azimuth_time) * len(slant_range_time) + 1, dtype="float32"
+ ).reshape(len(azimuth_time), len(slant_range_time))
+ return xr.Dataset(
+ {
+ "slc": xr.DataArray(
+ values * scale,
+ dims=("azimuth_time", "slant_range_time"),
+ coords={
+ "azimuth_time": azimuth_time,
+ "slant_range_time": slant_range_time,
+ "line": ("azimuth_time", np.arange(len(azimuth_time))),
+ "pixel": ("slant_range_time", np.arange(len(slant_range_time))),
+ },
+ )
+ }
+ )
+
+
+def make_s1_slc_datatree() -> xr.DataTree:
+ azimuth_time_by_swath = {
+ "IW1": np.array(
+ [
+ "2024-01-01T00:00:00",
+ "2024-01-01T00:00:01",
+ "2024-01-01T00:00:02",
+ ],
+ dtype="datetime64[ns]",
+ ),
+ "IW2": np.array(
+ [
+ "2024-01-01T00:00:00",
+ "2024-01-01T00:00:01",
+ "2024-01-01T00:00:02",
+ ],
+ dtype="datetime64[ns]",
+ ),
+ "IW3": np.array(
+ [
+ "2024-01-01T00:00:00",
+ "2024-01-01T00:00:01",
+ "2024-01-01T00:00:02",
+ ],
+ dtype="datetime64[ns]",
+ ),
+ }
+ slant_range_time_by_swath = {
+ "IW1": np.array([0.0, 1.0, 2.0, 3.0], dtype="float32"),
+ "IW2": np.array([1.0, 2.0, 3.0, 4.0], dtype="float32"),
+ "IW3": np.array([2.0, 3.0, 4.0, 5.0], dtype="float32"),
+ }
+ axis = np.array(["x", "y", "z"])
+ polarizations = ("VV", "VH")
+ swaths = ("IW1", "IW2", "IW3")
+
+ beta_nought = xr.Dataset(
+ {
+ "beta_nought": xr.DataArray(
+ np.ones((3, 4), dtype="float32"),
+ dims=("azimuth_time", "slant_range_time"),
+ coords={
+ "azimuth_time": azimuth_time_by_swath["IW1"],
+ "slant_range_time": slant_range_time_by_swath["IW1"],
+ },
+ )
+ }
+ )
+ orbit = xr.Dataset(
+ {
+ "position": xr.DataArray(
+ np.ones((3, 3), dtype="float32"),
+ dims=("azimuth_time", "axis"),
+ coords={
+ "azimuth_time": azimuth_time_by_swath["IW1"],
+ "axis": axis,
+ },
+ )
+ }
+ )
+ burst_info = xr.Dataset(
+ {
+ "first_valid_sample": xr.DataArray(
+ np.array([1, 1, -1], dtype="int32"),
+ dims=("azimuth_time",),
+ coords={"azimuth_time": azimuth_time_by_swath["IW1"]},
+ ),
+ "last_valid_sample": xr.DataArray(
+ np.array([3, 3, -1], dtype="int32"),
+ dims=("azimuth_time",),
+ coords={"azimuth_time": azimuth_time_by_swath["IW1"]},
+ ),
+ }
+ )
+
+ dt_nodes = {}
+ for mode_i, pol in enumerate(polarizations):
+ for swath_i, swath in enumerate(swaths):
+ group_name = f"S1A_IW_SLC_TEST_{pol}_{swath}_0"
+ dt_nodes.update(
+ {
+ f"{group_name}/measurements": _make_slc_group(
+ azimuth_time_by_swath[swath],
+ slant_range_time_by_swath[swath],
+ scale=float(mode_i + swath_i + 1),
+ ),
+ f"{group_name}/quality/calibration": beta_nought.assign_coords(
+ azimuth_time=azimuth_time_by_swath[swath],
+ slant_range_time=slant_range_time_by_swath[swath],
+ ).copy(deep=True),
+ f"{group_name}/conditions/orbit": orbit.assign_coords(
+ azimuth_time=azimuth_time_by_swath[swath]
+ ).copy(deep=True),
+ f"{group_name}/conditions/burst_info": burst_info.assign_coords(
+ azimuth_time=azimuth_time_by_swath[swath]
+ ).copy(deep=True),
+ }
+ )
+
+ dt = xr.DataTree.from_dict(dt_nodes)
+ dt.attrs["stac_discovery"] = {"bbox": [0.0, 50.0, 1.0, 51.0]}
+ dt["S1A_IW_SLC_TEST_VH_IW1_0"].attrs["other_metadata"] = {
+ "image_annotation": {
+ "image_information": {
+ "range_pixel_spacing": 10.0,
+ "slant_range_time": 1.0e-4,
+ "product_first_line_utc_time": "2024-01-01T00:00:00",
+ "azimuth_time_interval": 0.5,
+ "azimuth_pixel_spacing": 20.0,
+ }
+ }
+ }
+ return dt
+
+
def _make_owi_measurements(height: int, width: int):
shape = (height, width)
=====================================
xarray_eopf/amodes/sentinel1.py
=====================================
@@ -2,18 +2,20 @@
# Permissions are hereby granted under the terms of the Apache 2.0 License:
# https://opensource.org/license/apache-2-0.
-
import functools
import os
import re
+import uuid
import warnings
+import weakref
from abc import ABC
from collections.abc import Iterable, Sequence
-from dataclasses import dataclass
+from dataclasses import dataclass, fields
from typing import Any, Callable, Literal
import dask.array as da
import flox.xarray
+import fsspec
import numpy as np
import pyproj
import pystac_client
@@ -23,9 +25,17 @@ from xcube_resampling import resample_in_space
from xcube_resampling.constants import SpatialAggMethods, SpatialInterpMethods
from xcube_resampling.gridmapping import GridMapping
from xcube_resampling.rectify import rectify_dataset
-from xcube_resampling.utils import reproject_bbox, transform_resolution
+
+# noinspection PyProtectedMember
+from xcube_resampling.utils import (
+ SourceTileIndexing,
+ _reorganize_tiled_array,
+ reproject_bbox,
+ transform_resolution,
+)
from xarray_eopf.amode import AnalysisMode, AnalysisModeRegistry
+from xarray_eopf.constants import FloatInt
from xarray_eopf.source import get_source_path
from xarray_eopf.utils import NameFilter, assert_arg_has_length, assert_arg_is_instance
@@ -36,66 +46,36 @@ _CRS_ECEF = pyproj.CRS.from_string("EPSG:4978")
_CRS_WGS84 = pyproj.CRS.from_string("EPSG:4326")
_DEM_CHUNKSIZE = dict(lat=1800, lon=1800)
_CHUNKSIZE = (2048, 2048)
+_SLC_SWATHS = ["IW1", "IW2", "IW3"]
+_SENTINEL1_POLARZIATION_MODES = ["VV", "VH", "HV", "HH"]
+_REGISTERED_CACHE_URIS = []
@dataclass(frozen=True)
class GridParams:
- """RTC grid parameters."""
-
- slr0: float
- d_slr: float
- spacing_slr: float
+ """Range and azimuth grid parameters used for geocoding and RTC."""
+
+ range0: float
+ range0_scale: float
+ d_range: float
+ d_range_scale: float
+ spacing_range: float
+ spacing_range_scale: float
az0: np.datetime64
+ az0_scale: np.datetime64
d_az: float
+ d_az_scale: float
spacing_az: float
+ spacing_az_scale: float
def __iter__(self):
- return iter(("slr0", "d_slr", "spacing_slr", "az0", "d_az", "spacing_az"))
+ return (f.name for f in fields(self))
- def __getitem__(self, key: str) -> Any:
+ def __getitem__(self, key: str):
return getattr(self, key)
- def __contains__(self, key: object) -> bool:
- return key in {"slr0", "d_slr", "spacing_slr", "az0", "d_az", "spacing_az"}
-
-
- at dataclass
-class Acquisition:
- """Simulated acquisition geometry."""
-
- azimuth_time: xr.DataArray
- distance: xr.DataArray
- velocity: xr.DataArray
- slant_range_time: xr.DataArray
- gamma_area: xr.DataArray | None = None
-
- def __iter__(self):
- keys = ["azimuth_time", "distance", "velocity", "slant_range_time"]
- if self.gamma_area is not None:
- keys.append("gamma_area")
- return iter(keys)
-
- def __getitem__(self, key: str) -> xr.DataArray:
- value = getattr(self, key)
- if value is None:
- raise KeyError(key)
- return value
-
- def __contains__(self, key: object) -> bool:
- return key in set(iter(self))
-
- def to_dataset(self) -> xr.Dataset:
- dataset = xr.Dataset(
- {
- "azimuth_time": self.azimuth_time,
- "distance": self.distance,
- "velocity": self.velocity,
- "slant_range_time": self.slant_range_time,
- }
- )
- if self.gamma_area is not None:
- dataset["gamma_area"] = self.gamma_area
- return dataset
+ def __contains__(self, key):
+ return key in {f.name for f in fields(self)}
class Sen1(AnalysisMode, ABC):
@@ -124,13 +104,24 @@ class Sen1(AnalysisMode, ABC):
class Sen1GRD(Sen1):
product_type = "GRDH"
+ range_coord = "ground_range"
+ footprint_scale_factor = (3.0, 3.0)
+ cache_fs: fsspec.AbstractFileSystem | None = None
+ cache_uri: str | None = None
def get_applicable_params(self, **kwargs) -> dict[str, Any]:
params = {}
resolution = kwargs.get("resolution")
if resolution is not None:
- assert_arg_is_instance(resolution, "resolution", (float, int))
+ assert_arg_is_instance(resolution, "resolution", (float, int, tuple))
+ if isinstance(resolution, tuple):
+ assert_arg_has_length(resolution, "resolution", 2)
+ if not all(isinstance(v, (float, int)) for v in resolution):
+ raise TypeError(
+ "resolution argument must contain exactly "
+ "two float or int values."
+ )
params.update(resolution=resolution)
bbox = kwargs.get("bbox")
@@ -178,6 +169,11 @@ class Sen1GRD(Sen1):
assert_arg_is_instance(apply_rtc, "apply_rtc", bool)
params.update(apply_rtc=apply_rtc)
+ cache_uri = kwargs.get("cache_uri")
+ if cache_uri is not None:
+ assert_arg_is_instance(cache_uri, "cache_uri", str)
+ params.update(cache_uri=cache_uri)
+
return params
def convert_datatree(
@@ -185,17 +181,36 @@ class Sen1GRD(Sen1):
datatree: xr.DataTree,
includes: str | Iterable[str] | None = None,
excludes: str | Iterable[str] | None = None,
- resolution: float = None,
+ resolution: FloatInt | tuple[FloatInt, FloatInt] | None = None,
bbox: Sequence[float | int] | None = None,
crs: pyproj.CRS | None = None,
interp_methods: Literal["nearest", "bilinear"] = "bilinear",
- footprint_scale_factor: tuple[float, float] = (3.0, 3.0),
+ footprint_scale_factor: tuple[float, float] | None = None,
dem: xr.DataArray | None = None,
apply_rtc: bool = True,
+ cache_uri: str | None = None,
) -> xr.Dataset:
- # get dem data array
+ if footprint_scale_factor is not None:
+ self.footprint_scale_factor = footprint_scale_factor
+
+ if cache_uri is None:
+ self.cache_fs = fsspec.filesystem("file")
+ self.cache_uri = f"tmp_{uuid.uuid4().hex}"
+ else:
+ cache_uri = cache_uri.rstrip("/")
+ self.cache_fs, _ = fsspec.url_to_fs(cache_uri)
+ self.cache_uri = cache_uri
+ _register_cache_uri(self.cache_uri)
+ weakref.finalize(self, _cleanup_registered_cache_uris)
+
+ # Build the DEM if one was not supplied. When no bbox was passed,
+ # fall back to the product footprint from STAC metadata.
if dem is None:
if bbox is None:
+ warnings.warn(
+ f"No bounding box specified. Processing the full Sentinel-1 "
+ f"{self.product_type} product, which may take some time."
+ )
bbox = datatree.attrs["stac_discovery"]["bbox"]
bbox = [
min(bbox[0], bbox[2]),
@@ -205,93 +220,392 @@ class Sen1GRD(Sen1):
]
dem = get_dem(bbox, resolution=resolution, crs=crs)
- # load measurement data
- grd = None
- group = ""
- for mode in ["VV", "VH", "HV", "HH"]:
+ grd = self._open_data(datatree, includes, excludes)
+ return self._terrain_correct(
+ datatree,
+ grd,
+ dem,
+ apply_rtc=apply_rtc,
+ interp_method=interp_methods,
+ )
+
+ @staticmethod
+ def _open_data(
+ datatree: xr.DataTree,
+ includes: str | Iterable[str] | None = None,
+ excludes: str | Iterable[str] | None = None,
+ ) -> xr.Dataset:
+ """Load measurements from the input datatree and apply radiometric
+ calibration using look-up tables provided in the product.
+ """
+
+ dataset = None
+ measurement_group = ""
+ for mode in _SENTINEL1_POLARZIATION_MODES:
children = [x for x in datatree.children if mode in x]
if children:
- group = children[0]
- if grd is None:
- grd = datatree[group].measurements.to_dataset()
- grd = grd.rename({"grd": mode.lower()})
+ if len(children) != 1:
+ raise ValueError(
+ f"Expected exactly one measurement group for polarization "
+ f"'{mode}', but found {len(children)}: {children}. "
+ "The Sentinel-1 Level-1 GRD product appears to be invalid."
+ )
+ measurement_group = children[0]
+ if dataset is None:
+ dataset = datatree[measurement_group].measurements.to_dataset()
+ dataset = dataset.rename({"grd": mode.lower()})
else:
- grd[mode.lower()] = datatree[group].measurements.to_dataset().grd
+ dataset[mode.lower()] = (
+ datatree[measurement_group].measurements.to_dataset().grd
+ )
# filter dataset by variable names
name_filter = NameFilter(includes=includes, excludes=excludes)
- variable_names = [k for k in grd.data_vars if name_filter.accept(str(k))]
+ variable_names = [k for k in dataset.data_vars if name_filter.accept(str(k))]
if not variable_names:
raise ValueError("No valid variable names found in dataset")
- grd = grd[variable_names]
+ dataset = dataset[variable_names]
- # get calibration LUT data
- lut = datatree[group].quality.calibration.beta_nought
- lut_interp = lut.interp(ground_range=grd.ground_range).chunk(
- dict(ground_range=2048)
- )
- lut_interp = lut_interp.interp(azimuth_time=grd.azimuth_time).chunk(
- dict(azimuth_time=2048)
- )
- grd = (grd / lut_interp) ** 2
+ # Mask invalid border pixels (zero-valued fill areas) as NaN
+ dataset = dataset.where(dataset != 0)
+
+ # convert amplitudes to beta nought backscatter.
+ beta_lut = datatree[measurement_group].quality.calibration.beta_nought.values
+ assert len(np.unique(beta_lut)) == 1
+ dataset = (dataset / beta_lut[0, 0]) ** 2
rename_dict = {name: f"beta0_{name}" for name in variable_names}
- grd = grd.rename(rename_dict)
- for var in grd.data_vars:
- grd[var].attrs.update(
+ dataset = dataset.rename(rename_dict)
+ for var in dataset.data_vars:
+ dataset[var].attrs.update(
long_name="beta nought backscatter coefficient",
units="1",
)
+ return dataset
+ def _terrain_correct(
+ self,
+ datatree: xr.DataTree,
+ data: xr.Dataset,
+ dem: xr.DataArray,
+ apply_rtc: bool = True,
+ interp_method: Literal["nearest", "bilinear"] = "nearest",
+ ) -> xr.Dataset:
+ """Geocode the calibrated GRD data and optionally apply
+ radiometric terrain correction (RTC).
+ """
+ group = next(iter(datatree.children))
orbit = datatree[f"{group}/conditions/orbit"].to_dataset()
- sat_position = orbit["position"]
-
- gcp = datatree[f"{group}/conditions/gcp"].to_dataset()
- time_slr_gcp = gcp["slant_range_time_gcp"]
-
- grid_params = self._get_grid_parameters(datatree, footprint_scale_factor)
+ sat_position = orbit["position"].compute()
+
+ if self.range_coord == "ground_range":
+ gcp = datatree[f"{group}/conditions/gcp"].to_dataset()
+ time_slr_gcp = gcp["slant_range_time_gcp"]
+ else:
+ time_slr_gcp = None
+
+ grid_params = _get_grid_parameters(
+ datatree,
+ self.footprint_scale_factor,
+ range_coord=self.range_coord,
+ )
- return terrain_correct(
- grd,
- time_slr_gcp,
- sat_position,
- dem,
+ gm_dem = GridMapping.from_dataset(dem.to_dataset(name="dem"))
+ src_loc = get_source_location(
+ dem=dem,
+ time_slr_gcp=time_slr_gcp,
+ sat_position=sat_position,
grid_params=grid_params,
+ gm_dem_grid=gm_dem,
apply_rtc=apply_rtc,
- interp_method=interp_methods,
+ range_coord=self.range_coord,
)
+ store = fsspec.get_mapper(f"{self.cache_uri}/src_location.zarr")
+ src_loc.to_zarr(store)
+
+ src_loc = xr.open_zarr(store)
+ geocoded = geocode_data(
+ data, src_loc, grid_params, interp_method, range_coord=self.range_coord
+ )
+
+ if apply_rtc:
+ if interp_method == "bilinear":
+ weights_fn = gamma_weights_bilinear
+ else: # interp_method == "nearest"
+ weights_fn = gamma_weights_nearest
+ gamma_weights = apply_gamma_weights(
+ src_loc,
+ weights_fn,
+ grid_params,
+ range_coord=self.range_coord,
+ )
+ geocoded /= gamma_weights
+ rename_dict = {
+ name: str(name).replace("beta0", "gamma0")
+ for name in geocoded.data_vars
+ }
+ geocoded = geocoded.rename(rename_dict)
+ for var in geocoded.data_vars:
+ geocoded[var].attrs.update(
+ long_name="gamma nought backscatter coefficient",
+ units="1",
+ )
+
+ geocoded = assign_grid_mapping(geocoded)
+ return geocoded
+
+
+class Sen1SLC(Sen1GRD):
+ product_type = "SLC"
+ range_coord = "slant_range_time"
+ footprint_scale_factor = (3.0, 15.0)
+ cache_fs: fsspec.AbstractFileSystem | None = None
+ cache_uri: str | None = None
+
+ def _open_data(
+ self,
+ datatree: xr.DataTree,
+ includes: str | Iterable[str] | None = None,
+ excludes: str | Iterable[str] | None = None,
+ ) -> xr.Dataset:
+ """Load, calibrate, and merge SLC bursts into a single dataset."""
+
+ children, modes = self._get_groups(datatree)
+
+ # First combine all polarizations within each burst.
+ dss_all = []
+ for swath_i, _ in enumerate(_SLC_SWATHS):
+ dss_swath = []
+ for burst_i, _ in enumerate(children[0][swath_i]):
+ burst_dataset = xr.Dataset()
+ for mode_i, mode in enumerate(modes):
+ child = children[mode_i][swath_i][burst_i]
+ burst = datatree[child]
+ beta0 = self._calibrate_burst(burst)
+ beta0 = self._extract_valid_region(beta0, burst)
+ beta0 = beta0.drop_vars(["line", "pixel"])
+ beta0 = beta0.rename({"slc": f"beta0_{mode.lower()}"})
+ burst_dataset.update(beta0)
+ dss_swath.append(burst_dataset)
+ dss_all.append(dss_swath)
+
+ dss_swaths = []
+ for swath_i, swath in enumerate(_SLC_SWATHS):
+ dss_swaths.append(self._merge_bursts(dss_all[swath_i]))
+ dss_swaths = self._align_azimuth(dss_swaths)
+ merged = self._merge_swaths(dss_swaths)
+
+ def _expand_names(
+ names: str | Iterable[str] | None,
+ ) -> str | list[str] | None:
+ if names is None:
+ return None
+ if isinstance(names, str):
+ names = [names]
+ expanded = []
+ for name in names:
+ expanded.append(name)
+ if not str(name).startswith("beta0_"):
+ expanded.append(f"beta0_{name}")
+ return expanded
+
+ name_filter = NameFilter(
+ includes=_expand_names(includes),
+ excludes=_expand_names(excludes),
+ )
+ variable_names = [k for k in merged.data_vars if name_filter.accept(str(k))]
+ if not variable_names:
+ raise ValueError("No valid variable names found in dataset")
+ merged = merged[variable_names]
+ return merged
@staticmethod
- def _get_grid_parameters(
- dt: xr.DataTree,
- footprint_scale_factor: tuple[float, float],
- ) -> GridParams:
- """Build grid parameters for RTC from Sentinel-1 metadata.
-
- Args:
- dt: Source data tree.
- footprint_scale_factor: Scaling for SAR footprint spacing.
-
- Returns:
- Grid parameters for terrain correction.
- """
+ def _get_groups(datatree: xr.DataTree) -> tuple[list[list[list[str]]], list[str]]:
+ """Return child group names organized by polarization, swath, and burst."""
+ modes_sel = []
+ children = []
+ for mode in _SENTINEL1_POLARZIATION_MODES:
+ mode_children = []
+ for swath_i, swath in enumerate(_SLC_SWATHS):
+ bursts = [x for x in datatree.children if f"_{mode}_{swath}" in x]
+ if bursts:
+ mode_children.append(bursts)
+ modes_sel.append(mode)
+ if mode_children:
+ children.append(mode_children)
+ return children, list(np.unique(modes_sel))
- group_VH = [x for x in dt.children if "VH" in x][0]
- attrs = dt[f"{group_VH}"].attrs["other_metadata"]["image_annotation"][
- "image_information"
+ @staticmethod
+ def _calibrate_burst(burst: xr.DataTree) -> xr.Dataset:
+ """Convert a burst measurement to beta nought."""
+ slc = burst.measurements.to_dataset()
+ beta_lut = burst.quality.calibration.beta_nought
+ assert len(np.unique(beta_lut)) == 1
+ return (abs(slc) / beta_lut[0, 0].values) ** 2
+
+ @staticmethod
+ def _extract_valid_region(dataset: xr.Dataset, burst: xr.DataTree) -> xr.Dataset:
+ """Trim invalid burst samples using the burst information mask."""
+ first = burst.conditions.burst_info.first_valid_sample.values
+ last = burst.conditions.burst_info.last_valid_sample.values
+ first_idxs = np.unique(first)
+ assert len(first_idxs) == 2
+ assert -1 in first_idxs
+ valid = np.where(first != -1)[0]
+ first_idx = first_idxs[1]
+ last_idxs = np.unique(last[valid])
+ assert len(last_idxs) == 1
+ last_idx = last_idxs[0]
+ return dataset.isel(
+ azimuth_time=valid, slant_range_time=slice(first_idx, last_idx)
+ )
+
+ @staticmethod
+ def _merge_bursts(dss: list[xr.Dataset], tolerance: float = 0.01) -> xr.Dataset:
+ """Merge overlapping bursts along azimuth time."""
+ idxs = np.zeros((len(dss), 2), dtype=int)
+ tol = 0.01 * np.diff(dss[0].azimuth_time.values[:2])[0]
+
+ for i, (ds0, ds1) in enumerate(zip(dss[:-1], dss[1:])):
+
+ t0 = ds0.azimuth_time.values
+ t1 = ds1.azimuth_time.values
+
+ mask0 = t0 >= t1[0] - tol
+ mask1 = t1 <= t0[-1] + tol
+ n_overlap = min(mask0.sum(), mask1.sum())
+
+ if n_overlap == 0:
+ raise ValueError(f"No overlap found between swaths {i} and {i + 1}")
+
+ keep0 = n_overlap // 2
+ keep1 = n_overlap - keep0
+
+ idxs[i, 1] = len(t0) - keep0
+ idxs[i + 1, 0] = keep1
+
+ idxs[-1, 1] = dss[-1].sizes["azimuth_time"]
+
+ dss_cut = [
+ ds.isel(azimuth_time=slice(start, stop))
+ for ds, (start, stop) in zip(dss, idxs)
]
- slant_range_spacing_m = attrs["range_pixel_spacing"] * footprint_scale_factor[1]
- slant_range_time_interval_s = slant_range_spacing_m * 2 / _SPEED_OF_LIGHT
+ out = xr.concat(
+ dss_cut,
+ dim="azimuth_time",
+ join="outer",
+ coords="minimal",
+ )
- return GridParams(
- slr0=attrs["slant_range_time"],
- d_slr=slant_range_time_interval_s,
- spacing_slr=slant_range_spacing_m,
- az0=np.datetime64(attrs["product_first_line_utc_time"]),
- d_az=attrs["azimuth_time_interval"] * footprint_scale_factor[0],
- spacing_az=attrs["azimuth_pixel_spacing"] * footprint_scale_factor[0],
+ dt = out.azimuth_time.diff("azimuth_time").values
+ nominal_dt = np.median(dt)
+ relative_deviation = np.abs(dt - nominal_dt) / nominal_dt
+ if any(relative_deviation > tolerance):
+ warnings.warn(
+ f"Azimuth time spacing is not regular. "
+ f"There are intervals which deviate more than "
+ f"{tolerance * 100:.3f}% from the median step "
+ f"({nominal_dt / np.timedelta64(1, 's'):.9f} s). "
+ f"Maximum deviation: "
+ f"{relative_deviation.max() * 100:.3f}%."
+ )
+ out = out.assign_coords(
+ azimuth_time=out.azimuth_time.values[0]
+ + np.arange(out.sizes["azimuth_time"]) * nominal_dt
)
+ return out
+
+ @staticmethod
+ def _align_azimuth(
+ dss: list[xr.Dataset], tolerance: float = 0.01
+ ) -> list[xr.Dataset]:
+ """Align all swaths to a shared azimuth-time axis."""
+ tol = 0.1 * np.diff(dss[0].azimuth_time.values[:2])[0]
+
+ start = max(ds.azimuth_time.values[0] for ds in dss)
+
+ stop = min(ds.azimuth_time.values[-1] for ds in dss)
+
+ dss_align = [ds.sel(azimuth_time=slice(start - tol, stop + tol)) for ds in dss]
+
+ # check number of azimuth lines
+ sizes = [ds.sizes["azimuth_time"] for ds in dss_align]
+ if len(np.unique(sizes)) != 1:
+ warnings.warn(
+ f"Aligned swaths have different azimuth sizes: {sizes}. "
+ "TOPSAR merge may require additional trimming."
+ )
+ min_size = min(sizes)
+ dss_align = [ds.isel(azimuth_time=slice(0, min_size)) for ds in dss_align]
+
+ # check azimuth spacing
+ for i, ds in enumerate(dss_align):
+ dt = ds.azimuth_time.diff("azimuth_time").values
+ nominal_dt = np.median(dt)
+ deviation = np.abs(dt - nominal_dt) / nominal_dt
+
+ if np.any(deviation > tolerance):
+ warnings.warn(
+ f"Azimuth spacing is irregular in swath {i}. "
+ f"Maximum deviation: {deviation.max():.3%} "
+ f"(threshold {tolerance:.3%})."
+ )
+
+ azimuth_time = dss_align[0].azimuth_time
+
+ return [ds.assign_coords(azimuth_time=azimuth_time) for ds in dss_align]
+
+ @staticmethod
+ def _merge_swaths(dss: list[xr.Dataset], tolerance: float = 0.01) -> xr.Dataset:
+ """Merge swaths along slant range."""
+ idxs = np.zeros((len(dss), 2), dtype=int)
+ step_tol = tolerance * np.diff(dss[0].slant_range_time.values[:2])[0]
+
+ for i, (ds0, ds1) in enumerate(zip(dss[:-1], dss[1:])):
+
+ r0 = ds0.slant_range_time.values
+ r1 = ds1.slant_range_time.values
+
+ # overlap region
+ mask0 = r0 >= r1[0] - step_tol
+ mask1 = r1 <= r0[-1] + step_tol
+
+ n_overlap = min(mask0.sum(), mask1.sum())
+
+ if n_overlap == 0:
+ raise ValueError(f"No overlap found between swaths {i} and {i+1}")
+
+ keep0 = n_overlap // 2
+ keep1 = n_overlap - keep0
+
+ idxs[i, 1] = len(r0) - keep0
+ idxs[i + 1, 0] = keep1
+
+ idxs[-1, 1] = dss[-1].sizes["slant_range_time"]
+
+ dss_cut = [
+ ds.isel(slant_range_time=slice(start, stop))
+ for ds, (start, stop) in zip(dss, idxs)
+ ]
+
+ out = xr.concat(
+ dss_cut,
+ dim="slant_range_time",
+ coords="minimal",
+ )
+
+ dr = out.slant_range_time.diff("slant_range_time").values
+ nominal_dr = np.median(dr)
+ deviation = np.abs(dr - nominal_dr) / nominal_dr
+ if np.any(deviation > tolerance):
+ warnings.warn(
+ f"Slant range spacing is irregular. "
+ f"Maximum deviation: {deviation.max():.3%} "
+ f"(threshold {tolerance:.3%})."
+ )
+ return out
+
class Sen1OCN(Sen1):
product_type = "OCN"
@@ -301,7 +615,14 @@ class Sen1OCN(Sen1):
resolution = kwargs.get("resolution")
if resolution is not None:
- assert_arg_is_instance(resolution, "resolution", (float, int))
+ assert_arg_is_instance(resolution, "resolution", (float, int, tuple))
+ if isinstance(resolution, tuple):
+ assert_arg_has_length(resolution, "resolution", 2)
+ if not all(isinstance(v, (float, int)) for v in resolution):
+ raise TypeError(
+ "resolution argument must contain exactly "
+ "two float or int values."
+ )
params.update(resolution=resolution)
bbox = kwargs.get("bbox")
@@ -336,7 +657,7 @@ class Sen1OCN(Sen1):
datatree: xr.DataTree,
includes: str | Iterable[str] | None = None,
excludes: str | Iterable[str] | None = None,
- resolution: float = None,
+ resolution: FloatInt | tuple[FloatInt, FloatInt] | None = None,
bbox: Sequence[float | int] | None = None,
crs: pyproj.CRS | None = None,
interp_methods: SpatialInterpMethods | None = None,
@@ -351,20 +672,20 @@ class Sen1OCN(Sen1):
dataset.update(sub_dt.quality.to_dataset().drop_vars("calibration_constant"))
# correct attributes and encoding
- def _apply_valid_range(da, *, dtype=None, fill_value=None):
+ def _apply_valid_range(array, *, dtype=None, fill_value=None):
if dtype is not None:
- da = da.astype(dtype)
+ array = array.astype(dtype)
if fill_value is not None:
- da.encoding["_FillValue"] = fill_value
+ array.encoding["_FillValue"] = fill_value
- eopf_attrs = da.attrs["_eopf_attrs"]
- da.attrs.update(
+ eopf_attrs = array.attrs["_eopf_attrs"]
+ array.attrs.update(
valid_min=eopf_attrs["valid_min"],
valid_max=eopf_attrs["valid_max"],
)
- return da
+ return array
dataset["inversion_quality"] = _apply_valid_range(
dataset.inversion_quality,
@@ -396,12 +717,12 @@ class Sen1OCN(Sen1):
bbox = source_gm.xy_bbox
if resolution is None:
if crs and not crs.is_geographic:
- center_lat = (
+ ref_point = (
(source_gm.xy_bbox[0] + source_gm.xy_bbox[2]) / 2,
(source_gm.xy_bbox[1] + source_gm.xy_bbox[3]) / 2,
)
resolution = transform_resolution(
- center_lat, source_gm.xy_res, source_gm.crs, crs
+ ref_point, source_gm.xy_res, source_gm.crs, crs
)
else:
resolution = source_gm.xy_res
@@ -423,14 +744,25 @@ class Sen1OCN(Sen1):
def register(registry: AnalysisModeRegistry):
- """Register Sentinel-1 analysis modes."""
registry.register(Sen1GRD)
+ registry.register(Sen1SLC)
registry.register(Sen1OCN)
+def _register_cache_uri(cache_uri: str) -> None:
+ _REGISTERED_CACHE_URIS.append(cache_uri)
+
+
+def _cleanup_registered_cache_uris() -> None:
+ for cache_uri in _REGISTERED_CACHE_URIS:
+ fs, path = fsspec.url_to_fs(cache_uri)
+ if fs.exists(path):
+ fs.rm(path, recursive=True)
+
+
def get_dem(
bbox: Sequence[float | int],
- resolution: float | None = None,
+ resolution: FloatInt | tuple[FloatInt, FloatInt] | None = None,
crs: pyproj.CRS | None = None,
):
"""Fetch and prepare a DEM for the given area of interest.
@@ -446,6 +778,7 @@ def get_dem(
Raises:
ValueError: If required credentials are missing or resolution is invalid.
"""
+
# check that environment variables are set
missing = [
name
@@ -473,8 +806,7 @@ def get_dem(
bbox_wgs84 = bbox
# get STAC items
- STAC_URL = "https://stac.dataspace.copernicus.eu/v1"
- client = pystac_client.Client.open(STAC_URL)
+ client = pystac_client.Client.open("https://stac.dataspace.copernicus.eu/v1")
search = client.search(
collections=["cop-dem-glo-30-dged-cog"],
bbox=list(bbox_wgs84),
@@ -494,64 +826,105 @@ def get_dem(
lon=slice(bbox_wgs84[0], bbox_wgs84[2]),
).chunk(_DEM_CHUNKSIZE)
else:
- if resolution is None:
- raise ValueError("Resolution must be provided if CRS is not None.")
+ dem = dem.to_dataset(name="dem")
if crs is None:
crs = _CRS_WGS84
+ if resolution is None:
+ source_gm = GridMapping.from_dataset(dem)
+ ref_point = (
+ (bbox_wgs84[0] + bbox_wgs84[2]) / 2,
+ (bbox_wgs84[1] + bbox_wgs84[3]) / 2,
+ )
+ resolution = transform_resolution(
+ ref_point, source_gm.xy_res, source_gm.crs, crs
+ )
target_gm = GridMapping.regular_from_bbox(
bbox,
resolution,
crs,
tile_size=(_DEM_CHUNKSIZE["lat"], _DEM_CHUNKSIZE["lon"]),
)
- dem = resample_in_space(dem.to_dataset(name="dem"), target_gm=target_gm).dem
+ dem = resample_in_space(dem, target_gm=target_gm).dem
return dem
-def convert_dem_to_ecef(dem: xr.DataArray, gm_dem: GridMapping) -> xr.DataArray:
+def _get_grid_parameters(
+ datatree: xr.DataTree,
+ footprint_scale_factor: tuple[float, float],
+ range_coord: str = "ground_range",
+) -> GridParams:
+ """Build grid parameters for RTC from Sentinel-1 metadata.
+
+ Args:
+ datatree: Source data tree.
+ footprint_scale_factor: Scaling for SAR footprint spacing.
+
+ Returns:
+ Grid parameters for terrain correction.
+ """
+
+ vh_group = [x for x in datatree.children if "VH" in x][0]
+ attrs = datatree[f"{vh_group}"].attrs["other_metadata"]["image_annotation"][
+ "image_information"
+ ]
+
+ az_scale, range_scale = footprint_scale_factor
+ if range_coord == "ground_range":
+ range0 = 0.0
+ d_range = attrs["range_pixel_spacing"]
+ spacing_range = attrs["range_pixel_spacing"]
+ else:
+ meas = datatree[f"{vh_group}/measurements"]
+ range0 = meas[range_coord].values[0]
+ d_range = meas[range_coord].values[1] - meas[range_coord].values[0]
+ spacing_range = attrs["range_pixel_spacing"]
+ az0 = np.datetime64(attrs["product_first_line_utc_time"])
+ d_az = attrs["azimuth_time_interval"]
+ spacing_az = attrs["azimuth_pixel_spacing"]
+
+ return GridParams(
+ range0=range0,
+ d_range=d_range,
+ spacing_range=spacing_range,
+ d_range_scale=d_range * range_scale,
+ range0_scale=(range0 - (0.5 * d_range) + (0.5 * d_range * range_scale)),
+ spacing_range_scale=spacing_range * range_scale,
+ az0=az0,
+ d_az=d_az,
+ spacing_az=spacing_az,
+ d_az_scale=d_az * az_scale,
+ az0_scale=(az0 + (-(0.5 * d_az) + (0.5 * d_az * az_scale)) * _ONE_SECOND),
+ spacing_az_scale=spacing_az * az_scale,
+ )
+
+
+def convert_dem_to_ecef(dem: xr.DataArray, gm_dem_params: dict) -> xr.DataArray:
"""Convert a DEM from its native CRS to ECEF coordinates.
Args:
dem: DEM data array.
- gm_dem: GridMapping of the DEM data array.
+ gm_dem_params: GridMapping metadata of the DEM data array.
Returns:
DEM expressed in ECEF axes.
"""
- x_dim, y_dim = gm_dem.xy_var_names
- transformer = pyproj.Transformer.from_crs(gm_dem.crs, _CRS_ECEF, always_xy=True)
+ x_dim, y_dim = gm_dem_params["xy_var_names"]
+ xx, yy = np.meshgrid(dem[x_dim].values, dem[y_dim].values, indexing="xy")
- def _transform(
- block_xx: np.ndarray, block_yy: np.ndarray, block_dem: np.ndarray
- ) -> np.ndarray:
- x, y, z = transformer.transform(block_xx, block_yy, block_dem)
- return np.stack([x, y, z], axis=0)
-
- xx, yy = da.meshgrid(
- da.from_array(dem[x_dim].values, chunks=dem.data.chunks[1][0]),
- da.from_array(dem[y_dim].values, chunks=dem.data.chunks[0][0]),
- indexing="xy",
- )
-
- xyz_transformed = da.map_blocks(
- _transform,
- xx,
- yy,
- dem.data,
- dtype=np.float32,
- chunks=(3, *dem.data.chunks),
+ transformer = pyproj.Transformer.from_crs(
+ gm_dem_params["crs"], _CRS_ECEF, always_xy=True
)
+ x, y, z = transformer.transform(xx, yy, dem.values)
return xr.DataArray(
- xyz_transformed,
+ np.stack([x, y, z], axis=0),
dims=("axis", y_dim, x_dim),
coords={
y_dim: dem[y_dim].data,
x_dim: dem[x_dim].data,
"axis": ["x", "y", "z"],
- "spatial_ref": xr.DataArray(0, attrs=gm_dem.crs.to_cf()),
},
)
@@ -753,21 +1126,31 @@ def newton(
def backward_geocode(
- dem_ecef: xr.DataArray,
- pos_coeff: xr.DataArray,
- vel_coeff: xr.DataArray,
+ dem: xr.DataArray,
+ pos_coeff: xr.DataArray = None,
+ vel_coeff: xr.DataArray = None,
+ gr_coeff: xr.DataArray = None,
+ grid_params: GridParams = None,
+ range_coord: str = "ground_range",
+ apply_rtc: bool = True,
+ gm_dem_params: dict = None,
method="newton",
tol=1.0,
speed=7500.0,
maxiter=10,
t_shift=-0.1,
-) -> tuple[xr.DataArray, xr.DataArray, xr.DataArray]:
+) -> xr.Dataset:
"""Compute orbit time and vectors for a DEM using inverse geocoding.
Args:
- dem_ecef: DEM in ECEF coordinates.
+ dem: Digital elevation model.
pos_coeff: Position polynomial coefficients.
vel_coeff: Velocity polynomial coefficients.
+ gr_coeff: Ground-range polynomial coefficients.
+ grid_params: Grid parameters for RTC.
+ range_coord: Name of the coordinate representing the across-track axis.
+ apply_rtc: Whether to compute RTC gamma area.
+ gm_dem_params: DEM grid metadata for ECEF conversion.
method: Root-finding method.
tol: Function tolerance.
speed: Nominal platform speed for tolerance scaling.
@@ -775,11 +1158,21 @@ def backward_geocode(
t_shift: Time shift for the secant method.
Returns:
- Orbit time, distance vector, and velocity vector.
+ A dataset containing the optimized ground_range and azimuth time
+ for each target pixel and optionally the gamma area needed for RTC.
Raises:
ValueError: If the method is not supported.
"""
+ assert pos_coeff is not None
+ assert vel_coeff is not None
+ if range_coord == "ground_range":
+ assert gr_coeff is not None
+ assert grid_params is not None
+ assert gm_dem_params is not None
+
+ dem_ecef = convert_dem_to_ecef(dem, gm_dem_params)
+
f = functools.partial(zero_doppler, dem_ecef, pos_coeff, vel_coeff)
t0 = xr.zeros_like(dem_ecef.sel(axis="x", drop=True), dtype="float64")
@@ -797,58 +1190,44 @@ def backward_geocode(
else:
raise ValueError("method needs to be either 'secant' or 'newton'")
- dist, vel = payload
- return time_orbit, dist, vel
-
-
-def simulate_acquisition(
- dem_ecef: xr.DataArray,
- gm_dem: GridMapping,
- sat_position: xr.DataArray,
- apply_rtc: bool = True,
-) -> Acquisition:
- """Simulate SAR acquisition geometry for a DEM.
-
- Args:
- dem_ecef: DEM in ECEF coordinates.
- gm_dem: GridMapping of the DEM data array.
- sat_position: Satellite positions over time.
- apply_rtc: Whether to compute gamma area.
+ dist, _ = payload
- Returns:
- Simulated acquisition geometry.
- """
- pos_coeff = fit_position(sat_position)
- vel_coeff = poly_derivative(pos_coeff)
- time_orbit, dist, vel = backward_geocode(dem_ecef, pos_coeff, vel_coeff)
+ # apply bistatic correction
+ slant_range = np.sqrt((dist**2).sum("axis"))
+ time_orbit += slant_range / _SPEED_OF_LIGHT
+ # recalculate slant range
+ sat = xr.polyval(time_orbit, pos_coeff)
+ dist = dem_ecef - sat
slant_range = np.sqrt((dist**2).sum("axis"))
time_slr = 2 * slant_range / _SPEED_OF_LIGHT
- out = Acquisition(
- azimuth_time=orbit_to_az(time_orbit, pos_coeff.attrs["epoch"]),
- distance=dist,
- velocity=vel.transpose(*dist.dims),
- slant_range_time=time_slr,
+ # convert to ground range
+ azimuth_time = orbit_to_az(time_orbit, pos_coeff.attrs["epoch"])
+ range_values = (
+ get_ground_range(gr_coeff, azimuth_time, time_slr)
+ if range_coord == "ground_range"
+ else time_slr
)
-
+ out = xr.Dataset({"azimuth_time": azimuth_time, range_coord: range_values})
if apply_rtc:
- out.gamma_area = compute_gamma_area(dem_ecef, gm_dem, dist / slant_range)
-
+ out["gamma_area"] = compute_gamma_area(
+ dem_ecef, gm_dem_params, dist / slant_range
+ )
return out
-def compute_dem_area(dem_ecef: xr.DataArray, gm_dem: GridMapping) -> xr.DataArray:
+def compute_dem_area(dem_ecef: xr.DataArray, gm_dem_params: dict) -> xr.DataArray:
"""Compute per-pixel surface area on the DEM in ECEF coordinates.
Args:
dem_ecef: DEM in ECEF coordinates.
- gm_dem: GridMapping of the DEM data array.
+ gm_dem_params: GridMapping metadata of the DEM data array.
Returns:
Area vectors per DEM pixel.
"""
- x_dim, y_dim = gm_dem.xy_var_names
+ x_dim, y_dim = gm_dem_params["xy_var_names"]
x = dem_ecef[x_dim]
y = dem_ecef[y_dim]
@@ -870,17 +1249,11 @@ def compute_dem_area(dem_ecef: xr.DataArray, gm_dem: GridMapping) -> xr.DataArra
)
# interpolate DEM to pixel corners
- chunksizes = {key: val[0] for key, val in dem_ecef.chunksizes.items()}
xyz_c = dem_ecef.interp(
- {x_dim: x_corner},
+ {x_dim: x_corner, y_dim: y_corner},
method="linear",
kwargs={"fill_value": "extrapolate"},
- ).chunk({x_dim: chunksizes[x_dim]})
- xyz_c = xyz_c.interp(
- {y_dim: y_corner},
- method="linear",
- kwargs={"fill_value": "extrapolate"},
- ).chunk(chunksizes)
+ )
# compute edge vectors
dx = xyz_c.diff(x_dim)
@@ -893,10 +1266,10 @@ def compute_dem_area(dem_ecef: xr.DataArray, gm_dem: GridMapping) -> xr.DataArra
dy2 = dy.isel({x_dim: slice(None, -1)})
# restore original coords
- dx1 = dx1.assign_coords(dem_ecef.coords).chunk(chunksizes)
- dy1 = dy1.assign_coords(dem_ecef.coords).chunk(chunksizes)
- dx2 = dx2.assign_coords(dem_ecef.coords).chunk(chunksizes)
- dy2 = dy2.assign_coords(dem_ecef.coords).chunk(chunksizes)
+ dx1 = dx1.assign_coords(dem_ecef.coords)
+ dy1 = dy1.assign_coords(dem_ecef.coords)
+ dx2 = dx2.assign_coords(dem_ecef.coords)
+ dy2 = dy2.assign_coords(dem_ecef.coords)
# compute triangle areas
cross1 = xr.cross(dx1, dy1, dim="axis") / 2
@@ -910,19 +1283,21 @@ def compute_dem_area(dem_ecef: xr.DataArray, gm_dem: GridMapping) -> xr.DataArra
def compute_gamma_area(
- dem_ecef: xr.DataArray, gm_dem: GridMapping, direction: xr.DataArray
+ dem_ecef: xr.DataArray,
+ gm_dem_params: dict,
+ direction: xr.DataArray,
) -> xr.DataArray:
"""Compute gamma area by projecting DEM areas onto look direction.
Args:
dem_ecef: DEM in ECEF coordinates.
- gm_dem: GridMapping of the DEM data array.
+ gm_dem_params: GridMapping metadata of the DEM data array.
direction: Look direction vectors.
Returns:
Gamma area for each DEM pixel.
"""
- area = compute_dem_area(dem_ecef, gm_dem)
+ area = compute_dem_area(dem_ecef, gm_dem_params)
gamma_area = xr.dot(area, -direction, dim="axis")
return gamma_area.where(gamma_area > 0, 0)
@@ -930,102 +1305,107 @@ def compute_gamma_area(
def sum_weights(
weights: xr.DataArray,
az_idx: xr.DataArray,
- slr_idx: xr.DataArray,
+ gr_idx: xr.DataArray,
) -> xr.DataArray:
"""Accumulate weights into the SAR image grid.
Args:
weights: Weights to accumulate.
az_idx: Azimuth indices.
- slr_idx: Slant-range indices.
+ gr_idx: Ground-range indices.
Returns:
Accumulated weights on the SAR grid.
"""
reduced = flox.xarray.xarray_reduce(
weights,
- slr_idx,
+ gr_idx,
az_idx,
func="sum",
method="map-reduce",
)
return reduced.interp(
- slr_idx=slr_idx,
+ gr_idx=gr_idx,
az_idx=az_idx,
method="nearest",
- ).drop_vars(("az_idx", "slr_idx"))
+ ).drop_vars(("az_idx", "gr_idx"))
-def gamma_weights_bilinear(acq: xr.Dataset) -> xr.DataArray:
+def gamma_weights_bilinear(src_loc: xr.Dataset) -> xr.DataArray:
"""Compute bilinear gamma weights for the acquisition grid.
Args:
- acq: Acquisition dataset with indices and gamma area.
+ src_loc: Source location dataset with indices and gamma area.
Returns:
Gamma weights on the SAR grid.
"""
- az_idx = acq.az_idx
- slr_idx = acq.slr_idx
+ az_idx = src_loc.az_idx
+ gr_idx = src_loc.gr_idx
az0 = np.floor(az_idx).astype(np.intp)
az1 = np.ceil(az_idx).astype(np.intp)
- slr0 = np.floor(slr_idx).astype(np.intp)
- slr1 = np.ceil(slr_idx).astype(np.intp)
+ gr0 = np.floor(gr_idx).astype(np.intp)
+ gr1 = np.ceil(gr_idx).astype(np.intp)
- w00 = abs((az1 - az_idx) * (slr1 - slr_idx))
- w01 = abs((az1 - az_idx) * (slr0 - slr_idx))
- w10 = abs((az0 - az_idx) * (slr1 - slr_idx))
- w11 = abs((az0 - az_idx) * (slr0 - slr_idx))
+ w00 = abs((az1 - az_idx) * (gr1 - gr_idx))
+ w01 = abs((az1 - az_idx) * (gr0 - gr_idx))
+ w10 = abs((az0 - az_idx) * (gr1 - gr_idx))
+ w11 = abs((az0 - az_idx) * (gr0 - gr_idx))
- gamma = acq.gamma_area
+ gamma = src_loc.gamma_area
return (
- sum_weights(gamma * w00, az0, slr0)
- + sum_weights(gamma * w01, az0, slr1)
- + sum_weights(gamma * w10, az1, slr0)
- + sum_weights(gamma * w11, az1, slr1)
+ sum_weights(gamma * w00, az0, gr0)
+ + sum_weights(gamma * w01, az0, gr1)
+ + sum_weights(gamma * w10, az1, gr0)
+ + sum_weights(gamma * w11, az1, gr1)
)
-def gamma_weights_nearest(acq: xr.Dataset) -> xr.DataArray:
+def gamma_weights_nearest(src_loc: xr.Dataset) -> xr.DataArray:
"""Compute nearest-neighbor gamma weights for the acquisition grid.
Args:
- acq: Acquisition dataset with indices and gamma area.
+ src_loc: Source location dataset with indices and gamma area.
Returns:
Gamma weights on the SAR grid.
"""
- az_idx = np.round(acq.az_idx).astype(np.intp)
- slr_idx = np.round(acq.slr_idx).astype(np.intp)
- return sum_weights(acq.gamma_area, az_idx, slr_idx)
+ az_idx = np.round(src_loc.az_idx).astype(np.intp)
+ gr_idx = np.round(src_loc.gr_idx).astype(np.intp)
+ return sum_weights(src_loc.gamma_area, az_idx, gr_idx)
def apply_gamma_weights(
- acq: Acquisition,
+ src_loc: xr.Dataset,
func: Callable[..., xr.DataArray],
params: GridParams,
+ range_coord: str = "ground_range",
) -> xr.DataArray:
"""Apply gamma weighting block-wise.
Args:
- acq: Acquisition dataset with geometry.
+ src_loc: Source location dataset with geometry.
func: Weighting function.
params: Grid parameters for index conversion.
+ range_coord: Name of the coordinate representing the across-track axis.
Returns:
Gamma-corrected area per pixel.
"""
- acq_ds = acq.to_dataset()
- acq_ds["az_idx"] = (acq_ds.azimuth_time - params.az0) / _ONE_SECOND / params.d_az
- acq_ds["slr_idx"] = (acq_ds.slant_range_time - params.slr0) / params.d_slr
+ src_loc["az_idx"] = (
+ (src_loc.azimuth_time - params.az0_scale) / _ONE_SECOND / params.d_az_scale
+ )
+ src_loc["gr_idx"] = (
+ src_loc[range_coord] - params.range0_scale
+ ) / params.d_range_scale
- template = acq_ds.gamma_area * 0
+ template = src_loc.gamma_area * 0
+ area = xr.map_blocks(func, src_loc, template=template)
- area = xr.map_blocks(func, acq_ds, template=template)
- return area / (params.spacing_slr * params.spacing_az)
+ return area / (params.spacing_range_scale * params.spacing_az_scale)
def fit_ground_range(time_slr_gcp: xr.DataArray, deg: int = 8) -> xr.DataArray:
@@ -1045,132 +1425,230 @@ def fit_ground_range(time_slr_gcp: xr.DataArray, deg: int = 8) -> xr.DataArray:
# polynomial fit per azimuth line
coeff = []
- for i, time in enumerate(x_gcp.azimuth_time.values):
- coeff.append(np.polyfit(x_gcp[i, :], x_gcp.ground_range, deg=deg))
+ for i, time in enumerate(x_gcp["azimuth_time"].data):
+ coeff.append(np.polyfit(x_gcp[i, :], x_gcp["ground_range"], deg=deg))
return xr.DataArray(
coeff,
- coords=dict(azimuth_time=x_gcp.azimuth_time, degree=np.arange(deg, -1, -1)),
+ coords=dict(azimuth_time=x_gcp["azimuth_time"], degree=np.arange(deg, -1, -1)),
dims=("azimuth_time", "degree"),
attrs=dict(mean=mean, std=std),
)
+def get_ground_range(
+ coeff: xr.DataArray, time_az: xr.DataArray, time_slr: xr.DataArray
+) -> xr.DataArray:
+ coeff_interp = coeff.interp(azimuth_time=time_az).drop_vars("azimuth_time")
+ x_tgt = (time_slr - coeff.attrs["mean"]) / coeff.attrs["std"]
+ return (coeff_interp * x_tgt**coeff.degree).sum("degree")
+
+
def geocode_data(
data: xr.Dataset,
- time_az: xr.DataArray,
- time_slr: xr.DataArray,
- time_slr_gcp: xr.DataArray,
+ src_loc: xr.Dataset,
+ grid_params: GridParams,
interp_method: Literal["nearest", "bilinear"],
+ range_coord: str = "ground_range",
) -> xr.Dataset:
"""Geocode data from SAR grid to map coordinates.
Args:
data: Input dataset on the SAR grid.
- time_az: Target azimuth times.
- time_slr: Target slant-range times.
- time_slr_gcp: GCP slant-range times.
+ src_loc: Source location dataset with target coordinates.
+ grid_params: Grid parameters for index conversion.
interp_method: Interpolation method.
+ range_coord: Name of the coordinate representing the across-track axis.
Returns:
Geocoded dataset.
"""
-
- coeff = fit_ground_range(time_slr_gcp)
- method = "linear" if interp_method == "bilinear" else "nearest"
-
- def _interp_block(block):
- coeff_interp = coeff.interp(azimuth_time=block.time_az)
- x_tgt = (block.time_slr - coeff.attrs["mean"]) / coeff.attrs["std"]
- ground_range = (coeff_interp * x_tgt**coeff.degree).sum("degree")
- return data.interp(
- azimuth_time=block.time_az,
- ground_range=ground_range,
- method=method,
+ az_idx = (src_loc.azimuth_time - grid_params.az0) / _ONE_SECOND / grid_params.d_az
+ gr_idx = (src_loc[range_coord] - grid_params.range0) / grid_params.d_range
+ scr_indexing = _compute_indexing(data, az_idx, gr_idx, range_coord=range_coord)
+ temp_ij_bboxes = scr_indexing.ij_bboxes.copy()
+ temp_ij_bboxes[[1, 3]] -= scr_indexing.pad_width[0][0]
+ temp_ij_bboxes[[0, 2]] -= scr_indexing.pad_width[1][0]
+ tile_size = tuple(chunk[0] for chunk in gr_idx.chunks)
+ for j in range(temp_ij_bboxes.shape[1]):
+ for i in range(temp_ij_bboxes.shape[2]):
+ i_min = tile_size[1] * i
+ i_max = tile_size[1] * (i + 1)
+ j_min = tile_size[0] * j
+ j_max = tile_size[0] * (j + 1)
+ gr_idx[j_min:j_max, i_min:i_max] -= temp_ij_bboxes[0, j, i]
+ az_idx[j_min:j_max, i_min:i_max] -= temp_ij_bboxes[1, j, i]
+
+ target_ds = xr.Dataset(coords=az_idx.coords)
+ for var_name, data_array in data.items():
+ tiled = _reorganize_tiled_array(data_array.data, scr_indexing, np.nan)
+ resampled = da.map_blocks(
+ _sample_array_at_indices,
+ tiled,
+ gr_idx.data,
+ az_idx.data,
+ interp_method=interp_method,
+ dtype=data_array.dtype,
+ chunks=gr_idx.data.chunks,
)
+ target_ds[var_name] = (az_idx.dims, resampled)
- # Build template with new coordinates
- chunksizes = {}
- for val in [time_az, time_slr]:
- for dim in val.dims:
- chunksizes[dim] = val.chunksizes[dim]
- coeff_interp = coeff.interp(azimuth_time=time_az)
- x_tgt = (time_slr - coeff.attrs["mean"]) / coeff.attrs["std"]
- ground_range = (coeff_interp * x_tgt**coeff.degree).sum("degree")
- template = data.interp(
- azimuth_time=time_az,
- ground_range=ground_range,
- ).chunk(chunksizes)
-
- target_coords = xr.Dataset({"time_az": time_az, "time_slr": time_slr})
- return xr.map_blocks(_interp_block, target_coords, template=template)
+ return target_ds
-def terrain_correct(
- data: xr.Dataset,
- time_slr_gcp: xr.DataArray,
- sat_position: xr.DataArray,
+def get_source_location(
dem: xr.DataArray,
- apply_rtc: bool = True,
- grid_params: GridParams | None = None,
- interp_method: Literal["nearest", "bilinear"] = "nearest",
+ time_slr_gcp: xr.DataArray | None,
+ sat_position: xr.DataArray,
+ grid_params: GridParams,
+ gm_dem_grid: GridMapping,
+ apply_rtc: bool,
+ range_coord: str = "ground_range",
) -> xr.Dataset:
- """Apply terrain correction to SAR data.
- Args:
- data: Input SAR dataset.
- time_slr_gcp: GCP slant-range times.
- sat_position: Satellite positions over time.
- dem: DEM for terrain correction.
- apply_rtc: Whether to apply radiometric terrain correction.
- grid_params: Grid parameters for RTC.
- interp_method: Interpolation method.
+ gr_coeff = fit_ground_range(time_slr_gcp) if range_coord == "ground_range" else None
- Returns:
- Terrain-corrected dataset.
-
- Raises:
- ValueError: If RTC is enabled without grid parameters.
- """
- gm_dem = GridMapping.from_dataset(dem.to_dataset(name="dem"))
- dem_ecef = convert_dem_to_ecef(dem, gm_dem)
-
- acquisition = simulate_acquisition(
- dem_ecef, gm_dem, sat_position, apply_rtc=apply_rtc
- )
+ # get polynomial coefficient to convert from azimuth time to position and velocity
+ pos_coeff = fit_position(sat_position)
+ vel_coeff = poly_derivative(pos_coeff)
- geocoded = geocode_data(
- data,
- acquisition.azimuth_time,
- acquisition.slant_range_time,
- time_slr_gcp,
- interp_method,
+ data_array = xr.zeros_like(dem, dtype="float32")
+ azimuth_data = xr.zeros_like(dem, dtype="datetime64[ns]")
+ template = xr.Dataset(
+ {"azimuth_time": azimuth_data, range_coord: data_array},
)
-
if apply_rtc:
- if grid_params is None:
- raise ValueError("grid parameters required for RTC")
-
- if interp_method == "bilinear":
- weights_fn = gamma_weights_bilinear
- else: # interp_method == "nearest"
- weights_fn = gamma_weights_nearest
- beta_sim = apply_gamma_weights(acquisition, weights_fn, grid_params)
- geocoded = geocoded / beta_sim
- rename_dict = {
- name: name.replace("beta0", "gamma0") for name in geocoded.data_vars
- }
- geocoded = geocoded.rename(rename_dict)
- for var in geocoded.data_vars:
- geocoded[var].attrs.update(
- long_name="gamma nought backscatter coefficient",
- units="1",
- )
-
- geocoded = assign_grid_mapping(geocoded)
- return geocoded
+ template["gamma_area"] = data_array
+ if "spatial_ref" in template:
+ template = template.drop_vars("spatial_ref")
+ gm_dem_params = {
+ "crs": gm_dem_grid.crs.to_wkt(),
+ "xy_var_names": gm_dem_grid.xy_var_names,
+ }
+ out = xr.map_blocks(
+ backward_geocode,
+ dem,
+ kwargs={
+ "pos_coeff": pos_coeff,
+ "vel_coeff": vel_coeff,
+ "gr_coeff": gr_coeff,
+ "grid_params": grid_params,
+ "range_coord": range_coord,
+ "apply_rtc": apply_rtc,
+ "gm_dem_params": gm_dem_params,
+ },
+ template=template,
+ )
+ out.coords["spatial_ref"] = xr.DataArray(0, attrs=gm_dem_grid.crs.to_cf())
+ return out
def assign_grid_mapping(dataset: xr.Dataset) -> xr.Dataset:
for var_name, data_var in dataset.data_vars.items():
dataset[var_name].attrs["grid_mapping"] = "spatial_ref"
return dataset
+
+
+def _xy_bbox_block(x_coords: np.ndarray, y_coords: np.ndarray):
+ x_edges = np.concatenate([x_coords[:, 0], x_coords[:, -1]])
+ y_edges = np.concatenate([y_coords[0, :], y_coords[-1, :]])
+ bbox = np.array(
+ [
+ np.floor(x_edges.min()),
+ np.floor(y_edges.min()),
+ np.ceil(x_edges.max()),
+ np.ceil(y_edges.max()),
+ ],
+ dtype=np.int32,
+ )
+ return bbox[:, None, None]
+
+
+def _compute_indexing(
+ data: xr.Dataset,
+ az_ix: xr.DataArray,
+ gr_idx: xr.DataArray,
+ range_coord: str = "ground_range",
+) -> SourceTileIndexing:
+
+ src_ij_bboxes = da.map_blocks(
+ _xy_bbox_block,
+ gr_idx.data,
+ az_ix.data,
+ dtype=gr_idx.dtype,
+ chunks=(4, 1, 1),
+ )
+ src_ij_bboxes = src_ij_bboxes.compute()
+
+ # Extend bounding box indices to match the largest bounding box.
+ # This ensures uniform chunk sizes, which are required for da.map_blocks.
+ i_diff = src_ij_bboxes[2] - src_ij_bboxes[0]
+ j_diff = src_ij_bboxes[3] - src_ij_bboxes[1]
+ i_diff_max = np.nanmax(i_diff) + 1
+ j_diff_max = np.nanmax(j_diff) + 1
+ i_half = (i_diff_max - i_diff) // 2
+ j_half = (j_diff_max - j_diff) // 2
+ src_ij_bboxes[0] -= i_half
+ src_ij_bboxes[2] = src_ij_bboxes[0] + i_diff_max
+ src_ij_bboxes[1] -= j_half
+ src_ij_bboxes[3] = src_ij_bboxes[1] + j_diff_max
+
+ # assign padding if needed
+ i_min = np.nanmin(src_ij_bboxes[0])
+ i_max = np.nanmax(src_ij_bboxes[2])
+ j_min = np.nanmin(src_ij_bboxes[[1, 3]])
+ j_max = np.nanmax(src_ij_bboxes[[1, 3]])
+ pad_width = (
+ (-min(0, int(j_min)), max(0, int(j_max - data.sizes["azimuth_time"]))),
+ (-min(0, int(i_min)), max(0, int(i_max - data.sizes[range_coord]))),
+ )
+ src_ij_bboxes[[1, 3]] += pad_width[0][0]
+ src_ij_bboxes[[0, 2]] += pad_width[1][0]
+
+ tile_size = (int(j_diff_max), int(i_diff_max))
+ size = (
+ int(j_diff_max * src_ij_bboxes.shape[1]),
+ int(i_diff_max * src_ij_bboxes.shape[2]),
+ )
+
+ return SourceTileIndexing(
+ ij_bboxes=src_ij_bboxes,
+ pad_width=pad_width,
+ output_size=size,
+ tile_size=tile_size,
+ )
+
+
+def _sample_array_at_indices(
+ data: np.ndarray,
+ x_idx: np.ndarray,
+ y_idx: np.ndarray,
+ interp_method: Literal["nearest", "bilinear"] | None = None,
+) -> np.ndarray:
+ """Sample a 3D array at fractional indices (y_idx, x_idx)."""
+ if interp_method == "nearest":
+ x_i = np.ceil(x_idx - 0.5).astype(np.intp)
+ y_i = np.ceil(y_idx - 0.5).astype(np.intp)
+ return data[y_i, x_i]
+
+ x_floor = np.floor(x_idx).astype(np.intp)
+ y_floor = np.floor(y_idx).astype(np.intp)
+ x_ceil = np.ceil(x_idx).astype(np.intp)
+ y_ceil = np.ceil(y_idx).astype(np.intp)
+
+ dx = x_idx - x_floor
+ dy = y_idx - y_floor
+
+ v00 = data[y_floor, x_floor]
+ v01 = data[y_floor, x_ceil]
+ v10 = data[y_ceil, x_floor]
+ v11 = data[y_ceil, x_ceil]
+
+ if interp_method == "bilinear":
+ u0 = v00 + dx * (v01 - v00)
+ u1 = v10 + dx * (v11 - v10)
+ return u0 + dy * (u1 - u0)
+
+ raise NotImplementedError(
+ f"interp_methods must be one of 'nearest', 'bilinear', "
+ f"was '{interp_method}'."
+ )
=====================================
xarray_eopf/amodes/sentinel3.py
=====================================
@@ -56,7 +56,14 @@ class Sen3(AnalysisMode, ABC):
resolution = kwargs.get("resolution")
if resolution is not None:
- assert_arg_is_instance(resolution, "resolution", (int, float))
+ assert_arg_is_instance(resolution, "resolution", (float, int, tuple))
+ if isinstance(resolution, tuple):
+ assert_arg_has_length(resolution, "resolution", 2)
+ if not all(isinstance(v, (float, int)) for v in resolution):
+ raise TypeError(
+ "resolution argument must contain exactly "
+ "two float or int values."
+ )
params.update(resolution=resolution)
bbox = kwargs.get("bbox")
=====================================
xarray_eopf/backend.py
=====================================
@@ -19,6 +19,7 @@ from .constants import (
OP_MODE_ANALYSIS,
OP_MODE_NATIVE,
OP_MODES,
+ FloatInt,
OpMode,
Sen1InterpMethods,
)
@@ -122,14 +123,16 @@ class EopfBackend(BackendEntrypoint):
variables: str | Iterable[str] | None = None,
# params for op_mode=analysis
product_type: str | None = None,
- resolution: int | float | None = None,
+ resolution: FloatInt | tuple[FloatInt, FloatInt] | None = None,
bbox: Sequence[int | float] | None = None,
crs: pyproj.CRS | str | None = None,
interp_methods: SpatialInterpMethods | Sen1InterpMethods | None = None,
agg_methods: SpatialAggMethods | None = None,
+ # params for Sentinel-1 specifically
dem: xr.DataArray | None = None,
footprint_scale_factor: tuple[float | int, float | int] | None = None,
apply_rtc: bool = True,
+ cache_uri: str | None = None,
# params required by xarray backend interface
drop_variables: str | Iterable[str] | None = None,
# params for other reasons
@@ -154,9 +157,11 @@ class EopfBackend(BackendEntrypoint):
group_sep: Separator string used to concatenate groups names
to create prefixes for unique variable and dimension names.
Defaults to the underscore character (`"_"`)
- resolution: Target resolution for all spatial
- data variables / bands. For Sentinel-2 products it be one of
- `10`, `20`, or `60`. Only used if `op_mode="analysis"`.
+ resolution: Target spatial resolution for all spatial
+ data variables/bands. The resolution can be specified as a float, integer,
+ or a tuple in the form `(easting, northing)`. For Sentinel-2 products,
+ valid resolutions are `10`, `20`, and `60` meters. This parameter is only
+ used when `op_mode="analysis"`.
bbox: Bounding box [west, south, east, north], used for subsetting.
crs: coordinate reference system of output dataset. Can be provided as a
`str` or a `pyproj.CRS` object. If a string is given, it will be parsed
@@ -207,6 +212,11 @@ class EopfBackend(BackendEntrypoint):
when not provided.
apply_rtc: Whether to apply radiometric terrain correction (RTC) for
Sentinel-1 analysis mode. Defaults to `True`.
+ cache_uri: Temporary path, interpreted as a fsspec `urlpath`, where
+ intermediate results from the backward geocoding step of the Sentinel-1
+ processing workflow are stored. The cache is automatically deleted
+ when the Python process exits. If `None`, a temporary directory with
+ a unique UUID-based name is created automatically.
variables: Variables to include in the dataset. Can be a name or
regex pattern or iterable of the latter.
drop_variables: Variable name or iterable of variable names
@@ -266,6 +276,7 @@ class EopfBackend(BackendEntrypoint):
dem=dem,
footprint_scale_factor=footprint_scale_factor,
apply_rtc=apply_rtc,
+ cache_uri=cache_uri,
)
dataset = analysis_mode.convert_datatree(
datatree, includes=variables, **params
=====================================
xarray_eopf/version.py
=====================================
@@ -2,4 +2,4 @@
# Permissions are hereby granted under the terms of the Apache 2.0 License:
# https://opensource.org/license/apache-2-0.
-version = "0.2.9"
+version = "0.3.0"
View it on GitLab: https://salsa.debian.org/debian-gis-team/xarray-eopf/-/compare/3509a626c658af31d8d903381c65888adc6c4cdd...d69a15b04c6a775543891e799ff2f72b9ff1af44
--
View it on GitLab: https://salsa.debian.org/debian-gis-team/xarray-eopf/-/compare/3509a626c658af31d8d903381c65888adc6c4cdd...d69a15b04c6a775543891e799ff2f72b9ff1af44
You're receiving this email because of your account on salsa.debian.org. Manage all notifications: https://salsa.debian.org/-/profile/notifications | Help: https://salsa.debian.org/help
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <http://alioth-lists.debian.net/pipermail/pkg-grass-devel/attachments/20260729/c7565a88/attachment-0001.htm>
More information about the Pkg-grass-devel
mailing list