├── tests ├── __init__.py ├── test_utils.py ├── test_sparse_levels.py ├── test_pyramid_regrid.py ├── test_pyramids.py ├── test_pyramid_resample.py └── conftest.py ├── .prettierrc.toml ├── docs ├── _static │ ├── thumbnails │ │ ├── create.jpg │ │ ├── geotiff.jpg │ │ ├── regrid.jpg │ │ ├── resample.jpg │ │ └── reproject.jpg │ ├── monogram-dark-cropped.png │ └── monogram-light-cropped.png ├── api.rst ├── gallery.rst ├── index.rst ├── gallery.yml ├── Makefile ├── make.bat ├── install.md ├── conf.py ├── schema.md └── examples │ └── pyramid-create.ipynb ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── codspeed.yml │ ├── main.yaml │ └── pypi-release.yaml ├── ndpyramid ├── __init__.py ├── coarsen.py ├── testing.py ├── create.py ├── common.py ├── utils.py ├── reproject.py ├── resample.py └── regrid.py ├── codecov.yml ├── .readthedocs.yaml ├── .pre-commit-config.yaml ├── LICENSE ├── .gitignore ├── README.md └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc.toml: -------------------------------------------------------------------------------- 1 | tabWidth = 2 2 | semi = false 3 | singleQuote = true 4 | -------------------------------------------------------------------------------- /docs/_static/thumbnails/create.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carbonplan/ndpyramid/HEAD/docs/_static/thumbnails/create.jpg -------------------------------------------------------------------------------- /docs/_static/thumbnails/geotiff.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carbonplan/ndpyramid/HEAD/docs/_static/thumbnails/geotiff.jpg -------------------------------------------------------------------------------- /docs/_static/thumbnails/regrid.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carbonplan/ndpyramid/HEAD/docs/_static/thumbnails/regrid.jpg -------------------------------------------------------------------------------- /docs/_static/thumbnails/resample.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carbonplan/ndpyramid/HEAD/docs/_static/thumbnails/resample.jpg -------------------------------------------------------------------------------- /docs/_static/monogram-dark-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carbonplan/ndpyramid/HEAD/docs/_static/monogram-dark-cropped.png -------------------------------------------------------------------------------- /docs/_static/thumbnails/reproject.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carbonplan/ndpyramid/HEAD/docs/_static/thumbnails/reproject.jpg -------------------------------------------------------------------------------- /docs/_static/monogram-light-cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/carbonplan/ndpyramid/HEAD/docs/_static/monogram-light-cropped.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # SCM syntax highlighting & preventing 3-way merges 2 | pixi.lock merge=binary linguist-language=YAML linguist-generated=true 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | actions: 9 | patterns: 10 | - "*" 11 | -------------------------------------------------------------------------------- /ndpyramid/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | 3 | from .create import pyramid_create 4 | from .coarsen import pyramid_coarsen 5 | from .reproject import pyramid_reproject 6 | from .regrid import pyramid_regrid 7 | from .resample import pyramid_resample 8 | from ._version import __version__ 9 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | api 2 | === 3 | 4 | .. currentmodule:: ndpyramid 5 | 6 | 7 | Top level API 8 | ~~~~~~~~~~~~~ 9 | 10 | .. autosummary:: 11 | :toctree: generated/ 12 | 13 | pyramid_coarsen 14 | pyramid_create 15 | pyramid_reproject 16 | pyramid_regrid 17 | pyramid_resample 18 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: false 3 | max_report_age: off 4 | 5 | comment: false 6 | 7 | ignore: 8 | - "tests/*.py" 9 | - "setup.py" 10 | 11 | coverage: 12 | precision: 2 13 | round: down 14 | status: 15 | project: 16 | default: 17 | target: 95 18 | informational: true 19 | patch: false 20 | changes: false 21 | -------------------------------------------------------------------------------- /docs/gallery.rst: -------------------------------------------------------------------------------- 1 | Examples 2 | ======== 3 | 4 | Here's a list of examples on how to use ndpyramid. 5 | 6 | 7 | Notebook Examples 8 | ----------------- 9 | 10 | .. include:: notebooks-examples-gallery.txt 11 | 12 | 13 | .. toctree:: 14 | :maxdepth: 1 15 | :hidden: 16 | 17 | examples/geotiff 18 | examples/pyramid-resample 19 | examples/pyramid-reproject 20 | examples/pyramid-regrid 21 | examples/pyramid-create 22 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. _Intro: 2 | 3 | ndpyramid 4 | --------- 5 | 6 | A small utility for generating ND array pyramids using Xarray and Zarr. 7 | 8 | .. toctree:: 9 | :maxdepth: 1 10 | :hidden: 11 | :caption: Getting Started 12 | 13 | Installation 14 | 15 | .. toctree:: 16 | :maxdepth: 1 17 | :hidden: 18 | :caption: Usage 19 | 20 | Examples 21 | 22 | 23 | .. toctree:: 24 | :maxdepth: 2 25 | :hidden: 26 | :caption: Reference 27 | 28 | API 29 | schema 30 | -------------------------------------------------------------------------------- /docs/gallery.yml: -------------------------------------------------------------------------------- 1 | notebooks-examples: 2 | - title: Pyramids from GeoTiff 3 | path: examples/geotiff.html 4 | thumbnail: _static/thumbnails/geotiff.jpg 5 | - title: Pyramids from Zarr via pyresample 6 | path: examples/pyramid-resample.html 7 | thumbnail: _static/thumbnails/resample.jpg 8 | - title: Pyramids from Zarr 9 | path: examples/pyramid-reproject.html 10 | thumbnail: _static/thumbnails/reproject.jpg 11 | - title: Pyramids from Zarr via xesmf 12 | path: examples/pyramid-regrid.html 13 | thumbnail: _static/thumbnails/regrid.jpg 14 | - title: Pyramids via custom resampling methods 15 | path: examples/pyramid-create.html 16 | thumbnail: _static/thumbnails/create.jpg 17 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the version of Python and other tools you might need 8 | build: 9 | os: ubuntu-24.04 10 | tools: 11 | python: "3.13" 12 | jobs: 13 | create_environment: 14 | - asdf plugin add pixi 15 | - asdf install pixi latest 16 | - asdf global pixi latest 17 | install: 18 | - pixi install -e docs 19 | build: 20 | html: 21 | - pixi run -e docs sphinx-build -T -b html docs $READTHEDOCS_OUTPUT/html 22 | 23 | # Build documentation in the doc/ directory with Sphinx 24 | sphinx: 25 | configuration: docs/conf.py 26 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import ndpyramid 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "datasets,type,method,version,args,kwargs", [(None, "", "", "", None, None)] 8 | ) 9 | def test_multiscales_template(datasets, type, method, version, args, kwargs): 10 | template = ndpyramid.utils.multiscales_template( 11 | datasets=datasets, type=type, method=method, version=version, args=args, kwargs=kwargs 12 | )[0] 13 | if not kwargs: 14 | assert template["metadata"]["kwargs"] == {} 15 | if not datasets: 16 | assert template["datasets"] == [] 17 | if not args: 18 | assert template["metadata"]["args"] == [] 19 | assert template["type"] == type 20 | assert template["metadata"]["method"] == method 21 | assert template["metadata"]["version"] == version 22 | -------------------------------------------------------------------------------- /.github/workflows/codspeed.yml: -------------------------------------------------------------------------------- 1 | name: codspeed-benchmarks 2 | 3 | on: 4 | # Run on pushes to the main branch 5 | push: 6 | branches: 7 | - "main" 8 | # Run on pull requests 9 | pull_request: 10 | types: [labeled] 11 | 12 | jobs: 13 | benchmarks: 14 | if: ${{ github.event.label.name == 'benchmark' }} 15 | runs-on: ubuntu-latest 16 | defaults: 17 | run: 18 | shell: bash -el {0} 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v6 22 | 23 | - uses: prefix-dev/setup-pixi@v0.9.3 24 | with: 25 | pixi-version: v0.43.3 26 | cache: false # no pixi.lock 27 | locked: false 28 | 29 | - name: Run benchmarks 30 | uses: CodSpeedHQ/action@v4.4.1 31 | with: 32 | run: | 33 | pixi run -e py313 -m pytest --codspeed 34 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: quarterly 3 | autofix_prs: false 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v5.0.0 8 | hooks: 9 | - id: trailing-whitespace 10 | - id: end-of-file-fixer 11 | - id: check-json 12 | - id: check-yaml 13 | - id: mixed-line-ending 14 | 15 | - repo: https://github.com/astral-sh/ruff-pre-commit 16 | rev: "v0.12.2" 17 | hooks: 18 | - id: ruff 19 | args: ["--fix"] 20 | 21 | - repo: https://github.com/pre-commit/mirrors-prettier 22 | rev: v4.0.0-alpha.8 23 | hooks: 24 | - id: prettier 25 | 26 | - repo: https://github.com/pre-commit/mirrors-mypy 27 | rev: v1.16.1 28 | hooks: 29 | - id: mypy 30 | additional_dependencies: [ 31 | # Type stubs 32 | types-PyYAML, 33 | ] 34 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Ndpyramid can be installed in three ways: 4 | 5 | Using the [conda](https://conda.io) package manager that comes with the Anaconda/Miniconda distribution: 6 | 7 | ```shell 8 | conda install ndpyramid --channel conda-forge 9 | ``` 10 | 11 | Using the [pip](https://pypi.org/project/pip/) package manager: 12 | 13 | ```shell 14 | python -m pip install ndpyramid 15 | ``` 16 | 17 | To install a development version from source: 18 | 19 | ```python 20 | git clone https://github.com/carbonplan/ndpyramid 21 | cd ndpyramid 22 | python -m pip install -e . 23 | ``` 24 | 25 | ## Optional dependencies 26 | 27 | Depending on your use case you can specify optional dependencies on install. 28 | 29 | ``` 30 | python -m pip install "ndpyramid[xesmf]" # Install optional dependencies for regridding with ESMF 31 | python -m pip install "ndpyramid[dask]" # Install optional dependencies for resampling with pyresample and Dask 32 | python -m pip install "ndpyramid[complete]" # Install all optional dependencies 33 | ``` 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 carbonplan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /ndpyramid/coarsen.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations # noqa: F401 2 | 3 | import xarray as xr 4 | 5 | from .create import pyramid_create 6 | 7 | 8 | def pyramid_coarsen( 9 | ds: xr.Dataset, *, factors: list[int], dims: list[str], **kwargs 10 | ) -> xr.DataTree: 11 | """Create a multiscale pyramid via coarsening of a dataset by given factors 12 | 13 | Parameters 14 | ---------- 15 | ds : xarray.Dataset 16 | The dataset to coarsen. 17 | factors : list[int] 18 | The factors to coarsen by. 19 | dims : list[str] 20 | The dimensions to coarsen. 21 | kwargs : dict 22 | Additional keyword arguments to pass to xarray.Dataset.coarsen. 23 | 24 | """ 25 | 26 | def coarsen(ds: xr.Dataset, factor: int, dims: list[str], **kwargs): 27 | # merge dictionary via union operator 28 | kwargs |= {d: factor for d in dims} 29 | return ds.coarsen(**kwargs).mean() # type: ignore 30 | 31 | return pyramid_create( 32 | ds, 33 | factors=factors, 34 | dims=dims, 35 | func=coarsen, 36 | method_label="pyramid_coarsen", 37 | type_label="reduce", 38 | **kwargs, 39 | ) 40 | -------------------------------------------------------------------------------- /ndpyramid/testing.py: -------------------------------------------------------------------------------- 1 | import mercantile 2 | import numpy as np 3 | import xarray as xr 4 | 5 | 6 | def _bounds(ds): 7 | left = ds.x[0] - (ds.x[1] - ds.x[0]) / 2 8 | right = ds.x[-1] + (ds.x[-1] - ds.x[-2]) / 2 9 | top = ds.y[0] - (ds.y[1] - ds.y[0]) / 2 10 | bottom = ds.y[-1] + (ds.y[-1] - ds.y[-2]) / 2 11 | return np.array([left.data, bottom.data, right.data, top.data]) 12 | 13 | 14 | def verify_xy_bounds(ds, zoom): 15 | """Verifies that the bounds of a chunk conforms to expectations for a WebMercatorQuad.""" 16 | tile = mercantile.tile(ds.x[0], ds.y[0], zoom) 17 | bbox = mercantile.xy_bounds(tile) 18 | expected = np.array([bbox.left, bbox.bottom, bbox.right, bbox.top]) 19 | actual = _bounds(ds) 20 | np.testing.assert_allclose(actual, expected) 21 | return ds 22 | 23 | 24 | def verify_bounds(pyramid): 25 | for level in pyramid: 26 | if pyramid[level].attrs["multiscales"][0]["datasets"][0]["crs"] == "EPSG:3857": 27 | xr.map_blocks( 28 | verify_xy_bounds, 29 | pyramid[level].ds, 30 | template=pyramid[level].ds, 31 | kwargs={"zoom": int(level)}, 32 | ).compute() 33 | else: 34 | raise ValueError("Tile boundary verification has only been implemented for EPSG:3857") 35 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | schedule: 12 | - cron: "0 0 * * *" # Daily “At 00:00” 13 | 14 | concurrency: 15 | group: ${{ github.workflow }}-${{ github.ref }} 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | test: 20 | if: github.repository == 'carbonplan/ndpyramid' 21 | name: ${{ matrix.environment }}-build 22 | runs-on: ubuntu-latest 23 | permissions: 24 | id-token: write # This is required for requesting OIDC token for codecov 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | environment: [py312, py313] 29 | timeout-minutes: 20 30 | defaults: 31 | run: 32 | shell: bash -l {0} 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v6 36 | 37 | - uses: prefix-dev/setup-pixi@v0.9.3 38 | with: 39 | pixi-version: v0.43.3 40 | cache: false # no pixi.lock 41 | locked: false 42 | 43 | - run: pixi info 44 | 45 | - name: Run tests 46 | run: | 47 | pixi run -e ${{ matrix.environment }} pytest 48 | 49 | - name: Upload coverage to Codecov 50 | uses: codecov/codecov-action@v5 51 | with: 52 | files: ./coverage.xml 53 | fail_ci_if_error: false 54 | use_oidc: true 55 | verbose: true 56 | name: codecov-unit-${{ matrix.environment }} 57 | -------------------------------------------------------------------------------- /tests/test_sparse_levels.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ndpyramid import pyramid_reproject, pyramid_resample 4 | 5 | 6 | @pytest.mark.parametrize("levels_arg", [[4], [2, 4], [1, 3, 5]]) 7 | def test_reproject_sparse_levels(temperature, levels_arg): 8 | ds = temperature.isel(time=slice(0, 1)) # keep small 9 | pyr = pyramid_reproject(ds, level_list=levels_arg) 10 | requested = sorted({int(level) for level in levels_arg}) 11 | # DataTree keys exclude root; ensure only requested levels present 12 | assert set(pyr.keys()) == {*map(str, requested)} 13 | meta_levels = [d["level"] for d in pyr.ds.attrs["multiscales"][0]["datasets"]] 14 | assert meta_levels == requested 15 | # ensure dataset dimensions match expected sizes (pixels_per_tile * 2**level) 16 | pxt = pyr.ds.attrs["multiscales"][0]["datasets"][0]["pixels_per_tile"] 17 | for level in requested: 18 | assert pyr[str(level)].ds.dims["x"] == pxt * 2**level 19 | assert pyr[str(level)].ds.dims["y"] == pxt * 2**level 20 | 21 | 22 | @pytest.mark.parametrize("levels_arg", [[3], [0, 2], [2, 3, 4]]) 23 | def test_resample_sparse_levels(temperature, levels_arg): 24 | ds = temperature.isel(time=slice(0, 1)) # small 25 | # rename coordinates to lon/lat expected by tests 26 | pyr = pyramid_resample(ds, x="lon", y="lat", level_list=levels_arg) 27 | requested = sorted({int(level) for level in levels_arg}) 28 | assert set(pyr.keys()) == {*map(str, requested)} 29 | meta_paths = [d["path"] for d in pyr.ds.attrs["multiscales"][0]["datasets"]] 30 | assert meta_paths == [str(level) for level in requested] 31 | -------------------------------------------------------------------------------- /ndpyramid/create.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations # noqa: F401 2 | 3 | from collections.abc import Callable 4 | 5 | import xarray as xr 6 | 7 | from .utils import get_version, multiscales_template 8 | 9 | 10 | def pyramid_create( 11 | ds: xr.Dataset, 12 | *, 13 | factors: list[int], 14 | dims: list[str], 15 | func: Callable, 16 | type_label: str = "reduce", 17 | method_label: str | None = None, 18 | **kwargs, 19 | ): 20 | """Create a multiscale pyramid via a given function applied to a dataset. 21 | The generalized version of pyramid_coarsen. 22 | 23 | Parameters 24 | ---------- 25 | ds : xarray.Dataset 26 | The dataset to apply the function to. 27 | factors : list[int] 28 | The factors to coarsen by. 29 | dims : list[str] 30 | The dimensions to coarsen. 31 | func : Callable 32 | The function to apply to the dataset; must accept the 33 | `ds`, `factor`, and `dims` as positional arguments. 34 | type_label : str, optional 35 | The type label to use as metadata for the multiscales spec. 36 | The default is 'reduce'. 37 | method_label : str, optional 38 | The method label to use as metadata for the multiscales spec. 39 | The default is the name of the function. 40 | kwargs : dict 41 | Additional keyword arguments to pass to the func. 42 | 43 | """ 44 | # multiscales spec 45 | save_kwargs = locals() 46 | del save_kwargs["ds"] 47 | del save_kwargs["func"] 48 | del save_kwargs["type_label"] 49 | del save_kwargs["method_label"] 50 | 51 | attrs = { 52 | "multiscales": multiscales_template( 53 | datasets=[{"path": str(i)} for i in range(len(factors))], 54 | type=type_label, 55 | method=method_label or func.__name__, 56 | version=get_version(), 57 | kwargs=save_kwargs, 58 | ) 59 | } 60 | 61 | plevels = {str(key): func(ds, factor, dims, **kwargs) for key, factor in enumerate(factors)} 62 | plevels["/"] = xr.Dataset(attrs=attrs) 63 | return xr.DataTree.from_dict(plevels) 64 | -------------------------------------------------------------------------------- /ndpyramid/common.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import pydantic 4 | import pyproj 5 | from affine import Affine 6 | 7 | ProjectionOptions = typing.Literal["web-mercator", "equidistant-cylindrical"] 8 | 9 | 10 | class Projection(pydantic.BaseModel): 11 | name: ProjectionOptions = "web-mercator" 12 | _crs: str = pydantic.PrivateAttr() 13 | _proj = pydantic.PrivateAttr() 14 | 15 | def __init__(self, **data) -> None: 16 | super().__init__(**data) 17 | epsg_codes = {"web-mercator": "EPSG:3857", "equidistant-cylindrical": "EPSG:4326"} 18 | # Area extent for pyresample's `create_area_def` is (lower_left_x, lower_left_y, upper_right_x, upper_right_y) 19 | area_extents = { 20 | "web-mercator": ( 21 | -20037508.342789244, 22 | -20037508.342789248, 23 | 20037508.342789244, 24 | 20037508.342789248, 25 | ), 26 | "equidistant-cylindrical": (-180, 90, 180, -90), 27 | } 28 | self._crs = epsg_codes[self.name] 29 | self._proj = pyproj.Proj(self._crs) 30 | self._area_extent = area_extents[self.name] 31 | 32 | def transform(self, *, dim: int) -> Affine: 33 | if self.name == "web-mercator": 34 | # set up the transformation matrix for the web-mercator projection such that the data conform 35 | # to the slippy-map tiles assumed boundaries. See https://github.com/carbonplan/ndpyramid/pull/70 36 | # for detailed on calculating the parameters. 37 | return Affine.translation(-20037508.342789244, 20037508.342789248) * Affine.scale( 38 | (20037508.342789244 * 2) / dim, -(20037508.342789248 * 2) / dim 39 | ) 40 | elif self.name == "equidistant-cylindrical": 41 | # set up the transformation matrix that maps between the Equidistant Cylindrical projection 42 | # and the latitude-longitude projection. The Affine.translation function moves the origin 43 | # of the grid from (0, 0) to (-180, 90) in latitude-longitude coordinates, 44 | # and the Affine.scale function scales the grid coordinates to match the size of the grid 45 | # in latitude-longitude coordinates. The resulting transformation matrix maps grid coordinates to 46 | # latitude-longitude coordinates. 47 | return Affine.translation(-180, 90) * Affine.scale(360 / dim, -180 / dim) 48 | else: 49 | raise ValueError(f"Unsupported projection name: {self.name}") 50 | -------------------------------------------------------------------------------- /.github/workflows/pypi-release.yaml: -------------------------------------------------------------------------------- 1 | name: Build distribution 2 | on: 3 | release: 4 | types: 5 | - published 6 | push: 7 | 8 | jobs: 9 | build-artifacts: 10 | runs-on: ubuntu-latest 11 | if: github.repository == 'carbonplan/ndpyramid' 12 | steps: 13 | - uses: actions/checkout@v6 14 | with: 15 | fetch-depth: 0 16 | - uses: actions/setup-python@v6 17 | name: Install Python 18 | with: 19 | python-version: "3.13" 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | python -m pip install setuptools setuptools-scm wheel twine check-manifest 25 | 26 | - name: Build tarball and wheels 27 | run: | 28 | git clean -xdf 29 | git restore -SW . 30 | python -m build --sdist --wheel . 31 | 32 | - name: Check built artifacts 33 | run: | 34 | python -m twine check dist/* 35 | pwd 36 | if [ -f dist/ndpyramid-unknown.tar.gz ]; then 37 | echo "❌ INVALID VERSION NUMBER" 38 | exit 1 39 | else 40 | echo "✅ Looks good" 41 | fi 42 | - uses: actions/upload-artifact@v5 43 | with: 44 | name: releases 45 | path: dist 46 | 47 | test-built-dist: 48 | needs: build-artifacts 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/setup-python@v6 52 | name: Install Python 53 | with: 54 | python-version: "3.13" 55 | - uses: actions/download-artifact@v6 56 | with: 57 | name: releases 58 | path: dist 59 | - name: List contents of built dist 60 | run: | 61 | ls -ltrh 62 | ls -ltrh dist 63 | 64 | - name: Verify the built dist/wheel is valid 65 | if: github.event_name == 'push' 66 | run: | 67 | python -m pip install --upgrade pip 68 | python -m pip install dist/ndpyramid*.whl 69 | python -c "import ndpyramid; print(ndpyramid.__version__)" 70 | 71 | upload-to-pypi: 72 | needs: test-built-dist 73 | if: github.event_name == 'release' 74 | runs-on: ubuntu-latest 75 | steps: 76 | - uses: actions/download-artifact@v6 77 | with: 78 | name: releases 79 | path: dist 80 | - name: Publish package to PyPI 81 | uses: pypa/gh-action-pypi-publish@v1.13.0 82 | with: 83 | user: __token__ 84 | password: ${{ secrets.PYPI_TOKEN }} 85 | verbose: true 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | ndpyramid/_version.py 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | docs/generated/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | 133 | docs/generated/ 134 | docs/notebooks-examples-gallery.txt 135 | # pixi environments 136 | .pixi/* 137 | !.pixi/config.toml 138 | pixi.lock 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 | CarbonPlan monogram. 6 | 7 | 8 |

