From bb4749f749e686fd53d009e06393292c7f267f39 Mon Sep 17 00:00:00 2001 From: Alessandro Amici Date: Fri, 2 Oct 2026 20:51:23 +0200 Subject: [PATCH 01/14] Add the GLO-30 and GLO-90 integration tests Name the tests/data folders after the exact product name. --- tests/conftest.py | 2 +- tests/data/GLO-30/ne_rome.tif | Bin 0 -> 23097 bytes tests/data/GLO-30/nw_san_francisco.tif | Bin 0 -> 21225 bytes tests/data/GLO-30/se_sydney.tif | Bin 0 -> 22399 bytes tests/data/GLO-30/sw_santiago.tif | Bin 0 -> 15218 bytes tests/data/GLO-90/ne_rome.tif | Bin 0 -> 23146 bytes tests/data/GLO-90/nw_san_francisco.tif | Bin 0 -> 14194 bytes tests/data/GLO-90/se_sydney.tif | Bin 0 -> 20757 bytes tests/data/GLO-90/sw_santiago.tif | Bin 0 -> 16842 bytes tests/data/{mapzen => MAPZEN}/ne_rome.tif | Bin tests/data/{mapzen => MAPZEN}/nw_iceland.tif | Bin .../{mapzen => MAPZEN}/nw_san_francisco.tif | Bin tests/data/{mapzen => MAPZEN}/se_sydney.tif | Bin tests/data/{mapzen => MAPZEN}/sw_santiago.tif | Bin .../{srtm1_ellip => SRTM1_ELLIP}/ne_rome.tif | Bin .../nw_bogota.tif | Bin .../nw_san_francisco.tif | Bin .../se_sydney.tif | Bin .../sw_santiago.tif | Bin .../{srtm1_geoid => SRTM1_GEOID}/ne_rome.tif | Bin .../nw_san_francisco.tif | Bin .../se_sydney.tif | Bin .../sw_santiago.tif | Bin tests/data/{srtm3 => SRTM3}/ne_rome.tif | Bin .../{srtm3 => SRTM3}/nw_san_francisco.tif | Bin tests/data/{srtm3 => SRTM3}/se_sydney.tif | Bin tests/data/{srtm3 => SRTM3}/sw_santiago.tif | Bin tests/integration_glo_30.py | 40 +++++++++++++++++ tests/integration_glo_90.py | 41 ++++++++++++++++++ tests/integration_mapzen.py | 2 +- tests/integration_srtm1_ellip.py | 2 +- tests/integration_srtm1_geoid.py | 2 +- tests/integration_srtm3.py | 2 +- 33 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 tests/data/GLO-30/ne_rome.tif create mode 100644 tests/data/GLO-30/nw_san_francisco.tif create mode 100644 tests/data/GLO-30/se_sydney.tif create mode 100644 tests/data/GLO-30/sw_santiago.tif create mode 100644 tests/data/GLO-90/ne_rome.tif create mode 100644 tests/data/GLO-90/nw_san_francisco.tif create mode 100644 tests/data/GLO-90/se_sydney.tif create mode 100644 tests/data/GLO-90/sw_santiago.tif rename tests/data/{mapzen => MAPZEN}/ne_rome.tif (100%) rename tests/data/{mapzen => MAPZEN}/nw_iceland.tif (100%) rename tests/data/{mapzen => MAPZEN}/nw_san_francisco.tif (100%) rename tests/data/{mapzen => MAPZEN}/se_sydney.tif (100%) rename tests/data/{mapzen => MAPZEN}/sw_santiago.tif (100%) rename tests/data/{srtm1_ellip => SRTM1_ELLIP}/ne_rome.tif (100%) rename tests/data/{srtm1_ellip => SRTM1_ELLIP}/nw_bogota.tif (100%) rename tests/data/{srtm1_ellip => SRTM1_ELLIP}/nw_san_francisco.tif (100%) rename tests/data/{srtm1_ellip => SRTM1_ELLIP}/se_sydney.tif (100%) rename tests/data/{srtm1_ellip => SRTM1_ELLIP}/sw_santiago.tif (100%) rename tests/data/{srtm1_geoid => SRTM1_GEOID}/ne_rome.tif (100%) rename tests/data/{srtm1_geoid => SRTM1_GEOID}/nw_san_francisco.tif (100%) rename tests/data/{srtm1_geoid => SRTM1_GEOID}/se_sydney.tif (100%) rename tests/data/{srtm1_geoid => SRTM1_GEOID}/sw_santiago.tif (100%) rename tests/data/{srtm3 => SRTM3}/ne_rome.tif (100%) rename tests/data/{srtm3 => SRTM3}/nw_san_francisco.tif (100%) rename tests/data/{srtm3 => SRTM3}/se_sydney.tif (100%) rename tests/data/{srtm3 => SRTM3}/sw_santiago.tif (100%) create mode 100644 tests/integration_glo_30.py create mode 100644 tests/integration_glo_90.py diff --git a/tests/conftest.py b/tests/conftest.py index 2c2cf11..e504a82 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -107,7 +107,7 @@ def integration_data( def integrate( product: str, name: str, bounds: tuple[float, float, float, float] ) -> None: - reference = REFERENCE_DATA_DIR / product.lower() / f"{name}.tif" + reference = REFERENCE_DATA_DIR / product / f"{name}.tif" if not update and not reference.exists(): pytest.skip(f"missing {reference}: run --update-integration-data") output = tmp_path / f"{name}.tif" diff --git a/tests/data/GLO-30/ne_rome.tif b/tests/data/GLO-30/ne_rome.tif new file mode 100644 index 0000000000000000000000000000000000000000..a24fa0c4232d5457be65244efc4962bf7604e822 GIT binary patch literal 23097 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 X@V^&`|8M`N2s~q^0sG;e&V>I0u=K!U literal 0 HcmV?d00001 diff --git a/tests/data/GLO-30/se_sydney.tif b/tests/data/GLO-30/se_sydney.tif new file mode 100644 index 0000000000000000000000000000000000000000..79a38433051055ff0dca0a7deb9dd5dc1dc6a05b GIT binary patch literal 22399 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�Pa8 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 H)q(#5lOcZ{ literal 0 HcmV?d00001 diff --git a/tests/data/GLO-30/sw_santiago.tif b/tests/data/GLO-30/sw_santiago.tif new file mode 100644 index 0000000000000000000000000000000000000000..7564a0a2a6fa4d201b1619575b9c29d3d995b879 GIT binary patch literal 15218 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 N{R038FLc`de*vUQ{MG;f literal 0 HcmV?d00001 diff --git a/tests/data/GLO-90/ne_rome.tif b/tests/data/GLO-90/ne_rome.tif new file mode 100644 index 0000000000000000000000000000000000000000..7cf720dd533fa2d8adc31487ad7a0fd7ec1f38d9 GIT binary patch literal 23146 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 bb3`ek|C@LF|MmYofy*wG{2gwcUylC+Ei^rG literal 0 HcmV?d00001 diff --git a/tests/data/GLO-90/nw_san_francisco.tif b/tests/data/GLO-90/nw_san_francisco.tif new file mode 100644 index 0000000000000000000000000000000000000000..58d7aac7b78e46f7aa97d057e1ba9500b49b9c57 GIT binary patch literal 14194 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%0A~dAitp6-9E+Edi^jbMIxU69(eg&5}L4WGUgt!~$ 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 literal 0 HcmV?d00001 diff --git a/tests/data/GLO-90/se_sydney.tif b/tests/data/GLO-90/se_sydney.tif new file mode 100644 index 0000000000000000000000000000000000000000..718320983820e1a36ae1ef28258e613e89c88038 GIT binary patch literal 20757 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 Q_W#>1h#zPI^tQ?W1A9sBuK)l5 literal 0 HcmV?d00001 diff --git a/tests/data/GLO-90/sw_santiago.tif b/tests/data/GLO-90/sw_santiago.tif new file mode 100644 index 0000000000000000000000000000000000000000..5deb6b02ef4d2e1428daa13f18a49c1983fe7d35 GIT binary patch literal 16842 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 None: + bounds = (12.4, 41.8, 12.4 + SIZE, 41.8 + SIZE) + integration_data("GLO-30", "ne_rome", bounds) + + +def test_nw_san_francisco(integration_data: IntegrationData) -> None: + bounds = (-122.44, 37.74, -122.44 + SIZE, 37.74 + SIZE) + integration_data("GLO-30", "nw_san_francisco", bounds) + + +def test_se_sydney(integration_data: IntegrationData) -> None: + bounds = (151.16, -33.9, 151.16 + SIZE, -33.9 + SIZE) + integration_data("GLO-30", "se_sydney", bounds) + + +def test_sw_santiago(integration_data: IntegrationData) -> None: + bounds = (-70.6, -33.42, -70.6 + SIZE, -33.42 + SIZE) + integration_data("GLO-30", "sw_santiago", bounds) diff --git a/tests/integration_glo_90.py b/tests/integration_glo_90.py new file mode 100644 index 0000000..7d9e049 --- /dev/null +++ b/tests/integration_glo_90.py @@ -0,0 +1,41 @@ +# +# Copyright (c) 2016-2026 B-Open Solutions srl - https://bopen.eu +# + +"""Integration tests for the ``GLO-90`` product, i.e. Copernicus DEM 90m. + +The product is a north-up Zarr store on the Earth Data Hub that is read in place +instead of being downloaded, so these tests also cover the Earth Data Hub +credentials in ``~/.netrc`` and the ``ZARR:`` GDAL connection string. +The regions are the same as the ones of ``SRTM3``, so the two reference datasets +cover the same areas and can be cross-checked, even though the two grids are half +a pixel apart. +Each test clips a ~100x100 pixel DEM and compares it with the reference GeoTIFF +committed in ``tests/data/GLO-90``, see ``CONTRIBUTING.rst`` to regenerate them. +""" + +from collections.abc import Callable + +SIZE = 100 / 1200 + +IntegrationData = Callable[[str, str, tuple[float, float, float, float]], None] + + +def test_ne_rome(integration_data: IntegrationData) -> None: + bounds = (12.4, 41.8, 12.4 + SIZE, 41.8 + SIZE) + integration_data("GLO-90", "ne_rome", bounds) + + +def test_nw_san_francisco(integration_data: IntegrationData) -> None: + bounds = (-122.44, 37.74, -122.44 + SIZE, 37.74 + SIZE) + integration_data("GLO-90", "nw_san_francisco", bounds) + + +def test_se_sydney(integration_data: IntegrationData) -> None: + bounds = (151.16, -33.9, 151.16 + SIZE, -33.9 + SIZE) + integration_data("GLO-90", "se_sydney", bounds) + + +def test_sw_santiago(integration_data: IntegrationData) -> None: + bounds = (-70.6, -33.42, -70.6 + SIZE, -33.42 + SIZE) + integration_data("GLO-90", "sw_santiago", bounds) diff --git a/tests/integration_mapzen.py b/tests/integration_mapzen.py index e4d4c3c..f61e6b7 100644 --- a/tests/integration_mapzen.py +++ b/tests/integration_mapzen.py @@ -8,7 +8,7 @@ chosen to cover plain SRTM data, the USGS 3DEP data in the United States and the high latitudes that no SRTM product covers. Each test clips a ~100x100 pixel DEM and compares it with the reference GeoTIFF -committed in ``tests/data/mapzen``, see ``CONTRIBUTING.rst``. +committed in ``tests/data/MAPZEN``, see ``CONTRIBUTING.rst``. """ from collections.abc import Callable diff --git a/tests/integration_srtm1_ellip.py b/tests/integration_srtm1_ellip.py index e13cb66..11974c6 100644 --- a/tests/integration_srtm1_ellip.py +++ b/tests/integration_srtm1_ellip.py @@ -7,7 +7,7 @@ The tile names of this product are grouped in ``North/North_30_60``, ``North/North_0_29`` and ``South`` folders, all of them are covered here. Each test clips a ~100x100 pixel DEM and compares it with the reference GeoTIFF -committed in ``tests/data/srtm1_ellip``, see ``CONTRIBUTING.rst`` to regenerate them. +committed in ``tests/data/SRTM1_ELLIP``, see ``CONTRIBUTING.rst`` to regenerate them. """ from collections.abc import Callable diff --git a/tests/integration_srtm1_geoid.py b/tests/integration_srtm1_geoid.py index 4cb8dd4..b5bf5d7 100644 --- a/tests/integration_srtm1_geoid.py +++ b/tests/integration_srtm1_geoid.py @@ -5,7 +5,7 @@ """Integration tests for the ``SRTM1_GEOID`` product, i.e. SRTM GL1 on OpenTopography. Each test clips a ~100x100 pixel DEM and compares it with the reference GeoTIFF -committed in ``tests/data/srtm1_geoid``, see ``CONTRIBUTING.rst`` to regenerate them. +committed in ``tests/data/SRTM1_GEOID``, see ``CONTRIBUTING.rst`` to regenerate them. """ from collections.abc import Callable diff --git a/tests/integration_srtm3.py b/tests/integration_srtm3.py index b08a1f6..80dfa6c 100644 --- a/tests/integration_srtm3.py +++ b/tests/integration_srtm3.py @@ -5,7 +5,7 @@ """Integration tests for the ``SRTM3`` product, i.e. CGIAR-CSI SRTM 90m. Each test clips a ~100x100 pixel DEM and compares it with the reference GeoTIFF -committed in ``tests/data/srtm3``, see ``CONTRIBUTING.rst`` to regenerate them. +committed in ``tests/data/SRTM3``, see ``CONTRIBUTING.rst`` to regenerate them. """ from collections.abc import Callable From 8e9ef3a875986437ecc02d6242b132054e5d6c7c Mon Sep 17 00:00:00 2001 From: Alessandro Amici Date: Fri, 2 Oct 2026 21:59:04 +0200 Subject: [PATCH 02/14] Add the GLO-30 and GLO-90 Earth Data Hub products Read the Copernicus DEM Zarr stores in place, one store chunk at a time, and cache them as GeoTIFF tiles like the other products, so the mosaic and the clip are unchanged. The new chunks spec key tells a remote product from a local one, and write_cache_tile takes the chunk srcwin window. util.ensure_setup now creates only the product folder and its cache folder: the spool folder appears on demand with the first tile download. --- CHANGELOG.rst | 5 + README.md | 30 +++++- docs/index.md | 2 +- elevation/datasets/elevation.yaml | 2 +- elevation/datasource.py | 154 ++++++++++++++++++++++++------ elevation/util.py | 15 ++- tests/test_10_util.py | 20 ++-- tests/test_20_datasource.py | 79 ++++++++++++++- tests/test_40_main.py | 1 - 9 files changed, 248 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f7cd34c..d423d5f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ - Download the original SRTM GL1 data from OpenTopography for the ``SRTM1_GEOID`` product. - Add the ``MAPZEN`` product for the global Mapzen terrain tiles mosaic and make it the default product. +- Add the ``GLO-30`` and ``GLO-90`` products for the Copernicus DEM global 30m and 90m + DSM on the EGM2008 geoid. They are distributed by the Earth Data Hub as a cloud-hosted + Zarr store that is read in place, one chunk at a time, and cached as GeoTIFF tiles + like the other products, so the credentials in ``~/.netrc`` are used and GDAL 3.8 or + later is required. - Retire the ``SRTM1`` product name: requesting it raises an error pointing to the migration notes. - Accept ``str`` or ``Path`` for all path-valued arguments. diff --git a/README.md b/README.md index e4e190e..e9d9f2e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,10 @@ Elevation provides easy download, cache and access of the global datasets: hosted on OpenTopography, 30m heights on the WGS84 ellipsoid. - `SRTM3`: [SRTM global 90m v4.1](https://bigdata.cgiar.org/srtm-90m-digital-elevation-database/) produced by CGIAR-CSI. +- `GLO-30`: [Copernicus DEM global 30m (2021)](https://doi.org/10.5270/ESA-c5d3d65) + produced by ESA and the European Union, 30m heights on the EGM2008 geoid. +- `GLO-90`: [Copernicus DEM global 90m (2021)](https://doi.org/10.5270/ESA-c5d3d65) + the 90m companion of `GLO-30`. Note that any download policies and attribution requirements of the respective providers apply. @@ -69,6 +73,26 @@ For the SRTM global 90m v4.1 DEM use: $ eio --product SRTM3 clip -o Rome-SRTM3-DEM.tif --bounds 12.35 41.8 12.65 42 ``` +For the Copernicus DEM global 30m or 90m DEMs use: + +```console +$ eio --product GLO-30 clip -o Rome-GLO-30-DEM.tif --bounds 12.35 41.8 12.65 42 +$ eio --product GLO-90 clip -o Rome-GLO-90-DEM.tif --bounds 12.35 41.8 12.65 42 +``` + +The `GLO-30` and `GLO-90` products are distributed by the Earth Data Hub as a +single cloud-hosted Zarr store that is read in place and cached one chunk at a +time instead of downloading whole tiles, and the credentials of the account +stored in `~/.netrc` are used to access it: + +```console +machine data.earthdatahub.destine.eu + login + password +``` + +Reading the store needs GDAL 3.8 or later. + The `--bounds` option accepts latitude and longitude coordinates (more precisely in geodetic coordinates in the WGS84 reference system EPSG:4326 for those who care) given as `left bottom right top` similarly to the `rio` command form `rasterio`. @@ -91,6 +115,8 @@ The first time an area is accessed Elevation downloads the data tiles from the AWS S3, CGIAR-CSI or OpenTopography servers and caches them in GeoTIFF compressed formats, subsequent accesses to the same and nearby areas are much faster. +The `GLO-30` and `GLO-90` products are the exception: they are read in place +from the Earth Data Hub and cached one Zarr chunk at a time. The `clip` sub-command doesn't allow automatic download of a large amount of DEM tiles, please refer to the upstream providers' websites to learn the preferred procedures for bulk download. @@ -121,8 +147,8 @@ $ eio --help ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ │ --version Show the version and exit. │ │ [env var: EIO_VERSION] │ -│ --product [MAPZEN|SRTM1_GEOID|SRTM1_ELLIP|SRTM3 DEM product choice. │ -│ ] [env var: EIO_PRODUCT] │ +│ --product [MAPZEN|GLO-30|GLO-90|SRTM1_GEOID|SRT DEM product choice. │ +│ M1_ELLIP|SRTM3] [env var: EIO_PRODUCT] │ │ [default: MAPZEN] │ │ --cache_dir Root of the DEM cache folder. │ │ [env var: EIO_CACHE_DIR] │ diff --git a/docs/index.md b/docs/index.md index 9f52225..0cba758 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ # elevation Download, cache and clip global terrain digital elevation models: Mapzen terrain -tiles, SRTM 30m and 90m DEMs. +tiles, SRTM 30m and 90m DEMs, Copernicus DEM 30m and 90m DSMs. If you have any feedback or you want to help out head over to our main repository: https://github.com/bopen/elevation diff --git a/elevation/datasets/elevation.yaml b/elevation/datasets/elevation.yaml index a8911d5..946eec5 100644 --- a/elevation/datasets/elevation.yaml +++ b/elevation/datasets/elevation.yaml @@ -1,6 +1,6 @@ id: elevation title: elevation datasets -description: 'Download, cache and clip global terrain digital elevation models: Mapzen terrain tiles, SRTM 30m and 90m DEMs.' +description: 'Download, cache and clip global terrain digital elevation models: Mapzen terrain tiles, SRTM 30m and 90m DEMs, Copernicus DEM 30m and 90m DSMs.' type: Catalog stac_version: 1.1.0 links: diff --git a/elevation/datasource.py b/elevation/datasource.py index 975c23d..50c9517 100644 --- a/elevation/datasource.py +++ b/elevation/datasource.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import math import os import shutil @@ -20,7 +21,7 @@ from collections.abc import Callable, Iterator, Sequence from importlib import resources from pathlib import Path -from typing import TypedDict +from typing import NotRequired, TypedDict import appdirs @@ -47,8 +48,13 @@ CACHE_DIR: str = appdirs.user_cache_dir("elevation", "bopen") DEFAULT_OUTPUT = "out.tif" DEFAULT_GDAL_OPTIONS = "-co TILED=YES -co COMPRESS=DEFLATE -co ZLEVEL=9 -co PREDICTOR=2" +CACHE_EXT = ".tif" # the VRT mosaic reads the cache tiles whole, the options only trade size for speed TILE_GDAL_OPTIONS = "-co TILED=YES -co COMPRESS=DEFLATE -co ZLEVEL=9 -co PREDICTOR=2" +# the float products, e.g. the Copernicus DEM stores, need the float predictor +FLOAT_TILE_GDAL_OPTIONS = ( + "-co TILED=YES -co COMPRESS=DEFLATE -co ZLEVEL=9 -co PREDICTOR=3" +) MARGIN = "0" @@ -138,16 +144,65 @@ def mapzen_tiles_names( yield from srtm1_tiles_names(left, bottom, right, top, "{slat}/{slat}{slon}.tif") +def zarr_grid(source: str) -> tuple[list[float], list[int], list[int]]: + """Return the geotransform, the size and the block shape of a GDAL source. + + The ``gdalinfo`` report of a Zarr store pins the grid its chunks tile from: + the store is north-up, so chunk ``(ix, iy)`` is the ``-srcwin`` window + ``(ix * block_xsize, iy * block_ysize, block_xsize, block_ysize)``. + """ + env = {**os.environ, "GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR"} + report = subprocess.check_output(["gdalinfo", "-json", source], env=env) + info = json.loads(report) + return info["geoTransform"], info["size"], info["bands"][0]["block"] + + +def zarr_chunks( + source: str, left: float, bottom: float, right: float, top: float +) -> Iterator[tuple[str, tuple[int, int, int, int]]]: + """Yield the cache tile and the source window of the chunks covering the bounds. + + The tiles are named after the chunk indices of the store, so the cache + folder shows which chunks have been read. The bounds are rounded outward to + whole chunks, like ``gdal_translate -projwin`` rounds to whole pixels. + """ + geotransform, size, block = zarr_grid(source) + x0, pixel_x, _, y0, _, pixel_y = geotransform + width, height = size + block_xsize, block_ysize = block + # the store is north-up, so pixel_y is negative and the top row is the first + xoff = max(math.floor((left - x0) / pixel_x), 0) + xend = min(math.ceil((right - x0) / pixel_x), width) + yoff = max(math.floor((y0 - top) / -pixel_y), 0) + yend = min(math.ceil((y0 - bottom) / -pixel_y), height) + for ix in range(xoff // block_xsize, (xend - 1) // block_xsize + 1): + for iy in range(yoff // block_ysize, (yend - 1) // block_ysize + 1): + window = ( + ix * block_xsize, + iy * block_ysize, + min(block_xsize, width - ix * block_xsize), + min(block_ysize, height - iy * block_ysize), + ) + yield f"{ix}_{iy}{CACHE_EXT}", window + + +# a cache tile is its name plus the source window, ``None`` for a whole download +Tile = tuple[str, tuple[int, int, int, int] | None] + + class DatasourceSpec(TypedDict): - folders: tuple[str, ...] datasource_url: str - tile_ext: str - compressed_ext: str - tile_names: Callable[..., Iterator[str]] + # a local product has one URL per tile (``tile_names``), a remote one has many + # chunks of a single source (``chunks``): the enumerator tells the two apart + tile_names: NotRequired[Callable[..., Iterator[str]]] + chunks: NotRequired[Callable[..., Iterator[Tile]]] + tile_ext: NotRequired[str] + # only set when the provider serves the tile compressed + compressed_ext: NotRequired[str] + tile_gdal_options: NotRequired[str] MAPZEN_SPEC: DatasourceSpec = { - "folders": ("spool", "cache"), "datasource_url": "https://s3.amazonaws.com/elevation-tiles-prod/skadi", "tile_ext": ".hgt", "compressed_ext": ".hgt.gz", @@ -155,31 +210,51 @@ class DatasourceSpec(TypedDict): } SRTM1_GEOID_SPEC: DatasourceSpec = { - "folders": ("spool", "cache"), "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1/SRTM_GL1_srtm", "tile_ext": ".tif", - "compressed_ext": "", "tile_names": srtm1_tiles_names, } SRTM1_ELLIP_SPEC: DatasourceSpec = { - "folders": ("spool", "cache"), "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1_Ellip/SRTM_GL1_Ellip_srtm", "tile_ext": ".tif", - "compressed_ext": "", "tile_names": srtm_ellip_tiles_names, } SRTM3_SPEC: DatasourceSpec = { - "folders": ("spool", "cache"), "datasource_url": "https://srtm.csi.cgiar.org/wp-content/uploads/files/srtm_5x5/TIFF", "tile_ext": ".tif", "compressed_ext": ".zip", "tile_names": srtm3_tiles_names, } +# The Copernicus DEM products are distributed by the Earth Data Hub as a single +# north-up Zarr store that is read in place and cached one chunk at a time. The +# ``:/dsm`` array path is required, a bare store opens as a container of +# subdatasets, see https://gdal.org/en/stable/drivers/raster/zarr.html +GLO_30_SPEC: DatasourceSpec = { + "datasource_url": ( + 'ZARR:"/vsicurl/https://data.earthdatahub.destine.eu/copernicus-dem' + '/GLO-30-v1.zarr":/dsm' + ), + "chunks": zarr_chunks, + "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, +} + +# the GLO-90 store has the same layout at 3 arc seconds +GLO_90_SPEC: DatasourceSpec = { + "datasource_url": ( + 'ZARR:"/vsicurl/https://data.earthdatahub.destine.eu/copernicus-dem' + '/GLO-90-v1.zarr":/dsm' + ), + "chunks": zarr_chunks, + "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, +} + PRODUCTS_SPECS: dict[str, DatasourceSpec] = { "MAPZEN": MAPZEN_SPEC, + "GLO-30": GLO_30_SPEC, + "GLO-90": GLO_90_SPEC, "SRTM1_GEOID": SRTM1_GEOID_SPEC, "SRTM1_ELLIP": SRTM1_ELLIP_SPEC, "SRTM3": SRTM3_SPEC, @@ -210,9 +285,6 @@ def __str__(self) -> str: } -CACHE_EXT = ".tif" - - def tile_source(spec: DatasourceSpec, tile_name: str) -> tuple[str, str, str | None]: """Return the ``(url, spool_name, member)`` of the tile for *tile_name*. @@ -223,8 +295,8 @@ def tile_source(spec: DatasourceSpec, tile_name: str) -> tuple[str, str, str | N """ stem = tile_name.removesuffix(CACHE_EXT) spool_name = f"{stem}{spec['tile_ext']}" - compressed_ext = spec["compressed_ext"] - remote = spool_name if not compressed_ext else f"{stem}{compressed_ext}" + compressed_ext = spec.get("compressed_ext") + remote = spool_name if compressed_ext is None else f"{stem}{compressed_ext}" member = Path(spool_name).name if compressed_ext == ".zip" else None return f"{spec['datasource_url']}/{remote}", spool_name, member @@ -291,17 +363,31 @@ def write_cache_tile( return cmd -def ensure_tiles(root: Path, spec: DatasourceSpec, tile_names: Sequence[str]) -> None: - """Fetch and cache *tile_names*, skipping the tiles already in the cache.""" - for tile_name in tile_names: +def ensure_tiles(root: Path, spec: DatasourceSpec, tiles: Sequence[Tile]) -> None: + """Fetch and cache *tiles*, skipping the tiles already in the cache. + + A tile is a ``(name, window)`` pair: a tile with a window is read in place + from ``datasource_url``, a tile without one is downloaded whole from its own + URL and goes through the spool. + """ + gdal_options = spec.get("tile_gdal_options", TILE_GDAL_OPTIONS) + for tile_name, srcwin in tiles: cached = root / "cache" / tile_name if cached.exists() and cached.stat().st_size > 0: continue - source, spool_name, member = tile_source(spec, tile_name) - spooled = root / "spool" / spool_name - fetch_tile(source, spooled, member=member) - write_cache_tile(spooled, cached) - spooled.unlink(missing_ok=True) + if srcwin is None: + source, spool_name, member = tile_source(spec, tile_name) + spooled = root / "spool" / spool_name + fetch_tile(source, spooled, member=member) + write_cache_tile(spooled, cached) + spooled.unlink(missing_ok=True) + else: + write_cache_tile( + spec["datasource_url"], + cached, + srcwin=srcwin, + gdal_options=gdal_options, + ) def build_vrt(root: Path, product: str) -> list[str]: @@ -327,7 +413,7 @@ def ensure_setup( raise ProductRetiredError(RETIRED_PRODUCTS[product]) datasource_root = resolve_cache_dir(cache_dir) / product spec = PRODUCTS_SPECS[product] - util.ensure_setup(datasource_root, folders=spec["folders"]) + util.ensure_setup(datasource_root) return datasource_root, spec @@ -354,6 +440,9 @@ def seed( ) -> Path: """Seed the DEM to given bounds. + A remote product is not downloaded whole: only the chunks of the store that + cover the bounds are read in place and cached. + :param cache_dir: Root of the DEM cache folder. :param product: DEM product choice. :param bounds: Output bounds in 'left bottom right top' order. @@ -362,16 +451,21 @@ def seed( if bounds is None: raise TypeError("bounds must be supplied") datasource_root, spec = ensure_setup(cache_dir, product) - ensure_tiles_names = list(spec["tile_names"](*bounds)) + chunks = spec.get("chunks") + tiles: list[Tile] + if chunks is None: + tiles = [(tile_name, None) for tile_name in spec["tile_names"](*bounds)] + else: + tiles = list(chunks(spec["datasource_url"], *bounds)) # FIXME: emergency hack to enforce the no-bulk-download policy - if len(ensure_tiles_names) > max_download_tiles: + if len(tiles) > max_download_tiles: raise RuntimeError( - f"Too many tiles: {len(ensure_tiles_names)}. Please consult the " + f"Too many tiles: {len(tiles)}. Please consult the " "providers' websites for how to bulk download tiles." ) - with util.lock_tiles(datasource_root, ensure_tiles_names): - ensure_tiles(datasource_root, spec, ensure_tiles_names) + with util.lock_tiles(datasource_root, [tile_name for tile_name, _ in tiles]): + ensure_tiles(datasource_root, spec, tiles) with util.lock_vrt(datasource_root, product): build_vrt(datasource_root, product) diff --git a/elevation/util.py b/elevation/util.py index 49531b0..e224485 100644 --- a/elevation/util.py +++ b/elevation/util.py @@ -65,12 +65,11 @@ def lock_vrt(datasource_root: Path, product: str) -> Generator[None]: yield -def ensure_setup(root: Path, folders: Iterable[str] = ()) -> list[Path]: - """Create *root* and the *folders* in it, returning the created folders.""" +def ensure_setup(root: Path) -> None: + """Create the product folder and its ``cache`` subfolder. + + The ``spool`` folder is created on demand by the tile download. + """ with fasteners.InterProcessLock(root / FOLDER_LOCKFILE_NAME): - created_folders = [] - for path in [root] + [root / p for p in folders]: - if not path.exists(): - path.mkdir(parents=True) - created_folders.append(path) - return created_folders + for path in (root, root / "cache"): + path.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_10_util.py b/tests/test_10_util.py index bb8606b..f520d97 100644 --- a/tests/test_10_util.py +++ b/tests/test_10_util.py @@ -27,17 +27,9 @@ def test_lock_vrt(tmp_path: Path) -> None: def test_ensure_setup(tmp_path: Path) -> None: root = tmp_path / "root" - created_folders = util.ensure_setup(root) - assert len(created_folders) == 0 - assert len(list(tmp_path.iterdir())) == 1 - - folders = ["etc", "lib"] - created_folders = util.ensure_setup(root, folders=folders) - assert len(created_folders) == 2 - assert created_folders[0].name == "etc" - assert created_folders[1].name == "lib" - assert len(list(root.iterdir())) == 3 - - created_folders = util.ensure_setup(root, folders=folders) - assert len(created_folders) == 0 - assert len(list(root.iterdir())) == 3 + + util.ensure_setup(root) + + assert (root / "cache").is_dir() + # the spool folder is created on demand by the tile download + assert not (root / "spool").exists() diff --git a/tests/test_20_datasource.py b/tests/test_20_datasource.py index 1f46130..cc5b006 100644 --- a/tests/test_20_datasource.py +++ b/tests/test_20_datasource.py @@ -54,6 +54,23 @@ def test_mapzen_tiles_names() -> None: assert list(datasource.mapzen_tiles_names(10, 44, 11, 45)) == ["N44/N44E010.tif"] +def test_zarr_chunks(mocker: MockerFixture) -> None: + mocker.patch( + "elevation.datasource.zarr_grid", + return_value=([0.0, 1.0, 0.0, 0.0, 0.0, -1.0], [100, 100], [4, 5]), + ) + + assert list(datasource.zarr_chunks("source", 1.0, -9.0, 3.0, -1.0)) == [ + ("0_0.tif", (0, 0, 4, 5)), + ("0_1.tif", (0, 5, 4, 5)), + ] + # the bounds are rounded outward to whole chunks + assert list(datasource.zarr_chunks("source", 3.5, -1.5, 4.5, -0.5)) == [ + ("0_0.tif", (0, 0, 4, 5)), + ("1_0.tif", (4, 0, 4, 5)), + ] + + def test_srtm3_tiles_names() -> None: assert next(datasource.srtm3_tiles_names(10.1, 44.9, 10.1, 44.9)).endswith( "srtm_39_04.tif" @@ -76,6 +93,9 @@ def test_srtm_ellip_tiles_names() -> None: def test_tile_source() -> None: + # ``compressed_ext`` is only set when the source is compressed + assert "compressed_ext" not in datasource.SRTM1_GEOID_SPEC + url, spooled, member = datasource.tile_source( datasource.MAPZEN_SPEC, "N41/N41E012.tif" ) @@ -102,7 +122,9 @@ def test_ensure_tiles(mocker: MockerFixture, tmp_path: Path) -> None: mock_fetch = mocker.patch("elevation.datasource.fetch_tile") mock_write = mocker.patch("elevation.datasource.write_cache_tile") - datasource.ensure_tiles(tmp_path, datasource.SRTM1_GEOID_SPEC, ["N41E012.tif"]) + datasource.ensure_tiles( + tmp_path, datasource.SRTM1_GEOID_SPEC, [("N41E012.tif", None)] + ) mock_fetch.assert_called_once_with( f"{datasource.SRTM1_GEOID_SPEC['datasource_url']}/N41E012.tif", @@ -121,12 +143,31 @@ def test_ensure_tiles_skips_cached(mocker: MockerFixture, tmp_path: Path) -> Non mock_fetch = mocker.patch("elevation.datasource.fetch_tile") mock_write = mocker.patch("elevation.datasource.write_cache_tile") - datasource.ensure_tiles(tmp_path, datasource.SRTM1_GEOID_SPEC, ["N41E012.tif"]) + datasource.ensure_tiles( + tmp_path, datasource.SRTM1_GEOID_SPEC, [("N41E012.tif", None)] + ) mock_fetch.assert_not_called() mock_write.assert_not_called() +def test_ensure_tiles_remote(mocker: MockerFixture, tmp_path: Path) -> None: + mock_fetch = mocker.patch("elevation.datasource.fetch_tile") + mock_write = mocker.patch("elevation.datasource.write_cache_tile") + tile = ("192_48.tif", (230400, 57600, 1200, 1200)) + + datasource.ensure_tiles(tmp_path, datasource.GLO_90_SPEC, [tile]) + + # a remote product is read in place: no download, one chunk per tile + mock_fetch.assert_not_called() + mock_write.assert_called_once_with( + datasource.GLO_90_SPEC["datasource_url"], + tmp_path / "cache" / "192_48.tif", + srcwin=(230400, 57600, 1200, 1200), + gdal_options=datasource.FLOAT_TILE_GDAL_OPTIONS, + ) + + def test_fetch_tile(tmp_path: Path) -> None: destination = tmp_path / "spool" / "tile.tif" @@ -275,6 +316,38 @@ def test_seed(mocker: MockerFixture, tmp_path: Path) -> None: datasource.seed(cache_dir=root) +def test_seed_remote(mocker: MockerFixture, tmp_path: Path) -> None: + root = tmp_path / "root" + mock_check_call = mocker.patch("subprocess.check_call") + mock_fetch = mocker.patch("elevation.datasource.fetch_tile") + mock_write = mocker.patch("elevation.datasource.write_cache_tile") + mocker.patch( + "elevation.datasource.zarr_grid", + return_value=([0.0, 1.0, 0.0, 0.0, 0.0, -1.0], [100, 100], [4, 5]), + ) + + datasource_root = datasource.seed( + cache_dir=root, product="GLO-30", bounds=(1.0, -9.0, 3.0, -1.0) + ) + + assert datasource_root == root / "GLO-30" + assert not (datasource_root / "spool").exists() + mock_fetch.assert_not_called() + assert mock_write.call_count == 2 + mock_write.assert_any_call( + datasource.GLO_30_SPEC["datasource_url"], + datasource_root / "cache" / "0_0.tif", + srcwin=(0, 0, 4, 5), + gdal_options=datasource.FLOAT_TILE_GDAL_OPTIONS, + ) + assert mock_check_call.call_args[0][0][0] == "gdalbuildvrt" + + with pytest.raises(RuntimeError): + datasource.seed( + cache_dir=root, product="GLO-30", bounds=(0.0, -100.0, 100.0, 0.0) + ) + + def test_build_bounds() -> None: raw_bounds = (13.1, 43.1, 13.9, 43.9) assert datasource.build_bounds(raw_bounds, margin="0") == raw_bounds @@ -372,9 +445,9 @@ def test_info(tmp_path: Path) -> None: def test_dataset() -> None: assert "id: SRTM3\n" in elevation.dataset("SRTM3") + assert "id: GLO-30\n" in elevation.dataset("GLO-30") text = elevation.dataset() assert text.count("id: ") == len(elevation.PRODUCTS) - assert "GLO-30" not in text assert text.endswith("\n") assert text.count("\n---\n") == len(elevation.PRODUCTS) - 1 assert "\n\n---\nid: SRTM1_GEOID\n" in text diff --git a/tests/test_40_main.py b/tests/test_40_main.py index 64b545d..d248050 100644 --- a/tests/test_40_main.py +++ b/tests/test_40_main.py @@ -33,7 +33,6 @@ def test_eio_dataset() -> None: assert not result.exception for dataset in elevation.PRODUCTS: assert f"id: {dataset}\n" in result.output - assert "GLO-30" not in result.output def test_eio_dataset_one() -> None: From ade0b3041558b104e9b78cf7b6b16a39bdb50f17 Mon Sep 17 00:00:00 2001 From: Alessandro Amici Date: Sat, 3 Oct 2026 00:48:00 +0200 Subject: [PATCH 03/14] Record the GLO Zarr store geometry instead of probing it The chunk shape of a Zarr store cannot be read from gdalinfo: the block it reports is a read buffer that GDAL shrinks to fit its cache, so the cache tile names, their number and the max_download_tiles guard changed with the machine. The geotransform, the size and the chunk shape of the two Copernicus DEM stores are now recorded in the specs, like the tile names of the other products, and the ZARR connection string takes the array path as the driver tag suffix: addressing the array as ZARR:"/vsicurl/" reads the same raster but silently without any CRS, that the store keeps in a separate spatial_ref variable. The cache tiles are now whole store chunks and a fully cached clip needs neither the network nor the .netrc credentials. --- elevation/datasource.py | 132 ++++++++++++++++++++++++------------ tests/test_20_datasource.py | 64 ++++++++++++----- 2 files changed, 136 insertions(+), 60 deletions(-) diff --git a/elevation/datasource.py b/elevation/datasource.py index 50c9517..01ff742 100644 --- a/elevation/datasource.py +++ b/elevation/datasource.py @@ -13,7 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import json import math import os import shutil @@ -144,58 +143,73 @@ def mapzen_tiles_names( yield from srtm1_tiles_names(left, bottom, right, top, "{slat}/{slat}{slon}.tif") -def zarr_grid(source: str) -> tuple[list[float], list[int], list[int]]: - """Return the geotransform, the size and the block shape of a GDAL source. +# a cache tile is its name plus the source window, ``None`` for a whole download +Tile = tuple[str, tuple[int, int, int, int] | None] + + +def zarr_source(url: str) -> str: + """Return the GDAL connection string that reads the Zarr array at *url*. + + The array path must be given to the driver as the tag suffix. Addressing the + array as ``ZARR:"/vsicurl/"`` reads the same raster but without any + CRS: the store keeps it in a separate ``spatial_ref`` variable, that GDAL + only reads through the store itself. + """ + store, _, array = url.rpartition("/") + source = f'ZARR:"/vsicurl/{store}":/{array}' + return source + + +class StoreGrid(TypedDict): + """The georeferencing and the chunk layout of a remote Zarr store. - The ``gdalinfo`` report of a Zarr store pins the grid its chunks tile from: - the store is north-up, so chunk ``(ix, iy)`` is the ``-srcwin`` window - ``(ix * block_xsize, iy * block_ysize, block_xsize, block_ysize)``. + The geometry of the stores we read is fixed data, like the tile names of the + other products: it does not need to be probed at run time. """ - env = {**os.environ, "GDAL_DISABLE_READDIR_ON_OPEN": "EMPTY_DIR"} - report = subprocess.check_output(["gdalinfo", "-json", source], env=env) - info = json.loads(report) - return info["geoTransform"], info["size"], info["bands"][0]["block"] + + # as reported by ``gdalinfo``, i.e. ``(x0, pixel_x, 0, y0, 0, pixel_y)`` + geotransform: tuple[float, float, float, float, float, float] + # the size of the array, as ``(xsize, ysize)`` + size: tuple[int, int] + # the shape of the chunks, as ``(xsize, ysize)``; it is NOT what ``gdalinfo`` + # reports as ``block``, that is a read buffer that shrinks with the cache + chunk_size: tuple[int, int] def zarr_chunks( - source: str, left: float, bottom: float, right: float, top: float -) -> Iterator[tuple[str, tuple[int, int, int, int]]]: + grid: StoreGrid, left: float, bottom: float, right: float, top: float +) -> Iterator[Tile]: """Yield the cache tile and the source window of the chunks covering the bounds. The tiles are named after the chunk indices of the store, so the cache folder shows which chunks have been read. The bounds are rounded outward to whole chunks, like ``gdal_translate -projwin`` rounds to whole pixels. """ - geotransform, size, block = zarr_grid(source) - x0, pixel_x, _, y0, _, pixel_y = geotransform - width, height = size - block_xsize, block_ysize = block + x0, pixel_x, _, y0, _, pixel_y = grid["geotransform"] + width, height = grid["size"] + chunk_xsize, chunk_ysize = grid["chunk_size"] # the store is north-up, so pixel_y is negative and the top row is the first xoff = max(math.floor((left - x0) / pixel_x), 0) xend = min(math.ceil((right - x0) / pixel_x), width) yoff = max(math.floor((y0 - top) / -pixel_y), 0) yend = min(math.ceil((y0 - bottom) / -pixel_y), height) - for ix in range(xoff // block_xsize, (xend - 1) // block_xsize + 1): - for iy in range(yoff // block_ysize, (yend - 1) // block_ysize + 1): + for ix in range(xoff // chunk_xsize, (xend - 1) // chunk_xsize + 1): + for iy in range(yoff // chunk_ysize, (yend - 1) // chunk_ysize + 1): window = ( - ix * block_xsize, - iy * block_ysize, - min(block_xsize, width - ix * block_xsize), - min(block_ysize, height - iy * block_ysize), + ix * chunk_xsize, + iy * chunk_ysize, + min(chunk_xsize, width - ix * chunk_xsize), + min(chunk_ysize, height - iy * chunk_ysize), ) yield f"{ix}_{iy}{CACHE_EXT}", window -# a cache tile is its name plus the source window, ``None`` for a whole download -Tile = tuple[str, tuple[int, int, int, int] | None] - - class DatasourceSpec(TypedDict): datasource_url: str - # a local product has one URL per tile (``tile_names``), a remote one has many - # chunks of a single source (``chunks``): the enumerator tells the two apart + # a local product has one URL per tile (``tile_names``), a remote one is a + # single chunked source (``grid``): the key tells the two apart tile_names: NotRequired[Callable[..., Iterator[str]]] - chunks: NotRequired[Callable[..., Iterator[Tile]]] + grid: NotRequired[StoreGrid] tile_ext: NotRequired[str] # only set when the provider serves the tile compressed compressed_ext: NotRequired[str] @@ -229,25 +243,57 @@ class DatasourceSpec(TypedDict): } # The Copernicus DEM products are distributed by the Earth Data Hub as a single -# north-up Zarr store that is read in place and cached one chunk at a time. The -# ``:/dsm`` array path is required, a bare store opens as a container of -# subdatasets, see https://gdal.org/en/stable/drivers/raster/zarr.html +# north-up Zarr v3 store that is read in place and cached one chunk at a time. +# The URL is the ``dsm`` array inside the store, see +# https://gdal.org/en/stable/drivers/raster/zarr.html +# +# The geometry of the store is recorded here instead of being probed at run +# time, like the tile names of the other products: ``geotransform`` and ``size`` +# are what ``gdalinfo`` reports and ``chunk_size`` is what the Zarr metadata +# declares. Both stores are the global grid at 1 and 3 arc seconds, with the +# origin half a pixel outside the ``[-180, 180] x [-90, 90]`` extent, and the +# integration tests pin the geometry against the reference datasets. +GLO_30_GRID: StoreGrid = { + "geotransform": ( + -180.0001388888889, + 0.0002777777777778, + 0.0, + 90.00013888888888, + 0.0, + -0.0002777777777778, + ), + "size": (1296000, 648000), + # 1 degree of longitude by 0.5 degrees of latitude + "chunk_size": (3600, 1800), +} + +GLO_90_GRID: StoreGrid = { + "geotransform": ( + -180.00041666666667, + 0.0008333333333333, + 0.0, + 90.00041666666667, + 0.0, + -0.0008333333333333, + ), + "size": (432000, 216000), + # 2 degrees by 2 degrees + "chunk_size": (2400, 2400), +} + GLO_30_SPEC: DatasourceSpec = { "datasource_url": ( - 'ZARR:"/vsicurl/https://data.earthdatahub.destine.eu/copernicus-dem' - '/GLO-30-v1.zarr":/dsm' + "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-30-v1.zarr/dsm" ), - "chunks": zarr_chunks, + "grid": GLO_30_GRID, "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, } -# the GLO-90 store has the same layout at 3 arc seconds GLO_90_SPEC: DatasourceSpec = { "datasource_url": ( - 'ZARR:"/vsicurl/https://data.earthdatahub.destine.eu/copernicus-dem' - '/GLO-90-v1.zarr":/dsm' + "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-90-v1.zarr/dsm" ), - "chunks": zarr_chunks, + "grid": GLO_90_GRID, "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, } @@ -383,7 +429,7 @@ def ensure_tiles(root: Path, spec: DatasourceSpec, tiles: Sequence[Tile]) -> Non spooled.unlink(missing_ok=True) else: write_cache_tile( - spec["datasource_url"], + zarr_source(spec["datasource_url"]), cached, srcwin=srcwin, gdal_options=gdal_options, @@ -451,12 +497,12 @@ def seed( if bounds is None: raise TypeError("bounds must be supplied") datasource_root, spec = ensure_setup(cache_dir, product) - chunks = spec.get("chunks") + grid = spec.get("grid") tiles: list[Tile] - if chunks is None: + if grid is None: tiles = [(tile_name, None) for tile_name in spec["tile_names"](*bounds)] else: - tiles = list(chunks(spec["datasource_url"], *bounds)) + tiles = list(zarr_chunks(grid, *bounds)) # FIXME: emergency hack to enforce the no-bulk-download policy if len(tiles) > max_download_tiles: raise RuntimeError( diff --git a/tests/test_20_datasource.py b/tests/test_20_datasource.py index cc5b006..ed3c709 100644 --- a/tests/test_20_datasource.py +++ b/tests/test_20_datasource.py @@ -54,23 +54,56 @@ def test_mapzen_tiles_names() -> None: assert list(datasource.mapzen_tiles_names(10, 44, 11, 45)) == ["N44/N44E010.tif"] -def test_zarr_chunks(mocker: MockerFixture) -> None: - mocker.patch( - "elevation.datasource.zarr_grid", - return_value=([0.0, 1.0, 0.0, 0.0, 0.0, -1.0], [100, 100], [4, 5]), +def test_zarr_source() -> None: + assert ( + datasource.zarr_source("https://example.org/store.zarr/dsm") + == 'ZARR:"/vsicurl/https://example.org/store.zarr":/dsm' + ) + + +def test_zarr_chunks() -> None: + grid = datasource.StoreGrid( + geotransform=(0.0, 1.0, 0.0, 0.0, 0.0, -1.0), + size=(100, 100), + chunk_size=(4, 5), ) - assert list(datasource.zarr_chunks("source", 1.0, -9.0, 3.0, -1.0)) == [ + assert list(datasource.zarr_chunks(grid, 1.0, -9.0, 3.0, -1.0)) == [ ("0_0.tif", (0, 0, 4, 5)), ("0_1.tif", (0, 5, 4, 5)), ] # the bounds are rounded outward to whole chunks - assert list(datasource.zarr_chunks("source", 3.5, -1.5, 4.5, -0.5)) == [ + assert list(datasource.zarr_chunks(grid, 3.5, -1.5, 4.5, -0.5)) == [ ("0_0.tif", (0, 0, 4, 5)), ("1_0.tif", (4, 0, 4, 5)), ] +def test_zarr_grids() -> None: + # the store geometry is hardcoded, so these values are the Rome regions of + # the integration tests: they pin both the geotransform and the chunk size + tiles = list( + datasource.zarr_chunks( + datasource.GLO_30_GRID, 12.4, 41.8, 12.4 + 100 / 3600, 41.8 + 100 / 3600 + ) + ) + assert tiles == [("192_96.tif", (691200, 172800, 3600, 1800))] + + tiles = list( + datasource.zarr_chunks( + datasource.GLO_90_GRID, 12.4, 41.8, 12.4 + 100 / 1200, 41.8 + 100 / 1200 + ) + ) + assert tiles == [("96_24.tif", (230400, 57600, 2400, 2400))] + + # the origin is half a pixel outside the extent, so bounds that end on a + # round degree reach one pixel into the next chunk, like -projwin rounding + tiles = list( + datasource.zarr_chunks(datasource.GLO_30_GRID, 12.9, 41.9, 13.0, 41.95) + ) + assert [name for name, _ in tiles] == ["192_96.tif", "193_96.tif"] + + def test_srtm3_tiles_names() -> None: assert next(datasource.srtm3_tiles_names(10.1, 44.9, 10.1, 44.9)).endswith( "srtm_39_04.tif" @@ -161,7 +194,7 @@ def test_ensure_tiles_remote(mocker: MockerFixture, tmp_path: Path) -> None: # a remote product is read in place: no download, one chunk per tile mock_fetch.assert_not_called() mock_write.assert_called_once_with( - datasource.GLO_90_SPEC["datasource_url"], + datasource.zarr_source(datasource.GLO_90_SPEC["datasource_url"]), tmp_path / "cache" / "192_48.tif", srcwin=(230400, 57600, 1200, 1200), gdal_options=datasource.FLOAT_TILE_GDAL_OPTIONS, @@ -321,23 +354,20 @@ def test_seed_remote(mocker: MockerFixture, tmp_path: Path) -> None: mock_check_call = mocker.patch("subprocess.check_call") mock_fetch = mocker.patch("elevation.datasource.fetch_tile") mock_write = mocker.patch("elevation.datasource.write_cache_tile") - mocker.patch( - "elevation.datasource.zarr_grid", - return_value=([0.0, 1.0, 0.0, 0.0, 0.0, -1.0], [100, 100], [4, 5]), - ) datasource_root = datasource.seed( - cache_dir=root, product="GLO-30", bounds=(1.0, -9.0, 3.0, -1.0) + cache_dir=root, + product="GLO-30", + bounds=(12.4, 41.8, 12.4 + 100 / 3600, 41.8 + 100 / 3600), ) assert datasource_root == root / "GLO-30" assert not (datasource_root / "spool").exists() mock_fetch.assert_not_called() - assert mock_write.call_count == 2 - mock_write.assert_any_call( - datasource.GLO_30_SPEC["datasource_url"], - datasource_root / "cache" / "0_0.tif", - srcwin=(0, 0, 4, 5), + mock_write.assert_called_once_with( + datasource.zarr_source(datasource.GLO_30_SPEC["datasource_url"]), + datasource_root / "cache" / "192_96.tif", + srcwin=(691200, 172800, 3600, 1800), gdal_options=datasource.FLOAT_TILE_GDAL_OPTIONS, ) assert mock_check_call.call_args[0][0][0] == "gdalbuildvrt" From d90b149ac7eba61e7bb78b18c98a9ddd3152729e Mon Sep 17 00:00:00 2001 From: Alessandro Amici Date: Sat, 3 Oct 2026 02:47:38 +0200 Subject: [PATCH 04/14] Try to harmonize: use transforms to compute chunks --- elevation/datasource.py | 53 ++++++++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/elevation/datasource.py b/elevation/datasource.py index 01ff742..6fd32be 100644 --- a/elevation/datasource.py +++ b/elevation/datasource.py @@ -68,13 +68,18 @@ def resolve_cache_dir(cache_dir: str | Path | None) -> Path: return Path(cache_dir).resolve() -def srtm1_tile_ilonlat(lon: float, lat: float) -> tuple[int, int]: - return math.floor(lon), math.floor(lat) +def latlon_to_indeces( + lon: float, lat: float, transform: tuple[float, float, float, float] +) -> tuple[int, int]: + lon_start, lon_step, lat_start, lat_step = transform + ilon = math.floor((lon - lon_start) / lon_step) + ilat = math.floor((lat - lat_start) / lat_step) + return ilon, ilat -def srtm3_tile_ilonlat(lon: float, lat: float) -> tuple[int, int]: - ilon, ilat = srtm1_tile_ilonlat(lon, lat) - return (ilon + 180) // 5 + 1, (64 - ilat) // 5 +def srtm1_tile_ilonlat(lon: float, lat: float) -> tuple[int, int]: + # NOTE: 0.5" is half a pixel + return latlon_to_indeces(lon, lat, (-0.0001388888889, 1.0, -0.0001388888889, 1.0)) def srtm1_tiles_names( @@ -98,6 +103,11 @@ def srtm1_tiles_names( yield tile_name_template.format(**locals()) +def srtm3_tile_ilonlat(lon: float, lat: float) -> tuple[int, int]: + # NOTE: 1.5" is half a pixel + return latlon_to_indeces(lon, lat, (-185.00041666666, 5.0, 65.00041666666, -5.0)) + + def srtm3_tiles_names( left: float, bottom: float, @@ -137,6 +147,26 @@ def srtm_ellip_tiles_names( yield f"{subdir}/{fname}" +def glo_30_tile_ilonlat(lon: float, lat: float) -> tuple[int, int]: + # NOTE: 0.5" is half a DEM pixel + transform = (180.0001388888889, 1.0, 90.00013888888888, -0.5) + return latlon_to_indeces(lon, lat, transform) + + +def glo_30_tile_names( + left: float, + bottom: float, + right: float, + top: float, +) -> Iterator[str]: + ileft, itop = glo_30_tile_ilonlat(left, top) + iright, ibottom = glo_30_tile_ilonlat(right, bottom) + for ilon in range(ileft, iright + 1): + for ilat in range(itop, ibottom + 1): + if ilon > 0 and ilat > 0: + yield f"{ilat}/{ilon}.tif" + + def mapzen_tiles_names( left: float, bottom: float, right: float, top: float ) -> Iterator[str]: @@ -208,7 +238,7 @@ class DatasourceSpec(TypedDict): datasource_url: str # a local product has one URL per tile (``tile_names``), a remote one is a # single chunked source (``grid``): the key tells the two apart - tile_names: NotRequired[Callable[..., Iterator[str]]] + cached_tile_names: NotRequired[Callable[..., Iterator[str]]] grid: NotRequired[StoreGrid] tile_ext: NotRequired[str] # only set when the provider serves the tile compressed @@ -220,26 +250,26 @@ class DatasourceSpec(TypedDict): "datasource_url": "https://s3.amazonaws.com/elevation-tiles-prod/skadi", "tile_ext": ".hgt", "compressed_ext": ".hgt.gz", - "tile_names": mapzen_tiles_names, + "cached_tile_names": mapzen_tiles_names, } SRTM1_GEOID_SPEC: DatasourceSpec = { "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1/SRTM_GL1_srtm", "tile_ext": ".tif", - "tile_names": srtm1_tiles_names, + "cached_tile_names": srtm1_tiles_names, } SRTM1_ELLIP_SPEC: DatasourceSpec = { "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1_Ellip/SRTM_GL1_Ellip_srtm", "tile_ext": ".tif", - "tile_names": srtm_ellip_tiles_names, + "cached_tile_names": srtm_ellip_tiles_names, } SRTM3_SPEC: DatasourceSpec = { "datasource_url": "https://srtm.csi.cgiar.org/wp-content/uploads/files/srtm_5x5/TIFF", "tile_ext": ".tif", "compressed_ext": ".zip", - "tile_names": srtm3_tiles_names, + "cached_tile_names": srtm3_tiles_names, } # The Copernicus DEM products are distributed by the Earth Data Hub as a single @@ -287,6 +317,7 @@ class DatasourceSpec(TypedDict): ), "grid": GLO_30_GRID, "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, + "cached_tile_names": glo_30_tile_names, } GLO_90_SPEC: DatasourceSpec = { @@ -500,7 +531,7 @@ def seed( grid = spec.get("grid") tiles: list[Tile] if grid is None: - tiles = [(tile_name, None) for tile_name in spec["tile_names"](*bounds)] + tiles = [(tile_name, None) for tile_name in spec["cached_tile_names"](*bounds)] else: tiles = list(zarr_chunks(grid, *bounds)) # FIXME: emergency hack to enforce the no-bulk-download policy From 5bf70d6aaeac9c02fdc06ec8adb382dfc41aaad1 Mon Sep 17 00:00:00 2001 From: Alessandro Amici Date: Sat, 3 Oct 2026 12:13:49 +0200 Subject: [PATCH 05/14] Large human only refactor to use the correct concept / names --- elevation/datasource.py | 51 +++++++++++++++---------------------- tests/test_20_datasource.py | 21 +++++++-------- 2 files changed, 32 insertions(+), 40 deletions(-) diff --git a/elevation/datasource.py b/elevation/datasource.py index 6fd32be..9077c0a 100644 --- a/elevation/datasource.py +++ b/elevation/datasource.py @@ -56,6 +56,13 @@ ) MARGIN = "0" +# NOTE: +# 0.0001388888889 == 0.5" is half pixel for DEMs with 1" spacing (DTED L2) +# 0.0004166666667 == 1.5" is half pixel for DEMs with 3" spacing (DTED L1) +EDH_L2_CHUNK_INDECES_TRANSFORM = (180.0001388888889, 1.0, 90.00013888888888, -0.5) +DTED_L2_TILE_INDECES_TRANSFORM = (-0.0001388888889, 1.0, -0.0001388888889, 1.0) +CGIAR_L1_TILE_INDECES_TRANSFORM = (-185.0004166666667, 5.0, 65.0004166666667, -5.0) + def resolve_cache_dir(cache_dir: str | Path | None) -> Path: """Return the DEM cache folder to use, as an absolute path. @@ -69,7 +76,7 @@ def resolve_cache_dir(cache_dir: str | Path | None) -> Path: def latlon_to_indeces( - lon: float, lat: float, transform: tuple[float, float, float, float] + transform: tuple[float, float, float, float], lon: float, lat: float ) -> tuple[int, int]: lon_start, lon_step, lat_start, lat_step = transform ilon = math.floor((lon - lon_start) / lon_step) @@ -77,20 +84,15 @@ def latlon_to_indeces( return ilon, ilat -def srtm1_tile_ilonlat(lon: float, lat: float) -> tuple[int, int]: - # NOTE: 0.5" is half a pixel - return latlon_to_indeces(lon, lat, (-0.0001388888889, 1.0, -0.0001388888889, 1.0)) - - -def srtm1_tiles_names( +def dted_l2_tiles_names( left: float, bottom: float, right: float, top: float, tile_name_template: str = "{slat}{slon}.tif", ) -> Iterator[str]: - ileft, itop = srtm1_tile_ilonlat(left, top) - iright, ibottom = srtm1_tile_ilonlat(right, bottom) + ileft, itop = latlon_to_indeces(DTED_L2_TILE_INDECES_TRANSFORM, left, top) + iright, ibottom = latlon_to_indeces(DTED_L2_TILE_INDECES_TRANSFORM, right, bottom) # special case often used *integer* top and right to avoid downloading unneeded tiles if isinstance(top, int) or top.is_integer(): itop -= 1 @@ -103,20 +105,15 @@ def srtm1_tiles_names( yield tile_name_template.format(**locals()) -def srtm3_tile_ilonlat(lon: float, lat: float) -> tuple[int, int]: - # NOTE: 1.5" is half a pixel - return latlon_to_indeces(lon, lat, (-185.00041666666, 5.0, 65.00041666666, -5.0)) - - -def srtm3_tiles_names( +def cgiar_l1_tiles_names( left: float, bottom: float, right: float, top: float, tile_template: str = "srtm_{ilon:02d}_{ilat:02d}.tif", ) -> Iterator[str]: - ileft, itop = srtm3_tile_ilonlat(left, top) - iright, ibottom = srtm3_tile_ilonlat(right, bottom) + ileft, itop = latlon_to_indeces(CGIAR_L1_TILE_INDECES_TRANSFORM, left, top) + iright, ibottom = latlon_to_indeces(CGIAR_L1_TILE_INDECES_TRANSFORM, right, bottom) for ilon in range(ileft, iright + 1): for ilat in range(itop, ibottom + 1): if ilon > 0 and ilat > 0: @@ -130,8 +127,8 @@ def srtm_ellip_tiles_names( top: float, tile_name_template: str = "{slat}{slon}_wgs84.tif", ) -> Iterator[str]: - ileft, itop = srtm1_tile_ilonlat(left, top) - iright, ibottom = srtm1_tile_ilonlat(right, bottom) + ileft, itop = latlon_to_indeces(DTED_L2_TILE_INDECES_TRANSFORM, left, top) + iright, ibottom = latlon_to_indeces(DTED_L2_TILE_INDECES_TRANSFORM, right, bottom) for ilon in range(ileft, iright + 1): slon = f"{'E' if ilon >= 0 else 'W'}{abs(ilon):03d}" @@ -147,20 +144,14 @@ def srtm_ellip_tiles_names( yield f"{subdir}/{fname}" -def glo_30_tile_ilonlat(lon: float, lat: float) -> tuple[int, int]: - # NOTE: 0.5" is half a DEM pixel - transform = (180.0001388888889, 1.0, 90.00013888888888, -0.5) - return latlon_to_indeces(lon, lat, transform) - - def glo_30_tile_names( left: float, bottom: float, right: float, top: float, ) -> Iterator[str]: - ileft, itop = glo_30_tile_ilonlat(left, top) - iright, ibottom = glo_30_tile_ilonlat(right, bottom) + ileft, itop = latlon_to_indeces(EDH_L2_CHUNK_INDECES_TRANSFORM, left, top) + iright, ibottom = latlon_to_indeces(EDH_L2_CHUNK_INDECES_TRANSFORM, right, bottom) for ilon in range(ileft, iright + 1): for ilat in range(itop, ibottom + 1): if ilon > 0 and ilat > 0: @@ -170,7 +161,7 @@ def glo_30_tile_names( def mapzen_tiles_names( left: float, bottom: float, right: float, top: float ) -> Iterator[str]: - yield from srtm1_tiles_names(left, bottom, right, top, "{slat}/{slat}{slon}.tif") + yield from dted_l2_tiles_names(left, bottom, right, top, "{slat}/{slat}{slon}.tif") # a cache tile is its name plus the source window, ``None`` for a whole download @@ -256,7 +247,7 @@ class DatasourceSpec(TypedDict): SRTM1_GEOID_SPEC: DatasourceSpec = { "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1/SRTM_GL1_srtm", "tile_ext": ".tif", - "cached_tile_names": srtm1_tiles_names, + "cached_tile_names": dted_l2_tiles_names, } SRTM1_ELLIP_SPEC: DatasourceSpec = { @@ -269,7 +260,7 @@ class DatasourceSpec(TypedDict): "datasource_url": "https://srtm.csi.cgiar.org/wp-content/uploads/files/srtm_5x5/TIFF", "tile_ext": ".tif", "compressed_ext": ".zip", - "cached_tile_names": srtm3_tiles_names, + "cached_tile_names": cgiar_l1_tiles_names, } # The Copernicus DEM products are distributed by the Earth Data Hub as a single diff --git a/tests/test_20_datasource.py b/tests/test_20_datasource.py index ed3c709..6907ac8 100644 --- a/tests/test_20_datasource.py +++ b/tests/test_20_datasource.py @@ -30,20 +30,21 @@ def gdalinfo_json(path: Path) -> dict[str, Any]: return info -def test_srtm3_tile_ilonlat() -> None: +def test_latlon_to_indeces_CGIAR_L1_TILE_INDECES_TRANSFORM() -> None: + transform = datasource.CGIAR_L1_TILE_INDECES_TRANSFORM # values from https://srtm.csi.cgiar.org/SELECTION/inputCoord.asp - assert datasource.srtm3_tile_ilonlat(-177.5, 52.5) == (1, 2) - assert datasource.srtm3_tile_ilonlat(177.5, -47.5) == (72, 22) - assert datasource.srtm3_tile_ilonlat(10.1, 44.9) == (39, 4) - assert datasource.srtm3_tile_ilonlat(14.9, 44.9) == (39, 4) - assert datasource.srtm3_tile_ilonlat(10.1, 40.1) == (39, 4) - assert datasource.srtm3_tile_ilonlat(14.9, 40.1) == (39, 4) + assert datasource.latlon_to_indeces(transform, -177.5, 52.5) == (1, 2) + assert datasource.latlon_to_indeces(transform, 177.5, -47.5) == (72, 22) + assert datasource.latlon_to_indeces(transform, 10.1, 44.9) == (39, 4) + assert datasource.latlon_to_indeces(transform, 14.9, 44.9) == (39, 4) + assert datasource.latlon_to_indeces(transform, 10.1, 40.1) == (39, 4) + assert datasource.latlon_to_indeces(transform, 14.9, 40.1) == (39, 4) -def test_srtm1_tiles_names() -> None: - assert list(datasource.srtm1_tiles_names(10.1, 44.9, 10.1, 44.9)) == ["N44E010.tif"] +def test_dted_tiles_names() -> None: + assert list(datasource.dted_tiles_names(10.1, 44.9, 10.1, 44.9)) == ["N44E010.tif"] # NOTE this also tests int (not float) input - assert list(datasource.srtm1_tiles_names(10, 44, 11, 45)) == ["N44E010.tif"] + assert list(datasource.dted_tiles_names(10, 44, 11, 45)) == ["N44E010.tif"] def test_mapzen_tiles_names() -> None: From 811e1f8b9c6b5e709c040d8a846132cdaafbd0c1 Mon Sep 17 00:00:00 2001 From: Alessandro Amici Date: Sat, 3 Oct 2026 12:26:38 +0200 Subject: [PATCH 06/14] Fix the EDH chunk indices transform and test every transform The longitude origin of EDH_L2_CHUNK_INDECES_TRANSFORM was positive, so every longitude gave a negative chunk index and glo_30_tile_names yielded no tiles at all. With the origin back half a pixel west of 180 degrees the indices are the chunk coordinates again, and the guard keeps the two chunks that start at 180 degrees west and at 90 degrees north. The DTED L2 and the EDH L2 transforms now have a test like the one of the CGIAR L1 transform, over the tiles of the SRTM1 products and the chunks of the Copernicus store: the 192_96 chunk of the Rome region and the two chunks at the -180 90 corner, that the live store reproduced. The two tile names tests are renamed after the functions they cover. --- elevation/datasource.py | 4 ++-- tests/test_20_datasource.py | 41 ++++++++++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/elevation/datasource.py b/elevation/datasource.py index 9077c0a..72afc5b 100644 --- a/elevation/datasource.py +++ b/elevation/datasource.py @@ -59,7 +59,7 @@ # NOTE: # 0.0001388888889 == 0.5" is half pixel for DEMs with 1" spacing (DTED L2) # 0.0004166666667 == 1.5" is half pixel for DEMs with 3" spacing (DTED L1) -EDH_L2_CHUNK_INDECES_TRANSFORM = (180.0001388888889, 1.0, 90.00013888888888, -0.5) +EDH_L2_CHUNK_INDECES_TRANSFORM = (-180.0001388888889, 1.0, 90.00013888888888, -0.5) DTED_L2_TILE_INDECES_TRANSFORM = (-0.0001388888889, 1.0, -0.0001388888889, 1.0) CGIAR_L1_TILE_INDECES_TRANSFORM = (-185.0004166666667, 5.0, 65.0004166666667, -5.0) @@ -154,7 +154,7 @@ def glo_30_tile_names( iright, ibottom = latlon_to_indeces(EDH_L2_CHUNK_INDECES_TRANSFORM, right, bottom) for ilon in range(ileft, iright + 1): for ilat in range(itop, ibottom + 1): - if ilon > 0 and ilat > 0: + if ilon >= 0 and ilat >= 0: yield f"{ilat}/{ilon}.tif" diff --git a/tests/test_20_datasource.py b/tests/test_20_datasource.py index 6907ac8..603b006 100644 --- a/tests/test_20_datasource.py +++ b/tests/test_20_datasource.py @@ -41,10 +41,37 @@ def test_latlon_to_indeces_CGIAR_L1_TILE_INDECES_TRANSFORM() -> None: assert datasource.latlon_to_indeces(transform, 14.9, 40.1) == (39, 4) -def test_dted_tiles_names() -> None: - assert list(datasource.dted_tiles_names(10.1, 44.9, 10.1, 44.9)) == ["N44E010.tif"] +def test_latlon_to_indeces_DTED_L2_TILE_INDECES_TRANSFORM() -> None: + transform = datasource.DTED_L2_TILE_INDECES_TRANSFORM + # the 1 degree tiles of the SRTM1 products, e.g. N44E010 covers 10E-11E + assert datasource.latlon_to_indeces(transform, 10.1, 44.9) == (10, 44) + assert datasource.latlon_to_indeces(transform, -73.99, 7.056) == (-74, 7) + assert datasource.latlon_to_indeces(transform, 15.931, -19.194) == (15, -20) + # a whole degree is a tile node, shared by the two tiles that meet there, + # and the half pixel makes it belong to the one that starts at the node + assert datasource.latlon_to_indeces(transform, 10.0, 44.0) == (10, 44) + + +def test_latlon_to_indeces_EDH_L2_CHUNK_INDECES_TRANSFORM() -> None: + transform = datasource.EDH_L2_CHUNK_INDECES_TRANSFORM + # the chunks of the Copernicus store are 1 by 0.5 degrees and 192_96 is + # the Rome region of the integration tests + assert datasource.latlon_to_indeces(transform, 12.4, 41.9) == (192, 96) + assert datasource.latlon_to_indeces(transform, 12.4, 41.4) == (192, 97) + # the chunks at the north west and the south east corner of the store + assert datasource.latlon_to_indeces(transform, -180.0, 90.0) == (0, 0) + assert datasource.latlon_to_indeces(transform, 179.9, -89.9) == (359, 359) + # the chunk boundaries are half a pixel outside the whole degrees, so a + # bound on a whole degree falls in the chunk that starts there + assert datasource.latlon_to_indeces(transform, 13.0, 41.5) == (193, 97) + + +def test_dted_l2_tiles_names() -> None: + assert list(datasource.dted_l2_tiles_names(10.1, 44.9, 10.1, 44.9)) == [ + "N44E010.tif" + ] # NOTE this also tests int (not float) input - assert list(datasource.dted_tiles_names(10, 44, 11, 45)) == ["N44E010.tif"] + assert list(datasource.dted_l2_tiles_names(10, 44, 11, 45)) == ["N44E010.tif"] def test_mapzen_tiles_names() -> None: @@ -105,14 +132,14 @@ def test_zarr_grids() -> None: assert [name for name, _ in tiles] == ["192_96.tif", "193_96.tif"] -def test_srtm3_tiles_names() -> None: - assert next(datasource.srtm3_tiles_names(10.1, 44.9, 10.1, 44.9)).endswith( +def test_cgiar_l1_tiles_names() -> None: + assert next(datasource.cgiar_l1_tiles_names(10.1, 44.9, 10.1, 44.9)).endswith( "srtm_39_04.tif" ) - assert next(datasource.srtm3_tiles_names(25.50, 58.40, 27.67, 60.06)).endswith( + assert next(datasource.cgiar_l1_tiles_names(25.50, 58.40, 27.67, 60.06)).endswith( "srtm_42_01.tif" ) - assert len(list(datasource.srtm3_tiles_names(9.9, 39.1, 15.1, 45.1))) == 9 + assert len(list(datasource.cgiar_l1_tiles_names(9.9, 39.1, 15.1, 45.1))) == 9 def test_srtm_ellip_tiles_names() -> None: From de77e4bec758d94a7136718c49851d56ca6a5792 Mon Sep 17 00:00:00 2001 From: Alessandro Amici Date: Sat, 3 Oct 2026 12:46:47 +0200 Subject: [PATCH 07/14] Take the tile names arguments from the spec The tile names functions now take the little that differs between two products of the same lattice as an argument, carried by the new cached_tile_names_kwargs spec key that seed forwards: MAPZEN is the DTED L2 lattice with the subfolder in the tile name template, and the two Copernicus stores are the same zarr_tile_names with their own chunk transform. glo_30_tile_names and mapzen_tiles_names are gone. The new EDH L1 transform, that the GLO-90 chunks use, steps 2 degrees, the chunk size of that store, not the 3 arc seconds of its spacing; it has its own test next to the L2 one, and the MAPZEN test goes through the spec like seed does. --- elevation/datasource.py | 34 +++++++++++++++++++++------------- tests/test_20_datasource.py | 25 +++++++++++++++++++++---- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/elevation/datasource.py b/elevation/datasource.py index 72afc5b..f2459e0 100644 --- a/elevation/datasource.py +++ b/elevation/datasource.py @@ -20,7 +20,7 @@ from collections.abc import Callable, Iterator, Sequence from importlib import resources from pathlib import Path -from typing import NotRequired, TypedDict +from typing import Any, NotRequired, TypedDict import appdirs @@ -60,6 +60,7 @@ # 0.0001388888889 == 0.5" is half pixel for DEMs with 1" spacing (DTED L2) # 0.0004166666667 == 1.5" is half pixel for DEMs with 3" spacing (DTED L1) EDH_L2_CHUNK_INDECES_TRANSFORM = (-180.0001388888889, 1.0, 90.00013888888888, -0.5) +EDH_L1_CHUNK_INDECES_TRANSFORM = (-180.00041666666667, 2.0, 90.00041666666667, -2.0) DTED_L2_TILE_INDECES_TRANSFORM = (-0.0001388888889, 1.0, -0.0001388888889, 1.0) CGIAR_L1_TILE_INDECES_TRANSFORM = (-185.0004166666667, 5.0, 65.0004166666667, -5.0) @@ -144,26 +145,21 @@ def srtm_ellip_tiles_names( yield f"{subdir}/{fname}" -def glo_30_tile_names( +def zarr_tile_names( left: float, bottom: float, right: float, top: float, + transform: tuple[float, float, float, float], ) -> Iterator[str]: - ileft, itop = latlon_to_indeces(EDH_L2_CHUNK_INDECES_TRANSFORM, left, top) - iright, ibottom = latlon_to_indeces(EDH_L2_CHUNK_INDECES_TRANSFORM, right, bottom) + ileft, itop = latlon_to_indeces(transform, left, top) + iright, ibottom = latlon_to_indeces(transform, right, bottom) for ilon in range(ileft, iright + 1): for ilat in range(itop, ibottom + 1): if ilon >= 0 and ilat >= 0: yield f"{ilat}/{ilon}.tif" -def mapzen_tiles_names( - left: float, bottom: float, right: float, top: float -) -> Iterator[str]: - yield from dted_l2_tiles_names(left, bottom, right, top, "{slat}/{slat}{slon}.tif") - - # a cache tile is its name plus the source window, ``None`` for a whole download Tile = tuple[str, tuple[int, int, int, int] | None] @@ -230,6 +226,9 @@ class DatasourceSpec(TypedDict): # a local product has one URL per tile (``tile_names``), a remote one is a # single chunked source (``grid``): the key tells the two apart cached_tile_names: NotRequired[Callable[..., Iterator[str]]] + # keyword arguments for ``cached_tile_names``, e.g. the tile name template + # of a product that keeps its tiles in subfolders + cached_tile_names_kwargs: NotRequired[dict[str, Any]] grid: NotRequired[StoreGrid] tile_ext: NotRequired[str] # only set when the provider serves the tile compressed @@ -241,7 +240,8 @@ class DatasourceSpec(TypedDict): "datasource_url": "https://s3.amazonaws.com/elevation-tiles-prod/skadi", "tile_ext": ".hgt", "compressed_ext": ".hgt.gz", - "cached_tile_names": mapzen_tiles_names, + "cached_tile_names": dted_l2_tiles_names, + "cached_tile_names_kwargs": {"tile_name_template": "{slat}/{slat}{slon}.tif"}, } SRTM1_GEOID_SPEC: DatasourceSpec = { @@ -308,7 +308,8 @@ class DatasourceSpec(TypedDict): ), "grid": GLO_30_GRID, "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, - "cached_tile_names": glo_30_tile_names, + "cached_tile_names": zarr_tile_names, + "cached_tile_names_kwargs": {"transform": EDH_L2_CHUNK_INDECES_TRANSFORM}, } GLO_90_SPEC: DatasourceSpec = { @@ -317,6 +318,8 @@ class DatasourceSpec(TypedDict): ), "grid": GLO_90_GRID, "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, + "cached_tile_names": zarr_tile_names, + "cached_tile_names_kwargs": {"transform": EDH_L1_CHUNK_INDECES_TRANSFORM}, } PRODUCTS_SPECS: dict[str, DatasourceSpec] = { @@ -522,7 +525,12 @@ def seed( grid = spec.get("grid") tiles: list[Tile] if grid is None: - tiles = [(tile_name, None) for tile_name in spec["cached_tile_names"](*bounds)] + cached_tile_names = spec["cached_tile_names"] + cached_tile_names_kwargs = spec.get("cached_tile_names_kwargs", {}) + tiles = [ + (tile_name, None) + for tile_name in cached_tile_names(*bounds, **cached_tile_names_kwargs) + ] else: tiles = list(zarr_chunks(grid, *bounds)) # FIXME: emergency hack to enforce the no-bulk-download policy diff --git a/tests/test_20_datasource.py b/tests/test_20_datasource.py index 603b006..9c327a3 100644 --- a/tests/test_20_datasource.py +++ b/tests/test_20_datasource.py @@ -66,6 +66,20 @@ def test_latlon_to_indeces_EDH_L2_CHUNK_INDECES_TRANSFORM() -> None: assert datasource.latlon_to_indeces(transform, 13.0, 41.5) == (193, 97) +def test_latlon_to_indeces_EDH_L1_CHUNK_INDECES_TRANSFORM() -> None: + transform = datasource.EDH_L1_CHUNK_INDECES_TRANSFORM + # the chunks of the Copernicus store are 2 by 2 degrees at 3 arc seconds, + # 96_24 is the Rome region of the integration tests + assert datasource.latlon_to_indeces(transform, 12.4, 41.9) == (96, 24) + assert datasource.latlon_to_indeces(transform, 15.0, 39.9) == (97, 25) + # the chunks at the north west and the south east corner of the store + assert datasource.latlon_to_indeces(transform, -180.0, 90.0) == (0, 0) + assert datasource.latlon_to_indeces(transform, 179.9, -89.9) == (179, 89) + # an even degree is a chunk boundary, half a pixel west of it, so a bound + # that lands on one, like 14.0, reaches the chunk that starts there + assert datasource.latlon_to_indeces(transform, 14.0, 41.9) == (97, 24) + + def test_dted_l2_tiles_names() -> None: assert list(datasource.dted_l2_tiles_names(10.1, 44.9, 10.1, 44.9)) == [ "N44E010.tif" @@ -75,11 +89,14 @@ def test_dted_l2_tiles_names() -> None: def test_mapzen_tiles_names() -> None: - assert list(datasource.mapzen_tiles_names(10.1, 44.9, 10.1, 44.9)) == [ - "N44/N44E010.tif" - ] + # MAPZEN is the DTED L2 lattice with a subfolder in the tile name + spec = datasource.MAPZEN_SPEC + tile_names = spec["cached_tile_names"] + kwargs = spec["cached_tile_names_kwargs"] + + assert list(tile_names(10.1, 44.9, 10.1, 44.9, **kwargs)) == ["N44/N44E010.tif"] # NOTE this also tests int (not float) input - assert list(datasource.mapzen_tiles_names(10, 44, 11, 45)) == ["N44/N44E010.tif"] + assert list(tile_names(10, 44, 11, 45, **kwargs)) == ["N44/N44E010.tif"] def test_zarr_source() -> None: From 7aead1ac156379b75f59b4365d2e1606427fed72 Mon Sep 17 00:00:00 2001 From: Alessandro Amici Date: Sat, 3 Oct 2026 13:09:58 +0200 Subject: [PATCH 08/14] Apply the whole degree special case to the SRTM1 ellipsoidal tiles The SRTM_GL1_Ellip tiles are the same 3601x3601 grid as the geoid ones: they span half a pixel past each whole degree, so the edge row and column are shared with the neighbour and a top or right bound on a whole degree does not reach the tile that starts there. Both products use the same DTED L2 tile transform, so the ellipsoidal one needs the same special case, that the test now pins with a bound at 12 degrees east and 46 degrees north. --- elevation/datasource.py | 5 +++++ tests/test_20_datasource.py | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/elevation/datasource.py b/elevation/datasource.py index f2459e0..cb45443 100644 --- a/elevation/datasource.py +++ b/elevation/datasource.py @@ -130,6 +130,11 @@ def srtm_ellip_tiles_names( ) -> Iterator[str]: ileft, itop = latlon_to_indeces(DTED_L2_TILE_INDECES_TRANSFORM, left, top) iright, ibottom = latlon_to_indeces(DTED_L2_TILE_INDECES_TRANSFORM, right, bottom) + # special case often used *integer* top and right to avoid downloading unneeded tiles + if isinstance(top, int) or top.is_integer(): + itop -= 1 + if isinstance(right, int) or right.is_integer(): + iright -= 1 for ilon in range(ileft, iright + 1): slon = f"{'E' if ilon >= 0 else 'W'}{abs(ilon):03d}" diff --git a/tests/test_20_datasource.py b/tests/test_20_datasource.py index 9c327a3..a16bbfd 100644 --- a/tests/test_20_datasource.py +++ b/tests/test_20_datasource.py @@ -168,6 +168,14 @@ def test_srtm_ellip_tiles_names() -> None: assert ( list(datasource.srtm_ellip_tiles_names(15.931, -19.194, 15.329, -19.961)) == ds3 ) + # the tiles share their edge row and column, so a bound on a whole degree + # does not reach the tiles that start there + assert list(datasource.srtm_ellip_tiles_names(10.1, 44.1, 12.0, 46.0)) == [ + "North/North_30_60/N44E010_wgs84.tif", + "North/North_30_60/N45E010_wgs84.tif", + "North/North_30_60/N44E011_wgs84.tif", + "North/North_30_60/N45E011_wgs84.tif", + ] def test_tile_source() -> None: From 629f82b054d6ece48bb05e955e241594154a2c3f Mon Sep 17 00:00:00 2001 From: Alessandro Amici Date: Sat, 3 Oct 2026 17:46:07 +0200 Subject: [PATCH 09/14] Mid-way of one more big human refactor, all old dataset work, GLO-* are broken --- elevation/datasource.py | 154 +++++++++--------------------------- tests/test_20_datasource.py | 90 ++++++++------------- 2 files changed, 72 insertions(+), 172 deletions(-) diff --git a/elevation/datasource.py b/elevation/datasource.py index cb45443..049dd44 100644 --- a/elevation/datasource.py +++ b/elevation/datasource.py @@ -135,7 +135,6 @@ def srtm_ellip_tiles_names( itop -= 1 if isinstance(right, int) or right.is_integer(): iright -= 1 - for ilon in range(ileft, iright + 1): slon = f"{'E' if ilon >= 0 else 'W'}{abs(ilon):03d}" for ilat in range(ibottom, itop + 1): @@ -182,50 +181,6 @@ def zarr_source(url: str) -> str: return source -class StoreGrid(TypedDict): - """The georeferencing and the chunk layout of a remote Zarr store. - - The geometry of the stores we read is fixed data, like the tile names of the - other products: it does not need to be probed at run time. - """ - - # as reported by ``gdalinfo``, i.e. ``(x0, pixel_x, 0, y0, 0, pixel_y)`` - geotransform: tuple[float, float, float, float, float, float] - # the size of the array, as ``(xsize, ysize)`` - size: tuple[int, int] - # the shape of the chunks, as ``(xsize, ysize)``; it is NOT what ``gdalinfo`` - # reports as ``block``, that is a read buffer that shrinks with the cache - chunk_size: tuple[int, int] - - -def zarr_chunks( - grid: StoreGrid, left: float, bottom: float, right: float, top: float -) -> Iterator[Tile]: - """Yield the cache tile and the source window of the chunks covering the bounds. - - The tiles are named after the chunk indices of the store, so the cache - folder shows which chunks have been read. The bounds are rounded outward to - whole chunks, like ``gdal_translate -projwin`` rounds to whole pixels. - """ - x0, pixel_x, _, y0, _, pixel_y = grid["geotransform"] - width, height = grid["size"] - chunk_xsize, chunk_ysize = grid["chunk_size"] - # the store is north-up, so pixel_y is negative and the top row is the first - xoff = max(math.floor((left - x0) / pixel_x), 0) - xend = min(math.ceil((right - x0) / pixel_x), width) - yoff = max(math.floor((y0 - top) / -pixel_y), 0) - yend = min(math.ceil((y0 - bottom) / -pixel_y), height) - for ix in range(xoff // chunk_xsize, (xend - 1) // chunk_xsize + 1): - for iy in range(yoff // chunk_ysize, (yend - 1) // chunk_ysize + 1): - window = ( - ix * chunk_xsize, - iy * chunk_ysize, - min(chunk_xsize, width - ix * chunk_xsize), - min(chunk_ysize, height - iy * chunk_ysize), - ) - yield f"{ix}_{iy}{CACHE_EXT}", window - - class DatasourceSpec(TypedDict): datasource_url: str # a local product has one URL per tile (``tile_names``), a remote one is a @@ -234,84 +189,42 @@ class DatasourceSpec(TypedDict): # keyword arguments for ``cached_tile_names``, e.g. the tile name template # of a product that keeps its tiles in subfolders cached_tile_names_kwargs: NotRequired[dict[str, Any]] - grid: NotRequired[StoreGrid] - tile_ext: NotRequired[str] - # only set when the provider serves the tile compressed - compressed_ext: NotRequired[str] + # keyword arguments for ``tile_source``, e.g. the source extension and the + # archive the provider serves it in + tile_source_kwargs: NotRequired[dict[str, Any]] tile_gdal_options: NotRequired[str] MAPZEN_SPEC: DatasourceSpec = { "datasource_url": "https://s3.amazonaws.com/elevation-tiles-prod/skadi", - "tile_ext": ".hgt", - "compressed_ext": ".hgt.gz", + "tile_source_kwargs": { + "tile_ext": ".hgt", + "compressed_ext": ".hgt.gz", + }, "cached_tile_names": dted_l2_tiles_names, "cached_tile_names_kwargs": {"tile_name_template": "{slat}/{slat}{slon}.tif"}, } SRTM1_GEOID_SPEC: DatasourceSpec = { "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1/SRTM_GL1_srtm", - "tile_ext": ".tif", "cached_tile_names": dted_l2_tiles_names, } SRTM1_ELLIP_SPEC: DatasourceSpec = { "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1_Ellip/SRTM_GL1_Ellip_srtm", - "tile_ext": ".tif", "cached_tile_names": srtm_ellip_tiles_names, } SRTM3_SPEC: DatasourceSpec = { "datasource_url": "https://srtm.csi.cgiar.org/wp-content/uploads/files/srtm_5x5/TIFF", - "tile_ext": ".tif", - "compressed_ext": ".zip", + "tile_source_kwargs": {"compressed_ext": ".zip"}, "cached_tile_names": cgiar_l1_tiles_names, } -# The Copernicus DEM products are distributed by the Earth Data Hub as a single -# north-up Zarr v3 store that is read in place and cached one chunk at a time. -# The URL is the ``dsm`` array inside the store, see -# https://gdal.org/en/stable/drivers/raster/zarr.html -# -# The geometry of the store is recorded here instead of being probed at run -# time, like the tile names of the other products: ``geotransform`` and ``size`` -# are what ``gdalinfo`` reports and ``chunk_size`` is what the Zarr metadata -# declares. Both stores are the global grid at 1 and 3 arc seconds, with the -# origin half a pixel outside the ``[-180, 180] x [-90, 90]`` extent, and the -# integration tests pin the geometry against the reference datasets. -GLO_30_GRID: StoreGrid = { - "geotransform": ( - -180.0001388888889, - 0.0002777777777778, - 0.0, - 90.00013888888888, - 0.0, - -0.0002777777777778, - ), - "size": (1296000, 648000), - # 1 degree of longitude by 0.5 degrees of latitude - "chunk_size": (3600, 1800), -} - -GLO_90_GRID: StoreGrid = { - "geotransform": ( - -180.00041666666667, - 0.0008333333333333, - 0.0, - 90.00041666666667, - 0.0, - -0.0008333333333333, - ), - "size": (432000, 216000), - # 2 degrees by 2 degrees - "chunk_size": (2400, 2400), -} - GLO_30_SPEC: DatasourceSpec = { "datasource_url": ( "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-30-v1.zarr/dsm" ), - "grid": GLO_30_GRID, "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, "cached_tile_names": zarr_tile_names, "cached_tile_names_kwargs": {"transform": EDH_L2_CHUNK_INDECES_TRANSFORM}, @@ -321,7 +234,6 @@ class DatasourceSpec(TypedDict): "datasource_url": ( "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-90-v1.zarr/dsm" ), - "grid": GLO_90_GRID, "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, "cached_tile_names": zarr_tile_names, "cached_tile_names_kwargs": {"transform": EDH_L1_CHUNK_INDECES_TRANSFORM}, @@ -361,7 +273,12 @@ def __str__(self) -> str: } -def tile_source(spec: DatasourceSpec, tile_name: str) -> tuple[str, str, str | None]: +def tile_source( + datasource_url: str, + tile_name: str, + tile_ext: str = ".tif", + compressed_ext: str | None = None, +) -> tuple[str, str, str | None]: """Return the ``(url, spool_name, member)`` of the tile for *tile_name*. *tile_name* is the cache tile name, always a ``.tif``; the spool name is the @@ -370,11 +287,10 @@ def tile_source(spec: DatasourceSpec, tile_name: str) -> tuple[str, str, str | N to read inside a ``.zip`` archive and ``None`` otherwise. """ stem = tile_name.removesuffix(CACHE_EXT) - spool_name = f"{stem}{spec['tile_ext']}" - compressed_ext = spec.get("compressed_ext") + spool_name = f"{stem}{tile_ext}" remote = spool_name if compressed_ext is None else f"{stem}{compressed_ext}" member = Path(spool_name).name if compressed_ext == ".zip" else None - return f"{spec['datasource_url']}/{remote}", spool_name, member + return f"{datasource_url}/{remote}", spool_name, member def fetch_tile(source: str, destination: Path, *, member: str | None = None) -> None: @@ -439,27 +355,34 @@ def write_cache_tile( return cmd -def ensure_tiles(root: Path, spec: DatasourceSpec, tiles: Sequence[Tile]) -> None: +def ensure_tiles( + root: Path, + tiles: Sequence[Tile], + datasource_url: str, + gdal_options: str = TILE_GDAL_OPTIONS, + **kwargs: Any, +) -> None: """Fetch and cache *tiles*, skipping the tiles already in the cache. A tile is a ``(name, window)`` pair: a tile with a window is read in place from ``datasource_url``, a tile without one is downloaded whole from its own URL and goes through the spool. """ - gdal_options = spec.get("tile_gdal_options", TILE_GDAL_OPTIONS) for tile_name, srcwin in tiles: cached = root / "cache" / tile_name if cached.exists() and cached.stat().st_size > 0: continue if srcwin is None: - source, spool_name, member = tile_source(spec, tile_name) + source, spool_name, member = tile_source( + datasource_url, tile_name, **kwargs + ) spooled = root / "spool" / spool_name fetch_tile(source, spooled, member=member) - write_cache_tile(spooled, cached) + write_cache_tile(spooled, cached, gdal_options=gdal_options) spooled.unlink(missing_ok=True) else: write_cache_tile( - zarr_source(spec["datasource_url"]), + zarr_source(datasource_url), cached, srcwin=srcwin, gdal_options=gdal_options, @@ -527,17 +450,10 @@ def seed( if bounds is None: raise TypeError("bounds must be supplied") datasource_root, spec = ensure_setup(cache_dir, product) - grid = spec.get("grid") tiles: list[Tile] - if grid is None: - cached_tile_names = spec["cached_tile_names"] - cached_tile_names_kwargs = spec.get("cached_tile_names_kwargs", {}) - tiles = [ - (tile_name, None) - for tile_name in cached_tile_names(*bounds, **cached_tile_names_kwargs) - ] - else: - tiles = list(zarr_chunks(grid, *bounds)) + tile_names = spec["cached_tile_names"] + kwargs = spec.get("cached_tile_names_kwargs", {}) + tiles = [(tile_name, None) for tile_name in tile_names(*bounds, **kwargs)] # FIXME: emergency hack to enforce the no-bulk-download policy if len(tiles) > max_download_tiles: raise RuntimeError( @@ -546,7 +462,13 @@ def seed( ) with util.lock_tiles(datasource_root, [tile_name for tile_name, _ in tiles]): - ensure_tiles(datasource_root, spec, tiles) + ensure_tiles( + datasource_root, + tiles, + datasource_url=spec["datasource_url"], + gdal_options=spec.get("tile_gdal_options", TILE_GDAL_OPTIONS), + **spec.get("tile_source_kwargs", {}), + ) with util.lock_vrt(datasource_root, product): build_vrt(datasource_root, product) diff --git a/tests/test_20_datasource.py b/tests/test_20_datasource.py index a16bbfd..a5acd85 100644 --- a/tests/test_20_datasource.py +++ b/tests/test_20_datasource.py @@ -106,49 +106,6 @@ def test_zarr_source() -> None: ) -def test_zarr_chunks() -> None: - grid = datasource.StoreGrid( - geotransform=(0.0, 1.0, 0.0, 0.0, 0.0, -1.0), - size=(100, 100), - chunk_size=(4, 5), - ) - - assert list(datasource.zarr_chunks(grid, 1.0, -9.0, 3.0, -1.0)) == [ - ("0_0.tif", (0, 0, 4, 5)), - ("0_1.tif", (0, 5, 4, 5)), - ] - # the bounds are rounded outward to whole chunks - assert list(datasource.zarr_chunks(grid, 3.5, -1.5, 4.5, -0.5)) == [ - ("0_0.tif", (0, 0, 4, 5)), - ("1_0.tif", (4, 0, 4, 5)), - ] - - -def test_zarr_grids() -> None: - # the store geometry is hardcoded, so these values are the Rome regions of - # the integration tests: they pin both the geotransform and the chunk size - tiles = list( - datasource.zarr_chunks( - datasource.GLO_30_GRID, 12.4, 41.8, 12.4 + 100 / 3600, 41.8 + 100 / 3600 - ) - ) - assert tiles == [("192_96.tif", (691200, 172800, 3600, 1800))] - - tiles = list( - datasource.zarr_chunks( - datasource.GLO_90_GRID, 12.4, 41.8, 12.4 + 100 / 1200, 41.8 + 100 / 1200 - ) - ) - assert tiles == [("96_24.tif", (230400, 57600, 2400, 2400))] - - # the origin is half a pixel outside the extent, so bounds that end on a - # round degree reach one pixel into the next chunk, like -projwin rounding - tiles = list( - datasource.zarr_chunks(datasource.GLO_30_GRID, 12.9, 41.9, 13.0, 41.95) - ) - assert [name for name, _ in tiles] == ["192_96.tif", "193_96.tif"] - - def test_cgiar_l1_tiles_names() -> None: assert next(datasource.cgiar_l1_tiles_names(10.1, 44.9, 10.1, 44.9)).endswith( "srtm_39_04.tif" @@ -179,25 +136,31 @@ def test_srtm_ellip_tiles_names() -> None: def test_tile_source() -> None: - # ``compressed_ext`` is only set when the source is compressed - assert "compressed_ext" not in datasource.SRTM1_GEOID_SPEC + # a product that serves plain tiles uses the defaults + assert "tile_source_kwargs" not in datasource.SRTM1_GEOID_SPEC + spec = datasource.MAPZEN_SPEC url, spooled, member = datasource.tile_source( - datasource.MAPZEN_SPEC, "N41/N41E012.tif" + spec["datasource_url"], "N41/N41E012.tif", **spec["tile_source_kwargs"] ) assert url.endswith("/skadi/N41/N41E012.hgt.gz") assert spooled == "N41/N41E012.hgt" assert member is None + # the SRTM3 tiles are served inside a .zip, that the spec has to declare or + # seed asks for a plain .tif that the provider does not have + spec = datasource.SRTM3_SPEC + assert spec.get("tile_source_kwargs") == {"compressed_ext": ".zip"} url, spooled, member = datasource.tile_source( - datasource.SRTM3_SPEC, "srtm_39_04.tif" + spec["datasource_url"], "srtm_39_04.tif", **spec["tile_source_kwargs"] ) assert url.endswith("/srtm_39_04.zip") assert spooled == "srtm_39_04.tif" assert member == "srtm_39_04.tif" + spec = datasource.SRTM1_ELLIP_SPEC url, spooled, member = datasource.tile_source( - datasource.SRTM1_ELLIP_SPEC, "North/North_30_60/N44E010_wgs84.tif" + spec["datasource_url"], "North/North_30_60/N44E010_wgs84.tif" ) assert url.endswith("/North/North_30_60/N44E010_wgs84.tif") assert spooled == "North/North_30_60/N44E010_wgs84.tif" @@ -209,7 +172,9 @@ def test_ensure_tiles(mocker: MockerFixture, tmp_path: Path) -> None: mock_write = mocker.patch("elevation.datasource.write_cache_tile") datasource.ensure_tiles( - tmp_path, datasource.SRTM1_GEOID_SPEC, [("N41E012.tif", None)] + tmp_path, + [("N41E012.tif", None)], + datasource.SRTM1_GEOID_SPEC["datasource_url"], ) mock_fetch.assert_called_once_with( @@ -218,7 +183,9 @@ def test_ensure_tiles(mocker: MockerFixture, tmp_path: Path) -> None: member=None, ) mock_write.assert_called_once_with( - tmp_path / "spool" / "N41E012.tif", tmp_path / "cache" / "N41E012.tif" + tmp_path / "spool" / "N41E012.tif", + tmp_path / "cache" / "N41E012.tif", + gdal_options=datasource.TILE_GDAL_OPTIONS, ) @@ -230,7 +197,9 @@ def test_ensure_tiles_skips_cached(mocker: MockerFixture, tmp_path: Path) -> Non mock_write = mocker.patch("elevation.datasource.write_cache_tile") datasource.ensure_tiles( - tmp_path, datasource.SRTM1_GEOID_SPEC, [("N41E012.tif", None)] + tmp_path, + [("N41E012.tif", None)], + datasource.SRTM1_GEOID_SPEC["datasource_url"], ) mock_fetch.assert_not_called() @@ -242,7 +211,12 @@ def test_ensure_tiles_remote(mocker: MockerFixture, tmp_path: Path) -> None: mock_write = mocker.patch("elevation.datasource.write_cache_tile") tile = ("192_48.tif", (230400, 57600, 1200, 1200)) - datasource.ensure_tiles(tmp_path, datasource.GLO_90_SPEC, [tile]) + datasource.ensure_tiles( + tmp_path, + [tile], + datasource.GLO_90_SPEC["datasource_url"], + gdal_options=datasource.FLOAT_TILE_GDAL_OPTIONS, + ) # a remote product is read in place: no download, one chunk per tile mock_fetch.assert_not_called() @@ -392,6 +366,7 @@ def test_seed(mocker: MockerFixture, tmp_path: Path) -> None: mock_write.assert_called_once_with( datasource_root / "spool" / "N43E013.tif", datasource_root / "cache" / "N43E013.tif", + gdal_options=datasource.TILE_GDAL_OPTIONS, ) assert mock_check_call.call_args[0][0][0] == "gdalbuildvrt" @@ -416,11 +391,14 @@ def test_seed_remote(mocker: MockerFixture, tmp_path: Path) -> None: assert datasource_root == root / "GLO-30" assert not (datasource_root / "spool").exists() - mock_fetch.assert_not_called() + mock_fetch.assert_called_once_with( + f"{datasource.GLO_30_SPEC['datasource_url']}/96/192.tif", + datasource_root / "spool" / "96/192.tif", + member=None, + ) mock_write.assert_called_once_with( - datasource.zarr_source(datasource.GLO_30_SPEC["datasource_url"]), - datasource_root / "cache" / "192_96.tif", - srcwin=(691200, 172800, 3600, 1800), + datasource_root / "spool" / "96/192.tif", + datasource_root / "cache" / "96/192.tif", gdal_options=datasource.FLOAT_TILE_GDAL_OPTIONS, ) assert mock_check_call.call_args[0][0][0] == "gdalbuildvrt" From 261237e5584435cdbfb98473139692c7e63e9759 Mon Sep 17 00:00:00 2001 From: Alessandro Amici Date: Sat, 3 Oct 2026 18:02:59 +0200 Subject: [PATCH 10/14] Still in the middle of a human refactor, Zarr dataset still not working --- elevation/datasource.py | 50 +++++++++++++------------------------ tests/test_20_datasource.py | 6 ++--- 2 files changed, 20 insertions(+), 36 deletions(-) diff --git a/elevation/datasource.py b/elevation/datasource.py index 049dd44..bd63740 100644 --- a/elevation/datasource.py +++ b/elevation/datasource.py @@ -48,12 +48,9 @@ DEFAULT_OUTPUT = "out.tif" DEFAULT_GDAL_OPTIONS = "-co TILED=YES -co COMPRESS=DEFLATE -co ZLEVEL=9 -co PREDICTOR=2" CACHE_EXT = ".tif" -# the VRT mosaic reads the cache tiles whole, the options only trade size for speed -TILE_GDAL_OPTIONS = "-co TILED=YES -co COMPRESS=DEFLATE -co ZLEVEL=9 -co PREDICTOR=2" -# the float products, e.g. the Copernicus DEM stores, need the float predictor -FLOAT_TILE_GDAL_OPTIONS = ( - "-co TILED=YES -co COMPRESS=DEFLATE -co ZLEVEL=9 -co PREDICTOR=3" -) +TILE_GDAL_OPTIONS = "-co TILED=YES -co COMPRESS=DEFLATE -co ZLEVEL=9" +INT_TILE_GDAL_OPTIONS = TILE_GDAL_OPTIONS + " -co PREDICTOR=2" +FLOAT_TILE_GDAL_OPTIONS = TILE_GDAL_OPTIONS + " -co PREDICTOR=3" MARGIN = "0" # NOTE: @@ -222,18 +219,14 @@ class DatasourceSpec(TypedDict): } GLO_30_SPEC: DatasourceSpec = { - "datasource_url": ( - "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-30-v1.zarr/dsm" - ), + "datasource_url": "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-30-v1.zarr/dsm", "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, "cached_tile_names": zarr_tile_names, "cached_tile_names_kwargs": {"transform": EDH_L2_CHUNK_INDECES_TRANSFORM}, } GLO_90_SPEC: DatasourceSpec = { - "datasource_url": ( - "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-90-v1.zarr/dsm" - ), + "datasource_url": "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-90-v1.zarr/dsm", "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, "cached_tile_names": zarr_tile_names, "cached_tile_names_kwargs": {"transform": EDH_L1_CHUNK_INDECES_TRANSFORM}, @@ -357,7 +350,7 @@ def write_cache_tile( def ensure_tiles( root: Path, - tiles: Sequence[Tile], + tiles: Sequence[str], datasource_url: str, gdal_options: str = TILE_GDAL_OPTIONS, **kwargs: Any, @@ -368,25 +361,15 @@ def ensure_tiles( from ``datasource_url``, a tile without one is downloaded whole from its own URL and goes through the spool. """ - for tile_name, srcwin in tiles: + for tile_name in tiles: cached = root / "cache" / tile_name if cached.exists() and cached.stat().st_size > 0: continue - if srcwin is None: - source, spool_name, member = tile_source( - datasource_url, tile_name, **kwargs - ) - spooled = root / "spool" / spool_name - fetch_tile(source, spooled, member=member) - write_cache_tile(spooled, cached, gdal_options=gdal_options) - spooled.unlink(missing_ok=True) - else: - write_cache_tile( - zarr_source(datasource_url), - cached, - srcwin=srcwin, - gdal_options=gdal_options, - ) + source, spool_name, member = tile_source(datasource_url, tile_name, **kwargs) + spooled = root / "spool" / spool_name + fetch_tile(source, spooled, member=member) + write_cache_tile(spooled, cached, gdal_options=gdal_options) + spooled.unlink(missing_ok=True) def build_vrt(root: Path, product: str) -> list[str]: @@ -450,10 +433,9 @@ def seed( if bounds is None: raise TypeError("bounds must be supplied") datasource_root, spec = ensure_setup(cache_dir, product) - tiles: list[Tile] tile_names = spec["cached_tile_names"] kwargs = spec.get("cached_tile_names_kwargs", {}) - tiles = [(tile_name, None) for tile_name in tile_names(*bounds, **kwargs)] + tiles = list(tile_names(*bounds, **kwargs)) # FIXME: emergency hack to enforce the no-bulk-download policy if len(tiles) > max_download_tiles: raise RuntimeError( @@ -461,7 +443,7 @@ def seed( "providers' websites for how to bulk download tiles." ) - with util.lock_tiles(datasource_root, [tile_name for tile_name, _ in tiles]): + with util.lock_tiles(datasource_root, tiles): ensure_tiles( datasource_root, tiles, @@ -472,6 +454,7 @@ def seed( with util.lock_vrt(datasource_root, product): build_vrt(datasource_root, product) + return datasource_root @@ -485,12 +468,13 @@ def build_bounds( margin_lat = (top - bottom) * margin_percent / 100 else: margin_lon = margin_lat = float(margin) - return ( + bounds = ( left - margin_lon, bottom - margin_lat, right + margin_lon, top + margin_lat, ) + return bounds def clip( diff --git a/tests/test_20_datasource.py b/tests/test_20_datasource.py index a5acd85..accbe47 100644 --- a/tests/test_20_datasource.py +++ b/tests/test_20_datasource.py @@ -173,7 +173,7 @@ def test_ensure_tiles(mocker: MockerFixture, tmp_path: Path) -> None: datasource.ensure_tiles( tmp_path, - [("N41E012.tif", None)], + ["N41E012.tif"], datasource.SRTM1_GEOID_SPEC["datasource_url"], ) @@ -198,7 +198,7 @@ def test_ensure_tiles_skips_cached(mocker: MockerFixture, tmp_path: Path) -> Non datasource.ensure_tiles( tmp_path, - [("N41E012.tif", None)], + ["N41E012.tif"], datasource.SRTM1_GEOID_SPEC["datasource_url"], ) @@ -209,7 +209,7 @@ def test_ensure_tiles_skips_cached(mocker: MockerFixture, tmp_path: Path) -> Non def test_ensure_tiles_remote(mocker: MockerFixture, tmp_path: Path) -> None: mock_fetch = mocker.patch("elevation.datasource.fetch_tile") mock_write = mocker.patch("elevation.datasource.write_cache_tile") - tile = ("192_48.tif", (230400, 57600, 1200, 1200)) + tile = "192/48.tif" datasource.ensure_tiles( tmp_path, From 65d1665d2922c1c6de474df8c67cbf0275df6362 Mon Sep 17 00:00:00 2001 From: Alessandro Amici Date: Sun, 4 Oct 2026 16:26:32 +0200 Subject: [PATCH 11/14] Motly a changi in API and name of *_tile_names --- elevation/datasource.py | 69 +++++++++++++++++----------------- tests/test_20_datasource.py | 74 ++++++++++++++++++++----------------- 2 files changed, 76 insertions(+), 67 deletions(-) diff --git a/elevation/datasource.py b/elevation/datasource.py index bd63740..2937b89 100644 --- a/elevation/datasource.py +++ b/elevation/datasource.py @@ -82,13 +82,16 @@ def latlon_to_indeces( return ilon, ilat -def dted_l2_tiles_names( +Tile = tuple[tuple[int, int], str] + + +def dted_l2_tiles( left: float, bottom: float, right: float, top: float, tile_name_template: str = "{slat}{slon}.tif", -) -> Iterator[str]: +) -> Iterator[Tile]: ileft, itop = latlon_to_indeces(DTED_L2_TILE_INDECES_TRANSFORM, left, top) iright, ibottom = latlon_to_indeces(DTED_L2_TILE_INDECES_TRANSFORM, right, bottom) # special case often used *integer* top and right to avoid downloading unneeded tiles @@ -100,31 +103,31 @@ def dted_l2_tiles_names( slon = f"{'E' if ilon >= 0 else 'W'}{abs(ilon):03d}" for ilat in range(ibottom, itop + 1): slat = f"{'N' if ilat >= 0 else 'S'}{abs(ilat):02d}" - yield tile_name_template.format(**locals()) + yield (ilon, ilat), tile_name_template.format(**locals()) -def cgiar_l1_tiles_names( +def cgiar_l1_tiles( left: float, bottom: float, right: float, top: float, tile_template: str = "srtm_{ilon:02d}_{ilat:02d}.tif", -) -> Iterator[str]: +) -> Iterator[Tile]: ileft, itop = latlon_to_indeces(CGIAR_L1_TILE_INDECES_TRANSFORM, left, top) iright, ibottom = latlon_to_indeces(CGIAR_L1_TILE_INDECES_TRANSFORM, right, bottom) for ilon in range(ileft, iright + 1): for ilat in range(itop, ibottom + 1): if ilon > 0 and ilat > 0: - yield tile_template.format(**locals()) + yield (ilon, ilat), tile_template.format(**locals()) -def srtm_ellip_tiles_names( +def srtm_ellip_tiles( left: float, bottom: float, right: float, top: float, tile_name_template: str = "{slat}{slon}_wgs84.tif", -) -> Iterator[str]: +) -> Iterator[Tile]: ileft, itop = latlon_to_indeces(DTED_L2_TILE_INDECES_TRANSFORM, left, top) iright, ibottom = latlon_to_indeces(DTED_L2_TILE_INDECES_TRANSFORM, right, bottom) # special case often used *integer* top and right to avoid downloading unneeded tiles @@ -141,28 +144,24 @@ def srtm_ellip_tiles_names( fname = tile_name_template.format(**locals()) if ilat >= 0: - yield f"{subdir}/{north_subdir}/{fname}" + yield (ilon, ilat), f"{subdir}/{north_subdir}/{fname}" else: - yield f"{subdir}/{fname}" + yield (ilon, ilat), f"{subdir}/{fname}" -def zarr_tile_names( +def zarr_tiles( left: float, bottom: float, right: float, top: float, transform: tuple[float, float, float, float], -) -> Iterator[str]: +) -> Iterator[Tile]: ileft, itop = latlon_to_indeces(transform, left, top) iright, ibottom = latlon_to_indeces(transform, right, bottom) for ilon in range(ileft, iright + 1): for ilat in range(itop, ibottom + 1): if ilon >= 0 and ilat >= 0: - yield f"{ilat}/{ilon}.tif" - - -# a cache tile is its name plus the source window, ``None`` for a whole download -Tile = tuple[str, tuple[int, int, int, int] | None] + yield (ilon, ilat), f"{ilat}/{ilon}.tif" def zarr_source(url: str) -> str: @@ -180,12 +179,12 @@ def zarr_source(url: str) -> str: class DatasourceSpec(TypedDict): datasource_url: str - # a local product has one URL per tile (``tile_names``), a remote one is a + # a local product has one URL per tile (``tiles``), a remote one is a # single chunked source (``grid``): the key tells the two apart - cached_tile_names: NotRequired[Callable[..., Iterator[str]]] - # keyword arguments for ``cached_tile_names``, e.g. the tile name template + cached_tiles: NotRequired[Callable[..., Iterator[Tile]]] + # keyword arguments for ``cached_tiles``, e.g. the tile name template # of a product that keeps its tiles in subfolders - cached_tile_names_kwargs: NotRequired[dict[str, Any]] + cached_tiles_kwargs: NotRequired[dict[str, Any]] # keyword arguments for ``tile_source``, e.g. the source extension and the # archive the provider serves it in tile_source_kwargs: NotRequired[dict[str, Any]] @@ -198,38 +197,38 @@ class DatasourceSpec(TypedDict): "tile_ext": ".hgt", "compressed_ext": ".hgt.gz", }, - "cached_tile_names": dted_l2_tiles_names, - "cached_tile_names_kwargs": {"tile_name_template": "{slat}/{slat}{slon}.tif"}, + "cached_tiles": dted_l2_tiles, + "cached_tiles_kwargs": {"tile_name_template": "{slat}/{slat}{slon}.tif"}, } SRTM1_GEOID_SPEC: DatasourceSpec = { "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1/SRTM_GL1_srtm", - "cached_tile_names": dted_l2_tiles_names, + "cached_tiles": dted_l2_tiles, } SRTM1_ELLIP_SPEC: DatasourceSpec = { "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1_Ellip/SRTM_GL1_Ellip_srtm", - "cached_tile_names": srtm_ellip_tiles_names, + "cached_tiles": srtm_ellip_tiles, } SRTM3_SPEC: DatasourceSpec = { "datasource_url": "https://srtm.csi.cgiar.org/wp-content/uploads/files/srtm_5x5/TIFF", "tile_source_kwargs": {"compressed_ext": ".zip"}, - "cached_tile_names": cgiar_l1_tiles_names, + "cached_tiles": cgiar_l1_tiles, } GLO_30_SPEC: DatasourceSpec = { "datasource_url": "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-30-v1.zarr/dsm", "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, - "cached_tile_names": zarr_tile_names, - "cached_tile_names_kwargs": {"transform": EDH_L2_CHUNK_INDECES_TRANSFORM}, + "cached_tiles": zarr_tiles, + "cached_tiles_kwargs": {"transform": EDH_L2_CHUNK_INDECES_TRANSFORM}, } GLO_90_SPEC: DatasourceSpec = { "datasource_url": "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-90-v1.zarr/dsm", "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, - "cached_tile_names": zarr_tile_names, - "cached_tile_names_kwargs": {"transform": EDH_L1_CHUNK_INDECES_TRANSFORM}, + "cached_tiles": zarr_tiles, + "cached_tiles_kwargs": {"transform": EDH_L1_CHUNK_INDECES_TRANSFORM}, } PRODUCTS_SPECS: dict[str, DatasourceSpec] = { @@ -365,9 +364,11 @@ def ensure_tiles( cached = root / "cache" / tile_name if cached.exists() and cached.stat().st_size > 0: continue + source, spool_name, member = tile_source(datasource_url, tile_name, **kwargs) spooled = root / "spool" / spool_name fetch_tile(source, spooled, member=member) + write_cache_tile(spooled, cached, gdal_options=gdal_options) spooled.unlink(missing_ok=True) @@ -433,9 +434,11 @@ def seed( if bounds is None: raise TypeError("bounds must be supplied") datasource_root, spec = ensure_setup(cache_dir, product) - tile_names = spec["cached_tile_names"] - kwargs = spec.get("cached_tile_names_kwargs", {}) - tiles = list(tile_names(*bounds, **kwargs)) + cached_tiles = spec["cached_tiles"] + cached_tiles_kwargs = spec.get("cached_tiles_kwargs", {}) + # the name is all the cache and the source URL need, the indeces of the + # product cell the tile comes from are not used yet + tiles = [tile_name for _, tile_name in cached_tiles(*bounds, **cached_tiles_kwargs)] # FIXME: emergency hack to enforce the no-bulk-download policy if len(tiles) > max_download_tiles: raise RuntimeError( diff --git a/tests/test_20_datasource.py b/tests/test_20_datasource.py index accbe47..c8583db 100644 --- a/tests/test_20_datasource.py +++ b/tests/test_20_datasource.py @@ -80,23 +80,25 @@ def test_latlon_to_indeces_EDH_L1_CHUNK_INDECES_TRANSFORM() -> None: assert datasource.latlon_to_indeces(transform, 14.0, 41.9) == (97, 24) -def test_dted_l2_tiles_names() -> None: - assert list(datasource.dted_l2_tiles_names(10.1, 44.9, 10.1, 44.9)) == [ - "N44E010.tif" +def test_dted_l2_tiles() -> None: + assert list(datasource.dted_l2_tiles(10.1, 44.9, 10.1, 44.9)) == [ + ((10, 44), "N44E010.tif") ] # NOTE this also tests int (not float) input - assert list(datasource.dted_l2_tiles_names(10, 44, 11, 45)) == ["N44E010.tif"] + assert list(datasource.dted_l2_tiles(10, 44, 11, 45)) == [((10, 44), "N44E010.tif")] -def test_mapzen_tiles_names() -> None: +def test_mapzen_tiles() -> None: # MAPZEN is the DTED L2 lattice with a subfolder in the tile name spec = datasource.MAPZEN_SPEC - tile_names = spec["cached_tile_names"] - kwargs = spec["cached_tile_names_kwargs"] + tiles = spec["cached_tiles"] + kwargs = spec["cached_tiles_kwargs"] - assert list(tile_names(10.1, 44.9, 10.1, 44.9, **kwargs)) == ["N44/N44E010.tif"] + assert list(tiles(10.1, 44.9, 10.1, 44.9, **kwargs)) == [ + ((10, 44), "N44/N44E010.tif") + ] # NOTE this also tests int (not float) input - assert list(tile_names(10, 44, 11, 45, **kwargs)) == ["N44/N44E010.tif"] + assert list(tiles(10, 44, 11, 45, **kwargs)) == [((10, 44), "N44/N44E010.tif")] def test_zarr_source() -> None: @@ -106,32 +108,32 @@ def test_zarr_source() -> None: ) -def test_cgiar_l1_tiles_names() -> None: - assert next(datasource.cgiar_l1_tiles_names(10.1, 44.9, 10.1, 44.9)).endswith( - "srtm_39_04.tif" +def test_cgiar_l1_tiles() -> None: + assert next(datasource.cgiar_l1_tiles(10.1, 44.9, 10.1, 44.9)) == ( + (39, 4), + "srtm_39_04.tif", ) - assert next(datasource.cgiar_l1_tiles_names(25.50, 58.40, 27.67, 60.06)).endswith( - "srtm_42_01.tif" + assert next(datasource.cgiar_l1_tiles(25.50, 58.40, 27.67, 60.06)) == ( + (42, 1), + "srtm_42_01.tif", ) - assert len(list(datasource.cgiar_l1_tiles_names(9.9, 39.1, 15.1, 45.1))) == 9 + assert len(list(datasource.cgiar_l1_tiles(9.9, 39.1, 15.1, 45.1))) == 9 -def test_srtm_ellip_tiles_names() -> None: - ds1 = ["North/North_30_60/N44E010_wgs84.tif"] - ds2 = ["North/North_0_29/N07W074_wgs84.tif"] - ds3 = ["South/S20E015_wgs84.tif"] - assert list(datasource.srtm_ellip_tiles_names(10.1, 44.9, 10.1, 44.9)) == ds1 - assert list(datasource.srtm_ellip_tiles_names(-73.99, 7.056, -73.90, 7.660)) == ds2 - assert ( - list(datasource.srtm_ellip_tiles_names(15.931, -19.194, 15.329, -19.961)) == ds3 - ) +def test_srtm_ellip_tiles() -> None: + ds1 = [((10, 44), "North/North_30_60/N44E010_wgs84.tif")] + ds2 = [((-74, 7), "North/North_0_29/N07W074_wgs84.tif")] + ds3 = [((15, -20), "South/S20E015_wgs84.tif")] + assert list(datasource.srtm_ellip_tiles(10.1, 44.9, 10.1, 44.9)) == ds1 + assert list(datasource.srtm_ellip_tiles(-73.99, 7.056, -73.90, 7.660)) == ds2 + assert list(datasource.srtm_ellip_tiles(15.931, -19.194, 15.329, -19.961)) == ds3 # the tiles share their edge row and column, so a bound on a whole degree # does not reach the tiles that start there - assert list(datasource.srtm_ellip_tiles_names(10.1, 44.1, 12.0, 46.0)) == [ - "North/North_30_60/N44E010_wgs84.tif", - "North/North_30_60/N45E010_wgs84.tif", - "North/North_30_60/N44E011_wgs84.tif", - "North/North_30_60/N45E011_wgs84.tif", + assert list(datasource.srtm_ellip_tiles(10.1, 44.1, 12.0, 46.0)) == [ + ((10, 44), "North/North_30_60/N44E010_wgs84.tif"), + ((10, 45), "North/North_30_60/N45E010_wgs84.tif"), + ((11, 44), "North/North_30_60/N44E011_wgs84.tif"), + ((11, 45), "North/North_30_60/N45E011_wgs84.tif"), ] @@ -218,12 +220,16 @@ def test_ensure_tiles_remote(mocker: MockerFixture, tmp_path: Path) -> None: gdal_options=datasource.FLOAT_TILE_GDAL_OPTIONS, ) - # a remote product is read in place: no download, one chunk per tile - mock_fetch.assert_not_called() + # the chunks of a remote product are fetched and cached like any other + # tile, only the float creation options make them different + mock_fetch.assert_called_once_with( + f"{datasource.GLO_90_SPEC['datasource_url']}/{tile}", + tmp_path / "spool" / tile, + member=None, + ) mock_write.assert_called_once_with( - datasource.zarr_source(datasource.GLO_90_SPEC["datasource_url"]), - tmp_path / "cache" / "192_48.tif", - srcwin=(230400, 57600, 1200, 1200), + tmp_path / "spool" / tile, + tmp_path / "cache" / tile, gdal_options=datasource.FLOAT_TILE_GDAL_OPTIONS, ) From 47a078e57aab32472c4730cf8f77ae0a4505be4a Mon Sep 17 00:00:00 2001 From: Alessandro Amici Date: Sun, 4 Oct 2026 19:37:34 +0200 Subject: [PATCH 12/14] The design part of the refactor works: dataset declare two functions: cached_tiles and prepare_tile --- elevation/datasource.py | 88 ++++++++++++++++++++++++------------- tests/test_20_datasource.py | 51 ++++++++++++--------- 2 files changed, 88 insertions(+), 51 deletions(-) diff --git a/elevation/datasource.py b/elevation/datasource.py index 2937b89..b8f0033 100644 --- a/elevation/datasource.py +++ b/elevation/datasource.py @@ -149,6 +149,15 @@ def srtm_ellip_tiles( yield (ilon, ilat), f"{subdir}/{fname}" +def prepare_tile_download_uncompress( + tile_name, spool, datasource_url, ilon, ilat, **kwargs +) -> tuple[list[str], Path | None]: + source, spool_name, member = tile_source(datasource_url, tile_name, **kwargs) + spooled = spool / spool_name + fetch_tile(source, spooled, member=member) + return [str(spooled)], spooled + + def zarr_tiles( left: float, bottom: float, @@ -164,17 +173,16 @@ def zarr_tiles( yield (ilon, ilat), f"{ilat}/{ilon}.tif" -def zarr_source(url: str) -> str: - """Return the GDAL connection string that reads the Zarr array at *url*. - - The array path must be given to the driver as the tag suffix. Addressing the - array as ``ZARR:"/vsicurl/"`` reads the same raster but without any - CRS: the store keeps it in a separate ``spatial_ref`` variable, that GDAL - only reads through the store itself. - """ - store, _, array = url.rpartition("/") - source = f'ZARR:"/vsicurl/{store}":/{array}' - return source +def prepare_tile_zarr( + tile_name, spool, datasource_url, ilat, ilon, chunks, **kwargs +) -> tuple[list[str], Path | None]: + srcwin = [ilon * chunks[0], ilat * chunks[1], chunks[0], chunks[1]] + gdal_source = [ + "-srcwin", + *map(str, srcwin), + f'ZARR:"/vsicurl/{datasource_url}":/dsm', + ] + return gdal_source, None class DatasourceSpec(TypedDict): @@ -193,7 +201,8 @@ class DatasourceSpec(TypedDict): MAPZEN_SPEC: DatasourceSpec = { "datasource_url": "https://s3.amazonaws.com/elevation-tiles-prod/skadi", - "tile_source_kwargs": { + "prepare_tile": prepare_tile_download_uncompress, + "prepare_tile_kwargs": { "tile_ext": ".hgt", "compressed_ext": ".hgt.gz", }, @@ -204,21 +213,28 @@ class DatasourceSpec(TypedDict): SRTM1_GEOID_SPEC: DatasourceSpec = { "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1/SRTM_GL1_srtm", "cached_tiles": dted_l2_tiles, + "prepare_tile": prepare_tile_download_uncompress, } SRTM1_ELLIP_SPEC: DatasourceSpec = { "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1_Ellip/SRTM_GL1_Ellip_srtm", "cached_tiles": srtm_ellip_tiles, + "prepare_tile": prepare_tile_download_uncompress, } SRTM3_SPEC: DatasourceSpec = { "datasource_url": "https://srtm.csi.cgiar.org/wp-content/uploads/files/srtm_5x5/TIFF", "tile_source_kwargs": {"compressed_ext": ".zip"}, "cached_tiles": cgiar_l1_tiles, + "prepare_tile": prepare_tile_download_uncompress, } GLO_30_SPEC: DatasourceSpec = { - "datasource_url": "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-30-v1.zarr/dsm", + "datasource_url": "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-30-v1.zarr", + "prepare_tile": prepare_tile_zarr, + "prepare_tile_kwargs": { + "chunks": (3600, 1800), + }, "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, "cached_tiles": zarr_tiles, "cached_tiles_kwargs": {"transform": EDH_L2_CHUNK_INDECES_TRANSFORM}, @@ -226,6 +242,10 @@ class DatasourceSpec(TypedDict): GLO_90_SPEC: DatasourceSpec = { "datasource_url": "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-90-v1.zarr/dsm", + "prepare_tile": prepare_tile_zarr, + "prepare_tile_kwargs": { + "chunks": (2400, 2400), + }, "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, "cached_tiles": zarr_tiles, "cached_tiles_kwargs": {"transform": EDH_L1_CHUNK_INDECES_TRANSFORM}, @@ -315,10 +335,9 @@ def fetch_tile(source: str, destination: Path, *, member: str | None = None) -> def write_cache_tile( - source: str | Path, + gdal_source: Sequence[str], destination: Path, *, - srcwin: Sequence[int] | None = None, gdal_options: str = TILE_GDAL_OPTIONS, ) -> list[str]: """Write *source* to *destination* as the internal compressed GeoTIFF tile. @@ -334,13 +353,11 @@ def write_cache_tile( :return: The command arguments. """ destination.parent.mkdir(parents=True, exist_ok=True) - window = [] if srcwin is None else ["-srcwin", *map(str, srcwin)] cmd = [ "gdal_translate", "-q", *gdal_options.split(), - *window, - str(source), + *gdal_source, str(destination), ] subprocess.check_call(cmd) @@ -349,8 +366,8 @@ def write_cache_tile( def ensure_tiles( root: Path, - tiles: Sequence[str], - datasource_url: str, + tiles: Sequence[Tile], + prepare_tile: Callable, gdal_options: str = TILE_GDAL_OPTIONS, **kwargs: Any, ) -> None: @@ -360,17 +377,25 @@ def ensure_tiles( from ``datasource_url``, a tile without one is downloaded whole from its own URL and goes through the spool. """ - for tile_name in tiles: + for (ilon, ilat), tile_name in tiles: cached = root / "cache" / tile_name if cached.exists() and cached.stat().st_size > 0: continue - source, spool_name, member = tile_source(datasource_url, tile_name, **kwargs) - spooled = root / "spool" / spool_name - fetch_tile(source, spooled, member=member) + # prepare the data if GDAL cannot download it / read it as it is + gdal_source, spooled = prepare_tile( + tile_name, root / "spool", ilat=ilat, ilon=ilon, **kwargs + ) + + # convert the data to the internal cache format + ready = root / "spool/ready" / tile_name + write_cache_tile(gdal_source, ready, gdal_options=gdal_options) + if spooled is not None: + spooled.unlink(missing_ok=True) - write_cache_tile(spooled, cached, gdal_options=gdal_options) - spooled.unlink(missing_ok=True) + # finally move the data inside the cache. The move is atomic in most cases + cached.parent.mkdir(parents=True, exist_ok=True) + shutil.move(ready, cached) def build_vrt(root: Path, product: str) -> list[str]: @@ -436,9 +461,7 @@ def seed( datasource_root, spec = ensure_setup(cache_dir, product) cached_tiles = spec["cached_tiles"] cached_tiles_kwargs = spec.get("cached_tiles_kwargs", {}) - # the name is all the cache and the source URL need, the indeces of the - # product cell the tile comes from are not used yet - tiles = [tile_name for _, tile_name in cached_tiles(*bounds, **cached_tiles_kwargs)] + tiles = list(cached_tiles(*bounds, **cached_tiles_kwargs)) # FIXME: emergency hack to enforce the no-bulk-download policy if len(tiles) > max_download_tiles: raise RuntimeError( @@ -446,13 +469,16 @@ def seed( "providers' websites for how to bulk download tiles." ) - with util.lock_tiles(datasource_root, tiles): + prepare_tile = spec["prepare_tile"] + prepare_tile_kwargs = spec.get("prepare_tile_kwargs", {}) + with util.lock_tiles(datasource_root, [name for _, name in tiles]): ensure_tiles( datasource_root, tiles, + prepare_tile=prepare_tile, datasource_url=spec["datasource_url"], gdal_options=spec.get("tile_gdal_options", TILE_GDAL_OPTIONS), - **spec.get("tile_source_kwargs", {}), + **prepare_tile_kwargs, ) with util.lock_vrt(datasource_root, product): diff --git a/tests/test_20_datasource.py b/tests/test_20_datasource.py index c8583db..981bb12 100644 --- a/tests/test_20_datasource.py +++ b/tests/test_20_datasource.py @@ -30,6 +30,12 @@ def gdalinfo_json(path: Path) -> dict[str, Any]: return info +def write_ready_tile(source: str | Path, ready: Path, **kwargs: Any) -> None: + """Stand in for the mocked cache write: leave a tile for the cache move.""" + ready.parent.mkdir(parents=True, exist_ok=True) + ready.write_bytes(b"tile") + + def test_latlon_to_indeces_CGIAR_L1_TILE_INDECES_TRANSFORM() -> None: transform = datasource.CGIAR_L1_TILE_INDECES_TRANSFORM # values from https://srtm.csi.cgiar.org/SELECTION/inputCoord.asp @@ -101,13 +107,6 @@ def test_mapzen_tiles() -> None: assert list(tiles(10, 44, 11, 45, **kwargs)) == [((10, 44), "N44/N44E010.tif")] -def test_zarr_source() -> None: - assert ( - datasource.zarr_source("https://example.org/store.zarr/dsm") - == 'ZARR:"/vsicurl/https://example.org/store.zarr":/dsm' - ) - - def test_cgiar_l1_tiles() -> None: assert next(datasource.cgiar_l1_tiles(10.1, 44.9, 10.1, 44.9)) == ( (39, 4), @@ -171,11 +170,13 @@ def test_tile_source() -> None: def test_ensure_tiles(mocker: MockerFixture, tmp_path: Path) -> None: mock_fetch = mocker.patch("elevation.datasource.fetch_tile") - mock_write = mocker.patch("elevation.datasource.write_cache_tile") + mock_write = mocker.patch( + "elevation.datasource.write_cache_tile", side_effect=write_ready_tile + ) datasource.ensure_tiles( tmp_path, - ["N41E012.tif"], + [((12, 41), "N41E012.tif")], datasource.SRTM1_GEOID_SPEC["datasource_url"], ) @@ -186,9 +187,11 @@ def test_ensure_tiles(mocker: MockerFixture, tmp_path: Path) -> None: ) mock_write.assert_called_once_with( tmp_path / "spool" / "N41E012.tif", - tmp_path / "cache" / "N41E012.tif", + tmp_path / "spool" / "ready" / "N41E012.tif", gdal_options=datasource.TILE_GDAL_OPTIONS, ) + # the tile reaches the cache only once it has been written in the spool + assert (tmp_path / "cache" / "N41E012.tif").read_bytes() == b"tile" def test_ensure_tiles_skips_cached(mocker: MockerFixture, tmp_path: Path) -> None: @@ -200,22 +203,25 @@ def test_ensure_tiles_skips_cached(mocker: MockerFixture, tmp_path: Path) -> Non datasource.ensure_tiles( tmp_path, - ["N41E012.tif"], + [((12, 41), "N41E012.tif")], datasource.SRTM1_GEOID_SPEC["datasource_url"], ) mock_fetch.assert_not_called() mock_write.assert_not_called() + assert cached.read_bytes() == b"cached" def test_ensure_tiles_remote(mocker: MockerFixture, tmp_path: Path) -> None: mock_fetch = mocker.patch("elevation.datasource.fetch_tile") - mock_write = mocker.patch("elevation.datasource.write_cache_tile") + mock_write = mocker.patch( + "elevation.datasource.write_cache_tile", side_effect=write_ready_tile + ) tile = "192/48.tif" datasource.ensure_tiles( tmp_path, - [tile], + [((192, 48), tile)], datasource.GLO_90_SPEC["datasource_url"], gdal_options=datasource.FLOAT_TILE_GDAL_OPTIONS, ) @@ -229,9 +235,10 @@ def test_ensure_tiles_remote(mocker: MockerFixture, tmp_path: Path) -> None: ) mock_write.assert_called_once_with( tmp_path / "spool" / tile, - tmp_path / "cache" / tile, + tmp_path / "spool" / "ready" / tile, gdal_options=datasource.FLOAT_TILE_GDAL_OPTIONS, ) + assert (tmp_path / "cache" / tile).read_bytes() == b"tile" def test_fetch_tile(tmp_path: Path) -> None: @@ -357,7 +364,9 @@ def test_seed(mocker: MockerFixture, tmp_path: Path) -> None: bounds = (13.1, 43.1, 13.9, 43.9) mock_check_call = mocker.patch("subprocess.check_call") mock_fetch = mocker.patch("elevation.datasource.fetch_tile") - mock_write = mocker.patch("elevation.datasource.write_cache_tile") + mock_write = mocker.patch( + "elevation.datasource.write_cache_tile", side_effect=write_ready_tile + ) datasource_root = datasource.seed( cache_dir=root, product="SRTM1_GEOID", bounds=bounds @@ -371,7 +380,7 @@ def test_seed(mocker: MockerFixture, tmp_path: Path) -> None: ) mock_write.assert_called_once_with( datasource_root / "spool" / "N43E013.tif", - datasource_root / "cache" / "N43E013.tif", + datasource_root / "spool" / "ready" / "N43E013.tif", gdal_options=datasource.TILE_GDAL_OPTIONS, ) assert mock_check_call.call_args[0][0][0] == "gdalbuildvrt" @@ -387,7 +396,9 @@ def test_seed_remote(mocker: MockerFixture, tmp_path: Path) -> None: root = tmp_path / "root" mock_check_call = mocker.patch("subprocess.check_call") mock_fetch = mocker.patch("elevation.datasource.fetch_tile") - mock_write = mocker.patch("elevation.datasource.write_cache_tile") + mock_write = mocker.patch( + "elevation.datasource.write_cache_tile", side_effect=write_ready_tile + ) datasource_root = datasource.seed( cache_dir=root, @@ -396,7 +407,6 @@ def test_seed_remote(mocker: MockerFixture, tmp_path: Path) -> None: ) assert datasource_root == root / "GLO-30" - assert not (datasource_root / "spool").exists() mock_fetch.assert_called_once_with( f"{datasource.GLO_30_SPEC['datasource_url']}/96/192.tif", datasource_root / "spool" / "96/192.tif", @@ -404,9 +414,10 @@ def test_seed_remote(mocker: MockerFixture, tmp_path: Path) -> None: ) mock_write.assert_called_once_with( datasource_root / "spool" / "96/192.tif", - datasource_root / "cache" / "96/192.tif", + datasource_root / "spool" / "ready" / "96/192.tif", gdal_options=datasource.FLOAT_TILE_GDAL_OPTIONS, ) + assert (datasource_root / "cache" / "96/192.tif").read_bytes() == b"tile" assert mock_check_call.call_args[0][0][0] == "gdalbuildvrt" with pytest.raises(RuntimeError): @@ -438,7 +449,7 @@ def test_clip(mocker: MockerFixture, tmp_path: Path) -> None: bounds = (13.1, 43.1, 14.9, 44.9) mock_check_call = mocker.patch("subprocess.check_call") mocker.patch("elevation.datasource.fetch_tile") - mocker.patch("elevation.datasource.write_cache_tile") + mocker.patch("elevation.datasource.write_cache_tile", side_effect=write_ready_tile) datasource.clip(cache_dir=root, bounds=bounds, output="out.tif") From 2a3fc877cba8ed65be26d93765b63c0efa81b29a Mon Sep 17 00:00:00 2001 From: Alessandro Amici Date: Sun, 4 Oct 2026 19:53:28 +0200 Subject: [PATCH 13/14] Spec: cached_tiles and prepare_tile are required keys, the URL lives in prepare_tile_kwargs The datasource URL is a prepare_tile argument like the source extension, the archive and the chunk size, so seed forwards a single kwargs channel and no longer invents one. SRTM3 declares its .zip there too, so its tile URL exists on the provider again, and both prepare_tile implementations are annotated. --- elevation/datasource.py | 52 +++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/elevation/datasource.py b/elevation/datasource.py index b8f0033..11cda0b 100644 --- a/elevation/datasource.py +++ b/elevation/datasource.py @@ -150,7 +150,12 @@ def srtm_ellip_tiles( def prepare_tile_download_uncompress( - tile_name, spool, datasource_url, ilon, ilat, **kwargs + tile_name: str, + spool: Path, + datasource_url: str, + ilon: int, + ilat: int, + **kwargs: Any, ) -> tuple[list[str], Path | None]: source, spool_name, member = tile_source(datasource_url, tile_name, **kwargs) spooled = spool / spool_name @@ -174,7 +179,13 @@ def zarr_tiles( def prepare_tile_zarr( - tile_name, spool, datasource_url, ilat, ilon, chunks, **kwargs + tile_name: str, + spool: Path, + datasource_url: str, + ilat: int, + ilon: int, + chunks: tuple[int, int], + **kwargs: Any, ) -> tuple[list[str], Path | None]: srcwin = [ilon * chunks[0], ilat * chunks[1], chunks[0], chunks[1]] gdal_source = [ @@ -186,23 +197,25 @@ def prepare_tile_zarr( class DatasourceSpec(TypedDict): - datasource_url: str # a local product has one URL per tile (``tiles``), a remote one is a # single chunked source (``grid``): the key tells the two apart - cached_tiles: NotRequired[Callable[..., Iterator[Tile]]] + cached_tiles: Callable[..., Iterator[Tile]] # keyword arguments for ``cached_tiles``, e.g. the tile name template # of a product that keeps its tiles in subfolders cached_tiles_kwargs: NotRequired[dict[str, Any]] - # keyword arguments for ``tile_source``, e.g. the source extension and the - # archive the provider serves it in - tile_source_kwargs: NotRequired[dict[str, Any]] + # prepare the tile for GDAL, downloading it or reading the window of the + # chunked source, next to the spool file to remove once it is cached + prepare_tile: Callable[..., tuple[list[str], Path | None]] + # keyword arguments for ``prepare_tile``: the datasource URL, the source + # extension, the archive the provider serves it in, the chunk size + prepare_tile_kwargs: dict[str, Any] tile_gdal_options: NotRequired[str] MAPZEN_SPEC: DatasourceSpec = { - "datasource_url": "https://s3.amazonaws.com/elevation-tiles-prod/skadi", "prepare_tile": prepare_tile_download_uncompress, "prepare_tile_kwargs": { + "datasource_url": "https://s3.amazonaws.com/elevation-tiles-prod/skadi", "tile_ext": ".hgt", "compressed_ext": ".hgt.gz", }, @@ -211,28 +224,34 @@ class DatasourceSpec(TypedDict): } SRTM1_GEOID_SPEC: DatasourceSpec = { - "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1/SRTM_GL1_srtm", - "cached_tiles": dted_l2_tiles, "prepare_tile": prepare_tile_download_uncompress, + "prepare_tile_kwargs": { + "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1/SRTM_GL1_srtm", + }, + "cached_tiles": dted_l2_tiles, } SRTM1_ELLIP_SPEC: DatasourceSpec = { - "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1_Ellip/SRTM_GL1_Ellip_srtm", - "cached_tiles": srtm_ellip_tiles, "prepare_tile": prepare_tile_download_uncompress, + "prepare_tile_kwargs": { + "datasource_url": "https://opentopography.s3.sdsc.edu/raster/SRTM_GL1_Ellip/SRTM_GL1_Ellip_srtm", + }, + "cached_tiles": srtm_ellip_tiles, } SRTM3_SPEC: DatasourceSpec = { - "datasource_url": "https://srtm.csi.cgiar.org/wp-content/uploads/files/srtm_5x5/TIFF", - "tile_source_kwargs": {"compressed_ext": ".zip"}, + "prepare_tile_kwargs": { + "datasource_url": "https://srtm.csi.cgiar.org/wp-content/uploads/files/srtm_5x5/TIFF", + "compressed_ext": ".zip", + }, "cached_tiles": cgiar_l1_tiles, "prepare_tile": prepare_tile_download_uncompress, } GLO_30_SPEC: DatasourceSpec = { - "datasource_url": "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-30-v1.zarr", "prepare_tile": prepare_tile_zarr, "prepare_tile_kwargs": { + "datasource_url": "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-30-v1.zarr", "chunks": (3600, 1800), }, "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, @@ -241,9 +260,9 @@ class DatasourceSpec(TypedDict): } GLO_90_SPEC: DatasourceSpec = { - "datasource_url": "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-90-v1.zarr/dsm", "prepare_tile": prepare_tile_zarr, "prepare_tile_kwargs": { + "datasource_url": "https://data.earthdatahub.destine.eu/copernicus-dem/GLO-90-v1.zarr", "chunks": (2400, 2400), }, "tile_gdal_options": FLOAT_TILE_GDAL_OPTIONS, @@ -476,7 +495,6 @@ def seed( datasource_root, tiles, prepare_tile=prepare_tile, - datasource_url=spec["datasource_url"], gdal_options=spec.get("tile_gdal_options", TILE_GDAL_OPTIONS), **prepare_tile_kwargs, ) From eba55b5b1e966a883ac5c89c2bd10aa0584b0c34 Mon Sep 17 00:00:00 2001 From: Alessandro Amici Date: Sun, 4 Oct 2026 19:57:51 +0200 Subject: [PATCH 14/14] Adapt the tests to the two-function design, and type the prepare_tile callable The spec URL and the prepare arguments come from prepare_tile_kwargs, ensure_tiles takes the prepare_tile callable and write_cache_tile takes the GDAL source operands. Both store products now assert that nothing is downloaded, pinning the chunk window and the ZARR:"/vsicurl/":/dsm connection string that carries the CRS. --- elevation/datasource.py | 2 +- tests/test_20_datasource.py | 119 +++++++++++++++++++++++------------- 2 files changed, 77 insertions(+), 44 deletions(-) diff --git a/elevation/datasource.py b/elevation/datasource.py index 11cda0b..d872678 100644 --- a/elevation/datasource.py +++ b/elevation/datasource.py @@ -386,7 +386,7 @@ def write_cache_tile( def ensure_tiles( root: Path, tiles: Sequence[Tile], - prepare_tile: Callable, + prepare_tile: Callable[..., tuple[list[str], Path | None]], gdal_options: str = TILE_GDAL_OPTIONS, **kwargs: Any, ) -> None: diff --git a/tests/test_20_datasource.py b/tests/test_20_datasource.py index 981bb12..371d04c 100644 --- a/tests/test_20_datasource.py +++ b/tests/test_20_datasource.py @@ -137,38 +137,57 @@ def test_srtm_ellip_tiles() -> None: def test_tile_source() -> None: - # a product that serves plain tiles uses the defaults - assert "tile_source_kwargs" not in datasource.SRTM1_GEOID_SPEC + # a product that serves plain tiles declares only its datasource URL + spec = datasource.SRTM1_GEOID_SPEC + kwargs = dict(spec["prepare_tile_kwargs"]) + datasource_url = kwargs.pop("datasource_url") + assert kwargs == {} + url, spooled, member = datasource.tile_source( + datasource_url, "N41E012.tif", **kwargs + ) + assert url == f"{datasource_url}/N41E012.tif" + assert spooled == "N41E012.tif" + assert member is None + # MAPZEN serves the DTED tiles gunzipped, so the spool name drops the .gz spec = datasource.MAPZEN_SPEC + kwargs = dict(spec["prepare_tile_kwargs"]) + datasource_url = kwargs.pop("datasource_url") + assert kwargs == {"tile_ext": ".hgt", "compressed_ext": ".hgt.gz"} url, spooled, member = datasource.tile_source( - spec["datasource_url"], "N41/N41E012.tif", **spec["tile_source_kwargs"] + datasource_url, "N41/N41E012.tif", **kwargs ) - assert url.endswith("/skadi/N41/N41E012.hgt.gz") + assert url == f"{datasource_url}/N41/N41E012.hgt.gz" assert spooled == "N41/N41E012.hgt" assert member is None # the SRTM3 tiles are served inside a .zip, that the spec has to declare or # seed asks for a plain .tif that the provider does not have spec = datasource.SRTM3_SPEC - assert spec.get("tile_source_kwargs") == {"compressed_ext": ".zip"} + kwargs = dict(spec["prepare_tile_kwargs"]) + datasource_url = kwargs.pop("datasource_url") + assert kwargs == {"compressed_ext": ".zip"} url, spooled, member = datasource.tile_source( - spec["datasource_url"], "srtm_39_04.tif", **spec["tile_source_kwargs"] + datasource_url, "srtm_39_04.tif", **kwargs ) - assert url.endswith("/srtm_39_04.zip") + assert url == f"{datasource_url}/srtm_39_04.zip" assert spooled == "srtm_39_04.tif" assert member == "srtm_39_04.tif" + # the SRTM1 ellipsoidal tiles keep the name and the subfolder of the cache spec = datasource.SRTM1_ELLIP_SPEC + kwargs = dict(spec["prepare_tile_kwargs"]) + datasource_url = kwargs.pop("datasource_url") url, spooled, member = datasource.tile_source( - spec["datasource_url"], "North/North_30_60/N44E010_wgs84.tif" + datasource_url, "North/North_30_60/N44E010_wgs84.tif" ) - assert url.endswith("/North/North_30_60/N44E010_wgs84.tif") + assert url == f"{datasource_url}/North/North_30_60/N44E010_wgs84.tif" assert spooled == "North/North_30_60/N44E010_wgs84.tif" assert member is None def test_ensure_tiles(mocker: MockerFixture, tmp_path: Path) -> None: + spec = datasource.SRTM1_GEOID_SPEC mock_fetch = mocker.patch("elevation.datasource.fetch_tile") mock_write = mocker.patch( "elevation.datasource.write_cache_tile", side_effect=write_ready_tile @@ -177,16 +196,17 @@ def test_ensure_tiles(mocker: MockerFixture, tmp_path: Path) -> None: datasource.ensure_tiles( tmp_path, [((12, 41), "N41E012.tif")], - datasource.SRTM1_GEOID_SPEC["datasource_url"], + prepare_tile=spec["prepare_tile"], + **spec["prepare_tile_kwargs"], ) mock_fetch.assert_called_once_with( - f"{datasource.SRTM1_GEOID_SPEC['datasource_url']}/N41E012.tif", + f"{spec['prepare_tile_kwargs']['datasource_url']}/N41E012.tif", tmp_path / "spool" / "N41E012.tif", member=None, ) mock_write.assert_called_once_with( - tmp_path / "spool" / "N41E012.tif", + [str(tmp_path / "spool" / "N41E012.tif")], tmp_path / "spool" / "ready" / "N41E012.tif", gdal_options=datasource.TILE_GDAL_OPTIONS, ) @@ -195,6 +215,7 @@ def test_ensure_tiles(mocker: MockerFixture, tmp_path: Path) -> None: def test_ensure_tiles_skips_cached(mocker: MockerFixture, tmp_path: Path) -> None: + spec = datasource.SRTM1_GEOID_SPEC cached = tmp_path / "cache" / "N41E012.tif" cached.parent.mkdir(parents=True) cached.write_bytes(b"cached") @@ -204,7 +225,8 @@ def test_ensure_tiles_skips_cached(mocker: MockerFixture, tmp_path: Path) -> Non datasource.ensure_tiles( tmp_path, [((12, 41), "N41E012.tif")], - datasource.SRTM1_GEOID_SPEC["datasource_url"], + prepare_tile=spec["prepare_tile"], + **spec["prepare_tile_kwargs"], ) mock_fetch.assert_not_called() @@ -213,32 +235,41 @@ def test_ensure_tiles_skips_cached(mocker: MockerFixture, tmp_path: Path) -> Non def test_ensure_tiles_remote(mocker: MockerFixture, tmp_path: Path) -> None: + spec = datasource.GLO_90_SPEC mock_fetch = mocker.patch("elevation.datasource.fetch_tile") mock_write = mocker.patch( "elevation.datasource.write_cache_tile", side_effect=write_ready_tile ) - tile = "192/48.tif" + # the chunk the Rome bounds of the integration tests fall in + tiles = list( + spec["cached_tiles"](12.4, 41.8, 12.4, 41.8, **spec["cached_tiles_kwargs"]) + ) + assert tiles == [((96, 24), "24/96.tif")] datasource.ensure_tiles( tmp_path, - [((192, 48), tile)], - datasource.GLO_90_SPEC["datasource_url"], - gdal_options=datasource.FLOAT_TILE_GDAL_OPTIONS, + tiles, + prepare_tile=spec["prepare_tile"], + gdal_options=spec["tile_gdal_options"], + **spec["prepare_tile_kwargs"], ) - # the chunks of a remote product are fetched and cached like any other - # tile, only the float creation options make them different - mock_fetch.assert_called_once_with( - f"{datasource.GLO_90_SPEC['datasource_url']}/{tile}", - tmp_path / "spool" / tile, - member=None, - ) + # a store is read in place: no download, one window per chunk, and the + # connection string keeps the CRS in the store, so no ``:/dsm`` suffix + mock_fetch.assert_not_called() mock_write.assert_called_once_with( - tmp_path / "spool" / tile, - tmp_path / "spool" / "ready" / tile, + [ + "-srcwin", + "230400", + "57600", + "2400", + "2400", + 'ZARR:"/vsicurl/https://data.earthdatahub.destine.eu/copernicus-dem/GLO-90-v1.zarr":/dsm', + ], + tmp_path / "spool" / "ready" / "24/96.tif", gdal_options=datasource.FLOAT_TILE_GDAL_OPTIONS, ) - assert (tmp_path / "cache" / tile).read_bytes() == b"tile" + assert (tmp_path / "cache" / "24/96.tif").read_bytes() == b"tile" def test_fetch_tile(tmp_path: Path) -> None: @@ -270,19 +301,15 @@ def test_fetch_tile_zip(tmp_path: Path) -> None: def test_write_cache_tile_command(tmp_path: Path, mocker: MockerFixture) -> None: check_call = mocker.patch("subprocess.check_call") destination = tmp_path / "cache" / "destination.tif" + gdal_source = ["-srcwin", "0", "0", "1", "1", str(REFERENCE)] - cmd = datasource.write_cache_tile(REFERENCE, destination, srcwin=(0, 0, 1, 1)) + cmd = datasource.write_cache_tile(gdal_source, destination) assert cmd == [ "gdal_translate", "-q", *datasource.TILE_GDAL_OPTIONS.split(), - "-srcwin", - "0", - "0", - "1", - "1", - str(REFERENCE), + *gdal_source, str(destination), ] check_call.assert_called_once_with(cmd) @@ -293,7 +320,7 @@ def test_write_cache_tile_command(tmp_path: Path, mocker: MockerFixture) -> None def test_write_cache_tile(tmp_path: Path) -> None: destination = tmp_path / "cache" / "destination.tif" - datasource.write_cache_tile(REFERENCE, destination) + datasource.write_cache_tile([str(REFERENCE)], destination) source = gdalinfo_json(REFERENCE) tile = gdalinfo_json(destination) @@ -362,6 +389,7 @@ def test_do_clip_gdal_options(mocker: MockerFixture, tmp_path: Path) -> None: def test_seed(mocker: MockerFixture, tmp_path: Path) -> None: root = tmp_path / "root" bounds = (13.1, 43.1, 13.9, 43.9) + spec = datasource.SRTM1_GEOID_SPEC mock_check_call = mocker.patch("subprocess.check_call") mock_fetch = mocker.patch("elevation.datasource.fetch_tile") mock_write = mocker.patch( @@ -374,12 +402,12 @@ def test_seed(mocker: MockerFixture, tmp_path: Path) -> None: assert datasource_root == root / "SRTM1_GEOID" mock_fetch.assert_called_once_with( - f"{datasource.SRTM1_GEOID_SPEC['datasource_url']}/N43E013.tif", + f"{spec['prepare_tile_kwargs']['datasource_url']}/N43E013.tif", datasource_root / "spool" / "N43E013.tif", member=None, ) mock_write.assert_called_once_with( - datasource_root / "spool" / "N43E013.tif", + [str(datasource_root / "spool" / "N43E013.tif")], datasource_root / "spool" / "ready" / "N43E013.tif", gdal_options=datasource.TILE_GDAL_OPTIONS, ) @@ -407,13 +435,18 @@ def test_seed_remote(mocker: MockerFixture, tmp_path: Path) -> None: ) assert datasource_root == root / "GLO-30" - mock_fetch.assert_called_once_with( - f"{datasource.GLO_30_SPEC['datasource_url']}/96/192.tif", - datasource_root / "spool" / "96/192.tif", - member=None, - ) + # the store is read in place, one window per chunk of the 1 by 0.5 degrees + # grid, and the tile name mirrors the chunk layout + mock_fetch.assert_not_called() mock_write.assert_called_once_with( - datasource_root / "spool" / "96/192.tif", + [ + "-srcwin", + "691200", + "172800", + "3600", + "1800", + 'ZARR:"/vsicurl/https://data.earthdatahub.destine.eu/copernicus-dem/GLO-30-v1.zarr":/dsm', + ], datasource_root / "spool" / "ready" / "96/192.tif", gdal_options=datasource.FLOAT_TILE_GDAL_OPTIONS, )