From 80517cd3fa9ebcdf7fb094d58b6bfa3f990b2569 Mon Sep 17 00:00:00 2001 From: "Noah D. Brenowitz" Date: Wed, 29 May 2024 11:13:05 -0700 Subject: [PATCH] Initial commit --- .bumpversion.cfg | 12 + .editorconfig | 24 + .github/ISSUE_TEMPLATE.md | 15 + .github/workflows/dev.yml | 50 +++ .github/workflows/preview.yml | 50 +++ .github/workflows/release.yml | 89 ++++ .gitignore | 122 +++++ .gitlab-ci.yml | 12 + .pre-commit-config.yaml | 34 ++ CHANGELOG.md | 5 + CONTRIBUTING.md | 76 ++++ LICENSE.txt | 201 +++++++++ README.md | 75 ++++ docs/.gitignore | 2 + docs/Makefile | 20 + docs/api.rst | 25 ++ docs/changelog.md | 1 + docs/conf.py | 87 ++++ docs/contributing.md | 1 + docs/image_scraper.py | 59 +++ docs/img/image.jpg | Bin 0 -> 110167 bytes docs/index.rst | 28 ++ docs/installation.md | 43 ++ docs/make.bat | 35 ++ docs/push_docs.sh | 14 + docs/usage.md | 18 + earth2grid/__init__.py | 18 + earth2grid/_regrid.py | 69 +++ earth2grid/base.py | 47 ++ earth2grid/csrc/healpix_bare_wrapper.cpp | 228 ++++++++++ earth2grid/csrc/interpolation.h | 167 +++++++ earth2grid/healpix.py | 424 ++++++++++++++++++ earth2grid/healpix_bare.py | 79 ++++ earth2grid/latlon.py | 200 +++++++++ earth2grid/third_party/healpix_bare/LICENSE | 29 ++ earth2grid/third_party/healpix_bare/NEWS | 2 + earth2grid/third_party/healpix_bare/README | 42 ++ .../third_party/healpix_bare/healpix_bare.c | 310 +++++++++++++ .../third_party/healpix_bare/healpix_bare.h | 118 +++++ earth2grid/third_party/healpix_bare/test.c | 129 ++++++ earth2grid/third_party/zephyr/LICENSE | 21 + earth2grid/third_party/zephyr/healpix.py | 281 ++++++++++++ .../third_party/zephyr/test_healpix_pad.py | 13 + examples/sphinx_gallery/README.rst | 6 + examples/sphinx_gallery/hpx2grid.py | 46 ++ examples/sphinx_gallery/latlon_to_healpix.py | 66 +++ examples/sphinx_gallery/pyvista_grids.py | 60 +++ makefile | 39 ++ pyproject.toml | 89 ++++ setup.cfg | 94 ++++ setup.py | 69 +++ tests/__init__.py | 15 + tests/_license/config.json | 13 + tests/_license/header.txt | 14 + tests/_license/header_check.py | 141 ++++++ .../test_healpix_bare.test_boundaries.out | 12 + .../test_healpix_bare.test_hpc2loc.out | 3 + ...healpix_bare.test_pix2ang[False-False].out | 98 ++++ ..._healpix_bare.test_pix2ang[False-True].out | 98 ++++ ..._healpix_bare.test_pix2ang[True-False].out | 98 ++++ ...t_healpix_bare.test_pix2ang[True-True].out | 98 ++++ tests/test_earth2grid.py | 35 ++ tests/test_healpix.py | 145 ++++++ tests/test_healpix_bare.py | 92 ++++ tests/test_latlon.py | 16 + tests/test_regrid.py | 180 ++++++++ 66 files changed, 4802 insertions(+) create mode 100644 .bumpversion.cfg create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE.md create mode 100644 .github/workflows/dev.yml create mode 100644 .github/workflows/preview.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .gitlab-ci.yml create mode 100644 .pre-commit-config.yaml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 docs/.gitignore create mode 100644 docs/Makefile create mode 100644 docs/api.rst create mode 120000 docs/changelog.md create mode 100644 docs/conf.py create mode 120000 docs/contributing.md create mode 100644 docs/image_scraper.py create mode 100644 docs/img/image.jpg create mode 100644 docs/index.rst create mode 100644 docs/installation.md create mode 100644 docs/make.bat create mode 100755 docs/push_docs.sh create mode 100644 docs/usage.md create mode 100644 earth2grid/__init__.py create mode 100644 earth2grid/_regrid.py create mode 100644 earth2grid/base.py create mode 100644 earth2grid/csrc/healpix_bare_wrapper.cpp create mode 100644 earth2grid/csrc/interpolation.h create mode 100644 earth2grid/healpix.py create mode 100644 earth2grid/healpix_bare.py create mode 100644 earth2grid/latlon.py create mode 100644 earth2grid/third_party/healpix_bare/LICENSE create mode 100644 earth2grid/third_party/healpix_bare/NEWS create mode 100644 earth2grid/third_party/healpix_bare/README create mode 100644 earth2grid/third_party/healpix_bare/healpix_bare.c create mode 100644 earth2grid/third_party/healpix_bare/healpix_bare.h create mode 100644 earth2grid/third_party/healpix_bare/test.c create mode 100644 earth2grid/third_party/zephyr/LICENSE create mode 100644 earth2grid/third_party/zephyr/healpix.py create mode 100644 earth2grid/third_party/zephyr/test_healpix_pad.py create mode 100644 examples/sphinx_gallery/README.rst create mode 100644 examples/sphinx_gallery/hpx2grid.py create mode 100644 examples/sphinx_gallery/latlon_to_healpix.py create mode 100644 examples/sphinx_gallery/pyvista_grids.py create mode 100644 makefile create mode 100644 pyproject.toml create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/_license/config.json create mode 100644 tests/_license/header.txt create mode 100644 tests/_license/header_check.py create mode 100644 tests/_regtest_outputs/test_healpix_bare.test_boundaries.out create mode 100644 tests/_regtest_outputs/test_healpix_bare.test_hpc2loc.out create mode 100644 tests/_regtest_outputs/test_healpix_bare.test_pix2ang[False-False].out create mode 100644 tests/_regtest_outputs/test_healpix_bare.test_pix2ang[False-True].out create mode 100644 tests/_regtest_outputs/test_healpix_bare.test_pix2ang[True-False].out create mode 100644 tests/_regtest_outputs/test_healpix_bare.test_pix2ang[True-True].out create mode 100644 tests/test_earth2grid.py create mode 100644 tests/test_healpix.py create mode 100644 tests/test_healpix_bare.py create mode 100644 tests/test_latlon.py create mode 100644 tests/test_regrid.py diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 0000000..2c87cda --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,12 @@ +[bumpversion] +current_version = 2024.5.1 +commit = True +tag = True + +[bumpversion:file:pyproject.toml] +search = version = "{current_version}" +replace = version = "{new_version}" + +[bumpversion:file:earth2grid/__init__.py] +search = __version__ = '{current_version}' +replace = __version__ = '{new_version}' diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..814d7b2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true +charset = utf-8 +end_of_line = lf + +[*.bat] +indent_style = tab +end_of_line = crlf + +[LICENSE] +insert_final_newline = false + +[Makefile] +indent_style = tab + +[*.{yml, yaml}] +indent_size = 2 diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..8b0fb3b --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,15 @@ +* Earth2 Grid Utilities version: +* Python version: +* Operating System: + +### Description + +Describe what you were trying to get done. +Tell us what happened, what went wrong, and what you expected to happen. + +### What I Did + +``` +Paste the command(s) you ran and the output. +If there was a crash, please include the traceback here. +``` diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml new file mode 100644 index 0000000..ad5345b --- /dev/null +++ b/.github/workflows/dev.yml @@ -0,0 +1,50 @@ +# This is a basic workflow to help you get started with Actions + +name: dev workflow + +# Controls when the action will run. +on: + # Triggers the workflow on push or pull request events but only for the master branch + push: + branches: [ master, main ] + pull_request: + branches: [ master, main ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "test" + test: + # The type of runner that the job will run on + strategy: + matrix: + python-versions: [3.6, 3.7, 3.8, 3.9] + os: [ubuntu-18.04, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-versions }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install poetry tox tox-gh-actions + + - name: test with tox + run: + tox + + - name: list files + run: ls -l . + + - uses: codecov/codecov-action@v1 + with: + fail_ci_if_error: true + files: coverage.xml diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 0000000..953a60e --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,50 @@ +# This is a basic workflow to help you get started with Actions + +name: stage & preview workflow + +# Controls when the action will run. +on: + # Triggers the workflow on push or pull request events but only for the master branch + push: + branches: [ master, main ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + publish_dev_build: + runs-on: ubuntu-latest + + strategy: + matrix: + python-versions: [ 3.8 ] + + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-versions }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install poetry tox tox-gh-actions + + - name: test with tox + run: + tox + + - name: Build wheels and source tarball + run: | + poetry version $(poetry version --short)-dev.$GITHUB_RUN_NUMBER + poetry version --short + poetry build + + - name: publish to Test PyPI + uses: pypa/gh-action-pypi-publish@master + with: + user: __token__ + password: ${{ secrets.TEST_PYPI_API_TOKEN}} + repository_url: https://test.pypi.org/legacy/ + skip_existing: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6060909 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,89 @@ +# Publish package on main branch if it's tagged with 'v*' + +name: release & publish workflow + +# Controls when the action will run. +on: + # Triggers the workflow on push events but only for the master branch + push: + tags: + - 'v*' + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "release" + release: + name: Create Release + runs-on: ubuntu-20.04 + + strategy: + matrix: + python-versions: [3.8] + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + - name: Get version from tag + id: tag_name + run: | + echo ::set-output name=current_version::${GITHUB_REF#refs/tags/v} + shell: bash + + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + + - name: Get Changelog Entry + id: changelog_reader + uses: mindsers/changelog-reader-action@v2 + with: + validation_depth: 10 + version: ${{ steps.tag_name.outputs.current_version }} + path: ./CHANGELOG.md + + - uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-versions }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install poetry + + - name: build documentation + run: | + poetry install -E doc + poetry run mkdocs build + + - name: publish documentation + uses: peaceiris/actions-gh-pages@v3 + with: + personal_token: ${{ secrets.PERSONAL_TOKEN }} + publish_dir: ./site + + - name: Build wheels and source tarball + run: >- + poetry build + + - name: show temporary files + run: >- + ls -l + + - name: create github release + id: create_release + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + body: ${{ steps.changelog_reader.outputs.changes }} + files: dist/*.whl + draft: false + prerelease: false + + - name: publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + skip_existing: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1f3e35 --- /dev/null +++ b/.gitignore @@ -0,0 +1,122 @@ +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# dotenv +.env + +# virtualenv +.venv +venv/ +ENV/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# IDE settings +.vscode/ +.idea/ + +# mkdocs build dir +site/ +test_grid_visualize.png +*.png +*.jpg +*.jpeg +public/ + +a.out +*.o diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..d528f52 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,12 @@ +# The Docker image that will be used to build your app +image: ubuntu +pages: + script: "true" + artifacts: + paths: + # The folder that contains the files to be exposed at the Page URL + - public + rules: + # This ensures that only pushes to the default branch will trigger + # a pages deploy + - if: $CI_COMMIT_REF_NAME == "pages" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3f744b5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,34 @@ +repos: + - repo: https://github.com/Lucas-C/pre-commit-hooks + rev: v1.1.9 + hooks: + - id: forbid-crlf + - id: remove-crlf + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-yaml + args: [ --unsafe ] + - repo: https://github.com/pre-commit/mirrors-isort + rev: v5.8.0 + hooks: + - id: isort + args: [ "--filter-files" ] + - repo: https://github.com/psf/black + rev: 22.10.0 + hooks: + - id: black + additional_dependencies: ["click==8.0.4"] + - repo: https://github.com/pycqa/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + exclude: conf.py + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + exclude: tests/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a3343c9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## v2024.5.2 + +- First publicly available release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b72f172 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing + +Contributions are welcome, and they are greatly appreciated! Every little bit +helps, and credit will always be given. + +## Developer Certificate of Origin + +``` +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. +``` + +## Getting started + +1. Install the pre-commit hooks: `pre-commit install` +1. Checkout a feature branch `git checkout -b +1. When writing code, group changes logically into commits. Messy commits are + usually a result of excessive multitasking. If you work on one thing at a + time, then your commits will be cleaner. + 1. Name each commit "[imperative verb] [subject]" (e.g. "add earth2grid.some_modules"). Make sure it fits onto one line. + 1. Please provide context in the commit message. Start by explaining the + previous state of the code and why this needed changing. Focus on motivating + rather than explaining the changeset. + 1. run the test suite: `make unittest` + 1. run the docs: `make docs` +1. push the code to the repo `git push -u origin your-branch-name` and open an MR +1. ask for one reviewer. + +## Tips + + +## Deploying + +A reminder for the maintainers on how to deploy. +Make sure all your changes are committed (including an entry in CHANGELOG.md). +Then run: + +``` +$ poetry run bump2version patch # possible: major / minor / patch +$ git push +$ git push --tags +``` + +GitHub Actions will then deploy to PyPI if tests pass. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..f61f492 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 NVIDIA Corporation + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8950fb7 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Earth2 Grid Utilities + + + + +[![pypi](https://img.shields.io/pypi/v/earth2-grid.svg)](https://pypi.org/project/earth2-grid/) +[![python](https://img.shields.io/pypi/pyversions/earth2-grid.svg)](https://pypi.org/project/earth2-grid/) +[![Build Status](https://github.com/waynerv/earth2-grid/actions/workflows/dev.yml/badge.svg)](https://github.com/waynerv/earth2-grid/actions/workflows/dev.yml) +[![codecov](https://codecov.io/gh/waynerv/earth2-grid/branch/main/graphs/badge.svg)](https://codecov.io/github/waynerv/earth2-grid) + + + + +Utilities for working geographic data defined on various grids. + +Features: +- regridding + +Grids currently supported: +- regular lat lon +- HealPIX + +Under construction: +- exporting meshes to visualization software (e.g. VTK) +- neural network primitives for different grids: + - convolutional layers + - up/downsampling +- staggered grids + + +* Documentation: +* GitHub: +* PyPI: +* Free software: BSD-3-Clause + +## Install + + +``` +# Install the CUDA healpix padding library +git clone https://gitlab-master.nvidia.com/tkurth/healpixpad-pytorch +git clone ssh://git@gitlab-master.nvidia.com:12051/earth-2/earth2-grid.git +pip install --no-build-isolation healpixpad-pytorch earth2-grid +``` + +## Example + +``` +>>> import earth2grid +... # level is the resolution +... level = 6 +... hpx = earth2grid.healpix.Grid(level=level, pixel_order=earth2grid.healpix.PixelOrder.XY) +... src = earth2grid.latlon.equiangular_lat_lon_grid(32, 64) +... z_torch = torch.as_tensor(z) +... z_torch = torch.as_tensor(z) +>>> regrid = earth2grid.get_regridder(src, hpx) +>>> z_hpx = regrid(z_torch) +>>> z_hpx.shape +torch.Size([49152]) +>>> nside = 2**level +... reshaped = z_hpx.reshape(12, nside, nside) +... lat_r = hpx.lat.reshape(12, nside, nside) +... lon_r = hpx.lon.reshape(12, nside, nside) +>>> reshaped.shape +torch.Size([12, 64, 64]) +``` + + +## Features + +* TODO + +## Credits + +This package was created with [Cookiecutter](https://github.com/audreyr/cookiecutter) and the [waynerv/cookiecutter-pypackage](https://github.com/waynerv/cookiecutter-pypackage) project template. diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..015219a --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,2 @@ +auto_examples/ +sg_execution_times.rst diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..6570176 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @PYTHONPATH=$(shell pwd):$(PYTHONPATH) $(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..a272fc2 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,25 @@ +API +=== + +Grids +----- + +.. autoclass:: earth2grid.healpix.Grid + :members: + :show-inheritance: + +.. autoclass:: earth2grid.latlon.LatLonGrid + :members: + :show-inheritance: + +.. autofunction:: earth2grid.latlon.equiangular_lat_lon_grid + +Regridding +---------- + +.. autofunction:: earth2grid.get_regridder + +Other utilities +--------------- + +.. autofunction:: earth2grid.healpix.pad diff --git a/docs/changelog.md b/docs/changelog.md new file mode 120000 index 0000000..04c99a5 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1 @@ +../CHANGELOG.md \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..3196d06 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'Earth2-Grid' +copyright = '2024, NVIDIA Research' +author = 'NVIDIA Research' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'myst_parser', + 'sphinx.ext.autodoc', + 'sphinx.ext.napoleon', + 'sphinx_gallery.gen_gallery', +] + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +# sphinx + +import os + +# pyvista +import pyvista + +pyvista.BUILDING_GALLERY = True + +import pyvista +from image_scraper import PNGScraper +from pyvista.core.errors import PyVistaDeprecationWarning +from pyvista.core.utilities.docs import linkcode_resolve, pv_html_page_context # noqa: F401 +from pyvista.plotting.utilities.sphinx_gallery import DynamicScraper + +# Manage errors +pyvista.set_error_output_file("errors.txt") +# Ensure that offscreen rendering is used for docs generation +pyvista.OFF_SCREEN = True # Not necessary - simply an insurance policy +# Preferred plotting style for documentation +pyvista.set_plot_theme("document") +pyvista.global_theme.window_size = [1024, 768] +pyvista.global_theme.font.size = 22 +pyvista.global_theme.font.label_size = 22 +pyvista.global_theme.font.title_size = 22 +pyvista.global_theme.return_cpos = False +pyvista.set_jupyter_backend(None) +# Save figures in specified directory +pyvista.FIGURE_PATH = os.path.join(os.path.abspath("./images/"), "auto-generated/") +if not os.path.exists(pyvista.FIGURE_PATH): + os.makedirs(pyvista.FIGURE_PATH) + +# necessary when building the sphinx gallery +pyvista.BUILDING_GALLERY = True +os.environ['PYVISTA_BUILDING_GALLERY'] = 'true' + + +# sphinx gallery +sphinx_gallery_conf = { + 'examples_dirs': '../examples/sphinx_gallery/', # path to your example scripts + 'gallery_dirs': 'auto_examples', # path to where to save gallery generated output + 'filename_pattern': '', + "image_scrapers": (DynamicScraper(), "matplotlib", PNGScraper()), +} + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output +html_static_path = ['_static'] diff --git a/docs/contributing.md b/docs/contributing.md new file mode 120000 index 0000000..44fcc63 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1 @@ +../CONTRIBUTING.md \ No newline at end of file diff --git a/docs/image_scraper.py b/docs/image_scraper.py new file mode 100644 index 0000000..6ca0227 --- /dev/null +++ b/docs/image_scraper.py @@ -0,0 +1,59 @@ +# Copyright (c) 2015, Óscar Nájera +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of sphinx-gallery nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +# from https://sphinx-gallery.github.io/stable/advanced.html#example-2-detecting-image-files-on-disk +import os +import shutil +from glob import glob + +from sphinx_gallery.scrapers import figure_rst + + +class PNGScraper(object): + def __init__(self): + self.seen = set() + + def __repr__(self): + return 'PNGScraper' + + def __call__(self, block, block_vars, gallery_conf): + # Find all PNG files in the directory of this example. + path_current_example = os.path.dirname(block_vars['src_file']) + pngs = sorted(glob(os.path.join(path_current_example, '*.png'))) + + # Iterate through PNGs, copy them to the sphinx-gallery output directory + image_names = list() + image_path_iterator = block_vars['image_path_iterator'] + for png in pngs: + if png not in self.seen: + self.seen |= set(png) + this_image_path = image_path_iterator.next() + image_names.append(this_image_path) + shutil.move(png, this_image_path) + # Use the `figure_rst` helper function to generate reST for image files + return figure_rst(image_names, gallery_conf['src_dir']) diff --git a/docs/img/image.jpg b/docs/img/image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3efeef315fcf09b531c676ce7663cb4f8361d185 GIT binary patch literal 110167 zcmeFZbx>Tv7cMwB1PksGG{J(qhTu*D1O|5-+$C6WhcE?(o z+1%;f*7sL;-RiIJsqU}OIep*e-_`-|<)!7M0dQ~t0NlR^@U{ey1fU`#qaY)p zqM)Flp`pIRz{kWuN5>$+#lyy@Af=+DASEZKrekBIreUEaC;!OziG`hmo12@8Q9y*B zQ<#m5oAbZC1P%=i4Fes67!#A2^8@(@&i~W))(OBtMF1cI5a4J4@HlV?IB;*h0IGlE zM1uRT2Kb*04jus!2^j?y?H&5R4)yN=@NftS@Q4UVNQj94di(xs2O#1g;eOzdK*m!u zLZNZQ=M0F?L8bjt-9?~2aZbl&>=cOhj*y6$gp{6v@#7~ZZXRAfegQ$ruTs)7vU2j@ zzH4Y|{m|AiF*P%{u(Yyvc5!uc_we)z`V|}!8WtXrkoY?Zoct#xH8(H6ps=X8q_n2C zuD$`<*wozJ)7#fSFgP?kIW;{qJ2$_uxUsply|cTwe{gtld3Akrdw2iv_+NOz{p0yR z+y6-Hf5QvsA1`=BL8mR_c24u=@ zUn$+IY({JKdJ)#ULsr8V=e~gv<0a7Z7k%O>1~*^KWInTs6XTj=3Ea(L8Rr-DQ84K%~&WS^#?q27SQunbN=xbDPj)e?SacJAo`N4Lb zLJI*TV-B7a)3@sgQ!Z=a`q(pPEVkD#cFd7!yQYD3(Ra9cbU4tRt*3dI^=0h^ZC}TE ze!&IyE%aGQOY4a}>-fU2Y!Pdv#qE`URPgBAd12YbR(CQbk?gxz4!fd;NK+@#{!Vhh z4}_sR%A_IabDGN=;FjtQupkBSQpyVVao)ft-xuVIs8^schXEP*S5d(NA8Z5CnJP() z<^)KtA+cy4uVb*zQHV!!Z}%&n&+O&{3nkAt|1Shzw>thx3mj>-GM^{*WRlEm&YFN< zUtjQW6#Uo|M+OhGKkgykz;v!Ekq*6hQ=UYQKo!yq^{JO0>KGdZaiu5d`%YY$|7Zbj zi!SuTGHDvdMVG0*HHE?=P`BLwdN4YgA$@o7ieH-69Q6?El6?bgkY8x*#&0TE8D3zX zq;oEeI6OrzAyT#IUQ@#jJP~h!y!!z7YEu8d0DQiu?GKp`zSu zpH!+L+VFho$I_x_LR}3DPARg&b`I*c`L7JIgO9JD72jo|#VYRpF%mga6FI|JQtgAiB&0+Ve=pX)Rb^ z&RGg(KU-lCbch5C2MVepj}q*Grf_YoI=5>q zw(w(RD(f?FGlTHO*6c#6yO}|9<J1QFxuu|B&at*H7!(N4 zrXWDvMOT`exEAFn&mwB-izel9ntV~Qp^9mtTr)^)4sGO!>mfm+Bcb}@D_TEpN)ywMKH*!HGehv$9ExukmLARnvRq)u>8)@A>Rrg}!uhghSz3)Lz za=rleyg%}adUQ*lRrj1z6`hMc+d@Gh;inK9M=A0E@tk2>CPOKCFEBGUWUi3Lqm5+q zI@dS8yTbM~jPGmL<#IE>UQjg5b96WfX3CPoH`&P^q+YJ!A2hRwp~rccXif8+V6ExI zY-w;Rv`bKSzT0Mc&U@$ODsa+EMd%}We)RoZ3epj(K6HZ=k3nVDaJfkQ1_^!Nv z>pBsqJhIyKrF`lo(jlndODWOmtyRJXZ&|aeV{P(gl6X8z=n<_;5u2}ih9e-AUET_L-y z5V#TYwdl?ws%MsF1vz_K&?!og#Vzw=7G=yV06g}RC~0wF5h zsg(nwFWW?9HQgk zQWE7Je6yZpub^mBUbOy`dPAs+83q)SnrZ!#H~y}6iDc9xxkm;2fUg6wRmIi0Xi3Rw z_n0IWT=G`$=c;t7D>Ryy-}vNCda{Y+reEa9Lhgbgu|oe))5~=&OEiry-YpYYnam*D z{{!&zVB6GDpJeOAqRVR_TA0*_y`e@=rnsApp)S}hT}>#**+N|G2~m!LpEj>mCrv}7r{BDb?ydv191E~W=uYQ0~~X4 z8cV$a+~dSZPlctn#IWv_Oayn!kkaHi!xk)z4hbaq@A`b!FxuU_xPQYV-1qWg+jga% zRh(Wcp>H+RyKlKfNjlr$OjfYvuDvVPz(7Gnh?Dkp_qv4JZ|K1F`QS?zZUyNRk}|)8 z?ew2C;E(O{>!Wr({Jg8zJHLza5!3Qcu=8`@j6fWEG$y& zBYojI7h?Q%YORMa#WL@5D?x&iB+k$=Q_x9*=g1`0?xQ?Su@^un>%<81usw zG5kPD=ejD~CN}Qjtr&?9Yc_D@caanOaXm+y)6Ae;) zv7DXU;`=xUju;VkAw%bAQib!gP4m2b3Hpu@whBm0W>kU3ALW(>{A=>ceX&q{xRVAk zJYkX&>kBKlOzQfCOLau65V-Jt1RSX7!kEXAo|y+#uP?UZgwSqRuYQ{ z_6mis3b;LqiGjX--aG%-4mVo#o0VbB*uff`sq1QXlN^2x5ziO7=giM>L?Pr zrI*dJ1frm!&Pt(~R~`e!QRJYFgZKprIeTrA7_WF-4my)Y#zdGR6<4xy6EtpF@%D_i z+9P^O7*Do$=hlB`N%(wzuYqa%{gOZHbFH-=A2AQ#6N{1K7;W^IK@lhCBjsL!Tl#5X zjZI9qJF``&PhU9vsLvo>)Lzy~e?I#Om_q2%hW`LZCm-ZWy1?(dYO;bZzHD+D5+6H- z4&;!sa3}3VT&%N}khKM=lbMQ^?os`5vZ9@GrNRdC3qNOMjmAay{NOfFATKUfzOhR$F2Yd#f=-OMOd$HVVE&h+tNbL*Fkmm&PQE0Ragb zW(%sGz@Ke4b|Ug8i{GbjT0k@&F_fR_iDLTnhOmm}s8lgIwOxSoQCfm0uf@ND8i;%S zaRzCJV*|0j2xPMj2lgIY7SvDggon(k91j9swF_ZnKZ-%pYg*GTf42Bz4ApYp0Df_F z25yWLmz-Z4%BvMw&N`sm3DRQbjYN3e7Q|zdF(aV}H8EZR)96P8HM~+C5u99IU`;CU zmUxBItqtsW(=z4N&2hb^8ZVE%sJrwraJ1i0;Z|MQ}@NGAD8`VqNU_pVyDI zJr*Sz>iqoIyzuju&>?q*Bwxp((hb7HgY(2+yR3T6|9 zF%cLJVc;au7NT2F1;aUKH4!RtC(mXwbO^>r8q2eOFS#|0_vx7u=yzfwAsx}Gm93}H z(vZ>R>fwn1hld>>y%&yg?w7dhvpaAXrLIeUA}KFl*@_$uuCNC>MmoaD;bioAAYM%S zS~dTfy*`RP8GugZUXDvS&}Pibk=Vj7NJRfSh3VQ6@A@~5ND!oJCG>x;Ymp@WO9ZhV zCqikzHeH||6LcP;=vTb9ykGN=&fCUJOz8%3O!e>2lW zC7sG~srdP5&qhcH!Gta2pLp)88|O>rfMV_UoY8VSg2iRKgbm5J3M367FF6h@u4O=9 zXb2Cg)F*G%p7d3REzhESHug#M>oAJq$nP)F9-aEO6AJ560Ju$Rwp z52HXFk=$fg0|AA_X8KudGXt-WFw1h{2DRclb_QHe(bKRe0Yi9Eb@jhU0ef!5N5KD6 zs4ze3(G=`2ueA{rNqQ{Yk!gX(sFw`^><|O=c<`sH+4wB;iuRi|I`AKSO-25YmfkZTc|xqo`gVzS)miGu-~ZG~uF-#b1FDUh>I2-%p_$#r^H=9ShoK2eyLLntKgF4`k{n>U!dp=_K2d(&k zqkHEpxz-66i1aRV+6w3J?&jwkr%}6#p=&x*jl+)!yaw2oiHk2_5S2>8+SrrK@7uv| zqy2pAQFa4A>qT=4tQPBNDCE0mFR7hycM0Mp9@%;9A^I2zD|HQ!i>q zR4UC*KlRgRB88as1Sf?Qm@Pq&JYreHD4LIB9%`K>``H*P3O&t~wM+`7`-{HUVJ&K> z5Owvu3OGXtCIt|Ye9hFFO8pc|c8!w)Pzt^rl-Z=pZheCfmx3M2Eo7-l%ncI!2$tZ? z)yXY(obX3DbDo26UiHl4Em~JEN@51j7U9V_dV=1SL*k@2=O4m``kfISIMGC-D;<9GG%GCvV{SK)#gFjdoV)|C zDv1)S2KPX&-%pC@3~Pn!jw+2)+)r)%2Xnl~?wba9oT%deT^3TE$QoD1Cly*8r50y-_@Y*|S)9 zP>u#1M3^pPhK8}S5)nDo7Yle>GH7M;Tx-)AWbHfnl7ff*uup-J%*<`d=MH)so@SE! zFh*40a01pS!Jl~ovB1Wr)Cc6f+V)<{Y*%K|=a{^wHNIK!ElBXu)EA3nr#;M8Awxj? zE7giSzqG-voM?9dwlCk0)^@(zA^ZGg5cQ@CPvJH0M>4EBZYZU#-c6vzz&eUWoIT#MsJB2L} zaA5zQd_&-?6dhS))+e(8*To91DBPyOM`y*@UYk7K`rwn{LmcT8yd|T8D6Glc>oZW%kzEu~iOJn;08>vuaSphL^9(Anapa|CYamxHTFU{IAV13)oSQMIPqdoNELKXv3WLCD2gyp8X67WL2>Nt!Q%V8$uBG^tWQ1FS~u;gP{Mtq z%QN%qqp88;e!-8emxAYD0~O5u#rlZhIW4V=VLlll{Z@a#b7NqrlnL3#)nsMns>a*ahHNPAH>S@#|%!3L8KzgT? zbZ|X8XdlKVuJ}hx5YN8(BV1| z935>1PaW}Ak{7Fu|1cz=MWk9Ktt`XFIpk%kwrmJ9&SVbO?0EPyK-0eL=`pkvqvGWn zQUW=wlAWD6P;2IpGx0S0jOXY2JDd6q5Z?y}3F9W_8L7m!f_^F&&jnh}vov_aRw!g0 z6VKGF2p_Mtym~e77M=68I@l9trU@;#+-PyhF`&wxLudXgY~-{yL+BzX5h&8Ul=7h!RPEEd6CPzq(UTcOo@qH#Z!>RKH!6Q6-54=8 zv$ATr8zDuO$Ask`g{8s>?=P+KWJ#C|=Cr!Qv%=->CP}vG)F7q|r(W{kKa0nW`Bt9Ln8z$DS@$p6riU##CFGxe@PfN?;zzVTT3kt5f#iSda)tM;L8Ir>j0wZh z%l>fMQ+mcN^ll7KhOd%BQ5uIT%RYhl_C`!PlEzY(&c1dOI^FHBH4OO_n zj9$KIhNFNb;5x`4a_-rto1R8JAu+U>_rFf5*8|HwO?m+R_nl!5zS#91jN#WO%B=7k z+bs>#pbyLfz(0YqU*a5-$bhs_AQBXarjqqu7qr7>lA(R7AbwVW`$ZfKjhE|WTA&Sz ziIT5yKvIydF7buseXLOF{>dNO%SxYdlV_EhZ~6wvlV1c<f!9B_o z-5%ytuE}e~7UDTXjwG7yO2RsfrQh4d%vz4OFv=@&xgCdU)HO&)&KdjIcIl>qiW_k{~rAFEzXf6leMkP}SMSx_MtU4yppn3R>qfgx zbzFinKYja%f0rwXu)Ke(SfGiR1cf%~oDs z4@zQP_FsS*DW=_0IJYl4J8oLiWiH5mJa2gNfEk-~+CIhI-w@xusk56^WJEam~pE{~{OdE_& z@Z?hV0XlG34RsT3(VB=BsLS{=cCD|^;D?3M zW3JrS+Ukt2pijfw)z2jDzX2PSE7nj}BMTdX^a4-PG~+KKIp4rk>P&d+wGzoRTyj(QKGB0V=$Ra3`TyHv>T7x2}NC6M?CfB@~q1 z+oL9>g;~XNSAR}IQyK0oiGm|t8`O`x=yNrLrq>ESg zobPxvs*xr)ZCfuS7yPWLQmp5RTbMzjc1ICOvGW@`6ul$?iT*V8WYSB-a##w*`Jx>Q zVEH(3iJq<>v2^90 zbqOSWGO>^Cd?rT8_7i1EVJR`6j!&$v{u5841|NmYs+5aUW&bg79Y-% zIzj5PM}*75jyIGp1+P2RmX_DCcB@=$UhGv?@+g={Fwl0p$dl7d-{ZPP9W(X)JlKo+ zth?*Kg0pzUxsS`KWKzNqf&2{?5V`ndM}jO?g5*0Ocf(v#2WS&74J@=R%4ZRrg#P@YU! zQznN^&Z{M$7|fXSpzLHxmGLVP2%TjWtF70=Qzer48jInGtCf!S{=6rBeH}+ls#3)a zAKW||{4$m<-P@EohvD2QM|7IL2Z(9w6tT4tTn%;I(bqZR)qG4UVYR!G+X;dFSmS>H zb`+r$mo;S4jk~se^qd1rSbV8jQsFdpWAfbgzNY=BI;Ko$*1m&CCGmN^ApO5rKs!}` zS`jA5tT3{UtJhsbN>Oh{`4pFU6CEb4zG2iyjmr_M`b7}BzJq%`5b{MmtIyZ3NONh{ zg(^5(uU+i8wsH(SUpl{R%xATVbPX>{GKSi1chpOR*vvn>+Xaa#P}XE$pN_l%;K}!> zEls7055N~&vTZdTlLd%M1tf8;hBucpu_`}c8!^EQ)m0zuNf$*_WR3lNcXki^0=7^4 zFY#|+f7`bT3n2wEyKey7`6UT&a(|K091WKyMeGc&{&SAlGXUJN6gmV&Idzu8qGz_0 zb}5DRcz!7*xk)`(ez5i2LpYEt5Uw!l;d(dlueKay#W(N*9;nY9Xh{!~N_3OQ>wj1E zZHdaQV7i{d`bvJj`v$8#F)Co+~Ky4zfr ziIo2S=ap@zvBcQUpb5+|2yw{UCSPY%bY$thq_)BQ=+lzzIma$Jz6kqYUo-�@ERf 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 z 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 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/push_docs.sh b/docs/push_docs.sh new file mode 100755 index 0000000..251efc4 --- /dev/null +++ b/docs/push_docs.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +originalBranch=$(git rev-parse --abbrev-ref HEAD) +# Function to switch back to the original branch +function switchBack { + echo "Switching back to $originalBranch" + git checkout $originalBranch +} +git checkout pages +trap switchBack EXIT +rm -r public/ && cp -r docs/_build/html public +git add -f public/ +git commit -a -m "update doc from $ref" +git push origin pages diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..27b3493 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,18 @@ +# Usage + +To use Earth2 Grid Utilities in a project + +``` +import earth2grid +# level is the resolution +level = 6 +hpx = earth2grid.healpix.Grid(level=level, pixel_order=earth2grid.healpix.PixelOrder.XY) +src = earth2grid.latlon.equiangular_lat_lon_grid(32, 64) +z_torch = torch.as_tensor(z) +z_torch = torch.as_tensor(z) +regrid = earth2grid.get_regridder(src, hpx) +z_hpx = regrid(z_torch) +z_hpx.shape +nside = 2**level +reshaped = z_hpx.reshape(12, nside, nside) +``` diff --git a/earth2grid/__init__.py b/earth2grid/__init__.py new file mode 100644 index 0000000..3143b70 --- /dev/null +++ b/earth2grid/__init__.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from earth2grid import base, healpix, latlon +from earth2grid._regrid import get_regridder + +__all__ = ["base", "healpix", "latlon", "get_regridder"] diff --git a/earth2grid/_regrid.py b/earth2grid/_regrid.py new file mode 100644 index 0000000..4230026 --- /dev/null +++ b/earth2grid/_regrid.py @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import einops +import netCDF4 as nc +import torch + +from earth2grid import base, healpix +from earth2grid.latlon import LatLonGrid + + +class TempestRegridder(torch.nn.Module): + def __init__(self, file_path): + super().__init__() + dataset = nc.Dataset(file_path) + self.lat = dataset["latc_b"][:] + self.lon = dataset["lonc_b"][:] + + i = dataset["row"][:] - 1 + j = dataset["col"][:] - 1 + M = dataset["S"][:] + + i = i.data + j = j.data + M = M.data + + self.M = torch.sparse_coo_tensor((i, j), M, [max(i) + 1, max(j) + 1]).float() + + def to(self, device): + self.M = self.M.to(device) + return self + + def forward(self, x): + xr = einops.rearrange(x, "b c x y -> b c (x y)") + yr = xr @ self.M.T + y = einops.rearrange(yr, "b c (x y) -> b c x y", x=self.lat.size, y=self.lon.size) + return y + + +class Identity(torch.nn.Module): + def forward(self, x): + return x + + +def get_regridder(src: base.Grid, dest: base.Grid) -> torch.nn.Module: + """Get a regridder from `src` to `dest`""" + if src == dest: + return Identity() + elif isinstance(src, LatLonGrid) and isinstance(dest, LatLonGrid): + return src.get_bilinear_regridder_to(dest.lat, dest.lon) + elif isinstance(src, LatLonGrid) and isinstance(dest, healpix.Grid): + return src.get_bilinear_regridder_to(dest.lat, dest.lon) + elif isinstance(src, healpix.Grid): + return src.get_bilinear_regridder_to(dest.lat, dest.lon) + elif isinstance(dest, healpix.Grid): + return src.get_healpix_regridder(dest) # type: ignore + + raise ValueError(src, dest, "not supported.") diff --git a/earth2grid/base.py b/earth2grid/base.py new file mode 100644 index 0000000..2f43451 --- /dev/null +++ b/earth2grid/base.py @@ -0,0 +1,47 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from typing import Protocol + +import numpy as np + + +class Grid(Protocol): + """lat and lon should be broadcastable arrays""" + + @property + def lat(self): + pass + + @property + def lon(self): + pass + + @property + def shape(self) -> tuple[int, ...]: + pass + + def get_bilinear_regridder_to(self, lat: np.ndarray, lon: np.ndarray): + """Return a regridder from `self` to lat/lon. + + Args: + lat, lon: broadcastable arrays for the lat/lon + """ + raise NotImplementedError() + + def visualize(self, data): + pass + + def to_pyvista(self): + pass diff --git a/earth2grid/csrc/healpix_bare_wrapper.cpp b/earth2grid/csrc/healpix_bare_wrapper.cpp new file mode 100644 index 0000000..2cb0537 --- /dev/null +++ b/earth2grid/csrc/healpix_bare_wrapper.cpp @@ -0,0 +1,228 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// This NVIDIA code +#include +#include +#include "healpix_bare.c" +#include "interpolation.h" +#include + + +#define FILLNA(x) std::isnan(x) ? 0.0 : x + +int return_1(void) { + return 1; +} + +template +auto wrap(Func func) { + return [func](int nside, torch::Tensor input) { + auto output = torch::empty_like(input); + auto accessor = input.accessor(); + auto out_accessor = output.accessor(); + + for (int64_t i = 0; i < input.size(0); ++i) { + out_accessor[i] = func(nside, accessor[i]); // Specify nside appropriately + } + + return output; + }; +} + + +template +auto wrap_2hpd(Func func) { + + return [func](int nside, torch::Tensor input){ + auto options = torch::TensorOptions().dtype(torch::kInt64); + auto x = torch::empty_like(input, options); + auto y = torch::empty_like(input, options); + auto f = torch::empty_like(input, options.dtype(torch::kInt32)); + + auto output_options = torch::TensorOptions().dtype(torch::kInt64); + auto output = torch::empty({input.size(0), 3}, output_options); + auto out_accessor = output.accessor(); + auto accessor = input.accessor(); + + for (int64_t i = 0; i < input.size(0); ++i) { + auto hpd = func(nside, accessor[i]); + out_accessor[i][0] = hpd.f; + out_accessor[i][1] = hpd.y; + out_accessor[i][2] = hpd.x; + } + + return output; + }; +} + +torch::Tensor hpd2loc_wrapper(int nside, torch::Tensor input) { + auto accessor = input.accessor(); + + auto output_options = torch::TensorOptions().dtype(torch::kDouble); + auto output = torch::empty({input.size(0), 3}, output_options); + auto out_accessor = output.accessor(); + + int64_t x, y, f; + + for (int64_t i = 0; i < input.size(0); ++i) { + f = accessor[i][0]; + y = accessor[i][1]; + x = accessor[i][2]; + + t_hpd hpd {x, y, f}; + tloc loc = hpd2loc(nside, hpd); + out_accessor[i][0] = loc.z; + out_accessor[i][1] = loc.s; + out_accessor[i][2] = loc.phi; + } + + return output; +} + +torch::Tensor hpc2loc_wrapper(torch::Tensor x, torch::Tensor y, torch::Tensor f) { + auto accessor_f = f.accessor(); + auto accessor_x = x.accessor(); + auto accessor_y = y.accessor(); + + auto output_options = torch::TensorOptions().dtype(torch::kDouble); + auto output = torch::empty({f.size(0), 3}, output_options); + auto out_accessor = output.accessor(); + + + for (int64_t i = 0; i < x.size(0); ++i) { + t_hpc hpc {accessor_x[i], accessor_y[i], accessor_f[i]}; + tloc loc = hpc2loc(hpc); + out_accessor[i][0] = loc.z; + out_accessor[i][1] = loc.s; + out_accessor[i][2] = loc.phi; + } + + return output; +} + + + +torch::Tensor corners(int nside, torch::Tensor pix, bool nest) { + auto accessor = pix.accessor(); + + auto output_options = torch::TensorOptions().dtype(torch::kDouble); + auto output = torch::empty({pix.size(0), 3, 4}, output_options); + auto out_accessor = output.accessor(); + + + t_hpd hpd; + double n = nside; + + for (int64_t i = 0; i < pix.size(0); ++i) { + if (nest) { + hpd = nest2hpd(nside, accessor[i]); + } else { + hpd = ring2hpd(nside, accessor[i]); + } + + t_hpc hpc; + int offset = 0; + t_vec vec; + + hpc.x = static_cast(hpd.x) / n; + hpc.y = static_cast(hpd.y) / n; + hpc.f = hpd.f; + vec = loc2vec(hpc2loc(hpc)); + out_accessor[i][0][offset] = FILLNA(vec.x); + out_accessor[i][1][offset] = FILLNA(vec.y); + out_accessor[i][2][offset] = vec.z; + offset++; + + hpc.x = static_cast(hpd.x + 1) / n; + hpc.y = static_cast(hpd.y) / n; + vec = loc2vec(hpc2loc(hpc)); + out_accessor[i][0][offset] = FILLNA(vec.x); + out_accessor[i][1][offset] = FILLNA(vec.y); + out_accessor[i][2][offset] = vec.z; + offset++; + + hpc.x = static_cast(hpd.x + 1) / n; + hpc.y = static_cast(hpd.y + 1) / n; + vec = loc2vec(hpc2loc(hpc)); + out_accessor[i][0][offset] = FILLNA(vec.x); + out_accessor[i][1][offset] = FILLNA(vec.y); + out_accessor[i][2][offset] = vec.z; + offset++; + + hpc.x = static_cast(hpd.x) / n; + hpc.y = static_cast(hpd.y + 1) / n; + vec = loc2vec(hpc2loc(hpc)); + out_accessor[i][0][offset] = FILLNA(vec.x); + out_accessor[i][1][offset] = FILLNA(vec.y); + out_accessor[i][2][offset] = vec.z; + offset++; + } + return output; +} + +// these are the minimal routines +// nest2hpd +// ring2hpd +// hpd2loc +// loc2ang + +std::vector get_interp_weights(int nside, torch::Tensor lon, torch::Tensor lat) { + + // setup outputs + auto weight_options = torch::TensorOptions().dtype(torch::kDouble); + auto weight = torch::empty({4, lon.size(0)}, weight_options); + + auto pix_options = torch::TensorOptions().dtype(torch::kLong); + auto pix = torch::empty({4, lon.size(0)}, pix_options); + + + auto pix_a = pix.accessor(); + auto weight_a = weight.accessor(); + + auto lon_a = lon.accessor(); + auto lat_a = lat.accessor(); + const bool nest = false; + + { + // output information + std::array pix_i; + std::array wgt_i; + for (int64_t i = 0; i < lon.size(0); ++i) { + t_ang ptg = latlon2ang(lat_a[i], lon_a[i]); + interpolation_weights(ptg, pix_i, wgt_i, nside); + // TODO flip i and j to get better cache performance + for (int j= 0; j < 4; ++j){ + pix_a[j][i] = pix_i[j]; + weight_a[j][i] = wgt_i[j]; + } + }; + } + return std::vector{pix, weight}; +} + + +PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) { + m.def("return_1", &return_1, "First implementation."); + m.def("ring2nest", wrap(ring2nest), "Element-wise ring2nest conversion"); + m.def("nest2ring", wrap(nest2ring), "Element-wise nest2ring conversion"); + m.def("nest2hpd", wrap_2hpd(nest2hpd), "hpd is f, y ,x"); + m.def("ring2hpd", wrap_2hpd(ring2hpd), "hpd is f, y ,x"); + m.def("hpd2loc", &hpd2loc_wrapper, "loc is in z, s, phi"); + m.def("hpc2loc", &hpc2loc_wrapper, "hpc2loc(x, y, f) -> z, s, phi"); + m.def("corners", &corners, ""); + m.def("get_interp_weights", &get_interp_weights, ""); +}; diff --git a/earth2grid/csrc/interpolation.h b/earth2grid/csrc/interpolation.h new file mode 100644 index 0000000..55aa48f --- /dev/null +++ b/earth2grid/csrc/interpolation.h @@ -0,0 +1,167 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "healpix_bare.h" + +void hpx_info(int64_t nside, int64_t &npface, int64_t &ncap, int64_t &npix, + double &fact1, double &fact2) { + npix = nside2npix(nside); + npface = npix / 12; + ncap = (npface - nside) << 1; + fact2 = 4. / npix; + fact1 = (nside << 1) * fact2; +} + +int64_t ring_above(double z, int64_t nside) { + double az = std::abs(z); + if (az <= 2. / 3.) + return int64_t(nside * (2 - 1.5 * z)); + int64_t iring = int64_t(nside * std::sqrt(3 * (1 - az))); + return (z > 0) ? iring : 4 * nside - iring - 1; +} + +void ring_info(int64_t ring, int64_t &startpix, int64_t &ringpix, double &theta, + bool &shifted, int64_t nside) { + int64_t northring = (ring > 2 * nside) ? 4 * nside - ring : ring; + int64_t npface, ncap, npix; + double fact1, fact2; + hpx_info(nside, npface, ncap, npix, fact1, fact2); + if (northring < nside) { + double tmp = northring * northring * fact2; + double costheta = 1 - tmp; + double sintheta = std::sqrt(tmp * (2 - tmp)); + theta = std::atan2(sintheta, costheta); + ringpix = 4 * northring; + shifted = true; + startpix = 2 * northring * (northring - 1); + } else { + theta = acos((2 * nside - northring) * fact1); + ringpix = 4 * nside; + shifted = ((northring - nside) & 1) == 0; + startpix = ncap + (northring - nside) * ringpix; + } + if (northring != ring) // southern hemisphere + { + theta = std::numbers::pi - theta; + startpix = npix - startpix - ringpix; + } +} + +// TODO (asubramaniam): switch from std::array to double* pointers to avoid +// copies in the batched case +template +void interpolation_weights(const t_ang &ptg, std::array &pix, + std::array &wgt, int64_t nside) { + assert((ptg.theta >= 0) && (ptg.theta <= std::numbers::pi)); + double z = std::cos(ptg.theta); + int64_t npix = nside2npix(nside); + + // Do everything in ring ordering first + // Can convert indices to nest ordering at the end if needed + int64_t ir1 = ring_above(z, nside); + int64_t ir2 = ir1 + 1; + double theta1, theta2, w1, tmp, dphi; + int64_t sp, nr; + bool shift; + int64_t i1, i2; + if (ir1 > 0) { + ring_info(ir1, sp, nr, theta1, shift, nside); + dphi = 2. * std::numbers::pi / nr; + tmp = (ptg.phi / dphi - .5 * shift); + i1 = (tmp < 0) ? int64_t(tmp) - 1 : int64_t(tmp); + w1 = (ptg.phi - (i1 + .5 * shift) * dphi) / dphi; + if (i1 < 0) { + i1 += nr; + } + i2 = i1 + 1; + if (i2 >= nr) { + i2 -= nr; + } + pix[0] = sp + i1; + pix[1] = sp + i2; + wgt[0] = 1 - w1; + wgt[1] = w1; + } + if (ir2 < (4 * nside)) { + ring_info(ir2, sp, nr, theta2, shift, nside); + dphi = 2. * std::numbers::pi / nr; + tmp = (ptg.phi / dphi - .5 * shift); + i1 = (tmp < 0) ? int64_t(tmp) - 1 : int64_t(tmp); + w1 = (ptg.phi - (i1 + .5 * shift) * dphi) / dphi; + if (i1 < 0) + i1 += nr; + i2 = i1 + 1; + if (i2 >= nr) + i2 -= nr; + pix[2] = sp + i1; + pix[3] = sp + i2; + wgt[2] = 1 - w1; + wgt[3] = w1; + } + + if (ir1 == 0) { + double wtheta = ptg.theta / theta2; + wgt[2] *= wtheta; + wgt[3] *= wtheta; + double fac = (1 - wtheta) * 0.25; + wgt[0] = fac; + wgt[1] = fac; + wgt[2] += fac; + wgt[3] += fac; + pix[0] = (pix[2] + 2) & 3; + pix[1] = (pix[3] + 2) & 3; + } else if (ir2 == 4 * nside) { + double wtheta = (ptg.theta - theta1) / (std::numbers::pi - theta1); + wgt[0] *= (1 - wtheta); + wgt[1] *= (1 - wtheta); + double fac = wtheta * 0.25; + wgt[0] += fac; + wgt[1] += fac; + wgt[2] = fac; + wgt[3] = fac; + pix[2] = ((pix[0] + 2) & 3) + npix - 4; + pix[3] = ((pix[1] + 2) & 3) + npix - 4; + } else { + double wtheta = (ptg.theta - theta1) / (theta2 - theta1); + wgt[0] *= (1 - wtheta); + wgt[1] *= (1 - wtheta); + wgt[2] *= wtheta; + wgt[3] *= wtheta; + } + + // Convert indices from ring to nest format if needed + if (NEST) + for (size_t m = 0; m < pix.size(); ++m) + pix[m] = ring2nest(pix[m], nside); +} + +double degrees2radians(double theta) { return theta * std::numbers::pi / 180.; } + +t_ang latlon2ang(double lat, double lon) { + double theta = std::numbers::pi / 2. - degrees2radians(lat); + double phi = degrees2radians(lon); + t_ang ang = {theta, phi}; + return ang; +} diff --git a/earth2grid/healpix.py b/earth2grid/healpix.py new file mode 100644 index 0000000..6c757be --- /dev/null +++ b/earth2grid/healpix.py @@ -0,0 +1,424 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" + +From this notebook: https://colab.research.google.com/drive/1MzTyeNFiy-7RNY6UtGKsmDavX5dk6epU + + +Healpy has two indexing conventions NEST and RING. But for convolutions we want +2D array indexing in row or column major order. Here are some vectorized +routines `nest2xy` and `x2nest` for going in between these conventions. The +previous code shared by Dale used string computations to handle these +operations, which was probably quite slow. Here we use vectorized bit-shifting. + +## XY orientation + +For array-like indexing can have a different origin and orientation. For +example, the default is the origin is S and the data arr[f, y, x] follows the +right hand rule. In other words, (x + 1, y) being counterclockwise from (x, y) +when looking down on the face. + +""" + +import math +from dataclasses import dataclass +from enum import Enum +from typing import Union + +import einops +import numpy as np +import torch + +from earth2grid import healpix_bare + +try: + import pyvista as pv +except ImportError: + pv = None + +from earth2grid import base +from earth2grid.third_party.zephyr.healpix import healpix_pad + +try: + import healpixpad +except ImportError: + healpixpad = None + +__all__ = ["pad", "PixelOrder", "XY", "Compass", "Grid", "HEALPIX_PAD_XY", "conv2d"] + + +def pad(x: torch.Tensor, padding: int) -> torch.Tensor: + """ + Pad each face consistently with its according neighbors in the HEALPix + + Args: + x: The input tensor of shape [N, F, H, W] + padding: the amount of padding + + Returns: + The padded tensor with shape [N, F, H+2*padding, W+2*padding] + + Examples: + + Ths example show to pad data described by a :py:class:`Grid` object. + + >>> grid = Grid(level=4, pixel_order=PixelOrder.RING) + >>> lon = torch.from_numpy(grid.lon) + >>> faces = grid.reorder(HEALPIX_PAD_XY, lon) + >>> faces = faces.view(1, 12, grid._nside(), grid._nside()) + >>> faces.shape + torch.Size([1, 12, 16, 16]) + >>> padded = pad(faces, padding=1) + >>> padded.shape + torch.Size([1, 12, 18, 18]) + + """ + if healpixpad is None or x.device.type != 'cuda': + return healpix_pad(x, padding) + else: + return healpixpad.HEALPixPadFunction.apply(x.unsqueeze(2), padding).squeeze(2) + + +class PixelOrder(Enum): + RING = 0 + NEST = 1 + + +class Compass(Enum): + """Cardinal directions in counter clockwise order""" + + S = 0 + E = 1 + N = 2 + W = 3 + + +@dataclass(frozen=True) +class XY: + """ + Assumes + - i = n * n * f + n * y + x + - the origin (x,y)=(0,0) is South + - if clockwise=False follows the hand rule: + + Space + | + | + | / y + | / + |/______ x + + (Thumb points towards Space, index finger towards x, middle finger towards y) + """ + + origin: Compass = Compass.S + clockwise: bool = False + + +PixelOrderT = Union[PixelOrder, XY] + +HEALPIX_PAD_XY = XY(origin=Compass.N, clockwise=True) + + +def _convert_xyindex(nside: int, src: XY, dest: XY, i): + if src.clockwise != dest.clockwise: + i = _flip_xy(nside, i) + + rotations = dest.origin.value - src.origin.value + i = _rotate_index(nside=nside, rotations=-rotations if dest.clockwise else rotations, i=i) + return i + + +class ApplyWeights(torch.nn.Module): + def __init__(self, pix: torch.Tensor, weight: torch.Tensor): + super().__init__() + + # the first dim is the 4 point stencil + n, *self.shape = pix.shape + + pix = pix.view(n, -1).T + weight = weight.view(n, -1).T + + self.register_buffer("index", pix) + self.register_buffer("weight", weight) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + *shape, npix = x.shape + x = x.view(-1, npix).T + interpolated = torch.nn.functional.embedding_bag(self.index, x, per_sample_weights=self.weight, mode="sum").T + return interpolated.view(shape + self.shape) + + +@dataclass +class Grid(base.Grid): + """A Healpix Grid + + Attrs: + level: 2^level = nside + pixel_order: the ordering convection of the data + """ + + level: int + pixel_order: PixelOrderT = PixelOrder.RING + + def __post_init__(self): + if self.level > ZOOM_LEVELS: + raise ValueError(f"`level` must be less than or equal to {ZOOM_LEVELS}") + + def _nside(self): + return 2**self.level + + def _npix(self): + return self._nside() ** 2 * 12 + + def _nest_ipix(self): + """convert to nested index number""" + i = torch.arange(self._npix()) + if isinstance(self.pixel_order, XY): + i_xy = _convert_xyindex(nside=self._nside(), src=self.pixel_order, dest=XY(), i=i) + i = xy2nest(self._nside(), i_xy) + elif self.pixel_order == PixelOrder.RING: + i = healpix_bare.ring2nest(self._nside(), i) + elif self.pixel_order == PixelOrder.NEST: + pass + else: + raise ValueError(self.pixel_order) + return i.numpy() + + def _nest2me(self, ipix: np.ndarray) -> np.ndarray: + """return the index in my PIXELORDER corresponding to ipix in NEST ordering""" + if isinstance(self.pixel_order, XY): + i_xy = nest2xy(self._nside(), ipix) + i_me = _convert_xyindex(nside=self._nside(), src=XY(), dest=self.pixel_order, i=i_xy) + elif self.pixel_order == PixelOrder.RING: + ipix_t = torch.from_numpy(ipix) + i_me = healpix_bare.nest2ring(self._nside(), ipix_t).numpy() + elif self.pixel_order == PixelOrder.NEST: + i_me = ipix + return i_me + + @property + def lat(self): + ipix = torch.from_numpy(self._nest_ipix()) + _, lat = healpix_bare.pix2ang(self._nside(), ipix, lonlat=True, nest=True) + return lat.numpy() + + @property + def lon(self): + ipix = torch.from_numpy(self._nest_ipix()) + lon, _ = healpix_bare.pix2ang(self._nside(), ipix, lonlat=True, nest=True) + return lon.numpy() + + @property + def shape(self) -> tuple[int, ...]: + return (self._npix(),) + + def visualize(self, map): + raise NotImplementedError() + + def to_pyvista(self): + if pv is None: + raise ImportError("Need to install pyvista") + + # Make grid + nside = 2**self.level + pix = self._nest_ipix() + points = healpix_bare.corners(nside, torch.from_numpy(pix), True).numpy() + out = einops.rearrange(points, "n d s -> (n s) d") + unique_points, inverse = np.unique(out, return_inverse=True, axis=0) + assert unique_points.ndim == 2 + assert unique_points.shape[1] == 3 + inverse = einops.rearrange(inverse, "(n s) -> n s", n=pix.size) + n, s = inverse.shape + cells = np.ones_like(inverse, shape=(n, s + 1)) + cells[:, 0] = s + cells[:, 1:] = inverse + celltypes = np.full(shape=(n,), fill_value=pv.CellType.QUAD) + grid = pv.UnstructuredGrid(cells, celltypes, unique_points) + return grid + + def get_bilinear_regridder_to(self, lat: np.ndarray, lon: np.ndarray): + """Get regridder to the specified lat and lon points""" + lat, lon = np.broadcast_arrays(lat, lon) + i_ring, weights = healpix_bare.get_interp_weights(self._nside(), torch.tensor(lon), torch.tensor(lat)) + i_nest = healpix_bare.ring2nest(self._nside(), i_ring.ravel()) + i_me = torch.from_numpy(self._nest2me(i_nest.numpy())).view(i_ring.shape) + return ApplyWeights(i_me, weights) + + def approximate_grid_length_meters(self): + return approx_grid_length_meters(self._nside()) + + def reorder(self, order: PixelOrderT, x: torch.Tensor) -> torch.Tensor: + """Rorder the pixels of ``x`` to have ``order``""" + output_grid = Grid(level=self.level, pixel_order=order) + i_nest = output_grid._nest_ipix() + i_me = self._nest2me(i_nest) + return x[..., i_me] + + def get_healpix_regridder(self, dest: "Grid"): + if self.level != dest.level: + return self.get_bilinear_regridder_to(dest.lat, dest.lon) + + def regridder(x: torch.Tensor) -> torch.Tensor: + return self.reorder(dest.pixel_order, x) + + return regridder + + def to_image(self, x: torch.Tensor, fill_value=torch.nan) -> torch.Tensor: + """Use the 45 degree rotated grid pixelation + i points to SE, j point to NE + """ + grid = [[6, 9, -1, -1, -1], [1, 5, 8, -1, -1], [-1, 0, 4, 11, -1], [-1, -1, 3, 7, 10], [-1, -1, -1, 2, 6]] + pixel_order = XY(origin=Compass.W, clockwise=True) + x = self.reorder(pixel_order, x) + nside = self._nside() + *shape, _ = x.shape + x = x.reshape((*shape, 12, nside, nside)) + output = torch.full((*shape, 5 * nside, 5 * nside), device=x.device, dtype=x.dtype, fill_value=fill_value) + + for j in range(len(grid)): + for i in range(len(grid[0])): + face = grid[j][i] + if face != -1: + output[j * nside : (j + 1) * nside, i * nside : (i + 1) * nside] = x[face] + return output + + +# nside = 2^ZOOM_LEVELS +ZOOM_LEVELS = 20 + + +def _extract_every_other_bit(binary_number): + result = 0 + shift_count = 0 + + for i in range(ZOOM_LEVELS): + # Check if the least significant bit is 1 + # Set the corresponding bit in the result + result |= (binary_number & 1) << shift_count + + # Shift to the next bit to check + binary_number = binary_number >> 2 + shift_count += 1 + + return result + + +def _flip_xy(nside: int, i): + n2 = nside * nside + f = i // n2 + y = (i % n2) // nside + x = i % nside + return n2 * f + nside * x + y + + +def _rotate_index(nside: int, rotations: int, i): + # Extract f, x, and y from i + # convention is arr[f, y, x] ... x is the fastest changing index + n2 = nside * nside + f = i // n2 + y = (i % n2) // nside + x = i % nside + + # Reduce k to its equivalent in the range [0, 3] + k = rotations % 4 + + assert 0 <= k < 4 + + # Apply the rotation based on k + if k == 1: # 90 degrees counterclockwise + new_x, new_y = -y - 1, x + elif k == 2: # 180 degrees + new_x, new_y = -x - 1, -y - 1 + elif k == 3: # 270 degrees counterclockwise + new_x, new_y = y, -x - 1 + else: # k == 0, no change + new_x, new_y = x, y + + # Adjust for negative indices + new_x = new_x % nside + new_y = new_y % nside + + # Recalculate the linear index with the rotated x and y + return n2 * f + nside * new_y + new_x + + +def nest2xy(nside, i): + """convert NEST to XY index""" + tile = i // nside**2 + j = i % (nside**2) + x = _extract_every_other_bit(j) + y = _extract_every_other_bit(j >> 1) + return tile * nside**2 + y * nside + x + + +def xy2nest(nside, i): + """convert XY index to NEST""" + tile = i // (nside**2) + y = (i % (nside**2)) // nside + x = i % nside + + result = 0 + for i in range(ZOOM_LEVELS): + # Extract the ith bit from the number + extracted_bit = (x >> i) & 1 + result |= extracted_bit << (2 * i) + + extracted_bit = (y >> i) & 1 + result |= extracted_bit << (2 * i + 1) + return result | (tile * nside**2) + + +def approx_grid_length_meters(nside): + r_m = 6378140 + area = 4 * np.pi * r_m**2 / (12 * nside**2) + return np.sqrt(area) + + +def conv2d(input, weight, bias=None, stride=1, padding=0, dilation=1, groups=1): + """conv2d(input, weight, bias=None, stride=1, padding=0, dilation=1, groups=1) -> Tensor + + Applies a 2D convolution over an input image composed of several input + planes. + + This operator supports :ref:`TensorFloat32`. + + See :class:`~torch.nn.Conv2d` for details and output shape. + + """ + px, py = padding + assert px == py + + n, c, x, y = input.shape + npix = input.size(-1) + nside2 = npix // 12 + nside = int(math.sqrt(nside2)) + assert nside**2 * 12 == npix + + input = einops.rearrange(input, "n c () (f x y) -> (n c) f x y", f=12, x=nside) + input = pad(input, px) + input = einops.rearrange(input, "(n c) f x y -> n c f x y", c=c) + padding = (0, 0, 0) + padding = 'valid' + + if not isinstance(stride, int): + stride = stride + (1,) + + if not isinstance(dilation, int): + dilation = (1,) + dilation + + weight = weight.unsqueeze(-3) + out = torch.nn.functional.conv3d(input, weight, bias, stride, padding, dilation, groups) + return einops.rearrange(out, "n c f x y -> n c () (f x y)") diff --git a/earth2grid/healpix_bare.py b/earth2grid/healpix_bare.py new file mode 100644 index 0000000..7ec3a3b --- /dev/null +++ b/earth2grid/healpix_bare.py @@ -0,0 +1,79 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import torch + +from earth2grid import _healpix_bare +from earth2grid._healpix_bare import corners, hpc2loc, hpd2loc, nest2hpd, nest2ring, ring2hpd, ring2nest + +__all__ = [ + "pix2ang", + "ring2nest", + "nest2ring", + "hpc2loc", + "corners", +] + + +def pix2ang(nside, i, nest=False, lonlat=False): + if nest: + hpd = nest2hpd(nside, i) + else: + hpd = ring2hpd(nside, i) + loc = hpd2loc(nside, hpd) + lon, lat = _loc2ang(loc) + + if lonlat: + return torch.rad2deg(lon), 90 - torch.rad2deg(lat) + else: + return lat, lon + + +def _loc2ang(loc): + """ + static t_ang loc2ang(tloc loc) + { return (t_ang){atan2(loc.s,loc.z), loc.phi}; } + """ + z = loc[..., 0] + s = loc[..., 1] + phi = loc[..., 2] + return phi % (2 * torch.pi), torch.atan2(s, z) + + +def loc2vec(loc): + z = loc[..., 0] + s = loc[..., 1] + phi = loc[..., 2] + x = (s * torch.cos(phi),) + y = (s * torch.sin(phi),) + return x, y, z + + +def get_interp_weights(nside: int, lon: torch.Tensor, lat: torch.Tensor): + """ + + Args: + lon: longtiude in deg E. Shape (*) + lat: latitdue in deg E. Shape (*) + + Returns: + pix, weights: both shaped (4, *). pix is given in RING convention. + + """ + shape = lon.shape + lon = lon.double().cpu().flatten() + lat = lat.double().cpu().flatten() + pix, weights = _healpix_bare.get_interp_weights(nside, lon, lat) + + return pix.view(4, *shape), weights.view(4, *shape) diff --git a/earth2grid/latlon.py b/earth2grid/latlon.py new file mode 100644 index 0000000..8d55e7f --- /dev/null +++ b/earth2grid/latlon.py @@ -0,0 +1,200 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import numpy as np +import torch + +from earth2grid import base + +try: + import pyvista as pv +except ImportError: + pv = None + + +class BilinearInterpolator(torch.nn.Module): + """Bilinear interpolation for a non-uniform grid""" + + def __init__( + self, x_coords: torch.Tensor, y_coords: torch.Tensor, x_query: torch.Tensor, y_query: torch.Tensor + ) -> None: + """ + + Args: + x_coords (Tensor): X-coordinates of the input grid, shape [W]. Must be in increasing sorted order. + y_coords (Tensor): Y-coordinates of the input grid, shape [H]. Must be in increasing sorted order. + x_query (Tensor): X-coordinates for query points, shape [N]. + y_query (Tensor): Y-coordinates for query points, shape [N]. + """ + super().__init__() + + # Ensure input coordinates are float for interpolation + x_coords, y_coords = x_coords.float(), y_coords.float() + + if torch.any(x_coords[1:] < x_coords[:-1]): + raise ValueError("x_coords must be in non-decreasing order.") + + if torch.any(y_coords[1:] < y_coords[:-1]): + raise ValueError("y_coords must be in non-decreasing order.") + + # Find indices for the closest lower and upper bounds in x and y directions + x_l_idx = torch.searchsorted(x_coords, x_query, right=True) - 1 + x_u_idx = x_l_idx + 1 + y_l_idx = torch.searchsorted(y_coords, y_query, right=True) - 1 + y_u_idx = y_l_idx + 1 + + # Clip indices to ensure they are within the bounds of the input grid + x_l_idx = x_l_idx.clamp(0, x_coords.size(0) - 2) + x_u_idx = x_u_idx.clamp(1, x_coords.size(0) - 1) + y_l_idx = y_l_idx.clamp(0, y_coords.size(0) - 2) + y_u_idx = y_u_idx.clamp(1, y_coords.size(0) - 1) + + # Compute weights + x_l_weight = (x_coords[x_u_idx] - x_query) / (x_coords[x_u_idx] - x_coords[x_l_idx]) + x_u_weight = (x_query - x_coords[x_l_idx]) / (x_coords[x_u_idx] - x_coords[x_l_idx]) + y_l_weight = (y_coords[y_u_idx] - y_query) / (y_coords[y_u_idx] - y_coords[y_l_idx]) + y_u_weight = (y_query - y_coords[y_l_idx]) / (y_coords[y_u_idx] - y_coords[y_l_idx]) + weights = torch.stack( + [x_l_weight * y_l_weight, x_u_weight * y_l_weight, x_l_weight * y_u_weight, x_u_weight * y_u_weight], dim=-1 + ) + + self.register_buffer("weights", weights) + + stride = x_coords.size(-1) + index = torch.stack( + [ + x_l_idx + stride * y_l_idx, + x_u_idx + stride * y_l_idx, + x_l_idx + stride * y_u_idx, + x_u_idx + stride * y_u_idx, + ], + dim=-1, + ) + self.register_buffer("index", index) + + def forward(self, z: torch.Tensor): + """ + Interpolate the field + + Args: + z: shape [Y, X] + """ + *shape, y, x = z.shape + zrs = z.view(-1, y * x).T + # using embedding bag is 2x faster on cpu and 4x on gpu. + interpolated = torch.nn.functional.embedding_bag(self.index, zrs, per_sample_weights=self.weights, mode='sum') + interpolated = interpolated.T.view(*shape, self.weights.size(0)) + return interpolated + + +class LatLonGrid(base.Grid): + def __init__(self, lat: list[float], lon: list[float]): + """ + Args: + lat: center of lat cells + lon: center of lon cells + """ + self._lat = lat + self._lon = lon + + @property + def lat(self): + return np.array(self._lat)[:, None] + + @property + def lon(self): + return np.array(self._lon) + + @property + def shape(self): + return (len(self.lat), len(self.lon)) + + def get_bilinear_regridder_to(self, lat: np.ndarray, lon: np.ndarray): + """Get regridder to the specified lat and lon points""" + return _RegridFromLatLon(self, lat, lon) + + def _lonb(self): + edges = (self.lon[1:] + self.lon[:-1]) / 2 + d_left = self.lon[1] - self.lon[0] + d_right = self.lon[-1] - self.lon[-2] + return np.concatenate([self.lon[0:1] - d_left / 2, edges, self.lon[-1:] + d_right / 2]) + + def visualize(self, data): + raise NotImplementedError() + + def to_pyvista(self): + # TODO need to make lat the cell centers rather than boundaries + + if pv is None: + raise ImportError("Need to install pyvista") + + print(self._lonb()) + + lon, lat = np.meshgrid(self._lonb(), self.lat) + y = np.cos(np.deg2rad(lat)) * np.sin(np.deg2rad(lon)) + x = np.cos(np.deg2rad(lat)) * np.cos(np.deg2rad(lon)) + z = np.sin(np.deg2rad(lat)) + grid = pv.StructuredGrid(x, y, z) + return grid + + +class _RegridFromLatLon(torch.nn.Module): + """Regrid from lat-lon to unstructured grid with bilinear interpolation""" + + def __init__(self, src: LatLonGrid, lat: np.ndarray, lon: np.ndarray): + super().__init__() + + lat, lon = np.broadcast_arrays(lat, lon) + self.shape = lat.shape + + # TODO add device switching logic (maybe use torch registers for this + # info) + long = np.concatenate([src.lon.ravel(), [360]], axis=-1) + long_t = torch.from_numpy(long) + + # flip the order latg since bilinear only works with increasing coordinate values + lat_increasing = src.lat[1] > src.lat[0] + latg_t = torch.from_numpy(src.lat.ravel()) + lat_query = torch.from_numpy(lat.ravel()) + + if not lat_increasing: + lat_query = -lat_query + latg_t = -latg_t + + self._bilinear = BilinearInterpolator(long_t, latg_t, y_query=lat_query, x_query=torch.from_numpy(lon.ravel())) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + # pad z in lon direction + # only works for a global grid + # TODO generalize this to local grids and add options for padding + x = torch.cat([x, x[..., 0:1]], axis=-1) + out = self._bilinear(x) + return out.view(out.shape[:-1] + self.shape) + + +def equiangular_lat_lon_grid(nlat: int, nlon: int, includes_south_pole: bool = True) -> LatLonGrid: + """Return a regular lat-lon grid + + Lat is ordered from 90 to -90. Includes -90 and only if if includes_south_pole is True. + Lon is ordered from 0 to 360. includes 0, but not 360. + + Args: + nlat: number of latitude points + nlon: number of longtidue points + includes_south_pole: if true the final ``nlat`` includes the south pole + + """ # noqa + lat = np.linspace(90, -90, nlat, endpoint=includes_south_pole) + lon = np.linspace(0, 360, nlon, endpoint=False) + return LatLonGrid(lat.tolist(), lon.tolist()) diff --git a/earth2grid/third_party/healpix_bare/LICENSE b/earth2grid/third_party/healpix_bare/LICENSE new file mode 100644 index 0000000..4f12e3a --- /dev/null +++ b/earth2grid/third_party/healpix_bare/LICENSE @@ -0,0 +1,29 @@ +Copyright (C) 1997-2019 Krzysztof M. Gorski, Eric Hivon, Martin Reinecke, + Benjamin D. Wandelt, Anthony J. Banday, + Matthias Bartelmann, + Reza Ansari & Kenneth M. Ganga + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/earth2grid/third_party/healpix_bare/NEWS b/earth2grid/third_party/healpix_bare/NEWS new file mode 100644 index 0000000..b0debc4 --- /dev/null +++ b/earth2grid/third_party/healpix_bare/NEWS @@ -0,0 +1,2 @@ +Version 1.0 (March 2019): + initial release diff --git a/earth2grid/third_party/healpix_bare/README b/earth2grid/third_party/healpix_bare/README new file mode 100644 index 0000000..d78d621 --- /dev/null +++ b/earth2grid/third_party/healpix_bare/README @@ -0,0 +1,42 @@ +This package implements a small subset of routines for working with the +HEALPix tesselation of the sphere. + +For information about HEALPix, see the publication by +K.M. Gorski et al., 2005, Ap.J., 622, p.759 +(http://adsabs.harvard.edu/abs/2005ApJ...622..759G) +More comprehensive software packages supporting HEALPix, as well as extensive +documentation and references, are available at +https://healpix.sourceforge.io/. + + +Compilation +=========== + +gcc -O2 healpix_bare.c -std=c99 test.c -lm +clang -O2 healpix_bare.c -std=c99 test.c -lm +icc -O2 healpix_bare.c -std=c99 test.c -lm + + +Notes +===== + +This package is designed to cover a small set of frequently used HEALPix-related +routines, which are implemented with a focus on robustness, accuracy and clarity. +They have good performance, but are not heavily tuned to keep the code simple - +the full-featured, GPL-licensed Healpix Fortran and C++ libraries (available +via the link above) will be more efficient in most situations. + +The motivation for this package is to give developers of BSD-licensed +Healpix-related codes a reliable starting point, so that they don't have to +re-implement the central algorithms (like conversions between pixel index and +angular coordinates), which are nontrivial to do correctly for all corner cases. + + +Acknowledgements +================ + +If you are using this code in your own packages, please consider citing +the original paper in your publications: + +K.M. Gorski et al., 2005, Ap.J., 622, p.759 +(http://adsabs.harvard.edu/abs/2005ApJ...622..759G) diff --git a/earth2grid/third_party/healpix_bare/healpix_bare.c b/earth2grid/third_party/healpix_bare/healpix_bare.c new file mode 100644 index 0000000..bb01aea --- /dev/null +++ b/earth2grid/third_party/healpix_bare/healpix_bare.c @@ -0,0 +1,310 @@ +/* ----------------------------------------------------------------------------- + * + * Copyright (C) 1997-2019 Krzysztof M. Gorski, Eric Hivon, Martin Reinecke, + * Benjamin D. Wandelt, Anthony J. Banday, + * Matthias Bartelmann, + * Reza Ansari & Kenneth M. Ganga + * + * Implementation of the Healpix bare bones C library + * + * Licensed under a 3-clause BSD style license - see LICENSE + * + * For more information on HEALPix and additional software packages, see + * https://healpix.sourceforge.io/ + * + * If you are using this code in your own packages, please consider citing + * the original paper in your publications: + * K.M. Gorski et al., 2005, Ap.J., 622, p.759 + * (http://adsabs.harvard.edu/abs/2005ApJ...622..759G) + * + *----------------------------------------------------------------------------*/ + +#include +#include "healpix_bare.h" + +const double pi = 3.141592653589793238462643383279502884197; + +static const int jrll[] = { 2,2,2,2,3,3,3,3,4,4,4,4 }; +static const int jpll[] = { 1,3,5,7,0,2,4,6,1,3,5,7 }; + +/* conversions between continuous coordinate systems */ + +typedef struct { double z, s, phi; } tloc; + +/*! A structure describing the continuous Healpix coordinate system. + \a f takes values in [0;11], \a x and \a y lie in [0.0; 1.0]. */ +typedef struct { double x, y; int32_t f; } t_hpc; + +static t_hpc loc2hpc (tloc loc) + { + double za = fabs(loc.z); + double x = loc.phi*(1./(2.*pi)); + if (x<0.) + x += (int64_t)x + 1.; + else if (x>=1.) + x -= (int64_t)x; + double tt = 4.*x; + + if (za<=2./3.) /* Equatorial region */ + { + double temp1 = 0.5+tt; // [0.5; 4.5) + double temp2 = loc.z*0.75; // [-0.5; +0.5] + double jp = temp1-temp2; /* index of ascending edge line */ // [0; 5) + double jm = temp1+temp2; /* index of descending edge line */ // [0; 5) + int ifp = (int)jp; /* in {0,4} */ + int ifm = (int)jm; + return (t_hpc){jm-ifm, 1+ifp - jp, + (ifp==ifm) ? (ifp|4) : ((ifp=4) ntt=3; + double tp = tt-ntt; // [0;1) + double tmp = loc.s/sqrt((1.+za)*(1./3.)); // FIXME optimize! + + double jp = tp*tmp; /* increasing edge line index */ + double jm = (1.0-tp)*tmp; /* decreasing edge line index */ + if (jp>1.) jp = 1.; /* for points too close to the boundary */ + if (jm>1.) jm = 1.; + return (loc.z >= 0) ? (t_hpc){1.-jm, 1.-jp, ntt} + : (t_hpc){jp, jm, ntt+8}; + } + +static tloc hpc2loc (t_hpc hpc) + { + double jr = jrll[hpc.f] - hpc.x - hpc.y; + if (jr<1.) + { + double tmp = jr*jr*(1./3.); + double z = 1. - tmp; + double s = sqrt(tmp*(2.-tmp)); + double phi = (pi*0.25)*(jpll[hpc.f] + (hpc.x-hpc.y)/jr); + return (tloc){z,s,phi}; + } + else if (jr>3.) + { + jr = 4.-jr; + double tmp = jr*jr*(1./3.); + double z = tmp - 1.; + double s = sqrt(tmp*(2.-tmp)); + double phi = (pi*0.25)*(jpll[hpc.f] + (hpc.x-hpc.y)/jr); + return (tloc){z,s,phi}; + } + else + { + double z = (2.-jr)*(2./3.); + double s = sqrt((1.+z)*(1.-z)); + double phi = (pi*0.25)*(jpll[hpc.f] + hpc.x - hpc.y); + return (tloc){z,s,phi}; + } + } + +static tloc ang2loc(t_ang ang) + { + double cth=cos(ang.theta), sth=sin(ang.theta); + if (sth<0) { sth=-sth; ang.phi+=pi; } + return (tloc){cth, sth, ang.phi}; + } + +static t_ang loc2ang(tloc loc) + { return (t_ang){atan2(loc.s,loc.z), loc.phi}; } + +static tloc vec2loc(t_vec vec) + { + double vlen=sqrt(vec.x*vec.x+vec.y*vec.y+vec.z*vec.z); + double cth = vec.z/vlen; + double sth = sqrt(vec.x*vec.x+vec.y*vec.y)/vlen; + return (tloc){cth,sth,atan2(vec.y,vec.x)}; + } + +static t_vec loc2vec(tloc loc) + { return (t_vec){loc.s*cos(loc.phi), loc.s*sin(loc.phi), loc.z}; } + +t_vec ang2vec(t_ang ang) + { return loc2vec(ang2loc(ang)); } + +t_ang vec2ang(t_vec vec) + { + return (t_ang) {atan2(sqrt(vec.x*vec.x+vec.y*vec.y),vec.z), + atan2(vec.y,vec.x)}; + } + +/* conversions between discrete coordinate systems */ + +static int64_t isqrt(int64_t v) + { + int64_t res = sqrt(v+0.5); + if (v<((int64_t)(1)<<50)) return res; + if (res*res>v) + --res; + else if ((res+1)*(res+1)<=v) + ++res; + return res; + } + +static int64_t spread_bits (int64_t v) + { + int64_t res = v & 0xffffffff; + res = (res^(res<<16)) & 0x0000ffff0000ffff; + res = (res^(res<< 8)) & 0x00ff00ff00ff00ff; + res = (res^(res<< 4)) & 0x0f0f0f0f0f0f0f0f; + res = (res^(res<< 2)) & 0x3333333333333333; + res = (res^(res<< 1)) & 0x5555555555555555; + return res; + } + +static int64_t compress_bits (int64_t v) + { + int64_t res = v & 0x5555555555555555; + res = (res^(res>> 1)) & 0x3333333333333333; + res = (res^(res>> 2)) & 0x0f0f0f0f0f0f0f0f; + res = (res^(res>> 4)) & 0x00ff00ff00ff00ff; + res = (res^(res>> 8)) & 0x0000ffff0000ffff; + res = (res^(res>>16)) & 0x00000000ffffffff; + return res; + } + +/*! A structure describing the discrete Healpix coordinate system. + \a f takes values in [0;11], \a x and \a y lie in [0; nside[. */ +typedef struct { int64_t x, y; int32_t f; } t_hpd; + +static int64_t hpd2nest (int64_t nside, t_hpd hpd) + { return (hpd.f*nside*nside) + spread_bits(hpd.x) + (spread_bits(hpd.y)<<1); } + +static t_hpd nest2hpd (int64_t nside, int64_t pix) + { + int64_t npface_=nside*nside, p2=pix&(npface_-1); + return (t_hpd){compress_bits(p2), compress_bits(p2>>1), pix/npface_}; + } + +static int64_t hpd2ring (int64_t nside_, t_hpd hpd) + { + int64_t nl4 = 4*nside_; + int64_t jr = (jrll[hpd.f]*nside_) - hpd.x - hpd.y - 1; + + if (jrnl4) ? jp-nl4 : ((jp<1) ? jp+nl4 : jp); + return 2*jr*(jr-1) + jp - 1; + } + else if (jr > 3*nside_) + { + jr = nl4-jr; + int64_t jp = (jpll[hpd.f]*jr + hpd.x - hpd.y + 1) / 2; + jp = (jp>nl4) ? jp-nl4 : ((jp<1) ? jp+nl4 : jp); + return 12*nside_*nside_ - 2*(jr+1)*jr + jp - 1; + } + else + { + int64_t jp = (jpll[hpd.f]*nside_ + hpd.x - hpd.y + 1 + ((jr-nside_)&1)) / 2; + jp = (jp>nl4) ? jp-nl4 : ((jp<1) ? jp+nl4 : jp); + return 2*nside_*(nside_-1) + (jr-nside_)*nl4 + jp - 1; + } + } + +static t_hpd ring2hpd (int64_t nside_, int64_t pix) + { + int64_t ncap_=2*nside_*(nside_-1); + int64_t npix_=12*nside_*nside_; + + if (pix>1; /* counted from North pole */ + int64_t iphi = (pix+1) - 2*iring*(iring-1); + int64_t face = (iphi-1)/iring; + int64_t irt = iring - (jrll[face]*nside_) + 1; + int64_t ipt = 2*iphi- jpll[face]*iring -1; + if (ipt>=2*nside_) ipt-=8*nside_; + return (t_hpd) {(ipt-irt)>>1, (-(ipt+irt))>>1, face}; + } + else if (pix<(npix_-ncap_)) /* Equatorial region */ + { + int64_t ip = pix - ncap_; + int64_t iring = (ip/(4*nside_)) + nside_; /* counted from North pole */ + int64_t iphi = (ip%(4*nside_)) + 1; + int64_t kshift = (iring+nside_)&1; + int64_t ire = iring-nside_+1; + int64_t irm = 2*nside_+2-ire; + int64_t ifm = (iphi - ire/2 + nside_ -1) / nside_; + int64_t ifp = (iphi - irm/2 + nside_ -1) / nside_; + int64_t face = (ifp==ifm) ? (ifp|4) : ((ifp=2*nside_) ipt-=8*nside_; + return (t_hpd) {(ipt-irt)>>1, (-(ipt+irt))>>1, face}; + } + else /* South Polar cap */ + { + int64_t ip = npix_ - pix; + int64_t iring = (1+isqrt(2*ip-1))>>1; /* counted from South pole */ + int64_t iphi = 4*iring + 1 - (ip - 2*iring*(iring-1)); + int64_t face=8+(iphi-1)/iring; + int64_t irt = 4*nside_ - iring - (jrll[face]*nside_) + 1; + int64_t ipt = 2*iphi- jpll[face]*iring -1; + if (ipt>=2*nside_) ipt-=8*nside_; + return (t_hpd) {(ipt-irt)>>1, (-(ipt+irt))>>1, face}; + } + } + +int64_t nest2ring(int64_t nside, int64_t ipnest) + { + if ((nside&(nside-1))!=0) return -1; + return hpd2ring (nside, nest2hpd (nside, ipnest)); + } + +int64_t ring2nest(int64_t nside, int64_t ipring) + { + if ((nside&(nside-1))!=0) return -1; + return hpd2nest (nside, ring2hpd (nside, ipring)); + } + +/* mixed conversions */ + +static t_hpd loc2hpd (int64_t nside_, tloc loc) + { + t_hpc tmp = loc2hpc(loc); + return (t_hpd){(tmp.x*nside_), (tmp.y*nside_), tmp.f}; + } + +static tloc hpd2loc (int64_t nside_, t_hpd hpd) + { + double xns = 1./nside_; + t_hpc tmp = (t_hpc){(hpd.x+0.5)*xns,(hpd.y+0.5)*xns,hpd.f}; + return hpc2loc(tmp); + } + +int64_t npix2nside(int64_t npix) + { + int64_t res = isqrt(npix/12); + return (res*res*12==npix) ? res : -1; + } + +int64_t nside2npix(int64_t nside) + { return 12*nside*nside; } + +double vec_angle(t_vec v1, t_vec v2) + { + t_vec cross = { v1.y*v2.z - v1.z*v2.y, + v1.z*v2.x - v1.x*v2.z, + v1.x*v2.y - v1.y*v2.x }; + double len_cross = sqrt(cross.x*cross.x + cross.y*cross.y + cross.z*cross.z); + double dot = v1.x*v2.x + v1.y*v2.y + v1.z*v2.z; + return atan2 (len_cross, dot); + } + +int64_t ang2ring(int64_t nside, t_ang ang) + { return hpd2ring(nside, loc2hpd(nside, ang2loc(ang))); } +int64_t ang2nest(int64_t nside, t_ang ang) + { return hpd2nest(nside, loc2hpd(nside, ang2loc(ang))); } +int64_t vec2ring(int64_t nside, t_vec vec) + { return hpd2ring(nside, loc2hpd(nside, vec2loc(vec))); } +int64_t vec2nest(int64_t nside, t_vec vec) + { return hpd2nest(nside, loc2hpd(nside, vec2loc(vec))); } +t_ang ring2ang(int64_t nside, int64_t ipix) + { return loc2ang(hpd2loc(nside, ring2hpd(nside, ipix))); } +t_ang nest2ang(int64_t nside, int64_t ipix) + { return loc2ang(hpd2loc(nside, nest2hpd(nside, ipix))); } +t_vec ring2vec(int64_t nside, int64_t ipix) + { return loc2vec(hpd2loc(nside, ring2hpd(nside, ipix))); } +t_vec nest2vec(int64_t nside, int64_t ipix) + { return loc2vec(hpd2loc(nside, nest2hpd(nside, ipix))); } diff --git a/earth2grid/third_party/healpix_bare/healpix_bare.h b/earth2grid/third_party/healpix_bare/healpix_bare.h new file mode 100644 index 0000000..2314171 --- /dev/null +++ b/earth2grid/third_party/healpix_bare/healpix_bare.h @@ -0,0 +1,118 @@ +/* ----------------------------------------------------------------------------- + * + * Copyright (C) 1997-2019 Krzysztof M. Gorski, Eric Hivon, Martin Reinecke, + * Benjamin D. Wandelt, Anthony J. Banday, + * Matthias Bartelmann, + * Reza Ansari & Kenneth M. Ganga + * + * Public interface of the Healpix bare bones C library + * + * Licensed under a 3-clause BSD style license - see LICENSE + * + * For more information on HEALPix and additional software packages, see + * https://healpix.sourceforge.io/ + * + * If you are using this code in your own packages, please consider citing + * the original paper in your publications: + * K.M. Gorski et al., 2005, Ap.J., 622, p.759 + * (http://adsabs.harvard.edu/abs/2005ApJ...622..759G) + * + *----------------------------------------------------------------------------*/ + +#ifndef HEALPIX_BARE_H +#define HEALPIX_BARE_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* This code is written in C99; you may have to adjust your compiler flags. */ + +/* Continuous coordinate systems */ + +/* Admissible values for theta (definition see below) + 0 <= theta <= pi + + Admissible values for phi (definition see below) + In principle unconstrained, but best accuracy is obtained for + -2*pi <= phi <= 2*pi */ + +/*! A structure describing a location on the sphere. \a Theta is the co-latitude + in radians (0 at the North Pole, increasing to pi at the South Pole. + \a Phi is the azimuth in radians. */ +typedef struct { double theta, phi; } t_ang; +/*! A structure describing a 3-vector with coordinates \a x, \a y and \a z.*/ +typedef struct { double x, y, z; } t_vec; + +/*! Returns a normalized 3-vector pointing in the same direction as \a ang. */ +t_vec ang2vec(t_ang ang); +/*! Returns a t_ang describing the same direction as the 3-vector \a vec. + \a vec need not be normalized. */ +t_ang vec2ang(t_vec vec); + + +/* Discrete coordinate systems */ + +/* Admissible values for nside parameters: + any integer power of 2 with 1 <= nside <= 1<<29 + + Admissible values for pixel indices: + 0 <= idx < 12*nside*nside */ + +/*! Returns the RING pixel index of pixel \a ipnest at resolution \a nside. + On error, returns -1. */ +int64_t nest2ring(int64_t nside, int64_t ipnest); +/*! Returns the NEST pixel index of pixel \a ipring at resolution \a nside. + On error, returns -1. */ +int64_t ring2nest(int64_t nside, int64_t ipring); + + +/* Conversions between continuous and discrete coordinate systems */ + +/*! Returns the pixel number in NEST scheme at resolution \a nside, + which contains the position \a ang. */ +int64_t ang2nest(int64_t nside, t_ang ang); +/*! Returns the pixel number in RING scheme at resolution \a nside, + which contains the position \a ang. */ +int64_t ang2ring(int64_t nside, t_ang ang); + +/*! Returns a t_ang corresponding to the angular position of the center of + pixel \a ipix in NEST scheme at resolution \a nside. */ +t_ang nest2ang(int64_t nside, int64_t ipix); +/*! Returns a t_ang corresponding to the angular position of the center of + pixel \a ipix in RING scheme at resolution \a nside. */ +t_ang ring2ang(int64_t nside, int64_t ipix); + +/*! Returns the pixel number in NEST scheme at resolution \a nside, + which contains the direction described the 3-vector \a vec. */ +int64_t vec2nest(int64_t nside, t_vec vec); +/*! Returns the pixel number in RING scheme at resolution \a nside, + which contains the direction described the 3-vector \a vec. */ +int64_t vec2ring(int64_t nside, t_vec vec); + +/*! Returns a normalized 3-vector pointing in the direction of the center + of pixel \a ipix in NEST scheme at resolution \a nside. */ +t_vec nest2vec(int64_t nside, int64_t ipix); +/*! Returns a normalized 3-vector pointing in the direction of the center + of pixel \a ipix in RING scheme at resolution \a nside. */ +t_vec ring2vec(int64_t nside, int64_t ipix); + + +/* Miscellaneous utility routines */ + +/*! Returns \a 12*nside*nside. */ +int64_t nside2npix(int64_t nside); +/*! Returns \a sqrt(npix/12) if this is an integer number, otherwise \a -1. */ +int64_t npix2nside(int64_t npix); + +/*! Returns the angle (in radians) between the vectors \a v1 and \a v2. + The result is accurate even for angles close to 0 and pi. */ +double vec_angle(t_vec v1, t_vec v2); + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* HEALPIX_REFERENCE_H */ diff --git a/earth2grid/third_party/healpix_bare/test.c b/earth2grid/third_party/healpix_bare/test.c new file mode 100644 index 0000000..048a68a --- /dev/null +++ b/earth2grid/third_party/healpix_bare/test.c @@ -0,0 +1,129 @@ +/* ----------------------------------------------------------------------------- + * + * Copyright (C) 1997-2019 Krzysztof M. Gorski, Eric Hivon, Martin Reinecke, + * Benjamin D. Wandelt, Anthony J. Banday, + * Matthias Bartelmann, + * Reza Ansari & Kenneth M. Ganga + * + * Test for the Healpix bare bones C library + * + * Licensed under a 3-clause BSD style license - see LICENSE + * + * For more information on HEALPix and additional software packages, see + * https://healpix.sourceforge.io/ + * + * If you are using this code in your own packages, please consider citing + * the original paper in your publications: + * K.M. Gorski et al., 2005, Ap.J., 622, p.759 + * (http://adsabs.harvard.edu/abs/2005ApJ...622..759G) + * + *----------------------------------------------------------------------------*/ + +#include +#include +#include +#include +#include + +#include "healpix_bare.h" + +static void error_check(int64_t nside, int64_t ipix, int64_t ip1) + { + if (ip1 != ipix) { + printf("Error0: %" PRId64 " %" PRId64 " %" PRId64 "\n", nside, ipix, ip1); + abort(); + } + } + +static void test2_helper1 (int64_t nside, int64_t dpix, int64_t epix) + { + /* Find the number of pixels in the full map */ + for (int64_t ipix = 0; ipix < epix; ipix +=dpix) + error_check(nside, ipix, ring2nest(nside, nest2ring(nside, ipix))); + for (int64_t ipix = 0; ipix < epix; ipix +=dpix) + error_check(nside, ipix, ang2nest(nside, nest2ang(nside, ipix))); + for (int64_t ipix = 0; ipix < epix; ipix +=dpix) + error_check(nside, ipix, vec2nest(nside, nest2vec(nside, ipix))); + } + +static void test2_helper2 (int64_t nside, int64_t dpix) + { + /* Find the number of pixels in the full map */ + int64_t npix = nside2npix(nside); + for (int64_t ipix = 0; ipix < npix; ipix +=dpix) + error_check(nside, ipix, ang2ring(nside, ring2ang(nside, ipix))); + for (int64_t ipix = 0; ipix < npix; ipix +=dpix) + error_check(nside, ipix, vec2ring(nside, ring2vec(nside, ipix))); + } + +static void test2(void) { + printf("Starting basic C Healpix pixel routines test\n"); + + for (int i=0; i<=29; ++i) + { + int64_t nside = 1LL<12*nside*nside) epix = 12*nside*nside; + printf("Nside: %" PRId64 "\n",nside); + test2_helper1(nside,nside*nside/123456+1, npix); + test2_helper1(nside, 1, epix); + test2_helper2(nside,nside*nside/123456+1); + } + + printf("test completed\n\n"); +} + +static void test3(void) { + printf("Starting nontrivial C Healpix pixel routines test\n"); + + int64_t nside = 1024; + int64_t dpix = 23; + + /* Find the number of pixels in the full map */ + int64_t npix = nside2npix(nside); + printf("Number of pixels in full map: %ld\n", npix); + + printf("dpix: %ld\n", dpix); + printf("Nest -> ang -> vec -> ang -> Ring -> Nest\n"); + for (int64_t ipix = 0; ipix < npix; ipix +=dpix) + error_check(nside, ipix, + ring2nest(nside, ang2ring(nside, vec2ang(ang2vec(nest2ang(nside, ipix)))))); + printf("Ring -> ang -> Nest -> Ring\n"); + for (int64_t ipix = 0; ipix < npix; ipix +=dpix) + error_check(nside, ipix, + nest2ring(nside,ang2nest(nside, ring2ang(nside, ipix)))); + + printf("Nest -> vec -> Ring -> Nest\n"); + for (int64_t ipix = 0; ipix < npix; ipix +=dpix) + error_check(nside, ipix, + ring2nest(nside,vec2ring(nside,nest2vec(nside, ipix)))); + printf("Ring -> vec -> Nest -> Ring\n"); + for (int64_t ipix = 0; ipix < npix; ipix +=dpix) + error_check(nside, ipix, + nest2ring(nside,vec2nest(nside,ring2vec(nside, ipix)))); + + printf("%" PRId64 "\n", nside); + printf("test completed\n\n"); +} + +static void test_ang(void) { + printf("Starting vector angle test\n"); + for (double i=0; i<10; i+=0.01) { + t_vec v1 = { i,10-i,sin(i) }, v2 = { 3-1.5*i, sqrt(i), i*0.3 }; + double dot = v1.x*v2.x + v1.y*v2.y + v1.z*v2.z, + l1 = sqrt(v1.x*v1.x+v1.y*v1.y+v1.z*v1.z), + l2 = sqrt(v2.x*v2.x+v2.y*v2.y+v2.z*v2.z); + double ang = acos(dot/(l1*l2)); + if (fabs(ang - vec_angle(v1,v2))>1e-13) + printf("error in vector angle test: %e %e\n", ang, ang-vec_angle(v1, v2)); + } + printf("test completed\n\n"); +} + +int main(void) { + test2(); + test3(); + test_ang(); + return 0; +} diff --git a/earth2grid/third_party/zephyr/LICENSE b/earth2grid/third_party/zephyr/LICENSE new file mode 100644 index 0000000..78dd216 --- /dev/null +++ b/earth2grid/third_party/zephyr/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Jonathan Weyn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/earth2grid/third_party/zephyr/healpix.py b/earth2grid/third_party/zephyr/healpix.py new file mode 100644 index 0000000..c322afc --- /dev/null +++ b/earth2grid/third_party/zephyr/healpix.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 + +""" +This file contains padding and convolution classes to perform according operations on the twelve faces of the HEALPix. + + + HEALPix Face order 3D array representation + ----------------- +-------------------------- //\\ //\\ //\\ //\\ | | | | | +|| 0 | 1 | 2 | 3 || // \\// \\// \\// \\ |0 |1 |2 |3 | +|\\ //\\ //\\ //\\ //| /\\0 //\\1 //\\2 //\\3 // ----------------- +| \\// \\// \\// \\// | // \\// \\// \\// \\// | | | | | +|4//\\5 //\\6 //\\7 //\\4| \\4//\\5 //\\6 //\\7 //\\ |4 |5 |6 |7 | +|// \\// \\// \\// \\| \\/ \\// \\// \\// \\ ----------------- +|| 8 | 9 | 10 | 11 | \\8 //\\9 //\\10//\\11// | | | | | +-------------------------- \\// \\// \\// \\// |8 |9 |10 |11 | + ----------------- + "\\" are top and bottom, whereas + "//" are left and right borders + + +Details on the HEALPix can be found at https://iopscience.iop.org/article/10.1086/427976 + +""" + +import sys + +import torch +import torch as th + +sys.path.append('/home/disk/quicksilver/nacc/dlesm/HealPixPad') +have_healpixpad = False +try: + from healpixpad import HEALPixPad # noqa + + have_healpixpad = True +except ImportError: + print("Warning, cannot find healpixpad module") + have_healpixpad = False + + +def healpix_pad(x: torch.Tensor, padding: int, enable_nhwc: bool = False) -> torch.Tensor: + """ + Pad each face consistently with its according neighbors in the HEALPix (see ordering and neighborhoods above). + + :param data: The input tensor of shape [N, F, H, W] where each face is to be padded in its HPX context + :return: The padded tensor where each face's height and width are increased by 2*p + """ + if padding == 0: + return x + + padder = HEALPixPadding(padding, enable_nhwc) + return padder.pad(x) + + +class HEALPixPadding(th.nn.Module): + """ + Padding layer for data on a HEALPix sphere. The requirements for using this layer are as follows: + - The last three dimensions are (face=12, height, width) + - The first four indices in the faces dimension [0, 1, 2, 3] are the faces on the northern hemisphere + - The second four indices in the faces dimension [4, 5, 6, 7] are the faces on the equator + - The last four indices in the faces dimension [8, 9, 10, 11] are the faces on the southern hemisphere + + Orientation and arrangement of the HEALPix faces are outlined above. + """ + + def __init__(self, padding: int, enable_nhwc: bool = False): + """ + Constructor for a HEALPix padding layer. + + :param padding: The padding size + """ + super().__init__() + self.p = padding + self.d = [-2, -1] + self.ret_tl = th.zeros(1, 1, 1) + self.ret_br = th.zeros(1, 1, 1) + self.enable_nhwc = enable_nhwc + if not isinstance(padding, int) or padding < 1: + raise ValueError(f"invalid value for 'padding', expected int > 0 but got {padding}") + + def pad(self, data: th.Tensor) -> th.Tensor: + """ + Pad each face consistently with its according neighbors in the HEALPix (see ordering and neighborhoods above). + + :param data: The input tensor of shape [..., F, H, W] where each face is to be padded in its HPX context + :return: The padded tensor where each face's height and width are increased by 2*p + """ + # Extract the twelve faces (as views of the original tensors) + f00, f01, f02, f03, f04, f05, f06, f07, f08, f09, f10, f11 = [ + torch.squeeze(x, dim=1) for x in th.split(tensor=data, split_size_or_sections=1, dim=1) + ] + + # Assemble the four padded faces on the northern hemisphere + p00 = self.pn(c=f00, t=f01, tl=f02, l=f03, bl=f03, b=f04, br=f08, r=f05, tr=f01) + p01 = self.pn(c=f01, t=f02, tl=f03, l=f00, bl=f00, b=f05, br=f09, r=f06, tr=f02) + p02 = self.pn(c=f02, t=f03, tl=f00, l=f01, bl=f01, b=f06, br=f10, r=f07, tr=f03) + p03 = self.pn(c=f03, t=f00, tl=f01, l=f02, bl=f02, b=f07, br=f11, r=f04, tr=f00) + + # Assemble the four padded faces on the equator + p04 = self.pe(c=f04, t=f00, tl=self.tl(f00, f03), l=f03, bl=f07, b=f11, br=self.br(f11, f08), r=f08, tr=f05) + p05 = self.pe(c=f05, t=f01, tl=self.tl(f01, f00), l=f00, bl=f04, b=f08, br=self.br(f08, f09), r=f09, tr=f06) + p06 = self.pe(c=f06, t=f02, tl=self.tl(f02, f01), l=f01, bl=f05, b=f09, br=self.br(f09, f10), r=f10, tr=f07) + p07 = self.pe(c=f07, t=f03, tl=self.tl(f03, f02), l=f02, bl=f06, b=f10, br=self.br(f10, f11), r=f11, tr=f04) + + # Assemble the four padded faces on the southern hemisphere + p08 = self.ps(c=f08, t=f05, tl=f00, l=f04, bl=f11, b=f11, br=f10, r=f09, tr=f09) + p09 = self.ps(c=f09, t=f06, tl=f01, l=f05, bl=f08, b=f08, br=f11, r=f10, tr=f10) + p10 = self.ps(c=f10, t=f07, tl=f02, l=f06, bl=f09, b=f09, br=f08, r=f11, tr=f11) + p11 = self.ps(c=f11, t=f04, tl=f03, l=f07, bl=f10, b=f10, br=f09, r=f08, tr=f08) + + res = th.stack((p00, p01, p02, p03, p04, p05, p06, p07, p08, p09, p10, p11), dim=1) + + return res + + def pn( + self, + c: th.Tensor, + t: th.Tensor, + tl: th.Tensor, + l: th.Tensor, + bl: th.Tensor, + b: th.Tensor, + br: th.Tensor, + r: th.Tensor, + tr: th.Tensor, + ) -> th.Tensor: + """ + Applies padding to a northern hemisphere face c under consideration of its given neighbors. + + :param c: The central face and tensor that is subject for padding + :param t: The top neighboring face tensor + :param tl: The top left neighboring face tensor + :param l: The left neighboring face tensor + :param bl: The bottom left neighboring face tensor + :param b: The bottom neighboring face tensor + :param br: The bottom right neighboring face tensor + :param r: The right neighboring face tensor + :param tr: The top right neighboring face tensor + :return: The padded tensor p + """ + p = self.p # Padding size + d = self.d # Dimensions for rotations + + # Start with top and bottom to extend the height of the c tensor + c = th.cat((t.rot90(1, d)[..., -p:, :], c, b[..., :p, :]), dim=-2) + + # Construct the left and right pads including the corner faces + left = th.cat((tl.rot90(2, d)[..., -p:, -p:], l.rot90(-1, d)[..., -p:], bl[..., :p, -p:]), dim=-2) + right = th.cat((tr[..., -p:, :p], r[..., :p], br[..., :p, :p]), dim=-2) + + return th.cat((left, c, right), dim=-1) + + def pe( + self, + c: th.Tensor, + t: th.Tensor, + tl: th.Tensor, + l: th.Tensor, + bl: th.Tensor, + b: th.Tensor, + br: th.Tensor, + r: th.Tensor, + tr: th.Tensor, + ) -> th.Tensor: + """ + Applies padding to an equatorial face c under consideration of its given neighbors. + + :param c: The central face and tensor that is subject for padding + :param t: The top neighboring face tensor + :param tl: The top left neighboring face tensor + :param l: The left neighboring face tensor + :param bl: The bottom left neighboring face tensor + :param b: The bottom neighboring face tensor + :param br: The bottom right neighboring face tensor + :param r: The right neighboring face tensor + :param tr: The top right neighboring face tensor + :return: The padded tensor p + """ + p = self.p # Padding size + + # Start with top and bottom to extend the height of the c tensor + c = th.cat((t[..., -p:, :], c, b[..., :p, :]), dim=-2) + + # Construct the left and right pads including the corner faces + left = th.cat((tl[..., -p:, -p:], l[..., -p:], bl[..., :p, -p:]), dim=-2) + right = th.cat((tr[..., -p:, :p], r[..., :p], br[..., :p, :p]), dim=-2) + + return th.cat((left, c, right), dim=-1) + + def ps( + self, + c: th.Tensor, + t: th.Tensor, + tl: th.Tensor, + l: th.Tensor, + bl: th.Tensor, + b: th.Tensor, + br: th.Tensor, + r: th.Tensor, + tr: th.Tensor, + ) -> th.Tensor: + """ + Applies padding to a southern hemisphere face c under consideration of its given neighbors. + + :param c: The central face and tensor that is subject for padding + :param t: The top neighboring face tensor + :param tl: The top left neighboring face tensor + :param l: The left neighboring face tensor + :param bl: The bottom left neighboring face tensor + :param b: The bottom neighboring face tensor + :param br: The bottom right neighboring face tensor + :param r: The right neighboring face tensor + :param tr: The top right neighboring face tensor + :return: The padded tensor p + """ + p = self.p # Padding size + d = self.d # Dimensions for rotations + + # Start with top and bottom to extend the height of the c tensor + c = th.cat((t[..., -p:, :], c, b.rot90(1, d)[..., :p, :]), dim=-2) + + # Construct the left and right pads including the corner faces + left = th.cat((tl[..., -p:, -p:], l[..., -p:], bl[..., :p, -p:]), dim=-2) + right = th.cat((tr[..., -p:, :p], r.rot90(-1, d)[..., :p], br.rot90(2, d)[..., :p, :p]), dim=-2) + + return th.cat((left, c, right), dim=-1) + + def tl(self, t: th.Tensor, l: th.Tensor) -> th.Tensor: # noqa + """ + Assembles the top left corner of a center face in the cases where no according top left face is defined on the + HPX. + + :param t: The face above the center face + :param l: The face left of the center face + :return: The assembled top left corner (only the sub-part that is required for padding) + """ + # ret = th.zeros((*t.shape[:-2], self.p, self.p), dtype=t.dtype, device=t.device) + ret = th.zeros_like(t)[..., : self.p, : self.p] # super ugly but super fast + # tc = t[..., :self.p, :self.p] + # lc = l[..., :self.p, :self.p] + # td = torch.diag_embed(torch.diagonal(tc, dim1=-2, dim2=-1, offset=1), dim1=-2, dim2=-1) + # ld = torch.diag_embed(torch.diagonal(lc, dim1=-2, dim2=-1, offset=-1), dim1=-2, dim2=-1) + # ret2 = torch.triu(tc, diagonal = 1) + torch.tril(lc, diagonal = -1) + 0.5 * (td + ld) + + # Bottom left point + ret[..., -1, -1] = 0.5 * t[..., -1, 0] + 0.5 * l[..., 0, -1] + + # Remaining points + for i in range(1, self.p): + ret[..., -i - 1, -i:] = t[..., -i - 1, :i] # Filling top right above main diagonal + ret[..., -i:, -i - 1] = l[..., :i, -i - 1] # Filling bottom left below main diagonal + ret[..., -i - 1, -i - 1] = 0.5 * t[..., -i - 1, 0] + 0.5 * l[..., 0, -i - 1] # Diagonal + + # print("ALL GOOD", torch.allclose(ret, ret2), ret[0,0,...], ret2[0,0,...]) + # sys.exit(1) + + return ret + + def br(self, b: th.Tensor, r: th.Tensor) -> th.Tensor: + """ + Assembles the bottom right corner of a center face in the cases where no according bottom right face is defined + on the HPX. + + :param b: The face below the center face + :param r: The face right of the center face + :return: The assembled bottom right corner (only the sub-part that is required for padding) + """ + # ret = th.zeros((*b.shape[:-2], self.p, self.p), dtype=b.dtype, device=b.device) + ret = th.zeros_like(b)[..., : self.p, : self.p] + + # Top left point + ret[..., 0, 0] = 0.5 * b[..., 0, -1] + 0.5 * r[..., -1, 0] + + # Remaining points + for i in range(1, self.p): + ret[..., :i, i] = r[..., -i:, i] # Filling top right above main diagonal + ret[..., i, :i] = b[..., i, -i:] # Filling bottom left below main diagonal + ret[..., i, i] = 0.5 * b[..., i, -1] + 0.5 * r[..., -1, i] # Diagonal + + return ret diff --git a/earth2grid/third_party/zephyr/test_healpix_pad.py b/earth2grid/third_party/zephyr/test_healpix_pad.py new file mode 100644 index 0000000..70a3654 --- /dev/null +++ b/earth2grid/third_party/zephyr/test_healpix_pad.py @@ -0,0 +1,13 @@ +import torch + +from earth2grid.third_party.zephyr.healpix import healpix_pad + + +def test_healpix_pad(): + ntile = 12 + nside = 32 + padding = 1 + n = 3 + x = torch.ones([n, ntile, nside, nside]) + out = healpix_pad(x, padding=padding) + assert out.shape == (n, ntile, nside + padding * 2, nside + padding * 2) diff --git a/examples/sphinx_gallery/README.rst b/examples/sphinx_gallery/README.rst new file mode 100644 index 0000000..32e0929 --- /dev/null +++ b/examples/sphinx_gallery/README.rst @@ -0,0 +1,6 @@ +Examples +======== + +The examples are below + +The examples in misc/ are not included in the Sphinx Gallery. diff --git a/examples/sphinx_gallery/hpx2grid.py b/examples/sphinx_gallery/hpx2grid.py new file mode 100644 index 0000000..42655a2 --- /dev/null +++ b/examples/sphinx_gallery/hpx2grid.py @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +HealPIX Image visualization +--------------------------- + +HealPIX maps can be viewed as a 2D image rotated by 45 deg. This is useful for +quick visualization with image viewers without distorting the native pixels of +the image. +""" + +import matplotlib.pyplot as plt +import numpy as np +import torch + +# %% +from matplotlib.colors import Normalize +from PIL import Image + +from earth2grid.healpix import Grid + +grid = Grid(level=8) +lat = torch.tensor(grid.lat) +lat_img = grid.to_image(lat) + +# Use Image to save at full resolution +normalizer = Normalize(vmin=np.nanmin(lat_img), vmax=np.nanmax(lat_img)) +array = normalizer(lat_img) +array = plt.cm.viridis(array) +array = (256 * array).astype("uint8") +# set transparency for nans +array[..., -1] = np.where(np.isnan(lat_img), 0, 255) +image = Image.fromarray(array) +image.save("hpx_grid.png") diff --git a/examples/sphinx_gallery/latlon_to_healpix.py b/examples/sphinx_gallery/latlon_to_healpix.py new file mode 100644 index 0000000..fa66776 --- /dev/null +++ b/examples/sphinx_gallery/latlon_to_healpix.py @@ -0,0 +1,66 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +HealPIX regridding +------------------ + +In this notebook, I demonstrate bilinear regridding onto healpy grids in O(10) +ms. this is a 3 order of magnitude speed-up compared to what Dale has reported. + +Now, lets define a healpix grid with indexing in the XY convention. we convert +to NEST indexing in order to use the `healpy.pix2ang` to get the lat lon +coordinates. This operation is near instant. + +""" +# %% + +import matplotlib.pyplot as plt +import numpy as np +import torch + +import earth2grid + +# level is the resolution +level = 6 +hpx = earth2grid.healpix.Grid(level=level, pixel_order=earth2grid.healpix.XY()) +src = earth2grid.latlon.equiangular_lat_lon_grid(32, 64) +regrid = earth2grid.get_regridder(src, hpx) + + +z = np.cos(np.deg2rad(src.lat)) * np.cos(np.deg2rad(src.lon)) + + +z_torch = torch.as_tensor(z) +z_hpx = regrid(z_torch) + +fig, (a, b) = plt.subplots(2, 1) +a.pcolormesh(src.lon, src.lat, z) +a.set_title("Lat Lon Grid") + +b.scatter(hpx.lon, hpx.lat, c=z_hpx, s=0.1) +b.set_title("Healpix") + +# %% one tile +nside = 2**level +reshaped = z_hpx.reshape(12, nside, nside) +lat_r = hpx.lat.reshape(12, nside, nside) +lon_r = hpx.lon.reshape(12, nside, nside) + +tile = 11 +fig, axs = plt.subplots(3, 4, sharex=True, sharey=True) +axs = axs.ravel() + +for tile in range(12): + axs[tile].pcolormesh(lon_r[tile], lat_r[tile], reshaped[tile]) diff --git a/examples/sphinx_gallery/pyvista_grids.py b/examples/sphinx_gallery/pyvista_grids.py new file mode 100644 index 0000000..ce36d59 --- /dev/null +++ b/examples/sphinx_gallery/pyvista_grids.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Plot grids with PyVista +----------------------- + +""" +import pyvista as pv + +# %% +import earth2grid + + +def label(mesh, plotter, text): + """ + Add a label above a mesh in a PyVista plot. + + Parameters: + - mesh: The mesh to label. + - plotter: A PyVista plotter instance. + - text: The label text. + - color: The color of the text label. Default is 'white'. + """ + # Calculate the center of the mesh and the top Z-coordinate plus an offset + center = mesh.center + label_pos = [center[0], center[1], mesh.bounds[5] + 0.5] # Offset to place label above the mesh + + # Add the label using point labels for precise 3D positioning + plotter.add_point_labels( + [label_pos], [text], point_size=0, render_points_as_spheres=False, shape_opacity=0, font_size=20 + ) + + +grid = earth2grid.healpix.Grid(level=4) +hpx = grid.to_pyvista() +latlon = earth2grid.latlon.equiangular_lat_lon_grid(32, 64, includes_south_pole=False).to_pyvista() + + +pl = pv.Plotter() +mesh = hpx.translate([0, 2.5, 0]) +pl.add_mesh(mesh, show_edges=True) +label(mesh, pl, "HealPix") + +pl.add_mesh(latlon, show_edges=True) +label(latlon, pl, "Equiangular Lat/Lon") + +pl.camera.position = (5, 0, 5) +pl.show() diff --git a/makefile b/makefile new file mode 100644 index 0000000..f5cb2a1 --- /dev/null +++ b/makefile @@ -0,0 +1,39 @@ +sources = earth2grid + +.PHONY: test format lint unittest coverage pre-commit clean +test: format lint unittest + +.PHONY: license +license: + python tests/_license/header_check.py + +format: license + isort $(sources) tests + black $(sources) tests + +lint: license + pre-commit run --all-files + +unittest: + coverage run --source earth2grid/ -m pytest + coverage run --source earth2grid/ -a -m pytest --doctest-modules earth2grid/ -vv + +coverage: + coverage report + +pre-commit: + pre-commit run --all-files + +clean: + rm -rf .mypy_cache .pytest_cache + rm -rf *.egg-info + rm -rf .tox dist site + rm -rf coverage.xml .coverage + + +.PHONY: docs +docs: + $(MAKE) -C docs html + +push_docs: docs + docs/push_docs.sh diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..29ff5c1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,89 @@ +[build-system] +requires = ["setuptools>=40.8.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "earth2-grid" +version = "2024.5.1" +description = "Utilities for working with geographic data defined on various grids." +readme = "README.md" +license = { file="LICENSE.txt" } +authors = [ + { name = "NVIDIA", email = "nbrenowitz@nvidia.com" } +] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", +] + +dependencies = [ + "einops>=0.7.0", + "netCDF4>=1.6.5", + "numpy>=1.23.3", + "pyvista>=0.43.2", + "torch>=2.0.1", +] + +[project.urls] +"Homepage" = "https://github.com/waynerv/earth2-grid" + + +[project.optional-dependencies] +test = [ + "pytest>=6.2.4", + "black>=21.5b2", + "isort>=5.8.0", + "mypy>=0.900", + "flake8>=3.9.2", + "flake8-docstrings>=1.6.0", + "pytest-cov>=2.12.0" +] +dev = [ + "tox>=3.20.1", + "pre-commit>=2.12.0", + "virtualenv>=20.2.2", + "pip>=20.3.1", + "twine>=3.3.0", + "toml>=0.10.2", + "bump2version>=1.0.1" +] +doc = [ + "sphinx", + "myst-parser", + "sphinx_gallery", +] + +[tool.black] +line-length = 120 +skip-string-normalization = true +target-version = ['py36', 'py37', 'py38'] +include = '\\.pyi?$' +exclude = ''' +/( + \\.eggs + | \\.git + | \\.hg + | \\.mypy_cache + | \\.tox + | \\.venv + | _build + | buck-out + | build + | dist +)/ +''' + +[tool.isort] +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true +line_length = 120 +skip_gitignore = true diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..51d396b --- /dev/null +++ b/setup.cfg @@ -0,0 +1,94 @@ +[flake8] +max-line-length = 120 +max-complexity = 18 +ignore = E203, E266, W503 +docstring-convention = google +per-file-ignores = __init__.py:F401 +exclude = .git, + __pycache__, + setup.py, + build, + dist, + docs, + releases, + .venv, + .tox, + .mypy_cache, + .pytest_cache, + .vscode, + .github, + # By default test codes will be linted. + # tests + +[mypy] +ignore_missing_imports = True + +[coverage:run] +# uncomment the following to omit files during running +#omit = +[coverage:report] +exclude_lines = + pragma: no cover + def __repr__ + if self.debug: + if settings.DEBUG + raise AssertionError + raise NotImplementedError + if 0: + if __name__ == .__main__.: + def main + +[tox:tox] +isolated_build = true +envlist = py36, py37, py38, py39, format, lint, build + +[gh-actions] +python = + 3.9: py39 + 3.8: py38, format, lint, build + 3.7: py37 + 3.6: py36 + +[testenv] +allowlist_externals = pytest +extras = + test +passenv = * +setenv = + PYTHONPATH = {toxinidir} + PYTHONWARNINGS = ignore +commands = + pytest --cov=earth2grid --cov-branch --cov-report=xml --cov-report=term-missing tests + +[testenv:format] +allowlist_externals = + isort + black +extras = + test +commands = + isort earth2grid + black earth2grid tests + +[testenv:lint] +allowlist_externals = + flake8 + mypy +extras = + test +commands = + flake8 earth2grid tests + mypy earth2grid tests + +[testenv:build] +allowlist_externals = + poetry + mkdocs + twine +extras = + doc + dev +commands = + poetry build + mkdocs build + twine check dist/* diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f8c4e84 --- /dev/null +++ b/setup.py @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import os +import subprocess +from typing import List + +from setuptools import setup +from torch.utils import cpp_extension + + +def get_compiler(): + try: + # Try to get the compiler path from the CC environment variable + # If not set, it will default to gcc (which could be symlinked to clang or g++) + compiler = subprocess.check_output(["gcc", "--version"], universal_newlines=True) + + if "clang" in compiler: + return "clang" + elif "g++" in compiler or "gcc" in compiler: + return "gnu" + else: + return "unknown" + except Exception as e: + print(f"Error detecting compiler: {e}") + return "unknown" + + +compiler_type = get_compiler() +extra_compile_args: List[str] = ["-std=c++20"] + +if compiler_type == "clang": + print("Detected Clang compiler.") + # Additional settings or flags specific to Clang can be added here + extra_compile_args += ["-Wno-error=c++11-narrowing", "-Wno-c++11-narrowing"] +elif compiler_type == "gnu": + print("Detected GNU compiler.") + # Additional settings or flags specific to G++ can be added here +else: + print("Could not detect compiler or unknown compiler detected.") + + +src_files = [ + "earth2grid/csrc/healpix_bare_wrapper.cpp", +] + +setup( + name='earth2grid', + ext_modules=[ + cpp_extension.CppExtension( + 'earth2grid._healpix_bare', + src_files, + extra_compile_args=extra_compile_args, + include_dirs=[os.path.abspath("earth2grid/csrc"), os.path.abspath("earth2grid/third_party/healpix_bare")], + ) + ], + cmdclass={'build_ext': cpp_extension.BuildExtension}, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..edda28f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Unit test package for earth2-grid.""" diff --git a/tests/_license/config.json b/tests/_license/config.json new file mode 100644 index 0000000..bc55e7c --- /dev/null +++ b/tests/_license/config.json @@ -0,0 +1,13 @@ +{ + "copyright_file": "header.txt", + "dir": "../../", + "exclude-dir": [ + "../../build/", + "../../docs/", + "../../earth2grid/third_party", + "." + ], + "include-ext": [ + ".py" + ] + } diff --git a/tests/_license/header.txt b/tests/_license/header.txt new file mode 100644 index 0000000..a08b2c2 --- /dev/null +++ b/tests/_license/header.txt @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/_license/header_check.py b/tests/_license/header_check.py new file mode 100644 index 0000000..a2e12e0 --- /dev/null +++ b/tests/_license/header_check.py @@ -0,0 +1,141 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023 NVIDIA CORPORATION & AFFILIATES. +# SPDX-FileCopyrightText: All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""A script to check that copyright headers exists""" + +import itertools +import json +import re +from datetime import datetime +from pathlib import Path + + +def get_top_comments(_data): + """ + Get all lines where comments should exist + """ + lines_to_extract = [] + for i, line in enumerate(_data): + # If empty line, skip + if line in ["", "\n", "", "\r", "\r\n"]: + continue + # If it is a comment line, we should get it + if line.startswith("#"): + lines_to_extract.append(i) + # Assume all copyright headers occur before any import or from statements + # and not enclosed in a comment block + elif "import" in line: + break + elif "from" in line: + break + + comments = [_data[line] for line in lines_to_extract] + return comments + + +def main(): + + with open(Path(__file__).parent.resolve() / Path("config.json")) as f: + config = json.loads(f.read()) + print("License check config:") + print(json.dumps(config, sort_keys=True, indent=4)) + + current_year = int(datetime.today().year) + starting_year = 2023 + python_header_path = Path(__file__).parent.resolve() / Path(config["copyright_file"]) + working_path = Path(__file__).parent.resolve() / Path(config["dir"]) + exts = config["include-ext"] + + with open(python_header_path, "r", encoding="utf-8") as original: + pyheader = original.read().split("\n") + pyheader_lines = len(pyheader) + + # Build list of files to check + exclude_paths = [(Path(__file__).parent / Path(path)).resolve().rglob("*") for path in config["exclude-dir"]] + all_exclude_paths = itertools.chain.from_iterable(exclude_paths) + exclude_filenames = [p for p in all_exclude_paths if p.suffix in exts] + filenames = [p for p in working_path.resolve().rglob("*") if p.suffix in exts] + filenames = [filename for filename in filenames if filename not in exclude_filenames] + problematic_files = [] + gpl_files = [] + + for filename in filenames: + with open(str(filename), "r", encoding="utf-8") as original: + data = original.readlines() + + data = get_top_comments(data) + if data and "# ignore_header_test" in data[0]: + continue + if len(data) < pyheader_lines - 1: + print(f"{filename} has less header lines than the copyright template") + problematic_files.append(filename) + continue + + found = False + for i, line in enumerate(data): + if re.search(re.compile("Copyright.*NVIDIA.*", re.IGNORECASE), line): + found = True + # Check 1st line manually + year_good = False + for year in range(starting_year, current_year + 1): + year_line = pyheader[0].format(CURRENT_YEAR=year) + if year_line in data[i]: + year_good = True + break + year_line_aff = year_line.split(".") + year_line_aff = year_line_aff[0] + " & AFFILIATES." + year_line_aff[1] + if year_line_aff in data[i]: + year_good = True + break + if not year_good: + problematic_files.append(filename) + print(f"{filename} had an error with the year") + break + # while "opyright" in data[i]: + # i += 1 + # for j in range(1, pyheader_lines): + # if pyheader[j] not in data[i + j - 1]: + # problematic_files.append(filename) + # print(f"{filename} missed the line: {pyheader[j]}") + # break + if found: + break + if not found: + print(f"{filename} did not match the regex: `Copyright.*NVIDIA.*`") + problematic_files.append(filename) + + # test if GPL license exists + for lines in data: + if "gpl" in lines.lower(): + gpl_files.append(filename) + break + + if len(problematic_files) > 0: + print("The following files that might not have a copyright header:") + for _file in problematic_files: + print(_file) + if len(gpl_files) > 0: + print("test_header.py found the following files that might have GPL copyright:") + for _file in gpl_files: + print(_file) + assert len(problematic_files) == 0, "header test failed!" + assert len(gpl_files) == 0, "found gpl license, header test failed!" + + print(f"Success: File headers of {len(filenames)} files look good!") + + +if __name__ == "__main__": + main() diff --git a/tests/_regtest_outputs/test_healpix_bare.test_boundaries.out b/tests/_regtest_outputs/test_healpix_bare.test_boundaries.out new file mode 100644 index 0000000..78eb696 --- /dev/null +++ b/tests/_regtest_outputs/test_healpix_bare.test_boundaries.out @@ -0,0 +1,12 @@ +7.07107e-01 +4.56399e-17 +0.00000e+00 +7.45356e-01 +7.07107e-01 +7.45356e-01 +0.00000e+00 +0.00000e+00 +0.00000e+00 +6.66667e-01 +1.00000e+00 +6.66667e-01 diff --git a/tests/_regtest_outputs/test_healpix_bare.test_hpc2loc.out b/tests/_regtest_outputs/test_healpix_bare.test_hpc2loc.out new file mode 100644 index 0000000..dfbaa9f --- /dev/null +++ b/tests/_regtest_outputs/test_healpix_bare.test_hpc2loc.out @@ -0,0 +1,3 @@ +7.07107e-01 +7.07107e-01 +0.00000e+00 diff --git a/tests/_regtest_outputs/test_healpix_bare.test_pix2ang[False-False].out b/tests/_regtest_outputs/test_healpix_bare.test_pix2ang[False-False].out new file mode 100644 index 0000000..0332266 --- /dev/null +++ b/tests/_regtest_outputs/test_healpix_bare.test_pix2ang[False-False].out @@ -0,0 +1,98 @@ +x +4.11138e-01 +4.11138e-01 +4.11138e-01 +4.11138e-01 +8.41069e-01 +8.41069e-01 +8.41069e-01 +8.41069e-01 +8.41069e-01 +8.41069e-01 +8.41069e-01 +8.41069e-01 +1.23096e+00 +1.23096e+00 +1.23096e+00 +1.23096e+00 +1.23096e+00 +1.23096e+00 +1.23096e+00 +1.23096e+00 +1.57080e+00 +1.57080e+00 +1.57080e+00 +1.57080e+00 +1.57080e+00 +1.57080e+00 +1.57080e+00 +1.57080e+00 +1.91063e+00 +1.91063e+00 +1.91063e+00 +1.91063e+00 +1.91063e+00 +1.91063e+00 +1.91063e+00 +1.91063e+00 +2.30052e+00 +2.30052e+00 +2.30052e+00 +2.30052e+00 +2.30052e+00 +2.30052e+00 +2.30052e+00 +2.30052e+00 +2.73045e+00 +2.73045e+00 +2.73045e+00 +2.73045e+00 +y +7.85398e-01 +2.35619e+00 +3.92699e+00 +5.49779e+00 +3.92699e-01 +1.17810e+00 +1.96350e+00 +2.74889e+00 +3.53429e+00 +4.31969e+00 +5.10509e+00 +5.89049e+00 +0.00000e+00 +7.85398e-01 +1.57080e+00 +2.35619e+00 +3.14159e+00 +3.92699e+00 +4.71239e+00 +5.49779e+00 +3.92699e-01 +1.17810e+00 +1.96350e+00 +2.74889e+00 +3.53429e+00 +4.31969e+00 +5.10509e+00 +5.89049e+00 +0.00000e+00 +7.85398e-01 +1.57080e+00 +2.35619e+00 +3.14159e+00 +3.92699e+00 +4.71239e+00 +5.49779e+00 +3.92699e-01 +1.17810e+00 +1.96350e+00 +2.74889e+00 +3.53429e+00 +4.31969e+00 +5.10509e+00 +5.89049e+00 +7.85398e-01 +2.35619e+00 +3.92699e+00 +5.49779e+00 diff --git a/tests/_regtest_outputs/test_healpix_bare.test_pix2ang[False-True].out b/tests/_regtest_outputs/test_healpix_bare.test_pix2ang[False-True].out new file mode 100644 index 0000000..0c91d2c --- /dev/null +++ b/tests/_regtest_outputs/test_healpix_bare.test_pix2ang[False-True].out @@ -0,0 +1,98 @@ +x +1.23096e+00 +8.41069e-01 +8.41069e-01 +4.11138e-01 +1.23096e+00 +8.41069e-01 +8.41069e-01 +4.11138e-01 +1.23096e+00 +8.41069e-01 +8.41069e-01 +4.11138e-01 +1.23096e+00 +8.41069e-01 +8.41069e-01 +4.11138e-01 +1.91063e+00 +1.57080e+00 +1.57080e+00 +1.23096e+00 +1.91063e+00 +1.57080e+00 +1.57080e+00 +1.23096e+00 +1.91063e+00 +1.57080e+00 +1.57080e+00 +1.23096e+00 +1.91063e+00 +1.57080e+00 +1.57080e+00 +1.23096e+00 +2.73045e+00 +2.30052e+00 +2.30052e+00 +1.91063e+00 +2.73045e+00 +2.30052e+00 +2.30052e+00 +1.91063e+00 +2.73045e+00 +2.30052e+00 +2.30052e+00 +1.91063e+00 +2.73045e+00 +2.30052e+00 +2.30052e+00 +1.91063e+00 +y +7.85398e-01 +1.17810e+00 +3.92699e-01 +7.85398e-01 +2.35619e+00 +2.74889e+00 +1.96350e+00 +2.35619e+00 +3.92699e+00 +4.31969e+00 +3.53429e+00 +3.92699e+00 +5.49779e+00 +5.89049e+00 +5.10509e+00 +5.49779e+00 +0.00000e+00 +3.92699e-01 +5.89049e+00 +0.00000e+00 +1.57080e+00 +1.96350e+00 +1.17810e+00 +1.57080e+00 +3.14159e+00 +3.53429e+00 +2.74889e+00 +3.14159e+00 +4.71239e+00 +5.10509e+00 +4.31969e+00 +4.71239e+00 +7.85398e-01 +1.17810e+00 +3.92699e-01 +7.85398e-01 +2.35619e+00 +2.74889e+00 +1.96350e+00 +2.35619e+00 +3.92699e+00 +4.31969e+00 +3.53429e+00 +3.92699e+00 +5.49779e+00 +5.89049e+00 +5.10509e+00 +5.49779e+00 diff --git a/tests/_regtest_outputs/test_healpix_bare.test_pix2ang[True-False].out b/tests/_regtest_outputs/test_healpix_bare.test_pix2ang[True-False].out new file mode 100644 index 0000000..925fbfa --- /dev/null +++ b/tests/_regtest_outputs/test_healpix_bare.test_pix2ang[True-False].out @@ -0,0 +1,98 @@ +x +4.50000e+01 +1.35000e+02 +2.25000e+02 +3.15000e+02 +2.25000e+01 +6.75000e+01 +1.12500e+02 +1.57500e+02 +2.02500e+02 +2.47500e+02 +2.92500e+02 +3.37500e+02 +0.00000e+00 +4.50000e+01 +9.00000e+01 +1.35000e+02 +1.80000e+02 +2.25000e+02 +2.70000e+02 +3.15000e+02 +2.25000e+01 +6.75000e+01 +1.12500e+02 +1.57500e+02 +2.02500e+02 +2.47500e+02 +2.92500e+02 +3.37500e+02 +0.00000e+00 +4.50000e+01 +9.00000e+01 +1.35000e+02 +1.80000e+02 +2.25000e+02 +2.70000e+02 +3.15000e+02 +2.25000e+01 +6.75000e+01 +1.12500e+02 +1.57500e+02 +2.02500e+02 +2.47500e+02 +2.92500e+02 +3.37500e+02 +4.50000e+01 +1.35000e+02 +2.25000e+02 +3.15000e+02 +y +6.64435e+01 +6.64435e+01 +6.64435e+01 +6.64435e+01 +4.18103e+01 +4.18103e+01 +4.18103e+01 +4.18103e+01 +4.18103e+01 +4.18103e+01 +4.18103e+01 +4.18103e+01 +1.94712e+01 +1.94712e+01 +1.94712e+01 +1.94712e+01 +1.94712e+01 +1.94712e+01 +1.94712e+01 +1.94712e+01 +0.00000e+00 +0.00000e+00 +0.00000e+00 +0.00000e+00 +0.00000e+00 +0.00000e+00 +0.00000e+00 +0.00000e+00 +-1.94712e+01 +-1.94712e+01 +-1.94712e+01 +-1.94712e+01 +-1.94712e+01 +-1.94712e+01 +-1.94712e+01 +-1.94712e+01 +-4.18103e+01 +-4.18103e+01 +-4.18103e+01 +-4.18103e+01 +-4.18103e+01 +-4.18103e+01 +-4.18103e+01 +-4.18103e+01 +-6.64435e+01 +-6.64435e+01 +-6.64435e+01 +-6.64435e+01 diff --git a/tests/_regtest_outputs/test_healpix_bare.test_pix2ang[True-True].out b/tests/_regtest_outputs/test_healpix_bare.test_pix2ang[True-True].out new file mode 100644 index 0000000..a19f394 --- /dev/null +++ b/tests/_regtest_outputs/test_healpix_bare.test_pix2ang[True-True].out @@ -0,0 +1,98 @@ +x +4.50000e+01 +6.75000e+01 +2.25000e+01 +4.50000e+01 +1.35000e+02 +1.57500e+02 +1.12500e+02 +1.35000e+02 +2.25000e+02 +2.47500e+02 +2.02500e+02 +2.25000e+02 +3.15000e+02 +3.37500e+02 +2.92500e+02 +3.15000e+02 +0.00000e+00 +2.25000e+01 +3.37500e+02 +0.00000e+00 +9.00000e+01 +1.12500e+02 +6.75000e+01 +9.00000e+01 +1.80000e+02 +2.02500e+02 +1.57500e+02 +1.80000e+02 +2.70000e+02 +2.92500e+02 +2.47500e+02 +2.70000e+02 +4.50000e+01 +6.75000e+01 +2.25000e+01 +4.50000e+01 +1.35000e+02 +1.57500e+02 +1.12500e+02 +1.35000e+02 +2.25000e+02 +2.47500e+02 +2.02500e+02 +2.25000e+02 +3.15000e+02 +3.37500e+02 +2.92500e+02 +3.15000e+02 +y +1.94712e+01 +4.18103e+01 +4.18103e+01 +6.64435e+01 +1.94712e+01 +4.18103e+01 +4.18103e+01 +6.64435e+01 +1.94712e+01 +4.18103e+01 +4.18103e+01 +6.64435e+01 +1.94712e+01 +4.18103e+01 +4.18103e+01 +6.64435e+01 +-1.94712e+01 +0.00000e+00 +0.00000e+00 +1.94712e+01 +-1.94712e+01 +0.00000e+00 +0.00000e+00 +1.94712e+01 +-1.94712e+01 +0.00000e+00 +0.00000e+00 +1.94712e+01 +-1.94712e+01 +0.00000e+00 +0.00000e+00 +1.94712e+01 +-6.64435e+01 +-4.18103e+01 +-4.18103e+01 +-1.94712e+01 +-6.64435e+01 +-4.18103e+01 +-4.18103e+01 +-1.94712e+01 +-6.64435e+01 +-4.18103e+01 +-4.18103e+01 +-1.94712e+01 +-6.64435e+01 +-4.18103e+01 +-4.18103e+01 +-1.94712e+01 diff --git a/tests/test_earth2grid.py b/tests/test_earth2grid.py new file mode 100644 index 0000000..f25487d --- /dev/null +++ b/tests/test_earth2grid.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for `earth2grid` package.""" + +import pytest + + +@pytest.fixture +def response(): + """Sample pytest fixture. + + See more at: http://doc.pytest.org/en/latest/fixture.html + """ + # import requests + # return requests.get('https://github.com/audreyr/cookiecutter-pypackage') + + +def test_content(response): + """Sample pytest test function with the pytest fixture as an argument.""" + # from bs4 import BeautifulSoup + # assert 'GitHub' in BeautifulSoup(response.content).title.string + del response diff --git a/tests/test_healpix.py b/tests/test_healpix.py new file mode 100644 index 0000000..d7d0e63 --- /dev/null +++ b/tests/test_healpix.py @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import matplotlib.pyplot as plt +import numpy as np +import pytest +import torch + +from earth2grid import get_regridder, healpix + + +@pytest.mark.xfail +def test_grid_visualize(): + grid = healpix.Grid(level=4, pixel_order=healpix.XY()) + z = np.cos(10 * np.deg2rad(grid.lat)) + grid.visualize(z) + plt.savefig("test_grid_visualize.png") + + +@pytest.mark.parametrize("origin", list(healpix.Compass)) +def test_grid_healpix_orientations(tmp_path, origin): + + nest_grid = healpix.Grid(level=4, pixel_order=healpix.PixelOrder.NEST) + grid = healpix.Grid(level=4, pixel_order=healpix.XY(origin=origin)) + + nest_lat = nest_grid.lat.reshape([12, -1]) + lat = grid.lat.reshape([12, -1]) + + for i in range(12): + assert set(nest_lat[i]) == set(lat[i]) + + +@pytest.mark.parametrize("rot", range(4)) +def test_rotate_index_same_values(tmp_path, rot): + n = 8 + i = np.arange(12 * n * n) + i_rot = healpix._rotate_index(n, rot, i=i) + i = i.reshape(12, -1) + i_rot = i_rot.reshape(12, -1) + for f in range(12): + assert set(i[f]) == set(i_rot[f]) + + +@pytest.mark.parametrize("rot", range(4)) +def test_rotate_index(rot): + n = 32 + i = np.arange(12 * n * n) + i_rot = healpix._rotate_index(n, rot, i=i) + i_back = healpix._rotate_index(n, 4 - rot, i=i_rot) + np.testing.assert_array_equal(i_back, i) + + +@pytest.mark.parametrize("origin", list(healpix.Compass)) +@pytest.mark.parametrize("clockwise", [True, False]) +def test_reorder(tmp_path, origin, clockwise): + src_grid = healpix.Grid(level=4, pixel_order=healpix.XY(origin=origin, clockwise=clockwise)) + dest_grid = healpix.Grid(level=4, pixel_order=healpix.PixelOrder.NEST) + + z = np.cos(np.deg2rad(src_grid.lat)) * np.cos(np.deg2rad(src_grid.lon)) + z = torch.from_numpy(z) + z_reorder = src_grid.reorder(dest_grid.pixel_order, z) + z_roundtrip = dest_grid.reorder(src_grid.pixel_order, z_reorder) + np.testing.assert_array_equal(z, z_roundtrip) + + +def get_devices(): + devices = [torch.device("cpu")] + if torch.cuda.is_available(): + devices += [torch.device("cuda")] + return devices + + +@pytest.mark.parametrize("origin", list(healpix.Compass)) +@pytest.mark.parametrize("clockwise", [True, False]) +@pytest.mark.parametrize("padding", [0, 1, 2]) +@pytest.mark.parametrize("device", get_devices()) +def test_grid_healpix_pad(tmp_path, origin, clockwise, padding, device): + grid = healpix.Grid(level=4, pixel_order=healpix.XY(origin=origin, clockwise=clockwise)) + hpx_pad_grid = healpix.Grid(level=4, pixel_order=healpix.HEALPIX_PAD_XY) + z = np.cos(np.deg2rad(grid.lat)) * np.cos(np.deg2rad(grid.lon)) + z = torch.from_numpy(z) + regrid = get_regridder(grid, hpx_pad_grid) + z_hpx_pad = regrid(z) + + n = grid._nside() + z = z.view(-1, 12, n, n) + z_hpx_pad = z_hpx_pad.view(-1, 12, n, n) + + padded = healpix.pad(z_hpx_pad.to(device), padding).cpu() + + def grad_abs(z): + fx, fy = np.gradient(z, axis=(-1, -2)) + return np.mean(np.abs(fx)) + np.mean(np.abs(fy)) + + # the padded dtile should not vary much more than the non-padded tile + sigma_padded = grad_abs(padded) + sigma = grad_abs(z) + + if sigma_padded > sigma * 1.1: + + fig, axs = plt.subplots(3, 4) + axs = axs.ravel() + for i in range(12): + ax = axs[i] + ax.pcolormesh(padded[0, i]) + output_path = tmp_path / "output.png" + fig.savefig(output_path.as_posix()) + + raise ValueError( + f"The gradient of the padded data {sigma_padded} is too large. " + f"Examine the padding in the image at {output_path}." + ) + + +def test_to_image(): + grid = healpix.Grid(level=4) + lat = torch.tensor(grid.lat) + lat_img = grid.to_image(lat) + n = 2**grid.level + assert lat_img.shape == (5 * n, 5 * n) + + +def test_conv2d(): + f = 12 + nside = 16 + npix = f * nside * nside + cin = 3 + cout = 4 + n = 1 + + x = torch.ones(n, cin, 1, npix) + weight = torch.zeros(cout, cin, 3, 3) + out = healpix.conv2d(x, weight, padding=(1, 1)) + assert out.shape == (n, cout, 1, npix) diff --git a/tests/test_healpix_bare.py b/tests/test_healpix_bare.py new file mode 100644 index 0000000..c5b9f08 --- /dev/null +++ b/tests/test_healpix_bare.py @@ -0,0 +1,92 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import numpy +import numpy as np +import pytest +import torch + +import earth2grid.healpix_bare + + +def test_ring2nest(): + n = 8 + i = torch.arange(n * n * 12) + + j = earth2grid.healpix_bare.ring2nest(n, i) + i_round = earth2grid.healpix_bare.nest2ring(n, j) + numpy.testing.assert_array_equal(i, i_round) + + +@pytest.mark.parametrize("nest", [True, False]) +@pytest.mark.parametrize("lonlat", [True, False]) +def test_pix2ang(nest, lonlat, regtest): + n = 2 + i = torch.arange(n * n * 12) + + x, y = earth2grid.healpix_bare.pix2ang(n, i, nest=nest, lonlat=lonlat) + print("x", file=regtest) + np.savetxt(regtest, x, fmt="%.5e") + + print("y", file=regtest) + np.savetxt(regtest, y, fmt="%.5e") + + +def savetxt(file, array): + np.savetxt(file, array, fmt="%.5e") + + +def test_hpc2loc(regtest): + x = torch.tensor([0.0]).double() + y = torch.tensor([0.0]).double() + f = torch.tensor([0]) + + loc = earth2grid.healpix_bare.hpc2loc(x, y, f) + vec = earth2grid.healpix_bare.loc2vec(loc) + for array in vec: + savetxt(regtest, array) + + +def test_boundaries(regtest): + ipix = torch.tensor([0]) + boundaries = earth2grid.healpix_bare.corners(1, ipix, False) + assert not torch.any(torch.isnan(boundaries)), boundaries + assert boundaries.shape == (1, 3, 4) + savetxt(regtest, boundaries.flatten()) + + +def test_get_interp_weights_vector(): + lon = torch.tensor([23, 84, -23]).float() + lat = torch.tensor([0, 12, 67]).float() + pix, weights = earth2grid.healpix_bare.get_interp_weights(8, lon, lat) + assert pix.device == lon.device + assert pix.shape == (4, 3) + assert weights.shape == (4, 3) + + +def test_get_interp_weights_vector_interp_y(): + nside = 16 + inpix = torch.tensor([0, 1, 5, 6]) + + lon, lat = earth2grid.healpix_bare.pix2ang(nside, inpix, lonlat=True) + ay = 0.8 + + lonc = (lon[0] + lon[1]) / 2 + latc = lat[0] * ay + lat[3] * (1 - ay) + + pix, weights = earth2grid.healpix_bare.get_interp_weights(nside, lonc.unsqueeze(0), latc.unsqueeze(0)) + + assert torch.all(pix == inpix[:, None]) + expected_weights = torch.tensor([ay / 2, ay / 2, (1 - ay) / 2, (1 - ay) / 2]).double()[:, None] + assert torch.allclose(weights, expected_weights) diff --git a/tests/test_latlon.py b/tests/test_latlon.py new file mode 100644 index 0000000..2b20940 --- /dev/null +++ b/tests/test_latlon.py @@ -0,0 +1,16 @@ +import torch + +from earth2grid.latlon import equiangular_lat_lon_grid + + +def test_lat_lon_bilinear_regrid_to(): + src = equiangular_lat_lon_grid(15, 30) + dest = equiangular_lat_lon_grid(30, 60) + regrid = src.get_bilinear_regridder_to(dest.lat, dest.lon) + + regrid.float() + lat = torch.broadcast_to(torch.tensor(src.lat), src.shape) + z = torch.tensor(lat).float() + + out = regrid(z) + assert out.shape == dest.shape diff --git a/tests/test_regrid.py b/tests/test_regrid.py new file mode 100644 index 0000000..56e47d5 --- /dev/null +++ b/tests/test_regrid.py @@ -0,0 +1,180 @@ +# SPDX-FileCopyrightText: Copyright (c) 2024 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import unittest + +import matplotlib.pyplot as plt +import numpy as np +import pytest +import torch + +import earth2grid +from earth2grid.latlon import BilinearInterpolator + + +@pytest.mark.parametrize("with_channels", [True, False]) +def test_latlon_regridder(with_channels, tmp_path): + nlat = 30 + nlon = 60 + + src = earth2grid.healpix.Grid(level=6, pixel_order=earth2grid.healpix.XY()) + + lat = np.linspace(-90, 90, nlat + 2)[1:-1] + lon = np.linspace(0, 360, nlon) + dest = earth2grid.latlon.LatLonGrid(lat, lon) + regridder = earth2grid.get_regridder(src, dest) + + z = np.cos(10 * np.deg2rad(src.lat)) + z = torch.from_numpy(z) + if with_channels: + z = torch.stack([z, 0 * z]) + + out = regridder(z) + + if out.ndim == 3: + out = out[0] + + assert out.shape[-2:] == (nlat, nlon) + + expected = np.cos(10 * np.deg2rad(lat))[:, None] + diff = np.mean(np.abs(out.numpy() - expected)) + if diff > 1e-3 * 90 / nlat: + plt.figure() + if with_channels: + out = out[0] + plt.pcolormesh(lon, lat, out - expected) + plt.title("regridded - expected") + plt.colorbar() + image_path = tmp_path / "test_latlon_regridder.png" + plt.savefig(image_path) + raise ValueError(f"{diff} too big. See {image_path}.") + + +@pytest.mark.parametrize("with_channels", [True, False]) +def test_healpix_to_lat_lon(with_channels): + dest = earth2grid.healpix.Grid(level=6, pixel_order=earth2grid.healpix.XY()) + src = earth2grid.latlon.equiangular_lat_lon_grid(33, 64) + regrid = earth2grid.get_regridder(src, dest) + + def f(lat, lon): + lat = torch.from_numpy(lat) + lon = torch.from_numpy(lon) + return torch.cos(torch.deg2rad(lat)) * torch.sin(2 * torch.deg2rad(lon)) + + z = f(src.lat, src.lon) + if with_channels: + z = z[None] + z_regridded = regrid(z) + expected = f(dest.lat, dest.lon) + assert torch.allclose(z_regridded, expected, rtol=0.01) + + +@pytest.mark.parametrize("with_channels", [True, False]) +@pytest.mark.parametrize("level", [4, 5]) +def test_healpix_to_healpix(with_channels, level): + """Test finer healpix interpolation""" + src = earth2grid.healpix.Grid(level=4) + dest = earth2grid.healpix.Grid(level=level) + regrid = earth2grid.get_regridder(src, dest) + + def f(lat, lon): + lat = torch.from_numpy(lat) + lon = torch.from_numpy(lon) + return torch.cos(torch.deg2rad(lat)) * torch.sin(2 * torch.deg2rad(lon)) + + z = f(src.lat, src.lon) + if with_channels: + z = z[None] + z_regridded = regrid(z) + expected = f(dest.lat, dest.lon) + max_diff = torch.max(z_regridded - expected) + print(max_diff) + assert torch.allclose(z_regridded, expected, atol=0.03) + + +@pytest.mark.parametrize("reverse", [True, False]) +def test_latlon_to_latlon(reverse): + nlat = 30 + nlon = 60 + lon = np.linspace(0, 360, nlon) + lat = np.linspace(-90, 90, nlat) + src = earth2grid.latlon.LatLonGrid(lat[::-1] if reverse else lat, lon) + dest = earth2grid.latlon.LatLonGrid(lat, lon) + regrid = earth2grid.get_regridder(src, dest) + regrid.float() + + z = torch.zeros(src.shape) + regrid(z) + + +class TestBilinearInterpolateNonUniform(unittest.TestCase): + def test_interpolation(self): + # Setup + H, W = 5, 5 # Input tensor height and width + input_tensor = torch.arange(1.0, H * W + 1).view(H, W) # Example 2D tensor + x_coords = torch.linspace(-1, 1, steps=W) # Example non-uniform x-coordinates + y_coords = torch.linspace(-1, 1, steps=H) # Example non-uniform y-coordinates + x_query = torch.tensor([0.0]) # Query x-coordinates at the center + y_query = torch.tensor([0.0]) # Query y-coordinates at the center + + # Expected value at the center of a linearly spaced grid + expected = torch.tensor([(H * W + 1) / 2]) + + # Execute + interpolator = BilinearInterpolator(x_coords, y_coords, x_query, y_query) + result = interpolator(input_tensor) + + # Verify + self.assertTrue(torch.allclose(result, expected), "The interpolated value does not match the expected value.") + + def test_raises_error_when_coordinates_not_increasing_x(self): + x_coords = torch.linspace(1, -1, steps=32) # Example non-uniform x-coordinates + y_coords = torch.linspace(-1, 1, steps=32) # Example non-uniform y-coordinates + with self.assertRaises(ValueError): + BilinearInterpolator(x_coords, y_coords, [0], [0]) + + def test_raises_error_when_coordinates_not_increasing_y(self): + x_coords = torch.linspace(-1, 1, steps=32) # Example non-uniform x-coordinates + y_coords = torch.linspace(1, -1, steps=32) # Example non-uniform y-coordinates + with self.assertRaises(ValueError): + BilinearInterpolator(x_coords, y_coords, [0], [0]) + + def test_interpolation_func(self): + # Setup + H, W = 32, 32 # Input tensor height and width + + def func(x, y): + + return 10 * x + 5 * y**2 + 4 + + x_coords = torch.linspace(-1, 1, steps=W) # Example non-uniform x-coordinates + y_coords = torch.linspace(-1, 1, steps=H) # Example non-uniform y-coordinates + x_query = torch.tensor([0.0, 0.5, 0.25]) # Query x-coordinates at the center + y_query = torch.tensor([0.0, 0.0, -0.4]) # Query y-coordinates at the center + + input_tensor = func(x_coords, y_coords[:, None]) + + # Expected value at the center of a linearly spaced grid + expected = func(x_query, y_query) + + # Execute + interpolator = BilinearInterpolator(x_coords, y_coords, x_query, y_query) + result = interpolator(input_tensor) + + # Verify + self.assertTrue(torch.allclose(result, expected, rtol=0.01)) + + if torch.cuda.is_available() and torch.cuda.device_count() > 0: + interpolator.cuda() + interpolator(input_tensor.cuda())