├── .github ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── release.yml └── workflows │ ├── ci.yml │ ├── nightly.yml │ └── pypipublish.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── CITATION.cff ├── LICENSE ├── README.md ├── ci ├── docs.yml ├── environment.yml └── install-upstream-wheels.sh ├── design_doc.md ├── docs ├── _static │ └── logos │ │ ├── xdggs_logo.png │ │ └── xdggs_logo.svg ├── api-hidden.rst ├── api.rst ├── changelog.md ├── conf.py ├── contributor_guide │ └── docs.md ├── getting_started │ └── installation.md ├── index.md ├── reference_guide │ ├── publications.bib │ └── publications.md └── tutorials │ ├── h3.ipynb │ └── healpix.ipynb ├── environment.yml ├── examples ├── example_h3.ipynb ├── example_healpy.ipynb └── prepare_dataset_h3.ipynb ├── pyproject.toml ├── xdggs-cropped.gif └── xdggs ├── __init__.py ├── accessor.py ├── grid.py ├── h3.py ├── healpix.py ├── index.py ├── itertools.py ├── plotting.py ├── tests ├── __init__.py ├── matchers.py ├── test_accessor.py ├── test_h3.py ├── test_healpix.py ├── test_index.py ├── test_matchers.py ├── test_plotting.py ├── test_tutorial.py └── test_utils.py ├── tutorial.py └── utils.py /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | - [ ] closes #xxxx 2 | - [ ] Tests added 3 | - [ ] User visible changes (including notable bug fixes) are documented in `changelog.md` 4 | - [ ] New functions/methods are listed in `api.rst` 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | # Check for updates once a week 7 | interval: "weekly" 8 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | authors: 4 | - dependabot 5 | - pre-commit-ci 6 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | env: 15 | FORCE_COLOR: 3 16 | 17 | jobs: 18 | detect-ci-trigger: 19 | name: detect ci trigger 20 | runs-on: ubuntu-latest 21 | if: | 22 | github.repository_owner == 'xarray-contrib' 23 | && (github.event_name == 'pull_request' || github.event_name == 'push') 24 | outputs: 25 | triggered: ${{ steps.detect-trigger.outputs.trigger-found }} 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 2 30 | - uses: xarray-contrib/ci-trigger@v1 31 | id: detect-trigger 32 | with: 33 | keyword: "[skip-ci]" 34 | 35 | tests: 36 | name: ${{ matrix.os }} py${{ matrix.python-version }} 37 | runs-on: ${{ matrix.os }} 38 | needs: detect-ci-trigger 39 | if: needs.detect-ci-trigger.outputs.triggered == 'false' 40 | defaults: 41 | run: 42 | shell: bash -leo pipefail {0} {0} 43 | 44 | strategy: 45 | fail-fast: false 46 | 47 | matrix: 48 | os: ["ubuntu-latest", "macos-latest", "windows-latest"] 49 | python-version: ["3.10", "3.11", "3.12"] 50 | 51 | steps: 52 | - name: Clone repository 53 | uses: actions/checkout@v4 54 | with: 55 | fetch-depth: 0 # Fetch all history for all branches and tags 56 | 57 | - name: Setup environment 58 | run: >- 59 | echo "CONDA_ENV_FILE=ci/environment.yml" >> $GITHUB_ENV 60 | 61 | - name: Setup micromamba 62 | uses: mamba-org/setup-micromamba@v2 63 | with: 64 | micromamba-version: "1.5.10-0" 65 | environment-file: ${{ env.CONDA_ENV_FILE }} 66 | environment-name: xdggs-tests 67 | cache-environment: true 68 | cache-environment-key: "${{runner.os}}-${{runner.arch}}-py${{matrix.python-version}}-${{env.TODAY}}-${{hashFiles(env.CONDA_ENV_FILE)}}" 69 | create-args: >- 70 | python=${{matrix.python-version}} 71 | 72 | - name: Install xdggs 73 | run: | 74 | python -m pip install --no-deps -e . 75 | 76 | - name: Import xdggs 77 | run: | 78 | python -c "import xdggs" 79 | 80 | - name: Run tests 81 | run: | 82 | python -m pytest 83 | -------------------------------------------------------------------------------- /.github/workflows/nightly.yml: -------------------------------------------------------------------------------- 1 | name: Upstream-dev CI 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["main"] 8 | types: [opened, reopened, synchronize, labeled] 9 | schedule: 10 | - cron: "0 0 * * 1" # Mondays “At 00:00” UTC 11 | workflow_dispatch: # allows you to trigger the workflow run manually 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | detect-ci-trigger: 19 | name: detect ci trigger 20 | runs-on: ubuntu-latest 21 | if: | 22 | github.repository_owner == 'xarray-contrib' && github.event_name == 'pull_request' 23 | outputs: 24 | triggered: ${{ steps.detect-trigger.outputs.trigger-found }} 25 | steps: 26 | - uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 2 29 | - uses: xarray-contrib/ci-trigger@v1 30 | id: detect-trigger 31 | with: 32 | keyword: "[test-upstream]" 33 | 34 | tests: 35 | name: upstream-dev 36 | runs-on: ubuntu-latest 37 | needs: detect-ci-trigger 38 | if: | 39 | always() 40 | && ( 41 | (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') 42 | || needs.detect-ci-trigger.outputs.triggered == 'true' 43 | || contains( github.event.pull_request.labels.*.name, 'run-upstream') 44 | ) 45 | 46 | defaults: 47 | run: 48 | shell: bash -leo pipefail {0} {0} 49 | 50 | strategy: 51 | fail-fast: false 52 | 53 | matrix: 54 | python-version: ["3.12"] 55 | 56 | permissions: 57 | issues: write 58 | 59 | steps: 60 | - name: Clone repository 61 | uses: actions/checkout@v4 62 | with: 63 | fetch-depth: 0 # Fetch all history for all branches and tags 64 | 65 | - name: Setup micromamba 66 | uses: mamba-org/setup-micromamba@v2 67 | with: 68 | environment-file: ci/environment.yml 69 | environment-name: xdggs-tests 70 | cache-environment: false 71 | create-args: >- 72 | python=${{matrix.python-version}} 73 | pytest-reportlog 74 | conda 75 | 76 | - name: Install upstream versions 77 | run: | 78 | bash ci/install-upstream-wheels.sh 79 | 80 | - name: Install xdggs 81 | run: | 82 | python -m pip install --no-deps -e . 83 | 84 | - name: Import xdggs 85 | run: | 86 | python -c "import xdggs" 87 | 88 | - name: Run tests 89 | if: success() 90 | id: run-tests 91 | run: | 92 | python -m pytest --report-log output-${{ matrix.python-version }}-log.jsonl 93 | 94 | - name: Generate and publish a failure report 95 | if: | 96 | failure() 97 | && steps.run-tests.outcome == 'failure' 98 | && github.event_name == 'schedule' 99 | && github.repository_owner == 'xarray-contrib' 100 | uses: xarray-contrib/issue-from-pytest-log@v1 101 | with: 102 | log-path: output-${{ matrix.python-version }}-log.jsonl 103 | -------------------------------------------------------------------------------- /.github/workflows/pypipublish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package on PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | environment: 11 | name: pypi 12 | url: https://pypi.org/p/xdggs 13 | permissions: 14 | id-token: write 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.x" 22 | - name: Install publish dependencies 23 | run: python -m pip install build 24 | - name: Build package 25 | run: python -m build . -o py_dist 26 | - name: Publish package to PyPI 27 | uses: pypa/gh-action-pypi-publish@release/v1 28 | with: 29 | packages-dir: py_dist/ 30 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | docs/**/generated/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints/ 81 | .virtual_documents/ 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/#use-with-ide 112 | .pdm.toml 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # code formatter cache 144 | .prettier_cache/ 145 | 146 | # mypy 147 | .mypy_cache/ 148 | .dmypy.json 149 | dmypy.json 150 | 151 | # Pyre type checker 152 | .pyre/ 153 | 154 | # pytype static type analyzer 155 | .pytype/ 156 | 157 | # Cython debug symbols 158 | cython_debug/ 159 | 160 | # PyCharm 161 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 162 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 163 | # and can be added to the global gitignore or merged into this file. For a more nuclear 164 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 165 | #.idea/ 166 | 167 | # bibtex 168 | .auctex-auto 169 | *.bib.untidy 170 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-docstring-first 8 | - repo: https://github.com/rbubley/mirrors-prettier 9 | rev: v3.5.3 10 | hooks: 11 | - id: prettier 12 | args: [--cache-location=.prettier_cache/cache] 13 | - repo: https://github.com/ComPWA/taplo-pre-commit 14 | rev: v0.9.3 15 | hooks: 16 | - id: taplo-format 17 | args: [--option, array_auto_collapse=false] 18 | - id: taplo-lint 19 | args: [--no-schema] 20 | - repo: https://github.com/abravalheri/validate-pyproject 21 | rev: v0.24.1 22 | hooks: 23 | - id: validate-pyproject 24 | - repo: https://github.com/astral-sh/ruff-pre-commit 25 | rev: v0.11.12 26 | hooks: 27 | - id: ruff 28 | args: [--fix] 29 | - repo: https://github.com/psf/black-pre-commit-mirror 30 | rev: 25.1.0 31 | hooks: 32 | - id: black-jupyter 33 | - repo: https://github.com/keewis/blackdoc 34 | rev: v0.3.9 35 | hooks: 36 | - id: blackdoc 37 | additional_dependencies: ["black==25.1.0"] 38 | - id: blackdoc-autoupdate-black 39 | - repo: https://github.com/citation-file-format/cffconvert 40 | rev: b6045d78aac9e02b039703b030588d54d53262ac 41 | hooks: 42 | - id: validate-cff 43 | - repo: https://github.com/kynan/nbstripout 44 | rev: 0.8.1 45 | hooks: 46 | - id: nbstripout 47 | args: [--extra-keys=metadata.kernelspec metadata.langauge_info.version] 48 | - repo: https://github.com/FlamingTempura/bibtex-tidy 49 | rev: v1.14.0 50 | hooks: 51 | - id: bibtex-tidy 52 | stages: [manual] 53 | args: 54 | - "--modify" 55 | - "--blank-lines" 56 | - "--sort=-year,name" 57 | - "--duplicates" 58 | - "--escape" 59 | - "--trailing-commas" 60 | 61 | ci: 62 | autofix_prs: true 63 | autoupdate_schedule: monthly 64 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-lts-latest 5 | tools: 6 | python: mambaforge-latest 7 | jobs: 8 | post_checkout: 9 | - (git --no-pager log --pretty="tformat:%s" -1 | grep -vqF "[skip-rtd]") || exit 183 10 | - git fetch --unshallow || true 11 | pre_install: 12 | - git update-index --assume-unchanged docs/conf.py ci/docs.yml 13 | 14 | conda: 15 | environment: ci/docs.yml 16 | 17 | sphinx: 18 | fail_on_warning: true 19 | configuration: docs/conf.py 20 | 21 | formats: [] 22 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | title: xdggs 3 | message: "If you use this software, please cite it as below." 4 | type: software 5 | authors: 6 | - family-names: "Magin" 7 | given-names: "Justus" 8 | orcid: "https://orcid.org/0000-0002-4254-8002" 9 | - family-names: "Bovy" 10 | given-names: "Benoît" 11 | orcid: "https://orcid.org/0009-0001-4011-3574" 12 | - family-names: "Kmoch" 13 | given-names: "Alexander" 14 | orcid: "https://orcid.org/0000-0003-4386-4450" 15 | - family-names: "Abernathey" 16 | given-names: "Ryan" 17 | orcid: "https://orcid.org/0000-0001-5999-4917" 18 | - family-names: "Coca-Castro" 19 | given-names: "Alejandro" 20 | orcid: "https://orcid.org/0000-0002-9264-1539" 21 | - family-names: "Strobl" 22 | given-names: "Peter" 23 | orcid: "https://orcid.org/0000-0003-2733-1822" 24 | - family-names: "Fouilloux" 25 | given-names: "Anne" 26 | orcid: "https://orcid.org/0000-0002-1784-2920" 27 | - family-names: "Loos" 28 | given-names: "Daniel" 29 | orcid: "https://orcid.org/0000-0002-4024-4443" 30 | - family-names: "Chan" 31 | given-names: "Wai Tik" 32 | orcid: "https://orcid.org/0009-0005-3779-139X" 33 | - family-names: "Delouis" 34 | given-names: "Jean-Marc" 35 | orcid: "https://orcid.org/0000-0002-0713-1658" 36 | - family-names: "Odaka" 37 | given-names: "Tina" 38 | orcid: "https://orcid.org/0000-0002-1500-0156" 39 | repository-code: "https://github.com/xarray-contrib/xdggs" 40 | url: "https://xdggs.readthedocs.io" 41 | keywords: 42 | - xarray 43 | - discrete global grid systems 44 | - dggs 45 | license: Apache-2.0 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/xarray-contrib/xdggs/actions/workflows/ci.yml/badge.svg?branch=main&event=push)](https://github.com/xarray-contrib/xdggs/actions/ci.yml?query=branch%3Amain+event%3Apush) 2 | [![docs](https://readthedocs.org/projects/xdggs/badge/?version=latest)](https://xdggs.readthedocs.io) 3 | [![PyPI version](https://img.shields.io/pypi/v/xdggs.svg)](https://pypi.org/project/xdggs) 4 | [![codestyle](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) 5 | [![conda-forge](https://img.shields.io/conda/vn/conda-forge/xdggs)](https://github.com/conda-forge/xdggs-feedstock) 6 | 7 | --- 8 | 9 |
10 | 11 | # xdggs: discrete global grid systems with xarray 12 | 13 | `xdggs` is an open-source Python package that provides tools for handling geospatial data using Discrete Global Grid Systems (DGGS). 14 | 15 | It enables efficient manipulation and analysis of multi-dimensional gridded data within a DGGS framework, supporting spatial data processing, resampling, and aggregation on both global and regional scales. 16 | 17 | Inspired by the growing need for scalable geospatial data analysis with DGGS, `xdggs` is built upon the robust [Xarray](https://xarray.pydata.org/) ecosystem, which simplifies working with labeled multi-dimensional arrays. 18 | 19 | As an extension of Xarray, `xdggs` leverages Xarray's capabilities, including seamless access to formats like [NetCDF](https://www.unidata.ucar.edu/software/netcdf/), [Zarr](https://zarr.readthedocs.io/), and parallelization through [Dask](https://www.dask.org/), to provide a powerful and flexible toolkit for geospatial analysis. 20 | 21 | ## Key Features 22 | 23 | - **Seamless Integration with Xarray**: Use `xdggs` alongside Xarray's powerful tools for managing labeled, multi-dimensional data. 24 | - **Support for DGGS**: Convert geospatial data into DGGS representations, allowing for uniform spatial partitioning of the Earth's surface. 25 | - **Spatial Resampling**: Resample data on DGGS grids, enabling downscaling or upscaling across multiple resolutions. 26 | - **DGGS Aggregation**: Perform spatial aggregation of data on DGGS cells. 27 | - **Efficient Data Management**: Manage large datasets with Xarray's lazy loading, Dask integration, and chunking to optimize performance. 28 | 29 | ## Documentation 30 | 31 | You can find the documentation in [https://xdggs.readthedocs.io/en/latest/](https://xdggs.readthedocs.io/en/latest/). 32 | 33 | ## Demo 34 | 35 | ![xdggs demo](https://raw.githubusercontent.com/xarray-contrib/xdggs/refs/heads/main/xdggs-cropped.gif) 36 | 37 | ## Getting Started 38 | 39 | As an example, this is how you would use `xdggs` to reconstruct geographical coordinates from the cell ids then create an interactive plot indicating cell ids, data values and the associated geographical coordinates: 40 | 41 | ```python 42 | import xarray as xr 43 | import xdggs 44 | 45 | ds = xdggs.tutorial.open_dataset("air_temperature", "h3") 46 | 47 | # Decode DGGS coordinates 48 | ds_idx = ds.pipe(xdggs.decode) 49 | 50 | # Assign geographical coordinates 51 | ds_idx = ds_idx.dggs.assign_latlon_coords() 52 | 53 | # Interactive visualization 54 | ds_idx['air'].isel(time=0).compute().dggs.explore(center=0, cmap="viridis", alpha=0.5) 55 | 56 | ``` 57 | 58 | ## Roadmap 59 | 60 | We have exciting plans to expand xdggs with new features and improvements. You can check out our roadmap in the [design_doc.md](https://github.com/xarray-contrib/xdggs/blob/main/design_doc.md) file for details on the design of xdggs, upcoming features, and future enhancements. 61 | 62 | ## Contributing 63 | 64 | We welcome contributions to `xdggs`! Please follow these steps to get involved: 65 | 66 | 1. Fork the repository. 67 | 2. Create a new branch (`git checkout -b feature-branch`). 68 | 3. Make your changes and write tests. 69 | 4. Ensure all tests pass (`pytest`). 70 | 5. Submit a pull request! 71 | 72 | ## License 73 | 74 | `xdggs` is licensed under the Apache License License. See [LICENSE](https://github.com/xarray-contrib/xdggs/blob/main/LICENSE) for more details. 75 | 76 | ## Acknowledgments 77 | 78 | This project was initiated using funding from CNES (PANGEO IAOCEA, contract R&T R-S23/DU-0002-025-01) and the European Union (ERC, WaterSmartLand, 101125476, Interreg-BSR, HyTruck, #C031). 79 | -------------------------------------------------------------------------------- /ci/docs.yml: -------------------------------------------------------------------------------- 1 | name: xdggs-docs 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.12 6 | - sphinx 7 | - myst-nb 8 | - sphinx-autobuild 9 | - sphinx-book-theme 10 | - sphinx-autosummary-accessors 11 | - sphinx-copybutton 12 | - sphinx-design 13 | - sphinx-inline-tabs 14 | - sphinxcontrib-bibtex 15 | - xarray 16 | - h5netcdf 17 | - netcdf4 18 | - pooch 19 | - matplotlib-base 20 | - shapely 21 | - pytest 22 | - hypothesis 23 | - ruff 24 | - typing-extensions 25 | - geoarrow-pyarrow 26 | - lonboard 27 | - arro3-core 28 | - h3ronpy 29 | - cdshealpix 30 | - pip 31 | - pip: 32 | - -e .. 33 | -------------------------------------------------------------------------------- /ci/environment.yml: -------------------------------------------------------------------------------- 1 | channels: 2 | - conda-forge 3 | dependencies: 4 | - xarray 5 | - h5netcdf 6 | - netcdf4 7 | - pooch 8 | - matplotlib-base 9 | - shapely 10 | - pytest 11 | - hypothesis 12 | - ruff 13 | - typing-extensions 14 | - geoarrow-pyarrow 15 | - lonboard 16 | - arro3-core 17 | - cdshealpix 18 | - h3ronpy 19 | -------------------------------------------------------------------------------- /ci/install-upstream-wheels.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if which micromamba; then 4 | conda=micromamba 5 | else 6 | conda=mamba 7 | fi 8 | 9 | # force-remove re-installed versions 10 | $conda remove -y --force \ 11 | xarray \ 12 | pandas \ 13 | cdshealpix 14 | python -m pip uninstall -y h3ronpy 15 | 16 | # install from scientific-python wheels 17 | python -m pip install \ 18 | -i https://pypi.anaconda.org/scientific-python-nightly-wheels/simple \ 19 | --no-deps \ 20 | --pre \ 21 | --upgrade \ 22 | pandas \ 23 | numpy \ 24 | xarray 25 | # pyarrow nightly builds 26 | python -m pip install \ 27 | -i https://pypi.fury.io/arrow-nightlies/ \ 28 | --prefer-binary \ 29 | --no-deps \ 30 | --pre \ 31 | --upgrade \ 32 | pyarrow 33 | 34 | 35 | # install from github 36 | python -m pip install --no-deps --upgrade \ 37 | git+https://github.com/Unidata/cftime \ 38 | git+https://github.com/astropy/astropy \ 39 | "git+https://github.com/nmandery/h3ronpy#subdirectory=h3ronpy" \ 40 | git+https://github.com/cds-astro/cds-healpix-python 41 | -------------------------------------------------------------------------------- /design_doc.md: -------------------------------------------------------------------------------- 1 | # XDGGS - Design document 2 | 3 | Xarrays extension for DGGS. Technical specifications. 4 | 5 | ## Goals 6 | 7 | The goal of the `xdggs` library is to facilitate working with multiple Discrete Global Grid Systems (DGGSs) via a unified, high-level and user-friendly API that is deeply integrated with [Xarray](https://xarray.dev). 8 | This document describes the in-memory representation of DGGS data in Python environments. 9 | 10 | Examples of common DGGS features that `xdggs` should provide or facilitate: 11 | 12 | - convert a DGGS from/to another grid (e.g., a DGGS, a latitude/longitude rectilinear grid, a raster grid, an unstructured mesh) 13 | - convert a DGGS from/to vector data (points, lines, polygons, envelopes) 14 | - convert between different cell id representations of a same DGGS (e.g., uint64 vs. string) 15 | - select data on a DGGS by cell ids or by geometries (spatial indexing) 16 | - expand and reduce the available resolutions of a DGGS using down and upsampling, respectively. 17 | - operations between similar DGGS (with auto-alignment) 18 | - re-organize cell ids (e.g., spatial shuffling / partitioning) 19 | - plotting 20 | 21 | Conversion between DGGS and other grids or vector features may require specific interpolation or regridding methods. 22 | 23 | `xdggs` should leverage the current recommended Xarray extension mechanisms ([apply_ufunc](https://docs.xarray.dev/en/stable/examples/apply_ufunc_vectorize_1d.html), [accessors](https://docs.xarray.dev/en/stable/internals/extending-xarray.html), [custom indexes](https://docs.xarray.dev/en/stable/internals/how-to-create-custom-index.html)) and possibly the future ones (e.g., variable encoders/decoders) to provide DGGS-specific functionality on top of Xarray core features. 24 | 25 | `xdggs` should facilitate interoperability with other existing Xarray extensions (e.g., [xvec](https://github.com/xarray-contrib/xvec) for vector data or [uxarray](https://github.com/UXARRAY/uxarray) for unstructured grids). 26 | 27 | `xdggs` should also leverage the existing implementation of various DGGSs exposed to Python via 3rd-party libraries, here below referred as "backends". Preferrably, those backends would expose DGGS functionality in an efficient way as vectorized functions (e.g., working with NumPy arrays). 28 | 29 | `xdggs` should try to follow standards and/or conventions defined for DGGS (see below). However, we may need to depart from them for practical reasons (e.g., common practices in popular DGGS libraries that do not fit well with the proposed standards). Strict adherence to a standard is welcome but shouldn't be enforced by all means. 30 | 31 | `xdggs` should also try to support applications in both GIS and Earth-System communities, which may each use DGGS in slightly different ways (see examples below). 32 | 33 | When possible, `xdggs` operations should scale to fine DGGS resolutions (billions of cells). This can be done vertically using backends with vectorized bindings of DGGS implementations written in low-level languages and/or horizontally leveraging Xarray interoperability with Dask. Some operations like spatial indexing may be hard to scale horizontally, though. For the latter, we should probably focus `xdggs` development first towards good vertical scaling before figuring out how they can be scaled horizontally (for reference, see [dask-geopandas](https://github.com/geopandas/dask-geopandas) and [spatialpandas](https://github.com/holoviz/spatialpandas)). 34 | 35 | ## Non-Goals 36 | 37 | `xdggs` should focus on providing the core DGGS functionality and operations that are listed above. Higher-level operations that can be implemented by combining together those core operations are out-of-scope and should be implemented in downstream libraries. Likewise, there may be many ways of resampling a grid to a DGGS ; `xdggs` should support the most common methods but not try to support _all of them_. 38 | 39 | `xdggs` should try not re-inventing the wheel and delegate to Xarray API when possible. 40 | 41 | `xdggs` does not implement any particular DGGS from scratch. `xdggs` does not aim at providing _all the functionality provided by each grid_ (e.g., some functionality may be very specific to one DGGS and not supported by other DGGSs, or some functionality may not be available yet in one DGGS Python backend). 42 | 43 | Although some DGGS may handle both the spatial and temporal domains in a joint fashion, `xdggs` focuses primarily on the spatial domain. The temporal domain is considered as orthogonal and already benefits from many core features provided by Xarray. 44 | 45 | ## Discrete Global Grid Systems 46 | 47 | A Discrete Global Grid System (DGGS) can be roughly defined as a partitioning or tessellation of the entire Earth's surface into a finite number of "cells" or "zones". The shape and the properties of these cells generally vary from one DGGS to another. Most DGGSs are also hierarchical, i.e., the cells are arranged recursively on multiple levels or resolutions. Follow the links in the subsection below for a more strict and detailed definition of a DGGS. 48 | 49 | DGGSs may be used in various ways, e.g., 50 | 51 | - Applications in Earth-system modelling seem to use DGGS as grids of contiguous, fixed-resolution cells covering the entire Earth's surface or a region of interest (figure 1). This makes the analysis of simulation outputs on large extents of the Earth's surface easier. DGGS may also be used as pyramid data (multiple stacked datasets at different resolutions) 52 | - Applications in GIS often consist of using DGGS to display aggregated (vector) data as a collection of cells with a more complex spatial distribution (sparse) and sometimes with mixed resolutions (figures 2 and 3). 53 | 54 | ![figure1](https://user-images.githubusercontent.com/4160723/281698490-31cb5ce8-64db-4bbf-a0d9-a8d6597bb2df.png) 55 | Figure 1: DGGS data as contiguous cells of fixed resolution ([source](https://danlooo.github.io/DGGS.jl/)) 56 | 57 | ![figure2](https://github.com/benbovy/xdggs/assets/4160723/430fd646-220a-4027-8212-1d927bb339ba) 58 | 59 | Figure 2: Data aggreated on DGGS (H3) sparsely distributed cells of fixed resolution ([source](https://medium.com/@jesse.b.nestler/how-to-convert-h3-cell-boundaries-to-shapely-polygons-in-python-f7558add2f63)). 60 | 61 | ![image](https://github.com/benbovy/xdggs/assets/4160723/f2e4ec02-d88e-475e-9067-e93cf185923e) 62 | 63 | Figure 3: Raster data converted as DGGS (H3) cells of mixed resolutions ([source](https://github.com/nmandery/h3ronpy)). 64 | 65 | ### Standards and Conventions 66 | 67 | The [OGC abstract specification topic 21](http://www.opengis.net/doc/AS/dggs/2.0) defines properties of a DGGS including the reference systems of its grids. 68 | 69 | However, there is no consensus yet about the actual specification on how to work with DGGS-indexed data. 70 | [OGC API draft](https://github.com/opengeospatial/ogcapi-discrete-global-grid-systems) defines ways of how to access DGGS data via web-based API. 71 | A [DGGS data specification draft](https://github.com/danlooo/dggs-data-spec) aims to specify the storage format of DGGS data. 72 | 73 | There are discrepancies between the proposed standards and popular DGGS libraries (H3, S2, HealPIX). For example regarding the term used to define a grid unit: The two specifications above use "zone", S2/H3 use "cell" and HealPIX uses "pixel". 74 | OGC abstract specification topic 21 defines the region as a zone and its boundary geometry as a cell. 75 | Although in this document we use "cell", the term to choose for `xdggs` is still open for discussion. 76 | 77 | Furthermore, several libraries allow for customised instantiation of a DGGS. This makes it crucial to be able to specify the particular DGGS type and potentially additional parameters. The OGC DGGS working group is discussing how to define a DGGS reference system, analogously to the spatial/coordinate reference systems registries (PROJ, EPSG, ..). 78 | 79 | The OGC is [currently discussing](https://github.com/opengeospatial/ogcapi-discrete-global-grid-systems/issues/41) ways to describe and identify unique DGGS types. This is slightly different from storing DGGS data. But the important info here is to be able to store the metadata about the specifics of the used DGGS in form e.g. an identifier, label, link to a detailed definition. This would likely need to include the main type e.g. H3, S2, HEALPIX, RHEALPIX, DGGGRID_ISEAxxx, but also required parameters like HEALPIX (indexing: nested or ring), RHEALPIX (ellipsoid, orientation/rotation ..), DGGRID-icosahedron-based DGGS types (orientation, potentially mixed apertures..), GeoSOT. It would be good to have synergies here and not reinvent the wheel. 80 | 81 | ### Backends (Python) 82 | 83 | Several Python packages are currently available for handling certain DGGSs. They mostly consist of Python bindings of DGGS implementations written in C/C++/Rust. Here is a list (probably incomplete): 84 | 85 | - [healpy](https://github.com/healpy/healpy): Python bindings of [HealPix](https://healpix.sourceforge.io/) 86 | - mostly vectorized 87 | - [rhealpixdggs-py](https://github.com/manaakiwhenua/rhealpixdggs-py): Python/Numpy implementation of rHEALPix 88 | - [h3-py](https://github.com/uber/h3-py): "official" Python bindings of [H3](https://h3geo.org/) 89 | - experimental and incomplete vectorized version of H3's API (removed in the forthcoming v4 release?) 90 | - [h3pandas](https://github.com/DahnJ/H3-Pandas): integration of h3-py (non-vectorized) with pandas and geopandas 91 | - [h3ronpy](https://github.com/nmandery/h3ronpy): Python bindings of [h3o](https://github.com/HydroniumLabs/h3o) (Rust implementation of H3) 92 | - provides high-level features (conversion, etc.) working with arrow, numpy (?), pandas/geopandas and polars 93 | - [s2geometry](https://github.com/google/s2geometry): Python bindings generated with SWIG 94 | - not vectorized nor very "pythonic" 95 | - plans to switch to pybind11 (no time frame given) 96 | - [spherely](https://github.com/benbovy/spherely): Python bindings of S2, mostly copying shapely's API 97 | - provides numpy-like universal functions 98 | - not yet ready for use 99 | - [dggrid4py](https://github.com/allixender/dggrid4py): Python wrapper for [DGGRID](https://github.com/sahrk/DGGRID) 100 | - DGGRID implements many DGGS variants! 101 | - DGGRID current design makes it hardly reusable from within Python in an optimal way (the dggrid wrapper communicates with DGGRID through OS processes and I/O generated files) 102 | 103 | ## Representation of DGGS Data in Xdggs 104 | 105 | `xdggs` represents a DGGS as an Xarray Dataset or DataArray containing a 1-dimensional coordinate with cell ids as labels and with grid name, resolution & parameters (optional) as attributes. This coordinate is indexed using a custom, Xarray-compatible `DGGSIndex`. Multiple dimensions may be used if the coordinate consists of multiple parts, e.g., polyhedron face, x, and y on that face in DGGRID PROJTRI. 106 | 107 | `xdggs` does not support a Dataset or DataArray with multiple coordinates indexed with a `DGGSIndex` (only one DGGS per object is supported). 108 | 109 | The cell ids in the 1-dimensional coordinate are all relative to the _exact same_ grid, i.e., same grid system, same grid parameter values and same grid resolution! For simplicity, `xdggs` does not support cell ids of mixed-resolutions in the same coordinate. 110 | 111 | ### DGGSIndex 112 | 113 | `xdggs.DGGSIndex` is the base class for all Xarray DGGS-aware indexes. It inherits from `xarray.indexes.Index` and has the following specifications: 114 | 115 | - It encapsulates an `xarray.indexes.PandasIndex` built from cell ids so that selection and alignment by cell id is possible 116 | - It might also eventually encapsulate a spatial index (RTree, KDTree) to enable data selection by geometries, e.g., find nearest cell centroids, extract all cells intersecting a polygon, etc. 117 | - Alternatively, spatial indexing might be enabled by explicit conversion of cells to vector geometries and then by reusing the Xarray spatial indexes available in [xvec](https://github.com/xarray-contrib/xvec) 118 | - It partially implements the Xarray Index API to enable DGGS-aware alignment and selection 119 | - Calls are most often redirected to the encapsulated `PandasIndex` 120 | - Some pre/post checks or processing may be done, e.g., to prevent the alignment of two indexes that are not on the exact same grid. 121 | - The `DGGSIndex.__init__()` constructor only requires cell ids and the name of the cell (array) dimension 122 | - The `DGGSIndex.from_variables()` factory method parses the attributes of the given cell ids coordinates and creates the right index object (subclass) accordingly 123 | - It declares a few abstract methods for grid-aware operations (e.g., convert between cell id and lat/lon point or geometry, etc.) 124 | - They can be implemented in subclasses (see below) 125 | - They are either called from within the DGGSIndex or from the `.dggs` Dataset/DataArray accessors 126 | 127 | Each DGGS supported in `xdggs` has its own subclass of `DGGSIndex`, e.g., 128 | 129 | - `HealpixIndex` for Healpix 130 | - `H3Index` for H3 131 | - ... 132 | 133 | A DGGSIndex can be set directly from a cell ids coordinate using the Xarray API: 134 | 135 | ```python 136 | import xarray as xr 137 | import xdggs 138 | 139 | ds = xr.Dataset( 140 | coords={"cell": ("cell", [...], {"grid_name": "h3", "resolution": 3})} 141 | ) 142 | 143 | # auto-detect grid system and parameters 144 | ds.set_xindex("cell", xdggs.DGGSIndex) 145 | 146 | # set the grid system and parameters manually 147 | ds.set_xindex("cell", xdggs.H3Index, resolution=3) 148 | ``` 149 | 150 | The DGGSIndex is set automatically when converting a gridded or vector dataset to a DGGS dataset (see below). 151 | 152 | ## Conversion from/to DGGS 153 | 154 | DGGS data may be created from various sources, e.g., 155 | 156 | - regridded from a latitude/longitude rectilinear grid 157 | - regridded from an unstructured grid 158 | - regridded and reprojected from a raster having a local projection 159 | - aggregated from vector point data 160 | - filled from polygon data 161 | 162 | Conversely, DGGS data may be converted to various forms, e.g., 163 | 164 | - regridded on a latitude/longitude rectilinear grid 165 | - rasterized (resampling / projection) 166 | - conversion to vector point data (cell centroids) 167 | - conversion to vector polygon data (cell boundaries) 168 | 169 | Here is a tentative API based on Dataset/DataArray `.dggs` accessors (note: other options are discussed in [this issue](https://github.com/xarray-contrib/xdggs/issues/13)): 170 | 171 | ```python 172 | # "convert" directly from existing cell ids coordinate to DGGS 173 | # basically an alias to ds.set_xindex(..., DGGSIndex) 174 | ds.dggs.from_cell_ids(...) 175 | 176 | # convert from lat/lon grid 177 | ds.dggs.from_latlon_grid(...) 178 | 179 | # convert from raster 180 | ds.dggs.from_raster(...) 181 | 182 | # convert from point data 183 | ds.dggs.from_points(...) 184 | 185 | # convert from point data (with aggregation) 186 | ds.dggs.from_points_aggregate(...) 187 | 188 | # convert from point data (with aggregation using Xarray API) 189 | ds.dggs.from_points(...).groupby(...).mean() 190 | 191 | # convert to lat/lon grid 192 | ds.dggs.to_latlon_grid(...) 193 | 194 | # convert to raster 195 | ds.dggs.to_raster(...) 196 | 197 | # convert to points (cell centroids) 198 | ds.dggs.to_points(...) 199 | 200 | # convert to polygons (cell boundaries) 201 | ds.dggs.to_polygons(...) 202 | ``` 203 | 204 | In the API methods above, the "dggs" accessor name serves as a prefix. 205 | 206 | Those methods are all called from an existing xarray Dataset (DataArray) and should all return another Dataset (DataArray): 207 | 208 | - Xarray has built-in support for regular grids 209 | - for rasters, we could return objects that are [rioxarray](https://github.com/corteva/rioxarray)-friendly 210 | - for vector data, we could return objects that are [xvec](https://github.com/xarray-contrib/xvec)-friendly (coordinate of shapely objects) 211 | - etc. 212 | 213 | ## Extracting DGGS Cell Geometries 214 | 215 | DGGS cell geometries could be extracted using the conversion methods proposed above. Alternatively, it would be convenient to get them directly as xarray DataArrays so that we can for example manually assign them as coordinates. 216 | 217 | The API may look like: 218 | 219 | ```python 220 | # return a DataArray of DGGS cell centroids as shapely.POINT objects 221 | ds.dggs.cell_centroids() 222 | 223 | # return two DataArrays of DGGS cell centroids as lat/lon coordinates 224 | ds.dggs.cell_centroids_coords() 225 | 226 | # return a DataArray of DGGS cell boundaries as shapely.POLYGON objects 227 | ds.dggs.cell_boundaries() 228 | 229 | # return a DataArray of DGGS cell envelopes as shapely.POLYGON objects 230 | ds.dggs.cell_envelopes() 231 | ``` 232 | 233 | ## Indexing and Selecting DGGS Data 234 | 235 | ### Selection by Cell IDs 236 | 237 | The simplest way to select DGGS data is by cell ids. This can be done directly using Xarray's API (`.sel()`): 238 | 239 | ```python 240 | ds.sel(cell=value) 241 | ``` 242 | 243 | where `value` can be a single cell id (integer or string/token?) or an array-like of cell ids. This is easily supported by the DGGSIndex encapsulating a PandasIndex. We might also want to support other `value` types, e.g., 244 | 245 | - assuming that DGGS cell ids are defined such that contiguous cells in space have contiguous id values, we could provide a `slice` to define a range of cell ids. 246 | - DGGSIndex might implement some DGGS-aware logic such that it auto-detects if the given input cells are parent cells (lower DGGS resolution) and then selects all child cells accordingly. 247 | 248 | We might want to select cell neighbors (i.e., return a new Dataset/DataArray with a new neighbor dimension), probably via a specific API (`.dggs` accessors). 249 | 250 | ### Selection by Geometries (Spatial Indexing) 251 | 252 | Another useful way of selecting DGGS data is from input geometries (spatial queries), e.g., 253 | 254 | - Select all cells that are the closest to a collection of data points 255 | - Select all cells that intersects with or are fully contained in a polygon 256 | 257 | This kind of selection requires spatial indexes as this can not be done with a pandas index (see [this issue](https://github.com/xarray-contrib/xdggs/issues/16)). 258 | 259 | If we support spatial indexing directly in `xdggs`, we can hardly reuse Xarray's `.sel()` for spatial queries as `ds.sel(cell=shapely.Polygon(...))` would look quite confusing. Perhaps better would be to align with [xvec](https://github.com/xarray-contrib/xvec) and have a separate `.dggs.query()` method. 260 | 261 | Alternatively, we could just get away with the conversion and cell geometry extraction methods proposed above and leave spatial indexes/queries to [xvec](https://github.com/xarray-contrib/xvec). 262 | 263 | ## Handling hierarchical DGGS 264 | 265 | DGGS are grid systems with grids of the same topology but different spatial resolution. 266 | There is a hierarchical relationship between grids of different resolutions. 267 | Even though the coordinate of one grid in the DGGS of a Dataset (DataArray) is limited to cell ids of same resolution (no mixed-resolutions), `xdggs` can still provide functionality to deal with the hierarchical aspect of DGGSs. 268 | 269 | Selection by parent cell ids may be in example (see section above). Another example would be to have utility methods to explicitly change the grid resolution (see [issue #18](https://github.com/xarray-contrib/xdggs/issues/18) for more details and discussion). 270 | One can also store DGGS data at all resolutions as a list of datasets. 271 | 272 | However, like in hexagonal grids of aperture 3 or 4 (e.g. DGGRID ISEA4H), the parent child relationship can be also ambiguous. 273 | The actual spatial aggregation functions in the subclasses might be implemented differently depending on the selected DGGS. 274 | 275 | ## Operations between similar DGGS (alignment) 276 | 277 | Computation involving multiple DGGS datasets (or dataarrays) often requires to align them together. Sometimes this can be trivial (same DGGS with same resolution and parameter values) but in other cases this can be complex (requires regridding or a change of DGGS resolution). 278 | 279 | In Xarray, alignment of datasets (dataarrays) is done primarily via their indexes. Since a DGGSIndex wraps a PandasIndex, it is easy to support alignment by cells ids (trivial case). At the very least, a DGGSIndex should raise an error when trying to align cell ids that do not refer to the exact same DGGS (i.e., same system, resolution and parameter values). For the complex cases, it may be preferable to handle them manually instead of trying to make the DGGSIndex perform the alignment automatically. Regridding and/or changing the resolution of a DGGS (+ data aggregation) often highly depend on the use-case so it might be hard to find a default behavior. Also performing those operations automatically and implicitly would probably feel too magical. That being said, in order to help alignment `xdggs` may provide some utility methods to change the grid resolution (see section above) and/or to convert from one DGGS to another. 280 | 281 | ## Plotting 282 | 283 | Three approaches are possible (non-mutually exclusive): 284 | 285 | 1. convert cell data into gridded or raster data (choose grid/raster resolution depending on the resolution of the rendered figure) and then reuse existing python plotting libraries (matplotlib, cartopy) maybe through xarray plotting API 286 | 2. convert cell data into vector data and plot the latter via, e.g., [xvec](https://github.com/xarray-contrib/xvec) or [geopandas](https://github.com/geopandas/geopandas) API 287 | 3. leverage libraries that support plotting DGGS data, e.g., [lonboard](https://github.com/developmentseed/lonboard) enables interactive plotting in Jupyter via deck.gl, which has support of H3 and S2 cell data. 288 | 289 | The 3rd approach (lonboard) is efficient for plotting large DGGS data: we would only need to transfer cell ids (tokens) and cell data and then let deck.gl render the cells efficiently in the web browser using the GPU. For approach 1, we might want to investigate using [datashader](https://github.com/holoviz/datashader) to set both the resolution and raster extent dynamically. Likewise for approach 2, we could dynamically downgrade the DGGS resolution and aggregate the data before converting it into vector data in order to allow (interactive) plotting of large DGGS data. 290 | -------------------------------------------------------------------------------- /docs/_static/logos/xdggs_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xarray-contrib/xdggs/0a37d34c601f8521dec9228115ecba248e0b751e/docs/_static/logos/xdggs_logo.png -------------------------------------------------------------------------------- /docs/api-hidden.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | 3 | .. currentmodule:: xdggs 4 | 5 | .. autosummary:: 6 | :toctree: generated 7 | 8 | DGGSInfo.level 9 | 10 | DGGSInfo.from_dict 11 | DGGSInfo.to_dict 12 | DGGSInfo.cell_boundaries 13 | DGGSInfo.cell_ids2geographic 14 | DGGSInfo.geographic2cell_ids 15 | 16 | HealpixInfo.level 17 | HealpixInfo.indexing_scheme 18 | HealpixInfo.valid_parameters 19 | HealpixInfo.nside 20 | HealpixInfo.nest 21 | 22 | HealpixInfo.from_dict 23 | HealpixInfo.to_dict 24 | HealpixInfo.cell_boundaries 25 | HealpixInfo.cell_ids2geographic 26 | HealpixInfo.geographic2cell_ids 27 | 28 | H3Info.level 29 | H3Info.valid_parameters 30 | 31 | H3Info.from_dict 32 | H3Info.to_dict 33 | H3Info.cell_boundaries 34 | H3Info.cell_ids2geographic 35 | H3Info.geographic2cell_ids 36 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | ############# 4 | API reference 5 | ############# 6 | 7 | Top-level functions 8 | =================== 9 | 10 | .. currentmodule:: xdggs 11 | 12 | .. autosummary:: 13 | :toctree: generated 14 | 15 | decode 16 | 17 | Grid parameter objects 18 | ====================== 19 | 20 | .. autosummary:: 21 | :toctree: generated 22 | 23 | DGGSInfo 24 | 25 | HealpixInfo 26 | H3Info 27 | 28 | .. currentmodule:: xarray 29 | 30 | Dataset 31 | ======= 32 | 33 | Parameters 34 | ---------- 35 | .. autosummary:: 36 | :toctree: generated 37 | :template: autosummary/accessor_attribute.rst 38 | 39 | Dataset.dggs.grid_info 40 | Dataset.dggs.params 41 | Dataset.dggs.decode 42 | 43 | 44 | Data inference 45 | -------------- 46 | 47 | .. autosummary:: 48 | :toctree: generated 49 | :template: autosummary/accessor_method.rst 50 | 51 | Dataset.dggs.cell_centers 52 | Dataset.dggs.cell_boundaries 53 | 54 | DataArray 55 | ========= 56 | 57 | Parameters 58 | ---------- 59 | .. autosummary:: 60 | :toctree: generated 61 | :template: autosummary/accessor_attribute.rst 62 | 63 | DataArray.dggs.grid_info 64 | DataArray.dggs.params 65 | DataArray.dggs.decode 66 | 67 | 68 | Data inference 69 | -------------- 70 | 71 | .. autosummary:: 72 | :toctree: generated 73 | :template: autosummary/accessor_method.rst 74 | 75 | DataArray.dggs.cell_centers 76 | DataArray.dggs.cell_boundaries 77 | 78 | Plotting 79 | -------- 80 | .. autosummary:: 81 | :toctree: generated 82 | :template: autosummary/accessor_method.rst 83 | 84 | DataArray.dggs.explore 85 | 86 | Tutorial 87 | ======== 88 | 89 | .. currentmodule:: xdggs 90 | 91 | .. autosummary:: 92 | :toctree: generated 93 | 94 | tutorial.open_dataset 95 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.2.1 (_unreleased_) 4 | 5 | ### New features 6 | 7 | ### Bug fixes 8 | 9 | ### Documentation 10 | 11 | - Documentation Contributer Guide + Github Button ({pull}`137`) 12 | 13 | ### Internal changes 14 | 15 | ## 0.2.0 (2025-02-12) 16 | 17 | ## New features 18 | 19 | - allow adding additional coords to the cell inspection table in the map ({pull}`122`) 20 | - allow passing `matplotlib` colormap objects to `explore` ({pull}`120`) 21 | - support plotting multi-dimensional data ({pull}`124`) 22 | - allow overriding the grid info data using the function or a new accessor ({pull}`63`, {pull}`121`) 23 | 24 | ## Bug fixes 25 | 26 | - use explicit `arrow` API to extract cell coordinates ({issue}`113`, {pull}`114`) 27 | - correct the `HealpixIndex` `repr` ({pull}`119`) 28 | 29 | ## Internal changes 30 | 31 | - add initial set of tests for `explore` ({pull}`127`) 32 | - adapt to recent changes on RTD ({pull}`122`) 33 | 34 | ## 0.1.1 (2024-11-25) 35 | 36 | ### Bug fixes 37 | 38 | - properly reference the readme in the package metadata ({pull}`106`) 39 | 40 | ## 0.1.0 (2024-11-25) 41 | 42 | ### Enhancements 43 | 44 | - derive cell boundaries ({pull}`30`) 45 | - add grid objects ({pull}`39`, {pull}`57`) 46 | - decoder function ({pull}`47`, {pull}`48`) 47 | - rename the primary grid parameter to `level` ({pull}`65`) 48 | - interactive plotting with `lonboard` ({pull}`67`) 49 | - expose example datasets through `xdggs.tutorial` ({pull}`84`) 50 | - add a preliminary logo ({pull}`101`, {pull}`103`) 51 | 52 | ### Bug fixes 53 | 54 | - fix the cell centers computation ({pull}`61`) 55 | - work around blocked HTTP requests from RTD to github ({pull}`93`) 56 | 57 | ### Documentation 58 | 59 | - create a readme ({pull}`70`) 60 | - create the documentation ({pull}`79`, {pull}`80`, {pull}`81`, {pull}`89`) 61 | - fix headings in tutorials ({pull}`90`) 62 | - rewrite the readme ({pull}`97`) 63 | 64 | ### Internal changes 65 | 66 | - replace `h3` with `h3ronpy` ({pull}`28`) 67 | - setup CI ({pull}`31`) 68 | - tests for the healpix index ({pull}`36`) 69 | - testing utils for exception groups ({pull}`55`) 70 | 71 | ## 0.0.1 (2023-11-28) 72 | 73 | Preliminary version of `xdggs` python package. 74 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -- Project information ----------------------------------------------------- 2 | import datetime as dt 3 | 4 | import sphinx_autosummary_accessors 5 | 6 | import xdggs # noqa: F401 7 | 8 | project = "xdggs" 9 | author = f"{project} developers" 10 | initial_year = "2023" 11 | year = dt.datetime.now().year 12 | copyright = f"{initial_year}-{year}, {author}" 13 | 14 | # The root toctree document. 15 | root_doc = "index" 16 | 17 | # -- General configuration --------------------------------------------------- 18 | 19 | # Add any Sphinx extension module names here, as strings. They can be 20 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 21 | # ones. 22 | 23 | source_suffix = { 24 | ".rst": "restructuredtext", 25 | ".md": "myst-nb", # enables myst-nb support for plain md files 26 | } 27 | 28 | extensions = [ 29 | "sphinx.ext.extlinks", 30 | "sphinx.ext.intersphinx", 31 | "sphinx.ext.autodoc", 32 | "sphinx.ext.autosummary", 33 | "sphinx.ext.napoleon", 34 | "IPython.sphinxext.ipython_directive", 35 | "IPython.sphinxext.ipython_console_highlighting", 36 | "sphinx_autosummary_accessors", 37 | "myst_nb", 38 | "sphinx_design", 39 | "sphinx_copybutton", 40 | "sphinxcontrib.bibtex", 41 | ] 42 | 43 | extlinks = { 44 | "issue": ("https://github.com/xarray-contrib/xdggs/issues/%s", "GH%s"), 45 | "pull": ("https://github.com/xarray-contrib/xdggs/pull/%s", "PR%s"), 46 | } 47 | 48 | # Add any paths that contain templates here, relative to this directory. 49 | templates_path = ["_templates", sphinx_autosummary_accessors.templates_path] 50 | 51 | # List of patterns, relative to source directory, that match files and 52 | # directories to ignore when looking for source files. 53 | # This pattern also affects html_static_path and html_extra_path. 54 | exclude_patterns = ["_build", "directory"] 55 | 56 | ## Github Buttons 57 | html_theme_options = { 58 | "repository_url": "https://github.com/xarray-contrib/xdggs", 59 | "use_repository_button": True, 60 | "use_issues_button": True, 61 | } 62 | 63 | # -- autosummary / autodoc --------------------------------------------------- 64 | 65 | autosummary_generate = True 66 | autodoc_typehints = "none" 67 | 68 | # -- napoleon ---------------------------------------------------------------- 69 | 70 | napoleon_numpy_docstring = True 71 | napoleon_use_param = False 72 | napoleon_use_rtype = False 73 | napoleon_preprocess_types = True 74 | napoleon_type_aliases = { 75 | # general terms 76 | "sequence": ":term:`sequence`", 77 | "iterable": ":term:`iterable`", 78 | "callable": ":py:func:`callable`", 79 | "dict_like": ":term:`dict-like `", 80 | "dict-like": ":term:`dict-like `", 81 | "path-like": ":term:`path-like `", 82 | "mapping": ":term:`mapping`", 83 | "file-like": ":term:`file-like `", 84 | "any": ":py:class:`any `", 85 | # numpy terms 86 | "array_like": ":term:`array_like`", 87 | "array-like": ":term:`array-like `", 88 | "scalar": ":term:`scalar`", 89 | "array": ":term:`array`", 90 | "hashable": ":term:`hashable `", 91 | } 92 | 93 | # -- Options for HTML output ------------------------------------------------- 94 | 95 | # The theme to use for HTML and HTML Help pages. See the documentation for 96 | # a list of builtin themes. 97 | # 98 | html_theme = "sphinx_book_theme" 99 | html_logo = "_static/logos/xdggs_logo.png" 100 | 101 | # Add any paths that contain custom static files (such as style sheets) here, 102 | # relative to this directory. They are copied after the builtin static files, 103 | # so a file named "default.css" will overwrite the builtin "default.css". 104 | # html_static_path = ["_static"] 105 | 106 | # -- Options for the intersphinx extension ----------------------------------- 107 | 108 | intersphinx_mapping = { 109 | "python": ("https://docs.python.org/3/", None), 110 | "sphinx": ("https://www.sphinx-doc.org/en/stable/", None), 111 | "numpy": ("https://numpy.org/doc/stable", None), 112 | "xarray": ("https://docs.xarray.dev/en/latest/", None), 113 | "pandas": ("https://pandas.pydata.org/pandas-docs/stable", None), 114 | "lonboard": ("https://developmentseed.org/lonboard/latest", None), 115 | "healpy": ("https://healpy.readthedocs.io/en/latest", None), 116 | "cdshealpix-python": ("https://cds-astro.github.io/cds-healpix-python", None), 117 | "shapely": ("https://shapely.readthedocs.io/en/stable", None), 118 | } 119 | 120 | # -- myst-nb options --------------------------------------------------------- 121 | 122 | nb_execution_timeout = -1 123 | nb_execution_cache_path = "_build/myst-nb" 124 | 125 | # myst options --------------------------------------------------------------- 126 | myst_enable_extensions = [ 127 | "colon_fence", # Enables ::: directive syntax 128 | "deflist", 129 | "html_admonition", 130 | "html_image", 131 | "replacements", 132 | "substitution", 133 | ] 134 | 135 | # -- sphinxcontrib-bibtex ---------------------------------------------------- 136 | 137 | bibtex_bibfiles = ["reference_guide/publications.bib"] 138 | -------------------------------------------------------------------------------- /docs/contributor_guide/docs.md: -------------------------------------------------------------------------------- 1 | # Contribute to the Documentation 2 | 3 | ## Building the docs locally 4 | 5 | Set up your local environment with either mamba or conda 6 | 7 | ::::{tab-set} 8 | :::{tab-item} Mamba 9 | 10 | ```shell 11 | mamba env create -f ci/docs.yml 12 | ``` 13 | 14 | ::: 15 | 16 | :::{tab-item} Conda 17 | 18 | ```shell 19 | conda env create -f ci/docs.yml 20 | ``` 21 | 22 | ::: 23 | :::: 24 | 25 | And build the documentation locally (all commands assume you are in the root repo directory) 26 | 27 | ::::{tab-set} 28 | :::{tab-item} Automatically show changes 29 | 30 | ``` 31 | sphinx-autobuild docs docs/_build/html --open-browser 32 | ``` 33 | 34 | This will open a browser window that shows a live preview (meaning that changes you make to the configuration and content will be automatically updated and shown in the browser). 35 | ::: 36 | 37 | :::{tab-item} Build and open manually 38 | 39 | From the root repo diretory build the html 40 | 41 | ```shell 42 | sphinx-build -b html docs docs/_build/html 43 | ``` 44 | 45 | and open it in a browser 46 | 47 | ``` 48 | open docs/_build/html/index.html # macOS 49 | xdg-open docs/_build/html/index.html # Linux 50 | start docs/_build/html/index.html # Windows 51 | ``` 52 | 53 | You will have to repeat these steps when you make changes 54 | ::: 55 | :::: 56 | -------------------------------------------------------------------------------- /docs/getting_started/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | `xdggs` can be installed from PyPI: 4 | 5 | ```shell 6 | pip install xdggs 7 | ``` 8 | 9 | from `conda-forge`: 10 | 11 | ```shell 12 | conda install -c conda-forge xdggs 13 | ``` 14 | 15 | from github: 16 | 17 | ```shell 18 | pip install "xdggs @ git+https://github.com/xarray-contrib/xdggs.git" 19 | ``` 20 | 21 | or from source: 22 | 23 | ```shell 24 | git clone https://github.com/xarray-contrib/xdggs.git 25 | cd xdggs 26 | pip install . 27 | ``` 28 | 29 | ## Minimum dependency policy 30 | 31 | ```{warning} 32 | `xdggs` is experimental and thus will switch to newer versions of dependencies if there's significant benefit. 33 | ``` 34 | 35 | Otherwise, it will follow a rolling release policy similar to {ref}`xarray:/getting-started-guide/installing.rst#minimum-dependency-versions`: 36 | 37 | - **Python**: 30 months 38 | - **numpy**: 12 months 39 | - **all other libraries**: 6 months 40 | 41 | This means that `xdggs` will be allowed to require a minor version (X.Y) once it is older than N months. 42 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | ```{toctree} 2 | --- 3 | maxdepth: 3 4 | caption: Getting Started 5 | hidden: true 6 | --- 7 | 8 | Installation 9 | ``` 10 | 11 | ```{toctree} 12 | --- 13 | maxdepth: 3 14 | caption: Tutorials 15 | hidden: true 16 | --- 17 | tutorials/h3 18 | tutorials/healpix 19 | ``` 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ```{toctree} 30 | --- 31 | maxdepth: 3 32 | caption: Reference guide 33 | hidden: true 34 | --- 35 | 36 | Changelog 37 | API Reference 38 | Publications 39 | ``` 40 | 41 | ```{toctree} 42 | --- 43 | maxdepth: 3 44 | caption: Contributor Guide 45 | hidden: true 46 | --- 47 | 48 | Contribute to the Documentation 49 | ``` 50 | 51 | # Welcome to `xdggs` 52 | 53 | [![PyPI](https://img.shields.io/pypi/v/xdggs.svg?style=flat)](https://pypi.org/project/xdggs) 54 | 55 | _xdggs_ provides an accessor (`DataArray.dggs` or `Dataset.dggs`) that allows you to work with data on a discrete global grid system using {doc}`xarray:index` objects. 56 | -------------------------------------------------------------------------------- /docs/reference_guide/publications.bib: -------------------------------------------------------------------------------- 1 | @misc{magin_2024_13934967, 2 | author = {Magin, Justus and Bovy, Benoit and Delouis, Jean-Marc and Fouilloux, Anne and Coca-Castro, Alejandro and Abernathey, Ryan and Strobl, Peter and Kmoch, Alexander and Odaka, Tina Erica}, 3 | title = "{Advancing Geospatial Data Analysis with XDGGS}", 4 | year = 2024, 5 | month = oct, 6 | doi = {10.5281/zenodo.13934967}, 7 | publisher = {Zenodo}, 8 | url = {https://doi.org/10.5281/zenodo.13934967}, 9 | version = {1.0}, 10 | } 11 | 12 | @article{isprs-archives-XLVIII-4-W12-2024-75-2024, 13 | author = {Kmoch, A. and Bovy, B. and Magin, J. and Abernathey, R. and Coca-Castro, A. and Strobl, P. and Fouilloux, A. and Loos, D. and Uuemaa, E. and Chan, W. T. and Delouis, J.-M. and Odaka, T.}, 14 | title = "{XDGGS: A community-developed Xarray package to support planetary DGGS data cube computations}", 15 | journal = {The International Archives of the Photogrammetry, Remote Sensing and Spatial Information Sciences}, 16 | year = 2024, 17 | volume = {XLVIII-4/W12-2024}, 18 | pages = {75--80}, 19 | doi = {10.5194/isprs-archives-XLVIII-4-W12-2024-75-2024}, 20 | url = {https://isprs-archives.copernicus.org/articles/XLVIII-4-W12-2024/75/2024/}, 21 | } 22 | 23 | @inproceedings{kmoch2024, 24 | title = {{{XDGGS}}: {{Xarray Extension}} for {{Discrete Global Grid Systems}} ({{DGGS}})}, 25 | shorttitle = {{XDGGS}}, 26 | author = {Kmoch, Alexander and Bovy, Beno{\^i}t and Magin, Justus and Abernathey, Ryan and Strobl, Peter and {Coca-Castro}, Alejandro and Fouilloux, Anne and Loos, Daniel and Odaka, Tina}, 27 | year = {2024}, 28 | month = Apr, 29 | booktitle = {{{EGU General Assembly}} 2024}, 30 | address = {Vienna, Austria, 14-19 Apr 2024}, 31 | doi = {10.5194/egusphere-egu24-15416}, 32 | urldate = {2024-11-18}, 33 | } 34 | -------------------------------------------------------------------------------- /docs/reference_guide/publications.md: -------------------------------------------------------------------------------- 1 | # Publications mentioning `xdggs` 2 | 3 | ```{bibliography} publications.bib 4 | --- 5 | all: true 6 | list: bullet 7 | style: alpha 8 | --- 9 | ``` 10 | -------------------------------------------------------------------------------- /docs/tutorials/h3.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "0", 6 | "metadata": {}, 7 | "source": [ 8 | "# Working with H3 data\n", 9 | "\n", 10 | "H3 is a popular icosahedral DGGS with hexagonal cells, developed and popularized by Uber. For more information, see https://h3geo.org. The tutorial aims to showcase how to work with H3 data using `xdggs`." 11 | ] 12 | }, 13 | { 14 | "cell_type": "markdown", 15 | "id": "1", 16 | "metadata": {}, 17 | "source": [ 18 | "## Import libraries" 19 | ] 20 | }, 21 | { 22 | "cell_type": "code", 23 | "execution_count": null, 24 | "id": "2", 25 | "metadata": {}, 26 | "outputs": [], 27 | "source": [ 28 | "import xarray as xr\n", 29 | "\n", 30 | "import xdggs\n", 31 | "\n", 32 | "_ = xr.set_options(display_expand_data=False)" 33 | ] 34 | }, 35 | { 36 | "cell_type": "markdown", 37 | "id": "3", 38 | "metadata": {}, 39 | "source": [ 40 | "## Initialization\n", 41 | "\n", 42 | "To initialize, we first have to open the dataset. Here we'll use `xarray`'s `air_temperature` tutorial dataset, which was interpolated to the H3 grid.\n", 43 | "\n", 44 | "```{tip}\n", 45 | "If the dataset you want to work on is not already on a H3 grid, you will have to use a different package to interpolate.\n", 46 | "```\n", 47 | "\n", 48 | "```{warning}\n", 49 | "For the purpose of this tutorial we drop the geographic coordinates and load all data into memory, but this is not required.\n", 50 | "```" 51 | ] 52 | }, 53 | { 54 | "cell_type": "code", 55 | "execution_count": null, 56 | "id": "4", 57 | "metadata": {}, 58 | "outputs": [], 59 | "source": [ 60 | "original_ds = xdggs.tutorial.open_dataset(\"air_temperature\", \"h3\").load()\n", 61 | "air_temperature = original_ds.drop_vars([\"lat\", \"lon\"])\n", 62 | "air_temperature" 63 | ] 64 | }, 65 | { 66 | "cell_type": "markdown", 67 | "id": "5", 68 | "metadata": {}, 69 | "source": [ 70 | "After that, we can use {py:func}`xdggs.decode` to tell `xdggs` to interpret the cell ids.\n", 71 | "\n", 72 | "This will create a grid object (see {py:attr}`xarray.Dataset.dggs.grid_info` and {py:class}`xdggs.H3Info` for more information) containing the grid parameters and a custom index for the `cell_ids` coordinate (notice how the coordinate name is displayed in bold), which will allow us to perform grid-aware operations.\n", 73 | "\n", 74 | "````{important}\n", 75 | "For this to work, the dataset has to have a coordinate called `cell_ids`, and it also has to have the `grid_name` and `level` attributes.\n", 76 | "\n", 77 | "The `grid_name` refers to the short name of the grid, while `level` refers to the grid hierarchical level (the `h3` libraries call this the \"resolution\", while `xdggs` will use \"level\" for all grids).\n", 78 | "\n", 79 | "In this case, the attributes on `cell_ids` are:\n", 80 | "```python\n", 81 | "{\n", 82 | " \"grid_name\": \"h3\",\n", 83 | " \"level\": 2,\n", 84 | "}\n", 85 | "```\n", 86 | "````" 87 | ] 88 | }, 89 | { 90 | "cell_type": "code", 91 | "execution_count": null, 92 | "id": "6", 93 | "metadata": {}, 94 | "outputs": [], 95 | "source": [ 96 | "ds = air_temperature.pipe(xdggs.decode)\n", 97 | "ds" 98 | ] 99 | }, 100 | { 101 | "cell_type": "markdown", 102 | "id": "7", 103 | "metadata": {}, 104 | "source": [ 105 | "## Deriving data\n", 106 | "\n", 107 | "With the grid object and the custom index, we can derive additional data from the cell ids.\n", 108 | "\n", 109 | "### Cell center coordinates\n", 110 | "\n", 111 | "For example, we can reconstruct the cell centers we dropped from the original dataset, using {py:meth}`xarray.Dataset.dggs.cell_centers`:" 112 | ] 113 | }, 114 | { 115 | "cell_type": "code", 116 | "execution_count": null, 117 | "id": "8", 118 | "metadata": {}, 119 | "outputs": [], 120 | "source": [ 121 | "cell_centers = ds.dggs.cell_centers()\n", 122 | "cell_centers" 123 | ] 124 | }, 125 | { 126 | "cell_type": "markdown", 127 | "id": "9", 128 | "metadata": {}, 129 | "source": [ 130 | "These are the same as the ones we dropped before:" 131 | ] 132 | }, 133 | { 134 | "cell_type": "code", 135 | "execution_count": null, 136 | "id": "10", 137 | "metadata": {}, 138 | "outputs": [], 139 | "source": [ 140 | "derived_ds = ds.assign_coords(\n", 141 | " cell_centers.rename_vars({\"latitude\": \"lat\", \"longitude\": \"lon\"}).coords\n", 142 | ")\n", 143 | "derived_ds" 144 | ] 145 | }, 146 | { 147 | "cell_type": "code", 148 | "execution_count": null, 149 | "id": "11", 150 | "metadata": {}, 151 | "outputs": [], 152 | "source": [ 153 | "xr.testing.assert_allclose(derived_ds, original_ds)" 154 | ] 155 | }, 156 | { 157 | "cell_type": "markdown", 158 | "id": "12", 159 | "metadata": {}, 160 | "source": [ 161 | "### Cell boundary polygons\n", 162 | "\n", 163 | "Additionally, we can derive the cell boundary polygons as an array of {doc}`shapely:index` using {py:meth}`xarray.Dataset.dggs.cell_boundaries`:" 164 | ] 165 | }, 166 | { 167 | "cell_type": "code", 168 | "execution_count": null, 169 | "id": "13", 170 | "metadata": {}, 171 | "outputs": [], 172 | "source": [ 173 | "cell_boundaries = ds.dggs.cell_boundaries()\n", 174 | "cell_boundaries" 175 | ] 176 | }, 177 | { 178 | "cell_type": "markdown", 179 | "id": "14", 180 | "metadata": {}, 181 | "source": [ 182 | "## Plotting\n", 183 | "\n", 184 | "We can quickly visualize the data using {py:meth}`xarray.DataArray.dggs.explore`, which is powered by [lonboard](https://github.com/developmentseed/lonboard).\n", 185 | "\n", 186 | "```{warning}\n", 187 | "This is currently restricted to 1D `DataArray` objects, so we need to select a single timestep.\n", 188 | "```" 189 | ] 190 | }, 191 | { 192 | "cell_type": "code", 193 | "execution_count": null, 194 | "id": "15", 195 | "metadata": {}, 196 | "outputs": [], 197 | "source": [ 198 | "ds[\"air\"].isel(time=15).dggs.explore()" 199 | ] 200 | } 201 | ], 202 | "metadata": { 203 | "language_info": { 204 | "codemirror_mode": { 205 | "name": "ipython", 206 | "version": 3 207 | }, 208 | "file_extension": ".py", 209 | "mimetype": "text/x-python", 210 | "name": "python", 211 | "nbconvert_exporter": "python", 212 | "pygments_lexer": "ipython3", 213 | "version": "3.12.7" 214 | } 215 | }, 216 | "nbformat": 4, 217 | "nbformat_minor": 5 218 | } 219 | -------------------------------------------------------------------------------- /docs/tutorials/healpix.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "0", 6 | "metadata": {}, 7 | "source": [ 8 | "# Working with Healpix data\n", 9 | "\n", 10 | "Healpix has been used in cosmology for more than 20 years. See https://healpix.jpl.nasa.gov/html/intro.htm for more information.\n", 11 | "\n", 12 | "Healpix divides the sphere into sperical rectangles (\"pixels\") of equal size, starting with 12 \"base pixels\". Each of these \"base pixels\" is then further subdivided into 4 equally sized rectangles. The number of subdivisions is called \"order\", while the total number of rectangles within a \"base pixel\" is called \"nside\" (the relation between both is {math}`n_{\\mathrm{side}} = 2^{\\mathrm{order}}`).\n", 13 | "\n", 14 | "There are two major ways of numbering the \"pixels\" (the \"indexing scheme\"):\n", 15 | "- `\"ring\"`, which assigns IDs to pixels based on the latitude rings they are on, such that pixels on the same latitude will have IDs close to each other.\n", 16 | "- `\"nested\"`, which assigns IDs based on the pixel hierarchy, such that pixels within the same parent pixel have IDs close to each other.\n", 17 | "\n", 18 | "(Some Healpix libraries use the boolean `nest` to indicate the indexing scheme)" 19 | ] 20 | }, 21 | { 22 | "cell_type": "markdown", 23 | "id": "1", 24 | "metadata": {}, 25 | "source": [ 26 | "## Import libraries" 27 | ] 28 | }, 29 | { 30 | "cell_type": "code", 31 | "execution_count": null, 32 | "id": "2", 33 | "metadata": {}, 34 | "outputs": [], 35 | "source": [ 36 | "import xarray as xr\n", 37 | "\n", 38 | "import xdggs\n", 39 | "\n", 40 | "_ = xr.set_options(display_expand_data=False)" 41 | ] 42 | }, 43 | { 44 | "cell_type": "markdown", 45 | "id": "3", 46 | "metadata": {}, 47 | "source": [ 48 | "## Initialization\n", 49 | "\n", 50 | "To initialize, we first have to open the dataset. Here we'll use `xarray`'s `air_temperature` tutorial dataset, which was interpolated to the healpix grid.\n", 51 | "\n", 52 | "```{tip}\n", 53 | "If the dataset you want to work on is not already on a healpix grid, you will have to use a different package to interpolate.\n", 54 | "```\n", 55 | "\n", 56 | "```{warning}\n", 57 | "For the purpose of this tutorial we drop the geographic coordinates and load all data into memory, but this is not required.\n", 58 | "```" 59 | ] 60 | }, 61 | { 62 | "cell_type": "code", 63 | "execution_count": null, 64 | "id": "4", 65 | "metadata": {}, 66 | "outputs": [], 67 | "source": [ 68 | "original_ds = xdggs.tutorial.open_dataset(\"air_temperature\", \"healpix\").load()\n", 69 | "air_temperature = original_ds.drop_vars([\"lat\", \"lon\"])\n", 70 | "air_temperature" 71 | ] 72 | }, 73 | { 74 | "cell_type": "markdown", 75 | "id": "5", 76 | "metadata": {}, 77 | "source": [ 78 | "After that, we can use {py:func}`xdggs.decode` to tell `xdggs` to interpret the cell ids.\n", 79 | "\n", 80 | "This will create a grid object (see {py:attr}`xarray.Dataset.dggs.grid_info` and {py:class}`xdggs.HealpixInfo` for more information) containing the grid parameters and a custom index for the `cell_ids` coordinate (notice how the coordinate name is displayed in bold), which will allow us to perform grid-aware operations.\n", 81 | "\n", 82 | "````{important}\n", 83 | "For this to work, the dataset has to have a coordinate called `cell_ids`, and it also has to have the `grid_name`, `level` and `indexing_scheme` attributes.\n", 84 | "\n", 85 | "The `grid_name` refers to the short name of the grid, while `level` refers to the grid hierarchical level (the `healpix` libraries call this the \"order\", while `xdggs` will use \"level\" for all grids), and the indexing scheme depends on the dataset.\n", 86 | "\n", 87 | "In this case, the attributes on `cell_ids` are:\n", 88 | "```python\n", 89 | "{\n", 90 | " \"grid_name\": \"healpix\",\n", 91 | " \"level\": 4,\n", 92 | " \"indexing_scheme\": \"nested\",\n", 93 | "}\n", 94 | "```\n", 95 | "````" 96 | ] 97 | }, 98 | { 99 | "cell_type": "code", 100 | "execution_count": null, 101 | "id": "6", 102 | "metadata": {}, 103 | "outputs": [], 104 | "source": [ 105 | "ds = air_temperature.pipe(xdggs.decode)\n", 106 | "ds" 107 | ] 108 | }, 109 | { 110 | "cell_type": "markdown", 111 | "id": "7", 112 | "metadata": {}, 113 | "source": [ 114 | "## Deriving data\n", 115 | "\n", 116 | "With the grid object and the custom index, we can derive additional data from the cell ids.\n", 117 | "\n", 118 | "### Cell center coordinates\n", 119 | "\n", 120 | "For example, we can reconstruct the cell centers we dropped from the original dataset, using {py:meth}`xarray.Dataset.dggs.cell_centers`:" 121 | ] 122 | }, 123 | { 124 | "cell_type": "code", 125 | "execution_count": null, 126 | "id": "8", 127 | "metadata": {}, 128 | "outputs": [], 129 | "source": [ 130 | "cell_centers = ds.dggs.cell_centers()\n", 131 | "cell_centers" 132 | ] 133 | }, 134 | { 135 | "cell_type": "markdown", 136 | "id": "9", 137 | "metadata": {}, 138 | "source": [ 139 | "These are the same as the ones we dropped before:" 140 | ] 141 | }, 142 | { 143 | "cell_type": "code", 144 | "execution_count": null, 145 | "id": "10", 146 | "metadata": {}, 147 | "outputs": [], 148 | "source": [ 149 | "derived_ds = ds.assign_coords(\n", 150 | " cell_centers.rename_vars({\"latitude\": \"lat\", \"longitude\": \"lon\"}).coords\n", 151 | ")\n", 152 | "derived_ds" 153 | ] 154 | }, 155 | { 156 | "cell_type": "markdown", 157 | "id": "11", 158 | "metadata": {}, 159 | "source": [ 160 | "```{note}\n", 161 | "We need to use {py:func}`xarray.testing.assert_allclose` to compare cell centers because the cell center coordinates were computed using a [different library](https://github.com/healpy/healpy) with a slightly different implementation, resulting in small floating point differences.\n", 162 | "```" 163 | ] 164 | }, 165 | { 166 | "cell_type": "code", 167 | "execution_count": null, 168 | "id": "12", 169 | "metadata": {}, 170 | "outputs": [], 171 | "source": [ 172 | "xr.testing.assert_allclose(derived_ds, original_ds)" 173 | ] 174 | }, 175 | { 176 | "cell_type": "markdown", 177 | "id": "13", 178 | "metadata": {}, 179 | "source": [ 180 | "### Cell boundary polygons\n", 181 | "\n", 182 | "Additionally, we can derive the cell boundary polygons as an array of {doc}`shapely:index` using {py:meth}`xarray.Dataset.dggs.cell_boundaries`:" 183 | ] 184 | }, 185 | { 186 | "cell_type": "code", 187 | "execution_count": null, 188 | "id": "14", 189 | "metadata": {}, 190 | "outputs": [], 191 | "source": [ 192 | "cell_boundaries = ds.dggs.cell_boundaries()\n", 193 | "cell_boundaries" 194 | ] 195 | }, 196 | { 197 | "cell_type": "markdown", 198 | "id": "15", 199 | "metadata": {}, 200 | "source": [ 201 | "## Plotting\n", 202 | "\n", 203 | "We can quickly visualize the data using {py:meth}`xarray.DataArray.dggs.explore`, which is powered by [lonboard](https://github.com/developmentseed/lonboard).\n", 204 | "\n", 205 | "```{warning}\n", 206 | "This is currently restricted to 1D `DataArray` objects, so we need to select a single timestep.\n", 207 | "```" 208 | ] 209 | }, 210 | { 211 | "cell_type": "code", 212 | "execution_count": null, 213 | "id": "16", 214 | "metadata": {}, 215 | "outputs": [], 216 | "source": [ 217 | "ds[\"air\"].isel(time=15).dggs.explore()" 218 | ] 219 | } 220 | ], 221 | "metadata": { 222 | "language_info": { 223 | "codemirror_mode": { 224 | "name": "ipython", 225 | "version": 3 226 | }, 227 | "file_extension": ".py", 228 | "mimetype": "text/x-python", 229 | "name": "python", 230 | "nbconvert_exporter": "python", 231 | "pygments_lexer": "ipython3", 232 | "version": "3.11.6" 233 | } 234 | }, 235 | "nbformat": 4, 236 | "nbformat_minor": 5 237 | } 238 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: xdggs_dev 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - xarray 6 | - cdshealpix 7 | - ruff 8 | - pytest 9 | - h5netcdf 10 | - netcdf4 11 | - pooch 12 | - matplotlib-base 13 | - shapely 14 | - pip 15 | - pip: 16 | - h3ronpy 17 | -------------------------------------------------------------------------------- /examples/example_h3.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "0", 6 | "metadata": {}, 7 | "source": [ 8 | "# xdggs H3 example" 9 | ] 10 | }, 11 | { 12 | "cell_type": "code", 13 | "execution_count": null, 14 | "id": "1", 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import numpy as np\n", 19 | "import xarray as xr\n", 20 | "\n", 21 | "import xdggs\n", 22 | "\n", 23 | "xr.set_options(keep_attrs=True, display_expand_attrs=False, display_expand_indexes=True)" 24 | ] 25 | }, 26 | { 27 | "cell_type": "code", 28 | "execution_count": null, 29 | "id": "2", 30 | "metadata": {}, 31 | "outputs": [], 32 | "source": [ 33 | "ds = xr.open_dataset(\"data/h3.nc\", engine=\"netcdf4\")\n", 34 | "ds" 35 | ] 36 | }, 37 | { 38 | "cell_type": "code", 39 | "execution_count": null, 40 | "id": "3", 41 | "metadata": {}, 42 | "outputs": [], 43 | "source": [ 44 | "ds_idx = ds.pipe(xdggs.decode)\n", 45 | "ds_idx" 46 | ] 47 | }, 48 | { 49 | "cell_type": "code", 50 | "execution_count": null, 51 | "id": "4", 52 | "metadata": {}, 53 | "outputs": [], 54 | "source": [ 55 | "ds_idx.dggs.sel_latlon(np.array([37.0, 37.5]), np.array([299.3, 299.5]))" 56 | ] 57 | }, 58 | { 59 | "cell_type": "code", 60 | "execution_count": null, 61 | "id": "5", 62 | "metadata": {}, 63 | "outputs": [], 64 | "source": [ 65 | "ds2 = ds_idx.dggs.assign_latlon_coords()\n", 66 | "ds2" 67 | ] 68 | }, 69 | { 70 | "cell_type": "code", 71 | "execution_count": null, 72 | "id": "6", 73 | "metadata": {}, 74 | "outputs": [], 75 | "source": [ 76 | "result = ds_idx.dggs.sel_latlon(ds2.latitude.data, ds2.longitude.data)\n", 77 | "result" 78 | ] 79 | }, 80 | { 81 | "cell_type": "code", 82 | "execution_count": null, 83 | "id": "7", 84 | "metadata": {}, 85 | "outputs": [], 86 | "source": [ 87 | "xr.testing.assert_equal(result, ds)" 88 | ] 89 | } 90 | ], 91 | "metadata": { 92 | "language_info": { 93 | "codemirror_mode": { 94 | "name": "ipython", 95 | "version": 3 96 | }, 97 | "file_extension": ".py", 98 | "mimetype": "text/x-python", 99 | "name": "python", 100 | "nbconvert_exporter": "python", 101 | "pygments_lexer": "ipython3", 102 | "version": "3.11.6" 103 | } 104 | }, 105 | "nbformat": 4, 106 | "nbformat_minor": 5 107 | } 108 | -------------------------------------------------------------------------------- /examples/example_healpy.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "0", 6 | "metadata": {}, 7 | "source": [ 8 | "# xdggs Healpix example" 9 | ] 10 | }, 11 | { 12 | "cell_type": "code", 13 | "execution_count": null, 14 | "id": "1", 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import xarray as xr\n", 19 | "\n", 20 | "import xdggs" 21 | ] 22 | }, 23 | { 24 | "cell_type": "markdown", 25 | "id": "2", 26 | "metadata": {}, 27 | "source": [ 28 | "Download the dataset here: https://zenodo.org/records/10075001" 29 | ] 30 | }, 31 | { 32 | "cell_type": "code", 33 | "execution_count": null, 34 | "id": "3", 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "ds = xr.open_dataset(\"data/healpix_nolotation.nc\")\n", 39 | "\n", 40 | "ds = (\n", 41 | " ds.load()\n", 42 | " .drop_vars([\"latitude\", \"longitude\"])\n", 43 | " .stack(cell=[\"x\", \"y\"], create_index=False)\n", 44 | ")\n", 45 | "\n", 46 | "ds.cell_ids.attrs = {\n", 47 | " \"grid_name\": \"healpix\",\n", 48 | " \"nside\": 4096,\n", 49 | " \"nest\": True,\n", 50 | "}\n", 51 | "\n", 52 | "ds" 53 | ] 54 | }, 55 | { 56 | "cell_type": "code", 57 | "execution_count": null, 58 | "id": "4", 59 | "metadata": {}, 60 | "outputs": [], 61 | "source": [ 62 | "ds_idx = ds.set_xindex(\"cell_ids\", xdggs.DGGSIndex)\n", 63 | "\n", 64 | "ds_idx" 65 | ] 66 | }, 67 | { 68 | "cell_type": "markdown", 69 | "id": "5", 70 | "metadata": {}, 71 | "source": [ 72 | "## properties" 73 | ] 74 | }, 75 | { 76 | "cell_type": "markdown", 77 | "id": "6", 78 | "metadata": {}, 79 | "source": [ 80 | "cell boundaries" 81 | ] 82 | }, 83 | { 84 | "cell_type": "code", 85 | "execution_count": null, 86 | "id": "7", 87 | "metadata": {}, 88 | "outputs": [], 89 | "source": [ 90 | "cell_boundaries = ds_idx.dggs.cell_boundaries\n", 91 | "cell_boundaries" 92 | ] 93 | }, 94 | { 95 | "cell_type": "code", 96 | "execution_count": null, 97 | "id": "8", 98 | "metadata": {}, 99 | "outputs": [], 100 | "source": [ 101 | "df = (\n", 102 | " cell_boundaries.to_dataset(name=\"geometry\")\n", 103 | " .to_pandas()\n", 104 | " .set_geometry(\"geometry\", crs=4326)\n", 105 | " .set_index(\"cell_ids\")\n", 106 | ")\n", 107 | "df" 108 | ] 109 | }, 110 | { 111 | "cell_type": "code", 112 | "execution_count": null, 113 | "id": "9", 114 | "metadata": {}, 115 | "outputs": [], 116 | "source": [ 117 | "df.explore(tiles=\"OpenStreetMap\")" 118 | ] 119 | }, 120 | { 121 | "cell_type": "markdown", 122 | "id": "10", 123 | "metadata": {}, 124 | "source": [ 125 | "## selection" 126 | ] 127 | }, 128 | { 129 | "cell_type": "markdown", 130 | "id": "11", 131 | "metadata": {}, 132 | "source": [ 133 | "by cell id" 134 | ] 135 | }, 136 | { 137 | "cell_type": "code", 138 | "execution_count": null, 139 | "id": "12", 140 | "metadata": {}, 141 | "outputs": [], 142 | "source": [ 143 | "ds_idx.sel(cell_ids=[11320973, 11320975])" 144 | ] 145 | }, 146 | { 147 | "cell_type": "markdown", 148 | "id": "13", 149 | "metadata": {}, 150 | "source": [ 151 | "by lat / lon coordinates" 152 | ] 153 | }, 154 | { 155 | "cell_type": "code", 156 | "execution_count": null, 157 | "id": "14", 158 | "metadata": {}, 159 | "outputs": [], 160 | "source": [ 161 | "ds_idx.dggs.sel_latlon([48.0, 48.1], -5.0)" 162 | ] 163 | }, 164 | { 165 | "cell_type": "markdown", 166 | "id": "15", 167 | "metadata": {}, 168 | "source": [ 169 | "## assignment" 170 | ] 171 | }, 172 | { 173 | "cell_type": "code", 174 | "execution_count": null, 175 | "id": "16", 176 | "metadata": {}, 177 | "outputs": [], 178 | "source": [ 179 | "ds2 = ds_idx.dggs.assign_latlon_coords()\n", 180 | "ds2" 181 | ] 182 | }, 183 | { 184 | "cell_type": "code", 185 | "execution_count": null, 186 | "id": "17", 187 | "metadata": {}, 188 | "outputs": [], 189 | "source": [ 190 | "result = ds_idx.dggs.sel_latlon(ds2.latitude, ds2.longitude)\n", 191 | "result" 192 | ] 193 | }, 194 | { 195 | "cell_type": "code", 196 | "execution_count": null, 197 | "id": "18", 198 | "metadata": {}, 199 | "outputs": [], 200 | "source": [ 201 | "xr.testing.assert_equal(result.drop_vars([\"latitude\", \"longitude\"]), ds)" 202 | ] 203 | } 204 | ], 205 | "metadata": { 206 | "language_info": { 207 | "codemirror_mode": { 208 | "name": "ipython", 209 | "version": 3 210 | }, 211 | "file_extension": ".py", 212 | "mimetype": "text/x-python", 213 | "name": "python", 214 | "nbconvert_exporter": "python", 215 | "pygments_lexer": "ipython3", 216 | "version": "3.11.6" 217 | } 218 | }, 219 | "nbformat": 4, 220 | "nbformat_minor": 5 221 | } 222 | -------------------------------------------------------------------------------- /examples/prepare_dataset_h3.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "id": "0", 6 | "metadata": {}, 7 | "source": [ 8 | "# xdggs example to prepare H3 dataset" 9 | ] 10 | }, 11 | { 12 | "cell_type": "code", 13 | "execution_count": null, 14 | "id": "1", 15 | "metadata": {}, 16 | "outputs": [], 17 | "source": [ 18 | "import h3\n", 19 | "import h3.api.numpy_int\n", 20 | "import h3.unstable.vect\n", 21 | "import numpy as np\n", 22 | "import xarray as xr" 23 | ] 24 | }, 25 | { 26 | "cell_type": "code", 27 | "execution_count": null, 28 | "id": "2", 29 | "metadata": {}, 30 | "outputs": [], 31 | "source": [ 32 | "ds = xr.tutorial.load_dataset(\"air_temperature\").load()\n", 33 | "ds" 34 | ] 35 | }, 36 | { 37 | "cell_type": "code", 38 | "execution_count": null, 39 | "id": "3", 40 | "metadata": {}, 41 | "outputs": [], 42 | "source": [ 43 | "ds.air[0].plot()" 44 | ] 45 | }, 46 | { 47 | "cell_type": "code", 48 | "execution_count": null, 49 | "id": "4", 50 | "metadata": {}, 51 | "outputs": [], 52 | "source": [ 53 | "resolution = 3\n", 54 | "\n", 55 | "lon, lat = xr.broadcast(ds.lon, ds.lat)" 56 | ] 57 | }, 58 | { 59 | "cell_type": "code", 60 | "execution_count": null, 61 | "id": "5", 62 | "metadata": {}, 63 | "outputs": [], 64 | "source": [ 65 | "%%time\n", 66 | "index = h3.unstable.vect.geo_to_h3(lat.data.ravel(), lon.data.ravel(), resolution)" 67 | ] 68 | }, 69 | { 70 | "cell_type": "code", 71 | "execution_count": null, 72 | "id": "6", 73 | "metadata": {}, 74 | "outputs": [], 75 | "source": [ 76 | "index.shape = lon.shape\n", 77 | "\n", 78 | "len(np.unique(index)) / lon.size" 79 | ] 80 | }, 81 | { 82 | "cell_type": "code", 83 | "execution_count": null, 84 | "id": "7", 85 | "metadata": {}, 86 | "outputs": [], 87 | "source": [ 88 | "index.shape" 89 | ] 90 | }, 91 | { 92 | "cell_type": "code", 93 | "execution_count": null, 94 | "id": "8", 95 | "metadata": {}, 96 | "outputs": [], 97 | "source": [ 98 | "ds.lon.shape" 99 | ] 100 | }, 101 | { 102 | "cell_type": "code", 103 | "execution_count": null, 104 | "id": "9", 105 | "metadata": {}, 106 | "outputs": [], 107 | "source": [ 108 | "ds.coords[\"index\"] = (\"lat\", \"lon\"), index.transpose()\n", 109 | "ds" 110 | ] 111 | }, 112 | { 113 | "cell_type": "code", 114 | "execution_count": null, 115 | "id": "10", 116 | "metadata": {}, 117 | "outputs": [], 118 | "source": [ 119 | "ds.index.plot()" 120 | ] 121 | }, 122 | { 123 | "cell_type": "code", 124 | "execution_count": null, 125 | "id": "11", 126 | "metadata": {}, 127 | "outputs": [], 128 | "source": [ 129 | "lon_min, lon_max = ds.lon.min().values.item(), ds.lon.max().values.item()\n", 130 | "lat_min, lat_max = ds.lat.min().values.item(), ds.lat.max().values.item()" 131 | ] 132 | }, 133 | { 134 | "cell_type": "code", 135 | "execution_count": null, 136 | "id": "12", 137 | "metadata": {}, 138 | "outputs": [], 139 | "source": [ 140 | "import shapely\n", 141 | "\n", 142 | "bbox_coords = [\n", 143 | " (lon_min - 360, lat_min),\n", 144 | " (lon_min - 360, lat_max),\n", 145 | " (lon_max - 360, lat_max),\n", 146 | " (lon_max - 360, lat_min),\n", 147 | " (lon_min - 360, lat_min),\n", 148 | "]\n", 149 | "bbox = shapely.Polygon(bbox_coords)\n", 150 | "bbox" 151 | ] 152 | }, 153 | { 154 | "cell_type": "code", 155 | "execution_count": null, 156 | "id": "13", 157 | "metadata": {}, 158 | "outputs": [], 159 | "source": [ 160 | "bbox_coords" 161 | ] 162 | }, 163 | { 164 | "cell_type": "code", 165 | "execution_count": null, 166 | "id": "14", 167 | "metadata": {}, 168 | "outputs": [], 169 | "source": [ 170 | "# h3 wants lat first\n", 171 | "bbox_coords_lat_first = [(lat, lon) for lon, lat in bbox_coords]\n", 172 | "bbox_indexes = np.array(\n", 173 | " list(h3.api.basic_int.polyfill_polygon(bbox_coords_lat_first, resolution))\n", 174 | ")\n", 175 | "bbox_indexes.shape" 176 | ] 177 | }, 178 | { 179 | "cell_type": "code", 180 | "execution_count": null, 181 | "id": "15", 182 | "metadata": {}, 183 | "outputs": [], 184 | "source": [ 185 | "ll_points = np.array([h3.api.numpy_int.h3_to_geo(i) for i in bbox_indexes])\n", 186 | "ll_points_lon_first = ll_points[:, ::-1]" 187 | ] 188 | }, 189 | { 190 | "cell_type": "code", 191 | "execution_count": null, 192 | "id": "16", 193 | "metadata": {}, 194 | "outputs": [], 195 | "source": [ 196 | "coords = {\"cell_ids\": bbox_indexes}\n", 197 | "\n", 198 | "# remember to re-add the 360 degree offset\n", 199 | "dsi = ds.interp(\n", 200 | " lon=xr.DataArray(ll_points_lon_first[:, 0] + 360, dims=\"cell_ids\", coords=coords),\n", 201 | " lat=xr.DataArray(ll_points_lon_first[:, 1], dims=\"cell_ids\", coords=coords),\n", 202 | ")\n", 203 | "dsi" 204 | ] 205 | }, 206 | { 207 | "cell_type": "code", 208 | "execution_count": null, 209 | "id": "17", 210 | "metadata": {}, 211 | "outputs": [], 212 | "source": [ 213 | "dsi2 = dsi.drop_vars([\"lon\", \"lat\", \"index\"])\n", 214 | "dsi2.cell_ids.attrs = {\"grid_name\": \"h3\", \"resolution\": resolution}\n", 215 | "dsi2.to_netcdf(\"data/h3.nc\", mode=\"w\")\n", 216 | "dsi2" 217 | ] 218 | }, 219 | { 220 | "cell_type": "code", 221 | "execution_count": null, 222 | "id": "18", 223 | "metadata": {}, 224 | "outputs": [], 225 | "source": [] 226 | } 227 | ], 228 | "metadata": { 229 | "language_info": { 230 | "codemirror_mode": { 231 | "name": "ipython", 232 | "version": 3 233 | }, 234 | "file_extension": ".py", 235 | "mimetype": "text/x-python", 236 | "name": "python", 237 | "nbconvert_exporter": "python", 238 | "pygments_lexer": "ipython3", 239 | "version": "3.11.6" 240 | } 241 | }, 242 | "nbformat": 4, 243 | "nbformat_minor": 5 244 | } 245 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.setuptools_scm] 6 | fallback_version = "9999" 7 | 8 | [tool.setuptools.packages.find] 9 | include = [ 10 | "xdggs", 11 | "xdggs.*", 12 | ] 13 | 14 | [project] 15 | name = "xdggs" 16 | dynamic = ["version"] 17 | authors = [ 18 | { name = "Benoît Bovy" }, 19 | { name = "Justus Magin" }, 20 | ] 21 | maintainers = [ 22 | { name = "xdggs contributors" }, 23 | ] 24 | license = { text = "Apache-2.0" } 25 | description = "Xarray extension for DGGS" 26 | keywords = ["DGGS", "xarray", "GIS"] 27 | readme = "README.md" 28 | classifiers = [ 29 | "Intended Audience :: Science/Research", 30 | "License :: OSI Approved :: Apache Software License", 31 | "Operating System :: OS Independent", 32 | "Programming Language :: Python :: 3", 33 | "Programming Language :: Python :: 3.10", 34 | "Programming Language :: Python :: 3.11", 35 | "Programming Language :: Python :: 3.12", 36 | "Topic :: Scientific/Engineering :: GIS", 37 | ] 38 | requires-python = ">=3.10" 39 | dependencies = [ 40 | "xarray", 41 | "cdshealpix", 42 | "h3ronpy", 43 | "typing-extensions", 44 | "lonboard>=0.9.3", 45 | "pyproj>=3.3", 46 | "matplotlib", 47 | "arro3-core>=0.4.0", 48 | "pooch", 49 | ] 50 | 51 | [project.urls] 52 | Documentation = "https://xdggs.readthedocs.io" 53 | Repository = "https://github.com/xarray-contrib/xdggs" 54 | 55 | [tool.ruff] 56 | target-version = "py310" 57 | builtins = ["ellipsis"] 58 | exclude = [ 59 | ".git", 60 | ".eggs", 61 | "build", 62 | "dist", 63 | "__pycache__", 64 | ] 65 | line-length = 100 66 | 67 | [tool.ruff.lint] 68 | # E402: module level import not at top of file 69 | # E501: line too long - let black worry about that 70 | # E731: do not assign a lambda expression, use a def 71 | ignore = [ 72 | "E402", 73 | "E501", 74 | "E731", 75 | ] 76 | select = [ 77 | "F", # Pyflakes 78 | "E", # Pycodestyle 79 | "I", # isort 80 | "UP", # Pyupgrade 81 | "TID", # flake8-tidy-imports 82 | "W", 83 | ] 84 | extend-safe-fixes = [ 85 | "TID252", # absolute imports 86 | ] 87 | fixable = ["I", "TID252"] 88 | 89 | [tool.ruff.lint.isort] 90 | known-first-party = ["xdggs"] 91 | known-third-party = [ 92 | "xarray", 93 | "cdshealpix", 94 | "astropy", 95 | "h3ronpy", 96 | ] 97 | 98 | [tool.ruff.lint.flake8-tidy-imports] 99 | # Disallow all relative imports. 100 | ban-relative-imports = "all" 101 | 102 | [tool.coverage.run] 103 | source = ["xdggs"] 104 | branch = true 105 | 106 | [tool.coverage.report] 107 | show_missing = true 108 | exclude_lines = ["pragma: no cover", "if TYPE_CHECKING"] 109 | 110 | [tool.pytest.ini_options] 111 | filterwarnings = [ 112 | "error:::xdggs.*", 113 | ] 114 | -------------------------------------------------------------------------------- /xdggs-cropped.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xarray-contrib/xdggs/0a37d34c601f8521dec9228115ecba248e0b751e/xdggs-cropped.gif -------------------------------------------------------------------------------- /xdggs/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib.metadata import PackageNotFoundError, version 2 | 3 | import xdggs.tutorial # noqa: F401 4 | from xdggs.accessor import DGGSAccessor # noqa: F401 5 | from xdggs.grid import DGGSInfo 6 | from xdggs.h3 import H3Index, H3Info 7 | from xdggs.healpix import HealpixIndex, HealpixInfo 8 | from xdggs.index import DGGSIndex, decode 9 | 10 | try: 11 | __version__ = version("xdggs") 12 | except PackageNotFoundError: # noqa # pragma: no cover 13 | # package is not installed 14 | __version__ = "9999" 15 | 16 | __all__ = [ 17 | "__version__", 18 | "DGGSInfo", 19 | "H3Info", 20 | "HealpixInfo", 21 | "DGGSIndex", 22 | "H3Index", 23 | "HealpixIndex", 24 | "decode", 25 | ] 26 | -------------------------------------------------------------------------------- /xdggs/accessor.py: -------------------------------------------------------------------------------- 1 | import numpy.typing as npt 2 | import xarray as xr 3 | 4 | from xdggs.grid import DGGSInfo 5 | from xdggs.index import DGGSIndex 6 | from xdggs.plotting import explore 7 | 8 | 9 | @xr.register_dataset_accessor("dggs") 10 | @xr.register_dataarray_accessor("dggs") 11 | class DGGSAccessor: 12 | _obj: xr.Dataset | xr.DataArray 13 | _index: DGGSIndex | None 14 | _name: str 15 | 16 | def __init__(self, obj: xr.Dataset | xr.DataArray): 17 | self._obj = obj 18 | 19 | index = None 20 | name = "" 21 | for k, idx in obj.xindexes.items(): 22 | if isinstance(idx, DGGSIndex): 23 | if index is not None: 24 | raise ValueError( 25 | "Only one DGGSIndex per dataset or dataarray is supported" 26 | ) 27 | index = idx 28 | name = k 29 | self._name = name 30 | self._index = index 31 | 32 | def decode(self, grid_info=None, *, name="cell_ids") -> xr.Dataset | xr.DataArray: 33 | """decode the DGGS cell ids 34 | 35 | Parameters 36 | ---------- 37 | grid_info : dict or DGGSInfo, optional 38 | Override the grid parameters on the dataset. Useful to set attributes on 39 | the dataset. 40 | name : str, default: "cell_ids" 41 | The name of the coordinate containing the cell ids. 42 | 43 | Returns 44 | ------- 45 | obj : xarray.DataArray or xarray.Dataset 46 | The object with a DGGS index on the cell id coordinate. 47 | """ 48 | var = self._obj[name] 49 | if isinstance(grid_info, DGGSInfo): 50 | grid_info = grid_info.to_dict() 51 | if isinstance(grid_info, dict): 52 | var.attrs = grid_info 53 | 54 | return self._obj.drop_indexes(name, errors="ignore").set_xindex(name, DGGSIndex) 55 | 56 | @property 57 | def index(self) -> DGGSIndex: 58 | """The DGGSIndex instance for this Dataset or DataArray. 59 | 60 | Raises 61 | ------ 62 | ValueError 63 | if no DGGSIndex can be found 64 | """ 65 | if self._index is None: 66 | raise ValueError("no DGGSIndex found on this Dataset or DataArray") 67 | return self._index 68 | 69 | @property 70 | def coord(self) -> xr.DataArray: 71 | """The indexed DGGS (cell ids) coordinate as a DataArray. 72 | 73 | Raises 74 | ------ 75 | ValueError 76 | if no such coordinate is found on the Dataset / DataArray 77 | """ 78 | if not self._name: 79 | raise ValueError( 80 | "no coordinate with a DGGSIndex found on this Dataset or DataArray" 81 | ) 82 | return self._obj[self._name] 83 | 84 | @property 85 | def params(self) -> dict: 86 | """The grid parameters after normalization.""" 87 | return self.index.grid.to_dict() 88 | 89 | @property 90 | def grid_info(self) -> DGGSInfo: 91 | """The grid info object containing the DGGS type and its parameters. 92 | 93 | Returns 94 | ------- 95 | xdggs.DGGSInfo 96 | """ 97 | return self.index.grid_info 98 | 99 | def sel_latlon( 100 | self, latitude: npt.ArrayLike, longitude: npt.ArrayLike 101 | ) -> xr.Dataset | xr.DataArray: 102 | """Select grid cells from latitude/longitude data. 103 | 104 | Parameters 105 | ---------- 106 | latitude : array-like 107 | Latitude coordinates (degrees). 108 | longitude : array-like 109 | Longitude coordinates (degrees). 110 | 111 | Returns 112 | ------- 113 | subset 114 | A new :py:class:`xarray.Dataset` or :py:class:`xarray.DataArray` 115 | with all cells that contain the input latitude/longitude data points. 116 | """ 117 | cell_indexers = { 118 | self._name: self.grid_info.geographic2cell_ids(lon=longitude, lat=latitude) 119 | } 120 | return self._obj.sel(cell_indexers) 121 | 122 | def assign_latlon_coords(self) -> xr.Dataset | xr.DataArray: 123 | """Return a new Dataset or DataArray with new "latitude" and "longitude" 124 | coordinates representing the grid cell centers.""" 125 | 126 | lon_data, lat_data = self.index.cell_centers() 127 | 128 | return self._obj.assign_coords( 129 | latitude=(self.index._dim, lat_data), 130 | longitude=(self.index._dim, lon_data), 131 | ) 132 | 133 | @property 134 | def cell_ids(self): 135 | """The indexed DGGS (cell ids) coordinate as a DataArray. 136 | 137 | Alias of ``coord``. 138 | 139 | Raises 140 | ------ 141 | ValueError 142 | if no such coordinate is found on the Dataset / DataArray 143 | """ 144 | return self.coord 145 | 146 | def cell_centers(self): 147 | """derive geographic cell center coordinates 148 | 149 | Returns 150 | ------- 151 | coords : xarray.Dataset 152 | Dataset containing the cell centers in geographic coordinates. 153 | """ 154 | lon_data, lat_data = self.index.cell_centers() 155 | 156 | return xr.Dataset( 157 | coords={ 158 | "latitude": (self.index._dim, lat_data), 159 | "longitude": (self.index._dim, lon_data), 160 | } 161 | ) 162 | 163 | def cell_boundaries(self): 164 | """derive cell boundary polygons 165 | 166 | Returns 167 | ------- 168 | boundaries : xarray.DataArray 169 | The cell boundaries as shapely objects. 170 | """ 171 | boundaries = self.index.cell_boundaries() 172 | 173 | return xr.DataArray( 174 | boundaries, coords={self._name: self.cell_ids}, dims=self.cell_ids.dims 175 | ) 176 | 177 | def explore(self, *, cmap="viridis", center=None, alpha=None, coords=None): 178 | """interactively explore the data using `lonboard` 179 | 180 | Requires `lonboard`, `matplotlib`, and `arro3.core` to be installed. 181 | 182 | Parameters 183 | ---------- 184 | cmap : str 185 | The name of the color map to use 186 | center : int or float, optional 187 | If set, will use this as the center value of a diverging color map. 188 | alpha : float, optional 189 | If set, controls the transparency of the polygons. 190 | coords : list of str, default: ["latitude", "longitude"] 191 | Additional coordinates to contain in the table of contents. 192 | 193 | Returns 194 | ------- 195 | map : lonboard.Map 196 | The rendered map. 197 | 198 | Notes 199 | ----- 200 | Plotting currently is restricted to 1D `DataArray` objects. 201 | """ 202 | if isinstance(self._obj, xr.Dataset): 203 | raise ValueError("does not work with Dataset objects, yet") 204 | 205 | return explore( 206 | self._obj, 207 | cmap=cmap, 208 | center=center, 209 | alpha=alpha, 210 | coords=coords, 211 | ) 212 | -------------------------------------------------------------------------------- /xdggs/grid.py: -------------------------------------------------------------------------------- 1 | import operator 2 | from dataclasses import dataclass 3 | from typing import Any, TypeVar 4 | 5 | from xdggs.itertools import groupby, identity 6 | 7 | try: 8 | from typing import Self 9 | except ImportError: # pragma: no cover 10 | from typing_extensions import Self 11 | 12 | try: 13 | ExceptionGroup 14 | except NameError: # pragma: no cover 15 | from exceptiongroup import ExceptionGroup 16 | 17 | T = TypeVar("T") 18 | 19 | 20 | @dataclass(frozen=True) 21 | class DGGSInfo: 22 | """Base class for DGGS grid information objects 23 | 24 | Parameters 25 | ---------- 26 | level : int 27 | Grid hierarchical level. A higher value corresponds to a finer grid resolution 28 | with smaller cell areas. The number of cells covering the whole sphere usually 29 | grows exponentially with increasing level values, ranging from 5-100 cells at 30 | level 0 to millions or billions of cells at level 10+ (the exact numbers depends 31 | on the specific grid). 32 | """ 33 | 34 | level: int 35 | 36 | @classmethod 37 | def from_dict(cls: type[T], mapping: dict[str, Any]) -> T: 38 | return cls(**mapping) 39 | 40 | def to_dict(self: Self) -> dict[str, Any]: 41 | return {"level": self.level} 42 | 43 | def cell_ids2geographic(self, cell_ids): 44 | raise NotImplementedError() 45 | 46 | def geographic2cell_ids(self, lon, lat): 47 | raise NotImplementedError() 48 | 49 | def cell_boundaries(self, cell_ids, backend="shapely"): 50 | raise NotImplementedError() 51 | 52 | 53 | def translate_parameters(mapping, translations): 54 | def translate(name, value): 55 | new_name, translator = translations.get(name, (name, identity)) 56 | 57 | return new_name, name, translator(value) 58 | 59 | translated = (translate(name, value) for name, value in mapping.items()) 60 | grouped = { 61 | name: [(old_name, value) for _, old_name, value in group] 62 | for name, group in groupby(translated, key=operator.itemgetter(0)) 63 | } 64 | duplicated_parameters = { 65 | name: group for name, group in grouped.items() if len(group) != 1 66 | } 67 | if duplicated_parameters: 68 | raise ExceptionGroup( 69 | "received multiple values for parameters", 70 | [ 71 | ValueError( 72 | f"Parameter {name} received multiple values: {sorted(n for n, _ in group)}" 73 | ) 74 | for name, group in duplicated_parameters.items() 75 | ], 76 | ) 77 | 78 | params = { 79 | name: group[0][1] for name, group in grouped.items() if name != "grid_name" 80 | } 81 | return params 82 | -------------------------------------------------------------------------------- /xdggs/h3.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections.abc import Mapping 3 | from dataclasses import dataclass 4 | from typing import Any, ClassVar 5 | 6 | try: 7 | from typing import Self 8 | except ImportError: # pragma: no cover 9 | from typing_extensions import Self 10 | 11 | import numpy as np 12 | import xarray as xr 13 | 14 | try: 15 | from h3ronpy.vector import ( 16 | cells_to_coordinates, 17 | cells_to_wkb_polygons, 18 | coordinates_to_cells, 19 | ) 20 | except ImportError: 21 | from h3ronpy.arrow.vector import ( 22 | cells_to_coordinates, 23 | cells_to_wkb_polygons, 24 | coordinates_to_cells, 25 | ) 26 | from xarray.indexes import PandasIndex 27 | 28 | from xdggs.grid import DGGSInfo, translate_parameters 29 | from xdggs.index import DGGSIndex 30 | from xdggs.utils import _extract_cell_id_variable, register_dggs 31 | 32 | 33 | def polygons_shapely(wkb): 34 | import shapely 35 | 36 | return shapely.from_wkb(wkb) 37 | 38 | 39 | def polygons_geoarrow(wkb): 40 | import pyproj 41 | import shapely 42 | from arro3.core import list_array 43 | 44 | polygons = shapely.from_wkb(wkb) 45 | crs = pyproj.CRS.from_epsg(4326) 46 | 47 | geometry_type, coords, (ring_offsets, geom_offsets) = shapely.to_ragged_array( 48 | polygons 49 | ) 50 | 51 | if geometry_type != shapely.GeometryType.POLYGON: 52 | raise ValueError(f"unsupported geometry type found: {geometry_type}") 53 | 54 | polygon_array = list_array( 55 | geom_offsets.astype("int32"), list_array(ring_offsets.astype("int32"), coords) 56 | ) 57 | polygon_array_with_geo_meta = polygon_array.cast( 58 | polygon_array.field.with_metadata( 59 | { 60 | "ARROW:extension:name": "geoarrow.polygon", 61 | "ARROW:extension:metadata": json.dumps({"crs": crs.to_json_dict()}), 62 | } 63 | ) 64 | ) 65 | 66 | return polygon_array_with_geo_meta 67 | 68 | 69 | @dataclass(frozen=True) 70 | class H3Info(DGGSInfo): 71 | """ 72 | Grid information container for h3 grids. 73 | 74 | Parameters 75 | ---------- 76 | level : int 77 | Grid hierarchical level. A higher value corresponds to a finer grid resolution 78 | with smaller cell areas. The number of cells covering the whole sphere usually 79 | grows exponentially with increasing level values, ranging from 5-100 cells at 80 | level 0 to millions or billions of cells at level 10+ (the exact numbers depends 81 | on the specific grid). 82 | """ 83 | 84 | level: int 85 | """int : The hierarchical level of the grid""" 86 | 87 | valid_parameters: ClassVar[dict[str, Any]] = {"level": range(16)} 88 | 89 | def __post_init__(self): 90 | if self.level not in self.valid_parameters["level"]: 91 | raise ValueError("level must be an integer between 0 and 15") 92 | 93 | @classmethod 94 | def from_dict(cls: type[Self], mapping: dict[str, Any]) -> Self: 95 | """construct a `H3Info` object from a mapping of attributes 96 | 97 | Parameters 98 | ---------- 99 | mapping: mapping of str to any 100 | The attributes. 101 | 102 | Returns 103 | ------- 104 | grid_info : H3Info 105 | The constructed grid info object. 106 | """ 107 | translations = { 108 | "resolution": ("level", int), 109 | "level": ("level", int), 110 | } 111 | 112 | params = translate_parameters(mapping, translations) 113 | return cls(**params) 114 | 115 | def to_dict(self: Self) -> dict[str, Any]: 116 | """ 117 | Dump the normalized grid parameters. 118 | 119 | Returns 120 | ------- 121 | mapping : dict of str to any 122 | The normalized grid parameters. 123 | """ 124 | return {"grid_name": "h3", "level": self.level} 125 | 126 | def cell_ids2geographic( 127 | self, cell_ids: np.ndarray 128 | ) -> tuple[np.ndarray, np.ndarray]: 129 | """ 130 | Convert cell ids to geographic coordinates 131 | 132 | Parameters 133 | ---------- 134 | cell_ids : array-like 135 | Array-like containing the cell ids. 136 | 137 | Returns 138 | ------- 139 | lon : array-like 140 | The longitude coordinate values of the grid cells in degree 141 | lat : array-like 142 | The latitude coordinate values of the grid cells in degree 143 | """ 144 | result = cells_to_coordinates(cell_ids, radians=False) 145 | 146 | lon = result.column("lng").to_numpy() 147 | lat = result.column("lat").to_numpy() 148 | 149 | return lon, lat 150 | 151 | def geographic2cell_ids(self, lon, lat): 152 | """ 153 | Convert cell ids to geographic coordinates 154 | 155 | This will perform a binning operation: any point within a grid cell will be assign 156 | that cell's ID. 157 | 158 | Parameters 159 | ---------- 160 | lon : array-like 161 | The longitude coordinate values in degree 162 | lat : array-like 163 | The latitude coordinate values in degree 164 | 165 | Returns 166 | ------- 167 | cell_ids : array-like 168 | Array-like containing the cell ids. 169 | """ 170 | return coordinates_to_cells(lat, lon, self.level, radians=False) 171 | 172 | def cell_boundaries(self, cell_ids, backend="shapely"): 173 | """ 174 | Derive cell boundary polygons from cell ids 175 | 176 | Parameters 177 | ---------- 178 | cell_ids : array-like 179 | The cell ids. 180 | backend : {"shapely", "geoarrow"}, default: "shapely" 181 | The backend to convert to. 182 | 183 | Returns 184 | ------- 185 | polygons : array-like 186 | The derived cell boundary polygons. The format differs based on the passed 187 | backend: 188 | 189 | - ``"shapely"``: return a array of :py:class:`shapely.Polygon` objects 190 | - ``"geoarrow"``: return a ``geoarrow`` array 191 | """ 192 | # TODO: convert cell ids directly to geoarrow once h3ronpy supports it 193 | wkb = cells_to_wkb_polygons(cell_ids, radians=False, link_cells=False) 194 | 195 | backends = { 196 | "shapely": polygons_shapely, 197 | "geoarrow": polygons_geoarrow, 198 | } 199 | backend_func = backends.get(backend) 200 | if backend_func is None: 201 | raise ValueError("invalid backend: {backend!r}") 202 | return backend_func(wkb) 203 | 204 | 205 | @register_dggs("h3") 206 | class H3Index(DGGSIndex): 207 | _grid: DGGSInfo 208 | 209 | def __init__( 210 | self, 211 | cell_ids: Any | PandasIndex, 212 | dim: str, 213 | grid_info: DGGSInfo, 214 | ): 215 | super().__init__(cell_ids, dim, grid_info) 216 | 217 | @classmethod 218 | def from_variables( 219 | cls: type["H3Index"], 220 | variables: Mapping[Any, xr.Variable], 221 | *, 222 | options: Mapping[str, Any], 223 | ) -> "H3Index": 224 | _, var, dim = _extract_cell_id_variable(variables) 225 | 226 | grid_info = H3Info.from_dict(var.attrs | options) 227 | 228 | return cls(var.data, dim, grid_info) 229 | 230 | @property 231 | def grid_info(self) -> H3Info: 232 | return self._grid 233 | 234 | def _replace(self, new_pd_index: PandasIndex): 235 | return type(self)(new_pd_index, self._dim, self._grid) 236 | 237 | def _repr_inline_(self, max_width: int): 238 | return f"H3Index(level={self._grid.level})" 239 | -------------------------------------------------------------------------------- /xdggs/healpix.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections.abc import Mapping 3 | from dataclasses import dataclass 4 | from typing import Any, ClassVar, Literal, TypeVar 5 | 6 | try: 7 | from typing import Self 8 | except ImportError: # pragma: no cover 9 | from typing_extensions import Self 10 | 11 | import cdshealpix.nested 12 | import cdshealpix.ring 13 | import numpy as np 14 | import xarray as xr 15 | from xarray.indexes import PandasIndex 16 | 17 | from xdggs.grid import DGGSInfo, translate_parameters 18 | from xdggs.index import DGGSIndex 19 | from xdggs.itertools import identity 20 | from xdggs.utils import _extract_cell_id_variable, register_dggs 21 | 22 | T = TypeVar("T") 23 | 24 | 25 | def polygons_shapely(vertices): 26 | import shapely 27 | 28 | return shapely.polygons(vertices) 29 | 30 | 31 | def polygons_geoarrow(vertices): 32 | import pyproj 33 | from arro3.core import list_array 34 | 35 | polygon_vertices = np.concatenate([vertices, vertices[:, :1, :]], axis=1) 36 | crs = pyproj.CRS.from_epsg(4326) 37 | 38 | # construct geoarrow arrays 39 | coords = np.reshape(polygon_vertices, (-1, 2)) 40 | coords_per_pixel = polygon_vertices.shape[1] 41 | geom_offsets = np.arange(vertices.shape[0] + 1, dtype="int32") 42 | ring_offsets = geom_offsets * coords_per_pixel 43 | 44 | polygon_array = list_array(geom_offsets, list_array(ring_offsets, coords)) 45 | 46 | # We need to tag the array with extension metadata (`geoarrow.polygon`) so that Lonboard knows that this is a geospatial column. 47 | polygon_array_with_geo_meta = polygon_array.cast( 48 | polygon_array.field.with_metadata( 49 | { 50 | "ARROW:extension:name": "geoarrow.polygon", 51 | "ARROW:extension:metadata": json.dumps( 52 | {"crs": crs.to_json_dict(), "edges": "spherical"} 53 | ), 54 | } 55 | ) 56 | ) 57 | return polygon_array_with_geo_meta 58 | 59 | 60 | def center_around_prime_meridian(lon, lat): 61 | # three tasks: 62 | # - center around the prime meridian (map to a range of [-180, 180]) 63 | # - replace the longitude of points at the poles with the median 64 | # of longitude of the other vertices 65 | # - cells that cross the dateline should have longitudes around 180 66 | 67 | # center around prime meridian 68 | recentered = (lon + 180) % 360 - 180 69 | 70 | # replace lon of pole with the median of the remaining vertices 71 | contains_poles = np.isin(lat, np.array([-90, 90])) 72 | pole_cells = np.any(contains_poles, axis=-1) 73 | recentered[contains_poles] = np.median( 74 | np.reshape( 75 | recentered[pole_cells[:, None] & np.logical_not(contains_poles)], (-1, 3) 76 | ), 77 | axis=-1, 78 | ) 79 | 80 | # keep cells that cross the dateline centered around 180 81 | polygons_to_fix = np.any(recentered < -100, axis=-1) & np.any( 82 | recentered > 100, axis=-1 83 | ) 84 | result = np.where( 85 | polygons_to_fix[:, None] & (recentered < 0), recentered + 360, recentered 86 | ) 87 | 88 | return result 89 | 90 | 91 | @dataclass(frozen=True) 92 | class HealpixInfo(DGGSInfo): 93 | """ 94 | Grid information container for healpix grids. 95 | 96 | Parameters 97 | ---------- 98 | level : int 99 | Grid hierarchical level. A higher value corresponds to a finer grid resolution 100 | with smaller cell areas. The number of cells covering the whole sphere usually 101 | grows exponentially with increasing level values, ranging from 5-100 cells at 102 | level 0 to millions or billions of cells at level 10+ (the exact numbers depends 103 | on the specific grid). 104 | indexing_scheme : {"nested", "ring", "unique"}, default: "nested" 105 | The indexing scheme of the healpix grid. 106 | 107 | .. warning:: 108 | Note that ``"unique"`` is currently not supported as the underlying library 109 | (:doc:`cdshealpix `) does not support it. 110 | """ 111 | 112 | level: int 113 | """int : The hierarchical level of the grid""" 114 | 115 | indexing_scheme: Literal["nested", "ring", "unique"] = "nested" 116 | """int : The indexing scheme of the grid""" 117 | 118 | valid_parameters: ClassVar[dict[str, Any]] = { 119 | "level": range(0, 29 + 1), 120 | "indexing_scheme": ["nested", "ring", "unique"], 121 | } 122 | 123 | def __post_init__(self): 124 | if self.level not in self.valid_parameters["level"]: 125 | raise ValueError("level must be an integer in the range of [0, 29]") 126 | 127 | if self.indexing_scheme not in self.valid_parameters["indexing_scheme"]: 128 | raise ValueError( 129 | f"indexing scheme must be one of {self.valid_parameters['indexing_scheme']}" 130 | ) 131 | elif self.indexing_scheme == "unique": 132 | raise ValueError("the indexing scheme `unique` is currently not supported") 133 | 134 | @property 135 | def nside(self: Self) -> int: 136 | """resolution as the healpy-compatible nside parameter""" 137 | return 2**self.level 138 | 139 | @property 140 | def nest(self: Self) -> bool: 141 | """indexing_scheme as the healpy-compatible nest parameter""" 142 | if self.indexing_scheme not in {"nested", "ring"}: 143 | raise ValueError( 144 | f"cannot convert indexing scheme {self.indexing_scheme} to `nest`" 145 | ) 146 | else: 147 | return self.indexing_scheme == "nested" 148 | 149 | @classmethod 150 | def from_dict(cls: type[T], mapping: dict[str, Any]) -> T: 151 | """construct a `HealpixInfo` object from a mapping of attributes 152 | 153 | Parameters 154 | ---------- 155 | mapping: mapping of str to any 156 | The attributes. 157 | 158 | Returns 159 | ------- 160 | grid_info : HealpixInfo 161 | The constructed grid info object. 162 | """ 163 | 164 | def translate_nside(nside): 165 | log = np.log2(nside) 166 | potential_level = int(log) 167 | if potential_level != log: 168 | raise ValueError("`nside` has to be an integer power of 2") 169 | 170 | return potential_level 171 | 172 | translations = { 173 | "nside": ("level", translate_nside), 174 | "order": ("level", identity), 175 | "resolution": ("level", identity), 176 | "depth": ("level", identity), 177 | "nest": ("indexing_scheme", lambda nest: "nested" if nest else "ring"), 178 | } 179 | 180 | params = translate_parameters(mapping, translations) 181 | return cls(**params) 182 | 183 | def to_dict(self: Self) -> dict[str, Any]: 184 | """ 185 | Dump the normalized grid parameters. 186 | 187 | Returns 188 | ------- 189 | mapping : dict of str to any 190 | The normalized grid parameters. 191 | """ 192 | return { 193 | "grid_name": "healpix", 194 | "level": self.level, 195 | "indexing_scheme": self.indexing_scheme, 196 | } 197 | 198 | def cell_ids2geographic(self, cell_ids): 199 | """ 200 | Convert cell ids to geographic coordinates 201 | 202 | Parameters 203 | ---------- 204 | cell_ids : array-like 205 | Array-like containing the cell ids. 206 | 207 | Returns 208 | ------- 209 | lon : array-like 210 | The longitude coordinate values of the grid cells in degree 211 | lat : array-like 212 | The latitude coordinate values of the grid cells in degree 213 | """ 214 | converters = { 215 | "nested": cdshealpix.nested.healpix_to_lonlat, 216 | "ring": lambda cell_ids, level: cdshealpix.ring.healpix_to_lonlat( 217 | cell_ids, nside=2**level 218 | ), 219 | } 220 | converter = converters[self.indexing_scheme] 221 | 222 | lon, lat = converter(cell_ids, self.level) 223 | 224 | return np.asarray(lon.to("degree")), np.asarray(lat.to("degree")) 225 | 226 | def geographic2cell_ids(self, lon, lat): 227 | """ 228 | Convert cell ids to geographic coordinates 229 | 230 | This will perform a binning operation: any point within a grid cell will be assign 231 | that cell's ID. 232 | 233 | Parameters 234 | ---------- 235 | lon : array-like 236 | The longitude coordinate values in degree 237 | lat : array-like 238 | The latitude coordinate values in degree 239 | 240 | Returns 241 | ------- 242 | cell_ids : array-like 243 | Array-like containing the cell ids. 244 | """ 245 | from astropy.coordinates import Latitude, Longitude 246 | 247 | converters = { 248 | "nested": cdshealpix.nested.lonlat_to_healpix, 249 | "ring": lambda lon, lat, level: cdshealpix.ring.lonlat_to_healpix( 250 | lon, lat, nside=2**level 251 | ), 252 | } 253 | converter = converters[self.indexing_scheme] 254 | 255 | longitude = Longitude(lon, unit="degree") 256 | latitude = Latitude(lat, unit="degree") 257 | 258 | return converter(longitude, latitude, self.level) 259 | 260 | def cell_boundaries(self, cell_ids: Any, backend="shapely") -> np.ndarray: 261 | """ 262 | Derive cell boundary polygons from cell ids 263 | 264 | Parameters 265 | ---------- 266 | cell_ids : array-like 267 | The cell ids. 268 | backend : {"shapely", "geoarrow"}, default: "shapely" 269 | The backend to convert to. 270 | 271 | Returns 272 | ------- 273 | polygons : array-like 274 | The derived cell boundary polygons. The format differs based on the passed 275 | backend: 276 | 277 | - ``"shapely"``: return a array of :py:class:`shapely.Polygon` objects 278 | - ``"geoarrow"``: return a ``geoarrow`` array 279 | """ 280 | converters = { 281 | "nested": cdshealpix.nested.vertices, 282 | "ring": lambda cell_ids, level, **kwargs: cdshealpix.ring.vertices( 283 | cell_ids, nside=2**level, **kwargs 284 | ), 285 | } 286 | converter = converters[self.indexing_scheme] 287 | 288 | lon_, lat_ = converter(cell_ids, self.level, step=1) 289 | 290 | lon = np.asarray(lon_.to("degree")) 291 | lat = np.asarray(lat_.to("degree")) 292 | 293 | lon_reshaped = np.reshape(lon, (-1, 4)) 294 | lat_reshaped = np.reshape(lat, (-1, 4)) 295 | 296 | lon_ = center_around_prime_meridian(lon_reshaped, lat_reshaped) 297 | 298 | vertices = np.stack((lon_, lat_reshaped), axis=-1) 299 | 300 | backends = { 301 | "shapely": polygons_shapely, 302 | "geoarrow": polygons_geoarrow, 303 | } 304 | 305 | backend_func = backends.get(backend) 306 | if backend_func is None: 307 | raise ValueError("invalid backend: {backend!r}") 308 | 309 | return backend_func(vertices) 310 | 311 | 312 | @register_dggs("healpix") 313 | class HealpixIndex(DGGSIndex): 314 | def __init__( 315 | self, 316 | cell_ids: Any | PandasIndex, 317 | dim: str, 318 | grid_info: DGGSInfo, 319 | ): 320 | if not isinstance(grid_info, HealpixInfo): 321 | raise ValueError(f"grid info object has an invalid type: {type(grid_info)}") 322 | 323 | super().__init__(cell_ids, dim, grid_info) 324 | 325 | @classmethod 326 | def from_variables( 327 | cls: type["HealpixIndex"], 328 | variables: Mapping[Any, xr.Variable], 329 | *, 330 | options: Mapping[str, Any], 331 | ) -> "HealpixIndex": 332 | _, var, dim = _extract_cell_id_variable(variables) 333 | 334 | grid_info = HealpixInfo.from_dict(var.attrs | options) 335 | 336 | return cls(var.data, dim, grid_info) 337 | 338 | def _replace(self, new_pd_index: PandasIndex): 339 | return type(self)(new_pd_index, self._dim, self._grid) 340 | 341 | @property 342 | def grid_info(self) -> HealpixInfo: 343 | return self._grid 344 | 345 | def _repr_inline_(self, max_width: int): 346 | return f"HealpixIndex(level={self._grid.level}, indexing_scheme={self._grid.indexing_scheme})" 347 | -------------------------------------------------------------------------------- /xdggs/index.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Hashable, Mapping 2 | from typing import Any, Union 3 | 4 | import numpy as np 5 | import xarray as xr 6 | from xarray.indexes import Index, PandasIndex 7 | 8 | from xdggs.grid import DGGSInfo 9 | from xdggs.utils import GRID_REGISTRY, _extract_cell_id_variable 10 | 11 | 12 | def decode(ds, grid_info=None, *, name="cell_ids"): 13 | """ 14 | decode grid parameters and create a DGGS index 15 | 16 | Parameters 17 | ---------- 18 | ds : xarray.Dataset 19 | The input dataset. Must contain a coordinate for the cell ids with at 20 | least the attributes `grid_name` and `level`. 21 | grid_info : dict or DGGSInfo, optional 22 | Override the grid parameters on the dataset. Useful to set attributes on 23 | the dataset. 24 | name : str, default: "cell_ids" 25 | The name of the coordinate containing the cell ids. 26 | 27 | Returns 28 | ------- 29 | decoded : xarray.DataArray or xarray.Dataset 30 | The input dataset with a DGGS index on the cell id coordinate. 31 | 32 | See Also 33 | -------- 34 | xarray.Dataset.dggs.decode 35 | xarray.DataArray.dggs.decode 36 | """ 37 | return ds.dggs.decode(name=name, grid_info=grid_info) 38 | 39 | 40 | class DGGSIndex(Index): 41 | _dim: str 42 | _pd_index: PandasIndex 43 | 44 | def __init__(self, cell_ids: Any | PandasIndex, dim: str, grid_info: DGGSInfo): 45 | self._dim = dim 46 | 47 | if isinstance(cell_ids, PandasIndex): 48 | self._pd_index = cell_ids 49 | else: 50 | self._pd_index = PandasIndex(cell_ids, dim) 51 | 52 | self._grid = grid_info 53 | 54 | @classmethod 55 | def from_variables( 56 | cls: type["DGGSIndex"], 57 | variables: Mapping[Any, xr.Variable], 58 | *, 59 | options: Mapping[str, Any], 60 | ) -> "DGGSIndex": 61 | _, var, _ = _extract_cell_id_variable(variables) 62 | 63 | grid_name = var.attrs["grid_name"] 64 | cls = GRID_REGISTRY.get(grid_name) 65 | if cls is None: 66 | raise ValueError(f"unknown DGGS grid name: {grid_name}") 67 | 68 | return cls.from_variables(variables, options=options) 69 | 70 | def create_variables( 71 | self, variables: Mapping[Any, xr.Variable] | None = None 72 | ) -> dict[Hashable, xr.Variable]: 73 | return self._pd_index.create_variables(variables) 74 | 75 | def isel( 76 | self: "DGGSIndex", indexers: Mapping[Any, int | np.ndarray | xr.Variable] 77 | ) -> Union["DGGSIndex", None]: 78 | new_pd_index = self._pd_index.isel(indexers) 79 | if new_pd_index is not None: 80 | return self._replace(new_pd_index) 81 | else: 82 | return None 83 | 84 | def sel(self, labels, method=None, tolerance=None): 85 | if method == "nearest": 86 | raise ValueError("finding nearest grid cell has no meaning") 87 | return self._pd_index.sel(labels, method=method, tolerance=tolerance) 88 | 89 | def _replace(self, new_pd_index: PandasIndex): 90 | raise NotImplementedError() 91 | 92 | def cell_centers(self) -> tuple[np.ndarray, np.ndarray]: 93 | return self._grid.cell_ids2geographic(self._pd_index.index.values) 94 | 95 | def cell_boundaries(self) -> np.ndarray: 96 | return self.grid_info.cell_boundaries(self._pd_index.index.values) 97 | 98 | @property 99 | def grid_info(self) -> DGGSInfo: 100 | return self._grid 101 | -------------------------------------------------------------------------------- /xdggs/itertools.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | 4 | def identity(x): 5 | return x 6 | 7 | 8 | def groupby(iterable, key): 9 | return itertools.groupby(sorted(iterable, key=key), key=key) 10 | -------------------------------------------------------------------------------- /xdggs/plotting.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from functools import partial 3 | from typing import Any 4 | 5 | import ipywidgets 6 | import numpy as np 7 | import xarray as xr 8 | from lonboard import Map 9 | 10 | 11 | def on_slider_change(change, container): 12 | owner = change["owner"] 13 | dim = owner.description 14 | 15 | indexers = { 16 | slider.description: slider.value 17 | for slider in container.dimension_sliders.children 18 | if slider.description != dim 19 | } | {dim: change["new"]} 20 | new_slice = container.obj.isel(indexers) 21 | 22 | colors = colorize(new_slice.variable, **container.colorize_kwargs) 23 | 24 | layer = container.map.layers[0] 25 | layer.get_fill_color = colors 26 | 27 | 28 | @dataclass 29 | class MapContainer: 30 | """container for the map, any control widgets and the data object""" 31 | 32 | dimension_sliders: ipywidgets.VBox 33 | map: Map 34 | obj: xr.DataArray 35 | 36 | colorize_kwargs: dict[str, Any] 37 | 38 | def render(self): 39 | # add any additional control widgets here 40 | control_box = ipywidgets.HBox([self.dimension_sliders]) 41 | 42 | return ipywidgets.VBox([self.map, control_box]) 43 | 44 | 45 | def create_arrow_table(polygons, arr, coords=None): 46 | from arro3.core import Array, ChunkedArray, Schema, Table 47 | 48 | if coords is None: 49 | coords = ["latitude", "longitude"] 50 | 51 | array = Array.from_arrow(polygons) 52 | name = arr.name or "data" 53 | arrow_arrays = { 54 | "geometry": array, 55 | "cell_ids": ChunkedArray([Array.from_numpy(arr.coords["cell_ids"])]), 56 | name: ChunkedArray([Array.from_numpy(arr.data)]), 57 | } | { 58 | coord: ChunkedArray([Array.from_numpy(arr.coords[coord].data)]) 59 | for coord in coords 60 | if coord in arr.coords 61 | } 62 | 63 | fields = [array.field.with_name(name) for name, array in arrow_arrays.items()] 64 | schema = Schema(fields) 65 | 66 | return Table.from_arrays(list(arrow_arrays.values()), schema=schema) 67 | 68 | 69 | def normalize(var, center=None): 70 | from matplotlib.colors import CenteredNorm, Normalize 71 | 72 | if center is None: 73 | vmin = var.min(skipna=True) 74 | vmax = var.max(skipna=True) 75 | normalizer = Normalize(vmin=vmin, vmax=vmax) 76 | else: 77 | halfrange = np.abs(var - center).max(skipna=True) 78 | normalizer = CenteredNorm(vcenter=center, halfrange=halfrange) 79 | 80 | return normalizer(var.data) 81 | 82 | 83 | def colorize(var, *, center, colormap, alpha): 84 | from lonboard.colormap import apply_continuous_cmap 85 | 86 | normalized_data = normalize(var, center=center) 87 | 88 | return apply_continuous_cmap(normalized_data, colormap, alpha=alpha) 89 | 90 | 91 | def explore( 92 | arr, 93 | cmap="viridis", 94 | center=None, 95 | alpha=None, 96 | coords=None, 97 | ): 98 | import lonboard 99 | from lonboard import SolidPolygonLayer 100 | from matplotlib import colormaps 101 | 102 | # guaranteed to be 1D 103 | cell_id_coord = arr.dggs.coord 104 | [cell_dim] = cell_id_coord.dims 105 | 106 | cell_ids = cell_id_coord.data 107 | grid_info = arr.dggs.grid_info 108 | 109 | polygons = grid_info.cell_boundaries(cell_ids, backend="geoarrow") 110 | 111 | initial_indexers = {dim: 0 for dim in arr.dims if dim != cell_dim} 112 | initial_arr = arr.isel(initial_indexers) 113 | 114 | colormap = colormaps[cmap] if isinstance(cmap, str) else cmap 115 | colors = colorize(initial_arr, center=center, alpha=alpha, colormap=colormap) 116 | 117 | table = create_arrow_table(polygons, initial_arr, coords=coords) 118 | layer = SolidPolygonLayer(table=table, filled=True, get_fill_color=colors) 119 | 120 | map_ = lonboard.Map(layer) 121 | 122 | if not initial_indexers: 123 | # 1D data 124 | return map_ 125 | 126 | sliders = ipywidgets.VBox( 127 | [ 128 | ipywidgets.IntSlider(min=0, max=arr.sizes[dim] - 1, description=dim) 129 | for dim in arr.dims 130 | if dim != cell_dim 131 | ] 132 | ) 133 | 134 | container = MapContainer( 135 | sliders, 136 | map_, 137 | arr, 138 | colorize_kwargs={"alpha": alpha, "center": center, "colormap": colormap}, 139 | ) 140 | 141 | # connect slider with map 142 | for slider in sliders.children: 143 | slider.observe(partial(on_slider_change, container=container), names="value") 144 | 145 | return container.render() 146 | -------------------------------------------------------------------------------- /xdggs/tests/__init__.py: -------------------------------------------------------------------------------- 1 | import geoarrow.pyarrow as ga 2 | import shapely 3 | 4 | from xdggs.tests.matchers import ( # noqa: F401 5 | Match, 6 | MatchResult, 7 | assert_exceptions_equal, 8 | ) 9 | 10 | 11 | def geoarrow_to_shapely(arr): 12 | return shapely.from_wkb(ga.as_wkb(arr)) 13 | -------------------------------------------------------------------------------- /xdggs/tests/matchers.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import re 3 | from dataclasses import dataclass, field 4 | 5 | try: 6 | ExceptionGroup 7 | except NameError: 8 | from exceptiongroup import BaseExceptionGroup, ExceptionGroup 9 | 10 | 11 | class MatchResult(enum.Enum): 12 | match = 0 13 | mismatched_typing = 1 14 | mismatched_message = 2 15 | 16 | 17 | def is_exception_spec(exc): 18 | if not isinstance(exc, tuple): 19 | exc = (exc,) 20 | 21 | return all(isinstance(e, type) and issubclass(e, BaseException) for e in exc) 22 | 23 | 24 | def is_exceptiongroup_spec(exc): 25 | if not isinstance(exc, tuple): 26 | exc = (exc,) 27 | 28 | return all(isinstance(e, type) and issubclass(e, BaseExceptionGroup) for e in exc) 29 | 30 | 31 | class Match: ... 32 | 33 | 34 | MatchType = BaseException | Match | tuple[BaseException | Match, ...] 35 | 36 | 37 | def extract_message(exc): 38 | return getattr(exc, "message", str(exc)) 39 | 40 | 41 | # necessary until pytest-dev/pytest#11538 is resolved 42 | @dataclass 43 | class Match: 44 | """match exceptions and exception groups 45 | 46 | Think of `Match` objects as an equivalent to `re.Pattern` classes. 47 | 48 | Providing a tuple in place of a single exception / matcher means logical "or". 49 | 50 | Parameters 51 | ---------- 52 | exc : exception-like or tuple of exception-like 53 | The exceptions or exception groups to match. 54 | submatchers : match-like, optional 55 | The submatchers for exception groups. Note that matchers with a mixture of 56 | exception groups and exceptions can't provide submatchers. If that's what you 57 | need, provide a tuple containing multiple matchers. 58 | match : str or regex-like, optional 59 | A pattern for matching the message of the exception. 60 | """ 61 | 62 | exc: type[BaseException] | tuple[type[BaseException], ...] 63 | submatchers: list[MatchType] = field(default_factory=list) 64 | match: str = None 65 | 66 | def __post_init__(self): 67 | if not is_exception_spec(self.exc): 68 | raise TypeError( 69 | f"exception type must be one or more exceptions, got: {self.exc}" 70 | ) 71 | if not is_exceptiongroup_spec(self.exc) and self.submatchers: 72 | raise TypeError("can only pass sub-matchers for exception groups") 73 | if not isinstance(self.match, str) and self.match is not None: 74 | raise TypeError("match must be either `None` or a string pattern") 75 | 76 | @classmethod 77 | def from_dict(cls, mapping): 78 | children = [cls.from_dict(m) for m in mapping.get("children", [])] 79 | 80 | return cls( 81 | mapping["exceptions"], 82 | submatchers=children, 83 | match=mapping.get("match", None), 84 | ) 85 | 86 | def matches(self, exc): 87 | if not isinstance(exc, self.exc): 88 | return False 89 | 90 | if self.match is not None: 91 | message = extract_message(exc) 92 | match_ = re.search(self.match, message) 93 | if match_ is None: 94 | return False 95 | 96 | if self.submatchers and not isinstance(exc, BaseExceptionGroup): 97 | return False 98 | elif self.submatchers: 99 | if len(self.submatchers) != len(exc.exceptions): 100 | return False 101 | 102 | unmatched_matchers = [] 103 | exceptions = list(exc.exceptions) 104 | for matcher in self.submatchers: 105 | for index, exception in enumerate(exceptions): 106 | if matcher.matches(exception): 107 | exceptions.pop(index) 108 | break 109 | else: 110 | unmatched_matchers.append(matcher) 111 | 112 | if unmatched_matchers: 113 | return False 114 | 115 | return True 116 | 117 | 118 | def compare_exceptions(a, b): 119 | if type(a) is not type(b): 120 | return False 121 | 122 | if isinstance(a, ExceptionGroup): 123 | comparison = a.args[0] == b.args[0] and all( 124 | compare_exceptions(_a, _b) for _a, _b in zip(a.args[1], b.args[1]) 125 | ) 126 | else: 127 | comparison = a.args == b.args 128 | 129 | return comparison 130 | 131 | 132 | def format_exception_diff(a, b): 133 | sections = [] 134 | if type(a) is not type(b): 135 | sections.append("\n".join([f"L {type(a).__name__}", f"R {type(b).__name__}"])) 136 | else: 137 | sections.append(f"{type(a).__name__}") 138 | 139 | if isinstance(a, BaseExceptionGroup): 140 | if a.message != b.message: 141 | sections.append( 142 | "\n".join(["Message", f"L {a.message}", f"R {b.message}"]) 143 | ) 144 | if a.exceptions != b.exceptions: 145 | sections.append("\n".join(["Exceptions differ"])) 146 | elif str(a) != str(b): 147 | sections.append("\n".join(["Message", f"L {str(a)}", f"R {str(b)}"])) 148 | 149 | return "\n\n".join(sections) 150 | 151 | 152 | def assert_exceptions_equal(actual, expected): 153 | assert compare_exceptions(actual, expected), format_exception_diff(actual, expected) 154 | -------------------------------------------------------------------------------- /xdggs/tests/test_accessor.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import xarray as xr 3 | 4 | import xdggs 5 | 6 | 7 | @pytest.mark.parametrize( 8 | ["obj", "grid_info", "name"], 9 | ( 10 | pytest.param( 11 | xr.Dataset( 12 | coords={ 13 | "cell_ids": ( 14 | "cells", 15 | [1], 16 | { 17 | "grid_name": "healpix", 18 | "level": 1, 19 | "indexing_scheme": "ring", 20 | }, 21 | ) 22 | } 23 | ), 24 | None, 25 | None, 26 | id="dataset-from attrs-standard name", 27 | ), 28 | pytest.param( 29 | xr.DataArray( 30 | [0.1], 31 | coords={ 32 | "cell_ids": ( 33 | "cells", 34 | [1], 35 | { 36 | "grid_name": "healpix", 37 | "level": 1, 38 | "indexing_scheme": "ring", 39 | }, 40 | ) 41 | }, 42 | dims="cells", 43 | ), 44 | None, 45 | None, 46 | id="dataarray-from attrs-standard name", 47 | ), 48 | pytest.param( 49 | xr.Dataset( 50 | coords={ 51 | "zone_ids": ( 52 | "zones", 53 | [1], 54 | { 55 | "grid_name": "healpix", 56 | "level": 1, 57 | "indexing_scheme": "ring", 58 | }, 59 | ) 60 | } 61 | ), 62 | None, 63 | "zone_ids", 64 | id="dataset-from attrs-custom name", 65 | ), 66 | pytest.param( 67 | xr.Dataset(coords={"cell_ids": ("cells", [1])}), 68 | {"grid_name": "healpix", "level": 1, "indexing_scheme": "ring"}, 69 | None, 70 | id="dataset-dict-standard name", 71 | ), 72 | ), 73 | ) 74 | def test_decode(obj, grid_info, name) -> None: 75 | kwargs = {} 76 | if name is not None: 77 | kwargs["name"] = name 78 | 79 | if isinstance(grid_info, dict): 80 | expected_grid_info = grid_info 81 | elif isinstance(grid_info, xdggs.DGGSInfo): 82 | expected_grid_info = grid_info.to_dict() 83 | else: 84 | expected_grid_info = obj[name if name is not None else "cell_ids"].attrs 85 | 86 | actual = obj.dggs.decode(grid_info, **kwargs) 87 | assert any(isinstance(index, xdggs.DGGSIndex) for index in actual.xindexes.values()) 88 | assert actual.dggs.grid_info.to_dict() == expected_grid_info 89 | 90 | 91 | @pytest.mark.parametrize( 92 | ["obj", "expected"], 93 | ( 94 | ( 95 | xr.DataArray( 96 | [0], 97 | coords={ 98 | "cell_ids": ( 99 | "cells", 100 | [3], 101 | { 102 | "grid_name": "healpix", 103 | "level": 1, 104 | "indexing_scheme": "ring", 105 | }, 106 | ) 107 | }, 108 | dims="cells", 109 | ), 110 | xr.Dataset( 111 | coords={ 112 | "latitude": ("cells", [66.44353569089877]), 113 | "longitude": ("cells", [315.0]), 114 | } 115 | ), 116 | ), 117 | ( 118 | xr.Dataset( 119 | coords={ 120 | "cell_ids": ( 121 | "cells", 122 | [0x832830FFFFFFFFF], 123 | {"grid_name": "h3", "level": 3}, 124 | ) 125 | } 126 | ), 127 | xr.Dataset( 128 | coords={ 129 | "latitude": ("cells", [38.19320895]), 130 | "longitude": ("cells", [-122.19619676]), 131 | } 132 | ), 133 | ), 134 | ), 135 | ) 136 | def test_cell_centers(obj, expected): 137 | obj_ = obj.pipe(xdggs.decode) 138 | 139 | actual = obj_.dggs.cell_centers() 140 | 141 | xr.testing.assert_allclose(actual, expected) 142 | 143 | 144 | @pytest.mark.parametrize( 145 | ["obj", "expected"], 146 | ( 147 | ( 148 | xr.DataArray( 149 | [0], 150 | coords={ 151 | "cell_ids": ( 152 | "cells", 153 | [3], 154 | { 155 | "grid_name": "healpix", 156 | "level": 1, 157 | "indexing_scheme": "ring", 158 | }, 159 | ) 160 | }, 161 | dims="cells", 162 | ), 163 | xr.DataArray( 164 | [0], 165 | coords={ 166 | "latitude": ("cells", [66.44353569089877]), 167 | "longitude": ("cells", [315.0]), 168 | "cell_ids": ( 169 | "cells", 170 | [3], 171 | { 172 | "grid_name": "healpix", 173 | "level": 1, 174 | "indexing_scheme": "ring", 175 | }, 176 | ), 177 | }, 178 | dims="cells", 179 | ), 180 | ), 181 | ( 182 | xr.Dataset( 183 | coords={ 184 | "cell_ids": ( 185 | "cells", 186 | [0x832830FFFFFFFFF], 187 | {"grid_name": "h3", "level": 3}, 188 | ) 189 | } 190 | ), 191 | xr.Dataset( 192 | coords={ 193 | "latitude": ("cells", [38.19320895]), 194 | "longitude": ("cells", [-122.19619676]), 195 | "cell_ids": ( 196 | "cells", 197 | [0x832830FFFFFFFFF], 198 | {"grid_name": "h3", "level": 3}, 199 | ), 200 | } 201 | ), 202 | ), 203 | ), 204 | ) 205 | def test_assign_latlon_coords(obj, expected): 206 | obj_ = obj.pipe(xdggs.decode) 207 | 208 | actual = obj_.dggs.assign_latlon_coords() 209 | 210 | xr.testing.assert_allclose(actual, expected) 211 | -------------------------------------------------------------------------------- /xdggs/tests/test_h3.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | import numpy as np 4 | import pytest 5 | import shapely 6 | import shapely.testing 7 | import xarray as xr 8 | from xarray.core.indexes import PandasIndex 9 | 10 | from xdggs import h3 11 | from xdggs.tests import geoarrow_to_shapely 12 | 13 | # from the h3 gallery, at resolution 3 14 | cell_ids = [ 15 | np.array([0x832830FFFFFFFFF]), 16 | np.array([0x832831FFFFFFFFF, 0x832832FFFFFFFFF]), 17 | np.array([0x832833FFFFFFFFF, 0x832834FFFFFFFFF, 0x832835FFFFFFFFF]), 18 | ] 19 | cell_centers = [ 20 | np.array([[-122.19619676, 38.19320895]]), 21 | np.array([[-123.43390346, 38.63853196], [-121.00991811, 38.82387033]]), 22 | np.array( 23 | [ 24 | [-122.2594399, 39.27846774], 25 | [-122.13425086, 37.09786649], 26 | [-123.35925909, 37.55231005], 27 | ] 28 | ), 29 | ] 30 | dims = ["cells", "zones"] 31 | levels = [1, 5, 15] 32 | variable_names = ["cell_ids", "zonal_ids", "zone_ids"] 33 | 34 | variables = [ 35 | xr.Variable(dims[0], cell_ids[0], {"grid_name": "h3", "level": levels[0]}), 36 | xr.Variable(dims[1], cell_ids[0], {"grid_name": "h3", "level": levels[0]}), 37 | xr.Variable(dims[0], cell_ids[1], {"grid_name": "h3", "level": levels[1]}), 38 | xr.Variable(dims[1], cell_ids[2], {"grid_name": "h3", "level": levels[2]}), 39 | ] 40 | variable_combinations = [ 41 | (old, new) for old, new in itertools.product(variables, repeat=2) 42 | ] 43 | 44 | 45 | class TestH3Info: 46 | 47 | @pytest.mark.parametrize( 48 | ["level", "error"], 49 | ( 50 | (0, None), 51 | (1, None), 52 | (-1, ValueError("level must be an integer between")), 53 | ), 54 | ) 55 | def test_init(self, level, error): 56 | if error is not None: 57 | with pytest.raises(type(error), match=str(error)): 58 | h3.H3Info(level=level) 59 | return 60 | 61 | actual = h3.H3Info(level=level) 62 | 63 | assert actual.level == level 64 | 65 | @pytest.mark.parametrize( 66 | ["mapping", "expected"], 67 | ( 68 | ({"level": 0}, 0), 69 | ({"level": np.int64(2)}, 2), 70 | ({"resolution": 1}, 1), 71 | ({"level": -1}, ValueError("level must be an integer between")), 72 | ), 73 | ) 74 | def test_from_dict(self, mapping, expected): 75 | if isinstance(expected, Exception): 76 | with pytest.raises(type(expected), match=str(expected)): 77 | h3.H3Info.from_dict(mapping) 78 | return 79 | 80 | actual = h3.H3Info.from_dict(mapping) 81 | assert actual.level == expected and type(actual.level) is type(expected) 82 | 83 | def test_roundtrip(self): 84 | mapping = {"grid_name": "h3", "level": 0} 85 | 86 | grid = h3.H3Info.from_dict(mapping) 87 | actual = grid.to_dict() 88 | 89 | assert actual == mapping 90 | 91 | @pytest.mark.parametrize( 92 | ["cell_ids", "cell_centers"], list(zip(cell_ids, cell_centers)) 93 | ) 94 | def test_cell_ids2geographic(self, cell_ids, cell_centers): 95 | grid = h3.H3Info(level=3) 96 | 97 | actual = grid.cell_ids2geographic(cell_ids) 98 | expected = cell_centers.T 99 | 100 | assert isinstance(actual, tuple) and len(actual) == 2 101 | np.testing.assert_allclose(actual, expected) 102 | 103 | @pytest.mark.parametrize( 104 | ["cell_centers", "cell_ids"], list(zip(cell_centers, cell_ids)) 105 | ) 106 | def test_geographic2cell_ids(self, cell_centers, cell_ids): 107 | grid = h3.H3Info(level=3) 108 | 109 | actual = grid.geographic2cell_ids( 110 | lon=cell_centers[:, 0], lat=cell_centers[:, 1] 111 | ) 112 | expected = cell_ids 113 | 114 | np.testing.assert_equal(actual, expected) 115 | 116 | @pytest.mark.parametrize( 117 | ["level", "cell_ids", "expected_coords"], 118 | ( 119 | ( 120 | 1, 121 | np.array([0x81283FFFFFFFFFF]), 122 | np.array( 123 | [ 124 | [ 125 | [-121.70715692, 36.5742183], 126 | [-119.00228227, 40.57057179], 127 | [-122.13483148, 44.05769081], 128 | [-127.95866237, 43.41920841], 129 | [-130.22263777, 39.44242422], 130 | [-127.13810062, 36.07979648], 131 | [-121.70715692, 36.5742183], 132 | ] 133 | ] 134 | ), 135 | ), 136 | ( 137 | 3, 138 | np.array([0x832831FFFFFFFFF, 0x832832FFFFFFFFF]), 139 | np.array( 140 | [ 141 | [ 142 | [-122.99764068, 38.13012709], 143 | [-122.63105838, 38.7055711], 144 | [-123.06903465, 39.21268153], 145 | [-123.87318668, 39.14165748], 146 | [-124.23124622, 38.566387], 147 | [-123.7937797, 38.06195413], 148 | [-122.99764068, 38.13012709], 149 | ], 150 | [ 151 | [-120.57845933, 38.30365554], 152 | [-120.19218037, 38.87491264], 153 | [-120.62501818, 39.3938676], 154 | [-121.44467346, 39.33890002], 155 | [-121.82297225, 38.76738776], 156 | [-121.38971139, 38.25108747], 157 | [-120.57845933, 38.30365554], 158 | ], 159 | ] 160 | ), 161 | ), 162 | ( 163 | 2, 164 | np.array([0x822837FFFFFFFFF, 0x821987FFFFFFFFF, 0x82285FFFFFFFFFF]), 165 | np.array( 166 | [ 167 | [ 168 | [-121.70715692, 36.5742183], 169 | [-120.15030816, 37.77836118], 170 | [-120.62501818, 39.3938676], 171 | [-122.69909887, 39.78423084], 172 | [-124.23124622, 38.566387], 173 | [-123.71598552, 36.97229615], 174 | [-121.70715692, 36.5742183], 175 | ], 176 | [ 177 | [-21.86089163, 59.14600883], 178 | [-24.48971137, 58.33329382], 179 | [-24.24608918, 56.81195076], 180 | [-21.53679367, 56.10124011], 181 | [-18.98719147, 56.88329845], 182 | [-19.06702945, 58.40493644], 183 | [-21.86089163, 59.14600883], 184 | ], 185 | [ 186 | [-132.06227559, 43.79453729], 187 | [-130.64994419, 45.0396523], 188 | [-131.29015942, 46.41694831], 189 | [-133.36750995, 46.52708205], 190 | [-134.72528083, 45.27804582], 191 | [-134.06255972, 43.92295857], 192 | [-132.06227559, 43.79453729], 193 | ], 194 | ] 195 | ), 196 | ), 197 | ), 198 | ) 199 | @pytest.mark.parametrize("backend", ["shapely", "geoarrow"]) 200 | def test_cell_boundaries(self, level, cell_ids, backend, expected_coords): 201 | expected = shapely.polygons(expected_coords) 202 | 203 | grid = h3.H3Info(level=level) 204 | 205 | backends = {"shapely": lambda arr: arr, "geoarrow": geoarrow_to_shapely} 206 | converter = backends[backend] 207 | 208 | actual = grid.cell_boundaries(cell_ids, backend=backend) 209 | 210 | shapely.testing.assert_geometries_equal(converter(actual), expected) 211 | 212 | 213 | @pytest.mark.parametrize("level", levels) 214 | @pytest.mark.parametrize("dim", dims) 215 | @pytest.mark.parametrize("cell_ids", cell_ids) 216 | def test_init(cell_ids, dim, level): 217 | grid = h3.H3Info(level) 218 | index = h3.H3Index(cell_ids, dim, grid) 219 | 220 | assert index._grid == grid 221 | assert index._dim == dim 222 | 223 | # TODO: how do we check the index, if at all? 224 | assert index._pd_index.dim == dim 225 | assert np.all(index._pd_index.index.values == cell_ids) 226 | 227 | 228 | @pytest.mark.parametrize("level", levels) 229 | def test_grid(level): 230 | grid = h3.H3Info(level) 231 | 232 | index = h3.H3Index([0], "cell_ids", grid) 233 | 234 | assert index.grid_info is grid 235 | 236 | 237 | @pytest.mark.parametrize("variable", variables) 238 | @pytest.mark.parametrize("variable_name", variable_names) 239 | @pytest.mark.parametrize("options", [{}]) 240 | def test_from_variables(variable_name, variable, options): 241 | expected_level = variable.attrs["level"] 242 | 243 | variables = {variable_name: variable} 244 | index = h3.H3Index.from_variables(variables, options=options) 245 | 246 | assert index._grid.level == expected_level 247 | assert (index._dim,) == variable.dims 248 | 249 | # TODO: how do we check the index, if at all? 250 | assert (index._pd_index.dim,) == variable.dims 251 | assert np.all(index._pd_index.index.values == variable.data) 252 | 253 | 254 | @pytest.mark.parametrize(["old_variable", "new_variable"], variable_combinations) 255 | def test_replace(old_variable, new_variable): 256 | grid = h3.H3Info(level=old_variable.attrs["level"]) 257 | index = h3.H3Index( 258 | cell_ids=old_variable.data, 259 | dim=old_variable.dims[0], 260 | grid_info=grid, 261 | ) 262 | new_pandas_index = PandasIndex.from_variables( 263 | {"cell_ids": new_variable}, options={} 264 | ) 265 | 266 | new_index = index._replace(new_pandas_index) 267 | 268 | assert new_index._grid == index._grid 269 | assert new_index._dim == index._dim 270 | assert new_index._pd_index == new_pandas_index 271 | 272 | 273 | @pytest.mark.parametrize("max_width", [20, 50, 80, 120]) 274 | @pytest.mark.parametrize("level", levels) 275 | def test_repr_inline(level, max_width): 276 | grid = h3.H3Info(level=level) 277 | index = h3.H3Index(cell_ids=[0], dim="cells", grid_info=grid) 278 | 279 | actual = index._repr_inline_(max_width) 280 | 281 | assert f"level={level}" in actual 282 | # ignore max_width for now 283 | # assert len(actual) <= max_width 284 | -------------------------------------------------------------------------------- /xdggs/tests/test_healpix.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | import hypothesis.extra.numpy as npst 4 | import hypothesis.strategies as st 5 | import numpy as np 6 | import pytest 7 | import shapely 8 | import shapely.testing 9 | import xarray as xr 10 | import xarray.testing.strategies as xrst 11 | from hypothesis import given 12 | from xarray.core.indexes import PandasIndex 13 | 14 | from xdggs import healpix 15 | from xdggs.tests import assert_exceptions_equal, geoarrow_to_shapely 16 | 17 | try: 18 | ExceptionGroup 19 | except NameError: # pragma: no cover 20 | from exceptiongroup import ExceptionGroup 21 | 22 | 23 | # namespace class 24 | class strategies: 25 | invalid_levels = st.integers(max_value=-1) | st.integers(min_value=30) 26 | levels = st.integers(min_value=0, max_value=29) 27 | # TODO: add back `"unique"` once that is supported 28 | indexing_schemes = st.sampled_from(["nested", "ring"]) 29 | invalid_indexing_schemes = st.text().filter(lambda x: x not in ["nested", "ring"]) 30 | 31 | dims = xrst.names() 32 | 33 | @classmethod 34 | def grid_mappings(cls): 35 | strategies = { 36 | "resolution": cls.levels, 37 | "nside": cls.levels.map(lambda n: 2**n), 38 | "depth": cls.levels, 39 | "level": cls.levels, 40 | "order": cls.levels, 41 | "indexing_scheme": cls.indexing_schemes, 42 | "nest": st.booleans(), 43 | } 44 | 45 | names = { 46 | "level": st.sampled_from( 47 | ["resolution", "nside", "depth", "level", "order"] 48 | ), 49 | "indexing_scheme": st.sampled_from(["indexing_scheme", "nest"]), 50 | } 51 | 52 | return st.builds(lambda **x: list(x.values()), **names).flatmap( 53 | lambda params: st.builds(dict, **{p: strategies[p] for p in params}) 54 | ) 55 | 56 | def cell_ids(max_value=None, dtypes=None): 57 | if dtypes is None: 58 | dtypes = st.sampled_from(["int32", "int64", "uint32", "uint64"]) 59 | shapes = npst.array_shapes(min_dims=1, max_dims=1) 60 | 61 | return npst.arrays( 62 | dtypes, 63 | shapes, 64 | elements={"min_value": 0, "max_value": max_value}, 65 | unique=True, 66 | fill=st.nothing(), 67 | ) 68 | 69 | options = st.just({}) 70 | 71 | def grids( 72 | levels=levels, 73 | indexing_schemes=indexing_schemes, 74 | ): 75 | return st.builds( 76 | healpix.HealpixInfo, 77 | level=levels, 78 | indexing_scheme=indexing_schemes, 79 | ) 80 | 81 | @classmethod 82 | def grid_and_cell_ids( 83 | cls, 84 | levels=levels, 85 | indexing_schemes=indexing_schemes, 86 | dtypes=None, 87 | ): 88 | cell_levels = st.shared(levels, key="common-levels") 89 | grid_levels = st.shared(levels, key="common-levels") 90 | cell_ids_ = cell_levels.flatmap( 91 | lambda level: cls.cell_ids(max_value=12 * 4**level - 1, dtypes=dtypes) 92 | ) 93 | grids_ = cls.grids( 94 | levels=grid_levels, 95 | indexing_schemes=indexing_schemes, 96 | ) 97 | 98 | return cell_ids_, grids_ 99 | 100 | 101 | options = [{}] 102 | variable_names = ["cell_ids", "zonal_ids", "zone_ids"] 103 | variables = [ 104 | xr.Variable( 105 | "cells", 106 | np.array([3]), 107 | { 108 | "grid_name": "healpix", 109 | "level": 0, 110 | "indexing_scheme": "nested", 111 | }, 112 | ), 113 | xr.Variable( 114 | "zones", 115 | np.array([3]), 116 | { 117 | "grid_name": "healpix", 118 | "level": 0, 119 | "indexing_scheme": "ring", 120 | }, 121 | ), 122 | xr.Variable( 123 | "cells", 124 | np.array([5, 11, 21]), 125 | { 126 | "grid_name": "healpix", 127 | "level": 1, 128 | "indexing_scheme": "nested", 129 | }, 130 | ), 131 | xr.Variable( 132 | "zones", 133 | np.array([54, 70, 82, 91]), 134 | { 135 | "grid_name": "healpix", 136 | "level": 3, 137 | "indexing_scheme": "nested", 138 | }, 139 | ), 140 | ] 141 | variable_combinations = list(itertools.product(variables, repeat=2)) 142 | 143 | 144 | class TestHealpixInfo: 145 | @given(strategies.invalid_levels) 146 | def test_init_invalid_levels(self, level): 147 | with pytest.raises( 148 | ValueError, match="level must be an integer in the range of" 149 | ): 150 | healpix.HealpixInfo(level=level) 151 | 152 | @given(strategies.invalid_indexing_schemes) 153 | def test_init_invalid_indexing_scheme(self, indexing_scheme): 154 | with pytest.raises(ValueError, match="indexing scheme must be one of"): 155 | healpix.HealpixInfo( 156 | level=0, 157 | indexing_scheme=indexing_scheme, 158 | ) 159 | 160 | @given(strategies.levels, strategies.indexing_schemes) 161 | def test_init(self, level, indexing_scheme): 162 | grid = healpix.HealpixInfo(level=level, indexing_scheme=indexing_scheme) 163 | 164 | assert grid.level == level 165 | assert grid.indexing_scheme == indexing_scheme 166 | 167 | @given(strategies.levels) 168 | def test_nside(self, level): 169 | grid = healpix.HealpixInfo(level=level) 170 | 171 | assert grid.nside == 2**level 172 | 173 | @given(strategies.indexing_schemes) 174 | def test_nest(self, indexing_scheme): 175 | grid = healpix.HealpixInfo(level=1, indexing_scheme=indexing_scheme) 176 | if indexing_scheme not in {"nested", "ring"}: 177 | with pytest.raises( 178 | ValueError, match="cannot convert indexing scheme .* to `nest`" 179 | ): 180 | grid.nest 181 | return 182 | 183 | expected = indexing_scheme == "nested" 184 | 185 | assert grid.nest == expected 186 | 187 | @given(strategies.grid_mappings()) 188 | def test_from_dict(self, mapping) -> None: 189 | healpix.HealpixInfo.from_dict(mapping) 190 | 191 | @given(strategies.levels, strategies.indexing_schemes) 192 | def test_to_dict(self, level, indexing_scheme) -> None: 193 | grid = healpix.HealpixInfo(level=level, indexing_scheme=indexing_scheme) 194 | actual = grid.to_dict() 195 | 196 | assert set(actual) == {"grid_name", "level", "indexing_scheme"} 197 | assert actual["grid_name"] == "healpix" 198 | assert actual["level"] == level 199 | assert actual["indexing_scheme"] == indexing_scheme 200 | 201 | @given(strategies.levels, strategies.indexing_schemes) 202 | def test_roundtrip(self, level, indexing_scheme): 203 | mapping = { 204 | "grid_name": "healpix", 205 | "level": level, 206 | "indexing_scheme": indexing_scheme, 207 | } 208 | 209 | grid = healpix.HealpixInfo.from_dict(mapping) 210 | roundtripped = grid.to_dict() 211 | 212 | assert roundtripped == mapping 213 | 214 | @pytest.mark.parametrize( 215 | ["params", "cell_ids", "expected_coords"], 216 | ( 217 | ( 218 | {"level": 0, "indexing_scheme": "nested"}, 219 | np.array([2]), 220 | np.array( 221 | [ 222 | [-135.0, 0.0], 223 | [-90.0, 41.8103149], 224 | [-135.0, 90.0], 225 | [-180.0, 41.8103149], 226 | ] 227 | ), 228 | ), 229 | ( 230 | {"level": 2, "indexing_scheme": "ring"}, 231 | np.array([12, 54]), 232 | np.array( 233 | [ 234 | [ 235 | [22.5, 41.8103149], 236 | [30.0, 54.3409123], 237 | [0.0, 66.44353569], 238 | [0.0, 54.3409123], 239 | ], 240 | [ 241 | [-45.0, 19.47122063], 242 | [-33.75, 30.0], 243 | [-45.0, 41.8103149], 244 | [-56.25, 30.0], 245 | ], 246 | ] 247 | ), 248 | ), 249 | ( 250 | {"level": 3, "indexing_scheme": "nested"}, 251 | np.array([293, 17]), 252 | np.array( 253 | [ 254 | [ 255 | [-5.625, -4.78019185], 256 | [0.0, 0.0], 257 | [-5.625, 4.78019185], 258 | [-11.25, 0.0], 259 | ], 260 | [ 261 | [73.125, 24.62431835], 262 | [78.75, 30.0], 263 | [73.125, 35.68533471], 264 | [67.5, 30.0], 265 | ], 266 | ] 267 | ), 268 | ), 269 | ( 270 | {"level": 2, "indexing_scheme": "nested"}, 271 | np.array([79]), 272 | np.array( 273 | [ 274 | [0.0, 19.47122063], 275 | [11.25, 30], 276 | [0.0, 41.8103149], 277 | [-11.25, 30], 278 | ] 279 | ), 280 | ), 281 | ), 282 | ) 283 | @pytest.mark.parametrize("backend", ["shapely", "geoarrow"]) 284 | def test_cell_boundaries(self, params, cell_ids, backend, expected_coords): 285 | grid = healpix.HealpixInfo.from_dict(params) 286 | 287 | actual = grid.cell_boundaries(cell_ids, backend=backend) 288 | 289 | backends = { 290 | "shapely": lambda arr: arr, 291 | "geoarrow": geoarrow_to_shapely, 292 | } 293 | converter = backends[backend] 294 | expected = shapely.polygons(expected_coords) 295 | 296 | shapely.testing.assert_geometries_equal(converter(actual), expected) 297 | 298 | @given( 299 | *strategies.grid_and_cell_ids( 300 | # a dtype casting bug in the valid range check of `cdshealpix` 301 | # causes this test to fail for large levels 302 | levels=st.integers(min_value=0, max_value=10), 303 | indexing_schemes=st.sampled_from(["nested", "ring"]), 304 | dtypes=st.sampled_from(["int64"]), 305 | ) 306 | ) 307 | def test_cell_center_roundtrip(self, cell_ids, grid) -> None: 308 | centers = grid.cell_ids2geographic(cell_ids) 309 | 310 | roundtripped = grid.geographic2cell_ids(lat=centers[1], lon=centers[0]) 311 | 312 | np.testing.assert_equal(roundtripped, cell_ids) 313 | 314 | @pytest.mark.parametrize( 315 | ["cell_ids", "level", "indexing_scheme", "expected"], 316 | ( 317 | pytest.param( 318 | np.array([3]), 319 | 1, 320 | "ring", 321 | (np.array([315.0]), np.array([66.44353569089877])), 322 | ), 323 | pytest.param( 324 | np.array([5, 11, 21]), 325 | 3, 326 | "nested", 327 | ( 328 | np.array([61.875, 33.75, 84.375]), 329 | np.array([19.47122063, 24.62431835, 41.8103149]), 330 | ), 331 | ), 332 | ), 333 | ) 334 | def test_cell_ids2geographic( 335 | self, cell_ids, level, indexing_scheme, expected 336 | ) -> None: 337 | grid = healpix.HealpixInfo(level=level, indexing_scheme=indexing_scheme) 338 | 339 | actual_lon, actual_lat = grid.cell_ids2geographic(cell_ids) 340 | 341 | np.testing.assert_allclose(actual_lon, expected[0]) 342 | np.testing.assert_allclose(actual_lat, expected[1]) 343 | 344 | @pytest.mark.parametrize( 345 | ["cell_centers", "level", "indexing_scheme", "expected"], 346 | ( 347 | pytest.param( 348 | np.array([[315.0, 66.44353569089877]]), 349 | 1, 350 | "ring", 351 | np.array([3]), 352 | ), 353 | pytest.param( 354 | np.array( 355 | [[61.875, 19.47122063], [33.75, 24.62431835], [84.375, 41.8103149]] 356 | ), 357 | 3, 358 | "nested", 359 | np.array([5, 11, 21]), 360 | ), 361 | ), 362 | ) 363 | def test_geographic2cell_ids( 364 | self, cell_centers, level, indexing_scheme, expected 365 | ) -> None: 366 | grid = healpix.HealpixInfo(level=level, indexing_scheme=indexing_scheme) 367 | 368 | actual = grid.geographic2cell_ids( 369 | lon=cell_centers[:, 0], lat=cell_centers[:, 1] 370 | ) 371 | 372 | np.testing.assert_equal(actual, expected) 373 | 374 | 375 | @pytest.mark.parametrize( 376 | ["mapping", "expected"], 377 | ( 378 | pytest.param( 379 | {"resolution": 10, "indexing_scheme": "nested"}, 380 | {"level": 10, "indexing_scheme": "nested"}, 381 | id="no_translation", 382 | ), 383 | pytest.param( 384 | { 385 | "level": 10, 386 | "indexing_scheme": "nested", 387 | "grid_name": "healpix", 388 | }, 389 | {"level": 10, "indexing_scheme": "nested"}, 390 | id="no_translation-grid_name", 391 | ), 392 | pytest.param( 393 | {"nside": 1024, "indexing_scheme": "nested"}, 394 | {"level": 10, "indexing_scheme": "nested"}, 395 | id="nside-alone", 396 | ), 397 | pytest.param( 398 | { 399 | "nside": 1024, 400 | "level": 10, 401 | "indexing_scheme": "nested", 402 | }, 403 | ExceptionGroup( 404 | "received multiple values for parameters", 405 | [ 406 | ValueError( 407 | "Parameter level received multiple values: ['level', 'nside']" 408 | ) 409 | ], 410 | ), 411 | id="nside-duplicated", 412 | ), 413 | pytest.param( 414 | { 415 | "level": 10, 416 | "indexing_scheme": "nested", 417 | "nest": True, 418 | }, 419 | ExceptionGroup( 420 | "received multiple values for parameters", 421 | [ 422 | ValueError( 423 | "Parameter indexing_scheme received multiple values: ['indexing_scheme', 'nest']" 424 | ), 425 | ], 426 | ), 427 | id="indexing_scheme-duplicated", 428 | ), 429 | pytest.param( 430 | { 431 | "nside": 1024, 432 | "level": 10, 433 | "indexing_scheme": "nested", 434 | "nest": True, 435 | }, 436 | ExceptionGroup( 437 | "received multiple values for parameters", 438 | [ 439 | ValueError( 440 | "Parameter indexing_scheme received multiple values: ['indexing_scheme', 'nest']" 441 | ), 442 | ValueError( 443 | "Parameter level received multiple values: ['level', 'nside']" 444 | ), 445 | ], 446 | ), 447 | id="multiple_params-duplicated", 448 | ), 449 | ), 450 | ) 451 | def test_healpix_info_from_dict(mapping, expected) -> None: 452 | if isinstance(expected, ExceptionGroup): 453 | with pytest.raises(type(expected), match=expected.args[0]) as actual: 454 | healpix.HealpixInfo.from_dict(mapping) 455 | assert_exceptions_equal(actual.value, expected) 456 | return 457 | actual = healpix.HealpixInfo.from_dict(mapping) 458 | assert actual == healpix.HealpixInfo(**expected) 459 | 460 | 461 | class TestHealpixIndex: 462 | @given(strategies.cell_ids(), strategies.dims, strategies.grids()) 463 | def test_init(self, cell_ids, dim, grid) -> None: 464 | index = healpix.HealpixIndex(cell_ids, dim, grid) 465 | 466 | assert index._grid == grid 467 | assert index._dim == dim 468 | assert index._pd_index.dim == dim 469 | 470 | np.testing.assert_equal(index._pd_index.index.values, cell_ids) 471 | 472 | @given(strategies.grids()) 473 | def test_grid(self, grid): 474 | index = healpix.HealpixIndex([0], dim="cells", grid_info=grid) 475 | 476 | assert index.grid_info is grid 477 | 478 | 479 | @pytest.mark.parametrize("options", options) 480 | @pytest.mark.parametrize("variable", variables) 481 | @pytest.mark.parametrize("variable_name", variable_names) 482 | def test_from_variables(variable_name, variable, options) -> None: 483 | expected_level = variable.attrs["level"] 484 | expected_scheme = variable.attrs["indexing_scheme"] 485 | 486 | variables = {variable_name: variable} 487 | 488 | index = healpix.HealpixIndex.from_variables(variables, options=options) 489 | 490 | assert index._grid.level == expected_level 491 | assert index._grid.indexing_scheme == expected_scheme 492 | 493 | assert (index._dim,) == variable.dims 494 | np.testing.assert_equal(index._pd_index.index.values, variable.data) 495 | 496 | 497 | @pytest.mark.parametrize(["old_variable", "new_variable"], variable_combinations) 498 | def test_replace(old_variable, new_variable) -> None: 499 | grid = healpix.HealpixInfo.from_dict(old_variable.attrs) 500 | 501 | index = healpix.HealpixIndex( 502 | cell_ids=old_variable.data, 503 | dim=old_variable.dims[0], 504 | grid_info=grid, 505 | ) 506 | 507 | new_pandas_index = PandasIndex.from_variables( 508 | {"cell_ids": new_variable}, options={} 509 | ) 510 | 511 | new_index = index._replace(new_pandas_index) 512 | 513 | assert new_index._dim == index._dim 514 | assert new_index._pd_index == new_pandas_index 515 | assert index._grid == grid 516 | 517 | 518 | @pytest.mark.parametrize("max_width", [20, 50, 80, 120]) 519 | @pytest.mark.parametrize("level", [0, 1, 3]) 520 | def test_repr_inline(level, max_width) -> None: 521 | grid_info = healpix.HealpixInfo(level=level, indexing_scheme="nested") 522 | index = healpix.HealpixIndex(cell_ids=[0], dim="cells", grid_info=grid_info) 523 | 524 | actual = index._repr_inline_(max_width) 525 | 526 | assert f"level={level}" in actual 527 | # ignore max_width for now 528 | # assert len(actual) <= max_width 529 | -------------------------------------------------------------------------------- /xdggs/tests/test_index.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import xarray as xr 3 | 4 | from xdggs import index 5 | 6 | 7 | @pytest.fixture 8 | def dggs_example(): 9 | return xr.Dataset( 10 | coords={"cell_ids": ("cells", [0, 1], {"grid_name": "test", "level": 2})} 11 | ) 12 | 13 | 14 | def test_create_index(dggs_example): 15 | # TODO: improve unknown index message 16 | with pytest.raises(ValueError, match="test"): 17 | dggs_example.set_xindex("cell_ids", index.DGGSIndex) 18 | 19 | 20 | def test_decode(dggs_example): 21 | # TODO: improve unknown index message 22 | with pytest.raises(ValueError, match="test"): 23 | dggs_example.pipe(index.decode) 24 | 25 | 26 | def test_decode_indexed(dggs_example): 27 | with pytest.raises(ValueError, match="test"): 28 | dggs_example.set_xindex("cell_ids").pipe(index.decode) 29 | -------------------------------------------------------------------------------- /xdggs/tests/test_matchers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from xdggs.tests import matchers 4 | 5 | try: 6 | ExceptionGroup 7 | except NameError: # pragma: no cover 8 | from exceptiongroup import ExceptionGroup 9 | 10 | 11 | class TestMatch: 12 | def test_construct_simple(self): 13 | # simple 14 | matchers.Match(ValueError) 15 | matchers.Match((ValueError, NameError)) 16 | matchers.Match(ExceptionGroup) 17 | with pytest.raises( 18 | TypeError, match="exception type must be one or more exceptions" 19 | ): 20 | matchers.Match(int) 21 | 22 | # with match string 23 | matchers.Match(TypeError, match="pattern") 24 | matchers.Match(ExceptionGroup, match="pattern") 25 | with pytest.raises(TypeError): 26 | matchers.Match(ValueError, match=int) 27 | 28 | # with submatchers 29 | with pytest.raises(TypeError): 30 | matchers.Match(ValueError, submatchers=[matchers.Match(ValueError)]) 31 | 32 | @pytest.mark.parametrize( 33 | ["mapping", "args", "kwargs"], 34 | ( 35 | ({"exceptions": ValueError}, (ValueError,), {}), 36 | ( 37 | {"exceptions": ValueError, "match": "abc"}, 38 | (ValueError,), 39 | {"match": "abc"}, 40 | ), 41 | ), 42 | ) 43 | def test_from_dict(self, mapping, args, kwargs): 44 | actual = matchers.Match.from_dict(mapping) 45 | expected = matchers.Match(*args, **kwargs) 46 | assert actual == expected 47 | 48 | @pytest.mark.parametrize( 49 | ["exc", "match", "expected"], 50 | ( 51 | pytest.param( 52 | ValueError("e"), matchers.Match(ValueError), True, id="exc-match-wo pat" 53 | ), 54 | pytest.param( 55 | ValueError("error message"), 56 | matchers.Match(ValueError, match="message"), 57 | True, 58 | id="exc-match-w pat", 59 | ), 60 | pytest.param( 61 | ValueError("error message"), 62 | matchers.Match(ValueError, match="abc"), 63 | False, 64 | id="exc-no match-w pat", 65 | ), 66 | pytest.param( 67 | ExceptionGroup("eg", [ValueError("error")]), 68 | matchers.Match(ExceptionGroup), 69 | True, 70 | id="eg-match-wo pat-without submatchers", 71 | ), 72 | pytest.param( 73 | ExceptionGroup("error group", [ValueError("error")]), 74 | matchers.Match(ExceptionGroup, match="err"), 75 | True, 76 | id="eg-match-w pat-without submatchers", 77 | ), 78 | pytest.param( 79 | ExceptionGroup("eg", [ValueError("error")]), 80 | matchers.Match(ExceptionGroup, match="abc"), 81 | False, 82 | id="eg-no match-w pat-without submatchers", 83 | ), 84 | pytest.param( 85 | ExceptionGroup("eg", [ValueError("error")]), 86 | matchers.Match( 87 | ExceptionGroup, submatchers=[matchers.Match(ValueError)] 88 | ), 89 | True, 90 | id="eg-match-wo pat-with submatchers-wo subpat", 91 | ), 92 | pytest.param( 93 | ExceptionGroup("eg", [ValueError("error")]), 94 | matchers.Match(ExceptionGroup, submatchers=[matchers.Match(TypeError)]), 95 | False, 96 | id="eg-no match-wo pat-with submatchers-wo subpat", 97 | ), 98 | pytest.param( 99 | ExceptionGroup("eg", [ValueError("error")]), 100 | matchers.Match( 101 | ExceptionGroup, 102 | submatchers=[matchers.Match(ValueError, match="err")], 103 | ), 104 | True, 105 | id="eg-match-w pat-with submatchers-wo subpat", 106 | ), 107 | pytest.param( 108 | ExceptionGroup("eg", [ValueError("error")]), 109 | matchers.Match( 110 | ExceptionGroup, 111 | submatchers=[matchers.Match(ValueError, match="abc")], 112 | ), 113 | False, 114 | id="eg-no match-w pat-with submatchers-wo subpat", 115 | ), 116 | ), 117 | ) 118 | def test_matches(self, exc, match, expected): 119 | assert match.matches(exc) == expected 120 | 121 | 122 | def test_assert_exceptions_equal(): 123 | actual = ValueError("error message") 124 | expected = ValueError("error message") 125 | 126 | matchers.assert_exceptions_equal(actual, expected) 127 | 128 | try: 129 | raise ValueError("error message") 130 | except ValueError as e: 131 | actual = e 132 | expected = ValueError("error message") 133 | 134 | matchers.assert_exceptions_equal(actual, expected) 135 | 136 | actual = ValueError("error message") 137 | expected = TypeError("error message") 138 | with pytest.raises(AssertionError): 139 | matchers.assert_exceptions_equal(actual, expected) 140 | 141 | actual = ValueError("error message1") 142 | expected = ValueError("error message2") 143 | with pytest.raises(AssertionError): 144 | matchers.assert_exceptions_equal(actual, expected) 145 | 146 | actual = ExceptionGroup("group message1", [ValueError("error message")]) 147 | expected = ExceptionGroup("group message2", [ValueError("error message")]) 148 | with pytest.raises(AssertionError): 149 | matchers.assert_exceptions_equal(actual, expected) 150 | 151 | actual = ExceptionGroup("group message", [ValueError("error message1")]) 152 | expected = ExceptionGroup("group message", [ValueError("error message2")]) 153 | with pytest.raises(AssertionError): 154 | matchers.assert_exceptions_equal(actual, expected) 155 | 156 | actual = ExceptionGroup("group message", [ValueError("error message")]) 157 | expected = ExceptionGroup("group message", [TypeError("error message")]) 158 | with pytest.raises(AssertionError): 159 | matchers.assert_exceptions_equal(actual, expected) 160 | -------------------------------------------------------------------------------- /xdggs/tests/test_plotting.py: -------------------------------------------------------------------------------- 1 | import ipywidgets 2 | import lonboard 3 | import numpy as np 4 | import pytest 5 | import xarray as xr 6 | from arro3.core import Array, Table 7 | from matplotlib import colormaps 8 | 9 | from xdggs import plotting 10 | 11 | 12 | @pytest.mark.parametrize( 13 | ["polygons", "arr", "coords", "expected"], 14 | ( 15 | pytest.param( 16 | Array.from_numpy(np.array([1, 2])), 17 | xr.DataArray( 18 | [-1, 1], 19 | coords={ 20 | "cell_ids": ("cells", [0, 1]), 21 | "latitude": ("cells", [-5, 10]), 22 | "longitude": ("cells", [-60, -50]), 23 | }, 24 | dims="cells", 25 | ), 26 | None, 27 | Table.from_pydict( 28 | { 29 | "geometry": Array.from_numpy(np.array([1, 2])), 30 | "cell_ids": Array.from_numpy(np.array([0, 1])), 31 | "data": Array.from_numpy(np.array([-1, 1])), 32 | "latitude": Array.from_numpy(np.array([-5, 10])), 33 | "longitude": Array.from_numpy(np.array([-60, -50])), 34 | } 35 | ), 36 | ), 37 | pytest.param( 38 | Array.from_numpy(np.array([1, 2])), 39 | xr.DataArray( 40 | [-1, 1], 41 | coords={ 42 | "cell_ids": ("cells", [1, 2]), 43 | "latitude": ("cells", [-5, 10]), 44 | "longitude": ("cells", [-60, -50]), 45 | }, 46 | dims="cells", 47 | ), 48 | ["latitude"], 49 | Table.from_pydict( 50 | { 51 | "geometry": Array.from_numpy(np.array([1, 2])), 52 | "cell_ids": Array.from_numpy(np.array([1, 2])), 53 | "data": Array.from_numpy(np.array([-1, 1])), 54 | "latitude": Array.from_numpy(np.array([-5, 10])), 55 | } 56 | ), 57 | ), 58 | pytest.param( 59 | Array.from_numpy(np.array([1, 3])), 60 | xr.DataArray( 61 | [-1, 1], 62 | coords={ 63 | "cell_ids": ("cells", [0, 1]), 64 | "latitude": ("cells", [-5, 10]), 65 | "longitude": ("cells", [-60, -50]), 66 | }, 67 | dims="cells", 68 | name="new_data", 69 | ), 70 | ["longitude"], 71 | Table.from_pydict( 72 | { 73 | "geometry": Array.from_numpy(np.array([1, 3])), 74 | "cell_ids": Array.from_numpy(np.array([0, 1])), 75 | "new_data": Array.from_numpy(np.array([-1, 1])), 76 | "longitude": Array.from_numpy(np.array([-60, -50])), 77 | } 78 | ), 79 | ), 80 | ), 81 | ) 82 | def test_create_arrow_table(polygons, arr, coords, expected): 83 | actual = plotting.create_arrow_table(polygons, arr, coords=coords) 84 | 85 | assert actual == expected 86 | 87 | 88 | @pytest.mark.parametrize( 89 | ["var", "center", "expected"], 90 | ( 91 | pytest.param( 92 | xr.Variable("cells", np.array([-5, np.nan, -2, 1])), 93 | None, 94 | np.array([0, np.nan, 0.5, 1]), 95 | id="linear-missing_values", 96 | ), 97 | pytest.param( 98 | xr.Variable("cells", np.arange(-5, 2, dtype="float")), 99 | None, 100 | np.linspace(0, 1, 7), 101 | id="linear-manual", 102 | ), 103 | pytest.param( 104 | xr.Variable("cells", np.linspace(0, 10, 5)), 105 | None, 106 | np.linspace(0, 1, 5), 107 | id="linear-linspace", 108 | ), 109 | pytest.param( 110 | xr.Variable("cells", np.linspace(-5, 5, 10)), 111 | 0, 112 | np.linspace(0, 1, 10), 113 | id="centered-0", 114 | ), 115 | pytest.param( 116 | xr.Variable("cells", np.linspace(0, 10, 10)), 117 | 5, 118 | np.linspace(0, 1, 10), 119 | id="centered-2", 120 | ), 121 | ), 122 | ) 123 | def test_normalize(var, center, expected): 124 | actual = plotting.normalize(var, center=center) 125 | 126 | np.testing.assert_allclose(actual, expected) 127 | 128 | 129 | @pytest.mark.parametrize( 130 | ["var", "kwargs", "expected"], 131 | ( 132 | pytest.param( 133 | xr.Variable("cells", [0, 3]), 134 | {"center": 2, "colormap": colormaps["viridis"], "alpha": 1}, 135 | np.array([[68, 1, 84], [94, 201, 97]], dtype="uint8"), 136 | ), 137 | pytest.param( 138 | xr.Variable("cells", [-1, 1]), 139 | {"center": None, "colormap": colormaps["viridis"], "alpha": 0.8}, 140 | np.array([[68, 1, 84, 204], [253, 231, 36, 204]], dtype="uint8"), 141 | ), 142 | ), 143 | ) 144 | def test_colorize(var, kwargs, expected): 145 | actual = plotting.colorize(var, **kwargs) 146 | 147 | np.testing.assert_equal(actual, expected) 148 | 149 | 150 | class TestMapContainer: 151 | def test_init(self): 152 | map_ = lonboard.Map(layers=[]) 153 | sliders = ipywidgets.VBox( 154 | [ipywidgets.IntSlider(min=0, max=10, description="time")] 155 | ) 156 | obj = xr.DataArray([[0, 1], [2, 3]], dims=["time", "cells"]) 157 | colorize_kwargs = {"a": 1, "b": 2} 158 | 159 | container = plotting.MapContainer( 160 | dimension_sliders=sliders, 161 | map=map_, 162 | obj=obj, 163 | colorize_kwargs=colorize_kwargs, 164 | ) 165 | 166 | assert container.map == map_ 167 | xr.testing.assert_equal(container.obj, obj) 168 | assert container.dimension_sliders == sliders 169 | assert container.colorize_kwargs == colorize_kwargs 170 | 171 | def test_render(self): 172 | map_ = lonboard.Map(layers=[]) 173 | sliders = ipywidgets.VBox( 174 | [ipywidgets.IntSlider(min=0, max=10, description="time")] 175 | ) 176 | obj = xr.DataArray([[0, 1], [2, 3]], dims=["time", "cells"]) 177 | colorize_kwargs = {"a": 1, "b": 2} 178 | 179 | container = plotting.MapContainer( 180 | dimension_sliders=sliders, 181 | map=map_, 182 | obj=obj, 183 | colorize_kwargs=colorize_kwargs, 184 | ) 185 | rendered = container.render() 186 | 187 | assert isinstance(rendered, ipywidgets.VBox) 188 | 189 | 190 | @pytest.mark.parametrize( 191 | ["arr", "expected_type"], 192 | ( 193 | pytest.param( 194 | xr.DataArray( 195 | [0, 1], coords={"cell_ids": ("cells", [10, 26])}, dims="cells" 196 | ).dggs.decode( 197 | {"grid_name": "healpix", "level": 1, "indexing_scheme": "nested"} 198 | ), 199 | lonboard.Map, 200 | id="1d", 201 | ), 202 | pytest.param( 203 | xr.DataArray( 204 | [[0, 1], [2, 3]], 205 | coords={"cell_ids": ("cells", [10, 26])}, 206 | dims=["time", "cells"], 207 | ).dggs.decode( 208 | {"grid_name": "healpix", "level": 1, "indexing_scheme": "nested"} 209 | ), 210 | ipywidgets.VBox, 211 | id="2d", 212 | ), 213 | ), 214 | ) 215 | def test_explore(arr, expected_type): 216 | actual = arr.dggs.explore() 217 | 218 | assert isinstance(actual, expected_type) 219 | -------------------------------------------------------------------------------- /xdggs/tests/test_tutorial.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from xdggs import tutorial 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ["ds_name", "grid_name"], 8 | ( 9 | ("air_temperature", "h3"), 10 | ("air_temperature", "healpix"), 11 | ), 12 | ) 13 | def test_download_from_github(tmp_path, ds_name, grid_name): 14 | cache_dir = tmp_path / tutorial._default_cache_dir_name 15 | ds = tutorial.open_dataset(ds_name, grid_name, cache_dir=cache_dir).load() 16 | 17 | assert cache_dir.is_dir() and len(list(cache_dir.iterdir())) == 1 18 | assert ds["air"].count() > 0 19 | -------------------------------------------------------------------------------- /xdggs/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import xarray as xr 3 | 4 | import xdggs.utils 5 | 6 | 7 | @pytest.mark.parametrize("name", ["h3", "s2", "rhealpix", "healpix"]) 8 | def test_register_dggs(monkeypatch, name): 9 | registry = {} 10 | 11 | monkeypatch.setattr(xdggs.utils, "GRID_REGISTRY", registry) 12 | 13 | grid = object() 14 | registered_grid = xdggs.utils.register_dggs(name)(grid) 15 | 16 | assert grid is registered_grid 17 | 18 | assert len(registry) == 1 19 | assert name in registry and registry[name] is grid 20 | 21 | 22 | @pytest.mark.parametrize( 23 | ["variables", "expected"], 24 | ( 25 | ( 26 | {"cell_ids": xr.Variable("cells", [0, 1])}, 27 | ("cell_ids", "cells"), 28 | ), 29 | ( 30 | {"zone_ids": xr.Variable("zones", [0, 1])}, 31 | ("zone_ids", "zones"), 32 | ), 33 | ), 34 | ) 35 | def test_extract_cell_id_variable(variables, expected): 36 | expected_var = variables[expected[0]] 37 | 38 | actual = xdggs.utils._extract_cell_id_variable(variables) 39 | actual_var = actual[1] 40 | 41 | assert actual[::2] == expected 42 | xr.testing.assert_equal(actual_var, expected_var) 43 | -------------------------------------------------------------------------------- /xdggs/tutorial.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import pathlib 5 | from typing import TYPE_CHECKING 6 | 7 | import pooch 8 | from xarray import open_dataset as _open_dataset 9 | 10 | if TYPE_CHECKING: 11 | from xarray.backends.api import T_Engine 12 | 13 | _default_cache_dir_name = "xdggs_tutorial_data" 14 | base_url = "https://github.com/xdggs/xdggs-data" 15 | version = "main" 16 | 17 | external_urls = {} # type: dict 18 | file_formats = { 19 | "air_temperature": 4, 20 | } 21 | 22 | 23 | def _construct_cache_dir(path): 24 | if isinstance(path, os.PathLike): 25 | path = os.fspath(path) 26 | elif path is None: 27 | path = pooch.os_cache(_default_cache_dir_name) 28 | 29 | return path 30 | 31 | 32 | def _check_netcdf_engine_installed(name): 33 | version = file_formats.get(name) 34 | if version == 3: 35 | try: 36 | import scipy # noqa 37 | except ImportError: 38 | try: 39 | import netCDF4 # noqa 40 | except ImportError as err: 41 | raise ImportError( 42 | f"opening tutorial dataset {name} requires either scipy or " 43 | "netCDF4 to be installed." 44 | ) from err 45 | if version == 4: 46 | try: 47 | import h5netcdf # noqa 48 | except ImportError: 49 | try: 50 | import netCDF4 # noqa 51 | except ImportError as err: 52 | raise ImportError( 53 | f"opening tutorial dataset {name} requires either h5netcdf " 54 | "or netCDF4 to be installed." 55 | ) from err 56 | 57 | 58 | def open_dataset( 59 | name: str, 60 | grid_name: str, 61 | *, 62 | cache: bool = True, 63 | cache_dir: None | str | os.PathLike = None, 64 | engine: T_Engine = None, 65 | **kws, 66 | ): 67 | """ 68 | Open a dataset from the online repository (requires internet). 69 | 70 | If a local copy is found then always use that to avoid network traffic. 71 | 72 | Available datasets (available grid names in parentheses): 73 | 74 | * ``"air_temperature"`` (``h3``, ``healpix``): NCEP reanalysis subset. 75 | 76 | Parameters 77 | ---------- 78 | name : str 79 | Name of the file containing the dataset. 80 | e.g. 'air_temperature' 81 | grid_name : str 82 | Name of the grid file. 83 | cache_dir : path-like, optional 84 | The directory in which to search for and write cached data. 85 | cache : bool, optional 86 | If True, then cache data locally for use on subsequent calls 87 | **kws : dict, optional 88 | Passed to xarray.open_dataset 89 | 90 | See Also 91 | -------- 92 | xarray.tutorial.open_dataset 93 | """ 94 | import xdggs 95 | 96 | logger = pooch.get_logger() 97 | logger.setLevel("WARNING") 98 | 99 | cache_dir = _construct_cache_dir(cache_dir) 100 | if name in external_urls: 101 | url = external_urls[name] 102 | else: 103 | path = pathlib.Path(grid_name) 104 | if not path.suffix: 105 | # process the name 106 | default_extension = ".nc" 107 | if engine is None: 108 | _check_netcdf_engine_installed(grid_name) 109 | path = path.with_suffix(default_extension) 110 | 111 | url = f"{base_url}/raw/{version}/{name}/{path.name}" 112 | 113 | headers = {"User-Agent": f"xdggs/{xdggs.__version__}"} 114 | 115 | # retrieve the file 116 | downloader = pooch.HTTPDownloader(headers=headers) 117 | filepath = pooch.retrieve( 118 | url=url, known_hash=None, path=cache_dir, downloader=downloader 119 | ) 120 | ds = _open_dataset(filepath, engine=engine, **kws) 121 | if not cache: 122 | ds = ds.load() 123 | pathlib.Path(filepath).unlink() 124 | 125 | return ds 126 | -------------------------------------------------------------------------------- /xdggs/utils.py: -------------------------------------------------------------------------------- 1 | GRID_REGISTRY = {} 2 | 3 | 4 | def register_dggs(name): 5 | def inner(cls): 6 | GRID_REGISTRY[name] = cls 7 | return cls 8 | 9 | return inner 10 | 11 | 12 | def _extract_cell_id_variable(variables): 13 | # TODO: only one variable supported (raise otherwise) 14 | name, var = next(iter(variables.items())) 15 | 16 | # TODO: only 1-d variable supported (raise otherwise) 17 | dim = next(iter(var.dims)) 18 | 19 | return name, var, dim 20 | --------------------------------------------------------------------------------