9 | 10 | # ndpyramid 11 | 12 | A small utility for generating ND array pyramids using Xarray and Zarr. 13 | 14 | [![CI](https://github.com/carbonplan/ndpyramid/actions/workflows/main.yaml/badge.svg)](https://github.com/carbonplan/ndpyramid/actions/workflows/main.yaml) 15 | ![PyPI](https://img.shields.io/pypi/v/ndpyramid) 16 | [![Conda Version](https://img.shields.io/conda/vn/conda-forge/ndpyramid.svg)](https://anaconda.org/conda-forge/ndpyramid) 17 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 18 | [![Code coverage](https://codecov.io/gh/carbonplan/ndpyramid/branch/main/graph/badge.svg)](https://codecov.io/gh/carbonplan/ndpyramid) 19 | 20 | # installation 21 | 22 | Ndpyramid can be installed in three ways: 23 | 24 | Using the [conda](https://conda.io) package manager that comes with the Anaconda/Miniconda distribution: 25 | 26 | ```shell 27 | conda install ndpyramid --channel conda-forge 28 | ``` 29 | 30 | Using the [pip](https://pypi.org/project/pip/) package manager: 31 | 32 | ```shell 33 | python -m pip install ndpyramid 34 | ``` 35 | 36 | To install a development version from source: 37 | 38 | ```python 39 | git clone https://github.com/carbonplan/ndpyramid 40 | cd ndpyramid 41 | pixi install 42 | # or 43 | python -m pip install -e . 44 | ``` 45 | 46 | ## optional dependencies 47 | 48 | Depending on your use case you can specify optional dependencies on install. 49 | 50 | ``` 51 | python -m pip install "ndpyramid[xesmf]" # Install optional dependencies for regridding with ESMF 52 | python -m pip install "ndpyramid[dask]" # Install optional dependencies for resampling with pyresample and Dask 53 | python -m pip install "ndpyramid[complete]" # Install all optional dependencies 54 | ``` 55 | 56 | # usage 57 | 58 | Ndpyramid provides a set of utilities for creating pyramids with standardized metadata. 59 | The example below demonstrates the usage of the `pyramid_coarsen` and `pyramid_reproject` 60 | utilities. Check out [the examples gallery](https://ndpyramid.readthedocs.io/en/latest/gallery.html) 61 | for more complete demonstrations. 62 | 63 | ```python 64 | import xarray as xr 65 | import rioxarray 66 | from ndpyramid import pyramid_coarsen, pyramid_reproject 67 | 68 | # make a reprojected (EPSG:3857) pyramid 69 | from odc.geo.xr import assign_crs 70 | ds = assign_crs(ds, 'EPSG:4326') 71 | pyramid = pyramid_reproject(ds, levels=2) 72 | 73 | # write the pyramid to zarr 74 | pyramid.to_zarr('./path/to/write', zarr_format=2, consolidated=True, mode="w") 75 | ``` 76 | 77 | See the docstrings and [API documentation](https://ndpyramid.readthedocs.io/en/latest/api.html) for more details about input parameters and options. 78 | 79 | ## license 80 | 81 | All the code in this repository is [MIT](https://choosealicense.com/licenses/mit/)-licensed, but we request that you please provide attribution if reusing any of our digital content (graphics, logo, articles, etc.). 82 | 83 | ## about us 84 | 85 | CarbonPlan is a nonprofit organization that uses data and science for climate action. We aim to improve the transparency and scientific integrity of climate solutions with open data and tools. Find out more at [carbonplan.org](https://carbonplan.org/) or get in touch by [opening an issue](https://github.com/carbonplan/ndpyramid/issues/new) or [sending us an email](mailto:hello@carbonplan.org). 86 | -------------------------------------------------------------------------------- /tests/test_pyramid_regrid.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from zarr.storage import MemoryStore 4 | 5 | from ndpyramid import pyramid_regrid 6 | from ndpyramid.regrid import generate_weights_pyramid, make_grid_ds 7 | from ndpyramid.testing import verify_bounds 8 | 9 | 10 | @pytest.mark.parametrize("regridder_apply_kws", [None, {"keep_attrs": False}]) 11 | def test_regridded_pyramid(temperature, regridder_apply_kws, benchmark): 12 | pytest.importorskip("xesmf") 13 | # Select subset to speed up tests 14 | temperature = temperature.isel(time=slice(0, 5)) 15 | pyramid = benchmark( 16 | lambda: pyramid_regrid( 17 | temperature, 18 | levels=2, 19 | parallel_weights=False, 20 | regridder_apply_kws=regridder_apply_kws, 21 | other_chunks={"time": 2}, 22 | ) 23 | ) 24 | verify_bounds(pyramid) 25 | assert pyramid.ds.attrs["multiscales"] 26 | assert pyramid.attrs["multiscales"][0]["datasets"][0]["crs"] == "EPSG:3857" 27 | assert pyramid["0"].attrs["multiscales"][0]["datasets"][0]["crs"] == "EPSG:3857" 28 | expected_attrs = ( 29 | temperature["air"].attrs 30 | if not regridder_apply_kws or regridder_apply_kws.get("keep_attrs") 31 | else {} 32 | ) 33 | assert pyramid["0"].ds.air.attrs == expected_attrs 34 | assert pyramid["1"].ds.air.attrs == expected_attrs 35 | pyramid.to_zarr(MemoryStore(), zarr_format=2) 36 | 37 | 38 | def test_regridded_pyramid_with_weights(temperature, benchmark): 39 | pytest.importorskip("xesmf") 40 | levels = 2 41 | # Select subset to speed up tests 42 | temperature = temperature.isel(time=slice(0, 5)) 43 | weights_pyramid = generate_weights_pyramid(temperature.isel(time=0), levels) 44 | pyramid = benchmark( 45 | lambda: pyramid_regrid( 46 | temperature, levels=levels, weights_pyramid=weights_pyramid, other_chunks={"time": 2} 47 | ) 48 | ) 49 | verify_bounds(pyramid) 50 | assert pyramid.ds.attrs["multiscales"] 51 | assert len(pyramid.ds.attrs["multiscales"][0]["datasets"]) == levels 52 | pyramid.to_zarr(MemoryStore(), zarr_format=2) 53 | 54 | 55 | @pytest.mark.parametrize("projection", ["web-mercator", "equidistant-cylindrical"]) 56 | def test_make_grid_ds(projection, benchmark): 57 | grid = benchmark(lambda: make_grid_ds(0, pixels_per_tile=8, projection=projection)) 58 | lon_vals = grid.lon_b.values 59 | assert np.all((lon_vals[-1, :] - lon_vals[0, :]) < 0.001) 60 | assert ( 61 | grid.attrs["title"] == "Web Mercator Grid" 62 | if projection == "web-mercator" 63 | else "Equidistant Cylindrical Grid" 64 | ) 65 | 66 | 67 | @pytest.mark.parametrize("levels", [1, 2]) 68 | @pytest.mark.parametrize("method", ["bilinear", "conservative"]) 69 | def test_generate_weights_pyramid(temperature, levels, method, benchmark): 70 | pytest.importorskip("xesmf") 71 | weights_pyramid = benchmark( 72 | lambda: generate_weights_pyramid(temperature.isel(time=0), levels, method=method) 73 | ) 74 | assert weights_pyramid.ds.attrs["levels"] == levels 75 | assert weights_pyramid.ds.attrs["regrid_method"] == method 76 | assert set(weights_pyramid["0"].ds.data_vars) == {"S", "col", "row"} 77 | assert "n_in" in weights_pyramid["0"].ds.attrs and "n_out" in weights_pyramid["0"].ds.attrs 78 | 79 | 80 | def test_regridded_pyramid_sparse_levels(temperature): 81 | pytest.importorskip("xesmf") 82 | ds = temperature.isel(time=slice(0, 3)) 83 | sparse_levels = [1, 3] 84 | pyramid = pyramid_regrid(ds, level_list=sparse_levels, parallel_weights=False) 85 | # DataTree keys exclude root 86 | assert set(pyramid.keys()) == {"1", "3"} 87 | datasets_meta = pyramid.ds.attrs["multiscales"][0]["datasets"] 88 | assert [d["level"] for d in datasets_meta] == sparse_levels 89 | # verify dimension sizes scale as expected 90 | pixels_per_tile = datasets_meta[0]["pixels_per_tile"] 91 | for lvl in sparse_levels: 92 | assert pyramid[str(lvl)].ds.dims["x"] == pixels_per_tile * 2**lvl 93 | assert pyramid[str(lvl)].ds.dims["y"] == pixels_per_tile * 2**lvl 94 | -------------------------------------------------------------------------------- /tests/test_pyramids.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | from zarr.storage import MemoryStore 4 | 5 | from ndpyramid import pyramid_coarsen, pyramid_create, pyramid_reproject 6 | from ndpyramid.testing import verify_bounds 7 | 8 | 9 | def test_xarray_coarsened_pyramid(temperature, benchmark): 10 | factors = [4, 2, 1] 11 | pyramid = benchmark( 12 | lambda: pyramid_coarsen(temperature, dims=("lat", "lon"), factors=factors, boundary="trim") 13 | ) 14 | assert pyramid.ds.attrs["multiscales"] 15 | assert len(pyramid.ds.attrs["multiscales"][0]["datasets"]) == len(factors) 16 | assert pyramid.ds.attrs["multiscales"][0]["metadata"]["method"] == "pyramid_coarsen" 17 | assert pyramid.ds.attrs["multiscales"][0]["type"] == "reduce" 18 | pyramid.to_zarr(MemoryStore(), zarr_format=2) 19 | 20 | 21 | @pytest.mark.parametrize("method_label", [None, "sel_coarsen"]) 22 | def test_xarray_custom_coarsened_pyramid(temperature, benchmark, method_label): 23 | def sel_coarsen(ds, factor, dims, **kwargs): 24 | return ds.sel(**{dim: slice(None, None, factor) for dim in dims}) 25 | 26 | factors = [4, 2, 1] 27 | pyramid = benchmark( 28 | lambda: pyramid_create( 29 | temperature, 30 | dims=("lat", "lon"), 31 | factors=factors, 32 | boundary="trim", 33 | func=sel_coarsen, 34 | method_label=method_label, 35 | type_label="pick", 36 | ) 37 | ) 38 | assert pyramid.ds.attrs["multiscales"] 39 | assert len(pyramid.ds.attrs["multiscales"][0]["datasets"]) == len(factors) 40 | assert pyramid.ds.attrs["multiscales"][0]["metadata"]["method"] == "sel_coarsen" 41 | assert pyramid.ds.attrs["multiscales"][0]["type"] == "pick" 42 | pyramid.to_zarr(MemoryStore(), zarr_format=2) 43 | 44 | 45 | def test_reprojected_pyramid(temperature, benchmark): 46 | levels = 2 47 | pyramid = benchmark(lambda: pyramid_reproject(temperature, levels=levels)) 48 | verify_bounds(pyramid) 49 | assert pyramid.ds.attrs["multiscales"] 50 | assert len(pyramid.ds.attrs["multiscales"][0]["datasets"]) == levels 51 | assert pyramid.attrs["multiscales"][0]["datasets"][0]["crs"] == "EPSG:3857" 52 | assert pyramid["0"].attrs["multiscales"][0]["datasets"][0]["crs"] == "EPSG:3857" 53 | pyramid.to_zarr(MemoryStore(), zarr_format=2) 54 | 55 | 56 | def test_reprojected_pyramid_resampling_dict(dataset_3d, benchmark): 57 | levels = 2 58 | pyramid = benchmark( 59 | lambda: pyramid_reproject( 60 | dataset_3d.compute(), levels=levels, resampling={"ones": "bilinear", "rand": "nearest"} 61 | ) 62 | ) 63 | verify_bounds(pyramid) 64 | assert pyramid.attrs["multiscales"][0]["metadata"]["kwargs"]["resampling"] == { 65 | "ones": "bilinear", 66 | "rand": "nearest", 67 | } 68 | pyramid.to_zarr(MemoryStore(), zarr_format=2) 69 | 70 | 71 | def test_reprojected_pyramid_clear_attrs(dataset_3d, benchmark): 72 | levels = 2 73 | # Needs to call .compute() to avoid shapely.errors.GEOSException: TopologyException: 74 | # https://github.com/opendatacube/odc-geo/issues/147 75 | pyramid = benchmark( 76 | lambda: pyramid_reproject(dataset_3d.compute(), levels=levels, clear_attrs=True) 77 | ) 78 | verify_bounds(pyramid) 79 | for _, da in pyramid["0"].ds.items(): 80 | assert not da.attrs 81 | pyramid.to_zarr(MemoryStore(), zarr_format=2) 82 | 83 | 84 | def test_reprojected_pyramid_4d(dataset_4d, benchmark): 85 | levels = 2 86 | pyramid = benchmark(lambda: pyramid_reproject(dataset_4d, levels=levels, extra_dim="band")) 87 | verify_bounds(pyramid) 88 | assert pyramid.ds.attrs["multiscales"] 89 | assert len(pyramid.ds.attrs["multiscales"][0]["datasets"]) == levels 90 | assert pyramid.attrs["multiscales"][0]["datasets"][0]["crs"] == "EPSG:3857" 91 | assert pyramid["0"].attrs["multiscales"][0]["datasets"][0]["crs"] == "EPSG:3857" 92 | pyramid.to_zarr(MemoryStore(), zarr_format=2) 93 | 94 | 95 | def test_reprojected_pyramid_fill(temperature, benchmark): 96 | """Test for https://github.com/carbonplan/ndpyramid/issues/93.""" 97 | pyramid = benchmark(lambda: pyramid_reproject(temperature, levels=1)) 98 | assert np.isnan(pyramid["0"].air.isel(time=0, x=0, y=0).values) 99 | -------------------------------------------------------------------------------- /tests/test_pyramid_resample.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | import xarray as xr 4 | from zarr.storage import MemoryStore 5 | 6 | from ndpyramid import pyramid_reproject, pyramid_resample 7 | from ndpyramid.testing import verify_bounds 8 | 9 | 10 | @pytest.mark.parametrize("resampling", ["bilinear", "nearest"]) 11 | def test_resampled_pyramid(temperature, benchmark, resampling): 12 | levels = 2 13 | pyramid = benchmark( 14 | lambda: pyramid_resample( 15 | temperature, levels=levels, x="lon", y="lat", resampling=resampling 16 | ) 17 | ) 18 | verify_bounds(pyramid) 19 | assert pyramid.ds.attrs["multiscales"] 20 | assert len(pyramid.ds.attrs["multiscales"][0]["datasets"]) == levels 21 | assert pyramid.attrs["multiscales"][0]["datasets"][0]["crs"] == "EPSG:3857" 22 | assert pyramid["0"].attrs["multiscales"][0]["datasets"][0]["crs"] == "EPSG:3857" 23 | pyramid.to_zarr(MemoryStore(), zarr_format=2) 24 | 25 | 26 | @pytest.mark.xfail(reason="Need to fix resampling of 2D data (tied to other_chunks issue)") 27 | @pytest.mark.parametrize("method", ["bilinear", "nearest", {"air": "nearest"}]) 28 | def test_resampled_pyramid_2D(temperature, method, benchmark): 29 | levels = 2 30 | temperature = temperature.isel(time=0).drop_vars("time") 31 | pyramid = benchmark( 32 | lambda: pyramid_resample(temperature, levels=levels, x="lon", y="lat", resampling=method) 33 | ) 34 | verify_bounds(pyramid) 35 | assert pyramid.ds.attrs["multiscales"] 36 | assert len(pyramid.ds.attrs["multiscales"][0]["datasets"]) == levels 37 | assert pyramid.attrs["multiscales"][0]["datasets"][0]["crs"] == "EPSG:3857" 38 | assert pyramid["0"].attrs["multiscales"][0]["datasets"][0]["crs"] == "EPSG:3857" 39 | pyramid.to_zarr(MemoryStore(), zarr_format=2) 40 | 41 | 42 | def test_reprojected_pyramid_clear_attrs(dataset_3d, benchmark): 43 | levels = 2 44 | pyramid = benchmark( 45 | lambda: pyramid_resample(dataset_3d, levels=levels, x="x", y="y", clear_attrs=True) 46 | ) 47 | verify_bounds(pyramid) 48 | for _, da in pyramid["0"].ds.items(): 49 | assert not da.attrs 50 | pyramid.to_zarr(MemoryStore(), zarr_format=2) 51 | 52 | 53 | @pytest.mark.xfail(reason="Need to fix handling of other_chunks") 54 | def test_reprojected_pyramid_other_chunks(dataset_3d, benchmark): 55 | levels = 2 56 | pyramid = benchmark( 57 | lambda: pyramid_resample(dataset_3d, levels=levels, x="x", y="y", other_chunks={"time": 5}) 58 | ) 59 | verify_bounds(pyramid) 60 | pyramid.to_zarr(MemoryStore(), zarr_format=2) 61 | 62 | 63 | def test_resampled_pyramid_without_CF(dataset_3d, benchmark): 64 | levels = 2 65 | pyramid = benchmark(lambda: pyramid_resample(dataset_3d, levels=levels, x="x", y="y")) 66 | verify_bounds(pyramid) 67 | assert pyramid.ds.attrs["multiscales"] 68 | assert len(pyramid.ds.attrs["multiscales"][0]["datasets"]) == levels 69 | assert pyramid.attrs["multiscales"][0]["datasets"][0]["crs"] == "EPSG:3857" 70 | assert pyramid["0"].attrs["multiscales"][0]["datasets"][0]["crs"] == "EPSG:3857" 71 | pyramid.to_zarr(MemoryStore(), zarr_format=2) 72 | 73 | 74 | def test_resampled_pyramid_fill(temperature, benchmark): 75 | """Test for https://github.com/carbonplan/ndpyramid/issues/93.""" 76 | 77 | pyramid = benchmark(lambda: pyramid_resample(temperature, levels=1, x="lon", y="lat")) 78 | assert np.isnan(pyramid["0"].air.isel(time=0, x=0, y=0).values) 79 | 80 | 81 | @pytest.mark.parametrize( 82 | "method", 83 | [ 84 | pytest.param( 85 | "bilinear", 86 | marks=pytest.mark.xfail(reason="Need to investigate differences for bilinear"), 87 | ), 88 | "nearest", 89 | ], 90 | ) 91 | def test_reprojected_resample_pyramid_values(dataset_3d, method, benchmark): 92 | levels = 2 93 | # Needs to call .compute() before passing to odc-geo to avoid https://github.com/opendatacube/odc-geo/issues/147 94 | reprojected = pyramid_reproject(dataset_3d.compute(), levels=levels, resampling=method) 95 | resampled = pyramid_resample(dataset_3d, levels=levels, x="x", y="y", resampling=method) 96 | xr.testing.assert_allclose(reprojected["0"].ds, resampled["0"].ds) 97 | xr.testing.assert_allclose(reprojected["1"].ds, resampled["1"].ds) 98 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | import datetime 10 | import pathlib 11 | import sys 12 | from textwrap import dedent, indent 13 | 14 | import yaml 15 | from sphinx.application import Sphinx 16 | from sphinx.util import logging 17 | 18 | import ndpyramid 19 | 20 | LOGGER = logging.getLogger("conf") 21 | 22 | # If extensions (or modules to document with autodoc) are in another directory, 23 | # add these directories to sys.path here. If the directory is relative to the 24 | # documentation root, use os.path.abspath to make it absolute, like shown here. 25 | # sys.path.insert(0, os.path.abspath('.')) 26 | # sys.path.insert(os.path.abspath('..')) 27 | 28 | print("python exec:", sys.executable) 29 | print("sys.path:", sys.path) 30 | 31 | 32 | project = "ndpyramid" 33 | this_year = datetime.datetime.now().year 34 | copyright = f"{this_year}, carbonplan" 35 | author = "carbonplan" 36 | 37 | release = ndpyramid.__version__ 38 | 39 | # -- General configuration --------------------------------------------------- 40 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 41 | 42 | extensions = [ 43 | "sphinx.ext.autodoc", 44 | "sphinx.ext.viewcode", 45 | "sphinx.ext.autosummary", 46 | "sphinx.ext.doctest", 47 | "sphinx.ext.intersphinx", 48 | "sphinx.ext.extlinks", 49 | "sphinx.ext.intersphinx", 50 | "sphinx.ext.napoleon", 51 | "myst_nb", 52 | "sphinxext.opengraph", 53 | "sphinx_copybutton", 54 | "sphinx_design", 55 | ] 56 | 57 | # MyST config 58 | myst_enable_extensions = ["amsmath", "colon_fence", "deflist", "html_image"] 59 | myst_url_schemes = ["http", "https", "mailto"] 60 | 61 | # sphinx-copybutton configurations 62 | copybutton_prompt_text = r">>> |\.\.\. |\$ |In \[\d*\]: | {2,5}\.\.\.: | {5,8}: " 63 | copybutton_prompt_is_regexp = True 64 | 65 | autosummary_generate = True 66 | 67 | nb_execution_mode = "off" 68 | nb_execution_timeout = 600 69 | nb_execution_raise_on_error = False 70 | 71 | 72 | templates_path = ["_templates"] 73 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 74 | # Sphinx project configuration 75 | source_suffix = [".rst", ".md"] 76 | 77 | 78 | # -- Options for HTML output ------------------------------------------------- 79 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 80 | 81 | 82 | html_title = "ndpyarmid" 83 | html_theme_options = { 84 | "logo": { 85 | "image_light": "_static/monogram-dark-cropped.png", 86 | "image_dark": "_static/monogram-light-cropped.png", 87 | } 88 | } 89 | html_theme = "sphinx_book_theme" 90 | html_title = "" 91 | repository = "carbonplan/ndpyarmid" 92 | repository_url = "https://github.com/carbonplan/ndpyramid" 93 | 94 | html_static_path = ["_static"] 95 | 96 | 97 | def update_gallery(app: Sphinx): 98 | """Update the gallery page. 99 | 100 | Copied from https://github.com/pydata/xarray/blob/56209bd9a3192e4f1e82c21e5ffcf4c3bacaaae3/doc/conf.py#L399-L430. 101 | """ 102 | LOGGER.info("Updating gallery page...") 103 | 104 | gallery = yaml.safe_load(pathlib.Path(app.srcdir, "gallery.yml").read_bytes()) 105 | 106 | for key in gallery: 107 | items = [ 108 | f""" 109 | .. grid-item-card:: 110 | :text-align: center 111 | :link: {item["path"]} 112 | 113 | .. image:: {item["thumbnail"]} 114 | :alt: {item["title"]} 115 | +++ 116 | {item["title"]} 117 | """ 118 | for item in gallery[key] 119 | ] 120 | 121 | items_md = indent(dedent("\n".join(items)), prefix=" ") 122 | markdown = f""" 123 | .. grid:: 1 2 2 2 124 | :gutter: 2 125 | 126 | {items_md} 127 | """ 128 | pathlib.Path(app.srcdir, f"{key}-gallery.txt").write_text(markdown) 129 | LOGGER.info(f"{key} gallery page updated.") 130 | LOGGER.info("Gallery page updated.") 131 | 132 | 133 | def setup(app: Sphinx): 134 | app.connect("builder-inited", update_gallery) 135 | -------------------------------------------------------------------------------- /docs/schema.md: -------------------------------------------------------------------------------- 1 | # Schema 2 | 3 | ## Background 4 | 5 | `ndpyramid` was created to generate pyramids for use with the [`@carbonplan/maps`](https://github.com/carbonplan/maps) toolkit. Check out blog posts about the [initial release](https://carbonplan.org/blog/maps-library-release) and [updates](https://carbonplan.org/blog/zarr-visualization-update) for more information about the toolkit's history. While our mapping toolkit remains the primary motivation for the library, the pyramids also have the potential to speed up other mapping approaches such as [dynamic tiling](https://nasa-impact.github.io/zarr-visualization-report/approaches/tiling/05-cmip6-pyramids.html). 6 | 7 | In order to provide highly performant rendering, the `pyramid_reproject` and `pyramid_regrid` methods generate pyramids according to the [map zoom level quadtree](https://docs.mapbox.com/help/glossary/zoom-level/#zoom-level-quadtrees) pattern. This structure, in which the number of tiles at a given zoom level corresponds to 2zoom and each zoom level covers the entire globe, is commonly referred to as ['web-optimized'](https://cogeotiff.github.io/rio-cogeo/Advanced/#web-optimized-cog) when levels are in the Web Mercator projection because it minimizes the number of GET requests required for rendering a tile map. 8 | 9 | In fact, earlier releases of the toolkit only generated and supported Web Mercator (EPSG:3857) pyramids, both to minimize GET requests and avoid reprojection on the client. Release `v3.0.0` of `@carbonplan/maps` and `v0.1.0` of `ndpyramid` added support for the Equidistant Cylindrical (EPSG:4326) projection for cases in which users want pyramids in the same projection as the original data, at the [expense of slower rendering times](https://nasa-impact.github.io/zarr-visualization-report/approaches/dynamic-client/e2e-results-projection.html). 10 | 11 | ## Pyramid schema 12 | 13 | While the [map zoom level quadtree structure](https://docs.mapbox.com/help/glossary/zoom-level/#zoom-level-quadtrees) has been used for many years, there was no convention for storing the quadtree pyramids in Xarray and Zarr when we started work on this toolkit (although parallel development occurred in the microscopy and other communities). Therefore, we created a pyramid and metadata schema for `ndpyramid`. The resulting Zarr store for a dataset with one `tavg` data variable would look like: 14 | 15 | ```{code} 16 | / 17 | ├── .zmetadata 18 | ├── 0 19 | │ ├── tavg 20 | │ └── 0.0 21 | ├── 1 22 | │ ├── tavg 23 | │ └── 0.0 24 | │ └── 0.1 25 | │ └── 1.0 26 | │ └── 1.1 27 | ├── 2 28 | ... 29 | ``` 30 | 31 | Note the quadrupling of the number of chunks as zoom level increases. This, combined with the global extent of individual levels and specific projection, allows inference of the placement of chunks on a web map based on the chunk index. 32 | 33 | Metadata about the pyramids is stored in the `multiscales` attribute of the Xarray DataTree or Zarr store: 34 | 35 | ```{code} 36 | { 37 | "multiscales": [ 38 | { 39 | "datasets": [ 40 | { 41 | "path": "0", 42 | "pixels_per_tile": 128, 43 | "crs": "EPSG:3857" 44 | }, 45 | { 46 | "path": "1", 47 | "pixels_per_tile": 128, 48 | "crs": "EPSG:3857" 49 | } 50 | ... 51 | ], 52 | "metadata": { 53 | "args": [], 54 | "method": "pyramid_reproject", 55 | "version": "0.0.post64" 56 | }, 57 | "type": "reduce" 58 | } 59 | ] 60 | } 61 | ``` 62 | 63 | Currently, `@carbonplan/maps` does not rely on the `"crs"` attribute, but future releases may determine the projection based on that attribute (assuming Web Mercator projection if it is not provided). 64 | 65 | In addition, the mapping toolkit relies on the `_ARRAY_DIMENSIONS` attribute introduced by Xarray, which stores the dimension names. 66 | 67 | ## Pyramids for @carbonplan/maps 68 | 69 | In addition to following the quadtree pyramid structure and metadata schema, the pyramids currently must also meet the following requirements for use with `@carbonplan/maps`: 70 | 71 | - Consistent chunk size across pyramid levels (128, 256, or 512 are recommended) 72 | - Storage of non-spatial coordinate arrays in single chunk 73 | - [zlib](https://numcodecs.readthedocs.io/en/stable/zlib.html) or [gzip](https://numcodecs.readthedocs.io/en/stable/gzip.html) compression 74 | - Web Mercator (EPSG:3857) or Equidistant Cylindrical (EPSG:4326) projection 75 | - The `.zattrs` must conform to the [IETF JSON Standard](https://datatracker.ietf.org/doc/html/rfc8259). 76 | - Data types supported by [zarr-js](https://github.com/freeman-lab/zarr-js). The following are supported as of `v3.3.0` for Zarr v2: 77 | 78 | ```{code} 79 | '=6.2", "setuptools>=64", "wheel"] 5 | 6 | [project] 7 | authors = [{ name = "CarbonPlan", email = "tech@carbonplan.org" }] 8 | classifiers = [ 9 | "Development Status :: 4 - Beta", 10 | "Intended Audience :: Science/Research", 11 | "License :: OSI Approved :: MIT License", 12 | "Operating System :: OS Independent", 13 | "Programming Language :: Python :: 3", 14 | "Programming Language :: Python :: 3.12", 15 | "Programming Language :: Python :: 3.13", 16 | "Programming Language :: Python", 17 | 18 | "Topic :: Scientific/Engineering", 19 | ] 20 | description = "A small utility for generating ND array pyramids using Xarray and Zarr" 21 | dynamic = ["version"] 22 | license = { text = "MIT" } 23 | name = "ndpyramid" 24 | readme = "README.md" 25 | requires-python = ">=3.12" 26 | 27 | dependencies = [ 28 | "cf-xarray>=0.10.7,<0.11", 29 | "dask>=2025.7.0,<2026", 30 | "odc-geo>=0.5.0rc1,<0.6", 31 | "pydantic>=2.11.7,<3", 32 | "pyproj>=3.7.2,<4", 33 | # odc-geo's xr_reproject requires rasterio at runtime (even though direct rioxarray usage was removed) 34 | "pyarrow>=21.0.0,<22", 35 | "rasterio>=1.3.10,<2", 36 | "xarray>=2025.8.0,<2026", 37 | "zarr>=3.1.2,<4", 38 | ] 39 | 40 | [project.optional-dependencies] 41 | complete = [ 42 | "cftime", 43 | "mercantile", 44 | "ndpyramid[dask,jupyter,xesmf]", 45 | "scipy", 46 | ] 47 | dask = ["dask", "pyresample"] 48 | jupyter = [ 49 | 'ipython', 50 | 'ipytree>=0.2.2', 51 | 'ipywidgets>=8.0.0', 52 | 'jupyterlab', 53 | 'matplotlib', 54 | ] 55 | xesmf = ["xesmf>=0.8"] 56 | 57 | test = [ 58 | "jupyterlab", 59 | "ndpyramid[complete]", 60 | "pre-commit", 61 | "pytest", 62 | "pytest-benchmark", 63 | "pytest-codspeed", 64 | "pytest-cov", 65 | "pytest-mypy", 66 | ] 67 | 68 | docs = [ 69 | "jupyterlab", 70 | "matplotlib", 71 | "myst-nb", 72 | "nbsphinx", 73 | "s3fs", 74 | "sphinx-book-theme", 75 | "sphinx-copybutton", 76 | "sphinx-design", 77 | "sphinxext-opengraph", 78 | ] 79 | 80 | [project.urls] 81 | repository = "https://github.com/carbonplan/ndpyramid" 82 | 83 | [tool.setuptools.packages.find] 84 | include = ["ndpyramid*"] 85 | 86 | [tool.setuptools_scm] 87 | fallback_version = "999" 88 | local_scheme = "node-and-date" 89 | version_scheme = "post-release" 90 | write_to = "ndpyramid/_version.py" 91 | write_to_template = '__version__ = "{version}"' 92 | 93 | # [tool.setuptools.dynamic] 94 | # version = { attr = "ndpyramid.__version__" } 95 | 96 | [tool.black] 97 | line-length = 100 98 | skip-string-normalization = true 99 | target-version = ['py312'] 100 | 101 | [tool.ruff] 102 | builtins = ["ellipsis"] 103 | extend-include = ["*.ipynb"] 104 | line-length = 100 105 | target-version = "py312" 106 | # Exclude a variety of commonly ignored directories. 107 | exclude = [ 108 | ".bzr", 109 | ".direnv", 110 | ".eggs", 111 | ".git", 112 | ".hg", 113 | ".mypy_cache", 114 | ".nox", 115 | ".pants.d", 116 | ".ruff_cache", 117 | ".svn", 118 | ".tox", 119 | ".venv", 120 | "__pypackages__", 121 | "_build", 122 | "buck-out", 123 | "build", 124 | "dist", 125 | "node_modules", 126 | "venv", 127 | ] 128 | 129 | [tool.ruff.lint] 130 | # E402: module level import not at top of file 131 | # E501: line too long - let black worry about that 132 | # E731: do not assign a lambda expression, use a def 133 | ignore = ["E402", "E501", "E731"] 134 | select = [ 135 | # Pyflakes 136 | "F", 137 | # Pycodestyle 138 | "E", 139 | "Q", 140 | "W", 141 | # isort 142 | "I", 143 | # Pyupgrade 144 | "UP", 145 | ] 146 | 147 | [tool.ruff.lint.flake8-quotes] 148 | docstring-quotes = "double" 149 | inline-quotes = "double" 150 | multiline-quotes = "double" 151 | 152 | [tool.ruff.lint.mccabe] 153 | max-complexity = 18 154 | 155 | [tool.ruff.lint.isort] 156 | known-first-party = ["ndpyramid"] 157 | 158 | # Notebook ruff config 159 | [tool.ruff.lint.per-file-ignores] 160 | "*.ipynb" = ["D100", "E402", "F401"] 161 | 162 | [tool.pytest.ini_options] 163 | addopts = "--cov=./ --cov-report=xml --verbose" 164 | console_output_style = "count" 165 | 166 | [tool.mypy] 167 | ignore_missing_imports = true 168 | no_implicit_optional = false 169 | 170 | [tool.pixi.workspace] 171 | channels = ["conda-forge"] 172 | platforms = ["linux-64", "linux-aarch64", "osx-64", "osx-arm64", "win-64"] 173 | 174 | [tool.pixi.pypi-dependencies] 175 | ndpyramid = { path = ".", editable = true } 176 | 177 | [tool.pixi.feature.py312.dependencies] 178 | python = "3.12.*" 179 | [tool.pixi.feature.py313.dependencies] 180 | python = "3.13.*" 181 | 182 | [tool.pixi.environments] 183 | complete = { features = [ 184 | "complete", 185 | "dask", 186 | "jupyter", 187 | "xesmf", 188 | ], solve-group = "default" } 189 | dask = { features = ["dask"], solve-group = "default" } 190 | default = { solve-group = "default" } 191 | docs = { features = ["complete", "docs"], solve-group = "default" } 192 | jupyter = { features = ["jupyter"], solve-group = "default" } 193 | py312 = { features = ["complete", "py312", "test"], solve-group = "py312" } 194 | py313 = { features = ["complete", "py313", "test"], solve-group = "py313" } 195 | test = { features = ["complete", "test"], solve-group = "default" } 196 | xesmf = { features = ["xesmf"], solve-group = "default" } 197 | 198 | [tool.pixi.tasks] 199 | 200 | [tool.pixi.feature.test.tasks] 201 | tests = { cmd = "pytest" } 202 | 203 | [tool.pixi.dependencies] 204 | distributed = ">=2025.7.0,<2026" 205 | netcdf4 = ">=1.7.2,<2" 206 | pooch = ">=1.8.2,<2" 207 | pyproj = ">=3.7.2,<4" 208 | pyresample = ">=1.34.2,<2" 209 | rasterio = ">=1.4.3,<2" 210 | rioxarray = ">=0.19.0,<0.20" 211 | xesmf = ">=0.8.7,<0.9" 212 | -------------------------------------------------------------------------------- /ndpyramid/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations # noqa: F401 2 | 3 | import contextlib 4 | 5 | import cf_xarray # noqa: F401 6 | import numpy as np 7 | import numpy.typing as npt 8 | import xarray as xr 9 | 10 | from ._version import __version__ 11 | from .common import Projection 12 | 13 | # from netCDF4 and netCDF4-python 14 | default_fillvals = { 15 | "S1": "\x00", 16 | "i1": -127, 17 | "u1": 255, 18 | "i2": -32767, 19 | "u2": 65535, 20 | "i4": -2147483647, 21 | "u4": 4294967295, 22 | "i8": -9223372036854775806, 23 | "u8": 18446744073709551614, 24 | "f4": 9.969209968386869e36, 25 | "f8": 9.969209968386869e36, 26 | } 27 | 28 | 29 | def get_version() -> str: 30 | return __version__ 31 | 32 | 33 | def get_levels(ds: xr.Dataset) -> int: 34 | raise NotImplementedError("Automatic determination of number of levels is not yet implemented") 35 | 36 | 37 | def multiscales_template( 38 | *, 39 | datasets: list | None = None, 40 | type: str = "", 41 | method: str = "", 42 | version: str = "", 43 | args: list | None = None, 44 | kwargs: dict | None = None, 45 | ): 46 | if datasets is None: 47 | datasets = [] 48 | if args is None: 49 | args = [] 50 | if kwargs is None: 51 | kwargs = {} 52 | # https://forum.image.sc/t/multiscale-arrays-v0-1/37930 53 | return [ 54 | { 55 | "datasets": datasets, 56 | "type": type, 57 | "metadata": {"method": method, "version": version, "args": args, "kwargs": kwargs}, 58 | } 59 | ] 60 | 61 | 62 | def set_zarr_encoding( 63 | ds: xr.Dataset, 64 | codec_config: dict | None = None, 65 | float_dtype: npt.DTypeLike | None = None, 66 | int_dtype: npt.DTypeLike | None = None, 67 | datetime_dtype: npt.DTypeLike | None = None, 68 | object_dtype: npt.DTypeLike | None = None, 69 | ) -> xr.Dataset: 70 | """Set zarr encoding for each variable in the dataset 71 | 72 | Parameters 73 | ---------- 74 | ds : xr.Dataset 75 | Input dataset 76 | codec_config : dict, optional 77 | Dictionary of parameters to pass to numcodecs.get_codec. 78 | The default is {'id': 'zlib', 'level': 1} 79 | float_dtype : str or dtype, optional 80 | Dtype to cast floating point variables to 81 | int_dtype : str or dtype, optional 82 | Dtype to cast integer variables to 83 | object_dtype : str or dtype, optional 84 | Dtype to cast object variables to. 85 | datetime_dtype : str or dtype, optional 86 | Dtype to encode numpy.datetime64 variables as. 87 | Time coordinates are encoded as 'int32' if cf_xarray 88 | is able to identify the coordinates representing time, 89 | even if `datetime_dtype` is None. 90 | 91 | 92 | Returns 93 | ------- 94 | ds : xr.Dataset 95 | Output dataset with updated variable encodings 96 | 97 | Notes 98 | ----- 99 | The *_dtype parameters can be used to coerce variables into data types 100 | readable by Zarr implementations in other languages. 101 | 102 | """ 103 | import numcodecs 104 | 105 | ds = ds.copy() 106 | 107 | if codec_config is None: 108 | codec_config = {"id": "zlib", "level": 1} 109 | compressor = numcodecs.get_codec(codec_config) 110 | 111 | time_vars = ds.cf.axes.get("T", []) + ds.cf.bounds.get("T", []) 112 | for varname, da in ds.variables.items(): 113 | # remove old encoding 114 | da.encoding.clear() 115 | 116 | # maybe cast data type 117 | if np.issubdtype(da.dtype, np.floating) and float_dtype is not None: 118 | da = da.astype(float_dtype) 119 | da.encoding["dtype"] = str(float_dtype) 120 | elif np.issubdtype(da.dtype, np.integer) and int_dtype is not None: 121 | da = da.astype(int_dtype) 122 | da.encoding["dtype"] = str(int_dtype) 123 | elif da.dtype == "O" and object_dtype is not None: 124 | da = da.astype(object_dtype) 125 | da.encoding["dtype"] = str(object_dtype) 126 | elif np.issubdtype(da.dtype, np.datetime64) and datetime_dtype is not None: 127 | da.encoding["dtype"] = str(datetime_dtype) 128 | elif varname in time_vars: 129 | da.encoding["dtype"] = "int32" 130 | 131 | # update with new encoding 132 | da.encoding["compressor"] = compressor 133 | with contextlib.suppress(KeyError): 134 | del da.attrs["_FillValue"] 135 | da.encoding["_FillValue"] = default_fillvals.get(da.dtype.str[-2:], None) 136 | 137 | ds[varname] = da 138 | 139 | return ds 140 | 141 | 142 | def add_metadata_and_zarr_encoding( 143 | pyramid: xr.DataTree, 144 | *, 145 | levels: int | list[int] | tuple[int, ...], 146 | other_chunks: dict | None = None, 147 | pixels_per_tile: int = 128, 148 | projection: Projection | None = None, 149 | ) -> xr.DataTree: 150 | """Postprocess data pyramid. Adds multiscales metadata and sets Zarr encoding 151 | 152 | Parameters 153 | ---------- 154 | pyramid : xr.DataTree 155 | Input data pyramid 156 | levels : int | list[int] 157 | Number of levels in pyramid (if int) or explicit list of level indices 158 | other_chunks : dict 159 | Chunks for non-spatial dims 160 | pixels_per_tile : int 161 | Number of pixels per tile 162 | projection: Projection 163 | Projection model of the pyramids 164 | 165 | Returns 166 | ------- 167 | xr.DataTree 168 | Updated data pyramid with metadata / encoding set 169 | 170 | Notes 171 | ----- 172 | The variables within the pyramid are coerced into data types readable by 173 | `@carbonplan/maps`. See https://ndpyramid.readthedocs.io/en/latest/schema.html 174 | for more information. Raise an issue in https://github.com/carbonplan/ndpyramid 175 | if more flexibility is needed. 176 | 177 | """ 178 | chunks = {"x": pixels_per_tile, "y": pixels_per_tile} 179 | if other_chunks is not None: 180 | chunks |= other_chunks 181 | 182 | if isinstance(levels, int): 183 | level_indices = list(range(levels)) 184 | else: 185 | level_indices = list(levels) 186 | 187 | # Map from level index to position in datasets list 188 | level_to_pos = { 189 | d["level"] if "level" in d else int(d["path"]): i 190 | for i, d in enumerate(pyramid.ds.attrs["multiscales"][0]["datasets"]) 191 | } 192 | 193 | for level in level_indices: 194 | slevel = str(level) 195 | # locate dataset metadata entry position 196 | pos = level_to_pos.get(level) 197 | if pos is None: 198 | # fallback: search by path string 199 | for i, d in enumerate(pyramid.ds.attrs["multiscales"][0]["datasets"]): 200 | if d.get("path") == slevel: 201 | pos = i 202 | break 203 | if pos is None: 204 | continue # skip if metadata missing (should not happen) 205 | pyramid.ds.attrs["multiscales"][0]["datasets"][pos]["pixels_per_tile"] = pixels_per_tile 206 | if projection: 207 | pyramid.ds.attrs["multiscales"][0]["datasets"][pos]["crs"] = projection._crs 208 | pyramid[slevel].ds = pyramid[slevel].ds.chunk(chunks) 209 | pyramid[slevel].ds = set_zarr_encoding( 210 | pyramid[slevel].ds, 211 | codec_config={"id": "zlib", "level": 1}, 212 | float_dtype="float32", 213 | int_dtype="int32", 214 | datetime_dtype="int32", 215 | object_dtype="str", 216 | ) 217 | 218 | # set global metadata 219 | pyramid.ds.attrs.update({"title": "multiscale data pyramid", "version": __version__}) 220 | return pyramid 221 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pandas as pd 3 | import pytest 4 | import xarray as xr 5 | from odc.geo.xr import assign_crs 6 | 7 | # --------------------------------------------------------------------------- 8 | # Utility helpers 9 | # --------------------------------------------------------------------------- 10 | 11 | 12 | def _build_lon(nx: int) -> np.ndarray: 13 | """ 14 | Return longitude centers spanning (-180, 180) with uniform spacing. 15 | Current formula yields centers from: 16 | (0.5 * 360/nx - 180) .. ( (nx-0.5) * 360/nx - 180 ) 17 | """ 18 | return (np.arange(nx) + 0.5) * 360.0 / nx - 180.0 19 | 20 | 21 | def _build_lat(ny: int) -> np.ndarray: 22 | """ 23 | Return latitude centers spanning (-90, 90) (exclusive of the poles) 24 | using the same pattern as longitude. 25 | """ 26 | return (np.arange(ny) + 0.5) * 180.0 / ny - 90.0 27 | 28 | 29 | def _encode_time(ds: xr.Dataset, start_time: pd.Timestamp): 30 | """ 31 | Apply CF-like time encoding referencing the dataset's first time stamp. 32 | """ 33 | ds.time.encoding = { 34 | "units": f"days since {start_time.strftime('%Y-%m-%d')}", 35 | "calendar": "proleptic_gregorian", 36 | } 37 | return ds 38 | 39 | 40 | # --------------------------------------------------------------------------- 41 | # Base tutorial dataset fixture 42 | # --------------------------------------------------------------------------- 43 | 44 | 45 | @pytest.fixture 46 | def temperature(): 47 | """ 48 | Return the tutorial air temperature dataset with: 49 | - Time coerced to seconds resolution (avoids zarr-js int32 issues). 50 | - Longitude wrapped to (-180, 180). 51 | - CRS assigned (EPSG:4326). 52 | - Chunked for downstream pyramid tests. 53 | """ 54 | ds = xr.tutorial.open_dataset("air_temperature") 55 | 56 | # Normalize time resolution 57 | time = ds.time.astype("datetime64[s]") 58 | ds = ds.assign_coords(time=time) 59 | 60 | # Wrap lon to (-180, 180) 61 | lon = ds["lon"].where(ds["lon"] < 180, ds["lon"] - 360) 62 | ds = ds.assign_coords(lon=lon) 63 | 64 | # Ensure strictly increasing longitude for tools that assume monotonic 65 | if not (ds["lon"].diff(dim="lon") > 0).all(): 66 | ds = ds.reindex(lon=np.sort(ds["lon"].data)) 67 | 68 | # Adjust lon_bounds if present 69 | if "lon_bounds" in ds.variables: 70 | lon_b = ds["lon_bounds"].where(ds["lon_bounds"] < 180, ds["lon_bounds"] - 360) 71 | ds = ds.assign_coords(lon_bounds=lon_b) 72 | 73 | # Transpose for consistent (time, lat, lon) ordering 74 | ds = ds.transpose("time", "lat", "lon") 75 | 76 | # Assign CRS and chunk 77 | ds = assign_crs(ds, "EPSG:4326") 78 | ds = ds.chunk({"time": 1000, "lat": 20, "lon": 20}) 79 | return ds 80 | 81 | 82 | # --------------------------------------------------------------------------- 83 | # 4D dataset fixtures 84 | # --------------------------------------------------------------------------- 85 | 86 | 87 | def _make_dataset_4d( 88 | nb: int = 2, 89 | nt: int = 10, 90 | ny: int = 740, 91 | nx: int = 1440, 92 | start: str = "2010-01-01", 93 | non_dim_coords: bool = False, 94 | crs: str = "EPSG:4326", 95 | seed: int | None = 0, 96 | ) -> xr.Dataset: 97 | """ 98 | Construct a synthetic 4D dataset (band, time, y, x). 99 | 100 | Parameters 101 | ---------- 102 | crs : 103 | CRS string to assign (metadata only). 104 | seed : 105 | Random seed for reproducibility (None leaves RNG unseeded). 106 | """ 107 | rng = np.random.default_rng() if seed is None else np.random.default_rng(seed) 108 | time = pd.date_range(start=start, periods=nt, freq="D") 109 | 110 | x = _build_lon(nx) 111 | x_attrs = {"units": "degrees_east", "xg_name": "xgitude"} 112 | 113 | y = _build_lat(ny) 114 | y_full_attrs = {"units": "degrees_north", "xg_name": "yitude"} 115 | 116 | ny_eff = y.size 117 | 118 | band = np.arange(nb) 119 | 120 | # Build data AFTER knowing ny_eff 121 | ones = np.ones((nb, nt, ny_eff, nx), dtype="float64") 122 | rand = rng.random((nb, nt, ny_eff, nx)) 123 | 124 | dims = ("band", "time", "y", "x") 125 | coords: dict[str, tuple] = { 126 | "band": ("band", band), 127 | "time": ("time", time), 128 | "y": ("y", y, y_full_attrs), 129 | "x": ("x", x, x_attrs), 130 | } 131 | if non_dim_coords: 132 | coords["timestep"] = ("time", np.arange(nt)) 133 | coords["baz"] = (("y", "x"), rng.random((ny_eff, nx))) 134 | 135 | ds = xr.Dataset( 136 | { 137 | "rand": (dims, rand, {"xg_name": "Beautiful Bar"}), 138 | "ones": (dims, ones, {"xg_name": "Fantastic Foo"}), 139 | }, 140 | coords=coords, 141 | attrs={ 142 | "conventions": "CF 1.6", 143 | }, 144 | ) 145 | 146 | ds = _encode_time(ds, time[0]) 147 | ds = assign_crs(ds, crs) 148 | return ds 149 | 150 | 151 | @pytest.fixture() 152 | def dataset_4d(): 153 | """ 154 | Default 4D dataset including polar latitudes (NOT Web Mercator safe). 155 | """ 156 | return _make_dataset_4d() 157 | 158 | 159 | @pytest.fixture() 160 | def dataset_4d_factory(): 161 | """ 162 | Factory fixture returning a builder function for customized 4D datasets. 163 | 164 | Usage in a test: 165 | def test_custom(dataset_4d_factory): 166 | ds = dataset_4d_factory(web_mercator_safe=True, nb=3) 167 | ... 168 | """ 169 | 170 | def _factory(**kwargs): 171 | return _make_dataset_4d(**kwargs) 172 | 173 | return _factory 174 | 175 | 176 | # --------------------------------------------------------------------------- 177 | # 3D dataset fixtures 178 | # --------------------------------------------------------------------------- 179 | 180 | 181 | def _make_dataset_3d( 182 | nt: int = 10, 183 | ny: int = 740, 184 | nx: int = 1440, 185 | start: str = "2010-01-01", 186 | non_dim_coords: bool = False, 187 | crs: str = "EPSG:4326", 188 | seed: int | None = 0, 189 | chunks: dict | None = None, 190 | ) -> xr.Dataset: 191 | """ 192 | Construct a synthetic 3D dataset (time, y, x). 193 | 194 | Parameters mirror _make_dataset_4d except for band dimension removal. 195 | """ 196 | rng = np.random.default_rng() if seed is None else np.random.default_rng(seed) 197 | time = pd.date_range(start=start, periods=nt, freq="D") 198 | 199 | x = _build_lon(nx) 200 | x_attrs = {"units": "degrees_east", "xg_name": "xgitude"} 201 | 202 | y = _build_lat(ny) 203 | y_attrs = {"units": "degrees_north", "xg_name": "yitude"} 204 | 205 | ny_eff = y.size 206 | 207 | ones = np.ones((nt, ny_eff, nx), dtype="float64") 208 | rand = rng.random((nt, ny_eff, nx)) 209 | 210 | dims = ("time", "y", "x") 211 | coords: dict[str, tuple] = { 212 | "time": ("time", time), 213 | "y": ("y", y, y_attrs), 214 | "x": ("x", x, x_attrs), 215 | } 216 | if non_dim_coords: 217 | coords["timestep"] = ("time", np.arange(nt)) 218 | coords["baz"] = (("y", "x"), rng.random((ny_eff, nx))) 219 | 220 | ds = xr.Dataset( 221 | { 222 | "rand": (dims, rand, {"xg_name": "Beautiful Bar"}), 223 | "ones": (dims, ones, {"xg_name": "Fantastic Foo"}), 224 | }, 225 | coords=coords, 226 | attrs={ 227 | "conventions": "CF 1.6", 228 | }, 229 | ) 230 | 231 | ds = _encode_time(ds, time[0]) 232 | ds = assign_crs(ds, crs) 233 | 234 | if chunks is None: 235 | # Default chunking similar to original file 236 | ds = ds.chunk({"x": 100, "y": 100, "time": nt}) 237 | else: 238 | ds = ds.chunk(chunks) 239 | 240 | return ds 241 | 242 | 243 | @pytest.fixture() 244 | def dataset_3d(): 245 | """ 246 | Default 3D dataset including polar latitudes (NOT Web Mercator safe). 247 | """ 248 | return _make_dataset_3d() 249 | 250 | 251 | @pytest.fixture() 252 | def dataset_3d_factory(): 253 | """ 254 | Factory fixture returning a builder for customized 3D datasets. 255 | 256 | Example: 257 | def test_reproject(dataset_3d_factory): 258 | ds = dataset_3d_factory(chunks={'time': 5, 'y': 200, 'x': 200}) 259 | ... 260 | """ 261 | 262 | def _factory(**kwargs): 263 | return _make_dataset_3d(**kwargs) 264 | 265 | return _factory 266 | -------------------------------------------------------------------------------- /ndpyramid/reproject.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations # noqa: F401 2 | 3 | from collections import defaultdict 4 | from collections.abc import Sequence 5 | 6 | import numpy as np 7 | import shapely.errors 8 | import xarray as xr 9 | from odc.geo import CRS as OdcCRS 10 | from odc.geo.geobox import GeoBox 11 | from odc.geo.xr import xr_reproject 12 | 13 | from .common import Projection, ProjectionOptions 14 | from .utils import add_metadata_and_zarr_encoding, get_levels, get_version, multiscales_template 15 | 16 | 17 | def _da_reproject(da: xr.DataArray, *, geobox: GeoBox, resampling: str): 18 | """Reproject a DataArray to a given GeoBox. 19 | 20 | Notes 21 | ----- 22 | - Avoids rebuilding CRS/GeoBox per call. 23 | - Does not mutate the source DataArray; encodings are applied on a shallow copy. 24 | """ 25 | try: 26 | # Work on a shallow copy to avoid mutating caller's encoding/attrs 27 | da_local = da.copy(deep=False) 28 | 29 | # Ensure a sensible fill value for floating types (used by rasterio/odc-geo during warp) 30 | if da_local.encoding.get("_FillValue") is None and np.issubdtype( 31 | da_local.dtype, np.floating 32 | ): 33 | enc = dict(da_local.encoding) 34 | enc["_FillValue"] = np.nan 35 | da_local.encoding = enc 36 | 37 | return xr_reproject(da_local, geobox, resampling=resampling) 38 | 39 | # catch the GEOSException: TopologyException error from shapely and raise a more informative error in case the user runs into 40 | # https://github.com/opendatacube/odc-geo/issues/147 41 | except shapely.errors.GEOSException as e: 42 | raise RuntimeError( 43 | "Error during reprojection. This can be caused by invalid geometries in the input data. " 44 | "Try cleaning the geometries or using a different resampling method. If the input data contains dask-arrays, " 45 | "consider using .compute() to convert them to in-memory arrays before reprojection. " 46 | "See https://github.com/opendatacube/odc-geo/issues/147 for more details." 47 | ) from e 48 | 49 | 50 | def level_reproject( 51 | ds: xr.Dataset, 52 | *, 53 | projection: ProjectionOptions = "web-mercator", 54 | level: int, 55 | pixels_per_tile: int = 128, 56 | resampling: str | dict = "average", 57 | extra_dim: str | None = None, 58 | clear_attrs: bool = False, 59 | ) -> xr.Dataset: 60 | """Create a level of a multiscale pyramid of a dataset via reprojection. 61 | 62 | Parameters 63 | ---------- 64 | ds : xarray.Dataset 65 | The dataset to create a multiscale pyramid of. 66 | projection : str, optional 67 | The projection to use. Default is 'web-mercator'. 68 | level : int 69 | The level of the pyramid to create. 70 | pixels_per_tile : int, optional 71 | Number of pixels per tile 72 | resampling : str or dict 73 | Resampling method to use. If a dict, keys are variable names and values are odc-geo supported 74 | methods. A string applies to all variables. 75 | extra_dim : str, optional 76 | Deprecated/ignored. Extra dimensions are handled natively by odc-geo/xarray broadcasting. 77 | clear_attrs : bool, False 78 | Clear the attributes of the DataArrays within the multiscale level. Default is False. 79 | 80 | Returns 81 | ------- 82 | xr.Dataset 83 | The multiscale pyramid level. 84 | 85 | Warning 86 | ------- 87 | Pyramid generation by level is experimental and subject to change. 88 | 89 | """ 90 | 91 | # Ensure CRS is present at the dataset level 92 | # raise error if not present, as this is required for reprojection 93 | if "spatial_ref" not in ds.coords: 94 | raise ValueError( 95 | "Source Dataset has no 'spatial_ref' coordinate. Please assign a CRS to the dataset before reprojection. You can use the 'assign_crs' function from odc.geo.xr." 96 | ) 97 | projection_model = Projection(name=projection) 98 | dim = 2**level * pixels_per_tile 99 | dst_transform = projection_model.transform(dim=dim) 100 | # Build CRS/GeoBox once per level and reuse 101 | dst_crs_odc = OdcCRS(projection_model._crs) 102 | dst_geobox = GeoBox((dim, dim), dst_transform, dst_crs_odc) 103 | save_kwargs = { 104 | "level": level, 105 | "pixels_per_tile": pixels_per_tile, 106 | "projection": projection, 107 | "resampling": resampling, 108 | "extra_dim": extra_dim, 109 | "clear_attrs": clear_attrs, 110 | } 111 | 112 | attrs = { 113 | "multiscales": multiscales_template( 114 | datasets=[{"path": ".", "level": level, "crs": projection_model._crs}], 115 | type="reduce", 116 | method="pyramid_reproject", 117 | version=get_version(), 118 | kwargs=save_kwargs, 119 | ) 120 | } 121 | 122 | # Convert resampling from string to dictionary if necessary 123 | if isinstance(resampling, str): 124 | resampling_dict: dict = defaultdict(lambda: resampling) 125 | else: 126 | resampling_dict = resampling 127 | 128 | # create the data array for each level (broadcast over extra dims; no Python loop) 129 | ds_level = xr.Dataset(attrs=ds.attrs) 130 | for k, da in ds.items(): 131 | da_reprojected = _da_reproject( 132 | da, 133 | geobox=dst_geobox, 134 | resampling=resampling_dict[k], 135 | ) 136 | if clear_attrs: 137 | da_reprojected.attrs.clear() 138 | ds_level[k] = da_reprojected 139 | ds_level.attrs["multiscales"] = attrs["multiscales"] 140 | return ds_level 141 | 142 | 143 | def pyramid_reproject( 144 | ds: xr.Dataset, 145 | *, 146 | projection: ProjectionOptions = "web-mercator", 147 | levels: int | None = None, 148 | level_list: Sequence[int] | None = None, 149 | pixels_per_tile: int = 128, 150 | other_chunks: dict | None = None, 151 | resampling: str | dict = "average", 152 | extra_dim: str | None = None, 153 | clear_attrs: bool = False, 154 | ) -> xr.DataTree: 155 | """Create a multiscale pyramid of a dataset via reprojection. 156 | 157 | Parameters 158 | ---------- 159 | ds : xarray.Dataset 160 | The dataset to create a multiscale pyramid of. 161 | projection : str, optional 162 | The projection to use. Default is 'web-mercator'. 163 | levels : int, optional 164 | The number of (contiguous) levels to create starting at 0. Mutually exclusive with 165 | ``level_list``. If both ``levels`` and ``level_list`` are ``None`` then an attempt is 166 | made to infer the number of levels via ``get_levels`` (currently not implemented). 167 | level_list : Sequence[int], optional 168 | Explicit list of zoom levels to generate (e.g. ``[4]`` to only build Z4, or 169 | ``[2,4,6]`` for a sparse pyramid). Mutually exclusive with ``levels``. 170 | pixels_per_tile : int, optional 171 | Number of pixels per tile, by default 128 172 | other_chunks : dict 173 | Chunks for non-spatial dims to pass to :py:meth:`~xr.Dataset.chunk`. Default is None 174 | resampling : str or dict, optional 175 | Resampling method to use. Default is 'average'. If a dict, keys are variable names and values are resampling methods. 176 | extra_dim : str, optional 177 | Deprecated/ignored. Extra dimensions are handled natively by odc-geo/xarray broadcasting. 178 | clear_attrs : bool, False 179 | Clear the attributes of the DataArrays within the multiscale pyramid. Default is False. 180 | 181 | Returns 182 | ------- 183 | xr.DataTree 184 | The multiscale pyramid. 185 | 186 | """ 187 | if levels is not None and level_list is not None: 188 | raise ValueError("Specify only one of 'levels' or 'level_list'.") 189 | 190 | if level_list is not None: 191 | # sanitize and sort unique levels 192 | level_indices = sorted({int(idx) for idx in level_list}) 193 | else: 194 | if not levels: 195 | levels = get_levels(ds) 196 | level_indices = list(range(int(levels))) 197 | projection_model = Projection(name=projection) 198 | save_kwargs = { 199 | # store the explicit list for reproducibility 200 | "levels": level_indices, 201 | "pixels_per_tile": pixels_per_tile, 202 | "projection": projection, 203 | "other_chunks": other_chunks, 204 | "resampling": resampling, 205 | "extra_dim": extra_dim, 206 | "clear_attrs": clear_attrs, 207 | } 208 | attrs = { 209 | "multiscales": multiscales_template( 210 | datasets=[ 211 | {"path": str(i), "level": i, "crs": projection_model._crs} for i in level_indices 212 | ], 213 | type="reduce", 214 | method="pyramid_reproject", 215 | version=get_version(), 216 | kwargs=save_kwargs, 217 | ) 218 | } 219 | 220 | plevels = { 221 | str(level): level_reproject( 222 | ds, 223 | projection=projection, 224 | level=level, 225 | pixels_per_tile=pixels_per_tile, 226 | resampling=resampling, 227 | extra_dim=extra_dim, 228 | clear_attrs=clear_attrs, 229 | ) 230 | for level in level_indices 231 | } 232 | # create the final multiscale pyramid 233 | plevels["/"] = xr.Dataset(attrs=attrs) 234 | pyramid = xr.DataTree.from_dict(plevels) 235 | 236 | pyramid = add_metadata_and_zarr_encoding( 237 | pyramid, 238 | levels=level_indices, 239 | pixels_per_tile=pixels_per_tile, 240 | other_chunks=other_chunks, 241 | projection=projection_model, 242 | ) 243 | return pyramid 244 | -------------------------------------------------------------------------------- /ndpyramid/resample.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations # noqa: F401 2 | 3 | import typing 4 | import warnings 5 | from collections import defaultdict 6 | from collections.abc import Sequence 7 | 8 | import numpy as np 9 | import xarray as xr 10 | from odc.geo.xr import assign_crs 11 | from pyproj.crs import CRS 12 | 13 | from .common import Projection, ProjectionOptions 14 | from .utils import add_metadata_and_zarr_encoding, get_levels, get_version, multiscales_template 15 | 16 | ResamplingOptions = typing.Literal["bilinear", "nearest"] 17 | 18 | 19 | def _da_resample( 20 | da: xr.DataArray, 21 | *, 22 | dim: int, 23 | projection_model: Projection, 24 | pixels_per_tile: int, 25 | other_chunk: int, 26 | resampling: ResamplingOptions, 27 | ): 28 | try: 29 | from pyresample.area_config import create_area_def 30 | from pyresample.future.resamplers.resampler import ( 31 | add_crs_xy_coords, 32 | update_resampled_coords, 33 | ) 34 | from pyresample.gradient import ( 35 | block_bilinear_interpolator, 36 | block_nn_interpolator, 37 | gradient_resampler_indices_block, 38 | ) 39 | from pyresample.resampler import resample_blocks 40 | from pyresample.utils.cf import load_cf_area 41 | except ImportError as e: 42 | raise ImportError( 43 | "The use of pyramid_resample requires the packages pyresample and dask" 44 | ) from e 45 | if da.encoding.get("_FillValue") is None and np.issubdtype(da.dtype, np.floating): 46 | da.encoding["_FillValue"] = np.nan 47 | if resampling == "bilinear": 48 | fun = block_bilinear_interpolator 49 | elif resampling == "nearest": 50 | fun = block_nn_interpolator 51 | else: 52 | raise ValueError(f"Unrecognized interpolation method {resampling} for gradient resampling.") 53 | target_area_def = create_area_def( 54 | area_id=projection_model.name, 55 | projection=projection_model._crs, 56 | shape=(dim, dim), 57 | area_extent=projection_model._area_extent, 58 | ) 59 | try: 60 | source_area_def = load_cf_area(da.to_dataset(name="var"), variable="var")[0] 61 | except ValueError as e: 62 | warnings.warn( 63 | f"Automatic determination of source AreaDefinition from CF conventions failed with {e}." 64 | " Falling back to AreaDefinition creation from coordinates." 65 | ) 66 | lx = da.x[0] - (da.x[1] - da.x[0]) / 2 67 | rx = da.x[-1] + (da.x[-1] - da.x[-2]) / 2 68 | uy = da.y[0] - (da.y[1] - da.y[0]) / 2 69 | ly = da.y[-1] + (da.y[-1] - da.y[-2]) / 2 70 | # Retrieve CRS from odc-geo accessor (assigned via assign_crs in fixtures/pipeline) 71 | try: 72 | odc_crs = da.odc.crs # odc.geo.CRS instance 73 | source_crs = CRS.from_string(str(odc_crs)) 74 | except Exception as e: # pragma: no cover - fallback path 75 | raise ValueError("Unable to determine source CRS for resampling") from e 76 | source_area_def = create_area_def( 77 | area_id=2, 78 | projection=source_crs, 79 | shape=(da.sizes["y"], da.sizes["x"]), 80 | area_extent=(lx.values, ly.values, rx.values, uy.values), 81 | ) 82 | indices_xy = resample_blocks( 83 | gradient_resampler_indices_block, 84 | source_area_def, 85 | [], 86 | target_area_def, 87 | chunk_size=(other_chunk, pixels_per_tile, pixels_per_tile), 88 | dtype=float, 89 | ) 90 | resampled = resample_blocks( 91 | fun, 92 | source_area_def, 93 | [da.data], 94 | target_area_def, 95 | dst_arrays=[indices_xy], 96 | chunk_size=(other_chunk, pixels_per_tile, pixels_per_tile), 97 | dtype=da.dtype, 98 | ) 99 | resampled_da = xr.DataArray(resampled, dims=("time", "y", "x")) 100 | resampled_da = update_resampled_coords(da, resampled_da, target_area_def) 101 | resampled_da = add_crs_xy_coords(resampled_da, target_area_def) 102 | resampled_da = resampled_da.drop_vars("crs") 103 | resampled_da.attrs = {} 104 | return resampled_da 105 | 106 | 107 | def level_resample( 108 | ds: xr.Dataset, 109 | *, 110 | x, 111 | y, 112 | projection: ProjectionOptions = "web-mercator", 113 | level: int, 114 | pixels_per_tile: int = 128, 115 | other_chunks: dict | None = None, 116 | resampling: ResamplingOptions | dict = "bilinear", 117 | clear_attrs: bool = False, 118 | ) -> xr.Dataset: 119 | """Create a level of a multiscale pyramid of a dataset via resampling. 120 | 121 | Parameters 122 | ---------- 123 | ds : xarray.Dataset 124 | The dataset to create a multiscale pyramid of. 125 | y : string 126 | name of the variable to use as 'y' axis of the CF area definition 127 | x : string 128 | name of the variable to use as 'x' axis of the CF area definition 129 | projection : str, optional 130 | The projection to use. Default is 'web-mercator'. 131 | level : int 132 | The level of the pyramid to create. 133 | pixels_per_tile : int, optional 134 | Number of pixels per tile 135 | other_chunks : dict 136 | Chunks for non-spatial dims. 137 | resampling : str or dict, optional 138 | Pyresample resampling method to use. Default is 'bilinear'. 139 | If a dict, keys are variable names and values are resampling methods. 140 | clear_attrs : bool, False 141 | Clear the attributes of the DataArrays within the multiscale level. Default is False. 142 | 143 | Returns 144 | ------- 145 | xr.Dataset 146 | The multiscale pyramid level. 147 | 148 | Warning 149 | ------- 150 | Pyramid generation by level is experimental and subject to change. 151 | 152 | """ 153 | dim = 2**level * pixels_per_tile 154 | projection_model = Projection(name=projection) 155 | save_kwargs = {"pixels_per_tile": pixels_per_tile} 156 | attrs = { 157 | "multiscales": multiscales_template( 158 | datasets=[{"path": ".", "level": level, "crs": projection_model._crs}], 159 | type="reduce", 160 | method="pyramid_resample", 161 | version=get_version(), 162 | kwargs=save_kwargs, 163 | ) 164 | } 165 | 166 | # Convert resampling from string to dictionary if necessary 167 | if isinstance(resampling, str): 168 | resampling_dict: dict = defaultdict(lambda: resampling) 169 | else: 170 | resampling_dict = resampling 171 | # update coord naming to x & y and ensure order of dims is time, y, x 172 | ds = ds.rename({x: "x", y: "y"}) 173 | # create the data array for each level 174 | ds_level = xr.Dataset(attrs=ds.attrs) 175 | for k, da in ds.items(): 176 | if clear_attrs: 177 | da.attrs.clear() 178 | if len(da.shape) > 3: 179 | # if extra_dim is not specified, raise an error 180 | raise NotImplementedError( 181 | "4+ dimensional datasets are not currently supported for pyramid_resample." 182 | ) 183 | else: 184 | # if the data array is not 4D, just resample it 185 | if other_chunks is None: 186 | other_chunk = list(da.sizes.values())[0] 187 | else: 188 | other_chunk = list(other_chunks.values())[0] 189 | # Cast resampling method to expected literal type if possible 190 | method = resampling_dict[k] 191 | if method not in ("bilinear", "nearest"): 192 | raise ValueError(f"Unsupported resampling method '{method}' for pyramid_resample") 193 | ds_level[k] = _da_resample( 194 | da, 195 | dim=dim, 196 | projection_model=projection_model, 197 | pixels_per_tile=pixels_per_tile, 198 | other_chunk=other_chunk, 199 | resampling=method, # type: ignore[arg-type] 200 | ) 201 | ds_level.attrs["multiscales"] = attrs["multiscales"] 202 | ds_level = assign_crs(ds_level, projection_model._crs) 203 | return ds_level 204 | 205 | 206 | def pyramid_resample( 207 | ds: xr.Dataset, 208 | *, 209 | x: str, 210 | y: str, 211 | projection: ProjectionOptions = "web-mercator", 212 | levels: int | None = None, 213 | level_list: Sequence[int] | None = None, 214 | pixels_per_tile: int = 128, 215 | other_chunks: dict | None = None, 216 | resampling: ResamplingOptions | dict = "bilinear", 217 | clear_attrs: bool = False, 218 | ) -> xr.DataTree: 219 | """Create a multiscale pyramid of a dataset via resampling. 220 | 221 | Parameters 222 | ---------- 223 | ds : xarray.Dataset 224 | The dataset to create a multiscale pyramid of. 225 | y : string 226 | name of the variable to use as ``y`` axis of the CF area definition 227 | x : string 228 | name of the variable to use as ``x`` axis of the CF area definition 229 | projection : str, optional 230 | The projection to use. Default is ``web-mercator``. 231 | levels : int, optional 232 | Number of contiguous levels starting at 0 to create. Mutually exclusive with ``level_list``. 233 | level_list : Sequence[int], optional 234 | Explicit list of zoom levels to build (e.g. ``[4]``). Mutually exclusive with ``levels``. 235 | pixels_per_tile : int, optional 236 | Number of pixels per tile, by default 128 237 | other_chunks : dict 238 | Chunks for non-spatial dims to pass to :py:meth:`~xr.Dataset.chunk`. Default is None 239 | resampling : str or dict, optional 240 | Pyresample resampling method to use (``bilinear`` or ``nearest``). Default is ``bilinear``. 241 | If a dict, keys are variable names and values are resampling methods. 242 | clear_attrs : bool, False 243 | Clear the attributes of the DataArrays within the multiscale pyramid. Default is False. 244 | 245 | Returns 246 | ------- 247 | xr.DataTree 248 | The multiscale pyramid. 249 | 250 | Warnings 251 | -------- 252 | - Pyresample expects longitude ranges between -180 - 180 degrees and latitude ranges between -90 and 90 degrees. 253 | - 3-D datasets are expected to have a dimension order of ``(time, y, x)``. 254 | 255 | ``Ndpyramid`` and ``pyresample`` do not check the validity of these assumptions to improve performance. 256 | 257 | """ 258 | if levels is not None and level_list is not None: 259 | raise ValueError("Specify only one of 'levels' or 'level_list'.") 260 | if level_list is not None: 261 | level_indices = sorted({int(i) for i in level_list}) 262 | else: 263 | if not levels: 264 | levels = get_levels(ds) 265 | level_indices = list(range(int(levels))) 266 | save_kwargs = {"levels": level_indices, "pixels_per_tile": pixels_per_tile} 267 | attrs = { 268 | "multiscales": multiscales_template( 269 | datasets=[{"path": str(i)} for i in level_indices], 270 | type="reduce", 271 | method="pyramid_resample", 272 | version=get_version(), 273 | kwargs=save_kwargs, 274 | ) 275 | } 276 | 277 | plevels = { 278 | str(level): level_resample( 279 | ds, 280 | x=x, 281 | y=y, 282 | projection=projection, 283 | level=level, 284 | pixels_per_tile=pixels_per_tile, 285 | other_chunks=other_chunks, 286 | resampling=resampling, 287 | clear_attrs=clear_attrs, 288 | ) 289 | for level in level_indices 290 | } 291 | # create the final multiscale pyramid 292 | plevels["/"] = xr.Dataset(attrs=attrs) 293 | pyramid = xr.DataTree.from_dict(plevels) 294 | 295 | projection_model = Projection(name=projection) 296 | 297 | pyramid = add_metadata_and_zarr_encoding( 298 | pyramid, 299 | levels=level_indices, 300 | pixels_per_tile=pixels_per_tile, 301 | other_chunks=other_chunks, 302 | projection=projection_model, 303 | ) 304 | return pyramid 305 | -------------------------------------------------------------------------------- /ndpyramid/regrid.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations # noqa: F401 2 | 3 | import itertools 4 | import typing 5 | from collections.abc import Sequence 6 | 7 | import numpy as np 8 | import xarray as xr 9 | 10 | from .common import Projection 11 | from .utils import add_metadata_and_zarr_encoding, get_version, multiscales_template 12 | 13 | 14 | def xesmf_weights_to_xarray(regridder) -> xr.Dataset: 15 | w = regridder.weights.data 16 | dim = "n_s" 17 | ds = xr.Dataset( 18 | { 19 | "S": (dim, w.data), 20 | "col": (dim, w.coords[1, :] + 1), 21 | "row": (dim, w.coords[0, :] + 1), 22 | } 23 | ) 24 | ds.attrs = {"n_in": regridder.n_in, "n_out": regridder.n_out} 25 | return ds 26 | 27 | 28 | def _reconstruct_xesmf_weights(ds_w: xr.Dataset) -> xr.DataArray: 29 | """Reconstruct weights into format that xESMF understands""" 30 | import sparse 31 | import xarray as xr 32 | 33 | col = ds_w["col"].values - 1 34 | row = ds_w["row"].values - 1 35 | s = ds_w["S"].values 36 | n_out, n_in = ds_w.attrs["n_out"], ds_w.attrs["n_in"] 37 | crds = np.stack([row, col]) 38 | return xr.DataArray( 39 | sparse.COO(crds, s, (n_out, n_in)), dims=("out_dim", "in_dim"), name="weights" 40 | ) 41 | 42 | 43 | def make_grid_ds( 44 | level: int, 45 | pixels_per_tile: int = 128, 46 | projection: typing.Literal["web-mercator", "equidistant-cylindrical"] = "web-mercator", 47 | ) -> xr.Dataset: 48 | """Make a dataset representing a target grid 49 | 50 | Parameters 51 | ---------- 52 | level : int 53 | The zoom level to compute the grid for. Level zero is the furthest out zoom level 54 | pixels_per_tile : int, optional 55 | Number of pixels to include along each axis in individual tiles, by default 128 56 | projection : str, optional 57 | The projection to use for the grid, by default 'equidistant-cylindrical' 58 | 59 | Returns 60 | ------- 61 | xr.Dataset 62 | Target grid dataset with the following variables: 63 | - "x": X coordinate in Web Mercator projection (grid cell center) 64 | - "y": Y coordinate in Web Mercator projection (grid cell center) 65 | - "lat": latitude coordinate (grid cell center) 66 | - "lon": longitude coordinate (grid cell center) 67 | - "lat_b": latitude bounds for grid cell 68 | - "lon_b": longitude bounds for grid cell 69 | 70 | """ 71 | projection_model = Projection(name=projection) 72 | 73 | dim = (2**level) * pixels_per_tile 74 | 75 | transform = projection_model.transform(dim=dim) 76 | 77 | if projection_model.name == "equidistant-cylindrical": 78 | title = "Equidistant Cylindrical Grid" 79 | 80 | elif projection_model.name == "web-mercator": 81 | title = "Web Mercator Grid" 82 | 83 | else: 84 | title = "Unknown Projection Grid" 85 | 86 | p = projection_model._proj 87 | 88 | grid_shape = (dim, dim) 89 | bounds_shape = (dim + 1, dim + 1) 90 | 91 | xs = np.empty(grid_shape) 92 | ys = np.empty(grid_shape) 93 | lat = np.empty(grid_shape) 94 | lon = np.empty(grid_shape) 95 | lat_b = np.zeros(bounds_shape) 96 | lon_b = np.zeros(bounds_shape) 97 | 98 | # calc grid cell center coordinates 99 | ii, jj = np.meshgrid(np.arange(dim) + 0.5, np.arange(dim) + 0.5) 100 | for i, j in itertools.product(range(grid_shape[0]), range(grid_shape[1])): 101 | locs = [ii[i, j], jj[i, j]] 102 | xs[i, j], ys[i, j] = transform * locs 103 | lon[i, j], lat[i, j] = p(xs[i, j], ys[i, j], inverse=True) 104 | 105 | # calc grid cell bounds 106 | iib, jjb = np.meshgrid(np.arange(dim + 1), np.arange(dim + 1)) 107 | for i, j in itertools.product(range(bounds_shape[0]), range(bounds_shape[1])): 108 | locs = [iib[i, j], jjb[i, j]] 109 | x, y = transform * locs 110 | lon_b[i, j], lat_b[i, j] = p(x, y, inverse=True) 111 | 112 | return xr.Dataset( 113 | { 114 | "x": xr.DataArray(xs[0, :], dims=["x"]), 115 | "y": xr.DataArray(ys[:, 0], dims=["y"]), 116 | "lat": xr.DataArray(lat, dims=["y", "x"]), 117 | "lon": xr.DataArray(lon, dims=["y", "x"]), 118 | "lat_b": xr.DataArray(lat_b, dims=["y_b", "x_b"]), 119 | "lon_b": xr.DataArray(lon_b, dims=["y_b", "x_b"]), 120 | }, 121 | attrs=dict(title=title, Conventions="CF-1.8"), 122 | ) 123 | 124 | 125 | def make_grid_pyramid( 126 | levels: int = 6, 127 | *, 128 | level_list: Sequence[int] | None = None, 129 | projection: typing.Literal["web-mercator", "equidistant-cylindrical"] = "web-mercator", 130 | pixels_per_tile: int = 128, 131 | ) -> xr.DataTree: 132 | """Helper function to create a grid pyramid for use with xesmf 133 | 134 | Parameters 135 | ---------- 136 | levels : int, optional 137 | Number of contiguous levels (0..levels-1) to build. Ignored if ``level_list`` is provided. 138 | level_list : Sequence[int], optional 139 | Explicit list of zoom levels to build. Useful for sparse pyramids. Mutually exclusive with 140 | ``levels``. 141 | 142 | Returns 143 | ------- 144 | pyramid : xr.DataTree 145 | Multiscale grid definition 146 | 147 | """ 148 | if level_list is not None: 149 | level_indices = sorted({int(i) for i in level_list}) 150 | else: 151 | level_indices = list(range(levels)) 152 | 153 | plevels = { 154 | str(level): make_grid_ds( 155 | level, projection=projection, pixels_per_tile=pixels_per_tile 156 | ).chunk(-1) 157 | for level in level_indices 158 | } 159 | return xr.DataTree.from_dict(plevels) 160 | 161 | 162 | def generate_weights_pyramid( 163 | ds_in: xr.Dataset, 164 | levels: int | None = None, 165 | *, 166 | level_list: Sequence[int] | None = None, 167 | method: str = "bilinear", 168 | regridder_kws: dict | None = None, 169 | projection: typing.Literal["web-mercator", "equidistant-cylindrical"] = "web-mercator", 170 | ) -> xr.DataTree: 171 | """Helper function to generate weights for a multiscale regridder 172 | 173 | Parameters 174 | ---------- 175 | ds_in : xr.Dataset 176 | Input dataset to regrid 177 | levels : int, optional 178 | Number of contiguous levels (0..levels-1) to build. Ignored if ``level_list`` is provided. 179 | level_list : Sequence[int], optional 180 | Explicit list of zoom levels to build (sparse weights). Mutually exclusive with ``levels``. 181 | method : str, optional 182 | Regridding method. See :py:class:`~xesmf.Regridder` for valid options, by default 'bilinear' 183 | regridder_kws : dict 184 | Keyword arguments to pass to :py:class:`~xesmf.Regridder`. Default is `{'periodic': True}` 185 | projection : str, optional 186 | The projection to use for the grid, by default 'web-mercator' 187 | 188 | Returns 189 | ------- 190 | weights : xr.DataTree 191 | Multiscale weights 192 | 193 | """ 194 | import xesmf as xe 195 | 196 | regridder_kws = {} if regridder_kws is None else regridder_kws 197 | regridder_kws = {"periodic": True, **regridder_kws} 198 | 199 | if levels is not None and level_list is not None: 200 | raise ValueError("Specify only one of 'levels' or 'level_list'.") 201 | if level_list is not None: 202 | level_indices = sorted({int(i) for i in level_list}) 203 | else: 204 | if levels is None: 205 | raise ValueError("Must provide either 'levels' or 'level_list'.") 206 | level_indices = list(range(levels)) 207 | 208 | plevels = {} 209 | for level in level_indices: 210 | ds_out = make_grid_ds(level=level, projection=projection) 211 | regridder = xe.Regridder(ds_in, ds_out, method, **regridder_kws) 212 | ds = xesmf_weights_to_xarray(regridder) 213 | 214 | plevels[str(level)] = ds 215 | 216 | root_levels_attr: typing.Any = level_indices if level_list is not None else len(level_indices) 217 | root = xr.Dataset(attrs={"levels": root_levels_attr, "regrid_method": method}) 218 | plevels["/"] = root 219 | return xr.DataTree.from_dict(plevels) 220 | 221 | 222 | def pyramid_regrid( 223 | ds: xr.Dataset, 224 | projection: typing.Literal["web-mercator", "equidistant-cylindrical"] = "web-mercator", 225 | target_pyramid: xr.DataTree | None = None, 226 | levels: int | None = None, 227 | *, 228 | level_list: Sequence[int] | None = None, 229 | parallel_weights: bool = True, 230 | weights_pyramid: xr.DataTree | None = None, 231 | method: str = "bilinear", 232 | regridder_kws: dict | None = None, 233 | regridder_apply_kws: dict | None = None, 234 | other_chunks: dict | None = None, 235 | pixels_per_tile: int = 128, 236 | ) -> xr.DataTree: 237 | """Make a pyramid using xesmf's regridders 238 | 239 | Parameters 240 | ---------- 241 | ds : xr.Dataset 242 | Input dataset 243 | projection : str, optional 244 | Projection to use for the grid, by default 'web-mercator' 245 | target_pyramid : xr.DataTree, optional 246 | Target grids, if not provided, they will be generated, by default None 247 | levels : int, optional 248 | Number of contiguous levels to build (0..levels-1). Ignored if ``level_list`` provided. 249 | level_list : Sequence[int], optional 250 | Explicit list of zoom levels to build (sparse). Mutually exclusive with ``levels``. 251 | weights_pyramid : xr.DataTree, optional 252 | pyramid containing pregenerated weights 253 | parallel_weights : Bool 254 | Use dask to generate parallel weights 255 | method : str, optional 256 | Regridding method. See :py:class:`~xesmf.Regridder` for valid options, by default 'bilinear' 257 | regridder_kws : dict 258 | Keyword arguments to pass to regridder. Default is `{'periodic': True}` 259 | regridder_apply_kws : dict 260 | Keyword arguments such as `keep_attrs`, `skipna`, `na_thres` 261 | to pass to :py:meth:`~xesmf.Regridder.__call__`. Default is None 262 | other_chunks : dict 263 | Chunks for non-spatial dims to pass to :py:meth:`~xr.Dataset.chunk`. Default is None 264 | pixels_per_tile : int, optional 265 | Number of pixels per tile, by default 128 266 | 267 | Returns 268 | ------- 269 | pyramid : xr.DataTree 270 | Multiscale data pyramid 271 | 272 | """ 273 | import xesmf as xe 274 | 275 | if target_pyramid is None: 276 | if levels is not None and level_list is not None: 277 | raise ValueError("Specify only one of 'levels' or 'level_list'.") 278 | if levels is not None or level_list is not None: 279 | target_pyramid = make_grid_pyramid( 280 | levels if levels is not None else 0, 281 | level_list=level_list, 282 | projection=projection, 283 | pixels_per_tile=pixels_per_tile, 284 | ) 285 | else: 286 | raise ValueError( 287 | "must either provide a target_pyramid or number of levels / level_list" 288 | ) 289 | 290 | # determine list of level indices from target_pyramid keys (excluding root if present) 291 | level_indices = sorted([int(k) for k in target_pyramid.keys() if k != "/"]) 292 | 293 | # backward compatibility: if levels specified ensure it matches 294 | if levels is not None and level_list is None and levels != len(level_indices): 295 | raise ValueError("Provided 'levels' does not match target_pyramid contents") 296 | 297 | regridder_kws = {} if regridder_kws is None else regridder_kws 298 | regridder_kws = {"periodic": True, **regridder_kws} 299 | 300 | # multiscales spec 301 | projection_model = Projection(name=projection) 302 | save_kwargs = { 303 | "levels": level_indices, 304 | "pixels_per_tile": pixels_per_tile, 305 | "projection": projection, 306 | "other_chunks": other_chunks, 307 | "method": method, 308 | "regridder_kws": regridder_kws, 309 | "regridder_apply_kws": regridder_apply_kws, 310 | } 311 | 312 | attrs = { 313 | "multiscales": multiscales_template( 314 | datasets=[ 315 | {"path": str(i), "level": i, "crs": projection_model._crs} for i in level_indices 316 | ], 317 | type="reduce", 318 | method="pyramid_regrid", 319 | version=get_version(), 320 | kwargs=save_kwargs, 321 | ) 322 | } 323 | save_kwargs.pop("levels") 324 | save_kwargs.pop("other_chunks") 325 | 326 | # set up pyramid 327 | 328 | plevels = {} 329 | 330 | # pyramid data 331 | for level in level_indices: 332 | grid = target_pyramid[str(level)].ds.load() 333 | # get the regridder object 334 | if weights_pyramid is None: 335 | regridder = xe.Regridder(ds, grid, method, parallel=parallel_weights, **regridder_kws) 336 | else: 337 | # Reconstruct weights into format that xESMF understands 338 | # this is a hack that assumes the weights were generated by 339 | # the `generate_weights_pyramid` function 340 | 341 | ds_w = weights_pyramid[str(level)].ds 342 | weights = _reconstruct_xesmf_weights(ds_w) 343 | regridder = xe.Regridder( 344 | ds, grid, method, reuse_weights=True, weights=weights, **regridder_kws 345 | ) 346 | # regrid 347 | if regridder_apply_kws is None: 348 | regridder_apply_kws = {} 349 | regridder_apply_kws = {**{"keep_attrs": True}, **regridder_apply_kws} 350 | plevels[str(level)] = regridder(ds, **regridder_apply_kws) 351 | level_attrs = { 352 | "multiscales": multiscales_template( 353 | datasets=[{"path": ".", "level": level, "crs": projection_model._crs}], 354 | type="reduce", 355 | method="pyramid_regrid", 356 | version=get_version(), 357 | kwargs=save_kwargs, 358 | ) 359 | } 360 | plevels[str(level)].attrs["multiscales"] = level_attrs["multiscales"] 361 | 362 | root = xr.Dataset(attrs=attrs) 363 | plevels["/"] = root 364 | pyramid = xr.DataTree.from_dict(plevels) 365 | 366 | pyramid = add_metadata_and_zarr_encoding( 367 | pyramid, 368 | levels=level_indices, 369 | other_chunks=other_chunks, 370 | pixels_per_tile=pixels_per_tile, 371 | projection=Projection(name=projection), 372 | ) 373 | 374 | return pyramid 375 | -------------------------------------------------------------------------------- /docs/examples/pyramid-create.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# Pyramids via custom resampling methods\n", 8 | "\n", 9 | "In this example, we'll show how to use `pyramid_create` to generate multi-scale pyramids using a custom resampling method." 10 | ] 11 | }, 12 | { 13 | "cell_type": "code", 14 | "execution_count": 2, 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import dask # noqa\n", 19 | "import xarray as xr\n", 20 | "\n", 21 | "from ndpyramid import pyramid_create" 22 | ] 23 | }, 24 | { 25 | "cell_type": "markdown", 26 | "metadata": {}, 27 | "source": [ 28 | "## Setup output paths\n", 29 | "\n", 30 | "You can set the output path to object storage or a memory store, after changing the S3 URI to a location that you have write access to. \n" 31 | ] 32 | }, 33 | { 34 | "cell_type": "code", 35 | "execution_count": 3, 36 | "metadata": {}, 37 | "outputs": [], 38 | "source": [ 39 | "S3 = False\n", 40 | "if S3:\n", 41 | " output = \"s3://carbonplan-scratch/pyramid_comparison/custom.zarr\"\n", 42 | "else:\n", 43 | " import zarr\n", 44 | "\n", 45 | " output = zarr.storage.MemoryStore()" 46 | ] 47 | }, 48 | { 49 | "cell_type": "markdown", 50 | "metadata": {}, 51 | "source": [ 52 | "## Open input dataset\n", 53 | " \n", 54 | "In this example, we'll use the Xarray tutorial dataset `air_temperature`." 55 | ] 56 | }, 57 | { 58 | "cell_type": "code", 59 | "execution_count": 4, 60 | "metadata": {}, 61 | "outputs": [], 62 | "source": [ 63 | "ds = xr.tutorial.load_dataset(\"air_temperature\")" 64 | ] 65 | }, 66 | { 67 | "cell_type": "markdown", 68 | "metadata": {}, 69 | "source": [ 70 | "## Generate pyramids\n", 71 | "\n", 72 | "We'll use the `pyramid_create` function to generate a pyramid with two levels. This function can be used to apply a provided function to the dataset to create the individual pyramid levels." 73 | ] 74 | }, 75 | { 76 | "cell_type": "code", 77 | "execution_count": null, 78 | "metadata": {}, 79 | "outputs": [ 80 | { 81 | "name": "stderr", 82 | "output_type": "stream", 83 | "text": [ 84 | "/Users/nrhagen/miniforge3/envs/ndpyramid/lib/python3.12/site-packages/xarray/core/datatree_io.py:159: SerializationWarning: saving variable None with floating point data as an integer dtype without any _FillValue to use for NaNs\n", 85 | " ds.to_zarr(\n", 86 | "/Users/nrhagen/miniforge3/envs/ndpyramid/lib/python3.12/site-packages/xarray/core/datatree_io.py:159: SerializationWarning: saving variable None with floating point data as an integer dtype without any _FillValue to use for NaNs\n", 87 | " ds.to_zarr(\n", 88 | "/Users/nrhagen/miniforge3/envs/ndpyramid/lib/python3.12/site-packages/xarray/core/datatree_io.py:159: SerializationWarning: saving variable None with floating point data as an integer dtype without any _FillValue to use for NaNs\n", 89 | " ds.to_zarr(\n" 90 | ] 91 | } 92 | ], 93 | "source": [ 94 | "def sel_coarsen(ds, factor, dims, **kwargs):\n", 95 | " return ds.sel(**{dim: slice(None, None, factor) for dim in dims})\n", 96 | "\n", 97 | "\n", 98 | "factors = [4, 2, 1]\n", 99 | "pyramid = pyramid_create(\n", 100 | " ds,\n", 101 | " dims=(\"lat\", \"lon\"),\n", 102 | " factors=factors,\n", 103 | " boundary=\"trim\",\n", 104 | " func=sel_coarsen,\n", 105 | " method_label=\"slice_coarsen\",\n", 106 | " type_label=\"pick\",\n", 107 | ")\n", 108 | "pyramid.to_zarr(output, zarr_format=2, consolidated=True, mode=\"w\")" 109 | ] 110 | }, 111 | { 112 | "cell_type": "markdown", 113 | "metadata": {}, 114 | "source": [ 115 | "## Open and plot the result\n" 116 | ] 117 | }, 118 | { 119 | "cell_type": "code", 120 | "execution_count": 8, 121 | "metadata": {}, 122 | "outputs": [ 123 | { 124 | "data": { 125 | "text/plain": [ 126 | "" 127 | ] 128 | }, 129 | "execution_count": 8, 130 | "metadata": {}, 131 | "output_type": "execute_result" 132 | }, 133 | { 134 | "data": { 135 | "image/png": "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ti261l0V8scJANwF6a6C3x8AGAT10cP2uorukQCgFKTrYO4eKn+9iq5lcj5OEwgRERGVJM7+aKykYCBERETkCE741lhJxECIiIjIAdgiVDLwQSkRERE5LbYIEREROYJBuv+xJz/ZjYEQERGRI7CPUInAR2NERETktNgiRERE5AAK2NlZuthK4twYCBERETkCR5YuEfhojIiIiJwWW4SIiIgcgOMIlQwMhIiIiByBb42VCHw0RkRERE6LLUJEREQOoJAkKOzo8GxPXvqX0wRCXsoceChVRaYpYbB5uyqFfF6dJH94cySNcLv6R9RYpzTTlqoXpGkUolRz+xXXx1Whk03TK8V5DZLtx0oveAFVfIaAaq6psmkuynxh3uRcH9m0dL27MG+Y8oZsmpcyV5g326AVpj8yqruySXlS0b/LAgY7XhIWXe86iPert+O60irkz7+536DonqIR/EK1Zn6froIyaQT7BACd4PebZ+Y46gTH0dy9Vys69WZiANH9SnTu7bnPWc3w/x978pPdnCYQIiIiKknYIlQysI8QEREROS22CBERETkC3xorERgIEREROQJHli4R+GiMiIiInBYDISIiIgcoGFnano81oqOj0bRpU3h5eSEgIAC9evVCYmJiketKkoQuXbpAoVDgm2++MUlLSkpCt27d4O7ujoCAAEycOBH5+eI3ZUsyBkJERESOUPBozJ6PFfbv34+oqCgcOXIEcXFx0Ol0iIyMRFZWVqF1lyxZAoWi8PgFer0e3bp1Q15eHg4fPox169Zh7dq1mD59us2HwdHYR4iIiMgJ7Ny50+T72rVrERAQgISEBLRp08a4/PTp01i0aBFOnDiB4OBgkzw//vgjfvvtN+zevRuBgYFo0KAB3n//fUyaNAkzZ86EVuugscrswBYhIiIiB1AY7P8AQEZGhsknN1c8sGqB9PR0AIC/v79xWXZ2Nvr374+lS5ciKCioUJ74+HiEh4cjMDDQuKxz587IyMjAr7/+asfRcBwGQkRERI5QTI/GQkJC4OPjY/xER0eb3bXBYMDYsWPRqlUr1K1b17h83LhxaNmyJXr27FlkvpSUFJMgCIDxe0pKiq1HwqH4aIyIiKgUu379Ory9vY3fXVxczOaJiorCuXPncPDgQeOy7777Dnv37sWpU6ceSTlLKrYIEREROYJUDB8A3t7eJh9zgdDIkSOx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", 136 | "text/plain": [ 137 | "
" 138 | ] 139 | }, 140 | "metadata": {}, 141 | "output_type": "display_data" 142 | } 143 | ], 144 | "source": [ 145 | "dt = xr.open_datatree(output, engine=\"zarr\")\n", 146 | "dt[\"2\"].ds.isel(time=0).air.plot()" 147 | ] 148 | } 149 | ], 150 | "metadata": { 151 | "kernelspec": { 152 | "display_name": "ndpyramid", 153 | "language": "python", 154 | "name": "python3" 155 | }, 156 | "language_info": { 157 | "codemirror_mode": { 158 | "name": "ipython", 159 | "version": 3 160 | }, 161 | "file_extension": ".py", 162 | "mimetype": "text/x-python", 163 | "name": "python", 164 | "nbconvert_exporter": "python", 165 | "pygments_lexer": "ipython3", 166 | "version": "3.12.7" 167 | } 168 | }, 169 | "nbformat": 4, 170 | "nbformat_minor": 2 171 | } 172 | --------------------------------------------------------------------------------