diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 81a40161..00000000 --- a/.coveragerc +++ /dev/null @@ -1,16 +0,0 @@ -# -# .coveragerc to control coverage.py -# - -[run] -branch = True -omit = - setup.py - iris_grib/tests/* - - -[report] -exclude_lines = - pragma: no cover - def __repr__ - if __name__ == .__main__.: diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 00000000..595c9be9 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,5 @@ +# adopt ruff (#430) +669a5daf1433a99766f0ecbf1513758f3c97e6d6 + +# migrate iris_grib to src/iris_grib (#450) +e57dac079591b978806e55c7450b61d62b9342f5 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..6ce59a36 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,23 @@ +# Reference: +# - https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/keeping-your-actions-up-to-date-with-dependabot +# - https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#groups + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check later in the week - the upstream dependabot check in `workflows` runs deliberately early in the week. + # Therefore allowing time for the `workflows` update to be merged-and-released first. + interval: "weekly" + day: "thursday" + time: "01:00" + timezone: "Europe/London" + groups: + dependencies: + patterns: + - "*" + labels: + - "New: Pull Request" + - "Bot" diff --git a/.github/workflows/ci-linkchecks.yml b/.github/workflows/ci-linkchecks.yml new file mode 100644 index 00000000..1771ec59 --- /dev/null +++ b/.github/workflows/ci-linkchecks.yml @@ -0,0 +1,50 @@ +name: Linkcheck + +on: + workflow_dispatch: + schedule: + - cron: "00 06 * * *" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + linkChecker: + runs-on: ubuntu-latest + permissions: + issues: write # required for peter-evans/create-issue-from-file + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Link Checker + id: lychee + uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 + with: + token: ${{secrets.GITHUB_TOKEN}} + fail: false + args: | + --verbose + --max-concurrency 1 + './docs/**/*.rst' + './lib/**/*.py' + + - name: Create Issue From File + if: ${{ fromJSON(steps.lychee.outputs.exit_code) != 0 }} + uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 + with: + title: Link Checker Report + content-filepath: ./lychee/out.md + labels: | + Bot + Type: Documentation + Type: Bug + + - name: Fail Workflow On Link Errors + if: ${{ fromJSON(steps.lychee.outputs.exit_code) != 0 }} + run: + exit ${{ fromJSON(steps.lychee.outputs.exit_code) }} diff --git a/.github/workflows/ci-manifest.yml b/.github/workflows/ci-manifest.yml new file mode 100644 index 00000000..2454ece8 --- /dev/null +++ b/.github/workflows/ci-manifest.yml @@ -0,0 +1,25 @@ +name: ci-manifest + +on: + pull_request: + branches: + - "*" + + push: + branches-ignore: + - "auto-update-lockfiles" + - "pre-commit-ci-update-config" + - "dependabot/*" + + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +jobs: + manifest: + name: "check-manifest" + uses: scitools/workflows/.github/workflows/ci-manifest.yml@1f2141422a63321a32575ddd186e53acff12550c diff --git a/.github/workflows/ci-template-check.yml b/.github/workflows/ci-template-check.yml new file mode 100644 index 00000000..f5a4e306 --- /dev/null +++ b/.github/workflows/ci-template-check.yml @@ -0,0 +1,20 @@ +# Checks if a PR makes any changes that ought to be shared via templating. +# See the called workflow in the scitools/workflows repo for more details. + +name: ci-template-check + +on: + pull_request_target: + branches: + - main + +permissions: {} + +jobs: + prompt-share: + uses: scitools/workflows/.github/workflows/ci-template-check.yml@1f2141422a63321a32575ddd186e53acff12550c + secrets: + AUTH_APP_ID: ${{ secrets.AUTH_APP_ID }} + AUTH_APP_PRIVATE_KEY: ${{ secrets.AUTH_APP_PRIVATE_KEY }} + with: + pr_number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 00000000..eab3158e --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,138 @@ +# reference: +# - https://github.com/actions/cache +# - https://github.com/actions/checkout +# - https://github.com/marketplace/actions/setup-miniconda + +name: ci-tests + +on: + push: + branches: + - "main" + - "v*x" + tags: + - "v*" + pull_request: + branches: + - "*" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: "${{ matrix.session }} py${{ matrix.python-version }} [${{ matrix.iris-source }}]" + + runs-on: ${{ matrix.os }} + + defaults: + run: + shell: bash -l {0} + + strategy: + fail-fast: false + matrix: + os: ["ubuntu-latest"] + python-version: ["3.14"] + session: ["doctest"] + iris-source : ["conda-forge"] + include: + - os: "ubuntu-latest" + python-version: "3.14" + session: "tests" + iris-source: "conda-forge" + coverage: "--coverage" + - os: "ubuntu-latest" + python-version: "3.14" + session: "tests" + iris-source: "source" + - os: "ubuntu-latest" + python-version: "3.13" + session: "tests" + iris-source: "conda-forge" + - os: "ubuntu-latest" + python-version: "3.12" + session: "tests" + iris-source: "conda-forge" + + env: + IRIS_TEST_DATA_VERSION: "2.24" + ENV_NAME: "ci-tests" + + steps: + - name: "checkout" + uses: actions/checkout@v6 + + - name: "environment configure" + env: + # Maximum cache period (in weeks) before forcing a cache refresh. + CACHE_WEEKS: 2 + run: | + echo "CACHE_PERIOD=$(date +%Y).$(expr $(date +%U) / ${CACHE_WEEKS})" >> ${GITHUB_ENV} + echo "LOCK_FILE=requirements/locks/py$(echo ${{ matrix.python-version }} | tr -d '.')-linux-64.lock" >> ${GITHUB_ENV} + + - name: "data cache" + uses: ./.github/workflows/composite/iris-data-cache + with: + cache_build: 0 + env_name: ${{ env.ENV_NAME }} + version: ${{ env.IRIS_TEST_DATA_VERSION }} + + - name: "conda package cache" + uses: ./.github/workflows/composite/conda-pkg-cache + with: + cache_build: 0 + cache_period: ${{ env.CACHE_PERIOD }} + env_name: ${{ env.ENV_NAME }} + + - name: "conda install" + uses: conda-incubator/setup-miniconda@v4 + with: + miniforge-version: latest + channels: conda-forge,defaults + activate-environment: ${{ env.ENV_NAME }} + auto-update-conda: false + use-only-tar-bz2: true + + - name: "conda environment cache" + uses: ./.github/workflows/composite/conda-env-cache + with: + cache_build: 0 + cache_period: ${{ env.CACHE_PERIOD }} + env_name: ${{ env.ENV_NAME }} + install_packages: "cartopy nox pip" + + - name: "conda info" + run: | + conda info + conda list + + - name: "cartopy cache" + uses: ./.github/workflows/composite/cartopy-cache + with: + cache_build: 0 + cache_period: ${{ env.CACHE_PERIOD }} + env_name: ${{ env.ENV_NAME }} + + - name: "nox cache" + uses: ./.github/workflows/composite/nox-cache + with: + cache_build: 0 + env_name: ${{ env.ENV_NAME }} + lock_file: ${{ env.LOCK_FILE }} + + - name: "iris-grib ${{ matrix.session }}" + env: + IRIS_SOURCE: ${{ matrix.iris-source }} + PY_COLORS: 1 + PY_VER: ${{ matrix.python-version }} + run: | + nox --session ${{ matrix.session }} -- ${{ matrix.coverage }} --test-data-dir ${HOME}/iris-test-data/test_data + + - name: "upload coverage report" + if: ${{ matrix.coverage }} + uses: codecov/codecov-action@v7 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/composite/cartopy-cache/action.yml b/.github/workflows/composite/cartopy-cache/action.yml new file mode 100644 index 00000000..e41f575a --- /dev/null +++ b/.github/workflows/composite/cartopy-cache/action.yml @@ -0,0 +1,41 @@ +name: "cartopy-cache" +description: "create and cache cartopy assets" + +# +# Assumes the environment contains the following variables: +# - CONDA +# +inputs: + cache_build: + description: "conda environment cache build number" + required: false + default: "0" + cache_period: + description: "conda environment cache timestamp" + required: true + env_name: + description: "environment name" + required: true + +runs: + using: "composite" + steps: + - uses: actions/cache@v3 + id: cartopy-cache + with: + path: ~/.local/share/cartopy + key: ${{ runner.os }}-cartopy-${{ inputs.env_name }}-p${{ inputs.cache_period }}-b${{ inputs.cache_build }} + + - if: steps.cartopy-cache.outputs.cache-hit != 'true' + env: + CARTOPY_SHARE_DIR: ~/.local/share/cartopy + CARTOPY_FEATURE: https://raw.githubusercontent.com/SciTools/cartopy/v0.20.0/tools/cartopy_feature_download.py + shell: bash + run: | + # Require to explicitly activate the environment within the composite action. + source ${{ env.CONDA }}/etc/profile.d/conda.sh >/dev/null 2>&1 + conda activate ${{ inputs.env_name }} + wget --quiet ${CARTOPY_FEATURE} + mkdir -p ${CARTOPY_SHARE_DIR} + # Requires a pre-installed version of cartopy within the environment. + python cartopy_feature_download.py physical --output ${CARTOPY_SHARE_DIR} --no-warn \ No newline at end of file diff --git a/.github/workflows/composite/conda-env-cache/action.yml b/.github/workflows/composite/conda-env-cache/action.yml new file mode 100644 index 00000000..7a32a792 --- /dev/null +++ b/.github/workflows/composite/conda-env-cache/action.yml @@ -0,0 +1,35 @@ +name: "conda-env-cache" +description: "create and cache the conda environment" + +# +# Assumes the environment contains the following variables: +# - CONDA +# +inputs: + cache_build: + description: "conda environment cache build number" + required: false + default: "0" + cache_period: + description: "conda environment cache timestamp" + required: true + env_name: + description: "environment name" + required: true + install_packages: + description: "conda packages to install into environment" + required: true + +runs: + using: "composite" + steps: + - uses: actions/cache@v3 + id: conda-env-cache + with: + path: ${{ env.CONDA }}/envs/${{ inputs.env_name }} + key: ${{ runner.os }}-conda-env-${{ inputs.env_name }}-p${{ inputs.cache_period }}-b${{ inputs.cache_build }} + + - if: steps.conda-env-cache.outputs.cache-hit != 'true' + shell: bash + run: | + conda install --quiet --name ${{ inputs.env_name }} ${{ inputs.install_packages }} \ No newline at end of file diff --git a/.github/workflows/composite/conda-pkg-cache/action.yml b/.github/workflows/composite/conda-pkg-cache/action.yml new file mode 100644 index 00000000..d06c0941 --- /dev/null +++ b/.github/workflows/composite/conda-pkg-cache/action.yml @@ -0,0 +1,22 @@ +name: "conda-pkg-cache" +description: "cache the conda environment packages" + +inputs: + cache_build: + description: "conda environment cache build number" + required: false + default: "0" + cache_period: + description: "conda environment cache timestamp" + required: true + env_name: + description: "environment name" + required: true + +runs: + using: "composite" + steps: + - uses: actions/cache@v3 + with: + path: ~/conda_pkgs_dir + key: ${{ runner.os }}-conda-pkgs-${{ inputs.env_name }}-p${{ inputs.cache_period }}-b${{ inputs.cache_build }} \ No newline at end of file diff --git a/.github/workflows/composite/iris-data-cache/action.yml b/.github/workflows/composite/iris-data-cache/action.yml new file mode 100644 index 00000000..5869ab64 --- /dev/null +++ b/.github/workflows/composite/iris-data-cache/action.yml @@ -0,0 +1,30 @@ +name: "iris-data-cache" +description: "create and cache the iris test data" + +inputs: + cache_build: + description: "data cache build number" + required: false + default: "0" + env_name: + description: "environment name" + required: true + version: + description: "iris test data version" + required: true + +runs: + using: "composite" + steps: + - uses: actions/cache@v3 + id: data-cache + with: + path: ~/iris-test-data + key: ${{ runner.os }}-iris-test-data-${{ inputs.env_name }}-v${{ inputs.version }}-b${{ inputs.cache_build }} + + - if: steps.data-cache.outputs.cache-hit != 'true' + shell: bash + run: | + wget --quiet https://github.com/SciTools/iris-test-data/archive/v${{ inputs.version }}.zip -O iris-test-data.zip + unzip -q iris-test-data.zip + mv iris-test-data-${{ inputs.version }} ~/iris-test-data \ No newline at end of file diff --git a/.github/workflows/composite/nox-cache/action.yml b/.github/workflows/composite/nox-cache/action.yml new file mode 100644 index 00000000..23447f84 --- /dev/null +++ b/.github/workflows/composite/nox-cache/action.yml @@ -0,0 +1,22 @@ +name: "nox cache" +description: "cache the nox test environments" + +inputs: + cache_build: + description: "nox cache build number" + required: false + default: "0" + env_name: + description: "environment name" + required: true + lock_file: + description: "conda-lock environment requirements filename" + required: true + +runs: + using: "composite" + steps: + - uses: actions/cache@v3 + with: + path: ${{ github.workspace }}/.nox + key: ${{ runner.os }}-nox-${{ inputs.env_name }}-py${{ matrix.python-version }}-b${{ inputs.cache_build }}-${{ hashFiles(inputs.lock_file) }} \ No newline at end of file diff --git a/.github/workflows/refresh-lockfiles.yml b/.github/workflows/refresh-lockfiles.yml new file mode 100644 index 00000000..86674901 --- /dev/null +++ b/.github/workflows/refresh-lockfiles.yml @@ -0,0 +1,18 @@ +# Updates the environment lock files. See the called workflow in the +# scitools/reusable_workflows repo for more details. +name: Refresh Lockfiles + + +on: + workflow_dispatch: + schedule: + - cron: "2 0 * * 6" + +permissions: {} + +jobs: + refresh_lockfiles: + uses: scitools/workflows/.github/workflows/refresh-lockfiles.yml@1f2141422a63321a32575ddd186e53acff12550c + secrets: + AUTH_APP_ID: ${{ secrets.AUTH_APP_ID }} + AUTH_APP_PRIVATE_KEY: ${{ secrets.AUTH_APP_PRIVATE_KEY }} diff --git a/.gitignore b/.gitignore index fcae05cd..f12351f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,172 @@ -*.py[co] +# VCS by setuptools-scm +_version.py -# Packages -*.egg -*.egg-info -dist -build -eggs -parts -bin -var -sdist -develop-eggs +# Created by editors +*~ +\#* +\.\#* +*.swp +.idea/ +.vscode/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ .installed.cfg +*.egg +MANIFEST + +# 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 -#shared objects -*.so +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ -# Created by editors -*~ -\#* -\.\#* -*.swp +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +docs/api/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 00000000..eb3c2bb1 --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,10 @@ +# Change-log entries reference GitHub user accounts, which are common enough +# to trigger GitHub rate limiting. +https://github.com/[^/]*$ + +# GitHub seems especially keen to rate-limit "blob" URLs. +# (maybe because these are especially associated with abuse?) +https://github.com/[^/]*/[^/]*/blob/.* + +# Returns 403 when accessed from GitHub Actions +https://stackoverflow.com \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..13970687 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,133 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +# See https://pre-commit.ci/#configuration +# See https://github.com/scientific-python/cookie#sp-repo-review + +ci: + autofix_prs: true + autofix_commit_msg: "style: pre-commit fixes" + autoupdate_commit_msg: "chore: update pre-commit hooks" + autoupdate_schedule: "monthly" + + +# Alphabetised, for lack of a better order. +files: | + (?x)( + benchmarks\/.+\.py| + docs\/.+\.py| + docs\/.+\.rst| + lib\/.+\.py| + noxfile\.py| + pyproject\.toml| + setup\.py| + src\/.+\.py + ) +minimum_pre_commit_version: 1.21.0 + +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + # Prevent giant files from being committed. + - id: check-added-large-files + # Check whether files parse as valid Python. + - id: check-ast + # Check for file name conflicts on case-insensitive filesystems. + - id: check-case-conflict + # Check for files that contain merge conflict strings. + - id: check-merge-conflict + # Check for debugger imports and py37+ `breakpoint()` calls in Python source. + - id: debug-statements + # Check TOML file syntax. + - id: check-toml + # Check YAML file syntax. + - id: check-yaml + # Makes sure files end in a newline and only a newline. + # Duplicates Ruff W292 but also works on non-Python files. + - id: end-of-file-fixer + # Replaces or checks mixed line ending. + - id: mixed-line-ending + # Don't commit to main branch. + - id: no-commit-to-branch + # Trims trailing whitespace. + # Duplicates Ruff W291 but also works on non-Python files. + - id: trailing-whitespace + +- repo: https://github.com/adamchainz/blacken-docs + rev: 1.20.0 + hooks: + - id: blacken-docs + types: [file, rst] + +- repo: https://github.com/codespell-project/codespell + rev: "v2.4.2" + hooks: + - id: codespell + types_or: [asciidoc, python, markdown, rst] + additional_dependencies: [tomli] + +- repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v1.20.2' + hooks: + - id: mypy + exclude: 'noxfile\.py|docs/conf\.py' + +- repo: https://github.com/numpy/numpydoc + rev: v1.10.0 + hooks: + - id: numpydoc-validation + exclude: "src/iris_grib/tests/|noxfile.py" + types: [file, python] + +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: "v0.15.12" + hooks: + - id: ruff-check + types: [file, python] + args: [--fix, --show-fixes] + - id: ruff-format + types: [file, python] + +- repo: https://github.com/aio-libs/sort-all + rev: v1.3.0 + hooks: + - id: sort-all + types: [file, python] + +- repo: https://github.com/scientific-python/cookie + rev: 2026.04.04 + hooks: + - id: sp-repo-review + additional_dependencies: ["repo-review[cli]"] + args: ["--show=errskip"] + +- repo: https://github.com/abravalheri/validate-pyproject + # More exhaustive than Ruff RUF200. + rev: "v0.25" + hooks: + - id: validate-pyproject + +- repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: python-check-blanket-noqa + # Enforce that all noqa annotations always occur with specific codes. + - id: python-check-blanket-type-ignore + # Enforce that "# type: ignore" annotations always occur with specific codes. + - id: python-check-mock-methods + # Prevent common mistakes of assert mck.not_called(), assert + # mck.called_once_with(...) and mck.assert_called. + - id: python-no-eval + # A quick check for the eval() built-in function + - id: python-no-log-warn + # A quick check for the deprecated .warn() method of python loggers + - id: python-use-type-annotations + # Enforce that python3.6+ type annotations are used instead of type comments + - id: rst-backticks + # Detect common mistake of using single backticks when writing rst. + - id: rst-directive-colons + # Detect mistake of rst directive not ending with double colon. + - id: rst-inline-touching-normal + # Detect mistake of inline code touching normal text in rst. + - id: text-unicode-replacement-char + # Forbid files which have a UTF-8 Unicode replacement character. diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 00000000..af304cb2 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,31 @@ +version: 2 + +build: + os: ubuntu-20.04 + tools: + python: mambaforge-4.10 + jobs: + post_checkout: + # The SciTools/iris-grib repository is shallow i.e., has a .git/shallow, + # therefore complete the repository with a full history in order + # to allow setuptools-scm to correctly auto-discover the version. + - git fetch --unshallow + - git fetch --all + # Need to stash the local changes that Read the Docs makes so that + # setuptools_scm can generate the correct Iris-grib version. + pre_install: + - git stash + post_install: + - git stash pop + +conda: + environment: requirements/readthedocs.yml + +sphinx: + configuration: docs/conf.py + fail_on_warning: false + +python: + install: + - method: pip + path: . diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c4d68ad9..00000000 --- a/.travis.yml +++ /dev/null @@ -1,82 +0,0 @@ -language: python -sudo: false -env: - - python=2.7 - iris=1.9 - - - python=2.7 - iris_from_source=master - grib_api=1.14.7 - -# - python=3.4 -# iris=master -# grib_api=1.14.7 - -# - python=3.5 -# iris=master -# grib_api=1.14.7 - - -install: - # Fetch and install conda - # ----------------------- - - export CONDA_BASE=http://repo.continuum.io/miniconda/Miniconda3 - - wget ${CONDA_BASE}-latest-Linux-x86_64.sh -O miniconda.sh; - - bash miniconda.sh -b -p $HOME/miniconda - - export PATH="$HOME/miniconda/bin:$PATH" - - conda config --set show_channel_urls True - - conda config --add channels conda-forge - - # Install iris-grib's dependencies - # -------------------------------- - - | - conda install --quiet --yes python=${python} pep8 pip nomkl \ - iris=${iris}* \ - python-ecmwf_grib=${grib_api}* - - # Fetch and install Iris from source - # ---------------------------------- - - | - if [ -n "$iris_from_source" ]; then - conda remove --yes iris; - wget https://github.com/scitools/iris/archive/${iris_from_source}.tar.gz -O iris.tar.gz; - tar xzf iris.tar.gz && rm -rf iris.tar.gz && mv iris-${iris_from_source} iris_source; - pip install ./iris_source --no-deps; - rm -rf iris_source; - fi - - # Download iris-test-data - # -------------------------------- - - export IRIS_TEST_DATA_REF=https://github.com/SciTools/iris-test-data/archive/master.zip - - export IRIS_TEST_DATA_LOCATION=$HOME/iris-test-data - - mkdir $IRIS_TEST_DATA_LOCATION - - wget -O $IRIS_TEST_DATA_LOCATION/iris-test-data.zip ${IRIS_TEST_DATA_REF} - - unzip -q $IRIS_TEST_DATA_LOCATION/iris-test-data.zip -d $IRIS_TEST_DATA_LOCATION - - # Locate iris installation - # ------------------------ - - export IRIS_DIR=$(python -c "import iris; import os.path; print os.path.dirname(iris.__file__)") - - # Poke site.cfg to reference iris-test-data - - SITE_CFG=$IRIS_DIR/etc/site.cfg - - echo "[Resources]" > $SITE_CFG - - echo "test_data_dir = ${IRIS_TEST_DATA_LOCATION}/iris-test-data-master/test_data" >> $SITE_CFG - - # Install iris-grib itself - # ------------------------ - - pip install . --no-deps - - # Install coveralls for test coverage - # ----------------------------------- - - pip install coveralls - - # Summarise the environment - # ------------------------- - - conda list - -script: - # Ensure we can import iris_grib and that the tests pass - # ------------------------------------------------------ - - coverage run setup.py test - -after_success: coveralls diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 00000000..460a07f2 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,11 @@ +cff-version: 1.2.0 +message: "If iris-grib played an important role in your research then please add us to your reference list by using the references below." +title: "iris-grib" +authors: + - name: "iris-grib repository contributors" +abstract: "GRIB (GRIdded Binary) interface for Iris." +license: "BSD-3-Clause" +license-url: "https://spdx.org/licenses/BSD-3-Clause.html" +url: "http://www.scitools.org.uk/" +repository-code: "https://github.com/SciTools/iris-grib" +type: "software" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..a959e3ed --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +geovista.pub@gmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8952a210..a4ce74ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,9 +12,9 @@ Getting started 1. If you've not already got one, sign up for a [GitHub account](https://github.com/signup/free). 2. Fork the iris-grib repository, create your new fix/feature branch, and - start commiting code. + start committing code. - The main - [Iris development guide](http://scitools.org.uk/iris/docs/latest/developers_guide/gitwash/git_development.html) + [Iris development guide](https://scitools-iris.readthedocs.io/en/latest/developers_guide/gitwash/index.html) has more detail. 3. Remember to add appropriate documentation and tests to supplement any new or changed functionality. @@ -22,11 +22,11 @@ Getting started Submitting changes ------------------ -1. Read and sign the Contributor Licence Agreement (CLA). - - See our [governance page](http://scitools.org.uk/governance.html) - for the CLA and what to do with it. -2. Push your branch to your fork of iris-grib. -3. Submit your pull request. +1. Push your branch to your fork of iris-grib. +2. Submit your pull request. +3. Note that all authors on pull requests will automatically be asked to sign the + [SciTools Contributor Licence Agreement](https://cla-assistant.io/SciTools/) + (CLA), if they have not already done so. 4. Sit back and wait for the core iris-grib development team to review your code. diff --git a/COPYING b/COPYING deleted file mode 100644 index 94a9ed02..00000000 --- a/COPYING +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/COPYING.LESSER b/COPYING.LESSER deleted file mode 100644 index 65c5ca88..00000000 --- a/COPYING.LESSER +++ /dev/null @@ -1,165 +0,0 @@ - GNU LESSER GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - - This version of the GNU Lesser General Public License incorporates -the terms and conditions of version 3 of the GNU General Public -License, supplemented by the additional permissions listed below. - - 0. Additional Definitions. - - As used herein, "this License" refers to version 3 of the GNU Lesser -General Public License, and the "GNU GPL" refers to version 3 of the GNU -General Public License. - - "The Library" refers to a covered work governed by this License, -other than an Application or a Combined Work as defined below. - - An "Application" is any work that makes use of an interface provided -by the Library, but which is not otherwise based on the Library. -Defining a subclass of a class defined by the Library is deemed a mode -of using an interface provided by the Library. - - A "Combined Work" is a work produced by combining or linking an -Application with the Library. The particular version of the Library -with which the Combined Work was made is also called the "Linked -Version". - - The "Minimal Corresponding Source" for a Combined Work means the -Corresponding Source for the Combined Work, excluding any source code -for portions of the Combined Work that, considered in isolation, are -based on the Application, and not on the Linked Version. - - The "Corresponding Application Code" for a Combined Work means the -object code and/or source code for the Application, including any data -and utility programs needed for reproducing the Combined Work from the -Application, but excluding the System Libraries of the Combined Work. - - 1. Exception to Section 3 of the GNU GPL. - - You may convey a covered work under sections 3 and 4 of this License -without being bound by section 3 of the GNU GPL. - - 2. Conveying Modified Versions. - - If you modify a copy of the Library, and, in your modifications, a -facility refers to a function or data to be supplied by an Application -that uses the facility (other than as an argument passed when the -facility is invoked), then you may convey a copy of the modified -version: - - a) under this License, provided that you make a good faith effort to - ensure that, in the event an Application does not supply the - function or data, the facility still operates, and performs - whatever part of its purpose remains meaningful, or - - b) under the GNU GPL, with none of the additional permissions of - this License applicable to that copy. - - 3. Object Code Incorporating Material from Library Header Files. - - The object code form of an Application may incorporate material from -a header file that is part of the Library. You may convey such object -code under terms of your choice, provided that, if the incorporated -material is not limited to numerical parameters, data structure -layouts and accessors, or small macros, inline functions and templates -(ten or fewer lines in length), you do both of the following: - - a) Give prominent notice with each copy of the object code that the - Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the object code with a copy of the GNU GPL and this license - document. - - 4. Combined Works. - - You may convey a Combined Work under terms of your choice that, -taken together, effectively do not restrict modification of the -portions of the Library contained in the Combined Work and reverse -engineering for debugging such modifications, if you also do each of -the following: - - a) Give prominent notice with each copy of the Combined Work that - the Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the Combined Work with a copy of the GNU GPL and this license - document. - - c) For a Combined Work that displays copyright notices during - execution, include the copyright notice for the Library among - these notices, as well as a reference directing the user to the - copies of the GNU GPL and this license document. - - d) Do one of the following: - - 0) Convey the Minimal Corresponding Source under the terms of this - License, and the Corresponding Application Code in a form - suitable for, and under terms that permit, the user to - recombine or relink the Application with a modified version of - the Linked Version to produce a modified Combined Work, in the - manner specified by section 6 of the GNU GPL for conveying - Corresponding Source. - - 1) Use a suitable shared library mechanism for linking with the - Library. A suitable mechanism is one that (a) uses at run time - a copy of the Library already present on the user's computer - system, and (b) will operate properly with a modified version - of the Library that is interface-compatible with the Linked - Version. - - e) Provide Installation Information, but only if you would otherwise - be required to provide such information under section 6 of the - GNU GPL, and only to the extent that such information is - necessary to install and execute a modified version of the - Combined Work produced by recombining or relinking the - Application with a modified version of the Linked Version. (If - you use option 4d0, the Installation Information must accompany - the Minimal Corresponding Source and Corresponding Application - Code. If you use option 4d1, you must provide the Installation - Information in the manner specified by section 6 of the GNU GPL - for conveying Corresponding Source.) - - 5. Combined Libraries. - - You may place library facilities that are a work based on the -Library side by side in a single library together with other library -facilities that are not Applications and are not covered by this -License, and convey such a combined library under terms of your -choice, if you do both of the following: - - a) Accompany the combined library with a copy of the same work based - on the Library, uncombined with any other library facilities, - conveyed under the terms of this License. - - b) Give prominent notice with the combined library that part of it - is a work based on the Library, and explaining where to find the - accompanying uncombined form of the same work. - - 6. Revised Versions of the GNU Lesser General Public License. - - The Free Software Foundation may publish revised and/or new versions -of the GNU Lesser General Public License from time to time. Such new -versions will be similar in spirit to the present version, but may -differ in detail to address new problems or concerns. - - Each version is given a distinguishing version number. If the -Library as you received it specifies that a certain numbered version -of the GNU Lesser General Public License "or any later version" -applies to it, you have the option of following the terms and -conditions either of that published version or of any later version -published by the Free Software Foundation. If the Library as you -received it does not specify a version number of the GNU Lesser -General Public License, you may choose any version of the GNU Lesser -General Public License ever published by the Free Software Foundation. - - If the Library as you received it specifies that a proxy can decide -whether future versions of the GNU Lesser General Public License shall -apply, that proxy's public statement of acceptance of any version is -permanent authorization for you to choose that version for the -Library. diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..2d1d23e1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2010, Met Office. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. 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. + +3. 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/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..a9af457b --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,92 @@ +# NOTE: based on a generic template in Scitools/.github/templates/MANIFEST.in + +# General principles +# - enable user to build locally, as well as in CI +# - make it ignore temporary files generated by dev processes, e.g. coverage checks +# - encode typical decisions, e.g. whether we package docs, requirements etc + +#--------- +# SECTION: main code sources +# +recursive-include src *.cml *.grib2 *.json *.nc *.py + # principles: + # - *no* "prune" command is used + # - use "include-recursive", by relevant file extensions + # hints: + # - top-level dir is typically "src", but maybe "lib" or + # - default package rules mean we generally don't actually *need* a statement + # - but it's better to be explicit + # - extension filetypes are typically sources (*.py) + # - might also need testdata files, e.g. *.nc, *.npy *.npz + # - also possibly non-python, e.g. *.pyx for Cython + + +#--------- +# SECTION: requirements +prune requirements +recursive-include requirements *.txt + # principles: + # include just requirements-level info, not lock files + # hints: + # - not all projects include requirements, but they can be drawn in anyway by dynamic dependencies + # in the setuptools build process, linked via config in pyproject.toml + + +#--------- +# SECTION: root files +exclude .flake8 +exclude .git-blame-ignore-revs +exclude .git_archival.txt +exclude .gitattributes +exclude .gitignore +exclude .lycheeignore +exclude .mailmap +exclude .pre-commit-config.yaml +exclude .readthedocs.yml +exclude .ruff.toml +exclude CHANGES +include CHANGELOG.md +include CITATION.cff +exclude CODE_OF_CONDUCT.md +exclude CONTRIBUTING.md +include COPYING +include COPYING.LESSER +include INSTALL +include LICENSE +exclude Makefile +exclude codecov.yml +include noxfile.py +include tox.ini +exclude pixi.lock + # principles: + # - *ANY* file in the root should be explicitly "include"- or "exclude"-d + # - EXCEPT (possibly) those covered by setuptools default rules (see above link) + # - including : README.md/.rst; pyproject.toml; setup.py/.cfg + # - N.B. a GHA "ci-manifest" check, if used, will check all this + # - the above are typical ones : given in sorted order + # - NB many will (eventually) be templated, but that is a separate issue + # - probably, this section can be included as *boilerplate* + # - i.e. it doesn't matter if some of the files mentioned don't exist + + +#--------- +# SECTION: generic exclusions +# (1) top-level directories to omit entirely +prune .github +prune .nox +prune .tox +prune .coverage +prune docs +# (2) top-level files to omit +exclude .coveragerc +# (3) file types (path patterns) to skip everywhere +global-exclude *.py[cod] +global-exclude __pycache__ + # principles: + # - common directories, files and file-types to be generally ignored + # - all outside version control, temporary non-coding output and cache data + # produced by dev processes, automation or user tools + # - by having this section LAST, it can remove files which might have been added by + # previous sections -- such as python compiler cache files + # - can include this section as **boilerplate** : + # - won't all exist in every repo, but including them all does no harm diff --git a/README.md b/README.md new file mode 100644 index 00000000..baddfa6d --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +

+ + Iris GRIB
+

+ + +

+ GRIB (GRIdded Binary) interface for Iris +

+ +| | | +|------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| ⚙️ CI | [![ci-manifest](https://github.com/SciTools/iris-grib/actions/workflows/ci-manifest.yml/badge.svg)](https://github.com/SciTools/iris-grib/actions/workflows/ci-manifest.yml) [![ci-tests](https://github.com/SciTools/iris-grib/actions/workflows/ci-tests.yml/badge.svg)](https://github.com/SciTools/iris-grib/actions/workflows/ci-tests.yml) [![pre-commit](https://results.pre-commit.ci/badge/github/SciTools/iris-grib/main.svg)](https://results.pre-commit.ci/latest/github/SciTools/iris-grib/main) | +| 💬 Community | [![Contributor Covenant](https://img.shields.io/badge/contributor%20covenant-2.1-4baaaa.svg)](https://www.contributor-covenant.org/version/2/1/code_of_conduct/) [![GH Discussions](https://img.shields.io/badge/github-discussions%20%F0%9F%92%AC-yellow?logo=github&logoColor=lightgrey)](https://github.com/SciTools/iris-grib/discussions) | +| 📖 Documentation | [![rtd](https://readthedocs.org/projects/iris-grib/badge/?version=latest)](https://iris-grib.readthedocs.io/en/latest/?badge=latest) [![Check Links](https://github.com/SciTools/iris-grib/actions/workflows/ci-linkchecks.yml/badge.svg)](https://github.com/SciTools/iris-grib/actions/workflows/ci-linkchecks.yml) | +| 📈 Health | [![codecov](https://codecov.io/gh/SciTools/iris-grib/graph/badge.svg?token=5VtBaElXFW)](https://codecov.io/gh/SciTools/iris-grib) | +| ✨ Meta | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) [![NEP29](https://raster.shields.io/badge/follows-NEP29-orange.png)](https://numpy.org/neps/nep-0029-deprecation_policy.html) [![license - bds-3-clause](https://img.shields.io/github/license/SciTools/iris-grib)](https://github.com/SciTools/iris-grib/blob/main/LICENSE) [![conda platform](https://img.shields.io/conda/pn/conda-forge/iris-grib.svg)](https://anaconda.org/conda-forge/iris-grib) | +| 📦 Package | [![conda-forge](https://img.shields.io/conda/vn/conda-forge/iris-grib?color=orange&label=conda-forge&logo=conda-forge&logoColor=white)](https://anaconda.org/conda-forge/iris-grib) [![pypi](https://img.shields.io/pypi/v/iris-grib?color=orange&label=pypi&logo=python&logoColor=white)](https://pypi.org/project/iris-grib/) [![pypi - python version](https://img.shields.io/pypi/pyversions/iris-grib.svg?color=orange&logo=python&label=python&logoColor=white)](https://pypi.org/project/iris-grib/) | +| 🧰 Repo | [![commits-since](https://img.shields.io/github/commits-since/SciTools/iris-grib/latest.svg)](https://github.com/SciTools/iris-grib/commits/main) [![contributors](https://img.shields.io/github/contributors/SciTools/iris-grib)](https://github.com/SciTools/iris-grib/graphs/contributors) [![release](https://img.shields.io/github/v/release/scitools/iris-grib)](https://github.com/SciTools/iris-grib/releases) | +| | + +

+For documentation see the +latest +developer version or the most recent released +stable version. +

+ +## [#ShowYourStripes](https://showyourstripes.info/s/globe) + +

+ + #showyourstripes Global 1850-2021 +

+ +**Graphics and Lead Scientist**: [Ed Hawkins](https://www.met.reading.ac.uk/~ed/home/index.php), National Centre for Atmospheric Science, University of Reading. + +**Data**: Berkeley Earth, NOAA, UK Met Office, MeteoSwiss, DWD, SMHI, UoR, Meteo France & ZAMG. + +

+#ShowYourStripes is distributed under a +Creative Commons Attribution 4.0 International License + + creative-commons-by +

+ diff --git a/README.rst b/README.rst deleted file mode 100644 index ff31cc6b..00000000 --- a/README.rst +++ /dev/null @@ -1,39 +0,0 @@ -iris-grib -========= - -|Travis|_ |Coveralls|_ - -GRIB interface for `Iris `_. - -Code ----- -iris-grib is licenced under the GNU Lesser General Public License (LGPLv3). - -The full text of the licence can be found in the COPYING and COPYING.LESSER -files. - -Copyright and licence ---------------------- - -\(C) British Crown Copyright 2010 - 2016, Met Office - -This file is part of iris-grib. - -iris-grib is free software: you can redistribute it and/or modify it under -the terms of the GNU Lesser General Public License as published by the -Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -iris-grib is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Lesser General Public License for more details. - -You should have received a copy of the GNU Lesser General Public License -along with iris-grib. If not, see ``_. - -.. |Travis| image:: https://travis-ci.org/SciTools/iris-grib.svg?branch=master -.. _Travis: https://travis-ci.org/SciTools/iris-grib - -.. |Coveralls| image:: https://coveralls.io/repos/github/SciTools/iris-grib/badge.svg?branch=master -.. _Coveralls: https://coveralls.io/github/SciTools/iris-grib?branch=master diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..b41476dc --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +coverage: + # see https://docs.codecov.com/docs/commit-status + status: + project: + default: + target: auto + threshold: 5% # coverage can drop by up to % while still posting success + patch: off diff --git a/docs/Makefile b/docs/Makefile index 6e35eea2..3f197d11 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -19,6 +19,9 @@ ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . +# See https://www.sphinx-doc.org/en/master/man/sphinx-build.html?highlight=--keep-going#cmdoption-sphinx-build-W +WARNING_TO_ERROR = -W --keep-going + .PHONY: help help: @echo "Please use \`make ' where is one of" @@ -44,7 +47,6 @@ help: @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" @echo " coverage to run coverage check of the documentation (if enabled)" @echo " dummy to check syntax errors of document sources" @@ -52,10 +54,11 @@ help: .PHONY: clean clean: rm -rf $(BUILDDIR)/* + rm -rf ./api .PHONY: html html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html + $(SPHINXBUILD) $(WARNING_TO_ERROR) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." @@ -192,13 +195,6 @@ changes: @echo @echo "The overview file is in $(BUILDDIR)/changes." -.PHONY: linkcheck -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - .PHONY: doctest doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest diff --git a/docs/_static/iris-logo-title-dark.svg b/docs/_static/iris-logo-title-dark.svg new file mode 100644 index 00000000..44d8a3bb --- /dev/null +++ b/docs/_static/iris-logo-title-dark.svg @@ -0,0 +1,108 @@ + + \ No newline at end of file diff --git a/docs/_static/iris-logo-title.svg b/docs/_static/iris-logo-title.svg new file mode 100644 index 00000000..f1e7e236 --- /dev/null +++ b/docs/_static/iris-logo-title.svg @@ -0,0 +1,108 @@ + + \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 799300d3..e6f26ae8 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -12,258 +12,250 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys -import os +from importlib.metadata import version as get_version +from pathlib import Path -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.doctest', - 'sphinx.ext.intersphinx', + "sphinx.ext.apidoc", # included AS WELL AS autodoc : adds api in main build + "sphinx.ext.autodoc", + "sphinx.ext.doctest", + "sphinx.ext.intersphinx", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = {".rst": "restructuredtext"} # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'iris-grib' -copyright = u'2016, Met Office' -author = u'Met Office' +project = "iris-grib" +copyright = "2022, Met Office" +author = "Met Office" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # -# The short X.Y version. -version = u'0.9' -# The full version, including alpha/beta/rc tags. -release = u'0.9.0' +release = get_version("iris-grib") # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +# language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # The reST default role (used for this markup: `text`) to use for all # documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False +# -- Autodoc ------------------------------------------------------------------ + +autodoc_member_order = "groupwise" +autodoc_default_flags = ["show-inheritance"] + # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'sphinx_rtd_theme' +html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. # " v documentation" by default. -#html_title = u'iris-grib v0.1.0.dev0' +# html_title = u'iris-grib v0.1.0.dev0' # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (relative to this directory) to use as a favicon of -# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 +# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +# html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. -#html_extra_path = [] +# html_extra_path = [] # If not None, a 'Last updated on:' timestamp is inserted at every page # bottom, using the given strftime format. # The empty string is equivalent to '%b %d, %Y'. -#html_last_updated_fmt = None +# html_last_updated_fmt = None # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' -#html_search_language = 'en' +# html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # 'ja' uses this config value. # 'zh' user can custom change `jieba` dictionary path. -#html_search_options = {'type': 'default'} +# html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' +# html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. -htmlhelp_basename = 'iris-gribdoc' +htmlhelp_basename = "iris-gribdoc" # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', - -# Latex figure (float) alignment -#'figure_align': 'htbp', + # The paper size ('letterpaper' or 'a4paper'). + # 'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + # 'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + # 'preamble': '', + # Latex figure (float) alignment + # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, 'iris-grib.tex', u'iris-grib Documentation', - u'Met Office', 'manual'), + (master_doc, "iris-grib.tex", "iris-grib Documentation", "Met Office", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'iris-grib', u'iris-grib Documentation', - [author], 1) -] +man_pages = [(master_doc, "iris-grib", "iris-grib Documentation", [author], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -272,23 +264,48 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - (master_doc, 'iris-grib', u'iris-grib Documentation', - author, 'iris-grib', 'One line description of project.', - 'Miscellaneous'), + ( + master_doc, + "iris-grib", + "iris-grib Documentation", + author, + "iris-grib", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'https://docs.python.org/': None} +intersphinx_mapping = { + "python": ("https://docs.python.org/3/", None), + "iris": ("https://scitools-iris.readthedocs.io/en/stable/", None), +} + +# -- apidoc extension --------------------------------------------------------- +# See https://www.sphinx-doc.org/en/master/usage/extensions/apidoc.html +docs_path = Path(__file__).parent.absolute() +main_module_rootpath = (docs_path.parent / "src" / "iris_grib").absolute() +moduledir_str = str(main_module_rootpath) +destpath_str = str(docs_path / "api") +moduletestdir_str = str(main_module_rootpath / "tests") +apidoc_modules = [ + { + "path": moduledir_str, + "destination": "./api", + "exclude_patterns": [moduletestdir_str], + "module_first": True, + }, +] diff --git a/docs/explanation/phenom_translation.rst b/docs/explanation/phenom_translation.rst new file mode 100644 index 00000000..0759477f --- /dev/null +++ b/docs/explanation/phenom_translation.rst @@ -0,0 +1,33 @@ +Phenomenon translation +====================== + +``iris-grib`` attempts to translate between CF phenomenon identities +(i.e. 'standard_name' and possibly 'long_name' attributes), and GRIB parameter codes, +when converting cubes to or from the GRIB format. + +A set of tables define known CF translations for GRIB1 and GRIB2 parameters, and can be +interrogated with the functions in :mod:`iris_grib.grib_phenom_translation`. + +Parameter loading record +------------------------ +All cubes loaded from GRIB have a ``GRIB_PARAM`` attribute, which records the parameter +encodings present in the original file message. + +Examples : + +* ``"GRIB2:d000c003n005"`` represents GRIB2, discipline=0 ("Meteorological products"), + category=3 ("Mass") and indicatorOfParameter=5 ("Geopotential height (gpm)"). + + * This translates to a standard_name and units of "geopotential_height / m" + +* ``"GRIB1:t002c007n033"`` is GRIB1 with table2Version=2, centre=7 + ("US National Weather Service - NCEP (WMC)"), and indicatorOfParameter=33 + ("U-component of wind m s**-1"). + + * This translates to a standard_name and units of "x_wind / m s-1". + +Parameter saving control +------------------------ +When a cube has a ``GRIB_PARAM`` attribute, as described above, this controls what the +relevant message keys are set to on saving. +(N.B. at present applies only to GRIB2, since we don't support GRIB1 saving) diff --git a/docs/how_to/get_started.rst b/docs/how_to/get_started.rst new file mode 100644 index 00000000..98538453 --- /dev/null +++ b/docs/how_to/get_started.rst @@ -0,0 +1,21 @@ +Getting Started +=============== + +To ensure all ``iris-grib`` dependencies, it is sufficient to have installed +:mod:`Iris ` itself, and +`ecCodes `_ . + +The simplest way to install is with +`conda `_ , using the +`package on conda-forge `_ , +with the command:: + + $ conda install -c conda-forge iris-grib + +Pip can also be used, to install from the +`package on PyPI `_ , +with the command:: + + $ pip install iris-grib + +Development sources are hosted at ``_ . diff --git a/docs/how_to/modify_during_load.rst b/docs/how_to/modify_during_load.rst new file mode 100644 index 00000000..84fb6790 --- /dev/null +++ b/docs/how_to/modify_during_load.rst @@ -0,0 +1,45 @@ +How to modify GRIB content during loading +========================================= + +See also: :doc:`/tutorial/load_save_api`. + +.. testsetup:: + + import iris + import iris_grib + import warnings + + warnings.simplefilter("ignore") + cube = iris.load_cube(iris.sample_data_path("A1B_north_america.nc")) + cube.coord("forecast_period").guess_bounds() + filename = "testfile.grib" + iris.save(cube, filename, saver="grib2") + +Here is a basic example of how you can modify the GRIB messages being loaded, +and how to turn those modified messages into Iris cubes. + + >>> import iris + >>> from iris.cube import CubeList + >>> from iris_grib import GribMessage, load_pairs_from_fields + >>> + >>> def adjust_message(msg: GribMessage) -> GribMessage: + ... # Advance all time steps by 1 hour. + ... if msg.sections[0]["editionNumber"] == 2: + ... s1 = msg.sections[1] + ... s1["hour"] += 1 + ... s4 = msg.sections[4] + ... s4["hourOfEndOfOverallTimeInterval"] += 1 + ... return msg + + >>> original = iris.load_cube(filename) + >>> fields = GribMessage.messages_from_filename(filename) + >>> fields = (adjust_message(msg) for msg in fields) + >>> cubes = CubeList(cube for cube, _ in load_pairs_from_fields(fields)) + >>> modified = cubes.combine_cube() + +Compare the final 5 time steps of the original and modified cubes: + + >>> print(original.coord("time").points[-5:]) + [1084524. 1093236. 1101936. 1110636. 1119336.] + >>> print(modified.coord("time").points[-5:]) + [1084525. 1093237. 1101937. 1110637. 1119337.] diff --git a/docs/how_to/simple_grib_io.rst b/docs/how_to/simple_grib_io.rst new file mode 100644 index 00000000..6d5c4fd0 --- /dev/null +++ b/docs/how_to/simple_grib_io.rst @@ -0,0 +1,29 @@ +Simple GRIB Loading and Saving with Iris +======================================== + +You can use the functionality provided by ``iris-grib`` directly within Iris +without having to explicitly import ``iris-grib``, as long as you have both Iris +and ``iris-grib`` installed in your Python environment. + +**This is the preferred route if no special control is required.** + +.. testsetup:: + + import iris + import iris_grib + import warnings + + warnings.simplefilter("ignore") + cube = iris.load_cube(iris.sample_data_path("rotated_pole.nc")) + iris.save(cube, "testfile.grib", saver="grib2") + +For example, to load GRIB data : + + >>> cube = iris.load_cube('testfile.grib') + +Similarly, you can save cubes to a GRIB file directly from Iris : + + >>> iris.save(cube, 'my_file.grib2') + +.. note:: + As the filename suggests, **only saving to GRIB2 is currently supported**. diff --git a/docs/index.rst b/docs/index.rst index 2ef2f65f..1e1de2bd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,22 +1,91 @@ .. iris-grib documentation master file, created by sphinx-quickstart on Fri May 13 11:48:28 2016. You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. + contain the root ``toctree`` directive. -Welcome to iris-grib's documentation! -===================================== +Iris-grib v0.22 +=============== + +The library ``iris-grib`` provides functionality for converting between weather and +climate datasets that are stored as GRIB files and Iris :class:`~iris.cube.Cube`\s. +GRIB files can be loaded as Iris cubes using ``iris-grib`` so that you can use Iris +for analysing and visualising the contents of the GRIB files. Iris cubes can also be +saved to GRIB edition-2 files using ``iris-grib``. + + +New here? +--------- + +We recommend: + +- :doc:`/how_to/get_started` +- :doc:`/how_to/simple_grib_io` + + +Navigating this site +-------------------- + +.. list-table:: + :header-rows: 1 + :stub-columns: 1 + + * - + - Description + - supports ... + - via ... + * - Tutorials + - Guided lessons for understanding a topic. + - study + - action + * - Explanation + - In-depth discussion for understanding concepts. + - study + - theory + * - How-To Guides + - Step by step instructions for achieving a specific goal. + - work + - action + * - Reference + - Concise information to look up when needed. + - work + - theory + +Read more: `Diataxis.fr`_ -Contents: .. toctree:: - :maxdepth: 2 + :maxdepth: 1 + :caption: Tutorials - ref/index + tutorial/load_save_api -Indices and tables -================== +.. toctree:: + :maxdepth: 1 + :caption: Explanation + + explanation/phenom_translation + +.. toctree:: + :maxdepth: 1 + :caption: How-To Guides + + how_to/get_started + how_to/simple_grib_io + how_to/modify_during_load + +.. toctree:: + :maxdepth: 1 + :caption: Reference + + reference/release_notes + Iris-grib API + + +See also +-------- * :ref:`genindex` * :ref:`modindex` -* :ref:`search` + +.. _Diataxis.fr: https://diataxis.fr/ diff --git a/docs/make.bat b/docs/make.bat index d485d3bc..b567efac 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -36,7 +36,6 @@ if "%1" == "help" ( echo. changes to make an overview over all changed/added/deprecated items echo. xml to make Docutils-native XML files echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled echo. coverage to run coverage check of the documentation if enabled echo. dummy to check syntax errors of document sources @@ -227,15 +226,6 @@ if "%1" == "changes" ( goto end ) -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 diff --git a/docs/ref/index.rst b/docs/ref/index.rst deleted file mode 100644 index 7e39e274..00000000 --- a/docs/ref/index.rst +++ /dev/null @@ -1,5 +0,0 @@ -Reference documentation -======================= - -.. automodule:: iris_grib - :members: diff --git a/docs/reference/release_notes.rst b/docs/reference/release_notes.rst new file mode 100644 index 00000000..381a87f6 --- /dev/null +++ b/docs/reference/release_notes.rst @@ -0,0 +1,820 @@ +.. _release_notes: + +Release Notes +============= + +What's new in iris-grib v0.23.0 +------------------------------- + +:Release: 0.23.0 +:Date: TBC + +Internal +^^^^^^^^ +* `@trexfeathers `_ switched the link check CI + to be the standard SciTools overnight job using Lychee. + `(PR#795) `_ + + +What's new in iris-grib v0.22.0 +------------------------------- + +:Release: 0.22.0 +:Date: 24 March 2026 + +Features +^^^^^^^^ +* `@MoseleyS `_ added support for loading GRIB2 data + found in the Met Office wholesale Weather Data Hub and CEDA archive. + `(PR#647) `_ + +* `@andrewgryan `_ and + `@trexfeathers `_ added saving support for + Earth shape 6 - fixed radius 6,371,229 m. + `(PR#712) `_ + + +Fixes +^^^^^ +* `@pp-mo `_ fixed the setting of + :class:`~iris_grib.grib_message.GribMessage` values, making it possible to + set values for keys that have not been read yet. + `(PR#766) `_ + +* `@pp-mo `_ fixed a problem of scaled integers + rounding incorrectly, resulting from the transition to NumPy v2. + `(PR#767) `_ + + +Documentation +^^^^^^^^^^^^^ +* `@trexfeathers `_ reorganised the + documentation to be easier to navigate, using the Diataxis framework. + `(PR#768) `_ + +Dependencies +^^^^^^^^^^^^ +* `@trexfeathers `_ updated the supported + Python versions to be 3.12, 3.13, 3.14; in line with + `SPEC 0 `_. + `(PR#770) `_ + +* `@trexfeathers `_ updated the minimum + supported Iris version to >=3.10, owing to some internal changes in Iris-grib. + `(PR#770) `_ + +* Iris-grib is continually updated to test against the latest version of its + dependencies, allowing us to respond promptly to any breaking changes, and to + provide a record of what changed and when. + +* Iris-grib is continually updated to follow the latest coding standard + conventions in scientific Python - via + `repo-review `_ + and `pre-commit `_ - maximising the ease of + contribution and maintenance. + + +Internal +^^^^^^^^ +* `@ESadek-MO `_ improved Pickle testing to not + rely on Iris, and to use PyTest. + `(PR#620) `_ + +* `@stephenworsley `_ increased Iris-grib's + compliance with Scientific Python's + `repo-review `_. + `(PR#638) `_ + +* `@pp-mo `_ added testing for reading/writing + ``GRIB_PARAM`` attributes in NetCDF files. + `(PR#677) `_ + +* Various improvements to coding standards ('linting'): + + * `PR#599 `_ + * `PR#625 `_ + * `PR#667 `_ + * `PR#694 `_ + * `PR#758 `_ + +* `@pp-mo `_ dropped the outdated use of the + ``np.floating`` type. `PR#664 `_ + +* `@trexfeathers `_ adapted tests for upstream + changes in ecCodes test data (ECC-2110). + `(PR#716) `_ + +* `@trexfeathers `_ adapted tests for an + upstream fix in cf-time date2num conversion (cftime#355). + `(PR#748) `_ + +* `@pp-mo `_ and + `@trexfeathers `_ are working through a + large refactor so that GRIB1 and GRIB2 support share as much code as possible. + Progress in this release: `PR#738 `_ + +* `@ukmo-ccbunney `_ and + `@ESadek-MO `_ aligned ``CITATION.cff`` and + ``MANIFEST.in`` with agreed SciTools standards. + `(PR#675) `_, + `(PR#676) `_ + + +New Contributors +^^^^^^^^^^^^^^^^ +Welcome to + +* `@MoseleyS `_ +* `@andrewgryan `_ +* `@ukmo-ccbunney `_ + + +What's new in iris-grib v0.21.0 +------------------------------- + +:Release: 0.21.0 +:Date: 07 January 2025 + +Dependencies +^^^^^^^^^^^^ +* `@pp-mo `_ added a fix to support ecCodes versions >= 2.38 + `(PR#578) `_ +* `@pp-mo `_ fixed tests to work with numpy versions >= 2.0 + `(PR#580) `_ + +Internal +^^^^^^^^ +* `@trexfeathers `_ added integration tests to ensure + code coverage of GRIB1 loading code, to enable future refactoring. + `(ISSUE#488) `_, + `(PR#583) `_ +* `@pp-mo `_ updated repository automation to meet current + SciTools code quality recommendations + `(PR#568) `_, + `(PR#572) `_ + + +What's new in iris-grib v0.20.0 +------------------------------- + +:Release: 0.20.0 +:Date: 29 August 2024 + +Features +^^^^^^^^ +* `@abooton `_ added support for saving data on a + Lambert Azimuthal Equal Area (LAEA) projection, as grid definition template 3.140. + `(ISSUE#344) `_, + `(PR#343) `_ + +* `@trexfeathers `_, + `@mo-marqh `_ and + `@pp-mo `_ added support for production definition template + 4.6, i.e. percentile forecasts. + `(PR#401) `_, + `(PR#295) `_, + `(PR#271) `_ + +* `@pp-mo `_ expanded the use of the "GRIB_PARAM" + attributes to GRIB1 loading, and documented it more thoroughly. + `(ISSUE#330) `_, + `(PR#402) `_ + +* `@DPeterK `_ and + `@trexfeathers `_ added saving support for + grid definition template 20 - polar stereographic. + `(ISSUE#122) `_, + `(PR#405) `_ + + +Documentation +^^^^^^^^^^^^^ +* `@tkknight `_ fixed docs building on ReadTheDocs, and + enabled a test docs-build for each individual PR. + `(ISSUE#365) `_, + `(PR#366) `_ + +* `@tkknight `_ made docs builds treat warnings as errors. + `(PR#471) `_ + +* `@pp-mo `_ reworked the main docs page to : + headline basic load + save with Iris, rather than lower-level functions; + better explain load-pairs and save-pairs usage; make all usage examples into + doctests. + `(ISSUE#398) `_, + `(PR#402) `_ + +* `@bjlittle `_ updated the readme, replacing README.rst + with README.md and adding a logo . + `(PR#440) `_, + `(PR#447) `_ + +* `@tkknight `_ fixed the display formatting of linux + commands. + `(PR#455) `_ + + +Dependencies +^^^^^^^^^^^^ +* `@pp-mo `_ enabled support for + `eccodes v2.36 `_. + Eccodes v2.36 has implemented some backwards incompatible changes : + The ``indicatorOfUnitOfTimeRange`` key was removed, to be replaced with + ``indicatorOfUnitForForecastTime`` (but only in GRIB v2 messages, not GRIB 1); + and the ``iScansPositively`` and ``jScansPositively`` keys became read-only. + The resulting changes mean **we now only support eccodes >=2.33**. + `(PR#504) `_ + +* `@bjlittle `_ added iris-sample-data as a dependency, + as required for doctests. + `(PR#413) `_ + +* `@pp-mo `_ made essential changes for compatibility with + Iris >= 3.10. + `(PR#463) `_ + + +Internal +^^^^^^^^ +* `@trexfeathers `_ updated CONTRIBUTING.md in line with the + newer v5 SciTools CLA. `(PR#371) `_ + +* `@pp-mo `_ updated an obsolete license header in one test. + `(PR#374) `_ + +* `@trexfeathers `_ and + `@pp-mo `_ added a pre-commit configuration and got all + checks passing. + `(ISSUE#388) `_, + `(PR#400) `_, + `(PR#406) `_ + +* `@HGWright `_ and + `@trexfeathers `_ replaced setup.py with + pyproject.toml. + `(ISSUE#387) `_, + `(PR#408) `_, + `(PR#429) `_ + +* `@stephenworsley `_ configured for MyPy checking via + pre-commit. + `(ISSUE#386) `_, + `(PR#407) `_ + +* `@ESadek-MO `_ and + `@bjlittle `_ migrated CI testing from Cirrus to + GitHub Actions. + `(ISSUE#340) `_, + `(PR#415) `_, + `(PR#425) `_, + `(PR#432) `_ + +* `@trexfeathers `_ and + `@HGWright `_ adopted Ruff for code style checking. + `(ISSUE#384) `_, + `(PR#430) `_, + `(PR#419) `_ + +* `@bjlittle `_ migrated the test runs from + nose to pytest. + `(ISSUE#253) `_, + `(ISSUE#412) `_, + `(PR#420) `_, + `(PR#424) `_ + +* `@stephenworsley `_ removed the now-redundant + _iris_mercator_support.py. + `(ISSUE#431) `_, + `(PR#433) `_, + `(PR#435) `_ + +* `@bjlittle `_ added build manifest checking in GHA. + `(PR#427) `_, + `(PR#436) `_, + `(PR#441) `_ + +* `@bjlittle `_ added dependabot checking. + `(PR#426) `_ + +* `@bjlittle `_ removed 'wheel' dependency from build + system, as-per + `repo-review `_. + `(PR#437) `_ + +* `@bjlittle `_ fixed blacken-docs url in pre-commit, + as per + `repo-review `_. + `(PR#438) `_ + +* `@bjlittle `_ provided a custom per-commit.ci message, + as per + `repo-review `_. + `(PR#439) `_ + +* `@pp-mo `_ removed obsolete workaround routines relating to + older eccodes versions. + `(ISSUE#239) `_, + `(PR#410) `_ + +* `@HGWright `_ implemented version handling with + setuptools.scm . + `(ISSUE#418) `_, + `(PR#444) `_ + +* `@bjlittle `_ moved the top-level ``./iris_grib`` folder + to ``./src/iris_grib``, in line with modern practice, as per + `repo-review `_. + `(ISSUE#421) `_, + `(PR#450) `_ + +* `@bjlittle `_ adopted .git-blame-ignore-revs to exclude + some very noisy PRs from file "blame" views + `(PR#452) `_ + +* `@bjlittle `_ dropped Python 3.9 support and added 3.12, + in accordance with `nep29 `_. + `(PR#453) `_ + +* `@bjlittle `_ updated all optional dependency + requirements, and added codecov support. + `(PR#454) `_, + `(PR#459) `_ + +* `@bjlittle `_ added repository health checking with + `repo-review `_ + via pre-commit. + `(ISSUE#392) `_, + `(PR#456) `_ + +* `@bjlittle `_ added a CODE_OF_CONDUCT.md . + `(PR#460) `_ + +* `@bjlittle `_ aligned .gitignore with a suggested + standard form + `(PR#461) `_ + +* `@bjlittle `_ fixed some spelling errors to satisfy + codespell + `(PR#479) `_ + +* `@githubalexliu `_ fixed a problem with the MyPy + checking. + `(ISSUE#496) `_, + `(PR#497) `_ + +* `@trexfeathers `_ aligned the pre-commit-config with + the SciTools "reference" version. + `(PR#464) `_, + + +New Contributors +^^^^^^^^^^^^^^^^ +Welcome to + +* `@abooton `_ +* `@githubalexliu `_ +* `@stephenworsley `_ +* `@tkknight `_ fixed the display formatting of linux +* `@DPeterK `_ +* `@ESadek-MO `_ +* `@HGWright `_ + + +What's new in iris-grib v0.19.1 +------------------------------- + +:Release: 0.19.1 +:Date: 14 December 2023 + +Documentation +^^^^^^^^^^^^^ +* `@pp-mo `_ updated the release notes with v0.19 changes. + `(PR#370) `_ + + +What's new in iris-grib v0.19.0 +------------------------------- + +:Release: 0.19.0 +:Date: 16 November 2023 + +See also : +`GitHub v0.19.0 release page `_ + +Features +^^^^^^^^ +* `@lbdreyer `_ and + `@pp-mo `_ (reviewer) modified the loading of GRIB + messages with an unrecognised fixed surface type. These are now loaded in as + an unnamed coordinate with an attribute called GRIB_fixed_surface_type. + iris-grib will also save out cubes with this attribute as the given fixed + surface type. `(PR#318) `_ + +* `@trexfeathers `_ extended Transverse Mercator + to support negative scanning. + `(PR#296) `_ + +* `@trexfeathers `_ added a number of new GRIB-CF + mappings, i.e. translations from GRIB parameters to CF standard names and vice-versa. + `(PR#297) `_ + +Bugs Fixed +^^^^^^^^^^ +* `@lbdreyer `_ and + `@pp-mo `_ (reviewer) modified the GRIB1 loading + code so that it no longer assumes a spherical Earth with radius of 6371229 m + and instead uses the resolutionAndComponentFlag to determine the shape of the + Earth. This can either be a spherical Earth with radius of 6367470 m or an + oblate spheroid, the latter of which is not supported. Note that this change + in Earth's radius will result in a different coordinate system and may also + affect the coordinate values. + `(PR#316) `_ +* `@s-boardman `_ corrected the calculation of bounded + forecast periods in GRIB1 loading. + `(PR#322) `_ +* `@david-bentley `_ fixed the calculation of message + file offsets to work in Windows as well as Linux, which was causing load failures. + `(PR#287) `_ +* `@bjlittle `_ fixed an error that occurred when a + message had all-missing data points. + `(PR#362) `_ + + +Internal +^^^^^^^^ +* `@lbdreyer `_ relicensed the repo from LGPL-3 to BSD-3. + `(PR#359) `_ + +Dependencies +^^^^^^^^^^^^ +* now requires Python version >= 3.9 +* replaced deprecated eccodes-python PyPI package with new eccodes by @valeriupredoi in #357 +* `@valeriupredoi `_ replaced the deprecated + eccodes-python PyPI package with eccodes. + `(PR#357) `_ + +New Contributors +^^^^^^^^^^^^^^^^ +Welcome to + +* `@s-boardman `_ +* `@david-bentley `_ +* `@valeriupredoi `_ + + +What's new in iris-grib v0.18.0 +------------------------------- + +:Release: 0.18.0 +:Date: 14 March 2022 + +Bugs Fixed +^^^^^^^^^^ +* `@lbdreyer `_ made various updates to allow + iris-grib to work with the latest versions of + `iris `_, + `cf-units `_, + `ecCodes `_ and + `cartopy `_, including casting + the usage of :meth:`cf_units.Unit.date2num` as float. setting and setting the + values of some missing keys using ``gribapi.GRIB_MISSING_LONG``. + `(PR#288) `_ + + +Dependencies +^^^^^^^^^^^^ +* now requires Python version >= 3.8 + + +Internal +^^^^^^^^ +* `@TomDufall `_ updated the code so that it was + `flake8 `_ compliant and enabled flake8 + checks to the CI. + `(PR#271) `_ + + +What's new in iris-grib v0.17.1 +------------------------------- + +:Release: 0.17.1 +:Date: 8 June 2021 + +Bugs Fixed +^^^^^^^^^^ + +* `@TomDufall `_ removed the empty slice + handling (originally added in v0.15.1) as this used + iris.util._array_slice_ifempty which was removed in Iris v3.0.2 and is no + longer necessary. + `(PR#270) `_ + + +Dependencies +^^^^^^^^^^^^ + +* now requires Iris version >= 3.0.2. + +* now requires Python version >= 3.7. + + + +What's new in iris-grib v0.17 +----------------------------- + +:Release: 0.17.0 +:Date: 18 May 2021 + +Features +^^^^^^^^ + +* `@m1dr `_ added support for GRIB regulation 92.1.8 + for loading GRIB files where the longitude increment is not given. + `(PR#261) `_ + +* `@lbdreyer `_ added support for loading grid + point and spectral data with CCSDS recommended lossless compression, i.e. + data representation template 42. + `(PR#264) `_ + + +Internal +^^^^^^^^ + +* `@jamesp `_ moved CI testing to Cirrus CI. + `(PR#250) `_ + + + +What's new in iris-grib v0.16 +----------------------------- + +:Release: 0.16.0 +:Date: 27 Jan 2021 + +Features +^^^^^^^^ + +* `@tpowellmeto `_ added support for loading + data on a "Lambert Azimuthal Equal Area Projection", + i.e. grid definition template 3.140. + `(PR#187) `_ + +* `@bjlittle `_ made all the tests runnable for a + packaged install of iris-grib, where the grib testdata files will be missing. + `(PR#212) `_ + +* `@m1dr `_ added support for loading statistical + fields, as encoded in production definition template 3.8, even when the + "interval time increment" value is not specified (i.e. set to "missing"). + `(PR#206) `_ + +* `@pp-mo `_ ported some tests from Iris, which test + grib saving of data loaded from other formats. + `(PR#213) `_ + +* All grib-dependent testing is now contained in iris-grib : **There are no + remaining tests in Iris which use grib.** + + +Bugs Fixed +^^^^^^^^^^ + +* `@lbdreyer `_ unpinned the python-eccodes + version for Travis testing, and added a workaround for a known bug in recent + versions of python-eccodes. + Previously, we could only test against python-eccodes versions ">=0.9.1,<2". + `(PR#208) `_ + +* `@pp-mo `_ fixed save operations to round off the + the integer values of vertical surfaces, instead of truncating them. + `(PR#210) `_ + +* `@pp-mo `_ fixed loading of grid definition + template 3.90, "Space view perspective or orthographic grid", which was + **broken since Iris 2.3**. This now produces data with an iris + `Geostationary `_ + coordinate system. Prior to Iris 2.3, what is now the Iris 'Geostationary' + class was (incorrectly) named "VerticalPerspective" : When that was + `corrected in Iris 2.3 `_ , it + broke the iris-grib loading, since the data was now incorrectly + assigned the "new-style" Iris + `VerticalPerspective `_ + coordinate system, equivalent to the Cartopy + `NearsidePerspective `_ + and Proj + `"nsper" `_ . + The plotting behaviour of this is now **the same again as before Iris 2.3** : + only the Iris coordinate system has changed. + `(PR#223) `_ + +* `@pp-mo `_ fixed a problem where cubes were loading from GRIB 1 with a changed coordinate + system, since eccodes versions >= 1.19. This resulted from a change to eccodes, which now returns a different + 'shapeOfTheEarth' parameter. This resulted + in a coordinate system with a different earth radius. + For backwards compatibility, the earth radius has now been fixed to the same value as previously. + However, pending further investigation, this value may be technically incorrect and we may + yet decide to change it in a future release. + `(PR#240) `_ + + +Dependencies +^^^^^^^^^^^^ + +* now requires Iris version >= 3.0 + Needed for the bugfix in + `PR#223 `_ . + + + +What's new in iris-grib v0.15.1 +------------------------------- + +:Release: 0.15.1 +:Date: 24 Feb 2020 + +Bugs Fixed +^^^^^^^^^^ + +* `@pp-mo `_ fixed a problem that caused very slow + loading, and possible memory overflows, with Dask versions >= 2.0. + **This requires Iris >= 2.4**, as a new minimum dependency. + ( This problem was shared with UM file access in Iris, fixed in Iris 2.4. + `(PR#190) `_ + +* `@trexfeathers `_ fixed all the tests to + work with the latest Iris version, previously broken since Iris >= 2.3. + `(PR#184) `_ + and `(PR#185) `_ + +* `@lbdreyer `_ fixed a problem with the metadata + in setup.py. + `(PR#183) `_ + + +Internal +^^^^^^^^ + +* `@lbdreyer `_ and + `@pp-mo `_ ported various grib-specific tests from + Iris. + ( `PR#191 `_ , + `PR#192 `_ , + `PR#194 `_ , + `PR#195 `_ , + `PR#198 `_ , + `PR#199 `_ , + `PR#200 `_ , + `PR#201 `_ and + `PR#203 `_ ) + +Dependencies +^^^^^^^^^^^^ + +* now requires Iris version >= 2.4 + Needed for the bugfix in + `PR#190 `_ . + + +What's new in iris-grib v0.15 +----------------------------- + +:Release: 0.15.0 +:Date: 5 Dec 2019 + +Features +^^^^^^^^ + +* Updated translations between GRIB parameter code and CF standard_name or + long_name : + + * additional WAFC codes, both to and from CF + * 'mass_fraction_of_cloud_liquid_water_in_air' and 'mass_fraction_of_cloud_ice_in_air', both to and from CF + * 'surface_downwelling_longwave_flux_in_air', now translates to GRIBcode(2, 0, 5, 3) (but not the reverse). + * for full details, see : https://github.com/Scitools/iris-grib/compare/c4243ae..5c314e3#diff-cf46b46880cae59e82a91c7ab6bb81ba + +* Added support for loading GRIB messages with no fixed surface set in the + product definition section + +* Added support for loading GRIB messages where i or j increment are not set + +* Added support for saving cubes that have a "depth" coordinate + +* Cubes loaded from GRIB files now contain a new GRIB_PARAM attribute, the + value of which is an instance of + iris_grib.grib_phenom_translation.GRIBCode and represents the parameter code. + When saving, if a cube has a GRIBCode attribute, this determines the parameter code + in the created message(s): This will _override_ any translation from the CF names. + +Bug Fixes +^^^^^^^^^ + +* Reverted a bug that was fixed in v0.13 related to loading hybrid pressure + levels. It was agreed that the initial behaviour was correct + +Dependencies +^^^^^^^^^^^^ + +* Python 2 is no longer supported + + +What's new in iris-grib v0.14 +----------------------------- + +:Release: 0.14.0 +:Date: 6 Mar 2019 + +Features +^^^^^^^^ + +* Added support for WAFC aviation codes. + +* Added loading and saving of statistically processed values over a spatial + area at a horizontal level or in a horizontal layer at a point in time + (product definition template 15 in code table 4.0) + +:Release: 0.14.1 +:Date: 12 Jun 2019 + +Bug Fixes +^^^^^^^^^ + +* Added fixes to get iris-grib working with the Python 3 compatible release of + eccodes. This included workarounds such that lists that are returned by + eccodes are converted to NumPy arrays as expected. + + +What's new in iris-grib v0.13 +----------------------------- + +:Release: 0.13.0 +:Date: 15 Jun 2018 + +Features +^^^^^^^^ + +* Added saving of data on Hybrid Pressure levels (surface type 119 in + code table 4.5). + +* Added loading and saving of data on Hybrid Height levels (surface type 118 in + code table 4.5). + +* Added loading and saving of data using Mercator projection (grid definition + template 10 in template table 3.1) + + .. note:: + + Loading and saving for the Mercator projection is only available using + iris versions greater than 2.1.0. + +* Added saving for data on irregular, non-rotated grids (grid definition + template 4 in template table 3.1) + +* Added release notes for versions since 0.9. + + +Bug Fixes +^^^^^^^^^ + +* Fixed a bug with loading data on Hybrid Pressure levels (surface types 105 + and 119 in code table 4.5). + Previously, *all* hybrid coordinate values, in both 'level_pressure' and + 'sigma' coordinates, were loaded from the next level up, + i.e. (model_level_number + 1). + + .. note:: + + This changes loading behaviour for data on hybrid pressure levels only. + This is an incompatible change, but the coefficient values previously + returned were essentially useless, with some values missing. + + +What's new in iris-grib v0.12 +----------------------------- + +:Release: 0.12 +:Date: 25 Oct 2017 + +Updated to work with +`ecCodes `_ as its +interface to GRIB files. +This is ECMWF's replacement for the older GRIB-API, which is now deprecated. + + +What's new in iris-grib v0.11 +----------------------------- + +:Release: 0.11 +:Date: 25 Oct 2017 + +Update for Iris v2.0+, using `dask `_ in place of +`biggus `_ for deferred loading. + + +What's new in iris-grib v0.9 +----------------------------- + +:Release: 0.9.0 +:Date: 25 Jul 2016 + +Stable release of iris-grib to support iris v1.10 diff --git a/docs/tutorial/load_save_api.rst b/docs/tutorial/load_save_api.rst new file mode 100644 index 00000000..9e6fe01d --- /dev/null +++ b/docs/tutorial/load_save_api.rst @@ -0,0 +1,160 @@ +Iris-grib Load and Save API +=========================== + +In addition to direct load and save with Iris, as described above, +it is also possible to load and save GRIB data using iris-grib functions. + +.. note:: + + The API here is for special cases. It is usually simplest to load and + save GRIB data using the regular iris load and save functions, as described + at :doc:`/how_to/simple_grib_io`. + +.. testsetup:: + + import iris + import iris_grib + import warnings + + warnings.simplefilter("ignore") + cube = iris.load_cube(iris.sample_data_path("rotated_pole.nc")) + iris.save(cube, "testfile.grib", saver="grib2") + +Loading and saving Cubes +------------------------ + +Load +^^^^ + +To load from a GRIB file with ``iris-grib``, you can call the +:func:`~iris_grib.load_cubes` function : + + >>> cubes_iter = iris_grib.load_cubes('testfile.grib') + >>> print(cubes_iter) + + +As we can see, this returns a generator object. The generator object may be iterated +over to access all the Iris cubes loaded from the GRIB file, or converted directly +to a list:: + + >>> cubes = list(cubes_iter) + >>> print(cubes) + [] + +In effect, this is the same as using ``iris.load_raw(...)``. +So, in most cases, **that is preferable.** + +Save +^^^^ + +To use ``iris-grib`` to save Iris cubes to a GRIB file we can make use of the +:func:`~iris_grib.save_grib2` function : + + >>> iris_grib.save_grib2(cube, 'my_file.grib2') + +In effect, this is the same as using ``iris.save(cube, ...)``. +So, in most cases, **that is preferable.** + + +Working with GRIB messages +-------------------------- + +Iris-grib also provides lower-level functions which allow the user to inspect and +adjust actual GRIB encoding details, for precise custom control of loading and saving. + +These functions use intermediate objects which represent individual GRIB file +"messages", with all the GRIB metadata. + +For example: + +* correct loading of some messages with incorrectly encoded parameter number +* save messages with adjusted parameter encodings +* load messages with an unsupported parameter definition template : adjust them to + mimic a similar type which *is* supported by cube translation, and post-modify the + resulting cubes to correct the Iris metadata + +You can load and save messages to and from files, and convert them to and from Cubes. + +.. note:: + at present this only works with GRIB2 data. + +.. note:: + Messages are not represented in the same way for loading and saving : the messages + generated by loading *from* files are represented by + :class:`iris_grib.message.GribMessage` objects, whereas messages generated from + cubes, for saving *to* files, are represented as message handles from the + `Python eccodes library `_ . + +Load +^^^^ + +The key functions are :func:`~iris_grib.load_pairs_from_fields` and +:func:`~iris_grib.message.GribMessage.messages_from_filename`. +See those for more detail. + +You can load data to 'messages', and filter or modify them to enable or correct +how Iris converts them to 'raw' cubes (i.e. individual 2-dimensional fields). + +.. caution:: + + Remember: modifying a :class:`~iris_grib.message.GribMessage` does NOT modify + the underlying GRIB file. + +For example: + + >>> from iris_grib.message import GribMessage + >>> fields_iter = GribMessage.messages_from_filename('testfile.grib') + >>> # select only wanted data + >>> selected_fields = [ + ... field + ... for field in fields_iter + ... if field.sections[4]['parameterNumber'] == 33 + ... ] + >>> cube_field_pairs = iris_grib.load_pairs_from_fields(selected_fields) + +Filtering fields can be very useful to speed up loading, since otherwise all data must +be converted to Iris *before* selection with constraints, which can be quite costly. + +See also: :doc:`/how_to/modify_during_load`. + +Save +^^^^ + +The key functions are :func:`~iris_grib.save_pairs_from_cubes` and +:func:`~iris_grib.save_messages`. +See those for more detail. + +You can convert Iris cubes to eccodes messages, and modify or filter them before saving. + +.. note:: + The messages here are eccodes message "ids", essentially integers, and *not* + :class:`~iris_grib.message.GribMessages`. Thus, they must be inspected and + manipulated using the eccodes library functions. + +.. testsetup:: + + from iris.coords import DimCoord + import eccodes + + cube_height_2m5 = iris.load_cube(iris.sample_data_path("rotated_pole.nc")) + cube_height_2m5.add_aux_coord(DimCoord([2.5], standard_name="height", units="m"), ()) + +For example: + + >>> # translate data to grib2 fields + >>> cube_field_pairs = list(iris_grib.save_pairs_from_cube(cube_height_2m5)) + >>> # adjust some of them + >>> for cube, field in cube_field_pairs: + ... if cube.coords('height') and cube.coord('height').points[0] == 2.5: + ... # we know this will have been rounded, badly, so needs re-scaling. + ... assert eccodes.codes_get_long(field, 'scaleFactorOfFirstFixedSurface') == 0 + ... assert eccodes.codes_get_long(field, 'scaledValueOfFirstFixedSurface') == 2 + ... eccodes.codes_set_long(field, 'scaleFactorOfFirstFixedSurface', 1) + ... eccodes.codes_set_long(field, 'scaledValueOfFirstFixedSurface', 25) + ... + >>> # save to file + >>> messages = [msg for (cube, msg) in cube_field_pairs] + >>> iris_grib.save_messages(messages, 'temp.grib2') + >>> # check result + >>> print(iris.load_cube('temp.grib2').coord('height').points) + [2.5] diff --git a/environment.yml b/environment.yml deleted file mode 100644 index 70ae43a3..00000000 --- a/environment.yml +++ /dev/null @@ -1,4 +0,0 @@ -channels: - - conda-forge -dependencies: - - iris=1.* diff --git a/iris_grib/__init__.py b/iris_grib/__init__.py deleted file mode 100644 index 6ea46bc5..00000000 --- a/iris_grib/__init__.py +++ /dev/null @@ -1,862 +0,0 @@ -# (C) British Crown Copyright 2010 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Conversion of cubes to/from GRIB. - -See: `ECMWF GRIB API `_. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa -import six - -import datetime -import math # for fmod -import warnings - -import biggus -import cartopy.crs as ccrs -import cf_units -import gribapi -import numpy as np -import numpy.ma as ma - -import iris -import iris.coord_systems as coord_systems -from iris.exceptions import TranslationError, NotYetImplementedError - -# NOTE: careful here, to avoid circular imports (as iris imports grib) -from . import grib_phenom_translation as gptx -from . import _save_rules -from ._load_convert import convert as load_convert -from .message import GribMessage - - -__version__ = '0.9.0' - -__all__ = ['load_cubes', 'save_grib2', 'load_pairs_from_fields', - 'save_pairs_from_cube', 'save_messages', 'GribWrapper'] - - -CENTRE_TITLES = {'egrr': 'U.K. Met Office - Exeter', - 'ecmf': 'European Centre for Medium Range Weather Forecasts', - 'rjtd': 'Tokyo, Japan Meteorological Agency', - '55': 'San Francisco', - 'kwbc': ('US National Weather Service, National Centres for ' - 'Environmental Prediction')} - -TIME_RANGE_INDICATORS = {0: 'none', 1: 'none', 3: 'time mean', 4: 'time sum', - 5: 'time _difference', 10: 'none', - # TODO #567 Further exploration of following mappings - 51: 'time mean', 113: 'time mean', 114: 'time sum', - 115: 'time mean', 116: 'time sum', 117: 'time mean', - 118: 'time _covariance', 123: 'time mean', - 124: 'time sum', 125: 'time standard_deviation'} - -PROCESSING_TYPES = {0: 'time mean', 1: 'time sum', 2: 'time maximum', - 3: 'time minimum', 4: 'time _difference', - 5: 'time _root mean square', 6: 'time standard_deviation', - 7: 'time _convariance', 8: 'time _difference', - 9: 'time _ratio'} - -TIME_CODES_EDITION1 = { - 0: ('minutes', 60), - 1: ('hours', 60*60), - 2: ('days', 24*60*60), - # NOTE: do *not* support calendar-dependent units at all. - # So the following possible keys remain unsupported: - # 3: 'months', - # 4: 'years', - # 5: 'decades', - # 6: '30 years', - # 7: 'century', - 10: ('3 hours', 3*60*60), - 11: ('6 hours', 6*60*60), - 12: ('12 hours', 12*60*60), - 13: ('15 minutes', 15*60), - 14: ('30 minutes', 30*60), - 254: ('seconds', 1), -} - -unknown_string = "???" - - -class GribDataProxy(object): - """A reference to the data payload of a single Grib message.""" - - __slots__ = ('shape', 'dtype', 'fill_value', 'path', 'offset') - - def __init__(self, shape, dtype, fill_value, path, offset): - self.shape = shape - self.dtype = dtype - self.fill_value = fill_value - self.path = path - self.offset = offset - - @property - def ndim(self): - return len(self.shape) - - def __getitem__(self, keys): - with open(self.path, 'rb') as grib_fh: - grib_fh.seek(self.offset) - grib_message = gribapi.grib_new_from_file(grib_fh) - data = _message_values(grib_message, self.shape) - gribapi.grib_release(grib_message) - - return data.__getitem__(keys) - - def __repr__(self): - msg = '<{self.__class__.__name__} shape={self.shape} ' \ - 'dtype={self.dtype!r} fill_value={self.fill_value!r} ' \ - 'path={self.path!r} offset={self.offset}>' - return msg.format(self=self) - - def __getstate__(self): - return {attr: getattr(self, attr) for attr in self.__slots__} - - def __setstate__(self, state): - for key, value in six.iteritems(state): - setattr(self, key, value) - - -class GribWrapper(object): - """ - Contains a pygrib object plus some extra keys of our own. - - The class :class:`iris_grib.message.GribMessage` - provides alternative means of working with GRIB message instances. - - """ - def __init__(self, grib_message, grib_fh=None): - """Store the grib message and compute our extra keys.""" - self.grib_message = grib_message - - if self.edition != 1: - emsg = 'GRIB edition {} is not supported by {!r}.' - raise TranslationError(emsg.format(self.edition, - type(self).__name__)) - - deferred = grib_fh is not None - - # Store the file pointer and message length from the current - # grib message before it's changed by calls to the grib-api. - if deferred: - # Note that, the grib-api has already read this message and - # advanced the file pointer to the end of the message. - offset = grib_fh.tell() - message_length = gribapi.grib_get_long(grib_message, 'totalLength') - - # Initialise the key-extension dictionary. - # NOTE: this attribute *must* exist, or the the __getattr__ overload - # can hit an infinite loop. - self.extra_keys = {} - self._confirm_in_scope() - self._compute_extra_keys() - - # Calculate the data payload shape. - shape = (gribapi.grib_get_long(grib_message, 'numberOfValues'),) - - if not self.gridType.startswith('reduced'): - ni, nj = self.Ni, self.Nj - j_fast = gribapi.grib_get_long(grib_message, - 'jPointsAreConsecutive') - shape = (nj, ni) if j_fast == 0 else (ni, nj) - - if deferred: - # Wrap the reference to the data payload within the data proxy - # in order to support deferred data loading. - # The byte offset requires to be reset back to the first byte - # of this message. The file pointer offset is always at the end - # of the current message due to the grib-api reading the message. - proxy = GribDataProxy(shape, np.zeros(.0).dtype, np.nan, - grib_fh.name, - offset - message_length) - self._data = biggus.NumpyArrayAdapter(proxy) - else: - self.data = _message_values(grib_message, shape) - - def _confirm_in_scope(self): - """Ensure we have a grib flavour that we choose to support.""" - - # forbid alternate row scanning - # (uncommon entry from GRIB2 flag table 3.4, also in GRIB1) - if self.alternativeRowScanning == 1: - raise ValueError("alternativeRowScanning == 1 not handled.") - - def __getattr__(self, key): - """Return a grib key, or one of our extra keys.""" - - # is it in the grib message? - try: - # we just get as the type of the "values" - # array...special case here... - if key in ["values", "pv", "latitudes", "longitudes"]: - res = gribapi.grib_get_double_array(self.grib_message, key) - elif key in ('typeOfFirstFixedSurface', - 'typeOfSecondFixedSurface'): - res = np.int32(gribapi.grib_get_long(self.grib_message, key)) - else: - key_type = gribapi.grib_get_native_type(self.grib_message, key) - if key_type == int: - res = np.int32(gribapi.grib_get_long(self.grib_message, - key)) - elif key_type == float: - # Because some computer keys are floats, like - # longitudeOfFirstGridPointInDegrees, a float32 - # is not always enough... - res = np.float64(gribapi.grib_get_double(self.grib_message, - key)) - elif key_type == str: - res = gribapi.grib_get_string(self.grib_message, key) - else: - emsg = "Unknown type for {} : {}" - raise ValueError(emsg.format(key, str(key_type))) - except gribapi.GribInternalError: - res = None - - # ...or is it in our list of extras? - if res is None: - if key in self.extra_keys: - res = self.extra_keys[key] - else: - # must raise an exception for the hasattr() mechanism to work - raise AttributeError("Cannot find GRIB key %s" % key) - - return res - - def _timeunit_detail(self): - """Return the (string, seconds) describing the message time unit.""" - unit_code = self.indicatorOfUnitOfTimeRange - if unit_code not in TIME_CODES_EDITION1: - message = 'Unhandled time unit for forecast ' \ - 'indicatorOfUnitOfTimeRange : ' + str(unit_code) - raise NotYetImplementedError(message) - return TIME_CODES_EDITION1[unit_code] - - def _timeunit_string(self): - """Get the udunits string for the message time unit.""" - return self._timeunit_detail()[0] - - def _timeunit_seconds(self): - """Get the number of seconds in the message time unit.""" - return self._timeunit_detail()[1] - - def _compute_extra_keys(self): - """Compute our extra keys.""" - global unknown_string - - self.extra_keys = {} - forecastTime = self.startStep - - # regular or rotated grid? - try: - longitudeOfSouthernPoleInDegrees = \ - self.longitudeOfSouthernPoleInDegrees - latitudeOfSouthernPoleInDegrees = \ - self.latitudeOfSouthernPoleInDegrees - except AttributeError: - longitudeOfSouthernPoleInDegrees = 0.0 - latitudeOfSouthernPoleInDegrees = 90.0 - - centre = gribapi.grib_get_string(self.grib_message, "centre") - - # default values - self.extra_keys = {'_referenceDateTime': -1.0, - '_phenomenonDateTime': -1.0, - '_periodStartDateTime': -1.0, - '_periodEndDateTime': -1.0, - '_levelTypeName': unknown_string, - '_levelTypeUnits': unknown_string, - '_firstLevelTypeName': unknown_string, - '_firstLevelTypeUnits': unknown_string, - '_firstLevel': -1.0, - '_secondLevelTypeName': unknown_string, - '_secondLevel': -1.0, - '_originatingCentre': unknown_string, - '_forecastTime': None, - '_forecastTimeUnit': unknown_string, - '_coord_system': None, - '_x_circular': False, - '_x_coord_name': unknown_string, - '_y_coord_name': unknown_string, - # These are here to avoid repetition in the rules - # files, and reduce the very long line lengths. - '_x_points': None, - '_y_points': None, - '_cf_data': None} - - # cf phenomenon translation - # Get centre code (N.B. self.centre has default type = string) - centre_number = gribapi.grib_get_long(self.grib_message, "centre") - # Look for a known grib1-to-cf translation (or None). - cf_data = gptx.grib1_phenom_to_cf_info( - table2_version=self.table2Version, - centre_number=centre_number, - param_number=self.indicatorOfParameter) - self.extra_keys['_cf_data'] = cf_data - - # reference date - self.extra_keys['_referenceDateTime'] = \ - datetime.datetime(int(self.year), int(self.month), int(self.day), - int(self.hour), int(self.minute)) - - # forecast time with workarounds - self.extra_keys['_forecastTime'] = forecastTime - - # verification date - processingDone = self._get_processing_done() - # time processed? - if processingDone.startswith("time"): - validityDate = str(self.validityDate) - validityTime = "{:04}".format(int(self.validityTime)) - endYear = int(validityDate[:4]) - endMonth = int(validityDate[4:6]) - endDay = int(validityDate[6:8]) - endHour = int(validityTime[:2]) - endMinute = int(validityTime[2:4]) - - # fixed forecastTime in hours - self.extra_keys['_periodStartDateTime'] = \ - (self.extra_keys['_referenceDateTime'] + - datetime.timedelta(hours=int(forecastTime))) - self.extra_keys['_periodEndDateTime'] = \ - datetime.datetime(endYear, endMonth, endDay, endHour, - endMinute) - else: - self.extra_keys['_phenomenonDateTime'] = \ - self._get_verification_date() - - # originating centre - # TODO #574 Expand to include sub-centre - self.extra_keys['_originatingCentre'] = CENTRE_TITLES.get( - centre, "unknown centre %s" % centre) - - # forecast time unit as a cm string - # TODO #575 Do we want PP or GRIB style forecast delta? - self.extra_keys['_forecastTimeUnit'] = self._timeunit_string() - - # shape of the earth - - # pre-defined sphere - if self.shapeOfTheEarth == 0: - geoid = coord_systems.GeogCS(semi_major_axis=6367470) - - # custom sphere - elif self.shapeOfTheEarth == 1: - geoid = coord_systems.GeogCS( - self.scaledValueOfRadiusOfSphericalEarth * - 10 ** -self.scaleFactorOfRadiusOfSphericalEarth) - - # IAU65 oblate sphere - elif self.shapeOfTheEarth == 2: - geoid = coord_systems.GeogCS(6378160, inverse_flattening=297.0) - - # custom oblate spheroid (km) - elif self.shapeOfTheEarth == 3: - geoid = coord_systems.GeogCS( - semi_major_axis=self.scaledValueOfEarthMajorAxis * - 10 ** -self.scaleFactorOfEarthMajorAxis * 1000., - semi_minor_axis=self.scaledValueOfEarthMinorAxis * - 10 ** -self.scaleFactorOfEarthMinorAxis * 1000.) - - # IAG-GRS80 oblate spheroid - elif self.shapeOfTheEarth == 4: - geoid = coord_systems.GeogCS(6378137, None, 298.257222101) - - # WGS84 - elif self.shapeOfTheEarth == 5: - geoid = \ - coord_systems.GeogCS(6378137, inverse_flattening=298.257223563) - - # pre-defined sphere - elif self.shapeOfTheEarth == 6: - geoid = coord_systems.GeogCS(6371229) - - # custom oblate spheroid (m) - elif self.shapeOfTheEarth == 7: - geoid = coord_systems.GeogCS( - semi_major_axis=self.scaledValueOfEarthMajorAxis * - 10 ** -self.scaleFactorOfEarthMajorAxis, - semi_minor_axis=self.scaledValueOfEarthMinorAxis * - 10 ** -self.scaleFactorOfEarthMinorAxis) - - elif self.shapeOfTheEarth == 8: - raise ValueError("unhandled shape of earth : grib earth shape = 8") - - else: - raise ValueError("undefined shape of earth") - - gridType = gribapi.grib_get_string(self.grib_message, "gridType") - - if gridType in ["regular_ll", "regular_gg", "reduced_ll", - "reduced_gg"]: - self.extra_keys['_x_coord_name'] = "longitude" - self.extra_keys['_y_coord_name'] = "latitude" - self.extra_keys['_coord_system'] = geoid - elif gridType == 'rotated_ll': - # TODO: Confirm the translation from angleOfRotation to - # north_pole_lon (usually 0 for both) - self.extra_keys['_x_coord_name'] = "grid_longitude" - self.extra_keys['_y_coord_name'] = "grid_latitude" - southPoleLon = longitudeOfSouthernPoleInDegrees - southPoleLat = latitudeOfSouthernPoleInDegrees - self.extra_keys['_coord_system'] = \ - coord_systems.RotatedGeogCS( - -southPoleLat, - math.fmod(southPoleLon + 180.0, 360.0), - self.angleOfRotation, geoid) - elif gridType == 'polar_stereographic': - self.extra_keys['_x_coord_name'] = "projection_x_coordinate" - self.extra_keys['_y_coord_name'] = "projection_y_coordinate" - - if self.projectionCentreFlag == 0: - pole_lat = 90 - elif self.projectionCentreFlag == 1: - pole_lat = -90 - else: - raise TranslationError("Unhandled projectionCentreFlag") - - # Note: I think the grib api defaults LaDInDegrees to 60 for grib1. - self.extra_keys['_coord_system'] = \ - coord_systems.Stereographic( - pole_lat, self.orientationOfTheGridInDegrees, 0, 0, - self.LaDInDegrees, ellipsoid=geoid) - - elif gridType == 'lambert': - self.extra_keys['_x_coord_name'] = "projection_x_coordinate" - self.extra_keys['_y_coord_name'] = "projection_y_coordinate" - - flag_name = "projectionCenterFlag" - - if getattr(self, flag_name) == 0: - pole_lat = 90 - elif getattr(self, flag_name) == 1: - pole_lat = -90 - else: - raise TranslationError("Unhandled projectionCentreFlag") - - LambertConformal = coord_systems.LambertConformal - self.extra_keys['_coord_system'] = LambertConformal( - self.LaDInDegrees, self.LoVInDegrees, 0, 0, - secant_latitudes=(self.Latin1InDegrees, self.Latin2InDegrees), - ellipsoid=geoid) - else: - raise TranslationError("unhandled grid type: {}".format(gridType)) - - if gridType in ["regular_ll", "rotated_ll"]: - self._regular_longitude_common() - j_step = self.jDirectionIncrementInDegrees - if not self.jScansPositively: - j_step = -j_step - self._y_points = (np.arange(self.Nj, dtype=np.float64) * j_step + - self.latitudeOfFirstGridPointInDegrees) - - elif gridType in ['regular_gg']: - # longitude coordinate is straight-forward - self._regular_longitude_common() - # get the distinct latitudes, and make sure they are sorted - # (south-to-north) and then put them in the right direction - # depending on the scan direction - latitude_points = gribapi.grib_get_double_array( - self.grib_message, 'distinctLatitudes').astype(np.float64) - latitude_points.sort() - if not self.jScansPositively: - # we require latitudes north-to-south - self._y_points = latitude_points[::-1] - else: - self._y_points = latitude_points - - elif gridType in ["polar_stereographic", "lambert"]: - # convert the starting latlon into meters - cartopy_crs = self.extra_keys['_coord_system'].as_cartopy_crs() - x1, y1 = cartopy_crs.transform_point( - self.longitudeOfFirstGridPointInDegrees, - self.latitudeOfFirstGridPointInDegrees, - ccrs.Geodetic()) - - if not np.all(np.isfinite([x1, y1])): - raise TranslationError("Could not determine the first latitude" - " and/or longitude grid point.") - - self._x_points = x1 + self.DxInMetres * np.arange(self.Nx, - dtype=np.float64) - self._y_points = y1 + self.DyInMetres * np.arange(self.Ny, - dtype=np.float64) - - elif gridType in ["reduced_ll", "reduced_gg"]: - self._x_points = self.longitudes - self._y_points = self.latitudes - - else: - raise TranslationError("unhandled grid type") - - def _regular_longitude_common(self): - """Define a regular longitude dimension.""" - i_step = self.iDirectionIncrementInDegrees - if self.iScansNegatively: - i_step = -i_step - self._x_points = (np.arange(self.Ni, dtype=np.float64) * i_step + - self.longitudeOfFirstGridPointInDegrees) - if "longitude" in self.extra_keys['_x_coord_name'] and self.Ni > 1: - if _longitude_is_cyclic(self._x_points): - self.extra_keys['_x_circular'] = True - - def _get_processing_done(self): - """Determine the type of processing that was done on the data.""" - - processingDone = 'unknown' - timeRangeIndicator = self.timeRangeIndicator - default = 'time _grib1_process_unknown_%i' % timeRangeIndicator - processingDone = TIME_RANGE_INDICATORS.get(timeRangeIndicator, default) - - return processingDone - - def _get_verification_date(self): - reference_date_time = self._referenceDateTime - - # calculate start time - time_range_indicator = self.timeRangeIndicator - P1 = self.P1 - P2 = self.P2 - if time_range_indicator == 0: - # Forecast product valid at reference time + P1 P1>0), - # or Uninitialized analysis product for reference time (P1=0). - # Or Image product for reference time (P1=0) - time_diff = P1 - elif time_range_indicator == 1: - # Initialized analysis product for reference time (P1=0). - time_diff = P1 - elif time_range_indicator == 2: - # Product with a valid time ranging between reference time + P1 - # and reference time + P2 - time_diff = (P1 + P2) * 0.5 - elif time_range_indicator == 3: - # Average(reference time + P1 to reference time + P2) - time_diff = (P1 + P2) * 0.5 - elif time_range_indicator == 4: - # Accumulation (reference time + P1 to reference time + P2) - # product considered valid at reference time + P2 - time_diff = P2 - elif time_range_indicator == 5: - # Difference(reference time + P2 minus reference time + P1) - # product considered valid at reference time + P2 - time_diff = P2 - elif time_range_indicator == 10: - # P1 occupies octets 19 and 20; product valid at - # reference time + P1 - time_diff = P1 * 256 + P2 - elif time_range_indicator == 51: - # Climatological Mean Value: multiple year averages of - # quantities which are themselves means over some period of - # time (P2) less than a year. The reference time (R) indicates - # the date and time of the start of a period of time, given by - # R to R + P2, over which a mean is formed; N indicates the number - # of such period-means that are averaged together to form the - # climatological value, assuming that the N period-mean fields - # are separated by one year. The reference time indicates the - # start of the N-year climatology. N is given in octets 22-23 - # of the PDS. If P1 = 0 then the data averaged in the basic - # interval P2 are assumed to be continuous, i.e., all available - # data are simply averaged together. If P1 = 1 (the units of - # time - octet 18, code table 4 - are not relevant here) then - # the data averaged together in the basic interval P2 are valid - # only at the time (hour, minute) given in the reference time, - # for all the days included in the P2 period. The units of P2 - # are given by the contents of octet 18 and Table 4. - raise TranslationError("unhandled grib1 timeRangeIndicator " - "= 51 (avg of avgs)") - elif time_range_indicator == 113: - # Average of N forecasts (or initialized analyses); each - # product has forecast period of P1 (P1=0 for initialized - # analyses); products have reference times at intervals of P2, - # beginning at the given reference time. - time_diff = P1 - elif time_range_indicator == 114: - # Accumulation of N forecasts (or initialized analyses); each - # product has forecast period of P1 (P1=0 for initialized - # analyses); products have reference times at intervals of P2, - # beginning at the given reference time. - time_diff = P1 - elif time_range_indicator == 115: - # Average of N forecasts, all with the same reference time; - # the first has a forecast period of P1, the remaining - # forecasts follow at intervals of P2. - time_diff = P1 - elif time_range_indicator == 116: - # Accumulation of N forecasts, all with the same reference - # time; the first has a forecast period of P1, the remaining - # follow at intervals of P2. - time_diff = P1 - elif time_range_indicator == 117: - # Average of N forecasts, the first has a period of P1, the - # subsequent ones have forecast periods reduced from the - # previous one by an interval of P2; the reference time for - # the first is given in octets 13-17, the subsequent ones - # have reference times increased from the previous one by - # an interval of P2. Thus all the forecasts have the same - # valid time, given by the initial reference time + P1. - time_diff = P1 - elif time_range_indicator == 118: - # Temporal variance, or covariance, of N initialized analyses; - # each product has forecast period P1=0; products have - # reference times at intervals of P2, beginning at the given - # reference time. - time_diff = P1 - elif time_range_indicator == 123: - # Average of N uninitialized analyses, starting at the - # reference time, at intervals of P2. - time_diff = P1 - elif time_range_indicator == 124: - # Accumulation of N uninitialized analyses, starting at - # the reference time, at intervals of P2. - time_diff = P1 - else: - raise TranslationError("unhandled grib1 timeRangeIndicator " - "= %i" % time_range_indicator) - - # Get the timeunit interval. - interval_secs = self._timeunit_seconds() - # Multiply by start-offset and convert to a timedelta. - # NOTE: a 'float' conversion is required here, as time_diff may be - # a numpy scalar, which timedelta will not accept. - interval_delta = datetime.timedelta( - seconds=float(time_diff * interval_secs)) - # Return validity_time = (reference_time + start_offset*time_unit). - return reference_date_time + interval_delta - - def phenomenon_points(self, time_unit): - """ - Return the phenomenon time point offset from the epoch time reference - measured in the appropriate time units. - - """ - time_reference = '%s since epoch' % time_unit - return cf_units.date2num(self._phenomenonDateTime, time_reference, - cf_units.CALENDAR_GREGORIAN) - - def phenomenon_bounds(self, time_unit): - """ - Return the phenomenon time bound offsets from the epoch time reference - measured in the appropriate time units. - - """ - # TODO #576 Investigate when it's valid to get phenomenon_bounds - time_reference = '%s since epoch' % time_unit - unit = cf_units.Unit(time_reference, cf_units.CALENDAR_GREGORIAN) - return [unit.date2num(self._periodStartDateTime), - unit.date2num(self._periodEndDateTime)] - - -def _longitude_is_cyclic(points): - """Work out if a set of longitude points is cyclic.""" - # Is the gap from end to start smaller, or about equal to the max step? - gap = 360.0 - abs(points[-1] - points[0]) - max_step = abs(np.diff(points)).max() - cyclic = False - if gap <= max_step: - cyclic = True - else: - delta = 0.001 - if abs(1.0 - gap / max_step) < delta: - cyclic = True - return cyclic - - -def _message_values(grib_message, shape): - gribapi.grib_set_double(grib_message, 'missingValue', np.nan) - data = gribapi.grib_get_double_array(grib_message, 'values') - data = data.reshape(shape) - - # Handle missing values in a sensible way. - mask = np.isnan(data) - if mask.any(): - data = ma.array(data, mask=mask, fill_value=np.nan) - return data - - -def _load_generate(filename): - messages = GribMessage.messages_from_filename(filename) - for message in messages: - editionNumber = message.sections[0]['editionNumber'] - if editionNumber == 1: - message_id = message._raw_message._message_id - grib_fh = message._file_ref.open_file - message = GribWrapper(message_id, grib_fh=grib_fh) - elif editionNumber != 2: - emsg = 'GRIB edition {} is not supported by {!r}.' - raise TranslationError(emsg.format(editionNumber, - type(message).__name__)) - yield message - - -def load_cubes(filenames, callback=None): - """ - Returns a generator of cubes from the given list of filenames. - - Args: - - * filenames (string/list): - One or more GRIB filenames to load from. - - Kwargs: - - * callback (callable function): - Function which can be passed on to :func:`iris.io.run_callback`. - - """ - import iris.fileformats.rules as iris_rules - grib_loader = iris_rules.Loader(_load_generate, - {}, - load_convert) - return iris_rules.load_cubes(filenames, callback, grib_loader) - - -def load_pairs_from_fields(grib_messages): - """ - Convert an iterable of GRIB messages into an iterable of - (Cube, Grib message) tuples. - - Args: - - * grib_messages: - An iterable of :class:`iris_grib.message.GribMessage`. - - Returns: - An iterable of tuples of (:class:`iris.cube.Cube`, - :class:`iris_grib.message.GribMessage`). - - This capability can be used to filter out fields before they are passed to - the load pipeline, and amend the cubes once they are created, using - GRIB metadata conditions. Where the filtering - removes a significant number of fields, the speed up to load can be - significant: - - >>> import iris - >>> from iris_grib import load_pairs_from_fields - >>> from iris_grib.message import GribMessage - >>> filename = iris.sample_data_path('polar_stereo.grib2') - >>> filtered_messages = [] - >>> for message in GribMessage.messages_from_filename(filename): - ... if message.sections[1]['productionStatusOfProcessedData'] == 0: - ... filtered_messages.append(message) - >>> cubes_messages = load_pairs_from_fields(filtered_messages) - >>> for cube, msg in cubes_messages: - ... prod_stat = msg.sections[1]['productionStatusOfProcessedData'] - ... cube.attributes['productionStatusOfProcessedData'] = prod_stat - >>> print(cube.attributes['productionStatusOfProcessedData']) - 0 - - This capability can also be used to alter fields before they are passed to - the load pipeline. Fields with out of specification header elements can - be cleaned up this way and cubes created: - - >>> from iris_grib import load_pairs_from_fields - >>> cleaned_messages = GribMessage.messages_from_filename(filename) - >>> for message in cleaned_messages: - ... if message.sections[1]['productionStatusOfProcessedData'] == 0: - ... message.sections[1]['productionStatusOfProcessedData'] = 4 - >>> cubes = load_pairs_from_fields(cleaned_messages) - - """ - import iris.fileformats.rules as iris_rules - return iris_rules.load_pairs_from_fields(grib_messages, load_convert) - - -def save_grib2(cube, target, append=False): - """ - Save a cube or iterable of cubes to a GRIB2 file. - - Args: - - * cube - A :class:`iris.cube.Cube`, :class:`iris.cube.CubeList` or - list of cubes. - * target - A filename or open file handle. - - Kwargs: - - * append - Whether to start a new file afresh or add the cube(s) to - the end of the file. - Only applicable when target is a filename, not a file - handle. Default is False. - - """ - messages = (message for _, message in save_pairs_from_cube(cube)) - save_messages(messages, target, append=append) - - -def save_pairs_from_cube(cube): - """ - Convert one or more cubes to (2D cube, GRIB message) pairs. - Returns an iterable of tuples each consisting of one 2D cube and - one GRIB message ID, the result of the 2D cube being processed by the GRIB - save rules. - - Args: - * cube - A :class:`iris.cube.Cube`, :class:`iris.cube.CubeList` or - list of cubes. - - """ - x_coords = cube.coords(axis='x', dim_coords=True) - y_coords = cube.coords(axis='y', dim_coords=True) - if len(x_coords) != 1 or len(y_coords) != 1: - raise TranslationError("Did not find one (and only one) x or y coord") - - # Save each latlon slice2D in the cube - for slice2D in cube.slices([y_coords[0], x_coords[0]]): - grib_message = gribapi.grib_new_from_samples("GRIB2") - _save_rules.run(slice2D, grib_message) - yield (slice2D, grib_message) - - -def save_messages(messages, target, append=False): - """ - Save messages to a GRIB2 file. - The messages will be released as part of the save. - - Args: - - * messages - An iterable of grib_api message IDs. - * target - A filename or open file handle. - - Kwargs: - - * append - Whether to start a new file afresh or add the cube(s) to - the end of the file. - Only applicable when target is a filename, not a file - handle. Default is False. - - """ - # grib file (this bit is common to the pp and grib savers...) - if isinstance(target, six.string_types): - grib_file = open(target, "ab" if append else "wb") - elif hasattr(target, "write"): - if hasattr(target, "mode") and "b" not in target.mode: - raise ValueError("Target not binary") - grib_file = target - else: - raise ValueError("Can only save grib to filename or writable") - - try: - for message in messages: - gribapi.grib_write(message, grib_file) - gribapi.grib_release(message) - finally: - # (this bit is common to the pp and grib savers...) - if isinstance(target, six.string_types): - grib_file.close() diff --git a/iris_grib/_grib_cf_map.py b/iris_grib/_grib_cf_map.py deleted file mode 100644 index dc1299d3..00000000 --- a/iris_grib/_grib_cf_map.py +++ /dev/null @@ -1,225 +0,0 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -# -# DO NOT EDIT: AUTO-GENERATED -# Created on 12 February 2016 17:02 from -# http://www.metarelate.net/metOcean -# at commit cf419fba84a70fba5f394f1481cfcdbba28877ff - -# https://github.com/metarelate/metOcean/commit/cf419fba84a70fba5f394f1481cfcdbba28877ff - -""" -Provides GRIB/CF phenomenon translations. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -from collections import namedtuple - - -CFName = namedtuple('CFName', 'standard_name long_name units') - -DimensionCoordinate = namedtuple('DimensionCoordinate', - 'standard_name units points') - -G1LocalParam = namedtuple('G1LocalParam', 'edition t2version centre iParam') -G2Param = namedtuple('G2Param', 'edition discipline category number') - - -GRIB1_LOCAL_TO_CF_CONSTRAINED = { - G1LocalParam(1, 128, 98, 165): (CFName('x_wind', None, 'm s-1'), DimensionCoordinate('height', 'm', (10,))), - G1LocalParam(1, 128, 98, 166): (CFName('y_wind', None, 'm s-1'), DimensionCoordinate('height', 'm', (10,))), - G1LocalParam(1, 128, 98, 167): (CFName('air_temperature', None, 'K'), DimensionCoordinate('height', 'm', (2,))), - G1LocalParam(1, 128, 98, 168): (CFName('dew_point_temperature', None, 'K'), DimensionCoordinate('height', 'm', (2,))), - } - -GRIB1_LOCAL_TO_CF = { - G1LocalParam(1, 128, 98, 31): CFName('sea_ice_area_fraction', None, '1'), - G1LocalParam(1, 128, 98, 34): CFName('sea_surface_temperature', None, 'K'), - G1LocalParam(1, 128, 98, 59): CFName('atmosphere_specific_convective_available_potential_energy', None, 'J kg-1'), - G1LocalParam(1, 128, 98, 129): CFName('geopotential', None, 'm2 s-2'), - G1LocalParam(1, 128, 98, 130): CFName('air_temperature', None, 'K'), - G1LocalParam(1, 128, 98, 131): CFName('x_wind', None, 'm s-1'), - G1LocalParam(1, 128, 98, 132): CFName('y_wind', None, 'm s-1'), - G1LocalParam(1, 128, 98, 135): CFName('lagrangian_tendency_of_air_pressure', None, 'Pa s-1'), - G1LocalParam(1, 128, 98, 141): CFName('thickness_of_snowfall_amount', None, 'm'), - G1LocalParam(1, 128, 98, 151): CFName('air_pressure_at_sea_level', None, 'Pa'), - G1LocalParam(1, 128, 98, 157): CFName('relative_humidity', None, '%'), - G1LocalParam(1, 128, 98, 164): CFName('cloud_area_fraction', None, '1'), - G1LocalParam(1, 128, 98, 173): CFName('surface_roughness_length', None, 'm'), - G1LocalParam(1, 128, 98, 174): CFName(None, 'grib_physical_atmosphere_albedo', '1'), - G1LocalParam(1, 128, 98, 186): CFName('low_type_cloud_area_fraction', None, '1'), - G1LocalParam(1, 128, 98, 187): CFName('medium_type_cloud_area_fraction', None, '1'), - G1LocalParam(1, 128, 98, 188): CFName('high_type_cloud_area_fraction', None, '1'), - G1LocalParam(1, 128, 98, 235): CFName(None, 'grib_skin_temperature', 'K'), - } - -GRIB2_TO_CF = { - G2Param(2, 0, 0, 0): CFName('air_temperature', None, 'K'), - G2Param(2, 0, 0, 2): CFName('air_potential_temperature', None, 'K'), - G2Param(2, 0, 0, 6): CFName('dew_point_temperature', None, 'K'), - G2Param(2, 0, 0, 10): CFName('surface_upward_latent_heat_flux', None, 'W m-2'), - G2Param(2, 0, 0, 11): CFName('surface_upward_sensible_heat_flux', None, 'W m-2'), - G2Param(2, 0, 0, 17): CFName('surface_temperature', None, 'K'), - G2Param(2, 0, 1, 0): CFName('specific_humidity', None, 'kg kg-1'), - G2Param(2, 0, 1, 1): CFName('relative_humidity', None, '%'), - G2Param(2, 0, 1, 2): CFName('humidity_mixing_ratio', None, 'kg kg-1'), - G2Param(2, 0, 1, 3): CFName(None, 'precipitable_water', 'kg m-2'), - G2Param(2, 0, 1, 7): CFName('precipitation_flux', None, 'kg m-2 s-1'), - G2Param(2, 0, 1, 11): CFName('thickness_of_snowfall_amount', None, 'm'), - G2Param(2, 0, 1, 13): CFName('liquid_water_content_of_surface_snow', None, 'kg m-2'), - G2Param(2, 0, 1, 22): CFName(None, 'cloud_mixing_ratio', 'kg kg-1'), - G2Param(2, 0, 1, 49): CFName('precipitation_amount', None, 'kg m-2'), - G2Param(2, 0, 1, 51): CFName('atmosphere_mass_content_of_water', None, 'kg m-2'), - G2Param(2, 0, 1, 53): CFName('snowfall_flux', None, 'kg m-2 s-1'), - G2Param(2, 0, 1, 64): CFName('atmosphere_mass_content_of_water_vapor', None, 'kg m-2'), - G2Param(2, 0, 2, 0): CFName('wind_from_direction', None, 'degrees'), - G2Param(2, 0, 2, 1): CFName('wind_speed', None, 'm s-1'), - G2Param(2, 0, 2, 2): CFName('x_wind', None, 'm s-1'), - G2Param(2, 0, 2, 3): CFName('y_wind', None, 'm s-1'), - G2Param(2, 0, 2, 8): CFName('lagrangian_tendency_of_air_pressure', None, 'Pa s-1'), - G2Param(2, 0, 2, 10): CFName('atmosphere_absolute_vorticity', None, 's-1'), - G2Param(2, 0, 2, 14): CFName(None, 'ertel_potential_velocity', 'K m2 kg-1 s-1'), - G2Param(2, 0, 2, 22): CFName('wind_speed_of_gust', None, 'm s-1'), - G2Param(2, 0, 3, 0): CFName('air_pressure', None, 'Pa'), - G2Param(2, 0, 3, 1): CFName('air_pressure_at_sea_level', None, 'Pa'), - G2Param(2, 0, 3, 3): CFName(None, 'icao_standard_atmosphere_reference_height', 'm'), - G2Param(2, 0, 3, 4): CFName('geopotential', None, 'm2 s-2'), - G2Param(2, 0, 3, 5): CFName('geopotential_height', None, 'm'), - G2Param(2, 0, 3, 6): CFName('altitude', None, 'm'), - G2Param(2, 0, 3, 9): CFName('geopotential_height_anomaly', None, 'm'), - G2Param(2, 0, 4, 7): CFName('surface_downwelling_shortwave_flux_in_air', None, 'W m-2'), - G2Param(2, 0, 4, 9): CFName('surface_net_downward_shortwave_flux', None, 'W m-2'), - G2Param(2, 0, 5, 3): CFName('surface_downwelling_longwave_flux_in_air', None, 'W m-2'), - G2Param(2, 0, 5, 5): CFName('toa_outgoing_longwave_flux', None, 'W m-2'), - G2Param(2, 0, 6, 1): CFName('cloud_area_fraction', None, '%'), - G2Param(2, 0, 6, 3): CFName('low_type_cloud_area_fraction', None, '%'), - G2Param(2, 0, 6, 4): CFName('medium_type_cloud_area_fraction', None, '%'), - G2Param(2, 0, 6, 5): CFName('high_type_cloud_area_fraction', None, '%'), - G2Param(2, 0, 6, 6): CFName('atmosphere_mass_content_of_cloud_liquid_water', None, 'kg m-2'), - G2Param(2, 0, 6, 7): CFName('cloud_area_fraction_in_atmosphere_layer', None, '%'), - G2Param(2, 0, 7, 6): CFName('atmosphere_specific_convective_available_potential_energy', None, 'J kg-1'), - G2Param(2, 0, 7, 7): CFName(None, 'convective_inhibition', 'J kg-1'), - G2Param(2, 0, 7, 8): CFName(None, 'storm_relative_helicity', 'J kg-1'), - G2Param(2, 0, 14, 0): CFName('atmosphere_mole_content_of_ozone', None, 'Dobson'), - G2Param(2, 0, 19, 1): CFName(None, 'grib_physical_atmosphere_albedo', '%'), - G2Param(2, 2, 0, 0): CFName('land_area_fraction', None, '1'), - G2Param(2, 2, 0, 1): CFName('surface_roughness_length', None, 'm'), - G2Param(2, 2, 0, 2): CFName('soil_temperature', None, 'K'), - G2Param(2, 2, 0, 7): CFName('surface_altitude', None, 'm'), - G2Param(2, 2, 0, 22): CFName('moisture_content_of_soil_layer', None, 'kg m-2'), - G2Param(2, 2, 0, 34): CFName('surface_runoff_flux', None, 'kg m-2 s-1'), - G2Param(2, 10, 1, 2): CFName('sea_water_x_velocity', None, 'm s-1'), - G2Param(2, 10, 1, 3): CFName('sea_water_y_velocity', None, 'm s-1'), - G2Param(2, 10, 2, 0): CFName('sea_ice_area_fraction', None, '1'), - G2Param(2, 10, 3, 0): CFName('sea_surface_temperature', None, 'K'), - } - -CF_CONSTRAINED_TO_GRIB1_LOCAL = { - (CFName('air_temperature', None, 'K'), DimensionCoordinate('height', 'm', (2,))): G1LocalParam(1, 128, 98, 167), - (CFName('dew_point_temperature', None, 'K'), DimensionCoordinate('height', 'm', (2,))): G1LocalParam(1, 128, 98, 168), - (CFName('x_wind', None, 'm s-1'), DimensionCoordinate('height', 'm', (10,))): G1LocalParam(1, 128, 98, 165), - (CFName('y_wind', None, 'm s-1'), DimensionCoordinate('height', 'm', (10,))): G1LocalParam(1, 128, 98, 166), - } - -CF_TO_GRIB1_LOCAL = { - CFName(None, 'grib_physical_atmosphere_albedo', '1'): G1LocalParam(1, 128, 98, 174), - CFName(None, 'grib_skin_temperature', 'K'): G1LocalParam(1, 128, 98, 235), - CFName('air_pressure_at_sea_level', None, 'Pa'): G1LocalParam(1, 128, 98, 151), - CFName('air_temperature', None, 'K'): G1LocalParam(1, 128, 98, 130), - CFName('atmosphere_specific_convective_available_potential_energy', None, 'J kg-1'): G1LocalParam(1, 128, 98, 59), - CFName('cloud_area_fraction', None, '1'): G1LocalParam(1, 128, 98, 164), - CFName('geopotential', None, 'm2 s-2'): G1LocalParam(1, 128, 98, 129), - CFName('high_type_cloud_area_fraction', None, '1'): G1LocalParam(1, 128, 98, 188), - CFName('lagrangian_tendency_of_air_pressure', None, 'Pa s-1'): G1LocalParam(1, 128, 98, 135), - CFName('low_type_cloud_area_fraction', None, '1'): G1LocalParam(1, 128, 98, 186), - CFName('medium_type_cloud_area_fraction', None, '1'): G1LocalParam(1, 128, 98, 187), - CFName('relative_humidity', None, '%'): G1LocalParam(1, 128, 98, 157), - CFName('sea_ice_area_fraction', None, '1'): G1LocalParam(1, 128, 98, 31), - CFName('sea_surface_temperature', None, 'K'): G1LocalParam(1, 128, 98, 34), - CFName('surface_roughness_length', None, 'm'): G1LocalParam(1, 128, 98, 173), - CFName('thickness_of_snowfall_amount', None, 'm'): G1LocalParam(1, 128, 98, 141), - CFName('x_wind', None, 'm s-1'): G1LocalParam(1, 128, 98, 131), - CFName('y_wind', None, 'm s-1'): G1LocalParam(1, 128, 98, 132), - } - -CF_TO_GRIB2 = { - CFName(None, 'cloud_area_fraction_assuming_maximum_random_overlap', '1'): G2Param(2, 0, 6, 1), - CFName(None, 'cloud_mixing_ratio', 'kg kg-1'): G2Param(2, 0, 1, 22), - CFName(None, 'convective_inhibition', 'J kg-1'): G2Param(2, 0, 7, 7), - CFName(None, 'ertel_potential_velocity', 'K m2 kg-1 s-1'): G2Param(2, 0, 2, 14), - CFName(None, 'grib_physical_atmosphere_albedo', '%'): G2Param(2, 0, 19, 1), - CFName(None, 'icao_standard_atmosphere_reference_height', 'm'): G2Param(2, 0, 3, 3), - CFName(None, 'precipitable_water', 'kg m-2'): G2Param(2, 0, 1, 3), - CFName(None, 'storm_relative_helicity', 'J kg-1'): G2Param(2, 0, 7, 8), - CFName('air_potential_temperature', None, 'K'): G2Param(2, 0, 0, 2), - CFName('air_pressure', None, 'Pa'): G2Param(2, 0, 3, 0), - CFName('air_pressure_at_sea_level', None, 'Pa'): G2Param(2, 0, 3, 1), - CFName('air_temperature', None, 'K'): G2Param(2, 0, 0, 0), - CFName('altitude', None, 'm'): G2Param(2, 0, 3, 6), - CFName('atmosphere_absolute_vorticity', None, 's-1'): G2Param(2, 0, 2, 10), - CFName('atmosphere_mass_content_of_cloud_liquid_water', None, 'kg m-2'): G2Param(2, 0, 6, 6), - CFName('atmosphere_mass_content_of_water', None, 'kg m-2'): G2Param(2, 0, 1, 51), - CFName('atmosphere_mass_content_of_water_vapor', None, 'kg m-2'): G2Param(2, 0, 1, 64), - CFName('atmosphere_mole_content_of_ozone', None, 'Dobson'): G2Param(2, 0, 14, 0), - CFName('atmosphere_specific_convective_available_potential_energy', None, 'J kg-1'): G2Param(2, 0, 7, 6), - CFName('cloud_area_fraction', None, '%'): G2Param(2, 0, 6, 1), - CFName('cloud_area_fraction_in_atmosphere_layer', None, '%'): G2Param(2, 0, 6, 7), - CFName('dew_point_temperature', None, 'K'): G2Param(2, 0, 0, 6), - CFName('geopotential', None, 'm2 s-2'): G2Param(2, 0, 3, 4), - CFName('geopotential_height', None, 'm'): G2Param(2, 0, 3, 5), - CFName('geopotential_height_anomaly', None, 'm'): G2Param(2, 0, 3, 9), - CFName('high_type_cloud_area_fraction', None, '%'): G2Param(2, 0, 6, 5), - CFName('humidity_mixing_ratio', None, 'kg kg-1'): G2Param(2, 0, 1, 2), - CFName('lagrangian_tendency_of_air_pressure', None, 'Pa s-1'): G2Param(2, 0, 2, 8), - CFName('land_area_fraction', None, '1'): G2Param(2, 2, 0, 0), - CFName('land_binary_mask', None, '1'): G2Param(2, 2, 0, 0), - CFName('liquid_water_content_of_surface_snow', None, 'kg m-2'): G2Param(2, 0, 1, 13), - CFName('low_type_cloud_area_fraction', None, '%'): G2Param(2, 0, 6, 3), - CFName('medium_type_cloud_area_fraction', None, '%'): G2Param(2, 0, 6, 4), - CFName('moisture_content_of_soil_layer', None, 'kg m-2'): G2Param(2, 2, 0, 22), - CFName('precipitation_amount', None, 'kg m-2'): G2Param(2, 0, 1, 49), - CFName('precipitation_flux', None, 'kg m-2 s-1'): G2Param(2, 0, 1, 7), - CFName('relative_humidity', None, '%'): G2Param(2, 0, 1, 1), - CFName('sea_ice_area_fraction', None, '1'): G2Param(2, 10, 2, 0), - CFName('sea_surface_temperature', None, 'K'): G2Param(2, 10, 3, 0), - CFName('sea_water_x_velocity', None, 'm s-1'): G2Param(2, 10, 1, 2), - CFName('sea_water_y_velocity', None, 'm s-1'): G2Param(2, 10, 1, 3), - CFName('snowfall_flux', None, 'kg m-2 s-1'): G2Param(2, 0, 1, 53), - CFName('soil_temperature', None, 'K'): G2Param(2, 2, 0, 2), - CFName('specific_humidity', None, 'kg kg-1'): G2Param(2, 0, 1, 0), - CFName('surface_air_pressure', None, 'Pa'): G2Param(2, 0, 3, 0), - CFName('surface_altitude', None, 'm'): G2Param(2, 2, 0, 7), - CFName('surface_downwelling_longwave_flux_in_air', None, 'W m-2'): G2Param(2, 0, 5, 3), - CFName('surface_downwelling_shortwave_flux_in_air', None, 'W m-2'): G2Param(2, 0, 4, 7), - CFName('surface_net_downward_longwave_flux', None, 'W m-2'): G2Param(2, 0, 5, 5), - CFName('surface_net_downward_shortwave_flux', None, 'W m-2'): G2Param(2, 0, 4, 9), - CFName('surface_roughness_length', None, 'm'): G2Param(2, 2, 0, 1), - CFName('surface_runoff_flux', None, 'kg m-2 s-1'): G2Param(2, 2, 0, 34), - CFName('surface_temperature', None, 'K'): G2Param(2, 0, 0, 17), - CFName('surface_upward_latent_heat_flux', None, 'W m-2'): G2Param(2, 0, 0, 10), - CFName('surface_upward_sensible_heat_flux', None, 'W m-2'): G2Param(2, 0, 0, 11), - CFName('thickness_of_snowfall_amount', None, 'm'): G2Param(2, 0, 1, 11), - CFName('toa_outgoing_longwave_flux', None, 'W m-2'): G2Param(2, 0, 5, 5), - CFName('wind_from_direction', None, 'degrees'): G2Param(2, 0, 2, 0), - CFName('wind_speed', None, 'm s-1'): G2Param(2, 0, 2, 1), - CFName('wind_speed_of_gust', None, 'm s-1'): G2Param(2, 0, 2, 22), - CFName('x_wind', None, 'm s-1'): G2Param(2, 0, 2, 2), - CFName('y_wind', None, 'm s-1'): G2Param(2, 0, 2, 3), - } diff --git a/iris_grib/_load_convert.py b/iris_grib/_load_convert.py deleted file mode 100644 index 82f10fe2..00000000 --- a/iris_grib/_load_convert.py +++ /dev/null @@ -1,2295 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Module to support the loading and convertion of a GRIB2 message into -cube metadata. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -from collections import namedtuple, Iterable, OrderedDict -from datetime import datetime, timedelta -import math -import threading -import warnings - -import cartopy.crs as ccrs -from cf_units import CALENDAR_GREGORIAN, date2num, Unit -import numpy as np -import numpy.ma as ma - -from iris.aux_factory import HybridPressureFactory -import iris.coord_systems as icoord_systems -from iris.coords import AuxCoord, DimCoord, CellMethod -from iris.exceptions import TranslationError -from . import grib_phenom_translation as itranslation -from iris.fileformats.rules import ConversionMetadata, Factory, Reference -from iris.util import _is_circular - -from .load_rules import grib1_convert -from .message import GribMessage - - -# Restrict the names imported from this namespace. -__all__ = ['convert'] - -options = threading.local() -options.warn_on_unsupported = False -options.support_hindcast_values = True - -ScanningMode = namedtuple('ScanningMode', ['i_negative', - 'j_positive', - 'j_consecutive', - 'i_alternative']) - -ProjectionCentre = namedtuple('ProjectionCentre', - ['south_pole_on_projection_plane', - 'bipolar_and_symmetric']) - -ResolutionFlags = namedtuple('ResolutionFlags', - ['i_increments_given', - 'j_increments_given', - 'uv_resolved']) - -FixedSurface = namedtuple('FixedSurface', ['standard_name', - 'long_name', - 'units']) - -# Regulations 92.1.6. -_GRID_ACCURACY_IN_DEGREES = 1e-6 # 1/1,000,000 of a degree - -# Reference Common Code Table C-1. -_CENTRES = { - 'ecmf': 'European Centre for Medium Range Weather Forecasts' -} - -# Reference Code Table 1.0 -_CODE_TABLES_MISSING = 255 - -# UDUNITS-2 units time string. Reference GRIB2 Code Table 4.4. -_TIME_RANGE_UNITS = { - 0: 'minutes', - 1: 'hours', - 2: 'days', - # 3: 'months', Unsupported - # 4: 'years', Unsupported - # 5: '10 years', Unsupported - # 6: '30 years', Unsupported - # 7: '100 years', Unsupported - # 8-9 Reserved - 10: '3 hours', - 11: '6 hours', - 12: '12 hours', - 13: 'seconds' -} - -# Reference Code Table 4.5. -_FIXED_SURFACE = { - 100: FixedSurface(None, 'pressure', 'Pa'), # Isobaric surface - 103: FixedSurface(None, 'height', 'm') # Height level above ground -} -_TYPE_OF_FIXED_SURFACE_MISSING = 255 - -# Reference Code Table 6.0 -_BITMAP_CODE_PRESENT = 0 -_BITMAP_CODE_NONE = 255 - -# Reference Code Table 4.10. -_STATISTIC_TYPE_NAMES = { - 0: 'mean', - 1: 'sum', - 2: 'maximum', - 3: 'minimum', - 6: 'standard_deviation' -} - -# Reference Code Table 4.11. -_STATISTIC_TYPE_OF_TIME_INTERVAL = { - 2: 'same start time of forecast, forecast time is incremented' -} -# NOTE: Our test data contains the value 2, which is all we currently support. -# The exact interpretation of this is still unclear. - -# Class containing details of a probability analysis. -Probability = namedtuple('Probability', - ('probability_type_name', 'threshold')) - - -# Regulation 92.1.12 -def unscale(value, factor): - """ - Implements Regulation 92.1.12. - - Args: - - * value: - Scaled value or sequence of scaled values. - - * factor: - Scale factor or sequence of scale factors. - - Returns: - For scalar value and factor, the unscaled floating point - result is returned. If either value and/or factor are - MDI, then :data:`numpy.ma.masked` is returned. - - For sequence value and factor, the unscaled floating point - :class:`numpy.ndarray` is returned. If either value and/or - factor contain MDI, then :class:`numpy.ma.core.MaskedArray` - is returned. - - """ - def _unscale(v, f): - return v / 10.0 ** f - - if isinstance(value, Iterable) or isinstance(factor, Iterable): - def _masker(item): - result = ma.masked_equal(item, _MDI) - if ma.count_masked(result): - # Circumvent downstream NumPy "RuntimeWarning" - # of "overflow encountered in power" in _unscale - # for data containing _MDI. - result.data[result.mask] = 0 - return result - value = _masker(value) - factor = _masker(factor) - result = _unscale(value, factor) - if ma.count_masked(result) == 0: - result = result.data - else: - result = ma.masked - if value != _MDI and factor != _MDI: - result = _unscale(value, factor) - return result - - -# Regulations 92.1.4 and 92.1.5. -_MDI = 2 ** 32 - 1 -# Note: -# 1. Integer "on-disk" values (aka. coded keys) in GRIB messages: -# - Are 8-, 16-, or 32-bit. -# - Are either signed or unsigned, with signed values stored as -# sign-and-magnitude (*not* twos-complement). -# - Use all bits set to indicate a missing value (MDI). -# 2. Irrespective of the on-disk form, the ECMWF GRIB API *always*: -# - Returns values as 64-bit signed integers, either as native -# Python 'int' or numpy 'int64'. -# - Returns missing values as 2**32 - 1, but not all keys are -# defined as supporting missing values. -# NB. For keys which support missing values, the MDI value is reliably -# distinct from the valid range of either signed or unsigned 8-, 16-, -# or 32-bit values. For example: -# unsigned 32-bit: -# min = 0b000...000 = 0 -# max = 0b111...110 = 2**32 - 2 -# MDI = 0b111...111 = 2**32 - 1 -# signed 32-bit: -# MDI = 0b111...111 = 2**32 - 1 -# min = 0b111...110 = -(2**31 - 2) -# max = 0b011...111 = 2**31 - 1 - - -# Non-standardised usage for negative forecast times. -def _hindcast_fix(forecast_time): - """Return a forecast time interpreted as a possibly negative value.""" - uft = np.uint32(forecast_time) - HIGHBIT = 2**30 - - # Workaround grib api's assumption that forecast time is positive. - # Handles correctly encoded -ve forecast times up to one -1 billion. - if 2 * HIGHBIT < uft < 3 * HIGHBIT: - original_forecast_time = forecast_time - forecast_time = -(uft - 2 * HIGHBIT) - if options.warn_on_unsupported: - msg = ('Re-interpreting large grib forecastTime ' - 'from {} to {}.'.format(original_forecast_time, - forecast_time)) - warnings.warn(msg) - - return forecast_time - - -def fixup_float32_from_int32(value): - """ - Workaround for use when reading an IEEE 32-bit floating-point value - which the ECMWF GRIB API has erroneously treated as a 4-byte signed - integer. - - """ - # Convert from two's complement to sign-and-magnitude. - # NB. The bit patterns 0x00000000 and 0x80000000 will both be - # returned by the ECMWF GRIB API as an integer 0. Because they - # correspond to positive and negative zero respectively it is safe - # to treat an integer 0 as a positive zero. - if value < 0: - value = 0x80000000 - value - value_as_uint32 = np.array(value, dtype='u4') - value_as_float32 = value_as_uint32.view(dtype='f4') - return float(value_as_float32) - - -def fixup_int32_from_uint32(value): - """ - Workaround for use when reading a signed, 4-byte integer which the - ECMWF GRIB API has erroneously treated as an unsigned, 4-byte - integer. - - NB. This workaround is safe to use with values which are already - treated as signed, 4-byte integers. - - """ - if value >= 0x80000000: - value = 0x80000000 - value - return value - - -############################################################################### -# -# Identification Section 1 -# -############################################################################### - -def reference_time_coord(section): - """ - Translate section 1 reference time according to its significance. - - Reference section 1, year octets 13-14, month octet 15, day octet 16, - hour octet 17, minute octet 18, second octet 19. - - Returns: - The scalar reference time :class:`iris.coords.DimCoord`. - - """ - # Look-up standard name by significanceOfReferenceTime. - _lookup = {0: 'time', - 1: 'forecast_reference_time', - 2: 'time', - 3: 'time'} - - # Calculate the reference time and units. - dt = datetime(section['year'], section['month'], section['day'], - section['hour'], section['minute'], section['second']) - # XXX Defaulting to a Gregorian calendar. - # Current GRIBAPI does not cover GRIB Section 1 - Octets 22-nn (optional) - # which are part of GRIB spec v12. - unit = Unit('hours since epoch', calendar=CALENDAR_GREGORIAN) - point = unit.date2num(dt) - - # Reference Code Table 1.2. - significanceOfReferenceTime = section['significanceOfReferenceTime'] - standard_name = _lookup.get(significanceOfReferenceTime) - - if standard_name is None: - msg = 'Identificaton section 1 contains an unsupported significance ' \ - 'of reference time [{}]'.format(significanceOfReferenceTime) - raise TranslationError(msg) - - # Create the associated reference time of data coordinate. - coord = DimCoord(point, standard_name=standard_name, units=unit) - - return coord - - -############################################################################### -# -# Grid Definition Section 3 -# -############################################################################### - -def projection_centre(projectionCentreFlag): - """ - Translate the projection centre flag bitmask. - - Reference GRIB2 Flag Table 3.5. - - Args: - - * projectionCentreFlag - Message section 3, coded key value. - - Returns: - A :class:`collections.namedtuple` representation. - - """ - south_pole_on_projection_plane = bool(projectionCentreFlag & 0x80) - bipolar_and_symmetric = bool(projectionCentreFlag & 0x40) - return ProjectionCentre(south_pole_on_projection_plane, - bipolar_and_symmetric) - - -def scanning_mode(scanningMode): - """ - Translate the scanning mode bitmask. - - Reference GRIB2 Flag Table 3.4. - - Args: - - * scanningMode: - Message section 3, coded key value. - - Returns: - A :class:`collections.namedtuple` representation. - - """ - i_negative = bool(scanningMode & 0x80) - j_positive = bool(scanningMode & 0x40) - j_consecutive = bool(scanningMode & 0x20) - i_alternative = bool(scanningMode & 0x10) - - if i_alternative: - msg = 'Grid definition section 3 contains unsupported ' \ - 'alternative row scanning mode' - raise TranslationError(msg) - - return ScanningMode(i_negative, j_positive, - j_consecutive, i_alternative) - - -def resolution_flags(resolutionAndComponentFlags): - """ - Translate the resolution and component bitmask. - - Reference GRIB2 Flag Table 3.3. - - Args: - - * resolutionAndComponentFlags: - Message section 3, coded key value. - - Returns: - A :class:`collections.namedtuple` representation. - - """ - i_inc_given = bool(resolutionAndComponentFlags & 0x20) - j_inc_given = bool(resolutionAndComponentFlags & 0x10) - uv_resolved = bool(resolutionAndComponentFlags & 0x08) - - return ResolutionFlags(i_inc_given, j_inc_given, uv_resolved) - - -def ellipsoid(shapeOfTheEarth, major, minor, radius): - """ - Translate the shape of the earth to an appropriate coordinate - reference system. - - For MDI set either major and minor or radius to :data:`numpy.ma.masked` - - Reference GRIB2 Code Table 3.2. - - Args: - - * shapeOfTheEarth: - Message section 3, octet 15. - - * major: - Semi-major axis of the oblate spheroid in units determined by - the shapeOfTheEarth. - - * minor: - Semi-minor axis of the oblate spheroid in units determined by - the shapeOfTheEarth. - - * radius: - Radius of sphere (in m). - - Returns: - :class:`iris.coord_systems.CoordSystem` - - """ - # Supported shapeOfTheEarth values. - if shapeOfTheEarth not in (0, 1, 3, 6, 7): - msg = 'Grid definition section 3 contains an unsupported ' \ - 'shape of the earth [{}]'.format(shapeOfTheEarth) - raise TranslationError(msg) - - if shapeOfTheEarth == 0: - # Earth assumed spherical with radius of 6 367 470.0m - result = icoord_systems.GeogCS(6367470) - elif shapeOfTheEarth == 1: - # Earth assumed spherical with radius specified (in m) by - # data producer. - if radius is ma.masked: - msg = 'Ellipsoid for shape of the earth {} requires a' \ - 'radius to be specified.'.format(shapeOfTheEarth) - raise ValueError(msg) - result = icoord_systems.GeogCS(radius) - elif shapeOfTheEarth in [3, 7]: - # Earth assumed oblate spheroid with major and minor axes - # specified (in km)/(in m) by data producer. - emsg_oblate = 'Ellipsoid for shape of the earth [{}] requires a' \ - 'semi-{} axis to be specified.' - if major is ma.masked: - raise ValueError(emsg_oblate.format(shapeOfTheEarth, 'major')) - if minor is ma.masked: - raise ValueError(emsg_oblate.format(shapeOfTheEarth, 'minor')) - # Check whether to convert from km to m. - if shapeOfTheEarth == 3: - major *= 1000 - minor *= 1000 - result = icoord_systems.GeogCS(major, minor) - elif shapeOfTheEarth == 6: - # Earth assumed spherical with radius of 6 371 229.0m - result = icoord_systems.GeogCS(6371229) - - return result - - -def ellipsoid_geometry(section): - """ - Calculated the unscaled ellipsoid major-axis, minor-axis and radius. - - Args: - - * section: - Dictionary of coded key/value pairs from section 3 of the message. - - Returns: - Tuple containing the major-axis, minor-axis and radius. - - """ - major = unscale(section['scaledValueOfEarthMajorAxis'], - section['scaleFactorOfEarthMajorAxis']) - minor = unscale(section['scaledValueOfEarthMinorAxis'], - section['scaleFactorOfEarthMinorAxis']) - radius = unscale(section['scaledValueOfRadiusOfSphericalEarth'], - section['scaleFactorOfRadiusOfSphericalEarth']) - return major, minor, radius - - -def grid_definition_template_0_and_1(section, metadata, y_name, x_name, cs): - """ - Translate templates representing regularly spaced latitude/longitude - on either a standard or rotated grid. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 3 of the message - - * metadata: - :class:`collections.OrderedDict` of metadata. - - * y_name: - Name of the Y coordinate, e.g. latitude or grid_latitude. - - * x_name: - Name of the X coordinate, e.g. longitude or grid_longitude. - - * cs: - The :class:`iris.coord_systems.CoordSystem` to use when creating - the X and Y coordinates. - - """ - # Abort if this is a reduced grid, that case isn't handled yet. - if section['numberOfOctectsForNumberOfPoints'] != 0 or \ - section['interpretationOfNumberOfPoints'] != 0: - msg = 'Grid definition section 3 contains unsupported ' \ - 'quasi-regular grid' - raise TranslationError(msg) - - scan = scanning_mode(section['scanningMode']) - - # Calculate longitude points. - x_inc = section['iDirectionIncrement'] * _GRID_ACCURACY_IN_DEGREES - x_offset = section['longitudeOfFirstGridPoint'] * _GRID_ACCURACY_IN_DEGREES - x_direction = -1 if scan.i_negative else 1 - Ni = section['Ni'] - x_points = np.arange(Ni, dtype=np.float64) * x_inc * x_direction + x_offset - - # Determine whether the x-points (in degrees) are circular. - circular = _is_circular(x_points, 360.0) - - # Calculate latitude points. - y_inc = section['jDirectionIncrement'] * _GRID_ACCURACY_IN_DEGREES - y_offset = section['latitudeOfFirstGridPoint'] * _GRID_ACCURACY_IN_DEGREES - y_direction = 1 if scan.j_positive else -1 - Nj = section['Nj'] - y_points = np.arange(Nj, dtype=np.float64) * y_inc * y_direction + y_offset - - # Create the lat/lon coordinates. - y_coord = DimCoord(y_points, standard_name=y_name, units='degrees', - coord_system=cs) - x_coord = DimCoord(x_points, standard_name=x_name, units='degrees', - coord_system=cs, circular=circular) - - # Determine the lat/lon dimensions. - y_dim, x_dim = 0, 1 - if scan.j_consecutive: - y_dim, x_dim = 1, 0 - - # Add the lat/lon coordinates to the metadata dim coords. - metadata['dim_coords_and_dims'].append((y_coord, y_dim)) - metadata['dim_coords_and_dims'].append((x_coord, x_dim)) - - -def grid_definition_template_0(section, metadata): - """ - Translate template representing regular latitude/longitude - grid (regular_ll). - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 3 of the message - - * metadata: - :class:`collections.OrderedDict` of metadata. - - """ - # Determine the coordinate system. - major, minor, radius = ellipsoid_geometry(section) - cs = ellipsoid(section['shapeOfTheEarth'], major, minor, radius) - grid_definition_template_0_and_1(section, metadata, - 'latitude', 'longitude', cs) - - -def grid_definition_template_1(section, metadata): - """ - Translate template representing rotated latitude/longitude grid. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 3 of the message - - * metadata: - :class:`collections.OrderedDict` of metadata. - - """ - # Determine the coordinate system. - major, minor, radius = ellipsoid_geometry(section) - south_pole_lat = (section['latitudeOfSouthernPole'] * - _GRID_ACCURACY_IN_DEGREES) - south_pole_lon = (section['longitudeOfSouthernPole'] * - _GRID_ACCURACY_IN_DEGREES) - cs = icoord_systems.RotatedGeogCS(-south_pole_lat, - math.fmod(south_pole_lon + 180, 360), - section['angleOfRotation'], - ellipsoid(section['shapeOfTheEarth'], - major, minor, radius)) - grid_definition_template_0_and_1(section, metadata, - 'grid_latitude', 'grid_longitude', cs) - - -def grid_definition_template_4_and_5(section, metadata, y_name, x_name, cs): - """ - Translate template representing variable resolution latitude/longitude - and common variable resolution rotated latitude/longitude. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 3 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - * y_name: - Name of the Y coordinate, e.g. 'latitude' or 'grid_latitude'. - - * x_name: - Name of the X coordinate, e.g. 'longitude' or 'grid_longitude'. - - * cs: - The :class:`iris.coord_systems.CoordSystem` to use when createing - the X and Y coordinates. - - """ - # Determine the (variable) units of resolution. - key = 'basicAngleOfTheInitialProductionDomain' - basicAngleOfTheInitialProductDomain = section[key] - subdivisionsOfBasicAngle = section['subdivisionsOfBasicAngle'] - - if basicAngleOfTheInitialProductDomain in [0, _MDI]: - basicAngleOfTheInitialProductDomain = 1. - - if subdivisionsOfBasicAngle in [0, _MDI]: - subdivisionsOfBasicAngle = 1. / _GRID_ACCURACY_IN_DEGREES - - resolution = np.float64(basicAngleOfTheInitialProductDomain) - resolution /= subdivisionsOfBasicAngle - flags = resolution_flags(section['resolutionAndComponentFlags']) - - # Grid Definition Template 3.4. Notes (2). - # Flag bits 3-4 are not applicable for this template. - if flags.uv_resolved and options.warn_on_unsupported: - msg = 'Unable to translate resolution and component flags.' - warnings.warn(msg) - - # Calculate the latitude and longitude points. - x_points = np.array(section['longitudes'], dtype=np.float64) * resolution - y_points = np.array(section['latitudes'], dtype=np.float64) * resolution - - # Determine whether the x-points (in degrees) are circular. - circular = _is_circular(x_points, 360.0) - - # Create the lat/lon coordinates. - y_coord = DimCoord(y_points, standard_name=y_name, units='degrees', - coord_system=cs) - x_coord = DimCoord(x_points, standard_name=x_name, units='degrees', - coord_system=cs, circular=circular) - - scan = scanning_mode(section['scanningMode']) - - # Determine the lat/lon dimensions. - y_dim, x_dim = 0, 1 - if scan.j_consecutive: - y_dim, x_dim = 1, 0 - - # Add the lat/lon coordinates to the metadata dim coords. - metadata['dim_coords_and_dims'].append((y_coord, y_dim)) - metadata['dim_coords_and_dims'].append((x_coord, x_dim)) - - -def grid_definition_template_4(section, metadata): - """ - Translate template representing variable resolution latitude/longitude. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 3 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - """ - # Determine the coordinate system. - major, minor, radius = ellipsoid_geometry(section) - cs = ellipsoid(section['shapeOfTheEarth'], major, minor, radius) - grid_definition_template_4_and_5(section, metadata, - 'latitude', 'longitude', cs) - - -def grid_definition_template_5(section, metadata): - """ - Translate template representing variable resolution rotated - latitude/longitude. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 3 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - """ - # Determine the coordinate system. - major, minor, radius = ellipsoid_geometry(section) - south_pole_lat = (section['latitudeOfSouthernPole'] * - _GRID_ACCURACY_IN_DEGREES) - south_pole_lon = (section['longitudeOfSouthernPole'] * - _GRID_ACCURACY_IN_DEGREES) - cs = icoord_systems.RotatedGeogCS(-south_pole_lat, - math.fmod(south_pole_lon + 180, 360), - section['angleOfRotation'], - ellipsoid(section['shapeOfTheEarth'], - major, minor, radius)) - grid_definition_template_4_and_5(section, metadata, - 'grid_latitude', 'grid_longitude', cs) - - -def grid_definition_template_12(section, metadata): - """ - Translate template representing transverse Mercator. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 3 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - """ - major, minor, radius = ellipsoid_geometry(section) - geog_cs = ellipsoid(section['shapeOfTheEarth'], major, minor, radius) - - lat = section['latitudeOfReferencePoint'] * _GRID_ACCURACY_IN_DEGREES - lon = section['longitudeOfReferencePoint'] * _GRID_ACCURACY_IN_DEGREES - scale = section['scaleFactorAtReferencePoint'] - # Catch bug in ECMWF GRIB API (present at 1.12.1) where the scale - # is treated as a signed, 4-byte integer. - if isinstance(scale, int): - scale = fixup_float32_from_int32(scale) - CM_TO_M = 0.01 - easting = section['XR'] * CM_TO_M - northing = section['YR'] * CM_TO_M - cs = icoord_systems.TransverseMercator(lat, lon, easting, northing, - scale, geog_cs) - - # Deal with bug in ECMWF GRIB API (present at 1.12.1) where these - # values are treated as unsigned, 4-byte integers. - x1 = fixup_int32_from_uint32(section['X1']) - y1 = fixup_int32_from_uint32(section['Y1']) - x2 = fixup_int32_from_uint32(section['X2']) - y2 = fixup_int32_from_uint32(section['Y2']) - - # Rather unhelpfully this grid definition template seems to be - # overspecified, and thus open to inconsistency. But for determining - # the extents the X1, Y1, X2, and Y2 points have the highest - # precision, as opposed to using Di and Dj. - # Check whether Di and Dj are as consistent as possible with that - # interpretation - i.e. they are within 1cm. - def check_range(v1, v2, n, d): - min_last = v1 + (n - 1) * (d - 1) - max_last = v1 + (n - 1) * (d + 1) - if not (min_last < v2 < max_last): - raise TranslationError('Inconsistent grid definition') - check_range(x1, x2, section['Ni'], section['Di']) - check_range(y1, y2, section['Nj'], section['Dj']) - - x_points = np.linspace(x1 * CM_TO_M, x2 * CM_TO_M, section['Ni']) - y_points = np.linspace(y1 * CM_TO_M, y2 * CM_TO_M, section['Nj']) - - # This has only been tested with +x/+y scanning, so raise an error - # for other permutations. - scan = scanning_mode(section['scanningMode']) - if scan.i_negative: - raise TranslationError('Unsupported -x scanning') - if not scan.j_positive: - raise TranslationError('Unsupported -y scanning') - - # Create the X and Y coordinates. - y_coord = DimCoord(y_points, 'projection_y_coordinate', units='m', - coord_system=cs) - x_coord = DimCoord(x_points, 'projection_x_coordinate', units='m', - coord_system=cs) - - # Determine the lat/lon dimensions. - y_dim, x_dim = 0, 1 - scan = scanning_mode(section['scanningMode']) - if scan.j_consecutive: - y_dim, x_dim = 1, 0 - - # Add the X and Y coordinates to the metadata dim coords. - metadata['dim_coords_and_dims'].append((y_coord, y_dim)) - metadata['dim_coords_and_dims'].append((x_coord, x_dim)) - - -def grid_definition_template_20(section, metadata): - """ - Translate template representing a Polar Stereographic grid. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 3 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - """ - major, minor, radius = ellipsoid_geometry(section) - geog_cs = ellipsoid(section['shapeOfTheEarth'], major, minor, radius) - - proj_centre = projection_centre(section['projectionCentreFlag']) - if proj_centre.bipolar_and_symmetric: - raise TranslationError('Bipolar and symmetric polar stereo projections' - ' are not supported by the ' - 'grid_definition_template_20 translation.') - if proj_centre.south_pole_on_projection_plane: - central_lat = -90. - else: - central_lat = 90. - central_lon = section['orientationOfTheGrid'] * _GRID_ACCURACY_IN_DEGREES - true_scale_lat = section['LaD'] * _GRID_ACCURACY_IN_DEGREES - cs = icoord_systems.Stereographic(central_lat=central_lat, - central_lon=central_lon, - true_scale_lat=true_scale_lat, - ellipsoid=geog_cs) - x_coord, y_coord, scan = _calculate_proj_coords_from_lon_lat(section, cs) - - # Determine the order of the dimensions. - y_dim, x_dim = 0, 1 - if scan.j_consecutive: - y_dim, x_dim = 1, 0 - - # Add the projection coordinates to the metadata dim coords. - metadata['dim_coords_and_dims'].append((y_coord, y_dim)) - metadata['dim_coords_and_dims'].append((x_coord, x_dim)) - - -def _calculate_proj_coords_from_lon_lat(section, cs): - # Construct the coordinate points, the start point is given in millidegrees - # but the distance measurement is in 10-3 m, so a conversion is necessary - # to find the origin in m. - - scan = scanning_mode(section['scanningMode']) - lon_0 = section['longitudeOfFirstGridPoint'] * _GRID_ACCURACY_IN_DEGREES - lat_0 = section['latitudeOfFirstGridPoint'] * _GRID_ACCURACY_IN_DEGREES - x0_m, y0_m = cs.as_cartopy_crs().transform_point( - lon_0, lat_0, ccrs.Geodetic()) - dx_m = section['Dx'] * 1e-3 - dy_m = section['Dy'] * 1e-3 - x_dir = -1 if scan.i_negative else 1 - y_dir = 1 if scan.j_positive else -1 - x_points = x0_m + dx_m * x_dir * np.arange(section['Nx'], dtype=np.float64) - y_points = y0_m + dy_m * y_dir * np.arange(section['Ny'], dtype=np.float64) - - # Create the dimension coordinates. - x_coord = DimCoord(x_points, standard_name='projection_x_coordinate', - units='m', coord_system=cs) - y_coord = DimCoord(y_points, standard_name='projection_y_coordinate', - units='m', coord_system=cs) - return x_coord, y_coord, scan - - -def grid_definition_template_30(section, metadata): - """ - Translate template representing a Lambert Conformal grid. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 3 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - """ - major, minor, radius = ellipsoid_geometry(section) - geog_cs = ellipsoid(section['shapeOfTheEarth'], major, minor, radius) - - central_latitude = section['LaD'] * _GRID_ACCURACY_IN_DEGREES - central_longitude = section['LoV'] * _GRID_ACCURACY_IN_DEGREES - false_easting = 0 - false_northing = 0 - secant_latitudes = (section['Latin1'] * _GRID_ACCURACY_IN_DEGREES, - section['Latin2'] * _GRID_ACCURACY_IN_DEGREES) - - cs = icoord_systems.LambertConformal(central_latitude, - central_longitude, - false_easting, - false_northing, - secant_latitudes=secant_latitudes, - ellipsoid=geog_cs) - - # A projection centre flag is defined for GDT30. However, we don't need to - # know which pole is in the projection plane as Cartopy handles that. The - # Other component of the projection centre flag determines if there are - # multiple projection centres. There is no support for this in Proj4 or - # Cartopy so a translation error is raised if this flag is set. - proj_centre = projection_centre(section['projectionCentreFlag']) - if proj_centre.bipolar_and_symmetric: - msg = 'Unsupported projection centre: Bipolar and symmetric.' - raise TranslationError(msg) - - res_flags = resolution_flags(section['resolutionAndComponentFlags']) - if not res_flags.uv_resolved and options.warn_on_unsupported: - # Vector components are given as relative to east an north, rather than - # relative to the projection coordinates, issue a warning in this case. - # (ideally we need a way to add this information to a cube) - msg = 'Unable to translate resolution and component flags.' - warnings.warn(msg) - - x_coord, y_coord, scan = _calculate_proj_coords_from_lon_lat(section, cs) - - # Determine the order of the dimensions. - y_dim, x_dim = 0, 1 - if scan.j_consecutive: - y_dim, x_dim = 1, 0 - - # Add the projection coordinates to the metadata dim coords. - metadata['dim_coords_and_dims'].append((y_coord, y_dim)) - metadata['dim_coords_and_dims'].append((x_coord, x_dim)) - - -def grid_definition_template_40(section, metadata): - """ - Translate template representing a Gaussian grid. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 3 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - """ - major, minor, radius = ellipsoid_geometry(section) - cs = ellipsoid(section['shapeOfTheEarth'], major, minor, radius) - - if section['numberOfOctectsForNumberOfPoints'] != 0 or \ - section['interpretationOfNumberOfPoints'] != 0: - grid_definition_template_40_reduced(section, metadata, cs) - else: - grid_definition_template_40_regular(section, metadata, cs) - - -def grid_definition_template_40_regular(section, metadata, cs): - """ - Translate template representing a regular Gaussian grid. - - """ - scan = scanning_mode(section['scanningMode']) - - # Calculate longitude points. - x_inc = section['iDirectionIncrement'] * _GRID_ACCURACY_IN_DEGREES - x_offset = section['longitudeOfFirstGridPoint'] * _GRID_ACCURACY_IN_DEGREES - x_direction = -1 if scan.i_negative else 1 - Ni = section['Ni'] - x_points = np.arange(Ni, dtype=np.float64) * x_inc * x_direction + x_offset - - # Determine whether the x-points (in degrees) are circular. - circular = _is_circular(x_points, 360.0) - - # Get the latitude points. - # - # Gaussian latitudes are defined by Gauss-Legendre quadrature and the Gauss - # quadrature rule (http://en.wikipedia.org/wiki/Gaussian_quadrature). The - # latitudes of a particular Gaussian grid are uniquely defined by the - # number of latitudes between the equator and the pole, N. The latitudes - # are calculated from the roots of a Legendre series which must be - # calculated numerically. This process involves forming a (possibly large) - # companion matrix, computing its eigenvalues, and usually at least one - # application of Newton's method to achieve best results - # (http://en.wikipedia.org/wiki/Newton%27s_method). The latitudes are given - # by the arcsine of the roots converted to degrees. This computation can be - # time-consuming, especially for large grid sizes. - # - # A direct computation would require: - # 1. Reading the coded key 'N' representing the number of latitudes - # between the equator and pole. - # 2. Computing the set of global Gaussian latitudes associated with the - # value of N. - # 3. Determining the direction of the latitude points from the scanning - # mode. - # 4. Producing a subset of the latitudes based on the given first and - # last latitude points, given by the coded keys La1 and La2. - # - # Given the complexity and potential for poor performance of calculating - # the Gaussian latitudes directly, the GRIB-API computed key - # 'distinctLatitudes' is utilised to obtain the latitude points from the - # GRIB2 message. This computed key provides a rapid calculation of the - # monotonic latitude points that form the Gaussian grid, accounting for - # the coverage of the grid. - y_points = section.get_computed_key('distinctLatitudes') - y_points.sort() - if not scan.j_positive: - y_points = y_points[::-1] - - # Create lat/lon coordinates. - x_coord = DimCoord(x_points, standard_name='longitude', - units='degrees', coord_system=cs, - circular=circular) - y_coord = DimCoord(y_points, standard_name='latitude', - units='degrees', coord_system=cs) - - # Determine the lat/lon dimensions. - y_dim, x_dim = 0, 1 - if scan.j_consecutive: - y_dim, x_dim = 1, 0 - - # Add the lat/lon coordinates to the metadata dim coords. - metadata['dim_coords_and_dims'].append((y_coord, y_dim)) - metadata['dim_coords_and_dims'].append((x_coord, x_dim)) - - -def grid_definition_template_40_reduced(section, metadata, cs): - """ - Translate template representing a reduced Gaussian grid. - - """ - # Get the latitude and longitude points. - # - # The same comments made in grid_definition_template_40_regular regarding - # computation of Gaussian lattiudes applies here too. Further to this the - # reduced Gaussian grid is not rectangular, the number of points along - # each latitude circle vary with latitude. Whilst it is possible to - # compute the latitudes and longitudes individually for each grid point - # from coded keys, it would be complex and time-consuming compared to - # loading the latitude and longitude arrays directly using the computed - # keys 'latitudes' and 'longitudes'. - x_points = section.get_computed_key('longitudes') - y_points = section.get_computed_key('latitudes') - - # Create lat/lon coordinates. - x_coord = AuxCoord(x_points, standard_name='longitude', - units='degrees', coord_system=cs) - y_coord = AuxCoord(y_points, standard_name='latitude', - units='degrees', coord_system=cs) - - # Add the lat/lon coordinates to the metadata dim coords. - metadata['aux_coords_and_dims'].append((y_coord, 0)) - metadata['aux_coords_and_dims'].append((x_coord, 0)) - - -def grid_definition_template_90(section, metadata): - """ - Translate template representing space view. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 3 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - """ - if section['Nr'] == _MDI: - raise TranslationError('Unsupported orthographic grid.') - elif section['Nr'] == 0: - raise TranslationError('Unsupported zero height for space-view.') - if section['orientationOfTheGrid'] != 0: - raise TranslationError('Unsupported space-view orientation.') - - # Determine the coordinate system. - sub_satellite_lat = (section['latitudeOfSubSatellitePoint'] * - _GRID_ACCURACY_IN_DEGREES) - # The subsequent calculations to determine the apparent Earth - # diameters rely on the satellite being over the equator. - if sub_satellite_lat != 0: - raise TranslationError('Unsupported non-zero latitude for ' - 'space-view perspective.') - sub_satellite_lon = (section['longitudeOfSubSatellitePoint'] * - _GRID_ACCURACY_IN_DEGREES) - major, minor, radius = ellipsoid_geometry(section) - geog_cs = ellipsoid(section['shapeOfTheEarth'], major, minor, radius) - height_above_centre = geog_cs.semi_major_axis * section['Nr'] / 1e6 - height_above_ellipsoid = height_above_centre - geog_cs.semi_major_axis - cs = icoord_systems.VerticalPerspective(sub_satellite_lat, - sub_satellite_lon, - height_above_ellipsoid, - ellipsoid=geog_cs) - - # Figure out how large the Earth would appear in projection coodinates. - # For both the apparent equatorial and polar diameters this is a - # two-step process: - # 1) Determine the angle subtended by the visible surface. - # 2) Convert that angle into projection coordinates. - # NB. The solutions given below assume the satellite is over the - # equator. - # The apparent equatorial angle uses simple, circular geometry. - # But to derive the apparent polar angle we use the auxiliary circle - # parametric form of the ellipse. In this form, the equation for the - # tangent line is given by: - # x cos(psi) y sin(psi) - # ---------- + ---------- = 1 - # a b - # By considering the cases when x=0 and y=0, the apparent polar - # angle (theta) is given by: - # tan(theta) = b / sin(psi) - # ------------ - # a / cos(psi) - # This can be simplified using: cos(psi) = a / height_above_centre - half_apparent_equatorial_angle = math.asin(geog_cs.semi_major_axis / - height_above_centre) - x_apparent_diameter = (2 * half_apparent_equatorial_angle * - height_above_ellipsoid) - parametric_angle = math.acos(geog_cs.semi_major_axis / height_above_centre) - half_apparent_polar_angle = math.atan(geog_cs.semi_minor_axis / - (height_above_centre * - math.sin(parametric_angle))) - y_apparent_diameter = (2 * half_apparent_polar_angle * - height_above_ellipsoid) - - y_step = y_apparent_diameter / section['dy'] - x_step = x_apparent_diameter / section['dx'] - y_start = y_step * (section['Yo'] - section['Yp'] / 1000) - x_start = x_step * (section['Xo'] - section['Xp'] / 1000) - y_points = y_start + np.arange(section['Ny']) * y_step - x_points = x_start + np.arange(section['Nx']) * x_step - - # This has only been tested with -x/+y scanning, so raise an error - # for other permutations. - scan = scanning_mode(section['scanningMode']) - if scan.i_negative: - x_points = -x_points - else: - raise TranslationError('Unsupported +x scanning') - if not scan.j_positive: - raise TranslationError('Unsupported -y scanning') - - # Create the X and Y coordinates. - y_coord = DimCoord(y_points, 'projection_y_coordinate', units='m', - coord_system=cs) - x_coord = DimCoord(x_points, 'projection_x_coordinate', units='m', - coord_system=cs) - - # Determine the lat/lon dimensions. - y_dim, x_dim = 0, 1 - if scan.j_consecutive: - y_dim, x_dim = 1, 0 - - # Add the X and Y coordinates to the metadata dim coords. - metadata['dim_coords_and_dims'].append((y_coord, y_dim)) - metadata['dim_coords_and_dims'].append((x_coord, x_dim)) - - -def grid_definition_section(section, metadata): - """ - Translate section 3 from the GRIB2 message. - - Update the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 3 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - """ - # Reference GRIB2 Code Table 3.0. - value = section['sourceOfGridDefinition'] - if value != 0: - msg = 'Grid definition section 3 contains unsupported ' \ - 'source of grid definition [{}]'.format(value) - raise TranslationError(msg) - - # Reference GRIB2 Code Table 3.1. - template = section['gridDefinitionTemplateNumber'] - - if template == 0: - # Process regular latitude/longitude grid (regular_ll) - grid_definition_template_0(section, metadata) - elif template == 1: - # Process rotated latitude/longitude grid. - grid_definition_template_1(section, metadata) - elif template == 4: - # Process variable resolution latitude/longitude. - grid_definition_template_4(section, metadata) - elif template == 5: - # Process variable resolution rotated latitude/longitude. - grid_definition_template_5(section, metadata) - elif template == 12: - # Process transverse Mercator. - grid_definition_template_12(section, metadata) - elif template == 20: - # Polar stereographic. - grid_definition_template_20(section, metadata) - elif template == 30: - # Process Lambert conformal: - grid_definition_template_30(section, metadata) - elif template == 40: - grid_definition_template_40(section, metadata) - elif template == 90: - # Process space view. - grid_definition_template_90(section, metadata) - else: - msg = 'Grid definition template [{}] is not supported'.format(template) - raise TranslationError(msg) - - -############################################################################### -# -# Product Definition Section 4 -# -############################################################################### - -def translate_phenomenon(metadata, discipline, parameterCategory, - parameterNumber, probability=None): - """ - Translate GRIB2 phenomenon to CF phenomenon. - - Updates the metadata in-place with the translations. - - Args: - - * metadata: - :class:`collections.OrderedDict` of metadata. - - * discipline: - Message section 0, octet 7. - - * parameterCategory: - Message section 4, octet 10. - - * parameterNumber: - Message section 4, octet 11. - - Kwargs: - - * probability (:class:`Probability`): - If present, the data encodes a forecast probability analysis with the - given properties. - - """ - cf = itranslation.grib2_phenom_to_cf_info(param_discipline=discipline, - param_category=parameterCategory, - param_number=parameterNumber) - if cf is not None: - if probability is None: - metadata['standard_name'] = cf.standard_name - metadata['long_name'] = cf.long_name - metadata['units'] = cf.units - else: - # The basic name+unit info goes into a 'threshold coordinate' which - # encodes probability threshold values. - threshold_coord = DimCoord( - probability.threshold, - standard_name=cf.standard_name, long_name=cf.long_name, - units=cf.units) - metadata['aux_coords_and_dims'].append((threshold_coord, None)) - # The main cube has an adjusted name, and units of '1'. - base_name = cf.standard_name or cf.long_name - long_name = 'probability_of_{}_{}'.format( - base_name, probability.probability_type_name) - metadata['standard_name'] = None - metadata['long_name'] = long_name - metadata['units'] = Unit(1) - - -def time_range_unit(indicatorOfUnitOfTimeRange): - """ - Translate the time range indicator to an equivalent - :class:`cf_units.Unit`. - - Args: - - * indicatorOfUnitOfTimeRange: - Message section 4, octet 18. - - Returns: - :class:`cf_units.Unit`. - - """ - try: - unit = Unit(_TIME_RANGE_UNITS[indicatorOfUnitOfTimeRange]) - except (KeyError, ValueError): - msg = 'Product definition section 4 contains unsupported ' \ - 'time range unit [{}]'.format(indicatorOfUnitOfTimeRange) - raise TranslationError(msg) - return unit - - -def hybrid_factories(section, metadata): - """ - Translate the section 4 optional hybrid vertical coordinates. - - Updates the metadata in-place with the translations. - - Reference GRIB2 Code Table 4.5. - - Relevant notes: - [3] Hybrid pressure level (119) shall be used instead of Hybrid level (105) - - Args: - - * section: - Dictionary of coded key/value pairs from section 4 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - """ - NV = section['NV'] - if NV > 0: - typeOfFirstFixedSurface = section['typeOfFirstFixedSurface'] - if typeOfFirstFixedSurface == _TYPE_OF_FIXED_SURFACE_MISSING: - msg = 'Product definition section 4 contains missing ' \ - 'type of first fixed surface' - raise TranslationError(msg) - - typeOfSecondFixedSurface = section['typeOfSecondFixedSurface'] - if typeOfSecondFixedSurface != _TYPE_OF_FIXED_SURFACE_MISSING: - msg = 'Product definition section 4 contains unsupported type ' \ - 'of second fixed surface [{}]'.format(typeOfSecondFixedSurface) - raise TranslationError(msg) - - if typeOfFirstFixedSurface in [105, 119]: - # Hybrid level (105) and Hybrid pressure level (119). - scaleFactor = section['scaleFactorOfFirstFixedSurface'] - if scaleFactor != 0: - msg = 'Product definition section 4 contains invalid scale ' \ - 'factor of first fixed surface [{}]'.format(scaleFactor) - raise TranslationError(msg) - - # Create the model level number scalar coordinate. - scaledValue = section['scaledValueOfFirstFixedSurface'] - coord = DimCoord(scaledValue, standard_name='model_level_number', - attributes=dict(positive='up')) - metadata['aux_coords_and_dims'].append((coord, None)) - # Create the level pressure scalar coordinate. - pv = section['pv'] - offset = scaledValue - coord = DimCoord(pv[offset], long_name='level_pressure', - units='Pa') - metadata['aux_coords_and_dims'].append((coord, None)) - # Create the sigma scalar coordinate. - offset += NV / 2 - coord = AuxCoord(pv[offset], long_name='sigma') - metadata['aux_coords_and_dims'].append((coord, None)) - # Create the associated factory reference. - args = [{'long_name': 'level_pressure'}, {'long_name': 'sigma'}, - Reference('surface_air_pressure')] - factory = Factory(HybridPressureFactory, args) - metadata['factories'].append(factory) - else: - msg = 'Product definition section 4 contains unsupported ' \ - 'first fixed surface [{}]'.format(typeOfFirstFixedSurface) - raise TranslationError(msg) - - -def vertical_coords(section, metadata): - """ - Translate the vertical coordinates or hybrid vertical coordinates. - - Updates the metadata in-place with the translations. - - Reference GRIB2 Code Table 4.5. - - Args: - - * section: - Dictionary of coded key/value pairs from section 4 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - """ - if section['NV'] > 0: - # Generate hybrid vertical coordinates. - hybrid_factories(section, metadata) - else: - # Generate vertical coordinate. - typeOfFirstFixedSurface = section['typeOfFirstFixedSurface'] - key = 'scaledValueOfFirstFixedSurface' - scaledValueOfFirstFixedSurface = section[key] - fixed_surface = _FIXED_SURFACE.get(typeOfFirstFixedSurface) - - if fixed_surface is None: - if typeOfFirstFixedSurface != _TYPE_OF_FIXED_SURFACE_MISSING: - if scaledValueOfFirstFixedSurface == _MDI: - if options.warn_on_unsupported: - msg = 'Unable to translate type of first fixed ' \ - 'surface with missing scaled value.' - warnings.warn(msg) - else: - if options.warn_on_unsupported: - msg = 'Unable to translate type of first fixed ' \ - 'surface with scaled value.' - warnings.warn(msg) - else: - key = 'scaleFactorOfFirstFixedSurface' - scaleFactorOfFirstFixedSurface = section[key] - typeOfSecondFixedSurface = section['typeOfSecondFixedSurface'] - - if typeOfSecondFixedSurface != _TYPE_OF_FIXED_SURFACE_MISSING: - if typeOfFirstFixedSurface != typeOfSecondFixedSurface: - msg = 'Product definition section 4 has different ' \ - 'types of first and second fixed surface' - raise TranslationError(msg) - - key = 'scaledValueOfSecondFixedSurface' - scaledValueOfSecondFixedSurface = section[key] - - if scaledValueOfSecondFixedSurface == _MDI: - msg = 'Product definition section 4 has missing ' \ - 'scaled value of second fixed surface' - raise TranslationError(msg) - else: - key = 'scaleFactorOfSecondFixedSurface' - scaleFactorOfSecondFixedSurface = section[key] - first = unscale(scaledValueOfFirstFixedSurface, - scaleFactorOfFirstFixedSurface) - second = unscale(scaledValueOfSecondFixedSurface, - scaleFactorOfSecondFixedSurface) - point = 0.5 * (first + second) - bounds = [first, second] - coord = DimCoord(point, - standard_name=fixed_surface.standard_name, - long_name=fixed_surface.long_name, - units=fixed_surface.units, - bounds=bounds) - # Add the vertical coordinate to metadata aux coords. - metadata['aux_coords_and_dims'].append((coord, None)) - else: - point = unscale(scaledValueOfFirstFixedSurface, - scaleFactorOfFirstFixedSurface) - coord = DimCoord(point, - standard_name=fixed_surface.standard_name, - long_name=fixed_surface.long_name, - units=fixed_surface.units) - # Add the vertical coordinate to metadata aux coords. - metadata['aux_coords_and_dims'].append((coord, None)) - - -def forecast_period_coord(indicatorOfUnitOfTimeRange, forecastTime): - """ - Create the forecast period coordinate. - - Args: - - * indicatorOfUnitOfTimeRange: - Message section 4, octets 18. - - * forecastTime: - Message section 4, octets 19-22. - - Returns: - The scalar forecast period :class:`iris.coords.DimCoord`. - - """ - # Determine the forecast period and associated units. - unit = time_range_unit(indicatorOfUnitOfTimeRange) - point = unit.convert(forecastTime, 'hours') - # Create the forecast period scalar coordinate. - coord = DimCoord(point, standard_name='forecast_period', units='hours') - return coord - - -def statistical_forecast_period_coord(section, frt_coord): - """ - Create a forecast period coordinate for a time-statistic message. - - This applies only with a product definition template 4.8. - - Args: - - * section: - Dictionary of coded key/value pairs from section 4 of the message. - - * frt_coord: - The scalar forecast reference time :class:`iris.coords.DimCoord`. - - Returns: - The scalar forecast period :class:`iris.coords.DimCoord`, containing a - single, bounded point (period value). - - """ - # Get the period end time as a datetime. - end_time = datetime(section['yearOfEndOfOverallTimeInterval'], - section['monthOfEndOfOverallTimeInterval'], - section['dayOfEndOfOverallTimeInterval'], - section['hourOfEndOfOverallTimeInterval'], - section['minuteOfEndOfOverallTimeInterval'], - section['secondOfEndOfOverallTimeInterval']) - - # Get forecast reference time (frt) as a datetime. - frt_point = frt_coord.units.num2date(frt_coord.points[0]) - - # Get the period start time (as a timedelta relative to the frt). - forecast_time = section['forecastTime'] - if options.support_hindcast_values: - # Apply the hindcast fix. - forecast_time = _hindcast_fix(forecast_time) - forecast_units = time_range_unit(section['indicatorOfUnitOfTimeRange']) - forecast_seconds = forecast_units.convert(forecast_time, 'seconds') - start_time_delta = timedelta(seconds=forecast_seconds) - - # Get the period end time (as a timedelta relative to the frt). - end_time_delta = end_time - frt_point - - # Get the middle of the period (as a timedelta relative to the frt). - # Note: timedelta division in 2.7 is odd. Even though we request integer - # division, it's to the nearest _micro_second. - mid_time_delta = (start_time_delta + end_time_delta) // 2 - - # Create and return the forecast period coordinate. - def timedelta_hours(timedelta): - return timedelta.total_seconds() / 3600.0 - - mid_point_hours = timedelta_hours(mid_time_delta) - bounds_hours = [timedelta_hours(start_time_delta), - timedelta_hours(end_time_delta)] - fp_coord = DimCoord(mid_point_hours, bounds=bounds_hours, - standard_name='forecast_period', units='hours') - return fp_coord - - -def other_time_coord(rt_coord, fp_coord): - """ - Return the counterpart to the given scalar 'time' or - 'forecast_reference_time' coordinate, by combining it with the - given forecast_period coordinate. - - Bounds are not supported. - - Args: - - * rt_coord: - The scalar "reference time" :class:`iris.coords.DimCoord`, - as defined by section 1. This must be either a 'time' or - 'forecast_reference_time' coordinate. - - * fp_coord: - The scalar 'forecast_period' :class:`iris.coords.DimCoord`. - - Returns: - The scalar :class:`iris.coords.DimCoord` for either 'time' or - 'forecast_reference_time'. - - """ - if not rt_coord.units.is_time_reference(): - fmt = 'Invalid unit for reference time coord: {}' - raise ValueError(fmt.format(rt_coord.units)) - if not fp_coord.units.is_time(): - fmt = 'Invalid unit for forecast_period coord: {}' - raise ValueError(fmt.format(fp_coord.units)) - if rt_coord.has_bounds() or fp_coord.has_bounds(): - raise ValueError('Coordinate bounds are not supported') - if rt_coord.shape != (1,) or fp_coord.shape != (1,): - raise ValueError('Vector coordinates are not supported') - - if rt_coord.standard_name == 'time': - rt_base_unit = str(rt_coord.units).split(' since ')[0] - fp = fp_coord.units.convert(fp_coord.points[0], rt_base_unit) - frt = rt_coord.points[0] - fp - return DimCoord(frt, 'forecast_reference_time', units=rt_coord.units) - elif rt_coord.standard_name == 'forecast_reference_time': - return validity_time_coord(rt_coord, fp_coord) - else: - fmt = 'Unexpected reference time coordinate: {}' - raise ValueError(fmt.format(rt_coord.name())) - - -def validity_time_coord(frt_coord, fp_coord): - """ - Create the validity or phenomenon time coordinate. - - Args: - - * frt_coord: - The scalar forecast reference time :class:`iris.coords.DimCoord`. - - * fp_coord: - The scalar forecast period :class:`iris.coords.DimCoord`. - - Returns: - The scalar time :class:`iris.coords.DimCoord`. - It has bounds if the period coord has them, otherwise not. - - """ - if frt_coord.shape != (1,): - msg = 'Expected scalar forecast reference time coordinate when ' \ - 'calculating validity time, got shape {!r}'.format(frt_coord.shape) - raise ValueError(msg) - - if fp_coord.shape != (1,): - msg = 'Expected scalar forecast period coordinate when ' \ - 'calculating validity time, got shape {!r}'.format(fp_coord.shape) - raise ValueError(msg) - - def coord_timedelta(coord, value): - # Helper to convert a time coordinate value into a timedelta. - seconds = coord.units.convert(value, 'seconds') - return timedelta(seconds=seconds) - - # Calculate validity (phenomenon) time in forecast-reference-time units. - frt_point = frt_coord.units.num2date(frt_coord.points[0]) - point_delta = coord_timedelta(fp_coord, fp_coord.points[0]) - point = frt_coord.units.date2num(frt_point + point_delta) - - # Calculate bounds (if any) in the same way. - if fp_coord.bounds is None: - bounds = None - else: - bounds_deltas = [coord_timedelta(fp_coord, bound_point) - for bound_point in fp_coord.bounds[0]] - bounds = [frt_coord.units.date2num(frt_point + delta) - for delta in bounds_deltas] - - # Create the time scalar coordinate. - coord = DimCoord(point, bounds=bounds, - standard_name='time', units=frt_coord.units) - return coord - - -def generating_process(section): - if options.warn_on_unsupported: - # Reference Code Table 4.3. - warnings.warn('Unable to translate type of generating process.') - warnings.warn('Unable to translate background generating ' - 'process identifier.') - warnings.warn('Unable to translate forecast generating ' - 'process identifier.') - - -def data_cutoff(hoursAfterDataCutoff, minutesAfterDataCutoff): - """ - Handle the after reference time data cutoff. - - Args: - - * hoursAfterDataCutoff: - Message section 4, octets 15-16. - - * minutesAfterDataCutoff: - Message section 4, octet 17. - - """ - if (hoursAfterDataCutoff != _MDI or - minutesAfterDataCutoff != _MDI): - if options.warn_on_unsupported: - warnings.warn('Unable to translate "hours and/or minutes ' - 'after data cutoff".') - - -def statistical_cell_method(section): - """ - Create a cell method representing a time statistic. - - This applies only with a product definition template 4.8. - - Args: - - * section: - Dictionary of coded key/value pairs from section 4 of the message. - - Returns: - A cell method over 'time'. - - """ - # Handle the number of time ranges -- we currently only support one. - n_time_ranges = section['numberOfTimeRange'] - if n_time_ranges != 1: - if n_time_ranges == 0: - msg = ('Product definition section 4 specifies aggregation over ' - '"0 time ranges".') - raise TranslationError(msg) - else: - msg = ('Product definition section 4 specifies aggregation over ' - 'multiple time ranges [{}], which is not yet ' - 'supported.'.format(n_time_ranges)) - raise TranslationError(msg) - - # Decode the type of statistic (aggregation method). - statistic_code = section['typeOfStatisticalProcessing'] - statistic_name = _STATISTIC_TYPE_NAMES.get(statistic_code) - if statistic_name is None: - msg = ('grib statistical process type [{}] ' - 'is not supported'.format(statistic_code)) - raise TranslationError(msg) - - # Decode the type of time increment. - increment_typecode = section['typeOfTimeIncrement'] - if increment_typecode not in (2, 255): - # NOTE: All our current test data seems to contain the value 2, which - # is all we currently support. - # The exact interpretation of this is still unclear so we also accept - # a missing value. - msg = ('grib statistic time-increment type [{}] ' - 'is not supported.'.format(increment_typecode)) - raise TranslationError(msg) - - interval_number = section['timeIncrement'] - if interval_number == 0: - intervals_string = None - else: - units_string = _TIME_RANGE_UNITS[ - section['indicatorOfUnitForTimeIncrement']] - intervals_string = '{} {}'.format(interval_number, units_string) - - # Create a cell method to represent the time aggregation. - cell_method = CellMethod(method=statistic_name, - coords='time', - intervals=intervals_string) - return cell_method - - -def ensemble_identifier(section): - if options.warn_on_unsupported: - # Reference Code Table 4.6. - warnings.warn('Unable to translate type of ensemble forecast.') - warnings.warn('Unable to translate number of forecasts in ensemble.') - - # Create the realization coordinates. - realization = DimCoord(section['perturbationNumber'], - standard_name='realization', - units='no_unit') - return realization - - -def product_definition_template_0(section, metadata, rt_coord): - """ - Translate template representing an analysis or forecast at a horizontal - level or in a horizontal layer at a point in time. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 4 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - * rt_coord: - The scalar "reference time" :class:`iris.coords.DimCoord`. - This will be either 'time' or 'forecast_reference_time'. - - """ - # Handle generating process details. - generating_process(section) - - # Handle the data cutoff. - data_cutoff(section['hoursAfterDataCutoff'], - section['minutesAfterDataCutoff']) - - if 'forecastTime' in section.keys(): - forecast_time = section['forecastTime'] - # The gribapi encodes the forecast time as 'startStep' for pdt 4.4x; - # product_definition_template_40 makes use of this function. The - # following will be removed once the suspected bug is fixed. - elif 'startStep' in section.keys(): - forecast_time = section['startStep'] - - # Calculate the forecast period coordinate. - fp_coord = forecast_period_coord(section['indicatorOfUnitOfTimeRange'], - forecast_time) - # Add the forecast period coordinate to the metadata aux coords. - metadata['aux_coords_and_dims'].append((fp_coord, None)) - - # Calculate the "other" time coordinate - i.e. whichever of 'time' - # or 'forecast_reference_time' we don't already have. - other_coord = other_time_coord(rt_coord, fp_coord) - # Add the time coordinate to the metadata aux coords. - metadata['aux_coords_and_dims'].append((other_coord, None)) - - # Add the reference time coordinate to the metadata aux coords. - metadata['aux_coords_and_dims'].append((rt_coord, None)) - - # Check for vertical coordinates. - vertical_coords(section, metadata) - - -def product_definition_template_1(section, metadata, frt_coord): - """ - Translate template representing individual ensemble forecast, control - and perturbed, at a horizontal level or in a horizontal layer at a - point in time. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 4 of the message. - - * metadata: - :class:`collectins.OrderedDict` of metadata. - - * frt_coord: - The scalar forecast reference time :class:`iris.coords.DimCoord`. - - """ - # Perform identical message processing. - product_definition_template_0(section, metadata, frt_coord) - - realization = ensemble_identifier(section) - - # Add the realization coordinate to the metadata aux coords. - metadata['aux_coords_and_dims'].append((realization, None)) - - -def product_definition_template_8(section, metadata, frt_coord): - """ - Translate template representing average, accumulation and/or extreme values - or other statistically processed values at a horizontal level or in a - horizontal layer in a continuous or non-continuous time interval. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 4 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - * frt_coord: - The scalar forecast reference time :class:`iris.coords.DimCoord`. - - """ - # Handle generating process details. - generating_process(section) - - # Handle the data cutoff. - data_cutoff(section['hoursAfterDataCutoff'], - section['minutesAfterDataCutoff']) - - # Create a cell method to represent the time statistic. - time_statistic_cell_method = statistical_cell_method(section) - # Add the forecast cell method to the metadata. - metadata['cell_methods'].append(time_statistic_cell_method) - - # Add the forecast reference time coordinate to the metadata aux coords, - # if it is a forecast reference time, not a time coord, as defined by - # significanceOfReferenceTime. - if frt_coord.name() != 'time': - metadata['aux_coords_and_dims'].append((frt_coord, None)) - - # Add a bounded forecast period coordinate. - fp_coord = statistical_forecast_period_coord(section, frt_coord) - metadata['aux_coords_and_dims'].append((fp_coord, None)) - - # Calculate a bounded validity time coord matching the forecast period. - t_coord = validity_time_coord(frt_coord, fp_coord) - # Add the time coordinate to the metadata aux coords. - metadata['aux_coords_and_dims'].append((t_coord, None)) - - # Check for vertical coordinates. - vertical_coords(section, metadata) - - -def product_definition_template_9(section, metadata, frt_coord): - """ - Translate template representing probability forecasts at a - horizontal level or in a horizontal layer in a continuous or - non-continuous time interval. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 4 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - * frt_coord: - The scalar forecast reference time :class:`iris.coords.DimCoord`. - - """ - # Start by calling PDT8 as all elements of that are common to this. - product_definition_template_8(section, metadata, frt_coord) - - # Remove the cell_method encoding the underlying statistic, as CF does not - # currently support this type of representation. - cell_method, = metadata['cell_methods'] - metadata['cell_methods'] = [] - # NOTE: we currently don't record the nature of the underlying statistic, - # as we don't have an agreed way of representing that in CF. - - # Return a probability object to control the production of a probability - # result. This is done once the underlying phenomenon type is determined, - # in 'translate_phenomenon'. - probability_typecode = section['probabilityType'] - if probability_typecode == 1: - # Type is "above upper level". - threshold_value = section['scaledValueOfUpperLimit'] - if threshold_value == _MDI: - msg = 'Product definition section 4 has missing ' \ - 'scaled value of upper limit' - raise TranslationError(msg) - threshold_scaling = section['scaleFactorOfUpperLimit'] - if threshold_scaling == _MDI: - msg = 'Product definition section 4 has missing ' \ - 'scale factor of upper limit' - raise TranslationError(msg) - # Encode threshold information. - threshold = unscale(threshold_value, threshold_scaling) - probability_type = Probability('above_threshold', threshold) - # Note that GRIB provides separate "above lower threshold" and "above - # upper threshold" probability types. This naming style doesn't - # recognise that distinction. For now, assume this is not important. - else: - msg = ('Product definition section 4 contains an unsupported ' - 'probability type [{}]'.format(probability_typecode)) - raise TranslationError(msg) - - return probability_type - - -def product_definition_template_10(section, metadata, frt_coord): - """ - Translate template representing percentile forecasts at a horizontal level - or in a horizontal layer in a continuous or non-continuous time interval. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 4 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - * frt_coord: - The scalar forecast reference time :class:`iris.coords.DimCoord`. - - """ - product_definition_template_8(section, metadata, frt_coord) - - percentile = DimCoord(section['percentileValue'], - long_name='percentile_over_time', - units='no_unit') - - # Add the percentile data info - metadata['aux_coords_and_dims'].append((percentile, None)) - - -def product_definition_template_11(section, metadata, frt_coord): - """ - Translate template representing individual ensemble forecast, control - or perturbed; average, accumulation and/or extreme values - or other statistically processed values at a horizontal level or in a - horizontal layer in a continuous or non-continuous time interval. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 4 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - * frt_coord: - The scalar forecast reference time :class:`iris.coords.DimCoord`. - - """ - product_definition_template_8(section, metadata, frt_coord) - - realization = ensemble_identifier(section) - - # Add the realization coordinate to the metadata aux coords. - metadata['aux_coords_and_dims'].append((realization, None)) - - -def product_definition_template_31(section, metadata, rt_coord): - """ - Translate template representing a satellite product. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 4 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - * rt_coord: - The scalar observation time :class:`iris.coords.DimCoord'. - - """ - if options.warn_on_unsupported: - warnings.warn('Unable to translate type of generating process.') - warnings.warn('Unable to translate observation generating ' - 'process identifier.') - - # Number of contributing spectral bands. - NB = section['NB'] - - if NB > 0: - # Create the satellite series coordinate. - satelliteSeries = section['satelliteSeries'] - coord = AuxCoord(satelliteSeries, long_name='satellite_series') - # Add the satellite series coordinate to the metadata aux coords. - metadata['aux_coords_and_dims'].append((coord, None)) - - # Create the satellite number coordinate. - satelliteNumber = section['satelliteNumber'] - coord = AuxCoord(satelliteNumber, long_name='satellite_number') - # Add the satellite number coordinate to the metadata aux coords. - metadata['aux_coords_and_dims'].append((coord, None)) - - # Create the satellite instrument type coordinate. - instrumentType = section['instrumentType'] - coord = AuxCoord(instrumentType, long_name='instrument_type') - # Add the instrument type coordinate to the metadata aux coords. - metadata['aux_coords_and_dims'].append((coord, None)) - - # Create the central wave number coordinate. - scaleFactor = section['scaleFactorOfCentralWaveNumber'] - scaledValue = section['scaledValueOfCentralWaveNumber'] - wave_number = unscale(scaledValue, scaleFactor) - standard_name = 'sensor_band_central_radiation_wavenumber' - coord = AuxCoord(wave_number, - standard_name=standard_name, - units=Unit('m-1')) - # Add the central wave number coordinate to the metadata aux coords. - metadata['aux_coords_and_dims'].append((coord, None)) - - # Add the observation time coordinate. - metadata['aux_coords_and_dims'].append((rt_coord, None)) - - -def product_definition_template_40(section, metadata, frt_coord): - """ - Translate template representing an analysis or forecast at a horizontal - level or in a horizontal layer at a point in time for atmospheric chemical - constituents. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 4 of the message. - - * metadata: - :class:`collectins.OrderedDict` of metadata. - - * frt_coord: - The scalar forecast reference time :class:`iris.coords.DimCoord`. - - """ - # Perform identical message processing. - product_definition_template_0(section, metadata, frt_coord) - - # Reference GRIB2 Code Table 4.230. - constituent_type = section['constituentType'] - - # Add the constituent type as an attribute. - metadata['attributes']['WMO_constituent_type'] = constituent_type - - -def product_definition_section(section, metadata, discipline, tablesVersion, - rt_coord): - """ - Translate section 4 from the GRIB2 message. - - Updates the metadata in-place with the translations. - - Args: - - * section: - Dictionary of coded key/value pairs from section 4 of the message. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - * discipline: - Message section 0, octet 7. - - * tablesVersion: - Message section 1, octet 10. - - * rt_coord: - The scalar reference time :class:`iris.coords.DimCoord`. - - """ - # Reference GRIB2 Code Table 4.0. - template = section['productDefinitionTemplateNumber'] - - probability = None - if template == 0: - # Process analysis or forecast at a horizontal level or - # in a horizontal layer at a point in time. - product_definition_template_0(section, metadata, rt_coord) - elif template == 1: - # Process individual ensemble forecast, control and perturbed, at - # a horizontal level or in a horizontal layer at a point in time. - product_definition_template_1(section, metadata, rt_coord) - elif template == 8: - # Process statistically processed values at a horizontal level or in a - # horizontal layer in a continuous or non-continuous time interval. - product_definition_template_8(section, metadata, rt_coord) - elif template == 9: - probability = \ - product_definition_template_9(section, metadata, rt_coord) - elif template == 10: - product_definition_template_10(section, metadata, rt_coord) - elif template == 11: - product_definition_template_11(section, metadata, rt_coord) - elif template == 31: - # Process satellite product. - product_definition_template_31(section, metadata, rt_coord) - elif template == 40: - product_definition_template_40(section, metadata, rt_coord) - else: - msg = 'Product definition template [{}] is not ' \ - 'supported'.format(template) - raise TranslationError(msg) - - # Translate GRIB2 phenomenon to CF phenomenon. - if tablesVersion != _CODE_TABLES_MISSING: - translate_phenomenon(metadata, discipline, - section['parameterCategory'], - section['parameterNumber'], - probability=probability) - - -############################################################################### -# -# Data Representation Section 5 -# -############################################################################### - -def data_representation_section(section): - """ - Translate section 5 from the GRIB2 message. - Grid point template decoding is fully provided by the ECMWF GRIB API, - all grid point and spectral templates are supported, the data payload - is returned from the GRIB API already unpacked. - - """ - # Reference GRIB2 Code Table 5.0. - template = section['dataRepresentationTemplateNumber'] - - # Supported templates for both grid point and spectral data: - grid_point_templates = (0, 1, 2, 3, 4, 40, 41, 61) - spectral_templates = (50, 51) - supported_templates = grid_point_templates + spectral_templates - - if template not in supported_templates: - msg = 'Data Representation Section Template [{}] is not ' \ - 'supported'.format(template) - raise TranslationError(msg) - - -############################################################################### -# -# Bitmap Section 6 -# -############################################################################### - -def bitmap_section(section): - """ - Translate section 6 from the GRIB2 message. - - The bitmap can take the following values: - - * 0: Bitmap applies to the data and is specified in this section - of this message. - * 1-253: Bitmap applies to the data, is specified by originating - centre and is not specified in section 6 of this message. - * 254: Bitmap applies to the data, is specified in an earlier - section 6 of this message and is not specified in this - section 6 of this message. - * 255: Bitmap does not apply to the data. - - Only values 0 and 255 are supported. - - """ - # Reference GRIB2 Code Table 6.0. - bitMapIndicator = section['bitMapIndicator'] - - if bitMapIndicator not in [_BITMAP_CODE_NONE, _BITMAP_CODE_PRESENT]: - msg = 'Bitmap Section 6 contains unsupported ' \ - 'bitmap indicator [{}]'.format(bitMapIndicator) - raise TranslationError(msg) - - -############################################################################### - -def grib2_convert(field, metadata): - """ - Translate the GRIB2 message into the appropriate cube metadata. - - Updates the metadata in-place with the translations. - - Args: - - * field: - GRIB2 message to be translated. - - * metadata: - :class:`collections.OrderedDict` of metadata. - - """ - # Section 1 - Identification Section. - centre = _CENTRES.get(field.sections[1]['centre']) - if centre is not None: - metadata['attributes']['centre'] = centre - rt_coord = reference_time_coord(field.sections[1]) - - # Section 3 - Grid Definition Section (Grid Definition Template) - grid_definition_section(field.sections[3], metadata) - - # Section 4 - Product Definition Section (Product Definition Template) - product_definition_section(field.sections[4], metadata, - field.sections[0]['discipline'], - field.sections[1]['tablesVersion'], - rt_coord) - - # Section 5 - Data Representation Section (Data Representation Template) - data_representation_section(field.sections[5]) - - # Section 6 - Bitmap Section. - bitmap_section(field.sections[6]) - - -############################################################################### - -def convert(field): - """ - Translate the GRIB message into the appropriate cube metadata. - - Args: - - * field: - GRIB message to be translated. - - Returns: - A :class:`iris.fileformats.rules.ConversionMetadata` object. - - """ - if hasattr(field, 'sections'): - editionNumber = field.sections[0]['editionNumber'] - - if editionNumber != 2: - emsg = 'GRIB edition {} is not supported by {!r}.' - raise TranslationError(emsg.format(editionNumber, - type(field).__name__)) - - # Initialise the cube metadata. - metadata = OrderedDict() - metadata['factories'] = [] - metadata['references'] = [] - metadata['standard_name'] = None - metadata['long_name'] = None - metadata['units'] = None - metadata['attributes'] = {} - metadata['cell_methods'] = [] - metadata['dim_coords_and_dims'] = [] - metadata['aux_coords_and_dims'] = [] - - # Convert GRIB2 message to cube metadata. - grib2_convert(field, metadata) - - result = ConversionMetadata._make(metadata.values()) - else: - editionNumber = field.edition - - if editionNumber != 1: - emsg = 'GRIB edition {} is not supported by {!r}.' - raise TranslationError(emsg.format(editionNumber, - type(field).__name__)) - - result = grib1_convert(field) - - return result diff --git a/iris_grib/_save_rules.py b/iris_grib/_save_rules.py deleted file mode 100644 index 22653f3f..00000000 --- a/iris_grib/_save_rules.py +++ /dev/null @@ -1,1130 +0,0 @@ -# (C) British Crown Copyright 2010 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Grib save implementation. - -:mod:`iris_grib._save_rules` is a private module with no public API. -It is invoked from :meth:`iris_grib.save_grib2`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -import warnings - -import cf_units -import gribapi -import numpy as np -import numpy.ma as ma - -import iris -import iris.exceptions -from iris.coord_systems import GeogCS, RotatedGeogCS, TransverseMercator -from . import grib_phenom_translation as gptx -from ._load_convert import (_STATISTIC_TYPE_NAMES, _TIME_RANGE_UNITS) -from iris.util import is_regular, regular_step - - -# Invert code tables from :mod:`iris_grib._load_convert`. -_STATISTIC_TYPE_NAMES = {val: key for key, val in - _STATISTIC_TYPE_NAMES.items()} -_TIME_RANGE_UNITS = {val: key for key, val in _TIME_RANGE_UNITS.items()} - - -def fixup_float32_as_int32(value): - """ - Workaround for use when the ECMWF GRIB API treats an IEEE 32-bit - floating-point value as a signed, 4-byte integer. - - Returns the integer value which will result in the on-disk - representation corresponding to the IEEE 32-bit floating-point - value. - - """ - value_as_float32 = np.array(value, dtype='f4') - value_as_uint32 = value_as_float32.view(dtype='u4') - if value_as_uint32 >= 0x80000000: - # Convert from two's-complement to sign-and-magnitude. - # NB. Because of the silly representation of negative - # integers in GRIB2, there is no value we can pass to - # grib_set that will result in the bit pattern 0x80000000. - # But since that bit pattern corresponds to a floating - # point value of negative-zero, we can safely treat it as - # positive-zero instead. - value_as_grib_int = 0x80000000 - int(value_as_uint32) - else: - value_as_grib_int = int(value_as_uint32) - return value_as_grib_int - - -def fixup_int32_as_uint32(value): - """ - Workaround for use when the ECMWF GRIB API treats a signed, 4-byte - integer value as an unsigned, 4-byte integer. - - Returns the unsigned integer value which will result in the on-disk - representation corresponding to the signed, 4-byte integer value. - - """ - value = int(value) - if -0x7fffffff <= value <= 0x7fffffff: - if value < 0: - # Convert from two's-complement to sign-and-magnitude. - value = 0x80000000 - value - else: - msg = '{} out of range -2147483647 to 2147483647.'.format(value) - raise ValueError(msg) - return value - - -def ensure_set_int32_value(grib, key, value): - """ - Ensure the workaround function :func:`fixup_int32_as_uint32` is applied as - necessary to problem keys. - - """ - try: - gribapi.grib_set(grib, key, value) - except gribapi.GribInternalError: - value = fixup_int32_as_uint32(value) - gribapi.grib_set(grib, key, value) - - -############################################################################### -# -# Constants -# -############################################################################### - -# Reference Flag Table 3.3 -_RESOLUTION_AND_COMPONENTS_GRID_WINDS_BIT = 3 # NB "bit5", from MSB=1. - -# Reference Regulation 92.1.6 -_DEFAULT_DEGREES_UNITS = 1.0e-6 - - -############################################################################### -# -# Identification Section 1 -# -############################################################################### - - -def centre(cube, grib): - # TODO: read centre from cube - gribapi.grib_set_long(grib, "centre", 74) # UKMO - gribapi.grib_set_long(grib, "subCentre", 0) # exeter is not in the spec - - -def reference_time(cube, grib): - # Set the reference time. - # (analysis, forecast start, verify time, obs time, etc) - try: - fp_coord = cube.coord("forecast_period") - except iris.exceptions.CoordinateNotFoundError: - fp_coord = None - - if fp_coord is not None: - rt, rt_meaning, _, _ = _non_missing_forecast_period(cube) - else: - rt, rt_meaning, _, _ = _missing_forecast_period(cube) - - gribapi.grib_set_long(grib, "significanceOfReferenceTime", rt_meaning) - gribapi.grib_set_long( - grib, "dataDate", "%04d%02d%02d" % (rt.year, rt.month, rt.day)) - gribapi.grib_set_long( - grib, "dataTime", "%02d%02d" % (rt.hour, rt.minute)) - - # TODO: Set the calendar, when we find out what happened to the proposal! - # http://tinyurl.com/oefqgv6 - # I was sure it was approved for pre-operational use but it's not there. - - -def identification(cube, grib): - centre(cube, grib) - reference_time(cube, grib) - - # operational product, operational test, research product, etc - # (missing for now) - gribapi.grib_set_long(grib, "productionStatusOfProcessedData", 255) - - # Code table 1.4 - # analysis, forecast, processed satellite, processed radar, - if cube.coords('realization'): - # assume realization will always have 1 and only 1 point - # as cubes saving to GRIB2 a 2D horizontal slices - if cube.coord('realization').points[0] != 0: - gribapi.grib_set_long(grib, "typeOfProcessedData", 4) - else: - gribapi.grib_set_long(grib, "typeOfProcessedData", 3) - else: - gribapi.grib_set_long(grib, "typeOfProcessedData", 2) - - -############################################################################### -# -# Grid Definition Section 3 -# -############################################################################### - - -def shape_of_the_earth(cube, grib): - # assume latlon - cs = cube.coord(dimensions=[0]).coord_system - - # Initially set shape_of_earth keys to missing (255 for byte, -1 for long). - gribapi.grib_set_long(grib, "scaleFactorOfRadiusOfSphericalEarth", 255) - gribapi.grib_set_long(grib, "scaledValueOfRadiusOfSphericalEarth", -1) - gribapi.grib_set_long(grib, "scaleFactorOfEarthMajorAxis", 255) - gribapi.grib_set_long(grib, "scaledValueOfEarthMajorAxis", -1) - gribapi.grib_set_long(grib, "scaleFactorOfEarthMinorAxis", 255) - gribapi.grib_set_long(grib, "scaledValueOfEarthMinorAxis", -1) - - if isinstance(cs, GeogCS): - ellipsoid = cs - else: - ellipsoid = cs.ellipsoid - if ellipsoid is None: - msg = "Could not determine shape of the earth from coord system "\ - "of horizontal grid." - raise iris.exceptions.TranslationError(msg) - - # Spherical earth. - if ellipsoid.inverse_flattening == 0.0: - gribapi.grib_set_long(grib, "shapeOfTheEarth", 1) - gribapi.grib_set_long(grib, "scaleFactorOfRadiusOfSphericalEarth", 0) - gribapi.grib_set_long(grib, "scaledValueOfRadiusOfSphericalEarth", - ellipsoid.semi_major_axis) - # Oblate spheroid earth. - else: - gribapi.grib_set_long(grib, "shapeOfTheEarth", 7) - gribapi.grib_set_long(grib, "scaleFactorOfEarthMajorAxis", 0) - gribapi.grib_set_long(grib, "scaledValueOfEarthMajorAxis", - ellipsoid.semi_major_axis) - gribapi.grib_set_long(grib, "scaleFactorOfEarthMinorAxis", 0) - gribapi.grib_set_long(grib, "scaledValueOfEarthMinorAxis", - ellipsoid.semi_minor_axis) - - -def grid_dims(x_coord, y_coord, grib): - gribapi.grib_set_long(grib, "Ni", x_coord.shape[0]) - gribapi.grib_set_long(grib, "Nj", y_coord.shape[0]) - - -def latlon_first_last(x_coord, y_coord, grib): - if x_coord.has_bounds() or y_coord.has_bounds(): - warnings.warn("Ignoring xy bounds") - -# XXX Pending #1125 -# gribapi.grib_set_double(grib, "latitudeOfFirstGridPointInDegrees", -# float(y_coord.points[0])) -# gribapi.grib_set_double(grib, "latitudeOfLastGridPointInDegrees", -# float(y_coord.points[-1])) -# gribapi.grib_set_double(grib, "longitudeOfFirstGridPointInDegrees", -# float(x_coord.points[0])) -# gribapi.grib_set_double(grib, "longitudeOfLastGridPointInDegrees", -# float(x_coord.points[-1])) -# WORKAROUND - gribapi.grib_set_long(grib, "latitudeOfFirstGridPoint", - int(y_coord.points[0]*1000000)) - gribapi.grib_set_long(grib, "latitudeOfLastGridPoint", - int(y_coord.points[-1]*1000000)) - gribapi.grib_set_long(grib, "longitudeOfFirstGridPoint", - int((x_coord.points[0] % 360)*1000000)) - gribapi.grib_set_long(grib, "longitudeOfLastGridPoint", - int((x_coord.points[-1] % 360)*1000000)) - - -def dx_dy(x_coord, y_coord, grib): - x_step = regular_step(x_coord) - y_step = regular_step(y_coord) - gribapi.grib_set(grib, "DxInDegrees", float(abs(x_step))) - gribapi.grib_set(grib, "DyInDegrees", float(abs(y_step))) - - -def scanning_mode_flags(x_coord, y_coord, grib): - gribapi.grib_set_long(grib, "iScansPositively", - int(x_coord.points[1] - x_coord.points[0] > 0)) - gribapi.grib_set_long(grib, "jScansPositively", - int(y_coord.points[1] - y_coord.points[0] > 0)) - - -def horizontal_grid_common(cube, grib): - # Grib encoding of the sequences of X and Y points. - y_coord = cube.coord(dimensions=[0]) - x_coord = cube.coord(dimensions=[1]) - shape_of_the_earth(cube, grib) - grid_dims(x_coord, y_coord, grib) - scanning_mode_flags(x_coord, y_coord, grib) - - -def latlon_points_regular(cube, grib): - y_coord = cube.coord(dimensions=[0]) - x_coord = cube.coord(dimensions=[1]) - latlon_first_last(x_coord, y_coord, grib) - dx_dy(x_coord, y_coord, grib) - - -def latlon_points_irregular(cube, grib): - y_coord = cube.coord(dimensions=[0]) - x_coord = cube.coord(dimensions=[1]) - - # Distinguish between true-north and grid-oriented vectors. - is_grid_wind = cube.name() in ('x_wind', 'y_wind', 'grid_eastward_wind', - 'grid_northward_wind') - # Encode in bit "5" of 'resolutionAndComponentFlags' (other bits unused). - component_flags = 0 - if is_grid_wind: - component_flags |= 2 ** _RESOLUTION_AND_COMPONENTS_GRID_WINDS_BIT - gribapi.grib_set(grib, 'resolutionAndComponentFlags', component_flags) - - # Record the X and Y coordinate values. - # NOTE: there is currently a bug in the gribapi which means that the size - # of the longitudes array does not equal 'Nj', as it should. - # See : https://software.ecmwf.int/issues/browse/SUP-1096 - # So, this only works at present if the x and y dimensions are **equal**. - lon_values = x_coord.points / _DEFAULT_DEGREES_UNITS - lat_values = y_coord.points / _DEFAULT_DEGREES_UNITS - gribapi.grib_set_array(grib, 'longitudes', - np.array(np.round(lon_values), dtype=np.int64)) - gribapi.grib_set_array(grib, 'latitudes', - np.array(np.round(lat_values), dtype=np.int64)) - - -def rotated_pole(cube, grib): - # Grib encoding of a rotated pole coordinate system. - cs = cube.coord(dimensions=[0]).coord_system - - if cs.north_pole_grid_longitude != 0.0: - raise iris.exceptions.TranslationError( - 'Grib save does not yet support Rotated-pole coordinates with ' - 'a rotated prime meridian.') -# XXX Pending #1125 -# gribapi.grib_set_double(grib, "latitudeOfSouthernPoleInDegrees", -# float(cs.n_pole.latitude)) -# gribapi.grib_set_double(grib, "longitudeOfSouthernPoleInDegrees", -# float(cs.n_pole.longitude)) -# gribapi.grib_set_double(grib, "angleOfRotationInDegrees", 0) -# WORKAROUND - latitude = cs.grid_north_pole_latitude / _DEFAULT_DEGREES_UNITS - longitude = (((cs.grid_north_pole_longitude + 180) % 360) / - _DEFAULT_DEGREES_UNITS) - gribapi.grib_set(grib, "latitudeOfSouthernPole", - int(round(latitude))) - gribapi.grib_set(grib, "longitudeOfSouthernPole", int(round(longitude))) - gribapi.grib_set(grib, "angleOfRotation", 0) - - -def grid_definition_template_0(cube, grib): - """ - Set keys within the provided grib message based on - Grid Definition Template 3.0. - - Template 3.0 is used to represent "latitude/longitude (or equidistant - cylindrical, or Plate Carree)". - The coordinates are regularly spaced, true latitudes and longitudes. - - """ - # Constant resolution, aka 'regular' true lat-lon grid. - gribapi.grib_set_long(grib, "gridDefinitionTemplateNumber", 0) - horizontal_grid_common(cube, grib) - latlon_points_regular(cube, grib) - - -def grid_definition_template_1(cube, grib): - """ - Set keys within the provided grib message based on - Grid Definition Template 3.1. - - Template 3.1 is used to represent "rotated latitude/longitude (or - equidistant cylindrical, or Plate Carree)". - The coordinates are regularly spaced, rotated latitudes and longitudes. - - """ - # Constant resolution, aka 'regular' rotated lat-lon grid. - gribapi.grib_set_long(grib, "gridDefinitionTemplateNumber", 1) - - # Record details of the rotated coordinate system. - rotated_pole(cube, grib) - - # Encode the lat/lon points. - horizontal_grid_common(cube, grib) - latlon_points_regular(cube, grib) - - -def grid_definition_template_5(cube, grib): - """ - Set keys within the provided grib message based on - Grid Definition Template 3.5. - - Template 3.5 is used to represent "variable resolution rotated - latitude/longitude". - The coordinates are irregularly spaced, rotated latitudes and longitudes. - - """ - # NOTE: we must set Ni=Nj=1 before establishing the template. - # Without this, setting "gridDefinitionTemplateNumber" = 5 causes an - # immediate error. - # See: https://software.ecmwf.int/issues/browse/SUP-1095 - # This is acceptable, as the subsequent call to 'horizontal_grid_common' - # will set these to the correct horizontal dimensions - # (by calling 'grid_dims'). - gribapi.grib_set(grib, "Ni", 1) - gribapi.grib_set(grib, "Nj", 1) - gribapi.grib_set(grib, "gridDefinitionTemplateNumber", 5) - - # Record details of the rotated coordinate system. - rotated_pole(cube, grib) - # Encode the lat/lon points. - horizontal_grid_common(cube, grib) - latlon_points_irregular(cube, grib) - - -def grid_definition_template_12(cube, grib): - """ - Set keys within the provided grib message based on - Grid Definition Template 3.12. - - Template 3.12 is used to represent a Transverse Mercator grid. - - """ - gribapi.grib_set(grib, "gridDefinitionTemplateNumber", 12) - - # Retrieve some information from the cube. - y_coord = cube.coord(dimensions=[0]) - x_coord = cube.coord(dimensions=[1]) - cs = y_coord.coord_system - - # Normalise the coordinate values to centimetres - the resolution - # used in the GRIB message. - def points_in_cm(coord): - points = coord.units.convert(coord.points, 'cm') - points = np.around(points).astype(int) - return points - y_cm = points_in_cm(y_coord) - x_cm = points_in_cm(x_coord) - - # Set some keys specific to GDT12. - # Encode the horizontal points. - - # NB. Since we're already in centimetres, our tolerance for - # discrepancy in the differences is 1. - def step(points): - diffs = points[1:] - points[:-1] - mean_diff = np.mean(diffs).astype(points.dtype) - if not np.allclose(diffs, mean_diff, atol=1): - msg = ('Irregular coordinates not supported for transverse ' - 'Mercator.') - raise iris.exceptions.TranslationError(msg) - return int(mean_diff) - - gribapi.grib_set(grib, 'Di', abs(step(x_cm))) - gribapi.grib_set(grib, 'Dj', abs(step(y_cm))) - horizontal_grid_common(cube, grib) - - # GRIBAPI expects unsigned ints in X1, X2, Y1, Y2 but it should accept - # signed ints, so work around this. - # See https://software.ecmwf.int/issues/browse/SUP-1101 - ensure_set_int32_value(grib, 'Y1', int(y_cm[0])) - ensure_set_int32_value(grib, 'Y2', int(y_cm[-1])) - ensure_set_int32_value(grib, 'X1', int(x_cm[0])) - ensure_set_int32_value(grib, 'X2', int(x_cm[-1])) - - # Lat and lon of reference point are measured in millionths of a degree. - gribapi.grib_set(grib, "latitudeOfReferencePoint", - cs.latitude_of_projection_origin / _DEFAULT_DEGREES_UNITS) - gribapi.grib_set(grib, "longitudeOfReferencePoint", - cs.longitude_of_central_meridian / _DEFAULT_DEGREES_UNITS) - - # Convert a value in metres into the closest integer number of - # centimetres. - def m_to_cm(value): - return int(round(value * 100)) - - # False easting and false northing are measured in units of (10^-2)m. - gribapi.grib_set(grib, 'XR', m_to_cm(cs.false_easting)) - gribapi.grib_set(grib, 'YR', m_to_cm(cs.false_northing)) - - # GRIBAPI expects a signed int for scaleFactorAtReferencePoint - # but it should accept a float, so work around this. - # See https://software.ecmwf.int/issues/browse/SUP-1100 - value = cs.scale_factor_at_central_meridian - key_type = gribapi.grib_get_native_type(grib, - "scaleFactorAtReferencePoint") - if key_type is not float: - value = fixup_float32_as_int32(value) - gribapi.grib_set(grib, "scaleFactorAtReferencePoint", value) - - -def grid_definition_section(cube, grib): - """ - Set keys within the grid definition section of the provided grib message, - based on the properties of the cube. - - """ - x_coord = cube.coord(dimensions=[1]) - y_coord = cube.coord(dimensions=[0]) - cs = x_coord.coord_system # N.B. already checked same cs for x and y. - regular_x_and_y = is_regular(x_coord) and is_regular(y_coord) - - if isinstance(cs, GeogCS): - if not regular_x_and_y: - raise iris.exceptions.TranslationError( - 'Saving an irregular latlon grid to GRIB (PDT3.4) is not ' - 'yet supported.') - - grid_definition_template_0(cube, grib) - - elif isinstance(cs, RotatedGeogCS): - # Rotated coordinate system cases. - # Choose between GDT 3.1 and 3.5 according to coordinate regularity. - if regular_x_and_y: - grid_definition_template_1(cube, grib) - else: - grid_definition_template_5(cube, grib) - - elif isinstance(cs, TransverseMercator): - # Transverse Mercator coordinate system (template 3.12). - grid_definition_template_12(cube, grib) - - else: - raise ValueError('Grib saving is not supported for coordinate system: ' - '{}'.format(cs)) - - -############################################################################### -# -# Product Definition Section 4 -# -############################################################################### - -def set_discipline_and_parameter(cube, grib): - # NOTE: for now, can match by *either* standard_name or long_name. - # This allows workarounds for data with no identified standard_name. - grib2_info = gptx.cf_phenom_to_grib2_info(cube.standard_name, - cube.long_name) - if grib2_info is not None: - gribapi.grib_set(grib, "discipline", grib2_info.discipline) - gribapi.grib_set(grib, "parameterCategory", grib2_info.category) - gribapi.grib_set(grib, "parameterNumber", grib2_info.number) - else: - gribapi.grib_set(grib, "discipline", 255) - gribapi.grib_set(grib, "parameterCategory", 255) - gribapi.grib_set(grib, "parameterNumber", 255) - warnings.warn('Unable to determine Grib2 parameter code for cube.\n' - 'discipline, parameterCategory and parameterNumber ' - 'have been set to "missing".') - - -def _non_missing_forecast_period(cube): - # Calculate "model start time" to use as the reference time. - fp_coord = cube.coord("forecast_period") - - # Convert fp and t to hours so we can subtract to calculate R. - cf_fp_hrs = fp_coord.units.convert(fp_coord.points[0], 'hours') - t_coord = cube.coord("time").copy() - hours_since = cf_units.Unit("hours since epoch", - calendar=t_coord.units.calendar) - t_coord.convert_units(hours_since) - - rt_num = t_coord.points[0] - cf_fp_hrs - rt = hours_since.num2date(rt_num) - rt_meaning = 1 # "start of forecast" - - # Forecast period - if fp_coord.units == cf_units.Unit("hours"): - grib_time_code = 1 - elif fp_coord.units == cf_units.Unit("minutes"): - grib_time_code = 0 - elif fp_coord.units == cf_units.Unit("seconds"): - grib_time_code = 13 - else: - raise iris.exceptions.TranslationError( - "Unexpected units for 'forecast_period' : %s" % fp_coord.units) - - if not t_coord.has_bounds(): - fp = fp_coord.points[0] - else: - if not fp_coord.has_bounds(): - raise iris.exceptions.TranslationError( - "bounds on 'time' coordinate requires bounds on" - " 'forecast_period'.") - fp = fp_coord.bounds[0][0] - - if fp - int(fp): - warnings.warn("forecast_period encoding problem: " - "scaling required.") - fp = int(fp) - - return rt, rt_meaning, fp, grib_time_code - - -def _missing_forecast_period(cube): - """ - Returns a reference time and significance code together with a forecast - period and corresponding units type code. - - """ - t_coord = cube.coord("time") - - if cube.coords('forecast_reference_time'): - # Make copies and convert them to common "hours since" units. - hours_since = cf_units.Unit('hours since epoch', - calendar=t_coord.units.calendar) - frt_coord = cube.coord('forecast_reference_time').copy() - frt_coord.convert_units(hours_since) - t_coord = t_coord.copy() - t_coord.convert_units(hours_since) - # Extract values. - t = t_coord.bounds[0, 0] if t_coord.has_bounds() else t_coord.points[0] - frt = frt_coord.points[0] - # Calculate GRIB parameters. - rt = frt_coord.units.num2date(frt) - rt_meaning = 1 # Forecast reference time. - fp = t - frt - integer_fp = int(fp) - if integer_fp != fp: - msg = 'Truncating floating point forecast period {} to ' \ - 'integer value {}' - warnings.warn(msg.format(fp, integer_fp)) - fp = integer_fp - fp_meaning = 1 # Hours - else: - # With no forecast period or forecast reference time set assume a - # reference time significance of "Observation time" and set the - # forecast period to 0h. - t = t_coord.bounds[0, 0] if t_coord.has_bounds() else t_coord.points[0] - rt = t_coord.units.num2date(t) - rt_meaning = 3 # Observation time - fp = 0 - fp_meaning = 1 # Hours - - return rt, rt_meaning, fp, fp_meaning - - -def set_forecast_time(cube, grib): - """ - Set the forecast time keys based on the forecast_period coordinate. In - the absence of a forecast_period and forecast_reference_time, - the forecast time is set to zero. - - """ - try: - fp_coord = cube.coord("forecast_period") - except iris.exceptions.CoordinateNotFoundError: - fp_coord = None - - if fp_coord is not None: - _, _, fp, grib_time_code = _non_missing_forecast_period(cube) - else: - _, _, fp, grib_time_code = _missing_forecast_period(cube) - - gribapi.grib_set(grib, "indicatorOfUnitOfTimeRange", grib_time_code) - gribapi.grib_set(grib, "forecastTime", fp) - - -def set_fixed_surfaces(cube, grib): - - # Look for something we can export - v_coord = grib_v_code = output_unit = None - - # pressure - if cube.coords("air_pressure") or cube.coords("pressure"): - grib_v_code = 100 - output_unit = cf_units.Unit("Pa") - v_coord = (cube.coords("air_pressure") or cube.coords("pressure"))[0] - - # altitude - elif cube.coords("altitude"): - grib_v_code = 102 - output_unit = cf_units.Unit("m") - v_coord = cube.coord("altitude") - - # height - elif cube.coords("height"): - grib_v_code = 103 - output_unit = cf_units.Unit("m") - v_coord = cube.coord("height") - - elif cube.coords("air_potential_temperature"): - grib_v_code = 107 - output_unit = cf_units.Unit('K') - v_coord = cube.coord("air_potential_temperature") - - # unknown / absent - else: - # check for *ANY* height coords at all... - v_coords = cube.coords(axis='z') - if v_coords: - # There are vertical coordinate(s), but we don't understand them... - v_coords_str = ' ,'.join(["'{}'".format(c.name()) - for c in v_coords]) - raise iris.exceptions.TranslationError( - 'The vertical-axis coordinate(s) ({}) ' - 'are not recognised or handled.'.format(v_coords_str)) - - # What did we find? - if v_coord is None: - # No vertical coordinate: record as 'surface' level (levelType=1). - # NOTE: may *not* be truly correct, but seems to be common practice. - # Still under investigation : - # See https://github.com/SciTools/iris/issues/519 - gribapi.grib_set(grib, "typeOfFirstFixedSurface", 1) - gribapi.grib_set(grib, "scaleFactorOfFirstFixedSurface", 0) - gribapi.grib_set(grib, "scaledValueOfFirstFixedSurface", 0) - # Set secondary surface = 'missing'. - gribapi.grib_set(grib, "typeOfSecondFixedSurface", -1) - gribapi.grib_set(grib, "scaleFactorOfSecondFixedSurface", 255) - gribapi.grib_set(grib, "scaledValueOfSecondFixedSurface", -1) - elif not v_coord.has_bounds(): - # No second surface - output_v = v_coord.units.convert(v_coord.points[0], output_unit) - if output_v - abs(output_v): - warnings.warn("Vertical level encoding problem: scaling required.") - output_v = int(output_v) - - gribapi.grib_set(grib, "typeOfFirstFixedSurface", grib_v_code) - gribapi.grib_set(grib, "scaleFactorOfFirstFixedSurface", 0) - gribapi.grib_set(grib, "scaledValueOfFirstFixedSurface", output_v) - gribapi.grib_set(grib, "typeOfSecondFixedSurface", -1) - gribapi.grib_set(grib, "scaleFactorOfSecondFixedSurface", 255) - gribapi.grib_set(grib, "scaledValueOfSecondFixedSurface", -1) - else: - # bounded : set lower+upper surfaces - output_v = v_coord.units.convert(v_coord.bounds[0], output_unit) - if output_v[0] - abs(output_v[0]) or output_v[1] - abs(output_v[1]): - warnings.warn("Vertical level encoding problem: scaling required.") - gribapi.grib_set(grib, "typeOfFirstFixedSurface", grib_v_code) - gribapi.grib_set(grib, "typeOfSecondFixedSurface", grib_v_code) - gribapi.grib_set(grib, "scaleFactorOfFirstFixedSurface", 0) - gribapi.grib_set(grib, "scaleFactorOfSecondFixedSurface", 0) - gribapi.grib_set(grib, "scaledValueOfFirstFixedSurface", - int(output_v[0])) - gribapi.grib_set(grib, "scaledValueOfSecondFixedSurface", - int(output_v[1])) - - -def set_time_range(time_coord, grib): - """ - Set the time range keys in the specified message - based on the bounds of the provided time coordinate. - - """ - if len(time_coord.points) != 1: - msg = 'Expected length one time coordinate, got {} points' - raise ValueError(msg.format(len(time_coord.points))) - - if time_coord.nbounds != 2: - msg = 'Expected time coordinate with two bounds, got {} bounds' - raise ValueError(msg.format(time_coord.nbounds)) - - # Set type to hours and convert period to this unit. - gribapi.grib_set(grib, "indicatorOfUnitForTimeRange", - _TIME_RANGE_UNITS['hours']) - hours_since_units = cf_units.Unit('hours since epoch', - calendar=time_coord.units.calendar) - start_hours, end_hours = time_coord.units.convert(time_coord.bounds[0], - hours_since_units) - # Cast from np.float to Python int. The lengthOfTimeRange key is a - # 4 byte integer so we cast to highlight truncation of any floating - # point value. The grib_api will do the cast from float to int, but it - # cannot handle numpy floats. - time_range_in_hours = end_hours - start_hours - integer_hours = int(time_range_in_hours) - if integer_hours != time_range_in_hours: - msg = 'Truncating floating point lengthOfTimeRange {} to ' \ - 'integer value {}' - warnings.warn(msg.format(time_range_in_hours, integer_hours)) - gribapi.grib_set(grib, "lengthOfTimeRange", integer_hours) - - -def set_time_increment(cell_method, grib): - """ - Set the time increment keys in the specified message - based on the provided cell method. - - """ - # Type of time increment, e.g incrementing forecast period, incrementing - # forecast reference time, etc. Set to missing, but we could use the - # cell method coord to infer a value (see code table 4.11). - gribapi.grib_set(grib, "typeOfTimeIncrement", 255) - - # Default values for the time increment value and units type. - inc = 0 - units_type = 255 - # Attempt to determine time increment from cell method intervals string. - intervals = cell_method.intervals - if intervals is not None and len(intervals) == 1: - interval, = intervals - try: - inc, units = interval.split() - inc = float(inc) - if units in ('hr', 'hour', 'hours'): - units_type = _TIME_RANGE_UNITS['hours'] - else: - raise ValueError('Unable to parse units of interval') - except ValueError: - # Problem interpreting the interval string. - inc = 0 - units_type = 255 - else: - # Cast to int as timeIncrement key is a 4 byte integer. - integer_inc = int(inc) - if integer_inc != inc: - warnings.warn('Truncating floating point timeIncrement {} to ' - 'integer value {}'.format(inc, integer_inc)) - inc = integer_inc - - gribapi.grib_set(grib, "indicatorOfUnitForTimeIncrement", units_type) - gribapi.grib_set(grib, "timeIncrement", inc) - - -def _cube_is_time_statistic(cube): - """ - Test whether we can identify this cube as a statistic over time. - - At present, accept anything whose latest cell method operates over a single - coordinate that "looks like" a time factor (i.e. some specific names). - In particular, we recognise the coordinate names defined in - :py:mod:`iris.coord_categorisation`. - - """ - # The *only* relevant information is in cell_methods, as coordinates or - # dimensions of aggregation may no longer exist. So it's not possible to - # be definitive, but we handle *some* useful cases. - # In other cases just say "no", which is safe even when not ideal. - - # Identify a single coordinate from the latest cell_method. - if not cube.cell_methods: - return False - latest_coordnames = cube.cell_methods[-1].coord_names - if len(latest_coordnames) != 1: - return False - coord_name = latest_coordnames[0] - - # Define accepted time names, including those from coord_categorisations. - recognised_time_names = ['time', 'year', 'month', 'day', 'weekday', - 'season'] - - # Accept it if the name is recognised. - # Currently does *not* recognise related names like 'month_number' or - # 'years', as that seems potentially unsafe. - return coord_name in recognised_time_names - - -def product_definition_template_common(cube, grib): - """ - Set keys within the provided grib message that are common across - all of the supported product definition templates. - - """ - set_discipline_and_parameter(cube, grib) - - # Various missing values. - gribapi.grib_set(grib, "typeOfGeneratingProcess", 255) - gribapi.grib_set(grib, "backgroundProcess", 255) - gribapi.grib_set(grib, "generatingProcessIdentifier", 255) - - # Generic time handling. - set_forecast_time(cube, grib) - - # Handle vertical coords. - set_fixed_surfaces(cube, grib) - - -def product_definition_template_0(cube, grib): - """ - Set keys within the provided grib message based on Product - Definition Template 4.0. - - Template 4.0 is used to represent an analysis or forecast at - a horizontal level at a point in time. - - """ - gribapi.grib_set_long(grib, "productDefinitionTemplateNumber", 0) - product_definition_template_common(cube, grib) - - -def product_definition_template_8(cube, grib): - """ - Set keys within the provided grib message based on Product - Definition Template 4.8. - - Template 4.8 is used to represent an aggregation over a time - interval. - - """ - gribapi.grib_set(grib, "productDefinitionTemplateNumber", 8) - _product_definition_template_8_10_and_11(cube, grib) - - -def product_definition_template_10(cube, grib): - """ - Set keys within the provided grib message based on Product Definition - Template 4.10. - - Template 4.10 is used to represent a percentile forecast over a time - interval. - - """ - gribapi.grib_set(grib, "productDefinitionTemplateNumber", 10) - if not (cube.coords('percentile_over_time') and - len(cube.coord('percentile_over_time').points) == 1): - raise ValueError("A cube 'percentile_over_time' coordinate with one " - "point is required, but not present.") - gribapi.grib_set(grib, "percentileValue", - int(cube.coord('percentile_over_time').points[0])) - _product_definition_template_8_10_and_11(cube, grib) - - -def product_definition_template_11(cube, grib): - """ - Set keys within the provided grib message based on Product - Definition Template 4.11. - - Template 4.11 is used to represent an aggregation over a time - interval for an ensemble member. - - """ - gribapi.grib_set(grib, "productDefinitionTemplateNumber", 11) - if not (cube.coords('realization') and - len(cube.coord('realization').points) == 1): - raise ValueError("A cube 'realization' coordinate with one" - "point is required, but not present") - gribapi.grib_set(grib, "perturbationNumber", - int(cube.coord('realization').points[0])) - # no encoding at present in iris-grib, set to missing - gribapi.grib_set(grib, "numberOfForecastsInEnsemble", 255) - gribapi.grib_set(grib, "typeOfEnsembleForecast", 255) - _product_definition_template_8_10_and_11(cube, grib) - - -def _product_definition_template_8_10_and_11(cube, grib): - """ - Set keys within the provided grib message based on common aspects of - Product Definition Templates 4.8 and 4.11. - - Templates 4.8 and 4.11 are used to represent aggregations over a time - interval. - - """ - product_definition_template_common(cube, grib) - - # Check for time coordinate. - time_coord = cube.coord('time') - - if len(time_coord.points) != 1: - msg = 'Expected length one time coordinate, got {} points' - raise ValueError(msg.format(time_coord.points)) - - if time_coord.nbounds != 2: - msg = 'Expected time coordinate with two bounds, got {} bounds' - raise ValueError(msg.format(time_coord.nbounds)) - - # Extract the datetime-like object corresponding to the end of - # the overall processing interval. - end = time_coord.units.num2date(time_coord.bounds[0, -1]) - - # Set the associated keys for the end of the interval (octets 35-41 - # in section 4). - gribapi.grib_set(grib, "yearOfEndOfOverallTimeInterval", end.year) - gribapi.grib_set(grib, "monthOfEndOfOverallTimeInterval", end.month) - gribapi.grib_set(grib, "dayOfEndOfOverallTimeInterval", end.day) - gribapi.grib_set(grib, "hourOfEndOfOverallTimeInterval", end.hour) - gribapi.grib_set(grib, "minuteOfEndOfOverallTimeInterval", end.minute) - gribapi.grib_set(grib, "secondOfEndOfOverallTimeInterval", end.second) - - # Only one time range specification. If there were a series of aggregations - # (e.g. the mean of an accumulation) one might set this to a higher value, - # but we currently only handle a single time related cell method. - gribapi.grib_set(grib, "numberOfTimeRange", 1) - gribapi.grib_set(grib, "numberOfMissingInStatisticalProcess", 0) - - # Period over which statistical processing is performed. - set_time_range(time_coord, grib) - - # Check that there is one and only one cell method related to the - # time coord. - if cube.cell_methods: - time_cell_methods = [ - cell_method for cell_method in cube.cell_methods if 'time' in - cell_method.coord_names] - if not time_cell_methods: - raise ValueError("Expected a cell method with a coordinate name " - "of 'time'") - if len(time_cell_methods) > 1: - raise ValueError("Cannot handle multiple 'time' cell methods") - cell_method, = time_cell_methods - - if len(cell_method.coord_names) > 1: - raise ValueError("Cannot handle multiple coordinate names in " - "the time related cell method. Expected " - "('time',), got {!r}".format( - cell_method.coord_names)) - - # Type of statistical process (see code table 4.10) - statistic_type = _STATISTIC_TYPE_NAMES.get(cell_method.method, 255) - gribapi.grib_set(grib, "typeOfStatisticalProcessing", statistic_type) - - # Time increment i.e. interval of cell method (if any) - set_time_increment(cell_method, grib) - - -def product_definition_template_40(cube, grib): - """ - Set keys within the provided grib message based on Product - Definition Template 4.40. - - Template 4.40 is used to represent an analysis or forecast at a horizontal - level or in a horizontal layer at a point in time for atmospheric chemical - constituents. - - """ - gribapi.grib_set(grib, "productDefinitionTemplateNumber", 40) - product_definition_template_common(cube, grib) - constituent_type = cube.attributes['WMO_constituent_type'] - gribapi.grib_set(grib, "constituentType", constituent_type) - - -def product_definition_section(cube, grib): - """ - Set keys within the product definition section of the provided - grib message based on the properties of the cube. - - """ - if not cube.coord("time").has_bounds(): - if 'WMO_constituent_type' in cube.attributes: - # forecast for atmospheric chemical constiuent (template 4.40) - product_definition_template_40(cube, grib) - else: - # forecast (template 4.0) - product_definition_template_0(cube, grib) - elif _cube_is_time_statistic(cube): - if cube.coords('realization'): - # time processed (template 4.11) - pdt = product_definition_template_11 - else: - # time processed (template 4.8) - pdt = product_definition_template_8 - try: - pdt(cube, grib) - except ValueError as e: - raise ValueError('Saving to GRIB2 failed: the cube is not suitable' - ' for saving as a time processed statistic GRIB' - ' message. {}'.format(e)) - else: - # Don't know how to handle this kind of data - msg = 'A suitable product template could not be deduced' - raise iris.exceptions.TranslationError(msg) - - -############################################################################### -# -# Data Representation Section 5 -# -############################################################################### - -def data_section(cube, grib): - # Masked data? - if isinstance(cube.data, ma.core.MaskedArray): - # What missing value shall we use? - if not np.isnan(cube.data.fill_value): - # Use the data's fill value. - fill_value = float(cube.data.fill_value) - else: - # We can't use the data's fill value if it's NaN, - # the GRIB API doesn't like it. - # Calculate an MDI outside the data range. - min, max = cube.data.min(), cube.data.max() - fill_value = min - (max - min) * 0.1 - # Prepare the unmaksed data array, using fill_value as the MDI. - data = cube.data.filled(fill_value) - else: - fill_value = None - data = cube.data - - # units scaling - grib2_info = gptx.cf_phenom_to_grib2_info(cube.standard_name, - cube.long_name) - if grib2_info is None: - # for now, just allow this - warnings.warn('Unable to determine Grib2 parameter code for cube.\n' - 'Message data may not be correctly scaled.') - else: - if cube.units != grib2_info.units: - data = cube.units.convert(data, grib2_info.units) - if fill_value is not None: - fill_value = cube.units.convert(fill_value, grib2_info.units) - - if fill_value is None: - # Disable missing values in the grib message. - gribapi.grib_set(grib, "bitmapPresent", 0) - else: - # Enable missing values in the grib message. - gribapi.grib_set(grib, "bitmapPresent", 1) - gribapi.grib_set_double(grib, "missingValue", fill_value) - gribapi.grib_set_double_array(grib, "values", data.flatten()) - - # todo: check packing accuracy? -# print("packingError", gribapi.getb_get_double(grib, "packingError")) - - -############################################################################### - -def gribbability_check(cube): - "We always need the following things for grib saving." - - # GeogCS exists? - cs0 = cube.coord(dimensions=[0]).coord_system - cs1 = cube.coord(dimensions=[1]).coord_system - if cs0 is None or cs1 is None: - raise iris.exceptions.TranslationError("CoordSystem not present") - if cs0 != cs1: - raise iris.exceptions.TranslationError("Inconsistent CoordSystems") - - # Time period exists? - if not cube.coords("time"): - raise iris.exceptions.TranslationError("time coord not found") - - -def run(cube, grib): - """ - Set the keys of the grib message based on the contents of the cube. - - Args: - - * cube: - An instance of :class:`iris.cube.Cube`. - - * grib_message_id: - ID of a grib message in memory. This is typically the return value of - :func:`gribapi.grib_new_from_samples`. - - """ - gribbability_check(cube) - - # Section 1 - Identification Section. - identification(cube, grib) - - # Section 3 - Grid Definition Section (Grid Definition Template) - grid_definition_section(cube, grib) - - # Section 4 - Product Definition Section (Product Definition Template) - product_definition_section(cube, grib) - - # Section 5 - Data Representation Section (Data Representation Template) - data_section(cube, grib) diff --git a/iris_grib/load_rules.py b/iris_grib/load_rules.py deleted file mode 100644 index 9805c554..00000000 --- a/iris_grib/load_rules.py +++ /dev/null @@ -1,269 +0,0 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Historically this was auto-generated from -# SciTools/iris-code-generators:tools/gen_rules.py - -import warnings - -from cf_units import CALENDAR_GREGORIAN, Unit -import numpy as np - -from iris.aux_factory import HybridPressureFactory -from iris.coords import AuxCoord, CellMethod, DimCoord -from iris.exceptions import TranslationError -from iris.fileformats.rules import (ConversionMetadata, Factory, Reference, - ReferenceTarget) - - -# Restrict the names imported from this namespace. -__all__ = ['grib1_convert'] - - -def grib1_convert(grib): - """ - Converts a GRIB1 message into the corresponding items of Cube metadata. - - Args: - - * grib: - A :class:`~iris_grib.GribWrapper` object. - - Returns: - A :class:`iris.fileformats.rules.ConversionMetadata` object. - - """ - if grib.edition != 1: - emsg = 'GRIB edition {} is not supported by {!r}.' - raise TranslationError(emsg.format(grib.edition, - type(grib).__name__)) - - factories = [] - references = [] - standard_name = None - long_name = None - units = None - attributes = {} - cell_methods = [] - dim_coords_and_dims = [] - aux_coords_and_dims = [] - - if \ - (grib.gridType=="reduced_gg"): - aux_coords_and_dims.append((AuxCoord(grib._y_points, grib._y_coord_name, units='degrees', coord_system=grib._coord_system), 0)) - aux_coords_and_dims.append((AuxCoord(grib._x_points, grib._x_coord_name, units='degrees', coord_system=grib._coord_system), 0)) - - if \ - (grib.gridType=="regular_ll") and \ - (grib.jPointsAreConsecutive == 0): - dim_coords_and_dims.append((DimCoord(grib._y_points, grib._y_coord_name, units='degrees', coord_system=grib._coord_system), 0)) - dim_coords_and_dims.append((DimCoord(grib._x_points, grib._x_coord_name, units='degrees', coord_system=grib._coord_system, circular=grib._x_circular), 1)) - - if \ - (grib.gridType=="regular_ll") and \ - (grib.jPointsAreConsecutive == 1): - dim_coords_and_dims.append((DimCoord(grib._y_points, grib._y_coord_name, units='degrees', coord_system=grib._coord_system), 1)) - dim_coords_and_dims.append((DimCoord(grib._x_points, grib._x_coord_name, units='degrees', coord_system=grib._coord_system, circular=grib._x_circular), 0)) - - if \ - (grib.gridType=="regular_gg") and \ - (grib.jPointsAreConsecutive == 0): - dim_coords_and_dims.append((DimCoord(grib._y_points, grib._y_coord_name, units='degrees', coord_system=grib._coord_system), 0)) - dim_coords_and_dims.append((DimCoord(grib._x_points, grib._x_coord_name, units='degrees', coord_system=grib._coord_system, circular=grib._x_circular), 1)) - - if \ - (grib.gridType=="regular_gg") and \ - (grib.jPointsAreConsecutive == 1): - dim_coords_and_dims.append((DimCoord(grib._y_points, grib._y_coord_name, units='degrees', coord_system=grib._coord_system), 1)) - dim_coords_and_dims.append((DimCoord(grib._x_points, grib._x_coord_name, units='degrees', coord_system=grib._coord_system, circular=grib._x_circular), 0)) - - if \ - (grib.gridType=="rotated_ll") and \ - (grib.jPointsAreConsecutive == 0): - dim_coords_and_dims.append((DimCoord(grib._y_points, grib._y_coord_name, units='degrees', coord_system=grib._coord_system), 0)) - dim_coords_and_dims.append((DimCoord(grib._x_points, grib._x_coord_name, units='degrees', coord_system=grib._coord_system, circular=grib._x_circular), 1)) - - if \ - (grib.gridType=="rotated_ll") and \ - (grib.jPointsAreConsecutive == 1): - dim_coords_and_dims.append((DimCoord(grib._y_points, grib._y_coord_name, units='degrees', coord_system=grib._coord_system), 1)) - dim_coords_and_dims.append((DimCoord(grib._x_points, grib._x_coord_name, units='degrees', coord_system=grib._coord_system, circular=grib._x_circular), 0)) - - if grib.gridType in ["polar_stereographic", "lambert"]: - dim_coords_and_dims.append((DimCoord(grib._y_points, grib._y_coord_name, units="m", coord_system=grib._coord_system), 0)) - dim_coords_and_dims.append((DimCoord(grib._x_points, grib._x_coord_name, units="m", coord_system=grib._coord_system), 1)) - - if \ - (grib.table2Version < 128) and \ - (grib.indicatorOfParameter == 11) and \ - (grib._cf_data is None): - standard_name = "air_temperature" - units = "kelvin" - - if \ - (grib.table2Version < 128) and \ - (grib.indicatorOfParameter == 33) and \ - (grib._cf_data is None): - standard_name = "x_wind" - units = "m s-1" - - if \ - (grib.table2Version < 128) and \ - (grib.indicatorOfParameter == 34) and \ - (grib._cf_data is None): - standard_name = "y_wind" - units = "m s-1" - - if \ - (grib._cf_data is not None): - standard_name = grib._cf_data.standard_name - long_name = grib._cf_data.standard_name or grib._cf_data.long_name - units = grib._cf_data.units - - if \ - (grib.table2Version >= 128) and \ - (grib._cf_data is None): - long_name = "UNKNOWN LOCAL PARAM " + str(grib.indicatorOfParameter) + "." + str(grib.table2Version) - units = "???" - - if \ - (grib.table2Version == 1) and \ - (grib.indicatorOfParameter >= 128): - long_name = "UNKNOWN LOCAL PARAM " + str(grib.indicatorOfParameter) + "." + str(grib.table2Version) - units = "???" - - if \ - (grib._phenomenonDateTime != -1.0): - aux_coords_and_dims.append((DimCoord(points=grib.startStep, standard_name='forecast_period', units=grib._forecastTimeUnit), None)) - aux_coords_and_dims.append((DimCoord(points=grib.phenomenon_points('hours'), standard_name='time', units=Unit('hours since epoch', CALENDAR_GREGORIAN)), None)) - - def add_bounded_time_coords(aux_coords_and_dims, grib): - t_bounds = grib.phenomenon_bounds('hours') - period = Unit('hours').convert(t_bounds[1] - t_bounds[0], - grib._forecastTimeUnit) - aux_coords_and_dims.append(( - DimCoord(standard_name='forecast_period', - units=grib._forecastTimeUnit, - points=grib._forecastTime + 0.5 * period, - bounds=[grib._forecastTime, grib._forecastTime + period]), - None)) - aux_coords_and_dims.append(( - DimCoord(standard_name='time', - units=Unit('hours since epoch', CALENDAR_GREGORIAN), - points=0.5 * (t_bounds[0] + t_bounds[1]), - bounds=t_bounds), - None)) - - if \ - (grib.timeRangeIndicator == 2): - add_bounded_time_coords(aux_coords_and_dims, grib) - - if \ - (grib.timeRangeIndicator == 3): - add_bounded_time_coords(aux_coords_and_dims, grib) - cell_methods.append(CellMethod("mean", coords="time")) - - if \ - (grib.timeRangeIndicator == 4): - add_bounded_time_coords(aux_coords_and_dims, grib) - cell_methods.append(CellMethod("sum", coords="time")) - - if \ - (grib.timeRangeIndicator == 5): - add_bounded_time_coords(aux_coords_and_dims, grib) - cell_methods.append(CellMethod("_difference", coords="time")) - - if \ - (grib.timeRangeIndicator == 51): - add_bounded_time_coords(aux_coords_and_dims, grib) - cell_methods.append(CellMethod("mean", coords="time")) - - if \ - (grib.timeRangeIndicator == 113): - add_bounded_time_coords(aux_coords_and_dims, grib) - cell_methods.append(CellMethod("mean", coords="time")) - - if \ - (grib.timeRangeIndicator == 114): - add_bounded_time_coords(aux_coords_and_dims, grib) - cell_methods.append(CellMethod("sum", coords="time")) - - if \ - (grib.timeRangeIndicator == 115): - add_bounded_time_coords(aux_coords_and_dims, grib) - cell_methods.append(CellMethod("mean", coords="time")) - - if \ - (grib.timeRangeIndicator == 116): - add_bounded_time_coords(aux_coords_and_dims, grib) - cell_methods.append(CellMethod("sum", coords="time")) - - if \ - (grib.timeRangeIndicator == 117): - add_bounded_time_coords(aux_coords_and_dims, grib) - cell_methods.append(CellMethod("mean", coords="time")) - - if \ - (grib.timeRangeIndicator == 118): - add_bounded_time_coords(aux_coords_and_dims, grib) - cell_methods.append(CellMethod("_covariance", coords="time")) - - if \ - (grib.timeRangeIndicator == 123): - add_bounded_time_coords(aux_coords_and_dims, grib) - cell_methods.append(CellMethod("mean", coords="time")) - - if \ - (grib.timeRangeIndicator == 124): - add_bounded_time_coords(aux_coords_and_dims, grib) - cell_methods.append(CellMethod("sum", coords="time")) - - if \ - (grib.timeRangeIndicator == 125): - add_bounded_time_coords(aux_coords_and_dims, grib) - cell_methods.append(CellMethod("standard_deviation", coords="time")) - - if \ - (grib.levelType == 'pl'): - aux_coords_and_dims.append((DimCoord(points=grib.level, long_name="pressure", units="hPa"), None)) - - if \ - (grib.levelType == 'sfc'): - - if (grib._cf_data is not None) and \ - (grib._cf_data.set_height is not None): - aux_coords_and_dims.append((DimCoord(points=grib._cf_data.set_height, long_name="height", units="m", attributes={'positive':'up'}), None)) - elif grib.typeOfLevel == 'heightAboveGround': # required for NCAR - aux_coords_and_dims.append((DimCoord(points=grib.level, long_name="height", units="m", attributes={'positive':'up'}), None)) - - if \ - (grib.levelType == 'ml') and \ - (hasattr(grib, 'pv')): - aux_coords_and_dims.append((AuxCoord(grib.level, standard_name='model_level_number', attributes={'positive': 'up'}), None)) - aux_coords_and_dims.append((DimCoord(grib.pv[grib.level], long_name='level_pressure', units='Pa'), None)) - aux_coords_and_dims.append((AuxCoord(grib.pv[grib.numberOfCoordinatesValues//2 + grib.level], long_name='sigma'), None)) - factories.append(Factory(HybridPressureFactory, [{'long_name': 'level_pressure'}, {'long_name': 'sigma'}, Reference('surface_pressure')])) - - if grib._originatingCentre != 'unknown': - aux_coords_and_dims.append((AuxCoord(points=grib._originatingCentre, long_name='originating_centre', units='no_unit'), None)) - - return ConversionMetadata(factories, references, standard_name, long_name, - units, attributes, cell_methods, - dim_coords_and_dims, aux_coords_and_dims) diff --git a/iris_grib/tests/__init__.py b/iris_grib/tests/__init__.py deleted file mode 100644 index 48a5543b..00000000 --- a/iris_grib/tests/__init__.py +++ /dev/null @@ -1,89 +0,0 @@ -# (C) British Crown Copyright 2010 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Provides testing capabilities and customisations specific to iris-grib. - -This imports iris.tests, which requires to be done before anything else for -plot control reasons : see documentation there. - -""" -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa -import six - -import iris.tests - -import inspect -import os -import os.path - -from iris.tests import IrisTest, main, skip_data, get_data_path - - -#: Basepath for iris-grib test results. -_RESULT_PATH = os.path.join(os.path.dirname(__file__), 'results') - - -class IrisGribTest(IrisTest): - # A specialised version of an IrisTest that implements the correct - # automatic paths for test results in iris-grib. - - @staticmethod - def get_result_path(relative_path): - """ - Returns the absolute path to a result file when given the relative path - as a string, or sequence of strings. - - """ - if not isinstance(relative_path, six.string_types): - relative_path = os.path.join(*relative_path) - return os.path.abspath(os.path.join(_RESULT_PATH, relative_path)) - - def result_path(self, basename=None, ext=''): - """ - Return the full path to a test result, generated from the \ - calling file, class and, optionally, method. - - Optional kwargs : - - * basename - File basename. If omitted, this is \ - generated from the calling method. - * ext - Appended file extension. - - """ - if ext and not ext.startswith('.'): - ext = '.' + ext - - # Generate the folder name from the calling file name. - path = os.path.abspath(inspect.getfile(self.__class__)) - path = os.path.splitext(path)[0] - sub_path = path.rsplit('iris_grib', 1)[1].split('tests', 1)[1][1:] - - # Generate the file name from the calling function name? - if basename is None: - stack = inspect.stack() - for frame in stack[1:]: - if 'test_' in frame[3]: - basename = frame[3].replace('test_', '') - break - filename = basename + ext - - result = os.path.join(self.get_result_path(''), - sub_path.replace('test_', ''), - self.__class__.__name__.replace('Test_', ''), - filename) - return result diff --git a/iris_grib/tests/results/unit/load_cubes/load_cubes/reduced_raw.cml b/iris_grib/tests/results/unit/load_cubes/load_cubes/reduced_raw.cml deleted file mode 100644 index 1c3ea906..00000000 --- a/iris_grib/tests/results/unit/load_cubes/load_cubes/reduced_raw.cml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iris_grib/tests/test_license_headers.py b/iris_grib/tests/test_license_headers.py deleted file mode 100644 index 08781e30..00000000 --- a/iris_grib/tests/test_license_headers.py +++ /dev/null @@ -1,185 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for iris-grib license header conformance. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -from datetime import datetime -from fnmatch import fnmatch -import os -import re -import subprocess -import unittest - -import iris_grib - - -LICENSE_TEMPLATE = """ -# (C) British Crown Copyright {YEARS}, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see .""".strip() - -LICENSE_RE_PATTERN = re.escape(LICENSE_TEMPLATE).replace('\{YEARS\}', '(.*?)') -# Add shebang possibility to the LICENSE_RE_PATTERN -LICENSE_RE_PATTERN = r'(\#\!.*\n)?' + LICENSE_RE_PATTERN -LICENSE_RE = re.compile(LICENSE_RE_PATTERN, re.MULTILINE) - -# Guess iris repo directory of Iris - realpath is used to mitigate against -# Python finding the iris package via a symlink. -IRIS_GRIB_DIR = os.path.realpath(os.path.dirname(iris_grib.__file__)) -REPO_DIR = os.path.dirname(IRIS_GRIB_DIR) - - -class TestLicenseHeaders(unittest.TestCase): - @staticmethod - def years_of_license_in_file(fh): - """ - Using :data:`LICENSE_RE` look for the years defined in the license - header of the given file handle. - - If the license cannot be found in the given fh, None will be returned, - else a tuple of (start_year, end_year) will be returned. - - """ - license_matches = LICENSE_RE.match(fh.read()) - if not license_matches: - # no license found in file. - return None - - years = license_matches.groups()[-1] - if len(years) == 4: - start_year = end_year = int(years) - elif len(years) == 11: - start_year, end_year = int(years[:4]), int(years[7:]) - else: - fname = getattr(fh, 'name', 'unknown filename') - raise ValueError("Unexpected year(s) string in {}'s copyright " - "notice: {!r}".format(fname, years)) - return (start_year, end_year) - - @staticmethod - def whatchanged_parse(whatchanged_output): - """ - Returns a generator of tuples of data parsed from - "git whatchanged --pretty='TIME:%at". The tuples are of the form - ``(filename, last_commit_datetime)`` - - Sample input:: - - ['TIME:1366884020', '', - ':000000 100644 0000000... 5862ced... A\tlib/iris/cube.py'] - - """ - dt = None - for line in whatchanged_output: - if not line.strip(): - continue - elif line.startswith('TIME:'): - dt = datetime.fromtimestamp(int(line[5:])) - else: - # Non blank, non date, line -> must be the lines - # containing the file info. - fname = ' '.join(line.split('\t')[1:]) - yield fname, dt - - @staticmethod - def last_change_by_fname(): - """ - Return a dictionary of all the files under git which maps to - the datetime of their last modification in the git history. - - .. note:: - - This function raises a ValueError if the repo root does - not have a ".git" folder. If git is not installed on the system, - or cannot be found by subprocess, an IOError may also be raised. - - """ - # Check the ".git" folder exists at the repo dir. - if not os.path.isdir(os.path.join(REPO_DIR, '.git')): - raise ValueError('{} is not a git repository.'.format(REPO_DIR)) - - # Call "git whatchanged" to get the details of all the files and when - # they were last changed. - output = subprocess.check_output(['git', 'whatchanged', - "--pretty=TIME:%ct"], - cwd=REPO_DIR) - output = output.decode().split('\n') - res = {} - for fname, dt in TestLicenseHeaders.whatchanged_parse(output): - if fname not in res or dt > res[fname]: - res[fname] = dt - - return res - - def test_license_headers(self): - exclude_patterns = ('setup.py', - 'build/*', - 'dist/*', - 'docs/*', - 'iris_grib/tests/unit/results/*', - 'iris_grib.egg-info/*') - - try: - last_change_by_fname = self.last_change_by_fname() - except ValueError: - # Caught the case where this is not a git repo. - return self.skipTest('Iris-grib installation did not look like a ' - 'git repo.') - - failed = False - for fname, last_change in sorted(last_change_by_fname.items()): - full_fname = os.path.join(REPO_DIR, fname) - if full_fname.endswith('.py') and os.path.isfile(full_fname) and \ - not any(fnmatch(fname, pat) for pat in exclude_patterns): - with open(full_fname) as fh: - years = TestLicenseHeaders.years_of_license_in_file(fh) - if years is None: - print('The file {} has no valid header license and ' - 'has not been excluded from the license header ' - 'test.'.format(fname)) - failed = True - elif last_change.year > years[1]: - print('The file header at {} is out of date. The last' - ' commit was in {}, but the copyright states it' - ' was {}.'.format(fname, last_change.year, - years[1])) - failed = True - - if failed: - raise ValueError('There were license header failures. See stdout.') - - -if __name__ == '__main__': - unittest.main() diff --git a/iris_grib/tests/test_pep8.py b/iris_grib/tests/test_pep8.py deleted file mode 100644 index 0ce479a8..00000000 --- a/iris_grib/tests/test_pep8.py +++ /dev/null @@ -1,56 +0,0 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for iris-grib pep8 conformance. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -import os -import unittest - -import pep8 - -import iris_grib - - -class Test(unittest.TestCase): - def test_pep8_conformance(self): - pep8style = pep8.StyleGuide(quiet=False) - excluded = ['_grib_cf_map.py', 'load_rules.py'] - for fname in excluded: - path = '*{}{}'.format(os.path.sep, fname) - pep8style.options.exclude.append(path) - - extra_exclude_fname = os.path.join(os.path.dirname(__file__), - '.pep8_test_exclude.txt') - - if os.path.exists(extra_exclude_fname): - with open(extra_exclude_fname) as fh: - extra_exclude = [line.strip() for line in fh if line.strip()] - pep8style.options.exclude.extend(extra_exclude) - - root = os.path.dirname(os.path.abspath(iris_grib.__file__)) - result = pep8style.check_files([root]) - emsg = 'Found code pep8 errors (and warnings).' - self.assertEqual(result.total_errors, 0, emsg) - - -if __name__ == '__main__': - unittest.main() diff --git a/iris_grib/tests/unit/grib_phenom_translation/__init__.py b/iris_grib/tests/unit/grib_phenom_translation/__init__.py deleted file mode 100644 index 6e899f95..00000000 --- a/iris_grib/tests/unit/grib_phenom_translation/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -"""Unit tests for the :mod:`iris_grib.grib_phenom_translation` package.""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa diff --git a/iris_grib/tests/unit/grib_phenom_translation/test_grib_phenom_translation.py b/iris_grib/tests/unit/grib_phenom_translation/test_grib_phenom_translation.py deleted file mode 100644 index 8e68590b..00000000 --- a/iris_grib/tests/unit/grib_phenom_translation/test_grib_phenom_translation.py +++ /dev/null @@ -1,168 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -''' -Unit tests for the mod:`iris_grib.grib_phenom_translation` module. - -Carried over from old iris/tests/test_grib_phenom_translation.py. -Code is out of step with current test conventions and standards. - -''' -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import cf_units - -import iris_grib.grib_phenom_translation as gptx - - -class TestGribLookupTableType(tests.IrisTest): - def test_lookuptable_type(self): - ll = gptx.LookupTable([('a', 1), ('b', 2)]) - assert ll['a'] == 1 - assert ll['q'] is None - ll['q'] = 15 - assert ll['q'] == 15 - ll['q'] = 15 - assert ll['q'] == 15 - with self.assertRaises(KeyError): - ll['q'] = 7 - del ll['q'] - ll['q'] = 7 - assert ll['q'] == 7 - - -class TestGribPhenomenonLookup(tests.IrisTest): - def test_grib1_cf_lookup(self): - def check_grib1_cf(param, - standard_name, long_name, units, - height=None, - t2version=128, centre=98, expect_none=False): - a_cf_unit = cf_units.Unit(units) - cfdata = gptx.grib1_phenom_to_cf_info(param_number=param, - table2_version=t2version, - centre_number=centre) - if expect_none: - self.assertIsNone(cfdata) - else: - self.assertEqual(cfdata.standard_name, standard_name) - self.assertEqual(cfdata.long_name, long_name) - self.assertEqual(cfdata.units, a_cf_unit) - if height is None: - self.assertIsNone(cfdata.set_height) - else: - self.assertEqual(cfdata.set_height, float(height)) - - check_grib1_cf(165, 'x_wind', None, 'm s-1', 10.0) - check_grib1_cf(168, 'dew_point_temperature', None, 'K', 2) - check_grib1_cf(130, 'air_temperature', None, 'K') - check_grib1_cf(235, None, "grib_skin_temperature", "K") - check_grib1_cf(235, None, "grib_skin_temperature", "K", - t2version=9999, expect_none=True) - check_grib1_cf(235, None, "grib_skin_temperature", "K", - centre=9999, expect_none=True) - check_grib1_cf(9999, None, "grib_skin_temperature", "K", - expect_none=True) - - def test_grib2_cf_lookup(self): - def check_grib2_cf(discipline, category, number, - standard_name, long_name, units, - expect_none=False): - a_cf_unit = cf_units.Unit(units) - cfdata = gptx.grib2_phenom_to_cf_info(param_discipline=discipline, - param_category=category, - param_number=number) - if expect_none: - self.assertIsNone(cfdata) - else: - self.assertEqual(cfdata.standard_name, standard_name) - self.assertEqual(cfdata.long_name, long_name) - self.assertEqual(cfdata.units, a_cf_unit) - - # These should work - check_grib2_cf(0, 0, 2, "air_potential_temperature", None, "K") - check_grib2_cf(0, 19, 1, None, "grib_physical_atmosphere_albedo", "%") - check_grib2_cf(2, 0, 2, "soil_temperature", None, "K") - check_grib2_cf(10, 2, 0, "sea_ice_area_fraction", None, 1) - check_grib2_cf(2, 0, 0, "land_area_fraction", None, 1) - check_grib2_cf(0, 19, 1, None, "grib_physical_atmosphere_albedo", "%") - check_grib2_cf(0, 1, 64, - "atmosphere_mass_content_of_water_vapor", None, - "kg m-2") - check_grib2_cf(2, 0, 7, "surface_altitude", None, "m") - - # These should fail - check_grib2_cf(9999, 2, 0, "sea_ice_area_fraction", None, 1, - expect_none=True) - check_grib2_cf(10, 9999, 0, "sea_ice_area_fraction", None, 1, - expect_none=True) - check_grib2_cf(10, 2, 9999, "sea_ice_area_fraction", None, 1, - expect_none=True) - - def test_cf_grib2_lookup(self): - def check_cf_grib2(standard_name, long_name, - discipline, category, number, units, - expect_none=False): - a_cf_unit = cf_units.Unit(units) - gribdata = gptx.cf_phenom_to_grib2_info(standard_name, long_name) - if expect_none: - self.assertIsNone(gribdata) - else: - self.assertEqual(gribdata.discipline, discipline) - self.assertEqual(gribdata.category, category) - self.assertEqual(gribdata.number, number) - self.assertEqual(gribdata.units, a_cf_unit) - - # These should work - check_cf_grib2("sea_surface_temperature", None, - 10, 3, 0, 'K') - check_cf_grib2("air_temperature", None, - 0, 0, 0, 'K') - check_cf_grib2("soil_temperature", None, - 2, 0, 2, "K") - check_cf_grib2("land_area_fraction", None, - 2, 0, 0, '1') - check_cf_grib2("land_binary_mask", None, - 2, 0, 0, '1') - check_cf_grib2("atmosphere_mass_content_of_water_vapor", None, - 0, 1, 64, "kg m-2") - check_cf_grib2("surface_altitude", None, - 2, 0, 7, "m") - - # These should fail - check_cf_grib2("air_temperature", "user_long_UNRECOGNISED", - 0, 0, 0, 'K') - check_cf_grib2("air_temperature_UNRECOGNISED", None, - 0, 0, 0, 'K', - expect_none=True) - check_cf_grib2(None, "user_long_UNRECOGNISED", - 0, 0, 0, 'K', - expect_none=True) - check_cf_grib2(None, "precipitable_water", - 0, 1, 3, 'kg m-2') - check_cf_grib2("invalid_unknown", "precipitable_water", - 0, 1, 3, 'kg m-2', - expect_none=True) - check_cf_grib2(None, None, 0, 0, 0, '', - expect_none=True) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/__init__.py b/iris_grib/tests/unit/load_convert/__init__.py deleted file mode 100644 index 99f46f25..00000000 --- a/iris_grib/tests/unit/load_convert/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -"""Unit tests for the :mod:`iris_grib._load_convert` package.""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from collections import OrderedDict - - -def empty_metadata(): - metadata = OrderedDict() - metadata['factories'] = [] - metadata['references'] = [] - metadata['standard_name'] = None - metadata['long_name'] = None - metadata['units'] = None - metadata['attributes'] = {} - metadata['cell_methods'] = [] - metadata['dim_coords_and_dims'] = [] - metadata['aux_coords_and_dims'] = [] - return metadata - - -class LoadConvertTest(tests.IrisGribTest): - def assertMetadataEqual(self, result, expected): - # Compare two metadata dictionaries. Gives slightly more - # helpful error message than: self.assertEqual(result, expected) - self.assertEqual(result.keys(), expected.keys()) - for key in result.keys(): - self.assertEqual(result[key], expected[key]) diff --git a/iris_grib/tests/unit/load_convert/test__hindcast_fix.py b/iris_grib/tests/unit/load_convert/test__hindcast_fix.py deleted file mode 100644 index 8021119c..00000000 --- a/iris_grib/tests/unit/load_convert/test__hindcast_fix.py +++ /dev/null @@ -1,72 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Tests for function :func:`iris_grib._load_convert._hindcast_fix`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from collections import namedtuple - -from iris_grib._load_convert import _hindcast_fix as hindcast_fix - - -class TestHindcastFix(tests.IrisGribTest): - # setup tests : provided value, fix-applies, expected-fixed - FixTest = namedtuple('FixTest', ('given', 'fixable', 'fixed')) - test_values = [ - FixTest(0, False, None), - FixTest(100, False, None), - FixTest(2 * 2**30 - 1, False, None), - FixTest(2 * 2**30, False, None), - FixTest(2 * 2**30 + 1, True, -1), - FixTest(2 * 2**30 + 2, True, -2), - FixTest(3 * 2**30 - 1, True, -(2**30 - 1)), - FixTest(3 * 2**30, False, None)] - - def setUp(self): - self.patch_warn = self.patch('warnings.warn') - - def test_fix(self): - # Check hindcast fixing. - for given, fixable, fixed in self.test_values: - result = hindcast_fix(given) - expected = fixed if fixable else given - self.assertEqual(result, expected) - - def test_fix_warning(self): - # Check warning appears when enabled. - self.patch('iris_grib._load_convert.options.warn_on_unsupported', True) - hindcast_fix(2 * 2**30 + 5) - self.assertEqual(self.patch_warn.call_count, 1) - self.assertIn('Re-interpreting large grib forecastTime', - self.patch_warn.call_args[0][0]) - - def test_fix_warning_disabled(self): - # Default is no warning. - hindcast_fix(2 * 2**30 + 5) - self.assertEqual(self.patch_warn.call_count, 0) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_bitmap_section.py b/iris_grib/tests/unit/load_convert/test_bitmap_section.py deleted file mode 100644 index ddf424d5..00000000 --- a/iris_grib/tests/unit/load_convert/test_bitmap_section.py +++ /dev/null @@ -1,47 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.bitmap_section.` - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from iris.exceptions import TranslationError - -from iris_grib._load_convert import bitmap_section -from iris_grib.tests.unit import _make_test_message - - -class Test(tests.IrisGribTest): - def test_bitmap_unsupported(self): - # bitMapIndicator in range 1-254. - # Note that bitMapIndicator = 1-253 and bitMapIndicator = 254 mean two - # different things, but load_convert treats them identically. - message = _make_test_message({6: {'bitMapIndicator': 100, - 'bitmap': None}}) - with self.assertRaisesRegexp(TranslationError, 'unsupported bitmap'): - bitmap_section(message.sections[6]) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_convert.py b/iris_grib/tests/unit/load_convert/test_convert.py deleted file mode 100644 index 23090980..00000000 --- a/iris_grib/tests/unit/load_convert/test_convert.py +++ /dev/null @@ -1,78 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -"""Test function :func:`iris_grib._load_convert.convert`.""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -import mock - -from iris.exceptions import TranslationError - -from iris_grib._load_convert import convert -from iris_grib.tests.unit import _make_test_message - - -class TestGribMessage(tests.IrisGribTest): - def test_edition_2(self): - def func(field, metadata): - return metadata['factories'].append(factory) - - sections = [{'editionNumber': 2}] - field = _make_test_message(sections) - this = 'iris_grib._load_convert.grib2_convert' - factory = mock.sentinel.factory - with mock.patch(this, side_effect=func) as grib2_convert: - # The call being tested. - result = convert(field) - self.assertTrue(grib2_convert.called) - metadata = ([factory], [], None, None, None, {}, [], [], []) - self.assertEqual(result, metadata) - - def test_edition_1_bad(self): - sections = [{'editionNumber': 1}] - field = _make_test_message(sections) - emsg = 'edition 1 is not supported' - with self.assertRaisesRegexp(TranslationError, emsg): - convert(field) - - -class TestGribWrapper(tests.IrisGribTest): - def test_edition_2_bad(self): - # Test object with no '.sections', and '.edition' ==2. - field = mock.Mock(edition=2, spec=('edition')) - emsg = 'edition 2 is not supported' - with self.assertRaisesRegexp(TranslationError, emsg): - convert(field) - - def test_edition_1(self): - # Test object with no '.sections', and '.edition' ==1. - field = mock.Mock(edition=1, spec=('edition')) - func = 'iris_grib._load_convert.grib1_convert' - metadata = mock.sentinel.metadata - with mock.patch(func, return_value=metadata) as grib1_convert: - result = convert(field) - grib1_convert.assert_called_once_with(field) - self.assertEqual(result, metadata) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_ellipsoid_geometry.py b/iris_grib/tests/unit/load_convert/test_ellipsoid_geometry.py deleted file mode 100644 index 90a12a5b..00000000 --- a/iris_grib/tests/unit/load_convert/test_ellipsoid_geometry.py +++ /dev/null @@ -1,47 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.ellipsoid_geometry. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from iris_grib._load_convert import ellipsoid_geometry - - -class Test(tests.IrisGribTest): - def setUp(self): - self.section = {'scaledValueOfEarthMajorAxis': 10, - 'scaleFactorOfEarthMajorAxis': 1, - 'scaledValueOfEarthMinorAxis': 100, - 'scaleFactorOfEarthMinorAxis': 2, - 'scaledValueOfRadiusOfSphericalEarth': 1000, - 'scaleFactorOfRadiusOfSphericalEarth': 3} - - def test_geometry(self): - result = ellipsoid_geometry(self.section) - self.assertEqual(result, (1.0, 1.0, 1.0)) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_ensemble_identifier.py b/iris_grib/tests/unit/load_convert/test_ensemble_identifier.py deleted file mode 100644 index 0e7b3d57..00000000 --- a/iris_grib/tests/unit/load_convert/test_ensemble_identifier.py +++ /dev/null @@ -1,72 +0,0 @@ -# (C) British Crown Copyright 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function -:func:`iris_grib._load_convert.ensemble_identifier`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from copy import deepcopy -import mock -import warnings - -from iris.coords import DimCoord - -from iris_grib._load_convert import ensemble_identifier - - -class Test(tests.IrisGribTest): - def setUp(self): - module = 'iris_grib._load_convert' - self.patch('warnings.warn') - this = '{}.product_definition_template_0'.format(module) - - def _check(self, request_warning): - section = {'perturbationNumber': 17} - this = 'iris_grib._load_convert.options' - with mock.patch(this, warn_on_unsupported=request_warning): - realization = ensemble_identifier(section) - expected = DimCoord(section['perturbationNumber'], - standard_name='realization', - units='no_unit') - - if request_warning: - warn_msgs = [mcall[1][0] for mcall in warnings.warn.mock_calls] - expected_msgs = ['type of ensemble', 'number of forecasts'] - for emsg in expected_msgs: - matches = [wmsg for wmsg in warn_msgs if emsg in wmsg] - self.assertEqual(len(matches), 1) - warn_msgs.remove(matches[0]) - else: - self.assertEqual(len(warnings.warn.mock_calls), 0) - - def test_ens_no_warn(self): - self._check(False) - - def test_ens_warn(self): - self._check(True) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_fixup_float32_from_int32.py b/iris_grib/tests/unit/load_convert/test_fixup_float32_from_int32.py deleted file mode 100644 index cb30b872..00000000 --- a/iris_grib/tests/unit/load_convert/test_fixup_float32_from_int32.py +++ /dev/null @@ -1,47 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for `iris_grib._load_convert.fixup_float32_from_int32`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -from iris_grib._load_convert import fixup_float32_from_int32 - - -class Test(tests.IrisGribTest): - def test_negative(self): - result = fixup_float32_from_int32(-0x3f000000) - self.assertEqual(result, -0.5) - - def test_zero(self): - result = fixup_float32_from_int32(0) - self.assertEqual(result, 0) - - def test_positive(self): - result = fixup_float32_from_int32(0x3f000000) - self.assertEqual(result, 0.5) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_fixup_int32_from_uint32.py b/iris_grib/tests/unit/load_convert/test_fixup_int32_from_uint32.py deleted file mode 100644 index 4024b9d6..00000000 --- a/iris_grib/tests/unit/load_convert/test_fixup_int32_from_uint32.py +++ /dev/null @@ -1,57 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for `iris_grib._load_convert.fixup_int32_from_uint32`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -from iris_grib._load_convert import fixup_int32_from_uint32 - - -class Test(tests.IrisGribTest): - def test_negative(self): - result = fixup_int32_from_uint32(0x80000005) - self.assertEqual(result, -5) - - def test_negative_zero(self): - result = fixup_int32_from_uint32(0x80000000) - self.assertEqual(result, 0) - - def test_zero(self): - result = fixup_int32_from_uint32(0) - self.assertEqual(result, 0) - - def test_positive(self): - result = fixup_int32_from_uint32(200000) - self.assertEqual(result, 200000) - - def test_already_negative(self): - # If we *already* have a negative value the fixup routine should - # leave it alone. - result = fixup_int32_from_uint32(-7) - self.assertEqual(result, -7) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_forecast_period_coord.py b/iris_grib/tests/unit/load_convert/test_forecast_period_coord.py deleted file mode 100644 index fefa8ddf..00000000 --- a/iris_grib/tests/unit/load_convert/test_forecast_period_coord.py +++ /dev/null @@ -1,57 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.forecast_period_coord. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from iris.coords import DimCoord - -from iris_grib._load_convert import forecast_period_coord - - -class Test(tests.IrisGribTest): - def test(self): - # (indicatorOfUnitOfTimeRange, forecastTime, expected-hours) - times = [(0, 60, 1), # minutes - (1, 2, 2), # hours - (2, 1, 24), # days - (10, 2, 6), # 3 hours - (11, 3, 18), # 6 hours - (12, 2, 24), # 12 hours - (13, 3600, 1)] # seconds - - for indicatorOfUnitOfTimeRange, forecastTime, hours in times: - coord = forecast_period_coord(indicatorOfUnitOfTimeRange, - forecastTime) - self.assertIsInstance(coord, DimCoord) - self.assertEqual(coord.standard_name, 'forecast_period') - self.assertEqual(coord.units, 'hours') - self.assertEqual(coord.shape, (1,)) - self.assertEqual(coord.points[0], hours) - self.assertFalse(coord.has_bounds()) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_generating_process.py b/iris_grib/tests/unit/load_convert/test_generating_process.py deleted file mode 100644 index b6fa9d3c..00000000 --- a/iris_grib/tests/unit/load_convert/test_generating_process.py +++ /dev/null @@ -1,56 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Tests for function -:func:`iris_grib._load_convert.generating_process`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from iris_grib._load_convert import generating_process - - -class TestGeneratingProcess(tests.IrisGribTest): - def setUp(self): - self.warn_patch = self.patch('warnings.warn') - - def test_nowarn(self): - generating_process(None) - self.assertEqual(self.warn_patch.call_count, 0) - - def test_warn(self): - module = 'iris_grib._load_convert' - self.patch(module + '.options.warn_on_unsupported', True) - generating_process(None) - got_msgs = [call[0][0] for call in self.warn_patch.call_args_list] - expected_msgs = ['Unable to translate type of generating process', - 'Unable to translate background generating process', - 'Unable to translate forecast generating process'] - for expect_msg in expected_msgs: - matches = [msg for msg in got_msgs if expect_msg in msg] - self.assertEqual(len(matches), 1) - got_msgs.remove(matches[0]) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_grib2_convert.py b/iris_grib/tests/unit/load_convert/test_grib2_convert.py deleted file mode 100644 index fdd8ae32..00000000 --- a/iris_grib/tests/unit/load_convert/test_grib2_convert.py +++ /dev/null @@ -1,74 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -"""Test function :func:`iris_grib._load_convert.grib2_convert`.""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -import copy -import mock - -import iris_grib -from iris_grib._load_convert import grib2_convert -from iris_grib.tests.unit import _make_test_message - - -class Test(tests.IrisGribTest): - def setUp(self): - this = 'iris_grib._load_convert' - self.patch('{}.reference_time_coord'.format(this), return_value=None) - self.patch('{}.grid_definition_section'.format(this)) - self.patch('{}.product_definition_section'.format(this)) - self.patch('{}.data_representation_section'.format(this)) - self.patch('{}.bitmap_section'.format(this)) - - def test(self): - sections = [{'discipline': mock.sentinel.discipline}, # section 0 - {'centre': 'ecmf', # section 1 - 'tablesVersion': mock.sentinel.tablesVersion}, - None, # section 2 - mock.sentinel.grid_definition_section, # section 3 - mock.sentinel.product_definition_section, # section 4 - mock.sentinel.data_representation_section, # section 5 - mock.sentinel.bitmap_section] # section 6 - field = _make_test_message(sections) - metadata = {'factories': [], 'references': [], - 'standard_name': None, - 'long_name': None, 'units': None, 'attributes': {}, - 'cell_methods': [], 'dim_coords_and_dims': [], - 'aux_coords_and_dims': []} - expected = copy.deepcopy(metadata) - centre = 'European Centre for Medium Range Weather Forecasts' - expected['attributes'] = {'centre': centre} - # The call being tested. - grib2_convert(field, metadata) - self.assertEqual(metadata, expected) - this = iris_grib._load_convert - this.reference_time_coord.assert_called_with(sections[1]) - this.grid_definition_section.assert_called_with(sections[3], - expected) - args = (sections[4], expected, sections[0]['discipline'], - sections[1]['tablesVersion'], None) - this.product_definition_section.assert_called_with(*args) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_grid_definition_template_0_and_1.py b/iris_grib/tests/unit/load_convert/test_grid_definition_template_0_and_1.py deleted file mode 100644 index 5a866caf..00000000 --- a/iris_grib/tests/unit/load_convert/test_grid_definition_template_0_and_1.py +++ /dev/null @@ -1,62 +0,0 @@ -# (C) British Crown Copyright 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function -:func:`iris_grib._load_convert.grid_definition_template_0_and_1`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from iris.exceptions import TranslationError - -from iris_grib._load_convert import grid_definition_template_0_and_1 - - -class Test(tests.IrisGribTest): - - def test_unsupported_quasi_regular__number_of_octets(self): - section = {'numberOfOctectsForNumberOfPoints': 1} - cs = None - metadata = None - with self.assertRaisesRegexp(TranslationError, 'quasi-regular'): - grid_definition_template_0_and_1(section, - metadata, - 'latitude', - 'longitude', - cs) - - def test_unsupported_quasi_regular__interpretation(self): - section = {'numberOfOctectsForNumberOfPoints': 1, - 'interpretationOfNumberOfPoints': 1} - cs = None - metadata = None - with self.assertRaisesRegexp(TranslationError, 'quasi-regular'): - grid_definition_template_0_and_1(section, - metadata, - 'latitude', - 'longitude', - cs) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_grid_definition_template_12.py b/iris_grib/tests/unit/load_convert/test_grid_definition_template_12.py deleted file mode 100644 index 767e280c..00000000 --- a/iris_grib/tests/unit/load_convert/test_grid_definition_template_12.py +++ /dev/null @@ -1,167 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for -:func:`iris_grib._load_convert.grid_definition_template_12`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -import numpy as np - -import iris.coord_systems -import iris.coords -import iris.exceptions - -from iris_grib.tests.unit.load_convert import empty_metadata -from iris_grib._load_convert import grid_definition_template_12 - - -MDI = 2 ** 32 - 1 - - -class Test(tests.IrisGribTest): - def section_3(self): - section = { - 'shapeOfTheEarth': 7, - 'scaleFactorOfRadiusOfSphericalEarth': MDI, - 'scaledValueOfRadiusOfSphericalEarth': MDI, - 'scaleFactorOfEarthMajorAxis': 3, - 'scaledValueOfEarthMajorAxis': 6377563396, - 'scaleFactorOfEarthMinorAxis': 3, - 'scaledValueOfEarthMinorAxis': 6356256909, - 'Ni': 4, - 'Nj': 3, - 'latitudeOfReferencePoint': 49000000, - 'longitudeOfReferencePoint': -2000000, - 'resolutionAndComponentFlags': 0, - 'scaleFactorAtReferencePoint': 0.9996012717, - 'XR': 40000000, - 'YR': -10000000, - 'scanningMode': 64, - 'Di': 200000, - 'Dj': 100000, - 'X1': 29300000, - 'Y1': 9200000, - 'X2': 29900000, - 'Y2': 9400000 - } - return section - - def expected(self, y_dim, x_dim): - # Prepare the expectation. - expected = empty_metadata() - ellipsoid = iris.coord_systems.GeogCS(6377563.396, 6356256.909) - cs = iris.coord_systems.TransverseMercator(49, -2, 400000, -100000, - 0.9996012717, ellipsoid) - nx = 4 - x_origin = 293000 - dx = 2000 - x = iris.coords.DimCoord(np.arange(nx) * dx + x_origin, - 'projection_x_coordinate', units='m', - coord_system=cs) - ny = 3 - y_origin = 92000 - dy = 1000 - y = iris.coords.DimCoord(np.arange(ny) * dy + y_origin, - 'projection_y_coordinate', units='m', - coord_system=cs) - expected['dim_coords_and_dims'].append((y, y_dim)) - expected['dim_coords_and_dims'].append((x, x_dim)) - return expected - - def test(self): - section = self.section_3() - metadata = empty_metadata() - grid_definition_template_12(section, metadata) - expected = self.expected(0, 1) - self.assertEqual(metadata, expected) - - def test_spherical(self): - section = self.section_3() - section['shapeOfTheEarth'] = 0 - metadata = empty_metadata() - grid_definition_template_12(section, metadata) - expected = self.expected(0, 1) - cs = expected['dim_coords_and_dims'][0][0].coord_system - cs.ellipsoid = iris.coord_systems.GeogCS(6367470) - self.assertEqual(metadata, expected) - - def test_negative_x(self): - section = self.section_3() - section['scanningMode'] = 0b11000000 - metadata = empty_metadata() - with self.assertRaisesRegexp(iris.exceptions.TranslationError, - '-x scanning'): - grid_definition_template_12(section, metadata) - - def test_negative_y(self): - section = self.section_3() - section['scanningMode'] = 0b00000000 - metadata = empty_metadata() - with self.assertRaisesRegexp(iris.exceptions.TranslationError, - '-y scanning'): - grid_definition_template_12(section, metadata) - - def test_transposed(self): - section = self.section_3() - section['scanningMode'] = 0b01100000 - metadata = empty_metadata() - grid_definition_template_12(section, metadata) - expected = self.expected(1, 0) - self.assertEqual(metadata, expected) - - def test_di_tolerance(self): - # Even though Ni * Di doesn't exactly match X1 to X2 it should - # be close enough to allow the translation. - section = self.section_3() - section['X2'] += 1 - metadata = empty_metadata() - grid_definition_template_12(section, metadata) - expected = self.expected(0, 1) - x = expected['dim_coords_and_dims'][1][0] - x.points = np.linspace(293000, 299000.01, 4) - self.assertEqual(metadata, expected) - - def test_incompatible_grid_extent(self): - section = self.section_3() - section['X2'] += 100 - metadata = empty_metadata() - with self.assertRaisesRegexp(iris.exceptions.TranslationError, - 'grid'): - grid_definition_template_12(section, metadata) - - def test_scale_workaround(self): - section = self.section_3() - section['scaleFactorAtReferencePoint'] = 1065346526 - metadata = empty_metadata() - grid_definition_template_12(section, metadata) - expected = self.expected(0, 1) - # A float32 can't hold exactly the same value. - cs = expected['dim_coords_and_dims'][0][0].coord_system - cs.scale_factor_at_central_meridian = 0.9996012449264526 - self.assertEqual(metadata, expected) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_grid_definition_template_20.py b/iris_grib/tests/unit/load_convert/test_grid_definition_template_20.py deleted file mode 100644 index 3f02e7e6..00000000 --- a/iris_grib/tests/unit/load_convert/test_grid_definition_template_20.py +++ /dev/null @@ -1,107 +0,0 @@ -# (C) British Crown Copyright 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for -:func:`iris_grib._load_convert.grid_definition_template_20`. -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -import cartopy.crs as ccrs -import numpy as np - -import iris.coord_systems -import iris.coords - -from iris_grib.tests.unit.load_convert import empty_metadata -from iris_grib._load_convert import grid_definition_template_20 - - -MDI = 2 ** 32 - 1 - - -class Test(tests.IrisGribTest): - - def section_3(self): - section = { - 'shapeOfTheEarth': 0, - 'scaleFactorOfRadiusOfSphericalEarth': 0, - 'scaledValueOfRadiusOfSphericalEarth': 6367470, - 'scaleFactorOfEarthMajorAxis': 0, - 'scaledValueOfEarthMajorAxis': MDI, - 'scaleFactorOfEarthMinorAxis': 0, - 'scaledValueOfEarthMinorAxis': MDI, - 'Nx': 15, - 'Ny': 10, - 'latitudeOfFirstGridPoint': 32549114, - 'longitudeOfFirstGridPoint': 225385728, - 'resolutionAndComponentFlags': 0b00001000, - 'LaD': 60000000, - 'orientationOfTheGrid': 262000000, - 'Dx': 320000000, - 'Dy': 320000000, - 'projectionCentreFlag': 0b00000000, - 'scanningMode': 0b01000000, - } - return section - - def expected(self, y_dim, x_dim): - # Prepare the expectation. - expected = empty_metadata() - cs = iris.coord_systems.GeogCS(6367470) - cs = iris.coord_systems.Stereographic( - central_lat=90., - central_lon=262., - false_easting=0, - false_northing=0, - true_scale_lat=60., - ellipsoid=iris.coord_systems.GeogCS(6367470)) - lon0 = 225385728 * 1e-6 - lat0 = 32549114 * 1e-6 - x0m, y0m = cs.as_cartopy_crs().transform_point( - lon0, lat0, ccrs.Geodetic()) - dxm = dym = 320000. - x_points = x0m + dxm * np.arange(15) - y_points = y0m + dym * np.arange(10) - x = iris.coords.DimCoord(x_points, - standard_name='projection_x_coordinate', - units='m', - coord_system=cs, - circular=False) - y = iris.coords.DimCoord(y_points, - standard_name='projection_y_coordinate', - units='m', - coord_system=cs) - expected['dim_coords_and_dims'].append((y, y_dim)) - expected['dim_coords_and_dims'].append((x, x_dim)) - return expected - - def test(self): - section = self.section_3() - metadata = empty_metadata() - grid_definition_template_20(section, metadata) - expected = self.expected(0, 1) - self.assertEqual(metadata, expected) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_grid_definition_template_30.py b/iris_grib/tests/unit/load_convert/test_grid_definition_template_30.py deleted file mode 100644 index 04169894..00000000 --- a/iris_grib/tests/unit/load_convert/test_grid_definition_template_30.py +++ /dev/null @@ -1,109 +0,0 @@ -# (C) British Crown Copyright 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for -:func:`iris_grib._load_convert.grid_definition_template_30`. -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -import cartopy.crs as ccrs -import numpy as np - -import iris.coord_systems -import iris.coords - -from iris_grib.tests.unit.load_convert import empty_metadata -from iris_grib._load_convert import grid_definition_template_30 - - -MDI = 2 ** 32 - 1 - - -class Test(tests.IrisGribTest): - - def section_3(self): - section = { - 'shapeOfTheEarth': 0, - 'scaleFactorOfRadiusOfSphericalEarth': 0, - 'scaledValueOfRadiusOfSphericalEarth': 6367470, - 'scaleFactorOfEarthMajorAxis': 0, - 'scaledValueOfEarthMajorAxis': MDI, - 'scaleFactorOfEarthMinorAxis': 0, - 'scaledValueOfEarthMinorAxis': MDI, - 'Nx': 15, - 'Ny': 10, - 'longitudeOfFirstGridPoint': 239550000, - 'latitudeOfFirstGridPoint': 21641000, - 'resolutionAndComponentFlags': 0b00001000, - 'LaD': 60000000, - 'LoV': 262000000, - 'Dx': 320000000, - 'Dy': 320000000, - 'projectionCentreFlag': 0b00000000, - 'scanningMode': 0b01000000, - 'Latin1': 60000000, - 'Latin2': 30000000, - } - return section - - def expected(self, y_dim, x_dim): - # Prepare the expectation. - expected = empty_metadata() - cs = iris.coord_systems.GeogCS(6367470) - cs = iris.coord_systems.LambertConformal( - central_lat=60., - central_lon=262., - false_easting=0, - false_northing=0, - secant_latitudes=(60., 30.), - ellipsoid=iris.coord_systems.GeogCS(6367470)) - lon0 = 239.55 - lat0 = 21.641 - x0m, y0m = cs.as_cartopy_crs().transform_point( - lon0, lat0, ccrs.Geodetic()) - dxm = dym = 320000. - x_points = x0m + dxm * np.arange(15) - y_points = y0m + dym * np.arange(10) - x = iris.coords.DimCoord(x_points, - standard_name='projection_x_coordinate', - units='m', - coord_system=cs, - circular=False) - y = iris.coords.DimCoord(y_points, - standard_name='projection_y_coordinate', - units='m', - coord_system=cs) - expected['dim_coords_and_dims'].append((y, y_dim)) - expected['dim_coords_and_dims'].append((x, x_dim)) - return expected - - def test(self): - section = self.section_3() - metadata = empty_metadata() - grid_definition_template_30(section, metadata) - expected = self.expected(0, 1) - self.assertEqual(metadata, expected) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_grid_definition_template_40.py b/iris_grib/tests/unit/load_convert/test_grid_definition_template_40.py deleted file mode 100644 index 665c1eb2..00000000 --- a/iris_grib/tests/unit/load_convert/test_grid_definition_template_40.py +++ /dev/null @@ -1,178 +0,0 @@ -# (C) British Crown Copyright 2015 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for -:func:`iris_grib._load_convert.grid_definition_template_40`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -import numpy as np - -import iris.coord_systems -import iris.coords - -from iris_grib.tests.unit.load_convert import empty_metadata -from iris_grib._load_convert import grid_definition_template_40 - - -MDI = 2 ** 32 - 1 - - -class _Section(dict): - def get_computed_key(self, key): - return self.get(key) - - -class Test_regular(tests.IrisGribTest): - - def section_3(self): - section = _Section({ - 'shapeOfTheEarth': 0, - 'scaleFactorOfRadiusOfSphericalEarth': 0, - 'scaledValueOfRadiusOfSphericalEarth': 6367470, - 'scaleFactorOfEarthMajorAxis': 0, - 'scaledValueOfEarthMajorAxis': MDI, - 'scaleFactorOfEarthMinorAxis': 0, - 'scaledValueOfEarthMinorAxis': MDI, - 'iDirectionIncrement': 22500000, - 'longitudeOfFirstGridPoint': 0, - 'Ni': 16, - 'scanningMode': 0b01000000, - 'distinctLatitudes': np.array([-73.79921363, -52.81294319, - -31.70409175, -10.56988231, - 10.56988231, 31.70409175, - 52.81294319, 73.79921363]), - 'numberOfOctectsForNumberOfPoints': 0, - 'interpretationOfNumberOfPoints': 0, - }) - return section - - def expected(self, y_dim, x_dim, y_neg=True): - # Prepare the expectation. - expected = empty_metadata() - cs = iris.coord_systems.GeogCS(6367470) - nx = 16 - dx = 22.5 - x_origin = 0 - x = iris.coords.DimCoord(np.arange(nx) * dx + x_origin, - standard_name='longitude', - units='degrees_east', - coord_system=cs, - circular=True) - y_points = np.array([73.79921363, 52.81294319, - 31.70409175, 10.56988231, - -10.56988231, -31.70409175, - -52.81294319, -73.79921363]) - if not y_neg: - y_points = y_points[::-1] - y = iris.coords.DimCoord(y_points, - standard_name='latitude', - units='degrees_north', - coord_system=cs) - expected['dim_coords_and_dims'].append((y, y_dim)) - expected['dim_coords_and_dims'].append((x, x_dim)) - return expected - - def test(self): - section = self.section_3() - metadata = empty_metadata() - grid_definition_template_40(section, metadata) - expected = self.expected(0, 1, y_neg=False) - self.assertEqual(metadata, expected) - - def test_transposed(self): - section = self.section_3() - section['scanningMode'] = 0b01100000 - metadata = empty_metadata() - grid_definition_template_40(section, metadata) - expected = self.expected(1, 0, y_neg=False) - self.assertEqual(metadata, expected) - - def test_reverse_latitude(self): - section = self.section_3() - section['scanningMode'] = 0b00000000 - metadata = empty_metadata() - grid_definition_template_40(section, metadata) - expected = self.expected(0, 1, y_neg=True) - self.assertEqual(metadata, expected) - - -class Test_reduced(tests.IrisGribTest): - - def section_3(self): - section = _Section({ - 'shapeOfTheEarth': 0, - 'scaleFactorOfRadiusOfSphericalEarth': 0, - 'scaledValueOfRadiusOfSphericalEarth': 6367470, - 'scaleFactorOfEarthMajorAxis': 0, - 'scaledValueOfEarthMajorAxis': MDI, - 'scaleFactorOfEarthMinorAxis': 0, - 'scaledValueOfEarthMinorAxis': MDI, - 'longitudes': np.array([0., 180., - 0., 120., 240., - 0., 120., 240., - 0., 180.]), - 'latitudes': np.array([-59.44440829, -59.44440829, - -19.87571915, -19.87571915, -19.87571915, - 19.87571915, 19.87571915, 19.87571915, - 59.44440829, 59.44440829]), - 'numberOfOctectsForNumberOfPoints': 1, - 'interpretationOfNumberOfPoints': 1, - }) - return section - - def expected(self): - # Prepare the expectation. - expected = empty_metadata() - cs = iris.coord_systems.GeogCS(6367470) - x_points = np.array([0., 180., - 0., 120., 240., - 0., 120., 240., - 0., 180.]) - y_points = np.array([-59.44440829, -59.44440829, - -19.87571915, -19.87571915, -19.87571915, - 19.87571915, 19.87571915, 19.87571915, - 59.44440829, 59.44440829]) - x = iris.coords.AuxCoord(x_points, - standard_name='longitude', - units='degrees_east', - coord_system=cs) - y = iris.coords.AuxCoord(y_points, - standard_name='latitude', - units='degrees_north', - coord_system=cs) - expected['aux_coords_and_dims'].append((y, 0)) - expected['aux_coords_and_dims'].append((x, 0)) - return expected - - def test(self): - section = self.section_3() - metadata = empty_metadata() - expected = self.expected() - grid_definition_template_40(section, metadata) - self.assertEqual(metadata, expected) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_grid_definition_template_4_and_5.py b/iris_grib/tests/unit/load_convert/test_grid_definition_template_4_and_5.py deleted file mode 100644 index 0f293623..00000000 --- a/iris_grib/tests/unit/load_convert/test_grid_definition_template_4_and_5.py +++ /dev/null @@ -1,138 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function -:func:`iris_grib._load_convert.grid_definition_template_4_and_5`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from copy import deepcopy -import mock -import numpy as np -import warnings - -from iris.coords import DimCoord - -from iris_grib._load_convert import (grid_definition_template_4_and_5, - _MDI as MDI) - - -RESOLUTION = 1e6 - - -class Test(tests.IrisGribTest): - def setUp(self): - self.patch('warnings.warn') - self.patch('iris_grib._load_convert._is_circular', return_value=False) - self.metadata = {'factories': [], 'references': [], - 'standard_name': None, - 'long_name': None, 'units': None, 'attributes': {}, - 'cell_methods': [], 'dim_coords_and_dims': [], - 'aux_coords_and_dims': []} - self.cs = mock.sentinel.coord_system - self.data = np.arange(10, dtype=np.float64) - - def _check(self, section, request_warning, - expect_warning=False, y_dim=0, x_dim=1): - this = 'iris_grib._load_convert.options' - with mock.patch(this, warn_on_unsupported=request_warning): - metadata = deepcopy(self.metadata) - # The called being tested. - grid_definition_template_4_and_5(section, metadata, - 'latitude', 'longitude', self.cs) - expected = deepcopy(self.metadata) - coord = DimCoord(self.data, - standard_name='latitude', - units='degrees', - coord_system=self.cs) - expected['dim_coords_and_dims'].append((coord, y_dim)) - coord = DimCoord(self.data, - standard_name='longitude', - units='degrees', - coord_system=self.cs) - expected['dim_coords_and_dims'].append((coord, x_dim)) - self.assertEqual(metadata, expected) - if expect_warning: - self.assertEqual(len(warnings.warn.mock_calls), 1) - args, kwargs = warnings.warn.call_args - self.assertIn('resolution and component flags', args[0]) - else: - self.assertEqual(len(warnings.warn.mock_calls), 0) - - def test_resolution_default_0(self): - for request_warn in [False, True]: - section = {'basicAngleOfTheInitialProductionDomain': 0, - 'subdivisionsOfBasicAngle': 0, - 'resolutionAndComponentFlags': 0, - 'longitudes': self.data * RESOLUTION, - 'latitudes': self.data * RESOLUTION, - 'scanningMode': 0} - self._check(section, request_warn) - - def test_resolution_default_mdi(self): - for request_warn in [False, True]: - section = {'basicAngleOfTheInitialProductionDomain': MDI, - 'subdivisionsOfBasicAngle': MDI, - 'resolutionAndComponentFlags': 0, - 'longitudes': self.data * RESOLUTION, - 'latitudes': self.data * RESOLUTION, - 'scanningMode': 0} - self._check(section, request_warn) - - def test_resolution(self): - angle = 10 - for request_warn in [False, True]: - section = {'basicAngleOfTheInitialProductionDomain': 1, - 'subdivisionsOfBasicAngle': angle, - 'resolutionAndComponentFlags': 0, - 'longitudes': self.data * angle, - 'latitudes': self.data * angle, - 'scanningMode': 0} - self._check(section, request_warn) - - def test_uv_resolved_warn(self): - angle = 100 - for warn in [False, True]: - section = {'basicAngleOfTheInitialProductionDomain': 1, - 'subdivisionsOfBasicAngle': angle, - 'resolutionAndComponentFlags': 0x08, - 'longitudes': self.data * angle, - 'latitudes': self.data * angle, - 'scanningMode': 0} - self._check(section, warn, expect_warning=warn) - - def test_j_consecutive(self): - angle = 1000 - for request_warn in [False, True]: - section = {'basicAngleOfTheInitialProductionDomain': 1, - 'subdivisionsOfBasicAngle': angle, - 'resolutionAndComponentFlags': 0, - 'longitudes': self.data * angle, - 'latitudes': self.data * angle, - 'scanningMode': 0x20} - self._check(section, request_warn, y_dim=1, x_dim=0) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_grid_definition_template_5.py b/iris_grib/tests/unit/load_convert/test_grid_definition_template_5.py deleted file mode 100644 index b5d8e010..00000000 --- a/iris_grib/tests/unit/load_convert/test_grid_definition_template_5.py +++ /dev/null @@ -1,99 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function -:func:`iris_grib._load_convert.grid_definition_template_5`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from copy import deepcopy -import mock - -from iris_grib._load_convert import grid_definition_template_5 - - -class Test(tests.IrisGribTest): - def setUp(self): - def func(s, m, y, x, c): - return m['dim_coords_and_dims'].append(item) - - module = 'iris_grib._load_convert' - - self.major = mock.sentinel.major - self.minor = mock.sentinel.minor - self.radius = mock.sentinel.radius - - mfunc = '{}.ellipsoid_geometry'.format(module) - return_value = (self.major, self.minor, self.radius) - self.patch(mfunc, return_value=return_value) - - mfunc = '{}.ellipsoid'.format(module) - self.ellipsoid = mock.sentinel.ellipsoid - self.patch(mfunc, return_value=self.ellipsoid) - - mfunc = '{}.grid_definition_template_4_and_5'.format(module) - self.coord = mock.sentinel.coord - self.dim = mock.sentinel.dim - item = (self.coord, self.dim) - self.patch(mfunc, side_effect=func) - - mclass = 'iris.coord_systems.RotatedGeogCS' - self.cs = mock.sentinel.cs - self.patch(mclass, return_value=self.cs) - - self.metadata = {'factories': [], 'references': [], - 'standard_name': None, - 'long_name': None, 'units': None, 'attributes': {}, - 'cell_methods': [], 'dim_coords_and_dims': [], - 'aux_coords_and_dims': []} - - def test(self): - metadata = deepcopy(self.metadata) - angleOfRotation = mock.sentinel.angleOfRotation - shapeOfTheEarth = mock.sentinel.shapeOfTheEarth - section = {'latitudeOfSouthernPole': 45000000, - 'longitudeOfSouthernPole': 90000000, - 'angleOfRotation': angleOfRotation, - 'shapeOfTheEarth': shapeOfTheEarth} - # The called being tested. - grid_definition_template_5(section, metadata) - from iris_grib._load_convert import \ - ellipsoid_geometry, \ - ellipsoid, \ - grid_definition_template_4_and_5 as gdt_4_5 - self.assertEqual(ellipsoid_geometry.call_count, 1) - ellipsoid.assert_called_once_with(shapeOfTheEarth, self.major, - self.minor, self.radius) - from iris.coord_systems import RotatedGeogCS - RotatedGeogCS.assert_called_once_with(-45.0, 270.0, angleOfRotation, - self.ellipsoid) - gdt_4_5.assert_called_once_with(section, metadata, 'grid_latitude', - 'grid_longitude', self.cs) - expected = deepcopy(self.metadata) - expected['dim_coords_and_dims'].append((self.coord, self.dim)) - self.assertEqual(metadata, expected) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_grid_definition_template_90.py b/iris_grib/tests/unit/load_convert/test_grid_definition_template_90.py deleted file mode 100644 index dcffa4bf..00000000 --- a/iris_grib/tests/unit/load_convert/test_grid_definition_template_90.py +++ /dev/null @@ -1,200 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for -:func:`iris_grib._load_convert.grid_definition_template_90`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa -import six - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -import numpy as np - -import iris.coord_systems -import iris.coords -import iris.exceptions - -from iris_grib.tests.unit.load_convert import empty_metadata -from iris_grib._load_convert import grid_definition_template_90 - - -MDI = 2 ** 32 - 1 - - -class Test(tests.IrisGribTest): - def uk(self): - section = { - 'shapeOfTheEarth': 3, - 'scaleFactorOfRadiusOfSphericalEarth': MDI, - 'scaledValueOfRadiusOfSphericalEarth': MDI, - 'scaleFactorOfEarthMajorAxis': 4, - 'scaledValueOfEarthMajorAxis': 63781688, - 'scaleFactorOfEarthMinorAxis': 4, - 'scaledValueOfEarthMinorAxis': 63565840, - 'Nx': 390, - 'Ny': 227, - 'latitudeOfSubSatellitePoint': 0, - 'longitudeOfSubSatellitePoint': 0, - 'resolutionAndComponentFlags': 0, - 'dx': 3622, - 'dy': 3610, - 'Xp': 1856000, - 'Yp': 1856000, - 'scanningMode': 192, - 'orientationOfTheGrid': 0, - 'Nr': 6610674, - 'Xo': 1733, - 'Yo': 3320 - } - return section - - def expected_uk(self, y_dim, x_dim): - # Prepare the expectation. - expected = empty_metadata() - major = 6378168.8 - ellipsoid = iris.coord_systems.GeogCS(major, 6356584.0) - height = (6610674e-6 - 1) * major - lat = lon = 0 - easting = northing = 0 - cs = iris.coord_systems.VerticalPerspective(lat, lon, height, easting, - northing, ellipsoid) - nx = 390 - x_origin = 369081.56145444815 - dx = -3000.663101255676 - x = iris.coords.DimCoord(np.arange(nx) * dx + x_origin, - 'projection_x_coordinate', units='m', - coord_system=cs) - ny = 227 - y_origin = 4392884.59201891 - dy = 3000.604229521113 - y = iris.coords.DimCoord(np.arange(ny) * dy + y_origin, - 'projection_y_coordinate', units='m', - coord_system=cs) - expected['dim_coords_and_dims'].append((y, y_dim)) - expected['dim_coords_and_dims'].append((x, x_dim)) - return expected - - def compare(self, metadata, expected): - # Compare the result with the expectation. - self.assertEqual(len(metadata['dim_coords_and_dims']), - len(expected['dim_coords_and_dims'])) - for result_pair, expected_pair in zip(metadata['dim_coords_and_dims'], - expected['dim_coords_and_dims']): - result_coord, result_dims = result_pair - expected_coord, expected_dims = expected_pair - # Ensure the dims match. - self.assertEqual(result_dims, expected_dims) - # Ensure the coordinate systems match (allowing for precision). - result_cs = result_coord.coord_system - expected_cs = expected_coord.coord_system - self.assertEqual(type(result_cs), type(expected_cs)) - self.assertEqual(result_cs.latitude_of_projection_origin, - expected_cs.latitude_of_projection_origin) - self.assertEqual(result_cs.longitude_of_projection_origin, - expected_cs.longitude_of_projection_origin) - self.assertAlmostEqual(result_cs.perspective_point_height, - expected_cs.perspective_point_height) - self.assertEqual(result_cs.false_easting, - expected_cs.false_easting) - self.assertEqual(result_cs.false_northing, - expected_cs.false_northing) - self.assertAlmostEqual(result_cs.ellipsoid.semi_major_axis, - expected_cs.ellipsoid.semi_major_axis) - self.assertEqual(result_cs.ellipsoid.semi_minor_axis, - expected_cs.ellipsoid.semi_minor_axis) - # Now we can ignore the coordinate systems and compare the - # rest of the coordinate attributes. - result_coord.coord_system = None - expected_coord.coord_system = None - self.assertEqual(result_coord, expected_coord) - - # Ensure no other metadata was created. - for name in six.iterkeys(expected): - if name == 'dim_coords_and_dims': - continue - self.assertEqual(metadata[name], expected[name]) - - def test_uk(self): - section = self.uk() - metadata = empty_metadata() - grid_definition_template_90(section, metadata) - expected = self.expected_uk(0, 1) - self.compare(metadata, expected) - - def test_uk_transposed(self): - section = self.uk() - section['scanningMode'] = 0b11100000 - metadata = empty_metadata() - grid_definition_template_90(section, metadata) - expected = self.expected_uk(1, 0) - self.compare(metadata, expected) - - def test_non_zero_latitude(self): - section = self.uk() - section['latitudeOfSubSatellitePoint'] = 1 - metadata = empty_metadata() - with self.assertRaisesRegexp(iris.exceptions.TranslationError, - 'non-zero latitude'): - grid_definition_template_90(section, metadata) - - def test_rotated_meridian(self): - section = self.uk() - section['orientationOfTheGrid'] = 1 - metadata = empty_metadata() - with self.assertRaisesRegexp(iris.exceptions.TranslationError, - 'orientation'): - grid_definition_template_90(section, metadata) - - def test_zero_height(self): - section = self.uk() - section['Nr'] = 0 - metadata = empty_metadata() - with self.assertRaisesRegexp(iris.exceptions.TranslationError, - 'zero'): - grid_definition_template_90(section, metadata) - - def test_orthographic(self): - section = self.uk() - section['Nr'] = MDI - metadata = empty_metadata() - with self.assertRaisesRegexp(iris.exceptions.TranslationError, - 'orthographic'): - grid_definition_template_90(section, metadata) - - def test_scanning_mode_positive_x(self): - section = self.uk() - section['scanningMode'] = 0b01000000 - metadata = empty_metadata() - with self.assertRaisesRegexp(iris.exceptions.TranslationError, r'\+x'): - grid_definition_template_90(section, metadata) - - def test_scanning_mode_negative_y(self): - section = self.uk() - section['scanningMode'] = 0b10000000 - metadata = empty_metadata() - with self.assertRaisesRegexp(iris.exceptions.TranslationError, '-y'): - grid_definition_template_90(section, metadata) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_other_time_coord.py b/iris_grib/tests/unit/load_convert/test_other_time_coord.py deleted file mode 100644 index e6b210f8..00000000 --- a/iris_grib/tests/unit/load_convert/test_other_time_coord.py +++ /dev/null @@ -1,121 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.other_time_coord. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -import iris.coords - -from iris_grib._load_convert import other_time_coord - - -class TestValid(tests.IrisGribTest): - def test_t(self): - rt = iris.coords.DimCoord(48, 'time', units='hours since epoch') - fp = iris.coords.DimCoord(6, 'forecast_period', units='hours') - result = other_time_coord(rt, fp) - expected = iris.coords.DimCoord(42, 'forecast_reference_time', - units='hours since epoch') - self.assertEqual(result, expected) - - def test_frt(self): - rt = iris.coords.DimCoord(48, 'forecast_reference_time', - units='hours since epoch') - fp = iris.coords.DimCoord(6, 'forecast_period', units='hours') - result = other_time_coord(rt, fp) - expected = iris.coords.DimCoord(54, 'time', units='hours since epoch') - self.assertEqual(result, expected) - - -class TestInvalid(tests.IrisGribTest): - def test_t_with_bounds(self): - rt = iris.coords.DimCoord(48, 'time', units='hours since epoch', - bounds=[36, 60]) - fp = iris.coords.DimCoord(6, 'forecast_period', units='hours') - with self.assertRaisesRegexp(ValueError, 'bounds'): - other_time_coord(rt, fp) - - def test_frt_with_bounds(self): - rt = iris.coords.DimCoord(48, 'forecast_reference_time', - units='hours since epoch', - bounds=[42, 54]) - fp = iris.coords.DimCoord(6, 'forecast_period', units='hours') - with self.assertRaisesRegexp(ValueError, 'bounds'): - other_time_coord(rt, fp) - - def test_fp_with_bounds(self): - rt = iris.coords.DimCoord(48, 'time', units='hours since epoch') - fp = iris.coords.DimCoord(6, 'forecast_period', units='hours', - bounds=[3, 9]) - with self.assertRaisesRegexp(ValueError, 'bounds'): - other_time_coord(rt, fp) - - def test_vector_t(self): - rt = iris.coords.DimCoord([0, 3], 'time', units='hours since epoch') - fp = iris.coords.DimCoord(6, 'forecast_period', units='hours') - with self.assertRaisesRegexp(ValueError, 'Vector'): - other_time_coord(rt, fp) - - def test_vector_frt(self): - rt = iris.coords.DimCoord([0, 3], 'forecast_reference_time', - units='hours since epoch') - fp = iris.coords.DimCoord(6, 'forecast_period', units='hours') - with self.assertRaisesRegexp(ValueError, 'Vector'): - other_time_coord(rt, fp) - - def test_vector_fp(self): - rt = iris.coords.DimCoord(48, 'time', units='hours since epoch') - fp = iris.coords.DimCoord([6, 12], 'forecast_period', units='hours') - with self.assertRaisesRegexp(ValueError, 'Vector'): - other_time_coord(rt, fp) - - def test_invalid_rt_name(self): - rt = iris.coords.DimCoord(1, 'height') - fp = iris.coords.DimCoord(6, 'forecast_period', units='hours') - with self.assertRaisesRegexp(ValueError, 'reference time'): - other_time_coord(rt, fp) - - def test_invalid_t_unit(self): - rt = iris.coords.DimCoord(1, 'time', units='Pa') - fp = iris.coords.DimCoord(6, 'forecast_period', units='hours') - with self.assertRaisesRegexp(ValueError, 'unit.*Pa'): - other_time_coord(rt, fp) - - def test_invalid_frt_unit(self): - rt = iris.coords.DimCoord(1, 'forecast_reference_time', units='km') - fp = iris.coords.DimCoord(6, 'forecast_period', units='hours') - with self.assertRaisesRegexp(ValueError, 'unit.*km'): - other_time_coord(rt, fp) - - def test_invalid_fp_unit(self): - rt = iris.coords.DimCoord(48, 'forecast_reference_time', - units='hours since epoch') - fp = iris.coords.DimCoord(6, 'forecast_period', units='kg') - with self.assertRaisesRegexp(ValueError, 'unit.*kg'): - other_time_coord(rt, fp) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_product_definition_template_0.py b/iris_grib/tests/unit/load_convert/test_product_definition_template_0.py deleted file mode 100644 index 6cce4311..00000000 --- a/iris_grib/tests/unit/load_convert/test_product_definition_template_0.py +++ /dev/null @@ -1,110 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function -:func:`iris_grib._load_convert.product_definition_template_0`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -import mock - -import iris.coords - -import iris_grib -from iris_grib.tests.unit.load_convert import LoadConvertTest, empty_metadata -from iris_grib._load_convert import product_definition_template_0 - - -MDI = 0xffffffff - - -def section_4(): - return {'hoursAfterDataCutoff': MDI, - 'minutesAfterDataCutoff': MDI, - 'indicatorOfUnitOfTimeRange': 0, # minutes - 'forecastTime': 360, - 'NV': 0, - 'typeOfFirstFixedSurface': 103, - 'scaleFactorOfFirstFixedSurface': 0, - 'scaledValueOfFirstFixedSurface': 9999, - 'typeOfSecondFixedSurface': 255} - - -class Test(LoadConvertTest): - def test_given_frt(self): - metadata = empty_metadata() - rt_coord = iris.coords.DimCoord(24, 'forecast_reference_time', - units='hours since epoch') - product_definition_template_0(section_4(), metadata, rt_coord) - expected = empty_metadata() - aux = expected['aux_coords_and_dims'] - aux.append((iris.coords.DimCoord(6, 'forecast_period', units='hours'), - None)) - aux.append(( - iris.coords.DimCoord(30, 'time', units='hours since epoch'), None)) - aux.append((rt_coord, None)) - aux.append((iris.coords.DimCoord(9999, long_name='height', units='m'), - None)) - self.assertMetadataEqual(metadata, expected) - - def test_given_t(self): - metadata = empty_metadata() - rt_coord = iris.coords.DimCoord(24, 'time', - units='hours since epoch') - product_definition_template_0(section_4(), metadata, rt_coord) - expected = empty_metadata() - aux = expected['aux_coords_and_dims'] - aux.append((iris.coords.DimCoord(6, 'forecast_period', units='hours'), - None)) - aux.append(( - iris.coords.DimCoord(18, 'forecast_reference_time', - units='hours since epoch'), None)) - aux.append((rt_coord, None)) - aux.append((iris.coords.DimCoord(9999, long_name='height', units='m'), - None)) - self.assertMetadataEqual(metadata, expected) - - def test_generating_process_warnings(self): - metadata = empty_metadata() - rt_coord = iris.coords.DimCoord(24, 'forecast_reference_time', - units='hours since epoch') - convert_options = iris_grib._load_convert.options - emit_warnings = convert_options.warn_on_unsupported - try: - convert_options.warn_on_unsupported = True - with mock.patch('warnings.warn') as warn: - product_definition_template_0(section_4(), metadata, rt_coord) - warn_msgs = [call[1][0] for call in warn.mock_calls] - expected = ['Unable to translate type of generating process.', - 'Unable to translate background generating process ' - 'identifier.', - 'Unable to translate forecast generating process ' - 'identifier.'] - self.assertEqual(warn_msgs, expected) - finally: - convert_options.warn_on_unsupported = emit_warnings - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_product_definition_template_1.py b/iris_grib/tests/unit/load_convert/test_product_definition_template_1.py deleted file mode 100644 index 06135931..00000000 --- a/iris_grib/tests/unit/load_convert/test_product_definition_template_1.py +++ /dev/null @@ -1,90 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function -:func:`iris_grib._load_convert.product_definition_template_1`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from copy import deepcopy -import mock -import warnings - -from iris.coords import DimCoord - -from iris_grib._load_convert import product_definition_template_1 - - -class Test(tests.IrisGribTest): - def setUp(self): - def func(s, m, f): - return m['cell_methods'].append(self.cell_method) - - module = 'iris_grib._load_convert' - self.patch('warnings.warn') - this = '{}.product_definition_template_0'.format(module) - self.cell_method = mock.sentinel.cell_method - self.patch(this, side_effect=func) - self.metadata = {'factories': [], 'references': [], - 'standard_name': None, - 'long_name': None, 'units': None, 'attributes': {}, - 'cell_methods': [], 'dim_coords_and_dims': [], - 'aux_coords_and_dims': []} - - def _check(self, request_warning): - this = 'iris_grib._load_convert.options' - with mock.patch(this, warn_on_unsupported=request_warning): - metadata = deepcopy(self.metadata) - perturbationNumber = 666 - section = {'perturbationNumber': perturbationNumber} - forecast_reference_time = mock.sentinel.forecast_reference_time - # The called being tested. - product_definition_template_1(section, metadata, - forecast_reference_time) - expected = deepcopy(self.metadata) - expected['cell_methods'].append(self.cell_method) - realization = DimCoord(perturbationNumber, - standard_name='realization', - units='no_unit') - expected['aux_coords_and_dims'].append((realization, None)) - self.assertEqual(metadata, expected) - if request_warning: - warn_msgs = [mcall[1][0] for mcall in warnings.warn.mock_calls] - expected_msgs = ['type of ensemble', 'number of forecasts'] - for emsg in expected_msgs: - matches = [wmsg for wmsg in warn_msgs if emsg in wmsg] - self.assertEqual(len(matches), 1) - warn_msgs.remove(matches[0]) - else: - self.assertEqual(len(warnings.warn.mock_calls), 0) - - def test_pdt_no_warn(self): - self._check(False) - - def test_pdt_warn(self): - self._check(True) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_product_definition_template_10.py b/iris_grib/tests/unit/load_convert/test_product_definition_template_10.py deleted file mode 100644 index 7b056ef4..00000000 --- a/iris_grib/tests/unit/load_convert/test_product_definition_template_10.py +++ /dev/null @@ -1,81 +0,0 @@ -# (C) British Crown Copyright 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.product_definition_template_10`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from copy import deepcopy -import mock - -from iris.coords import DimCoord -from iris_grib._load_convert import product_definition_template_10 -from iris.tests.unit.fileformats.grib.load_convert import empty_metadata - - -class Test(tests.IrisGribTest): - def setUp(self): - module = 'iris_grib._load_convert' - this_module = '{}.product_definition_template_10'.format(module) - self.patch_statistical_fp_coord = self.patch( - module + '.statistical_forecast_period_coord', - return_value=mock.sentinel.dummy_fp_coord) - self.patch_time_coord = self.patch( - module + '.validity_time_coord', - return_value=mock.sentinel.dummy_time_coord) - self.patch_vertical_coords = self.patch(module + '.vertical_coords') - - def test_percentile_coord(self): - metadata = empty_metadata() - percentileValue = 75 - section = {'percentileValue': percentileValue, - 'hoursAfterDataCutoff': 1, - 'minutesAfterDataCutoff': 1, - 'numberOfTimeRange': 1, - 'typeOfStatisticalProcessing': 1, - 'typeOfTimeIncrement': 2, - 'timeIncrement': 0, - 'yearOfEndOfOverallTimeInterval': 2000, - 'monthOfEndOfOverallTimeInterval': 1, - 'dayOfEndOfOverallTimeInterval': 1, - 'hourOfEndOfOverallTimeInterval': 1, - 'minuteOfEndOfOverallTimeInterval': 0, - 'secondOfEndOfOverallTimeInterval': 1} - forecast_reference_time = mock.Mock() - # The called being tested. - product_definition_template_10(section, metadata, - forecast_reference_time) - - expected = {'aux_coords_and_dims': []} - percentile = DimCoord(percentileValue, - long_name='percentile_over_time', - units='no_unit') - expected['aux_coords_and_dims'].append((percentile, None)) - - self.assertEqual(metadata['aux_coords_and_dims'][-1], - expected['aux_coords_and_dims'][0]) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_product_definition_template_11.py b/iris_grib/tests/unit/load_convert/test_product_definition_template_11.py deleted file mode 100644 index 1abcb8d5..00000000 --- a/iris_grib/tests/unit/load_convert/test_product_definition_template_11.py +++ /dev/null @@ -1,114 +0,0 @@ -# (C) British Crown Copyright 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.product_definition_template_11`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from copy import deepcopy -import mock -import warnings - -from iris.coords import DimCoord, CellMethod - -from iris_grib._load_convert import product_definition_template_11 - - -class Test(tests.IrisGribTest): - def setUp(self): - def func(s, m, f): - return m['cell_methods'].append(self.cell_method) - - module = 'iris_grib._load_convert' - self.patch('warnings.warn') - this_module = '{}.product_definition_template_11'.format(module) - self.cell_method = mock.sentinel.cell_method - self.patch(this_module, side_effect=func) - self.patch_statistical_fp_coord = self.patch( - module + '.statistical_forecast_period_coord', - return_value=mock.sentinel.dummy_fp_coord) - self.patch_time_coord = self.patch( - module + '.validity_time_coord', - return_value=mock.sentinel.dummy_time_coord) - self.patch_vertical_coords = self.patch(module + '.vertical_coords') - self.metadata = {'factories': [], 'references': [], - 'standard_name': None, - 'long_name': None, 'units': None, 'attributes': {}, - 'cell_methods': [], 'dim_coords_and_dims': [], - 'aux_coords_and_dims': []} - - def _check(self, request_warning): - grib_lconv_opt = 'iris_grib._load_convert.options' - with mock.patch(grib_lconv_opt, warn_on_unsupported=request_warning): - metadata = deepcopy(self.metadata) - perturbationNumber = 666 - section = {'perturbationNumber': perturbationNumber, - 'hoursAfterDataCutoff': 1, - 'minutesAfterDataCutoff': 1, - 'numberOfTimeRange': 1, - 'typeOfStatisticalProcessing': 1, - 'typeOfTimeIncrement': 2, - 'timeIncrement': 0, - 'yearOfEndOfOverallTimeInterval': 2000, - 'monthOfEndOfOverallTimeInterval': 1, - 'dayOfEndOfOverallTimeInterval': 1, - 'hourOfEndOfOverallTimeInterval': 1, - 'minuteOfEndOfOverallTimeInterval': 0, - 'secondOfEndOfOverallTimeInterval': 1} - forecast_reference_time = mock.Mock() - # The called being tested. - product_definition_template_11(section, metadata, - forecast_reference_time) - expected = {'cell_methods': [], 'aux_coords_and_dims': []} - expected['cell_methods'].append(CellMethod(method='sum', - coords=('time',))) - realization = DimCoord(perturbationNumber, - standard_name='realization', - units='no_unit') - expected['aux_coords_and_dims'].append((realization, None)) - self.maxDiff = None - self.assertEqual(metadata['aux_coords_and_dims'][-1], - expected['aux_coords_and_dims'][0]) - self.assertEqual(metadata['cell_methods'][-1], - expected['cell_methods'][0]) - - if request_warning: - warn_msgs = [mcall[1][0] for mcall in warnings.warn.mock_calls] - expected_msgs = ['type of ensemble', 'number of forecasts'] - for emsg in expected_msgs: - matches = [wmsg for wmsg in warn_msgs if emsg in wmsg] - self.assertEqual(len(matches), 1) - warn_msgs.remove(matches[0]) - else: - self.assertEqual(len(warnings.warn.mock_calls), 0) - - def test_pdt_no_warn(self): - self._check(False) - - def test_pdt_warn(self): - self._check(True) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_product_definition_template_31.py b/iris_grib/tests/unit/load_convert/test_product_definition_template_31.py deleted file mode 100644 index f6611de6..00000000 --- a/iris_grib/tests/unit/load_convert/test_product_definition_template_31.py +++ /dev/null @@ -1,108 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Tests for `iris_grib._load_convert.product_definition_template_31`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from copy import deepcopy -import mock -import numpy as np -import warnings - -from iris.coords import AuxCoord - -from iris_grib._load_convert import product_definition_template_31 - - -class Test(tests.IrisGribTest): - def setUp(self): - self.patch('warnings.warn') - self.metadata = {'factories': [], 'references': [], - 'standard_name': None, - 'long_name': None, 'units': None, 'attributes': None, - 'cell_methods': [], 'dim_coords_and_dims': [], - 'aux_coords_and_dims': []} - - def _check(self, request_warning=False, value=10, factor=1): - # Prepare the arguments. - def unscale(v, f): - return v / 10.0 ** f - - series = mock.sentinel.satelliteSeries - number = mock.sentinel.satelliteNumber - instrument = mock.sentinel.instrumentType - rt_coord = mock.sentinel.observation_time - section = {'NB': 1, - 'satelliteSeries': series, - 'satelliteNumber': number, - 'instrumentType': instrument, - 'scaleFactorOfCentralWaveNumber': factor, - 'scaledValueOfCentralWaveNumber': value} - metadata = deepcopy(self.metadata) - this = 'iris_grib._load_convert.options' - with mock.patch(this, warn_on_unsupported=request_warning): - # The call being tested. - product_definition_template_31(section, metadata, rt_coord) - # Check the result. - expected = deepcopy(self.metadata) - coord = AuxCoord(series, long_name='satellite_series') - expected['aux_coords_and_dims'].append((coord, None)) - coord = AuxCoord(number, long_name='satellite_number') - expected['aux_coords_and_dims'].append((coord, None)) - coord = AuxCoord(instrument, long_name='instrument_type') - expected['aux_coords_and_dims'].append((coord, None)) - standard_name = 'sensor_band_central_radiation_wavenumber' - coord = AuxCoord(unscale(value, factor), - standard_name=standard_name, - units='m-1') - expected['aux_coords_and_dims'].append((coord, None)) - expected['aux_coords_and_dims'].append((rt_coord, None)) - self.assertEqual(metadata, expected) - if request_warning: - warn_msgs = [arg[1][0] for arg in warnings.warn.mock_calls] - expected_msgs = ['type of generating process', - 'observation generating process identifier'] - for emsg in expected_msgs: - matches = [wmsg for wmsg in warn_msgs if emsg in wmsg] - self.assertEqual(len(matches), 1) - warn_msgs.remove(matches[0]) - else: - self.assertEqual(len(warnings.warn.mock_calls), 0) - - def test_pdt_no_warn(self): - self._check(request_warning=False) - - def test_pdt_warn(self): - self._check(request_warning=True) - - def test_wavelength_array(self): - value = np.array([1, 10, 100, 1000]) - for i in range(value.size): - factor = np.ones(value.shape) * i - self._check(value=value, factor=factor) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_product_definition_template_40.py b/iris_grib/tests/unit/load_convert/test_product_definition_template_40.py deleted file mode 100644 index d8f784fe..00000000 --- a/iris_grib/tests/unit/load_convert/test_product_definition_template_40.py +++ /dev/null @@ -1,59 +0,0 @@ -# (C) British Crown Copyright 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.product_definition_template_40`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -import iris.coords - -from iris_grib._load_convert import product_definition_template_40, _MDI -from iris_grib.tests.unit.load_convert import empty_metadata - - -class Test(tests.IrisGribTest): - def setUp(self): - self.section_4 = {'hoursAfterDataCutoff': _MDI, - 'minutesAfterDataCutoff': _MDI, - 'constituentType': 1, - 'indicatorOfUnitOfTimeRange': 0, # minutes - 'startStep': 360, - 'NV': 0, - 'typeOfFirstFixedSurface': 103, - 'scaleFactorOfFirstFixedSurface': 0, - 'scaledValueOfFirstFixedSurface': 9999, - 'typeOfSecondFixedSurface': 255} - - def test_constituent_type(self): - metadata = empty_metadata() - rt_coord = iris.coords.DimCoord(24, 'forecast_reference_time', - units='hours since epoch') - product_definition_template_40(self.section_4, metadata, rt_coord) - expected = empty_metadata() - expected['attributes']['WMO_constituent_type'] = 1 - self.assertEqual(metadata['attributes'], expected['attributes']) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_product_definition_template_8.py b/iris_grib/tests/unit/load_convert/test_product_definition_template_8.py deleted file mode 100644 index 13a6db80..00000000 --- a/iris_grib/tests/unit/load_convert/test_product_definition_template_8.py +++ /dev/null @@ -1,83 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Tests for function -:func:`iris_grib._load_convert.product_definition_template_8`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa -import six - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -import mock - -from iris_grib._load_convert import product_definition_template_8 - - -class Test(tests.IrisGribTest): - def setUp(self): - module = 'iris_grib._load_convert' - self.module = module - # Create patches for called routines - self.patch_generating_process = self.patch( - module + '.generating_process') - self.patch_data_cutoff = self.patch(module + '.data_cutoff') - self.patch_statistical_cell_method = self.patch( - module + '.statistical_cell_method', - return_value=mock.sentinel.dummy_cell_method) - self.patch_statistical_fp_coord = self.patch( - module + '.statistical_forecast_period_coord', - return_value=mock.sentinel.dummy_fp_coord) - self.patch_time_coord = self.patch( - module + '.validity_time_coord', - return_value=mock.sentinel.dummy_time_coord) - self.patch_vertical_coords = self.patch(module + '.vertical_coords') - # Construct dummy call arguments - self.section = {} - self.section['hoursAfterDataCutoff'] = mock.sentinel.cutoff_hours - self.section['minutesAfterDataCutoff'] = mock.sentinel.cutoff_mins - self.frt_coord = mock.Mock() - self.metadata = {'cell_methods': [], 'aux_coords_and_dims': []} - - def test_basic(self): - product_definition_template_8( - self.section, self.metadata, self.frt_coord) - # Check all expected functions were called just once. - self.assertEqual(self.patch_generating_process.call_count, 1) - self.assertEqual(self.patch_data_cutoff.call_count, 1) - self.assertEqual(self.patch_statistical_cell_method.call_count, 1) - self.assertEqual(self.patch_statistical_fp_coord.call_count, 1) - self.assertEqual(self.patch_time_coord.call_count, 1) - self.assertEqual(self.patch_vertical_coords.call_count, 1) - # Check metadata content. - self.assertEqual(sorted(self.metadata.keys()), - ['aux_coords_and_dims', 'cell_methods']) - self.assertEqual(self.metadata['cell_methods'], - [mock.sentinel.dummy_cell_method]) - six.assertCountEqual(self, self.metadata['aux_coords_and_dims'], - [(self.frt_coord, None), - (mock.sentinel.dummy_fp_coord, None), - (mock.sentinel.dummy_time_coord, None)]) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_product_definition_template_9.py b/iris_grib/tests/unit/load_convert/test_product_definition_template_9.py deleted file mode 100644 index acbb49b0..00000000 --- a/iris_grib/tests/unit/load_convert/test_product_definition_template_9.py +++ /dev/null @@ -1,89 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Tests for function -:func:`iris_grib._load_convert.product_definition_template_9`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -import mock - -from iris.exceptions import TranslationError - -from iris_grib._load_convert import product_definition_template_9 -from iris_grib._load_convert import Probability, _MDI - - -class Test(tests.IrisGribTest): - def setUp(self): - # Create patches for called routines - module = 'iris_grib._load_convert' - self.patch_pdt8_call = self.patch( - module + '.product_definition_template_8') - # Construct dummy call arguments - self.section = {} - self.section['probabilityType'] = 1 - self.section['scaledValueOfUpperLimit'] = 53 - self.section['scaleFactorOfUpperLimit'] = 1 - self.frt_coord = mock.sentinel.frt_coord - self.metadata = {'cell_methods': [mock.sentinel.cell_method], - 'aux_coords_and_dims': []} - - def test_basic(self): - result = product_definition_template_9( - self.section, self.metadata, self.frt_coord) - # Check expected function was called. - self.assertEqual( - self.patch_pdt8_call.call_args_list, - [mock.call(self.section, self.metadata, self.frt_coord)]) - # Check metadata content (N.B. cell_method has been removed!). - self.assertEqual(self.metadata, {'cell_methods': [], - 'aux_coords_and_dims': []}) - # Check result. - self.assertEqual(result, Probability('above_threshold', 5.3)) - - def test_fail_bad_probability_type(self): - self.section['probabilityType'] = 17 - with self.assertRaisesRegexp(TranslationError, - 'unsupported probability type'): - product_definition_template_9( - self.section, self.metadata, self.frt_coord) - - def test_fail_bad_threshold_value(self): - self.section['scaledValueOfUpperLimit'] = _MDI - with self.assertRaisesRegexp(TranslationError, - 'missing scaled value'): - product_definition_template_9( - self.section, self.metadata, self.frt_coord) - - def test_fail_bad_threshold_scalefactor(self): - self.section['scaleFactorOfUpperLimit'] = _MDI - with self.assertRaisesRegexp(TranslationError, - 'missing scale factor'): - product_definition_template_9( - self.section, self.metadata, self.frt_coord) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_projection_centre.py b/iris_grib/tests/unit/load_convert/test_projection_centre.py deleted file mode 100644 index 618419fd..00000000 --- a/iris_grib/tests/unit/load_convert/test_projection_centre.py +++ /dev/null @@ -1,51 +0,0 @@ -# (C) British Crown Copyright 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.projection centre. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from iris_grib._load_convert import projection_centre, ProjectionCentre - - -class Test(tests.IrisGribTest): - def test_unset(self): - expected = ProjectionCentre(False, False) - self.assertEqual(projection_centre(0x0), expected) - - def test_bipolar_and_symmetric(self): - expected = ProjectionCentre(False, True) - self.assertEqual(projection_centre(0x40), expected) - - def test_south_pole_on_projection_plane(self): - expected = ProjectionCentre(True, False) - self.assertEqual(projection_centre(0x80), expected) - - def test_both(self): - expected = ProjectionCentre(True, True) - self.assertEqual(projection_centre(0xc0), expected) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_reference_time_coord.py b/iris_grib/tests/unit/load_convert/test_reference_time_coord.py deleted file mode 100644 index 91bce6ae..00000000 --- a/iris_grib/tests/unit/load_convert/test_reference_time_coord.py +++ /dev/null @@ -1,82 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.reference_time_coord. - -Reference Code Table 1.2. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from copy import deepcopy -from datetime import datetime - -from cf_units import CALENDAR_GREGORIAN, Unit - -from iris.coords import DimCoord -from iris.exceptions import TranslationError - -from iris_grib._load_convert import reference_time_coord - - -class Test(tests.IrisGribTest): - def setUp(self): - self.section = {'year': 2007, - 'month': 1, - 'day': 15, - 'hour': 0, - 'minute': 3, - 'second': 0} - self.unit = Unit('hours since epoch', calendar=CALENDAR_GREGORIAN) - dt = datetime(self.section['year'], self.section['month'], - self.section['day'], self.section['hour'], - self.section['minute'], self.section['second']) - self.point = self.unit.date2num(dt) - - def _check(self, section, standard_name=None): - expected = DimCoord(self.point, standard_name=standard_name, - units=self.unit) - # The call being tested. - coord = reference_time_coord(section) - self.assertEqual(coord, expected) - - def test_start_of_forecast(self): - section = deepcopy(self.section) - section['significanceOfReferenceTime'] = 1 - self._check(section, 'forecast_reference_time') - - def test_observation_time(self): - section = deepcopy(self.section) - section['significanceOfReferenceTime'] = 3 - self._check(section, 'time') - - def test_unknown_significance(self): - section = deepcopy(self.section) - section['significanceOfReferenceTime'] = 5 - emsg = 'unsupported significance' - with self.assertRaisesRegexp(TranslationError, emsg): - self._check(section) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_resolution_flags.py b/iris_grib/tests/unit/load_convert/test_resolution_flags.py deleted file mode 100644 index 7384dce0..00000000 --- a/iris_grib/tests/unit/load_convert/test_resolution_flags.py +++ /dev/null @@ -1,51 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.resolution_flags.` - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from iris_grib._load_convert import resolution_flags, ResolutionFlags - - -class Test(tests.IrisGribTest): - def test_unset(self): - expected = ResolutionFlags(False, False, False) - self.assertEqual(resolution_flags(0x0), expected) - - def test_i_increments_given(self): - expected = ResolutionFlags(True, False, False) - self.assertEqual(resolution_flags(0x20), expected) - - def test_j_increments_given(self): - expected = ResolutionFlags(False, True, False) - self.assertEqual(resolution_flags(0x10), expected) - - def test_uv_resolved(self): - expected = ResolutionFlags(False, False, True) - self.assertEqual(resolution_flags(0x08), expected) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_scanning_mode.py b/iris_grib/tests/unit/load_convert/test_scanning_mode.py deleted file mode 100644 index be2afcf6..00000000 --- a/iris_grib/tests/unit/load_convert/test_scanning_mode.py +++ /dev/null @@ -1,60 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.scanning_mode. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from iris.exceptions import TranslationError - -from iris_grib._load_convert import scanning_mode, ScanningMode - - -class Test(tests.IrisGribTest): - def test_unset(self): - expected = ScanningMode(False, False, False, False) - self.assertEqual(scanning_mode(0x0), expected) - - def test_i_negative(self): - expected = ScanningMode(i_negative=True, j_positive=False, - j_consecutive=False, i_alternative=False) - self.assertEqual(scanning_mode(0x80), expected) - - def test_j_positive(self): - expected = ScanningMode(i_negative=False, j_positive=True, - j_consecutive=False, i_alternative=False) - self.assertEqual(scanning_mode(0x40), expected) - - def test_j_consecutive(self): - expected = ScanningMode(i_negative=False, j_positive=False, - j_consecutive=True, i_alternative=False) - self.assertEqual(scanning_mode(0x20), expected) - - def test_i_alternative(self): - with self.assertRaises(TranslationError): - scanning_mode(0x10) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_statistical_cell_method.py b/iris_grib/tests/unit/load_convert/test_statistical_cell_method.py deleted file mode 100644 index e125d5cb..00000000 --- a/iris_grib/tests/unit/load_convert/test_statistical_cell_method.py +++ /dev/null @@ -1,85 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Tests for function -:func:`iris_grib._load_convert.statistical_cell_method`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from iris.exceptions import TranslationError - -from iris_grib._load_convert import statistical_cell_method - - -class Test(tests.IrisGribTest): - def setUp(self): - self.section = {} - self.section['numberOfTimeRange'] = 1 - self.section['typeOfStatisticalProcessing'] = 0 - self.section['typeOfTimeIncrement'] = 2 - self.section['timeIncrement'] = 0 - - def test_basic(self): - cell_method = statistical_cell_method(self.section) - self.assertEqual(cell_method.method, 'mean') - self.assertEqual(cell_method.coord_names, ('time',)) - self.assertEqual(cell_method.intervals, ()) - - def test_intervals(self): - self.section['timeIncrement'] = 3 - self.section['indicatorOfUnitForTimeIncrement'] = 1 - cell_method = statistical_cell_method(self.section) - self.assertEqual(cell_method.method, 'mean') - self.assertEqual(cell_method.coord_names, ('time',)) - self.assertEqual(cell_method.intervals, ('3 hours',)) - - def test_fail_bad_ranges(self): - self.section['numberOfTimeRange'] = 0 - with self.assertRaisesRegexp(TranslationError, - 'aggregation over "0 time ranges"'): - statistical_cell_method(self.section) - - def test_fail_multiple_ranges(self): - self.section['numberOfTimeRange'] = 2 - with self.assertRaisesRegexp(TranslationError, - 'multiple time ranges \[2\]'): - statistical_cell_method(self.section) - - def test_fail_unknown_statistic(self): - self.section['typeOfStatisticalProcessing'] = 17 - with self.assertRaisesRegexp( - TranslationError, - 'statistical process type \[17\] is not supported'): - statistical_cell_method(self.section) - - def test_fail_bad_increment_type(self): - self.section['typeOfTimeIncrement'] = 7 - with self.assertRaisesRegexp( - TranslationError, - 'time-increment type \[7\] is not supported'): - statistical_cell_method(self.section) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_statistical_forecast_period_coord.py b/iris_grib/tests/unit/load_convert/test_statistical_forecast_period_coord.py deleted file mode 100644 index 7a6593bd..00000000 --- a/iris_grib/tests/unit/load_convert/test_statistical_forecast_period_coord.py +++ /dev/null @@ -1,80 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Tests for function :func:`iris_grib._load_convert.statistical_forecast_period`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -import datetime -import mock - -from iris_grib._load_convert import statistical_forecast_period_coord - - -class Test(tests.IrisGribTest): - def setUp(self): - module = 'iris_grib._load_convert' - self.module = module - self.patch_hindcast = self.patch(module + '._hindcast_fix') - self.forecast_seconds = 0.0 - self.forecast_units = mock.Mock() - self.forecast_units.convert = lambda x, y: self.forecast_seconds - self.patch(module + '.time_range_unit', - return_value=self.forecast_units) - self.frt_coord = mock.Mock() - self.frt_coord.points = [1] - self.frt_coord.units.num2date = mock.Mock( - return_value=datetime.datetime(2010, 2, 3)) - self.section = {} - self.section['yearOfEndOfOverallTimeInterval'] = 2010 - self.section['monthOfEndOfOverallTimeInterval'] = 2 - self.section['dayOfEndOfOverallTimeInterval'] = 3 - self.section['hourOfEndOfOverallTimeInterval'] = 8 - self.section['minuteOfEndOfOverallTimeInterval'] = 0 - self.section['secondOfEndOfOverallTimeInterval'] = 0 - self.section['forecastTime'] = mock.Mock() - self.section['indicatorOfUnitOfTimeRange'] = mock.Mock() - - def test_basic(self): - coord = statistical_forecast_period_coord(self.section, - self.frt_coord) - self.assertEqual(coord.standard_name, 'forecast_period') - self.assertEqual(coord.units, 'hours') - self.assertArrayAlmostEqual(coord.points, [4.0]) - self.assertArrayAlmostEqual(coord.bounds, [[0.0, 8.0]]) - - def test_with_hindcast(self): - coord = statistical_forecast_period_coord(self.section, - self.frt_coord) - self.assertEqual(self.patch_hindcast.call_count, 1) - - def test_no_hindcast(self): - self.patch(self.module + '.options.support_hindcast_values', False) - coord = statistical_forecast_period_coord(self.section, - self.frt_coord) - self.assertEqual(self.patch_hindcast.call_count, 0) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_time_range_unit.py b/iris_grib/tests/unit/load_convert/test_time_range_unit.py deleted file mode 100644 index 6a1a0d34..00000000 --- a/iris_grib/tests/unit/load_convert/test_time_range_unit.py +++ /dev/null @@ -1,57 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.time_range_unit. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from cf_units import Unit -from iris.exceptions import TranslationError - -from iris_grib._load_convert import time_range_unit - - -class Test(tests.IrisGribTest): - def setUp(self): - self.unit_by_indicator = {0: Unit('minutes'), - 1: Unit('hours'), - 2: Unit('days'), - 10: Unit('3 hours'), - 11: Unit('6 hours'), - 12: Unit('12 hours'), - 13: Unit('seconds')} - - def test_units(self): - for indicator, unit in self.unit_by_indicator.items(): - result = time_range_unit(indicator) - self.assertEqual(result, unit) - - def test_bad_indicator(self): - emsg = 'unsupported time range' - with self.assertRaisesRegexp(TranslationError, emsg): - time_range_unit(-1) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_translate_phenomenon.py b/iris_grib/tests/unit/load_convert/test_translate_phenomenon.py deleted file mode 100644 index f6e02516..00000000 --- a/iris_grib/tests/unit/load_convert/test_translate_phenomenon.py +++ /dev/null @@ -1,71 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Tests for function :func:`iris_grib._load_convert.translate_phenomenon`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from copy import deepcopy - -from cf_units import Unit -from iris.coords import DimCoord - -from iris_grib._load_convert import Probability, translate_phenomenon -from iris_grib.grib_phenom_translation import _GribToCfDataClass - - -class Test_probability(tests.IrisGribTest): - def setUp(self): - # Patch inner call to return a given phenomenon type. - target_module = 'iris_grib._load_convert' - self.phenom_lookup_patch = self.patch( - target_module + '.itranslation.grib2_phenom_to_cf_info', - return_value=_GribToCfDataClass('air_temperature', '', 'K', None)) - # Construct dummy call arguments - self.probability = Probability('', 22.0) - self.metadata = {'aux_coords_and_dims': []} - - def test_basic(self): - result = translate_phenomenon(self.metadata, None, None, None, - probability=self.probability) - # Check metadata. - thresh_coord = DimCoord([22.0], - standard_name='air_temperature', - long_name='', units='K') - self.assertEqual(self.metadata, { - 'standard_name': None, - 'long_name': 'probability_of_air_temperature_', - 'units': Unit(1), - 'aux_coords_and_dims': [(thresh_coord, None)]}) - - def test_no_phenomenon(self): - original_metadata = deepcopy(self.metadata) - self.phenom_lookup_patch.return_value = None - result = translate_phenomenon(self.metadata, None, None, None, - probability=self.probability) - self.assertEqual(self.metadata, original_metadata) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_unscale.py b/iris_grib/tests/unit/load_convert/test_unscale.py deleted file mode 100644 index 39e95527..00000000 --- a/iris_grib/tests/unit/load_convert/test_unscale.py +++ /dev/null @@ -1,67 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.unscale. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -import numpy as np -import numpy.ma as ma - -from iris_grib._load_convert import unscale, _MDI as MDI - -# Reference GRIB2 Regulation 92.1.12. - - -class Test(tests.IrisGribTest): - def test_single(self): - self.assertEqual(unscale(123, 1), 12.3) - self.assertEqual(unscale(123, -1), 1230.0) - self.assertEqual(unscale(123, 2), 1.23) - self.assertEqual(unscale(123, -2), 12300.0) - - def test_single_mdi(self): - self.assertIs(unscale(10, MDI), ma.masked) - self.assertIs(unscale(MDI, 1), ma.masked) - - def test_array(self): - items = [[1, [0.1, 1.2, 12.3, 123.4]], - [-1, [10.0, 120.0, 1230.0, 12340.0]], - [2, [0.01, 0.12, 1.23, 12.34]], - [-2, [100.0, 1200.0, 12300.0, 123400.0]]] - values = np.array([1, 12, 123, 1234]) - for factor, expected in items: - result = unscale(values, [factor] * values.size) - self.assertFalse(ma.isMaskedArray(result)) - np.testing.assert_array_equal(result, np.array(expected)) - - def test_array_mdi(self): - result = unscale([1, MDI, 100, 1000], [1, 1, 1, MDI]) - self.assertTrue(ma.isMaskedArray(result)) - expected = ma.masked_values([0.1, MDI, 10.0, MDI], MDI) - np.testing.assert_array_almost_equal(result, expected) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_validity_time_coord.py b/iris_grib/tests/unit/load_convert/test_validity_time_coord.py deleted file mode 100644 index 9af4841b..00000000 --- a/iris_grib/tests/unit/load_convert/test_validity_time_coord.py +++ /dev/null @@ -1,85 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.validity_time_coord. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from cf_units import Unit -import mock -import numpy as np - -from iris.coords import DimCoord - -from iris_grib._load_convert import validity_time_coord - - -class Test(tests.IrisGribTest): - def setUp(self): - self.fp = DimCoord(5, standard_name='forecast_period', units='hours') - self.fp_test_bounds = np.array([[1.0, 9.0]]) - self.unit = Unit('hours since epoch') - self.frt = DimCoord(10, standard_name='forecast_reference_time', - units=self.unit) - - def test_frt_shape(self): - frt = mock.Mock(shape=(2,)) - fp = mock.Mock(shape=(1,)) - emsg = 'scalar forecast reference time' - with self.assertRaisesRegexp(ValueError, emsg): - validity_time_coord(frt, fp) - - def test_fp_shape(self): - frt = mock.Mock(shape=(1,)) - fp = mock.Mock(shape=(2,)) - emsg = 'scalar forecast period' - with self.assertRaisesRegexp(ValueError, emsg): - validity_time_coord(frt, fp) - - def test(self): - coord = validity_time_coord(self.frt, self.fp) - self.assertIsInstance(coord, DimCoord) - self.assertEqual(coord.standard_name, 'time') - self.assertEqual(coord.units, self.unit) - self.assertEqual(coord.shape, (1,)) - point = self.frt.points[0] + self.fp.points[0] - self.assertEqual(coord.points[0], point) - self.assertFalse(coord.has_bounds()) - - def test_bounded(self): - self.fp.bounds = self.fp_test_bounds - coord = validity_time_coord(self.frt, self.fp) - self.assertIsInstance(coord, DimCoord) - self.assertEqual(coord.standard_name, 'time') - self.assertEqual(coord.units, self.unit) - self.assertEqual(coord.shape, (1,)) - point = self.frt.points[0] + self.fp.points[0] - self.assertEqual(coord.points[0], point) - self.assertTrue(coord.has_bounds()) - bounds = self.frt.points[0] + self.fp_test_bounds - self.assertArrayAlmostEqual(coord.bounds, bounds) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_vertical_coords.py b/iris_grib/tests/unit/load_convert/test_vertical_coords.py deleted file mode 100644 index 5d0a7544..00000000 --- a/iris_grib/tests/unit/load_convert/test_vertical_coords.py +++ /dev/null @@ -1,177 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Test function :func:`iris_grib._load_convert.vertical_coords`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# import iris_grib.tests first so that some things can be initialised -# before importing anything else. -import iris_grib.tests as tests - -from copy import deepcopy -import mock - -from iris.coords import DimCoord -from iris.exceptions import TranslationError - -from iris_grib._load_convert import vertical_coords -from iris_grib._load_convert import \ - _TYPE_OF_FIXED_SURFACE_MISSING as MISSING_SURFACE, \ - _MDI as MISSING_LEVEL - - -class Test(tests.IrisGribTest): - def setUp(self): - self.metadata = {'factories': [], 'references': [], - 'standard_name': None, - 'long_name': None, 'units': None, 'attributes': {}, - 'cell_methods': [], 'dim_coords_and_dims': [], - 'aux_coords_and_dims': []} - - def test_hybrid_factories(self): - def func(section, metadata): - return metadata['factories'].append(factory) - - metadata = deepcopy(self.metadata) - section = {'NV': 1} - this = 'iris_grib._load_convert.hybrid_factories' - factory = mock.sentinel.factory - with mock.patch(this, side_effect=func) as hybrid_factories: - vertical_coords(section, metadata) - self.assertTrue(hybrid_factories.called) - self.assertEqual(metadata['factories'], [factory]) - - def test_no_first_fixed_surface(self): - metadata = deepcopy(self.metadata) - section = {'NV': 0, - 'typeOfFirstFixedSurface': MISSING_SURFACE, - 'scaledValueOfFirstFixedSurface': MISSING_LEVEL} - vertical_coords(section, metadata) - self.assertEqual(metadata, self.metadata) - - def _check(self, value, msg): - this = 'iris_grib._load_convert.options' - with mock.patch('warnings.warn') as warn: - with mock.patch(this) as options: - for request_warning in [False, True]: - options.warn_on_unsupported = request_warning - metadata = deepcopy(self.metadata) - section = {'NV': 0, - 'typeOfFirstFixedSurface': 0, - 'scaledValueOfFirstFixedSurface': value} - # The call being tested. - vertical_coords(section, metadata) - self.assertEqual(metadata, self.metadata) - if request_warning: - self.assertEqual(len(warn.mock_calls), 1) - args, _ = warn.call_args - self.assertIn(msg, args[0]) - else: - self.assertEqual(len(warn.mock_calls), 0) - - def test_unknown_first_fixed_surface_with_missing_scaled_value(self): - self._check(MISSING_LEVEL, 'surface with missing scaled value') - - def test_unknown_first_fixed_surface_with_scaled_value(self): - self._check(0, 'surface with scaled value') - - def test_pressure_with_no_second_fixed_surface(self): - metadata = deepcopy(self.metadata) - section = {'NV': 0, - 'typeOfFirstFixedSurface': 100, # pressure / Pa - 'scaledValueOfFirstFixedSurface': 10, - 'scaleFactorOfFirstFixedSurface': 1, - 'typeOfSecondFixedSurface': MISSING_SURFACE} - vertical_coords(section, metadata) - coord = DimCoord(1.0, long_name='pressure', units='Pa') - expected = deepcopy(self.metadata) - expected['aux_coords_and_dims'].append((coord, None)) - self.assertEqual(metadata, expected) - - def test_height_with_no_second_fixed_surface(self): - metadata = deepcopy(self.metadata) - section = {'NV': 0, - 'typeOfFirstFixedSurface': 103, # height / m - 'scaledValueOfFirstFixedSurface': 100, - 'scaleFactorOfFirstFixedSurface': 2, - 'typeOfSecondFixedSurface': MISSING_SURFACE} - vertical_coords(section, metadata) - coord = DimCoord(1.0, long_name='height', units='m') - expected = deepcopy(self.metadata) - expected['aux_coords_and_dims'].append((coord, None)) - self.assertEqual(metadata, expected) - - def test_different_fixed_surfaces(self): - section = {'NV': 0, - 'typeOfFirstFixedSurface': 100, - 'scaledValueOfFirstFixedSurface': None, - 'scaleFactorOfFirstFixedSurface': None, - 'typeOfSecondFixedSurface': 0} - emsg = 'different types of first and second fixed surface' - with self.assertRaisesRegexp(TranslationError, emsg): - vertical_coords(section, None) - - def test_same_fixed_surfaces_missing_second_scaled_value(self): - section = {'NV': 0, - 'typeOfFirstFixedSurface': 100, - 'scaledValueOfFirstFixedSurface': None, - 'scaleFactorOfFirstFixedSurface': None, - 'typeOfSecondFixedSurface': 100, - 'scaledValueOfSecondFixedSurface': MISSING_LEVEL} - emsg = 'missing scaled value of second fixed surface' - with self.assertRaisesRegexp(TranslationError, emsg): - vertical_coords(section, None) - - def test_pressure_with_second_fixed_surface(self): - metadata = deepcopy(self.metadata) - section = {'NV': 0, - 'typeOfFirstFixedSurface': 100, - 'scaledValueOfFirstFixedSurface': 10, - 'scaleFactorOfFirstFixedSurface': 1, - 'typeOfSecondFixedSurface': 100, - 'scaledValueOfSecondFixedSurface': 30, - 'scaleFactorOfSecondFixedSurface': 1} - vertical_coords(section, metadata) - coord = DimCoord(2.0, long_name='pressure', units='Pa', - bounds=[1.0, 3.0]) - expected = deepcopy(self.metadata) - expected['aux_coords_and_dims'].append((coord, None)) - self.assertEqual(metadata, expected) - - def test_height_with_second_fixed_surface(self): - metadata = deepcopy(self.metadata) - section = {'NV': 0, - 'typeOfFirstFixedSurface': 103, - 'scaledValueOfFirstFixedSurface': 1000, - 'scaleFactorOfFirstFixedSurface': 2, - 'typeOfSecondFixedSurface': 103, - 'scaledValueOfSecondFixedSurface': 3000, - 'scaleFactorOfSecondFixedSurface': 2} - vertical_coords(section, metadata) - coord = DimCoord(20.0, long_name='height', units='m', - bounds=[10.0, 30.0]) - expected = deepcopy(self.metadata) - expected['aux_coords_and_dims'].append((coord, None)) - self.assertEqual(metadata, expected) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/load_rules/__init__.py b/iris_grib/tests/unit/load_rules/__init__.py deleted file mode 100644 index 9fb52835..00000000 --- a/iris_grib/tests/unit/load_rules/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -"""Unit tests for the :mod:`iris_grib.load_rules` module.""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa diff --git a/iris_grib/tests/unit/load_rules/test_grib1_convert.py b/iris_grib/tests/unit/load_rules/test_grib1_convert.py deleted file mode 100644 index 0e458a3a..00000000 --- a/iris_grib/tests/unit/load_rules/test_grib1_convert.py +++ /dev/null @@ -1,148 +0,0 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -"""Unit tests for :func:`iris_grib.load_rules.grib1_convert`.""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else -import iris_grib.tests as tests - -import cf_units -import gribapi -import mock - -import iris -from iris.exceptions import TranslationError -from iris.fileformats.rules import Reference - -from iris_grib import GribWrapper -from iris_grib.load_rules import grib1_convert -from iris_grib.tests.unit import TestField - - -class TestBadEdition(tests.IrisGribTest): - def test(self): - message = mock.Mock(edition=2) - emsg = 'GRIB edition 2 is not supported' - with self.assertRaisesRegexp(TranslationError, emsg): - grib1_convert(message) - - -class TestBoundedTime(TestField): - @staticmethod - def is_forecast_period(coord): - return (coord.standard_name == 'forecast_period' and - coord.units == 'hours') - - @staticmethod - def is_time(coord): - return (coord.standard_name == 'time' and - coord.units == 'hours since epoch') - - def assert_bounded_message(self, **kwargs): - attributes = {'productDefinitionTemplateNumber': 0, - 'edition': 1, '_forecastTime': 15, - '_forecastTimeUnit': 'hours', - 'phenomenon_bounds': lambda u: (80, 120), - '_phenomenonDateTime': -1, - 'table2Version': 9999} - attributes.update(kwargs) - message = mock.Mock(**attributes) - self._test_for_coord(message, grib1_convert, self.is_forecast_period, - expected_points=[35], - expected_bounds=[[15, 55]]) - self._test_for_coord(message, grib1_convert, self.is_time, - expected_points=[100], - expected_bounds=[[80, 120]]) - - def test_time_range_indicator_2(self): - self.assert_bounded_message(timeRangeIndicator=2) - - def test_time_range_indicator_3(self): - self.assert_bounded_message(timeRangeIndicator=3) - - def test_time_range_indicator_4(self): - self.assert_bounded_message(timeRangeIndicator=4) - - def test_time_range_indicator_5(self): - self.assert_bounded_message(timeRangeIndicator=5) - - def test_time_range_indicator_51(self): - self.assert_bounded_message(timeRangeIndicator=51) - - def test_time_range_indicator_113(self): - self.assert_bounded_message(timeRangeIndicator=113) - - def test_time_range_indicator_114(self): - self.assert_bounded_message(timeRangeIndicator=114) - - def test_time_range_indicator_115(self): - self.assert_bounded_message(timeRangeIndicator=115) - - def test_time_range_indicator_116(self): - self.assert_bounded_message(timeRangeIndicator=116) - - def test_time_range_indicator_117(self): - self.assert_bounded_message(timeRangeIndicator=117) - - def test_time_range_indicator_118(self): - self.assert_bounded_message(timeRangeIndicator=118) - - def test_time_range_indicator_123(self): - self.assert_bounded_message(timeRangeIndicator=123) - - def test_time_range_indicator_124(self): - self.assert_bounded_message(timeRangeIndicator=124) - - def test_time_range_indicator_125(self): - self.assert_bounded_message(timeRangeIndicator=125) - - -class Test_GribLevels(tests.IrisTest): - def test_grib1_hybrid_height(self): - gm = gribapi.grib_new_from_samples('regular_gg_ml_grib1') - gw = GribWrapper(gm) - results = grib1_convert(gw) - - factory, = results[0] - self.assertEqual(factory.factory_class, - iris.aux_factory.HybridPressureFactory) - delta, sigma, ref = factory.args - self.assertEqual(delta, {'long_name': 'level_pressure'}) - self.assertEqual(sigma, {'long_name': 'sigma'}) - self.assertEqual(ref, Reference(name='surface_pressure')) - - ml_ref = iris.coords.CoordDefn('model_level_number', None, None, - cf_units.Unit('1'), - {'positive': 'up'}, None) - lp_ref = iris.coords.CoordDefn(None, 'level_pressure', None, - cf_units.Unit('Pa'), - {}, None) - s_ref = iris.coords.CoordDefn(None, 'sigma', None, - cf_units.Unit('1'), - {}, None) - - aux_coord_defns = [coord._as_defn() for coord, dim in results[8]] - self.assertIn(ml_ref, aux_coord_defns) - self.assertIn(lp_ref, aux_coord_defns) - self.assertIn(s_ref, aux_coord_defns) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/message/__init__.py b/iris_grib/tests/unit/message/__init__.py deleted file mode 100644 index c3a2c938..00000000 --- a/iris_grib/tests/unit/message/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -"""Unit tests for the :mod:`iris_grib.message` package.""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa diff --git a/iris_grib/tests/unit/message/test_GribMessage.py b/iris_grib/tests/unit/message/test_GribMessage.py deleted file mode 100644 index ec6959dd..00000000 --- a/iris_grib/tests/unit/message/test_GribMessage.py +++ /dev/null @@ -1,281 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for the `iris_grib.message.GribMessage` class. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa -import six - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -from abc import ABCMeta, abstractmethod - -import biggus -import mock -import numpy as np - -from iris.exceptions import TranslationError - -from iris_grib.message import GribMessage -from iris_grib.tests.unit import _make_test_message - - -SECTION_6_NO_BITMAP = {'bitMapIndicator': 255, 'bitmap': None} - - -@tests.skip_data -class Test_messages_from_filename(tests.IrisGribTest): - def test(self): - filename = tests.get_data_path(('GRIB', '3_layer_viz', - '3_layer.grib2')) - messages = list(GribMessage.messages_from_filename(filename)) - self.assertEqual(len(messages), 3) - - def test_release_file(self): - filename = tests.get_data_path(('GRIB', '3_layer_viz', - '3_layer.grib2')) - my_file = open(filename) - self.patch('__builtin__.open', mock.Mock(return_value=my_file)) - messages = list(GribMessage.messages_from_filename(filename)) - self.assertFalse(my_file.closed) - del messages - self.assertTrue(my_file.closed) - - -class Test_sections(tests.IrisGribTest): - def test(self): - # Check that the `sections` attribute defers to the `sections` - # attribute on the underlying _RawGribMessage. - message = _make_test_message(mock.sentinel.SECTIONS) - self.assertIs(message.sections, mock.sentinel.SECTIONS) - - -class Test_data__masked(tests.IrisGribTest): - def setUp(self): - self.bitmap = np.array([0, 0, 1, 1, 0, 0, 0, 1, 0, 1, 0, 1]) - self.shape = (3, 4) - self._section_3 = {'sourceOfGridDefinition': 0, - 'numberOfOctectsForNumberOfPoints': 0, - 'interpretationOfNumberOfPoints': 0, - 'gridDefinitionTemplateNumber': 0, - 'scanningMode': 0, - 'Nj': self.shape[0], - 'Ni': self.shape[1]} - - def test_no_bitmap(self): - values = np.arange(12) - message = _make_test_message({3: self._section_3, - 6: SECTION_6_NO_BITMAP, - 7: {'codedValues': values}}) - result = message.data.ndarray() - expected = values.reshape(self.shape) - self.assertEqual(result.shape, self.shape) - self.assertArrayEqual(result, expected) - - def test_bitmap_present(self): - # Test the behaviour where bitmap and codedValues shapes - # are not equal. - input_values = np.arange(5) - output_values = np.array([-1, -1, 0, 1, -1, -1, -1, 2, -1, 3, -1, 4]) - message = _make_test_message({3: self._section_3, - 6: {'bitMapIndicator': 0, - 'bitmap': self.bitmap}, - 7: {'codedValues': input_values}}) - result = message.data.masked_array() - expected = np.ma.masked_array(output_values, - np.logical_not(self.bitmap)) - expected = expected.reshape(self.shape) - self.assertMaskedArrayEqual(result, expected) - - def test_bitmap__shapes_mismatch(self): - # Test the behaviour where bitmap and codedValues shapes do not match. - # Too many or too few unmasked values in codedValues will cause this. - values = np.arange(6) - message = _make_test_message({3: self._section_3, - 6: {'bitMapIndicator': 0, - 'bitmap': self.bitmap}, - 7: {'codedValues': values}}) - with self.assertRaisesRegexp(TranslationError, 'do not match'): - message.data.masked_array() - - def test_bitmap__invalid_indicator(self): - values = np.arange(12) - message = _make_test_message({3: self._section_3, - 6: {'bitMapIndicator': 100, - 'bitmap': None}, - 7: {'codedValues': values}}) - with self.assertRaisesRegexp(TranslationError, 'unsupported bitmap'): - message.data.ndarray() - - -class Test_data__unsupported(tests.IrisGribTest): - def test_unsupported_grid_definition(self): - message = _make_test_message({3: {'sourceOfGridDefinition': 1}, - 6: SECTION_6_NO_BITMAP}) - with self.assertRaisesRegexp(TranslationError, 'source'): - message.data - - def test_unsupported_quasi_regular__number_of_octets(self): - message = _make_test_message( - {3: {'sourceOfGridDefinition': 0, - 'numberOfOctectsForNumberOfPoints': 1, - 'gridDefinitionTemplateNumber': 0}, - 6: SECTION_6_NO_BITMAP}) - with self.assertRaisesRegexp(TranslationError, 'quasi-regular'): - message.data - - def test_unsupported_quasi_regular__interpretation(self): - message = _make_test_message( - {3: {'sourceOfGridDefinition': 0, - 'numberOfOctectsForNumberOfPoints': 0, - 'interpretationOfNumberOfPoints': 1, - 'gridDefinitionTemplateNumber': 0}, - 6: SECTION_6_NO_BITMAP}) - with self.assertRaisesRegexp(TranslationError, 'quasi-regular'): - message.data - - def test_unsupported_template(self): - message = _make_test_message( - {3: {'sourceOfGridDefinition': 0, - 'numberOfOctectsForNumberOfPoints': 0, - 'interpretationOfNumberOfPoints': 0, - 'gridDefinitionTemplateNumber': 2}}) - with self.assertRaisesRegexp(TranslationError, 'template'): - message.data - - -# Abstract, mix-in class for testing the `data` attribute for various -# grid definition templates. -class Mixin_data__grid_template(six.with_metaclass(ABCMeta, object)): - @abstractmethod - def section_3(self, scanning_mode): - raise NotImplementedError() - - def test_unsupported_scanning_mode(self): - message = _make_test_message( - {3: self.section_3(1), - 6: SECTION_6_NO_BITMAP}) - with self.assertRaisesRegexp(TranslationError, 'scanning mode'): - message.data - - def _test(self, scanning_mode): - message = _make_test_message( - {3: self.section_3(scanning_mode), - 6: SECTION_6_NO_BITMAP, - 7: {'codedValues': np.arange(12)}}) - data = message.data - self.assertIsInstance(data, biggus.Array) - self.assertEqual(data.shape, (3, 4)) - self.assertEqual(data.dtype, np.floating) - self.assertIs(data.fill_value, np.nan) - self.assertArrayEqual(data.ndarray(), np.arange(12).reshape(3, 4)) - - def test_regular_mode_0(self): - self._test(0) - - def test_regular_mode_64(self): - self._test(64) - - def test_regular_mode_128(self): - self._test(128) - - def test_regular_mode_64_128(self): - self._test(64 | 128) - - -def _example_section_3(grib_definition_template_number, scanning_mode): - return {'sourceOfGridDefinition': 0, - 'numberOfOctectsForNumberOfPoints': 0, - 'interpretationOfNumberOfPoints': 0, - 'gridDefinitionTemplateNumber': grib_definition_template_number, - 'scanningMode': scanning_mode, - 'Nj': 3, - 'Ni': 4} - - -class Test_data__grid_template_0(tests.IrisGribTest, - Mixin_data__grid_template): - def section_3(self, scanning_mode): - return _example_section_3(0, scanning_mode) - - -class Test_data__grid_template_1(tests.IrisGribTest, - Mixin_data__grid_template): - def section_3(self, scanning_mode): - return _example_section_3(1, scanning_mode) - - -class Test_data__grid_template_5(tests.IrisGribTest, - Mixin_data__grid_template): - def section_3(self, scanning_mode): - return _example_section_3(5, scanning_mode) - - -class Test_data__grid_template_12(tests.IrisGribTest, - Mixin_data__grid_template): - def section_3(self, scanning_mode): - return _example_section_3(12, scanning_mode) - - -class Test_data__grid_template_30(tests.IrisGribTest, - Mixin_data__grid_template): - def section_3(self, scanning_mode): - section_3 = _example_section_3(30, scanning_mode) - # Dimensions are 'Nx' + 'Ny' instead of 'Ni' + 'Nj'. - section_3['Nx'] = section_3['Ni'] - section_3['Ny'] = section_3['Nj'] - del section_3['Ni'] - del section_3['Nj'] - return section_3 - - -class Test_data__grid_template_40_regular(tests.IrisGribTest, - Mixin_data__grid_template): - def section_3(self, scanning_mode): - return _example_section_3(40, scanning_mode) - - -class Test_data__grid_template_90(tests.IrisGribTest, - Mixin_data__grid_template): - def section_3(self, scanning_mode): - section_3 = _example_section_3(90, scanning_mode) - # Exceptionally, dimensions are 'Nx' + 'Ny' instead of 'Ni' + 'Nj'. - section_3['Nx'] = section_3['Ni'] - section_3['Ny'] = section_3['Nj'] - del section_3['Ni'] - del section_3['Nj'] - return section_3 - - -class Test_data__unknown_grid_template(tests.IrisGribTest): - def test(self): - message = _make_test_message( - {3: _example_section_3(999, 0), - 6: SECTION_6_NO_BITMAP, - 7: {'codedValues': np.arange(12)}}) - with self.assertRaisesRegexp(TranslationError, - 'template 999 is not supported'): - data = message.data - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/message/test_Section.py b/iris_grib/tests/unit/message/test_Section.py deleted file mode 100644 index 98e81511..00000000 --- a/iris_grib/tests/unit/message/test_Section.py +++ /dev/null @@ -1,98 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for `iris_grib.message.Section`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import gribapi -import numpy as np - -from iris_grib.message import Section - - -@tests.skip_data -class Test___getitem__(tests.IrisGribTest): - def setUp(self): - filename = tests.get_data_path(('GRIB', 'uk_t', 'uk_t.grib2')) - with open(filename, 'rb') as grib_fh: - self.grib_id = gribapi.grib_new_from_file(grib_fh) - - def test_scalar(self): - section = Section(self.grib_id, None, ['Ni']) - self.assertEqual(section['Ni'], 47) - - def test_array(self): - section = Section(self.grib_id, None, ['codedValues']) - codedValues = section['codedValues'] - self.assertEqual(codedValues.shape, (1551,)) - self.assertArrayAlmostEqual(codedValues[:3], - [-1.78140259, -1.53140259, -1.28140259]) - - def test_typeOfFirstFixedSurface(self): - section = Section(self.grib_id, None, ['typeOfFirstFixedSurface']) - self.assertEqual(section['typeOfFirstFixedSurface'], 100) - - def test_numberOfSection(self): - n = 4 - section = Section(self.grib_id, n, ['numberOfSection']) - self.assertEqual(section['numberOfSection'], n) - - def test_invalid(self): - section = Section(self.grib_id, None, ['Ni']) - with self.assertRaisesRegexp(KeyError, 'Nii'): - section['Nii'] - - -@tests.skip_data -class Test__getitem___pdt_31(tests.IrisGribTest): - def setUp(self): - filename = tests.get_data_path(('GRIB', 'umukv', 'ukv_chan9.grib2')) - with open(filename, 'rb') as grib_fh: - self.grib_id = gribapi.grib_new_from_file(grib_fh) - self.keys = ['satelliteSeries', 'satelliteNumber', 'instrumentType', - 'scaleFactorOfCentralWaveNumber', - 'scaledValueOfCentralWaveNumber'] - - def test_array(self): - section = Section(self.grib_id, None, self.keys) - for key in self.keys: - value = section[key] - self.assertIsInstance(value, np.ndarray) - self.assertEqual(value.shape, (1,)) - - -@tests.skip_data -class Test_get_computed_key(tests.IrisGribTest): - def test_gdt40_computed(self): - fname = tests.get_data_path(('GRIB', 'gaussian', 'regular_gg.grib2')) - with open(fname, 'rb') as grib_fh: - self.grib_id = gribapi.grib_new_from_file(grib_fh) - section = Section(self.grib_id, None, []) - latitudes = section.get_computed_key('latitudes') - self.assertTrue(88.55 < latitudes[0] < 88.59) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/message/test__DataProxy.py b/iris_grib/tests/unit/message/test__DataProxy.py deleted file mode 100644 index 95b61504..00000000 --- a/iris_grib/tests/unit/message/test__DataProxy.py +++ /dev/null @@ -1,59 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for the `iris.message._DataProxy` class. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import numpy as np -from numpy.random import randint - -from iris.exceptions import TranslationError - -from iris_grib.message import _DataProxy - - -class Test__bitmap(tests.IrisGribTest): - def test_no_bitmap(self): - section_6 = {'bitMapIndicator': 255, 'bitmap': None} - data_proxy = _DataProxy(0, 0, 0, 0) - result = data_proxy._bitmap(section_6) - self.assertIsNone(result) - - def test_bitmap_present(self): - bitmap = randint(2, size=(12)) - section_6 = {'bitMapIndicator': 0, 'bitmap': bitmap} - data_proxy = _DataProxy(0, 0, 0, 0) - result = data_proxy._bitmap(section_6) - self.assertArrayEqual(bitmap, result) - - def test_bitmap__invalid_indicator(self): - section_6 = {'bitMapIndicator': 100, 'bitmap': None} - data_proxy = _DataProxy(0, 0, 0, 0) - with self.assertRaisesRegexp(TranslationError, 'unsupported bitmap'): - data_proxy._bitmap(section_6) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/message/test__MessageLocation.py b/iris_grib/tests/unit/message/test__MessageLocation.py deleted file mode 100644 index cc6b9147..00000000 --- a/iris_grib/tests/unit/message/test__MessageLocation.py +++ /dev/null @@ -1,48 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for the `iris.message._MessageLocation` class. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import mock - -from iris_grib.message import _MessageLocation - - -class Test(tests.IrisGribTest): - def test(self): - message_location = _MessageLocation(mock.sentinel.filename, - mock.sentinel.location) - patch_target = 'iris_grib.message._RawGribMessage.from_file_offset' - expected = mock.sentinel.message - with mock.patch(patch_target, return_value=expected) as rgm: - result = message_location() - rgm.assert_called_once_with(mock.sentinel.filename, - mock.sentinel.location) - self.assertIs(result, expected) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/message/test__RawGribMessage.py b/iris_grib/tests/unit/message/test__RawGribMessage.py deleted file mode 100644 index fae0a652..00000000 --- a/iris_grib/tests/unit/message/test__RawGribMessage.py +++ /dev/null @@ -1,67 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for the `iris_grib.message._RawGribMessage` class. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import gribapi - -from iris_grib.message import _RawGribMessage - - -@tests.skip_data -class Test(tests.IrisGribTest): - def setUp(self): - filename = tests.get_data_path(('GRIB', 'uk_t', 'uk_t.grib2')) - with open(filename, 'rb') as grib_fh: - grib_id = gribapi.grib_new_from_file(grib_fh) - self.message = _RawGribMessage(grib_id) - - def test_sections__set(self): - # Test that sections writes into the _sections attribute. - res = self.message.sections - self.assertNotEqual(self.message._sections, None) - - def test_sections__indexing(self): - res = self.message.sections[3]['scanningMode'] - expected = 64 - self.assertEqual(expected, res) - - def test__get_message_sections__section_numbers(self): - res = list(self.message.sections.keys()) - self.assertEqual(res, list(range(9))) - - def test_sections__numberOfSection_value(self): - # The key `numberOfSection` is repeated in every section meaning that - # if requested using gribapi it always defaults to its last value (7). - # This tests that the `_RawGribMessage._get_message_sections` - # override is functioning. - section_number = 4 - res = self.message.sections[section_number]['numberOfSection'] - self.assertEqual(res, section_number) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/save_rules/__init__.py b/iris_grib/tests/unit/save_rules/__init__.py deleted file mode 100644 index 0903e82e..00000000 --- a/iris_grib/tests/unit/save_rules/__init__.py +++ /dev/null @@ -1,97 +0,0 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -"""Unit tests for the :mod:`iris_grib.grib_save_rules` module.""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import mock -import numpy as np - -import iris -from iris.fileformats.pp import EARTH_RADIUS as PP_DEFAULT_EARTH_RADIUS - - -class GdtTestMixin(object): - """Some handy common test capabilities for grib grid-definition tests.""" - TARGET_MODULE = 'iris_grib._save_rules' - - def setUp(self): - # Patch the gribapi of the tested module. - self.mock_gribapi = self.patch(self.TARGET_MODULE + '.gribapi') - - # Fix the mock gribapi to record key assignments. - def grib_set_trap(grib, name, value): - # Record a key setting on the mock passed as the 'grib message id'. - grib.keys[name] = value - - self.mock_gribapi.grib_set = grib_set_trap - self.mock_gribapi.grib_set_long = grib_set_trap - self.mock_gribapi.grib_set_float = grib_set_trap - self.mock_gribapi.grib_set_double = grib_set_trap - self.mock_gribapi.grib_set_long_array = grib_set_trap - self.mock_gribapi.grib_set_array = grib_set_trap - - # Create a mock 'grib message id', with a 'keys' dict for settings. - self.mock_grib = mock.Mock(keys={}) - - # Initialise the test cube and its coords to something barely usable. - self.test_cube = self._make_test_cube() - - def _default_coord_system(self): - return iris.coord_systems.GeogCS(PP_DEFAULT_EARTH_RADIUS) - - def _default_x_points(self): - # Define simple, regular coordinate points. - return [1.0, 2.0, 3.0] - - def _default_y_points(self): - return [7.0, 8.0] # N.B. is_regular will *fail* on length-1 coords. - - def _make_test_cube(self, cs=None, x_points=None, y_points=None): - # Create a cube with given properties, or minimal defaults. - if cs is None: - cs = self._default_coord_system() - if x_points is None: - x_points = self._default_x_points() - if y_points is None: - y_points = self._default_y_points() - - x_coord = iris.coords.DimCoord(x_points, standard_name='longitude', - units='degrees', - coord_system=cs) - y_coord = iris.coords.DimCoord(y_points, standard_name='latitude', - units='degrees', - coord_system=cs) - test_cube = iris.cube.Cube(np.zeros((len(y_points), len(x_points)))) - test_cube.add_dim_coord(y_coord, 0) - test_cube.add_dim_coord(x_coord, 1) - return test_cube - - def _check_key(self, name, value): - # Test that a specific grib key assignment occurred. - msg_fmt = 'Expected grib setting "{}" = {}, got {}' - found = self.mock_grib.keys.get(name) - if found is None: - self.assertEqual(0, 1, msg_fmt.format(name, value, '((UNSET))')) - else: - self.assertArrayEqual(found, value, - msg_fmt.format(name, value, found)) diff --git a/iris_grib/tests/unit/save_rules/test__missing_forecast_period.py b/iris_grib/tests/unit/save_rules/test__missing_forecast_period.py deleted file mode 100644 index b7932e88..00000000 --- a/iris_grib/tests/unit/save_rules/test__missing_forecast_period.py +++ /dev/null @@ -1,114 +0,0 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for :func:`iris_grib._save_rules._missing_forecast_period.` - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import datetime -import numpy as np - -from iris.cube import Cube -from iris.coords import DimCoord - -from iris_grib._save_rules import _missing_forecast_period - - -class TestNoForecastReferenceTime(tests.IrisGribTest): - def test_no_bounds(self): - t_coord = DimCoord(15, 'time', units='hours since epoch') - cube = Cube(23) - cube.add_aux_coord(t_coord) - - res = _missing_forecast_period(cube) - expected_rt = t_coord.units.num2date(15) - expected_rt_type = 3 - expected_fp = 0 - expected_fp_type = 1 - expected = (expected_rt, - expected_rt_type, - expected_fp, - expected_fp_type) - self.assertEqual(res, expected) - - def test_with_bounds(self): - t_coord = DimCoord(15, 'time', bounds=[14, 16], - units='hours since epoch') - cube = Cube(23) - cube.add_aux_coord(t_coord) - - res = _missing_forecast_period(cube) - expected_rt = t_coord.units.num2date(14) - expected_rt_type = 3 - expected_fp = 0 - expected_fp_type = 1 - expected = (expected_rt, - expected_rt_type, - expected_fp, - expected_fp_type) - self.assertEqual(res, expected) - - -class TestWithForecastReferenceTime(tests.IrisGribTest): - def test_no_bounds(self): - t_coord = DimCoord(3, 'time', units='days since epoch') - frt_coord = DimCoord(8, 'forecast_reference_time', - units='hours since epoch') - cube = Cube(23) - cube.add_aux_coord(t_coord) - cube.add_aux_coord(frt_coord) - - res = _missing_forecast_period(cube) - expected_rt = frt_coord.units.num2date(8) - expected_rt_type = 1 - expected_fp = 3 * 24 - 8 - expected_fp_type = 1 - expected = (expected_rt, - expected_rt_type, - expected_fp, - expected_fp_type) - self.assertEqual(res, expected) - - def test_with_bounds(self): - t_coord = DimCoord(3, 'time', bounds=[2, 4], units='days since epoch') - frt_coord = DimCoord(8, 'forecast_reference_time', - units='hours since epoch') - cube = Cube(23) - cube.add_aux_coord(t_coord) - cube.add_aux_coord(frt_coord) - - res = _missing_forecast_period(cube) - expected_rt = frt_coord.units.num2date(8) - expected_rt_type = 1 - expected_fp = 2 * 24 - 8 - expected_fp_type = 1 - expected = (expected_rt, - expected_rt_type, - expected_fp, - expected_fp_type) - self.assertEqual(res, expected) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test__non_missing_forecast_period.py b/iris_grib/tests/unit/save_rules/test__non_missing_forecast_period.py deleted file mode 100644 index f302bb67..00000000 --- a/iris_grib/tests/unit/save_rules/test__non_missing_forecast_period.py +++ /dev/null @@ -1,65 +0,0 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -"""Unit tests for module-level functions.""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import cf_units - -import iris - -from iris_grib._save_rules import _non_missing_forecast_period - - -class Test(tests.IrisGribTest): - def _cube(self, t_bounds=False): - time_coord = iris.coords.DimCoord(15, standard_name='time', - units='hours since epoch') - fp_coord = iris.coords.DimCoord(10, standard_name='forecast_period', - units='hours') - if t_bounds: - time_coord.bounds = [[8, 100]] - fp_coord.bounds = [[3, 95]] - cube = iris.cube.Cube([23]) - cube.add_dim_coord(time_coord, 0) - cube.add_aux_coord(fp_coord, 0) - return cube - - def test_time_point(self): - cube = self._cube() - rt, rt_meaning, fp, fp_meaning = _non_missing_forecast_period(cube) - self.assertEqual((rt_meaning, fp, fp_meaning), (1, 10, 1)) - - def test_time_bounds(self): - cube = self._cube(t_bounds=True) - rt, rt_meaning, fp, fp_meaning = _non_missing_forecast_period(cube) - self.assertEqual((rt_meaning, fp, fp_meaning), (1, 3, 1)) - - def test_time_bounds_in_minutes(self): - cube = self._cube(t_bounds=True) - cube.coord('forecast_period').convert_units('minutes') - rt, rt_meaning, fp, fp_meaning = _non_missing_forecast_period(cube) - self.assertEqual((rt_meaning, fp, fp_meaning), (1, 180, 0)) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test__product_definition_template_8_10_and_11.py b/iris_grib/tests/unit/save_rules/test__product_definition_template_8_10_and_11.py deleted file mode 100644 index f0b00dfc..00000000 --- a/iris_grib/tests/unit/save_rules/test__product_definition_template_8_10_and_11.py +++ /dev/null @@ -1,210 +0,0 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for -:func:`iris_grib._save_rules._product_definition_template_8_10_and_11` - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -from cf_units import Unit -import gribapi -import mock - -from iris.coords import CellMethod, DimCoord -import iris.tests.stock as stock - -from iris_grib._save_rules import _product_definition_template_8_10_and_11 - - -class TestTypeOfStatisticalProcessing(tests.IrisGribTest): - def setUp(self): - self.cube = stock.lat_lon_cube() - # Rename cube to avoid warning about unknown discipline/parameter. - self.cube.rename('air_temperature') - coord = DimCoord(23, 'time', bounds=[0, 100], - units=Unit('days since epoch', calendar='standard')) - self.cube.add_aux_coord(coord) - - @mock.patch.object(gribapi, 'grib_set') - def test_sum(self, mock_set): - cube = self.cube - cell_method = CellMethod(method='sum', coords=['time']) - cube.add_cell_method(cell_method) - - _product_definition_template_8_10_and_11(cube, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, - "typeOfStatisticalProcessing", 1) - - @mock.patch.object(gribapi, 'grib_set') - def test_unrecognised(self, mock_set): - cube = self.cube - cell_method = CellMethod(method='95th percentile', coords=['time']) - cube.add_cell_method(cell_method) - - _product_definition_template_8_10_and_11(cube, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, - "typeOfStatisticalProcessing", 255) - - @mock.patch.object(gribapi, 'grib_set') - def test_multiple_cell_method_coords(self, mock_set): - cube = self.cube - cell_method = CellMethod(method='sum', - coords=['time', 'forecast_period']) - cube.add_cell_method(cell_method) - with self.assertRaisesRegexp(ValueError, - 'Cannot handle multiple coordinate name'): - _product_definition_template_8_10_and_11(cube, mock.sentinel.grib) - - @mock.patch.object(gribapi, 'grib_set') - def test_cell_method_coord_name_fail(self, mock_set): - cube = self.cube - cell_method = CellMethod(method='mean', coords=['season']) - cube.add_cell_method(cell_method) - with self.assertRaisesRegexp( - ValueError, "Expected a cell method with a coordinate " - "name of 'time'"): - _product_definition_template_8_10_and_11(cube, mock.sentinel.grib) - - -class TestTimeCoordPrerequisites(tests.IrisGribTest): - def setUp(self): - self.cube = stock.lat_lon_cube() - # Rename cube to avoid warning about unknown discipline/parameter. - self.cube.rename('air_temperature') - - @mock.patch.object(gribapi, 'grib_set') - def test_multiple_points(self, mock_set): - # Add time coord with multiple points. - coord = DimCoord([23, 24, 25], 'time', - bounds=[[22, 23], [23, 24], [24, 25]], - units=Unit('days since epoch', calendar='standard')) - self.cube.add_aux_coord(coord, 0) - with self.assertRaisesRegexp( - ValueError, 'Expected length one time coordinate'): - _product_definition_template_8_10_and_11(self.cube, - mock.sentinel.grib) - - @mock.patch.object(gribapi, 'grib_set') - def test_no_bounds(self, mock_set): - # Add time coord with no bounds. - coord = DimCoord(23, 'time', - units=Unit('days since epoch', calendar='standard')) - self.cube.add_aux_coord(coord) - with self.assertRaisesRegexp( - ValueError, 'Expected time coordinate with two bounds, ' - 'got 0 bounds'): - _product_definition_template_8_10_and_11(self.cube, - mock.sentinel.grib) - - @mock.patch.object(gribapi, 'grib_set') - def test_more_than_two_bounds(self, mock_set): - # Add time coord with more than two bounds. - coord = DimCoord(23, 'time', bounds=[21, 22, 23], - units=Unit('days since epoch', calendar='standard')) - self.cube.add_aux_coord(coord) - with self.assertRaisesRegexp( - ValueError, 'Expected time coordinate with two bounds, ' - 'got 3 bounds'): - _product_definition_template_8_10_and_11(self.cube, - mock.sentinel.grib) - - -class TestEndOfOverallTimeInterval(tests.IrisGribTest): - def setUp(self): - self.cube = stock.lat_lon_cube() - # Rename cube to avoid warning about unknown discipline/parameter. - self.cube.rename('air_temperature') - cell_method = CellMethod(method='sum', coords=['time']) - self.cube.add_cell_method(cell_method) - - @mock.patch.object(gribapi, 'grib_set') - def test_default_calendar(self, mock_set): - cube = self.cube - # End bound is 1972-04-26 10:27:07. - coord = DimCoord(23.0, 'time', bounds=[0.452, 20314.452], - units=Unit('hours since epoch')) - cube.add_aux_coord(coord) - - grib = mock.sentinel.grib - _product_definition_template_8_10_and_11(cube, grib) - - mock_set.assert_any_call( - grib, "yearOfEndOfOverallTimeInterval", 1972) - mock_set.assert_any_call( - grib, "monthOfEndOfOverallTimeInterval", 4) - mock_set.assert_any_call( - grib, "dayOfEndOfOverallTimeInterval", 26) - mock_set.assert_any_call( - grib, "hourOfEndOfOverallTimeInterval", 10) - mock_set.assert_any_call( - grib, "minuteOfEndOfOverallTimeInterval", 27) - mock_set.assert_any_call( - grib, "secondOfEndOfOverallTimeInterval", 7) - - @mock.patch.object(gribapi, 'grib_set') - def test_360_day_calendar(self, mock_set): - cube = self.cube - # End bound is 1972-05-07 10:27:07 - coord = DimCoord(23.0, 'time', bounds=[0.452, 20314.452], - units=Unit('hours since epoch', calendar='360_day')) - cube.add_aux_coord(coord) - - grib = mock.sentinel.grib - _product_definition_template_8_10_and_11(cube, grib) - - mock_set.assert_any_call( - grib, "yearOfEndOfOverallTimeInterval", 1972) - mock_set.assert_any_call( - grib, "monthOfEndOfOverallTimeInterval", 5) - mock_set.assert_any_call( - grib, "dayOfEndOfOverallTimeInterval", 7) - mock_set.assert_any_call( - grib, "hourOfEndOfOverallTimeInterval", 10) - mock_set.assert_any_call( - grib, "minuteOfEndOfOverallTimeInterval", 27) - mock_set.assert_any_call( - grib, "secondOfEndOfOverallTimeInterval", 7) - - -class TestNumberOfTimeRange(tests.IrisGribTest): - @mock.patch.object(gribapi, 'grib_set') - def test_other_cell_methods(self, mock_set): - cube = stock.lat_lon_cube() - # Rename cube to avoid warning about unknown discipline/parameter. - cube.rename('air_temperature') - coord = DimCoord(23, 'time', bounds=[0, 24], - units=Unit('hours since epoch')) - cube.add_aux_coord(coord) - # Add one time cell method and another unrelated one. - cell_method = CellMethod(method='mean', coords=['elephants']) - cube.add_cell_method(cell_method) - cell_method = CellMethod(method='sum', coords=['time']) - cube.add_cell_method(cell_method) - - _product_definition_template_8_10_and_11(cube, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, 'numberOfTimeRange', 1) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_fixup_float32_as_int32.py b/iris_grib/tests/unit/save_rules/test_fixup_float32_as_int32.py deleted file mode 100644 index 6a857911..00000000 --- a/iris_grib/tests/unit/save_rules/test_fixup_float32_as_int32.py +++ /dev/null @@ -1,63 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for `iris_grib._save_rules.fixup_float32_as_int32`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -from iris_grib._save_rules import fixup_float32_as_int32 - - -class Test(tests.IrisGribTest): - def test_positive_zero(self): - result = fixup_float32_as_int32(0.0) - self.assertEqual(result, 0) - - def test_negative_zero(self): - result = fixup_float32_as_int32(-0.0) - self.assertEqual(result, 0) - - def test_high_bit_clear_1(self): - # Start with the float32 value for the bit pattern 0x00000001. - result = fixup_float32_as_int32(1.401298464324817e-45) - self.assertEqual(result, 1) - - def test_high_bit_clear_2(self): - # Start with the float32 value for the bit pattern 0x00000002. - result = fixup_float32_as_int32(2.802596928649634e-45) - self.assertEqual(result, 2) - - def test_high_bit_set_1(self): - # Start with the float32 value for the bit pattern 0x80000001. - result = fixup_float32_as_int32(-1.401298464324817e-45) - self.assertEqual(result, -1) - - def test_high_bit_set_2(self): - # Start with the float32 value for the bit pattern 0x80000002. - result = fixup_float32_as_int32(-2.802596928649634e-45) - self.assertEqual(result, -2) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_fixup_int32_as_uint32.py b/iris_grib/tests/unit/save_rules/test_fixup_int32_as_uint32.py deleted file mode 100644 index fd37daf5..00000000 --- a/iris_grib/tests/unit/save_rules/test_fixup_int32_as_uint32.py +++ /dev/null @@ -1,55 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for `iris_grib._save_rules.fixup_int32_as_uint32`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -from iris_grib._save_rules import fixup_int32_as_uint32 - - -class Test(tests.IrisGribTest): - def test_very_negative(self): - with self.assertRaises(ValueError): - fixup_int32_as_uint32(-0x80000000) - - def test_negative(self): - result = fixup_int32_as_uint32(-3) - self.assertEqual(result, 0x80000003) - - def test_zero(self): - result = fixup_int32_as_uint32(0) - self.assertEqual(result, 0) - - def test_positive(self): - result = fixup_int32_as_uint32(5) - self.assertEqual(result, 5) - - def test_very_positive(self): - with self.assertRaises(ValueError): - fixup_int32_as_uint32(0x80000000) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_grid_definition_section.py b/iris_grib/tests/unit/save_rules/test_grid_definition_section.py deleted file mode 100644 index a525f6b6..00000000 --- a/iris_grib/tests/unit/save_rules/test_grid_definition_section.py +++ /dev/null @@ -1,58 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for :meth:`iris_grib._save_rules.grid_definition_section`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -from iris.coord_systems import LambertConformal -from iris.exceptions import TranslationError - -from iris_grib._save_rules import grid_definition_section -from iris_grib.tests.unit.save_rules import GdtTestMixin - - -class Test(tests.IrisGribTest, GdtTestMixin): - def setUp(self): - GdtTestMixin.setUp(self) - - def test__fail_irregular_latlon(self): - test_cube = self._make_test_cube(x_points=(1, 2, 11, 12), - y_points=(4, 5, 6)) - with self.assertRaisesRegexp( - TranslationError, - 'irregular latlon grid .* not yet supported'): - grid_definition_section(test_cube, self.mock_grib) - - def test__fail_unsupported_coord_system(self): - cs = LambertConformal() - test_cube = self._make_test_cube(cs=cs) - with self.assertRaisesRegexp( - ValueError, - 'Grib saving is not supported for coordinate system:'): - grid_definition_section(test_cube, self.mock_grib) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_grid_definition_template_0.py b/iris_grib/tests/unit/save_rules/test_grid_definition_template_0.py deleted file mode 100644 index c1b74530..00000000 --- a/iris_grib/tests/unit/save_rules/test_grid_definition_template_0.py +++ /dev/null @@ -1,95 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for :meth:`iris_grib._save_rules.grid_definition_template_0`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import numpy as np - -from iris.coord_systems import GeogCS - -from iris_grib._save_rules import grid_definition_template_0 -from iris_grib.tests.unit.save_rules import GdtTestMixin - - -class Test(tests.IrisGribTest, GdtTestMixin): - def setUp(self): - GdtTestMixin.setUp(self) - - def test__template_number(self): - grid_definition_template_0(self.test_cube, self.mock_grib) - self._check_key('gridDefinitionTemplateNumber', 0) - - def test__shape_of_earth_spherical(self): - cs = GeogCS(semi_major_axis=1.23) - test_cube = self._make_test_cube(cs=cs) - grid_definition_template_0(test_cube, self.mock_grib) - self._check_key('shapeOfTheEarth', 1) - self._check_key('scaleFactorOfRadiusOfSphericalEarth', 0) - self._check_key('scaledValueOfRadiusOfSphericalEarth', 1.23) - - def test__shape_of_earth_flattened(self): - cs = GeogCS(semi_major_axis=1.456, - semi_minor_axis=1.123) - test_cube = self._make_test_cube(cs=cs) - grid_definition_template_0(test_cube, self.mock_grib) - self._check_key('shapeOfTheEarth', 7) - self._check_key('scaleFactorOfEarthMajorAxis', 0) - self._check_key('scaledValueOfEarthMajorAxis', 1.456) - self._check_key('scaleFactorOfEarthMinorAxis', 0) - self._check_key('scaledValueOfEarthMinorAxis', 1.123) - - def test__grid_shape(self): - test_cube = self._make_test_cube(x_points=np.arange(13), - y_points=np.arange(6)) - grid_definition_template_0(test_cube, self.mock_grib) - self._check_key('Ni', 13) - self._check_key('Nj', 6) - - def test__grid_points(self): - test_cube = self._make_test_cube( - x_points=[1, 3, 5, 7], y_points=[4, 9]) - grid_definition_template_0(test_cube, self.mock_grib) - self._check_key("longitudeOfFirstGridPoint", 1000000) - self._check_key("longitudeOfLastGridPoint", 7000000) - self._check_key("latitudeOfFirstGridPoint", 4000000) - self._check_key("latitudeOfLastGridPoint", 9000000) - self._check_key("DxInDegrees", 2.0) - self._check_key("DyInDegrees", 5.0) - - def test__scanmode(self): - grid_definition_template_0(self.test_cube, self.mock_grib) - self._check_key('iScansPositively', 1) - self._check_key('jScansPositively', 1) - - def test__scanmode_reverse(self): - test_cube = self._make_test_cube(x_points=np.arange(7, 0, -1)) - grid_definition_template_0(test_cube, self.mock_grib) - self._check_key('iScansPositively', 0) - self._check_key('jScansPositively', 1) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_grid_definition_template_1.py b/iris_grib/tests/unit/save_rules/test_grid_definition_template_1.py deleted file mode 100644 index 1e824652..00000000 --- a/iris_grib/tests/unit/save_rules/test_grid_definition_template_1.py +++ /dev/null @@ -1,131 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for :meth:`iris_grib._save_rules.grid_definition_template_1`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import numpy as np - -from iris.coord_systems import GeogCS, RotatedGeogCS -from iris.exceptions import TranslationError -from iris.fileformats.pp import EARTH_RADIUS as PP_DEFAULT_EARTH_RADIUS - -from iris_grib._save_rules import grid_definition_template_1 -from iris_grib.tests.unit.save_rules import GdtTestMixin - - -class Test(tests.IrisGribTest, GdtTestMixin): - def setUp(self): - self.default_ellipsoid = GeogCS(PP_DEFAULT_EARTH_RADIUS) - GdtTestMixin.setUp(self) - - def _default_coord_system(self): - # Define an alternate, rotated coordinate system to test. - cs = RotatedGeogCS(grid_north_pole_latitude=90.0, - grid_north_pole_longitude=0.0, - ellipsoid=self.default_ellipsoid) - return cs - - def test__template_number(self): - grid_definition_template_1(self.test_cube, self.mock_grib) - self._check_key('gridDefinitionTemplateNumber', 1) - - def test__shape_of_earth_spherical(self): - ellipsoid = GeogCS(1.23) - cs = RotatedGeogCS(grid_north_pole_latitude=90.0, - grid_north_pole_longitude=0.0, - ellipsoid=ellipsoid) - test_cube = self._make_test_cube(cs=cs) - grid_definition_template_1(test_cube, self.mock_grib) - self._check_key('shapeOfTheEarth', 1) - self._check_key('scaleFactorOfRadiusOfSphericalEarth', 0) - self._check_key('scaledValueOfRadiusOfSphericalEarth', 1.23) - - def test__shape_of_earth_flattened(self): - ellipsoid = GeogCS(semi_major_axis=1.456, semi_minor_axis=1.123) - cs = RotatedGeogCS(grid_north_pole_latitude=90.0, - grid_north_pole_longitude=0.0, - ellipsoid=ellipsoid) - test_cube = self._make_test_cube(cs=cs) - grid_definition_template_1(test_cube, self.mock_grib) - self._check_key('shapeOfTheEarth', 7) - self._check_key('scaleFactorOfEarthMajorAxis', 0) - self._check_key('scaledValueOfEarthMajorAxis', 1.456) - self._check_key('scaleFactorOfEarthMinorAxis', 0) - self._check_key('scaledValueOfEarthMinorAxis', 1.123) - - def test__grid_shape(self): - test_cube = self._make_test_cube(x_points=np.arange(13), - y_points=np.arange(6)) - grid_definition_template_1(test_cube, self.mock_grib) - self._check_key('Ni', 13) - self._check_key('Nj', 6) - - def test__grid_points(self): - test_cube = self._make_test_cube(x_points=[1, 3, 5, 7], - y_points=[4, 9]) - grid_definition_template_1(test_cube, self.mock_grib) - self._check_key("longitudeOfFirstGridPoint", 1000000) - self._check_key("longitudeOfLastGridPoint", 7000000) - self._check_key("latitudeOfFirstGridPoint", 4000000) - self._check_key("latitudeOfLastGridPoint", 9000000) - self._check_key("DxInDegrees", 2.0) - self._check_key("DyInDegrees", 5.0) - - def test__scanmode(self): - grid_definition_template_1(self.test_cube, self.mock_grib) - self._check_key('iScansPositively', 1) - self._check_key('jScansPositively', 1) - - def test__scanmode_reverse(self): - test_cube = self._make_test_cube(x_points=np.arange(7, 0, -1)) - grid_definition_template_1(test_cube, self.mock_grib) - self._check_key('iScansPositively', 0) - self._check_key('jScansPositively', 1) - - def test__rotated_pole(self): - cs = RotatedGeogCS(grid_north_pole_latitude=75.3, - grid_north_pole_longitude=54.321, - ellipsoid=self.default_ellipsoid) - test_cube = self._make_test_cube(cs=cs) - grid_definition_template_1(test_cube, self.mock_grib) - self._check_key("latitudeOfSouthernPole", -75300000) - self._check_key("longitudeOfSouthernPole", 234321000) - self._check_key("angleOfRotation", 0) - - def test__fail_rotated_pole_nonstandard_meridian(self): - cs = RotatedGeogCS(grid_north_pole_latitude=90.0, - grid_north_pole_longitude=0.0, - north_pole_grid_longitude=22.5, - ellipsoid=self.default_ellipsoid) - test_cube = self._make_test_cube(cs=cs) - with self.assertRaisesRegexp( - TranslationError, - 'not yet support .* rotated prime meridian.'): - grid_definition_template_1(test_cube, self.mock_grib) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_grid_definition_template_12.py b/iris_grib/tests/unit/save_rules/test_grid_definition_template_12.py deleted file mode 100644 index 0e191272..00000000 --- a/iris_grib/tests/unit/save_rules/test_grid_definition_template_12.py +++ /dev/null @@ -1,188 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for :meth:`iris_grib._save_rules.grid_definition_template_12`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import numpy as np - -import iris.coords -from iris.coord_systems import GeogCS, TransverseMercator -from iris.exceptions import TranslationError - -from iris_grib._save_rules import grid_definition_template_12 -from iris_grib.tests.unit.save_rules import GdtTestMixin - - -class FakeGribError(Exception): - pass - - -class Test(tests.IrisGribTest, GdtTestMixin): - def setUp(self): - self.default_ellipsoid = GeogCS(semi_major_axis=6377563.396, - semi_minor_axis=6356256.909) - self.test_cube = self._make_test_cube() - - GdtTestMixin.setUp(self) - - def _make_test_cube(self, cs=None, x_points=None, y_points=None): - # Create a cube with given properties, or minimal defaults. - if cs is None: - cs = self._default_coord_system() - if x_points is None: - x_points = self._default_x_points() - if y_points is None: - y_points = self._default_y_points() - - x_coord = iris.coords.DimCoord(x_points, 'projection_x_coordinate', - units='m', coord_system=cs) - y_coord = iris.coords.DimCoord(y_points, 'projection_y_coordinate', - units='m', coord_system=cs) - test_cube = iris.cube.Cube(np.zeros((len(y_points), len(x_points)))) - test_cube.add_dim_coord(y_coord, 0) - test_cube.add_dim_coord(x_coord, 1) - return test_cube - - def _default_coord_system(self): - # This defines an OSGB coord system. - cs = TransverseMercator(latitude_of_projection_origin=49.0, - longitude_of_central_meridian=-2.0, - false_easting=400000.0, - false_northing=-100000.0, - scale_factor_at_central_meridian=0.9996012717, - ellipsoid=self.default_ellipsoid) - return cs - - def test__template_number(self): - grid_definition_template_12(self.test_cube, self.mock_grib) - self._check_key('gridDefinitionTemplateNumber', 12) - - def test__shape_of_earth(self): - grid_definition_template_12(self.test_cube, self.mock_grib) - self._check_key('shapeOfTheEarth', 7) - self._check_key('scaleFactorOfEarthMajorAxis', 0) - self._check_key('scaledValueOfEarthMajorAxis', 6377563.396) - self._check_key('scaleFactorOfEarthMinorAxis', 0) - self._check_key('scaledValueOfEarthMinorAxis', 6356256.909) - - def test__grid_shape(self): - test_cube = self._make_test_cube(x_points=np.arange(13), - y_points=np.arange(6)) - grid_definition_template_12(test_cube, self.mock_grib) - self._check_key('Ni', 13) - self._check_key('Nj', 6) - - def test__grid_points_exact(self): - test_cube = self._make_test_cube(x_points=[1, 3, 5, 7], - y_points=[4, 9]) - grid_definition_template_12(test_cube, self.mock_grib) - self._check_key("X1", 100) - self._check_key("X2", 700) - self._check_key("Y1", 400) - self._check_key("Y2", 900) - self._check_key("Di", 200) - self._check_key("Dj", 500) - - def test__grid_points_approx(self): - test_cube = self._make_test_cube(x_points=[1.001, 3.003, 5.005, 7.007], - y_points=[4, 9]) - grid_definition_template_12(test_cube, self.mock_grib) - self._check_key("X1", 100) - self._check_key("X2", 701) - self._check_key("Y1", 400) - self._check_key("Y2", 900) - self._check_key("Di", 200) - self._check_key("Dj", 500) - - def test__negative_grid_points_gribapi_broken(self): - self.mock_gribapi.GribInternalError = FakeGribError - - # Force the test to run the signed int --> unsigned int workaround. - def set(grib, key, value): - if key in ["X1", "X2", "Y1", "Y2"] and value < 0: - raise self.mock_gribapi.GribInternalError() - grib.keys[key] = value - self.mock_gribapi.grib_set = set - - test_cube = self._make_test_cube(x_points=[-1, 1, 3, 5, 7], - y_points=[-4, 9]) - grid_definition_template_12(test_cube, self.mock_grib) - self._check_key("X1", 0x80000064) - self._check_key("X2", 700) - self._check_key("Y1", 0x80000190) - self._check_key("Y2", 900) - - def test__negative_grid_points_gribapi_fixed(self): - test_cube = self._make_test_cube(x_points=[-1, 1, 3, 5, 7], - y_points=[-4, 9]) - grid_definition_template_12(test_cube, self.mock_grib) - self._check_key("X1", -100) - self._check_key("X2", 700) - self._check_key("Y1", -400) - self._check_key("Y2", 900) - - def test__template_specifics(self): - grid_definition_template_12(self.test_cube, self.mock_grib) - self._check_key("latitudeOfReferencePoint", 49000000.0) - self._check_key("longitudeOfReferencePoint", -2000000.0) - self._check_key("XR", 40000000.0) - self._check_key("YR", -10000000.0) - - def test__scale_factor_gribapi_broken(self): - # GRIBAPI expects a signed int for scaleFactorAtReferencePoint - # but it should accept a float, so work around this. - # See https://software.ecmwf.int/issues/browse/SUP-1100 - - def get_native_type(grib, key): - assert key == "scaleFactorAtReferencePoint" - return int - self.mock_gribapi.grib_get_native_type = get_native_type - grid_definition_template_12(self.test_cube, self.mock_grib) - self._check_key("scaleFactorAtReferencePoint", 1065346526) - - def test__scale_factor_gribapi_fixed(self): - - def get_native_type(grib, key): - assert key == "scaleFactorAtReferencePoint" - return float - self.mock_gribapi.grib_get_native_type = get_native_type - grid_definition_template_12(self.test_cube, self.mock_grib) - self._check_key("scaleFactorAtReferencePoint", 0.9996012717) - - def test__scanmode(self): - grid_definition_template_12(self.test_cube, self.mock_grib) - self._check_key('iScansPositively', 1) - self._check_key('jScansPositively', 1) - - def test__scanmode_reverse(self): - test_cube = self._make_test_cube(x_points=np.arange(7, 0, -1)) - grid_definition_template_12(test_cube, self.mock_grib) - self._check_key('iScansPositively', 0) - self._check_key('jScansPositively', 1) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_identification.py b/iris_grib/tests/unit/save_rules/test_identification.py deleted file mode 100644 index 6903800f..00000000 --- a/iris_grib/tests/unit/save_rules/test_identification.py +++ /dev/null @@ -1,83 +0,0 @@ -# (C) British Crown Copyright 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -"""Unit tests for `iris_grib.grib_save_rules.identification`.""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import gribapi -import mock - -import iris -import iris.tests.stock as stock - -from iris_grib._save_rules import identification -from iris_grib.tests.unit import TestGribSimple - - -GRIB_API = 'iris_grib._save_rules.gribapi' - - -class Test(TestGribSimple): - @tests.skip_data - def test_no_realization(self): - cube = stock.simple_pp() - grib = mock.Mock() - mock_gribapi = mock.Mock(spec=gribapi) - with mock.patch(GRIB_API, mock_gribapi): - identification(cube, grib) - - mock_gribapi.assert_has_calls( - [mock.call.grib_set_long(grib, "typeOfProcessedData", 2)]) - - @tests.skip_data - def test_realization_0(self): - cube = stock.simple_pp() - realisation = iris.coords.AuxCoord((0,), standard_name='realization', - units='1') - cube.add_aux_coord(realisation) - - grib = mock.Mock() - mock_gribapi = mock.Mock(spec=gribapi) - with mock.patch(GRIB_API, mock_gribapi): - identification(cube, grib) - - mock_gribapi.assert_has_calls( - [mock.call.grib_set_long(grib, "typeOfProcessedData", 3)]) - - @tests.skip_data - def test_realization_n(self): - cube = stock.simple_pp() - realisation = iris.coords.AuxCoord((2,), standard_name='realization', - units='1') - cube.add_aux_coord(realisation) - - grib = mock.Mock() - mock_gribapi = mock.Mock(spec=gribapi) - with mock.patch(GRIB_API, mock_gribapi): - identification(cube, grib) - - mock_gribapi.assert_has_calls( - [mock.call.grib_set_long(grib, "typeOfProcessedData", 4)]) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_product_definition_template_10.py b/iris_grib/tests/unit/save_rules/test_product_definition_template_10.py deleted file mode 100644 index ad824af8..00000000 --- a/iris_grib/tests/unit/save_rules/test_product_definition_template_10.py +++ /dev/null @@ -1,74 +0,0 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for :func:`iris_grib._save_rules.product_definition_template_10` - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -from cf_units import Unit -import gribapi -import mock - -from iris.coords import DimCoord -import iris.tests.stock as stock - -from iris_grib._save_rules import product_definition_template_10 - - -class TestPercentileValueIdentifier(tests.IrisGribTest): - def setUp(self): - self.cube = stock.lat_lon_cube() - # Rename cube to avoid warning about unknown discipline/parameter. - self.cube.rename('y_wind') - time_coord = DimCoord( - 20, 'time', bounds=[0, 40], - units=Unit('days since epoch', calendar='julian')) - self.cube.add_aux_coord(time_coord) - - @mock.patch.object(gribapi, 'grib_set') - def test_percentile_value(self, mock_set): - cube = self.cube - percentile_coord = DimCoord(95, long_name='percentile_over_time') - cube.add_aux_coord(percentile_coord) - - product_definition_template_10(cube, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, - "productDefinitionTemplateNumber", 10) - mock_set.assert_any_call(mock.sentinel.grib, - "percentileValue", 95) - - @mock.patch.object(gribapi, 'grib_set') - def test_multiple_percentile_value(self, mock_set): - cube = self.cube - percentile_coord = DimCoord([5, 10, 15], - long_name='percentile_over_time') - cube.add_aux_coord(percentile_coord, 0) - err_msg = "A cube 'percentile_over_time' coordinate with one point "\ - "is required" - with self.assertRaisesRegexp(ValueError, err_msg): - product_definition_template_10(cube, mock.sentinel.grib) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_product_definition_template_11.py b/iris_grib/tests/unit/save_rules/test_product_definition_template_11.py deleted file mode 100644 index dc15de16..00000000 --- a/iris_grib/tests/unit/save_rules/test_product_definition_template_11.py +++ /dev/null @@ -1,68 +0,0 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for :func:`iris_grib._save_rules.product_definition_template_11` - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -from cf_units import Unit -import gribapi -import mock - -from iris.coords import CellMethod, DimCoord -import iris.tests.stock as stock - -from iris_grib._save_rules import product_definition_template_11 - - -class TestRealizationIdentifier(tests.IrisGribTest): - def setUp(self): - self.cube = stock.lat_lon_cube() - # Rename cube to avoid warning about unknown discipline/parameter. - self.cube.rename('air_temperature') - coord = DimCoord(23, 'time', bounds=[0, 100], - units=Unit('days since epoch', calendar='standard')) - self.cube.add_aux_coord(coord) - coord = DimCoord(4, 'realization', units='1') - self.cube.add_aux_coord(coord) - - @mock.patch.object(gribapi, 'grib_set') - def test_realization(self, mock_set): - cube = self.cube - cell_method = CellMethod(method='sum', coords=['time']) - cube.add_cell_method(cell_method) - - product_definition_template_11(cube, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, - "productDefinitionTemplateNumber", 11) - mock_set.assert_any_call(mock.sentinel.grib, - "perturbationNumber", 4) - mock_set.assert_any_call(mock.sentinel.grib, - "numberOfForecastsInEnsemble", 255) - mock_set.assert_any_call(mock.sentinel.grib, - "typeOfEnsembleForecast", 255) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_product_definition_template_40.py b/iris_grib/tests/unit/save_rules/test_product_definition_template_40.py deleted file mode 100644 index 2aafa2da..00000000 --- a/iris_grib/tests/unit/save_rules/test_product_definition_template_40.py +++ /dev/null @@ -1,61 +0,0 @@ -# (C) British Crown Copyright 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for :func:`iris_grib._save_rules.product_definition_template_40` - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -from cf_units import Unit -import gribapi -import mock - -from iris.coords import DimCoord -import iris.tests.stock as stock - -from iris_grib._save_rules import product_definition_template_40 - - -class TestChemicalConstituentIdentifier(tests.IrisGribTest): - def setUp(self): - self.cube = stock.lat_lon_cube() - # Rename cube to avoid warning about unknown discipline/parameter. - self.cube.rename('atmosphere_mole_content_of_ozone') - coord = DimCoord(24, 'time', - units=Unit('days since epoch', calendar='standard')) - self.cube.add_aux_coord(coord) - self.cube.attributes['WMO_constituent_type'] = 0 - - @mock.patch.object(gribapi, 'grib_set') - def test_constituent_type(self, mock_set): - cube = self.cube - - product_definition_template_40(cube, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, - 'productDefinitionTemplateNumber', 40) - mock_set.assert_any_call(mock.sentinel.grib, - 'constituentType', 0) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_product_definition_template_8.py b/iris_grib/tests/unit/save_rules/test_product_definition_template_8.py deleted file mode 100644 index eefad842..00000000 --- a/iris_grib/tests/unit/save_rules/test_product_definition_template_8.py +++ /dev/null @@ -1,60 +0,0 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for :func:`iris_grib._save_rules.product_definition_template_8` - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -from cf_units import Unit -import gribapi -import mock - -from iris.coords import CellMethod, DimCoord -import iris.tests.stock as stock - -from iris_grib._save_rules import product_definition_template_8 - - -class TestProductDefinitionIdentifier(tests.IrisGribTest): - def setUp(self): - self.cube = stock.lat_lon_cube() - # Rename cube to avoid warning about unknown discipline/parameter. - self.cube.rename('air_temperature') - coord = DimCoord(23, 'time', bounds=[0, 100], - units=Unit('days since epoch', calendar='standard')) - self.cube.add_aux_coord(coord) - - @mock.patch.object(gribapi, 'grib_set') - def test_product_definition(self, mock_set): - cube = self.cube - cell_method = CellMethod(method='sum', coords=['time']) - cube.add_cell_method(cell_method) - - product_definition_template_8(cube, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, - "productDefinitionTemplateNumber", 8) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_reference_time.py b/iris_grib/tests/unit/save_rules/test_reference_time.py deleted file mode 100644 index 9b777133..00000000 --- a/iris_grib/tests/unit/save_rules/test_reference_time.py +++ /dev/null @@ -1,62 +0,0 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -"""Unit tests for `iris_grib.grib_save_rules.reference_time`.""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import gribapi -import mock - -from iris_grib import load_cubes -from iris_grib._save_rules import reference_time - - -class Test(tests.IrisGribTest): - def _test(self, cube): - grib = mock.Mock() - mock_gribapi = mock.Mock(spec=gribapi) - with mock.patch('iris_grib._save_rules.gribapi', mock_gribapi): - reference_time(cube, grib) - - mock_gribapi.assert_has_calls( - [mock.call.grib_set_long(grib, "significanceOfReferenceTime", 1), - mock.call.grib_set_long(grib, "dataDate", '19980306'), - mock.call.grib_set_long(grib, "dataTime", '0300')]) - - @tests.skip_data - def test_forecast_period(self): - # The stock cube has a non-compliant forecast_period. - fname = tests.get_data_path(('GRIB', 'global_t', 'global.grib2')) - [cube] = load_cubes(fname) - self._test(cube) - - @tests.skip_data - def test_no_forecast_period(self): - # The stock cube has a non-compliant forecast_period. - fname = tests.get_data_path(('GRIB', 'global_t', 'global.grib2')) - [cube] = load_cubes(fname) - cube.remove_coord("forecast_period") - self._test(cube) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_set_fixed_surfaces.py b/iris_grib/tests/unit/save_rules/test_set_fixed_surfaces.py deleted file mode 100644 index 77544acf..00000000 --- a/iris_grib/tests/unit/save_rules/test_set_fixed_surfaces.py +++ /dev/null @@ -1,82 +0,0 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for :func:`iris_grib._save_rules.set_fixed_surfaces`. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import gribapi -import numpy as np - -import iris.cube -import iris.coords - -from iris_grib._save_rules import set_fixed_surfaces - - -class Test(tests.IrisGribTest): - def test_bounded_altitude_feet(self): - cube = iris.cube.Cube([0]) - cube.add_aux_coord(iris.coords.AuxCoord( - 1500.0, long_name='altitude', units='ft', - bounds=np.array([1000.0, 2000.0]))) - grib = gribapi.grib_new_from_samples("GRIB2") - set_fixed_surfaces(cube, grib) - self.assertEqual( - gribapi.grib_get_double(grib, "scaledValueOfFirstFixedSurface"), - 304.0) - self.assertEqual( - gribapi.grib_get_double(grib, "scaledValueOfSecondFixedSurface"), - 609.0) - self.assertEqual( - gribapi.grib_get_long(grib, "typeOfFirstFixedSurface"), - 102) - self.assertEqual( - gribapi.grib_get_long(grib, "typeOfSecondFixedSurface"), - 102) - - def test_theta_level(self): - cube = iris.cube.Cube([0]) - cube.add_aux_coord(iris.coords.AuxCoord( - 230.0, standard_name='air_potential_temperature', - units='K', attributes={'positive': 'up'}, - bounds=np.array([220.0, 240.0]))) - grib = gribapi.grib_new_from_samples("GRIB2") - set_fixed_surfaces(cube, grib) - self.assertEqual( - gribapi.grib_get_double(grib, "scaledValueOfFirstFixedSurface"), - 220.0) - self.assertEqual( - gribapi.grib_get_double(grib, "scaledValueOfSecondFixedSurface"), - 240.0) - self.assertEqual( - gribapi.grib_get_long(grib, "typeOfFirstFixedSurface"), - 107) - self.assertEqual( - gribapi.grib_get_long(grib, "typeOfSecondFixedSurface"), - 107) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_set_time_increment.py b/iris_grib/tests/unit/save_rules/test_set_time_increment.py deleted file mode 100644 index ea108cd0..00000000 --- a/iris_grib/tests/unit/save_rules/test_set_time_increment.py +++ /dev/null @@ -1,99 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for :func:`iris_grib._save_rules.set_time_increment` - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import gribapi -import mock - -from iris.coords import CellMethod - -from iris_grib._save_rules import set_time_increment - - -class Test(tests.IrisGribTest): - @mock.patch.object(gribapi, 'grib_set') - def test_no_intervals(self, mock_set): - cell_method = CellMethod('sum', 'time') - set_time_increment(cell_method, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, - 'indicatorOfUnitForTimeIncrement', 255) - mock_set.assert_any_call(mock.sentinel.grib, 'timeIncrement', 0) - - @mock.patch.object(gribapi, 'grib_set') - def test_area(self, mock_set): - cell_method = CellMethod('sum', 'area', '25 km') - set_time_increment(cell_method, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, - 'indicatorOfUnitForTimeIncrement', 255) - mock_set.assert_any_call(mock.sentinel.grib, 'timeIncrement', 0) - - @mock.patch.object(gribapi, 'grib_set') - def test_multiple_intervals(self, mock_set): - cell_method = CellMethod('sum', 'time', ('1 hour', '24 hour')) - set_time_increment(cell_method, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, - 'indicatorOfUnitForTimeIncrement', 255) - mock_set.assert_any_call(mock.sentinel.grib, 'timeIncrement', 0) - - @mock.patch.object(gribapi, 'grib_set') - def test_hr(self, mock_set): - cell_method = CellMethod('sum', 'time', '23 hr') - set_time_increment(cell_method, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, - 'indicatorOfUnitForTimeIncrement', 1) - mock_set.assert_any_call(mock.sentinel.grib, 'timeIncrement', 23) - - @mock.patch.object(gribapi, 'grib_set') - def test_hour(self, mock_set): - cell_method = CellMethod('sum', 'time', '24 hour') - set_time_increment(cell_method, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, - 'indicatorOfUnitForTimeIncrement', 1) - mock_set.assert_any_call(mock.sentinel.grib, 'timeIncrement', 24) - - @mock.patch.object(gribapi, 'grib_set') - def test_hours(self, mock_set): - cell_method = CellMethod('sum', 'time', '25 hours') - set_time_increment(cell_method, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, - 'indicatorOfUnitForTimeIncrement', 1) - mock_set.assert_any_call(mock.sentinel.grib, 'timeIncrement', 25) - - @mock.patch.object(gribapi, 'grib_set') - def test_fractional_hours(self, mock_set): - cell_method = CellMethod('sum', 'time', '25.9 hours') - with mock.patch('warnings.warn') as warn: - set_time_increment(cell_method, mock.sentinel.grib) - warn.assert_called_once_with('Truncating floating point timeIncrement ' - '25.9 to integer value 25') - mock_set.assert_any_call(mock.sentinel.grib, - 'indicatorOfUnitForTimeIncrement', 1) - mock_set.assert_any_call(mock.sentinel.grib, 'timeIncrement', 25) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_set_time_range.py b/iris_grib/tests/unit/save_rules/test_set_time_range.py deleted file mode 100644 index cbb7fdc4..00000000 --- a/iris_grib/tests/unit/save_rules/test_set_time_range.py +++ /dev/null @@ -1,108 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for :func:`iris_grib._save_rules.set_time_range` - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa -import six - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import warnings - -from cf_units import Unit -import gribapi -import mock - -from iris.coords import DimCoord - -from iris_grib._save_rules import set_time_range - - -class Test(tests.IrisGribTest): - def setUp(self): - self.coord = DimCoord(0, 'time', - units=Unit('hours since epoch', - calendar='standard')) - - def test_no_bounds(self): - with self.assertRaisesRegexp(ValueError, 'Expected time coordinate ' - 'with two bounds, got 0 bounds'): - set_time_range(self.coord, mock.sentinel.grib) - - def test_three_bounds(self): - self.coord.bounds = [0, 1, 2] - with self.assertRaisesRegexp(ValueError, 'Expected time coordinate ' - 'with two bounds, got 3 bounds'): - set_time_range(self.coord, mock.sentinel.grib) - - def test_non_scalar(self): - coord = DimCoord([0, 1], 'time', bounds=[[0, 1], [1, 2]], - units=Unit('hours since epoch', calendar='standard')) - with self.assertRaisesRegexp(ValueError, 'Expected length one time ' - 'coordinate, got 2 points'): - set_time_range(coord, mock.sentinel.grib) - - @mock.patch.object(gribapi, 'grib_set') - def test_hours(self, mock_set): - lower = 10 - upper = 20 - self.coord.bounds = [lower, upper] - set_time_range(self.coord, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, - 'indicatorOfUnitForTimeRange', 1) - mock_set.assert_any_call(mock.sentinel.grib, - 'lengthOfTimeRange', upper - lower) - - @mock.patch.object(gribapi, 'grib_set') - def test_days(self, mock_set): - lower = 4 - upper = 6 - self.coord.bounds = [lower, upper] - self.coord.units = Unit('days since epoch', calendar='standard') - set_time_range(self.coord, mock.sentinel.grib) - mock_set.assert_any_call(mock.sentinel.grib, - 'indicatorOfUnitForTimeRange', 1) - mock_set.assert_any_call(mock.sentinel.grib, - 'lengthOfTimeRange', - (upper - lower) * 24) - - @mock.patch.object(gribapi, 'grib_set') - def test_fractional_hours(self, mock_set_long): - lower = 10.0 - upper = 20.9 - self.coord.bounds = [lower, upper] - with warnings.catch_warnings(record=True) as warn: - warnings.simplefilter("always") - set_time_range(self.coord, mock.sentinel.grib) - self.assertEqual(len(warn), 1) - msg = 'Truncating floating point lengthOfTimeRange 10\.8?9+ ' \ - 'to integer value 10' - six.assertRegex(self, str(warn[0].message), msg) - mock_set_long.assert_any_call(mock.sentinel.grib, - 'indicatorOfUnitForTimeRange', 1) - mock_set_long.assert_any_call(mock.sentinel.grib, - 'lengthOfTimeRange', int(upper - lower)) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/test_GribWrapper.py b/iris_grib/tests/unit/test_GribWrapper.py deleted file mode 100644 index d8963327..00000000 --- a/iris_grib/tests/unit/test_GribWrapper.py +++ /dev/null @@ -1,178 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -""" -Unit tests for the `iris_grib.GribWrapper` class. - -""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -from biggus import NumpyArrayAdapter -import mock -import numpy as np - -from iris.exceptions import TranslationError - -from iris_grib import GribWrapper, GribDataProxy - - -_message_length = 1000 - - -def _mock_grib_get_long(grib_message, key): - lookup = dict(totalLength=_message_length, - numberOfValues=200, - jPointsAreConsecutive=0, - Ni=20, - Nj=10, - edition=1) - try: - result = lookup[key] - except KeyError: - msg = 'Mock grib_get_long unknown key: {!r}'.format(key) - raise AttributeError(msg) - return result - - -def _mock_grib_get_string(grib_message, key): - return grib_message - - -def _mock_grib_get_native_type(grib_message, key): - result = int - if key == 'gridType': - result = str - return result - - -class Test_edition(tests.IrisGribTest): - def setUp(self): - self.patch('iris_grib.GribWrapper._confirm_in_scope') - self.patch('iris_grib.GribWrapper._compute_extra_keys') - self.patch('gribapi.grib_get_long', _mock_grib_get_long) - self.patch('gribapi.grib_get_string', _mock_grib_get_string) - self.patch('gribapi.grib_get_native_type', _mock_grib_get_native_type) - self.tell = mock.Mock(side_effect=[_message_length]) - - def test_not_edition_1(self): - def func(grib_message, key): - return 2 - - emsg = "GRIB edition 2 is not supported by 'GribWrapper'" - with mock.patch('gribapi.grib_get_long', func): - with self.assertRaisesRegexp(TranslationError, emsg): - GribWrapper(None) - - def test_edition_1(self): - grib_message = 'regular_ll' - grib_fh = mock.Mock(tell=self.tell) - wrapper = GribWrapper(grib_message, grib_fh) - self.assertEqual(wrapper.grib_message, grib_message) - - -class Test_deferred(tests.IrisGribTest): - def setUp(self): - confirm_patch = mock.patch( - 'iris_grib.GribWrapper._confirm_in_scope') - compute_patch = mock.patch( - 'iris_grib.GribWrapper._compute_extra_keys') - long_patch = mock.patch('gribapi.grib_get_long', _mock_grib_get_long) - string_patch = mock.patch('gribapi.grib_get_string', - _mock_grib_get_string) - native_patch = mock.patch('gribapi.grib_get_native_type', - _mock_grib_get_native_type) - confirm_patch.start() - compute_patch.start() - long_patch.start() - string_patch.start() - native_patch.start() - self.addCleanup(confirm_patch.stop) - self.addCleanup(compute_patch.stop) - self.addCleanup(long_patch.stop) - self.addCleanup(string_patch.stop) - self.addCleanup(native_patch.stop) - - def test_regular_sequential(self): - tell_tale = np.arange(1, 5) * _message_length - grib_fh = mock.Mock(tell=mock.Mock(side_effect=tell_tale)) - grib_message = 'regular_ll' - for i, _ in enumerate(tell_tale): - gw = GribWrapper(grib_message, grib_fh) - self.assertIsInstance(gw._data, NumpyArrayAdapter) - proxy = gw._data.concrete - self.assertIsInstance(proxy, GribDataProxy) - self.assertEqual(proxy.shape, (10, 20)) - self.assertEqual(proxy.dtype, np.float) - self.assertIs(proxy.fill_value, np.nan) - self.assertEqual(proxy.path, grib_fh.name) - self.assertEqual(proxy.offset, _message_length * i) - - def test_regular_mixed(self): - tell_tale = np.arange(1, 5) * _message_length - expected = tell_tale - _message_length - grib_fh = mock.Mock(tell=mock.Mock(side_effect=tell_tale)) - grib_message = 'regular_ll' - for offset in expected: - gw = GribWrapper(grib_message, grib_fh) - self.assertIsInstance(gw._data, NumpyArrayAdapter) - proxy = gw._data.concrete - self.assertIsInstance(proxy, GribDataProxy) - self.assertEqual(proxy.shape, (10, 20)) - self.assertEqual(proxy.dtype, np.float) - self.assertIs(proxy.fill_value, np.nan) - self.assertEqual(proxy.path, grib_fh.name) - self.assertEqual(proxy.offset, offset) - - def test_reduced_sequential(self): - tell_tale = np.arange(1, 5) * _message_length - grib_fh = mock.Mock(tell=mock.Mock(side_effect=tell_tale)) - grib_message = 'reduced_gg' - for i, _ in enumerate(tell_tale): - gw = GribWrapper(grib_message, grib_fh) - self.assertIsInstance(gw._data, NumpyArrayAdapter) - proxy = gw._data.concrete - self.assertIsInstance(proxy, GribDataProxy) - self.assertEqual(proxy.shape, (200,)) - self.assertEqual(proxy.dtype, np.float) - self.assertIs(proxy.fill_value, np.nan) - self.assertEqual(proxy.path, grib_fh.name) - self.assertEqual(proxy.offset, _message_length * i) - - def test_reduced_mixed(self): - tell_tale = np.arange(1, 5) * _message_length - expected = tell_tale - _message_length - grib_fh = mock.Mock(tell=mock.Mock(side_effect=tell_tale)) - grib_message = 'reduced_gg' - for offset in expected: - gw = GribWrapper(grib_message, grib_fh) - self.assertIsInstance(gw._data, NumpyArrayAdapter) - proxy = gw._data.concrete - self.assertIsInstance(proxy, GribDataProxy) - self.assertEqual(proxy.shape, (200,)) - self.assertEqual(proxy.dtype, np.float) - self.assertIs(proxy.fill_value, np.nan) - self.assertEqual(proxy.path, grib_fh.name) - self.assertEqual(proxy.offset, offset) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/test__load_generate.py b/iris_grib/tests/unit/test__load_generate.py deleted file mode 100644 index 0e1d9146..00000000 --- a/iris_grib/tests/unit/test__load_generate.py +++ /dev/null @@ -1,80 +0,0 @@ -# (C) British Crown Copyright 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -"""Unit tests for the `iris_grib._load_generate` function.""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -import iris_grib.tests as tests - -import mock - -import iris -from iris.exceptions import TranslationError -from iris.fileformats.rules import Loader - -import iris_grib -from iris_grib import GribWrapper -from iris_grib import _load_generate -from iris_grib.message import GribMessage - - -class Test(tests.IrisGribTest): - def setUp(self): - self.fname = mock.sentinel.fname - self.message_id = mock.sentinel.message_id - self.grib_fh = mock.sentinel.grib_fh - - def _make_test_message(self, sections): - raw_message = mock.Mock(sections=sections, _message_id=self.message_id) - file_ref = mock.Mock(open_file=self.grib_fh) - return GribMessage(raw_message, None, file_ref=file_ref) - - def test_grib1(self): - sections = [{'editionNumber': 1}] - message = self._make_test_message(sections) - mfunc = 'iris_grib.GribMessage.messages_from_filename' - mclass = 'iris_grib.GribWrapper' - with mock.patch(mfunc, return_value=[message]) as mock_func: - with mock.patch(mclass, spec=GribWrapper) as mock_wrapper: - field = next(_load_generate(self.fname)) - mock_func.assert_called_once_with(self.fname) - self.assertIsInstance(field, GribWrapper) - mock_wrapper.assert_called_once_with(self.message_id, - grib_fh=self.grib_fh) - - def test_grib2(self): - sections = [{'editionNumber': 2}] - message = self._make_test_message(sections) - mfunc = 'iris_grib.GribMessage.messages_from_filename' - with mock.patch(mfunc, return_value=[message]) as mock_func: - field = next(_load_generate(self.fname)) - mock_func.assert_called_once_with(self.fname) - self.assertEqual(field, message) - - def test_grib_unknown(self): - sections = [{'editionNumber': 0}] - message = self._make_test_message(sections) - mfunc = 'iris_grib.GribMessage.messages_from_filename' - emsg = 'GRIB edition 0 is not supported' - with mock.patch(mfunc, return_value=[message]): - with self.assertRaisesRegexp(TranslationError, emsg): - next(_load_generate(self.fname)) - - -if __name__ == '__main__': - tests.main() diff --git a/iris_grib/tests/unit/test_load_cubes.py b/iris_grib/tests/unit/test_load_cubes.py deleted file mode 100644 index d2e426d6..00000000 --- a/iris_grib/tests/unit/test_load_cubes.py +++ /dev/null @@ -1,62 +0,0 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -"""Unit tests for the `iris_grib.load_cubes` function.""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - -import iris_grib.tests as tests - -import mock - -import iris -from iris.fileformats.rules import Loader - -import iris_grib -from iris_grib import load_cubes - - -class Test(tests.IrisGribTest): - def test(self): - generator = iris_grib._load_generate - converter = iris_grib._load_convert.convert - files = mock.sentinel.FILES - callback = mock.sentinel.CALLBACK - expected_result = mock.sentinel.RESULT - with mock.patch('iris.fileformats.rules.load_cubes') as rules_load: - rules_load.return_value = expected_result - result = load_cubes(files, callback) - kwargs = {} - loader = Loader(generator, kwargs, converter, None) - rules_load.assert_called_once_with(files, callback, loader) - self.assertIs(result, expected_result) - - -@tests.skip_data -class Test_load_cubes(tests.IrisGribTest): - - def test_reduced_raw(self): - # Loading a GRIB message defined on a reduced grid without - # interpolating to a regular grid. - gribfile = tests.get_data_path( - ("GRIB", "reduced", "reduced_gg.grib2")) - grib_generator = load_cubes(gribfile) - self.assertCML(next(grib_generator)) - - -if __name__ == "__main__": - tests.main() diff --git a/iris_grib/tests/unit/test_save_messages.py b/iris_grib/tests/unit/test_save_messages.py deleted file mode 100644 index eb666eed..00000000 --- a/iris_grib/tests/unit/test_save_messages.py +++ /dev/null @@ -1,70 +0,0 @@ -# (C) British Crown Copyright 2016, Met Office -# -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -"""Unit tests for the `iris_grib.save_messages` function.""" - -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa -import six - -# Import iris_grib.tests first so that some things can be initialised before -# importing anything else. -import iris_grib.tests as tests - -import gribapi -import mock -import numpy as np - -import iris_grib - - -class TestSaveMessages(tests.IrisGribTest): - def setUp(self): - # Create a test object to stand in for a real PPField. - self.grib_message = gribapi.grib_new_from_samples("GRIB2") - - def test_save(self): - if six.PY3: - open_func = 'builtins.open' - else: - open_func = '__builtin__.open' - m = mock.mock_open() - with mock.patch(open_func, m, create=True): - # sending a MagicMock object to gribapi raises an AssertionError - # as the gribapi code does a type check - # this is deemed acceptable within the scope of this unit test - with self.assertRaises((AssertionError, TypeError)): - iris_grib.save_messages([self.grib_message], 'foo.grib2') - self.assertTrue(mock.call('foo.grib2', 'wb') in m.mock_calls) - - def test_save_append(self): - if six.PY3: - open_func = 'builtins.open' - else: - open_func = '__builtin__.open' - m = mock.mock_open() - with mock.patch(open_func, m, create=True): - # sending a MagicMock object to gribapi raises an AssertionError - # as the gribapi code does a type check - # this is deemed acceptable within the scope of this unit test - with self.assertRaises((AssertionError, TypeError)): - iris_grib.save_messages([self.grib_message], 'foo.grib2', - append=True) - self.assertTrue(mock.call('foo.grib2', 'ab') in m.mock_calls) - - -if __name__ == "__main__": - tests.main() diff --git a/noxfile.py b/noxfile.py new file mode 100755 index 00000000..fc347727 --- /dev/null +++ b/noxfile.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Perform test automation with nox. + +For further details, see https://nox.thea.codes/en/stable/# + +""" + +from contextlib import contextmanager +import hashlib +import os +from pathlib import Path + +import nox +from nox.logger import logger + + +# Adopt sp-repo-review recommendations. +nox.needs_version = ">=2022.1.7" +nox.options.default_venv_backend = "conda" +# /// script +# dependencies = ["nox"] +# /// + +#: Default to reusing any pre-existing nox environments. +nox.options.reuse_existing_virtualenvs = True + +#: Name of the package to test. +PACKAGE = str("iris_grib") + +#: Cirrus-CI environment variable hook. +PY_VER = os.environ.get("PY_VER") +if PY_VER is None: + # Default to testing against 3 standard Python versions. + PY_VER = ["3.12", "3.13", "3.14"] +IRIS_SOURCE = os.environ.get("IRIS_SOURCE") +if IRIS_SOURCE is None: + # Default to testing against *both* "upstream-main" and "latest-release" Iris. + IRIS_SOURCE = ["source", "conda-forge"] + +#: Default cartopy cache directory. +CARTOPY_CACHE_DIR = os.environ.get("HOME") / Path(".local/share/cartopy") + + +def _cache_cartopy(session: nox.sessions.Session) -> None: + """ + Determine whether to cache the cartopy natural earth shapefiles. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + """ + if not CARTOPY_CACHE_DIR.is_dir(): + session.run( + "python", + "-c", + "import cartopy; cartopy.io.shapereader.natural_earth()", + ) + + +def _write_iris_config(session: nox.sessions.Session) -> None: + """ + Add test data dir and libudunits2.so to iris config. + + test data dir is set from session pos args. i.e. can be + configured by passing in on the command line: + nox --session tests -- --test-data-dir $TEST_DATA_DIR/test_data + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + """ + try: + test_data_dir = session.posargs[session.posargs.index("--test-data-dir") + 1] + except Exception: + test_data_dir = "" + + iris_config_file = os.path.join( + session.virtualenv.location, + "lib", + f"python{session.python}", + "site-packages", + "iris", + "etc", + "site.cfg", + ) + iris_config = f""" +[Resources] +test_data_dir = {test_data_dir} +[System] +udunits2_path = {os.path.join(session.virtualenv.location, "lib", "libudunits2.so")} +""" + + print("Iris config\n-----------") + print(iris_config) + + with open(iris_config_file, "w") as f: + f.write(iris_config) + + +def _session_lockfile(session: nox.sessions.Session) -> Path: + """ + Return the path of the session lockfile for the relevant python string + e.g ``py38``. + + """ + lockfile_name = f"py{session.python.replace('.', '')}-linux-64.lock" + return Path("requirements/locks") / lockfile_name + + +def _file_content(file_path: Path) -> str: + with file_path.open("r") as file: + return file.read() + + +def _session_cachefile(session: nox.sessions.Session) -> Path: + """Return the path of the session lockfile cache.""" + tmp_dir = Path(session.create_tmp()) + cache = tmp_dir / _session_lockfile(session).name + return cache + + +def _venv_populated(session: nox.sessions.Session) -> bool: + """ + Return True if the Conda venv has been created and the list of packages in + the lockfile installed. + + """ + return _session_cachefile(session).is_file() + + +def _venv_changed(session: nox.sessions.Session) -> bool: + """ + Return True if the installed session is different to that specified in the + lockfile. + + """ + result = False + if _venv_populated(session): + expected = _file_content(_session_lockfile(session)) + actual = _file_content(_session_cachefile(session)) + result = actual != expected + return result + + +def _install_and_cache_venv(session: nox.sessions.Session) -> None: + """ + Install and cache the nox session environment. + This consists of saving a hexdigest (sha256) of the associated + Conda lock file. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + """ + lockfile = _session_lockfile(session) + session.conda_install(f"--file={lockfile}") + + with open(lockfile, "rb") as fi: + hexdigest = hashlib.sha256(fi.read()).hexdigest() + with _session_cachefile(session).open("w") as cachefile: + cachefile.write(hexdigest) + + +@contextmanager +def prepare_venv( + session: nox.sessions.Session, iris_source: str = "conda-forge" +) -> None: + """ + Create and cache the nox session conda environment, and additionally + provide conda environment package details and info. + + Note that, iris-grib is installed into the environment using pip. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + iris_source: str + Determines where Iris was sourced from. Either 'conda-forge' (the + default), or 'source' which refers to the Iris main branch. + + Notes + ----- + See + - https://github.com/theacodes/nox/issues/346 + - https://github.com/theacodes/nox/issues/260 + + """ + venv_dir = session.virtualenv.location_name + + if not _venv_populated(session): + # Environment has been created but packages not yet installed. + # Populate the environment from the lockfile. + logger.debug(f"Populating conda env: {venv_dir}") + _install_and_cache_venv(session) + + elif _venv_changed(session): + # Destroy the environment and rebuild it. + logger.debug(f"Lockfile changed. Recreating conda env: {venv_dir}") + _reuse_original = session.virtualenv.reuse_existing + session.virtualenv.reuse_existing = False + session.virtualenv.create() + _install_and_cache_venv(session) + session.virtualenv.reuse_existing = _reuse_original + + logger.debug(f"Environment up to date: {venv_dir}") + + if iris_source == "source": + # get latest iris + iris_dir = f"{session.create_tmp()}/iris" + + if os.path.exists(iris_dir): + # cached. update by pulling from origin/master + session.run( + "git", + "-C", + iris_dir, + "pull", + "origin", + "main", + external=True, # use git from host environment + ) + else: + session.run( + "git", + "clone", + "https://github.com/scitools/iris.git", + iris_dir, + external=True, + ) + session.install(iris_dir, "--no-deps") + + _cache_cartopy(session) + _write_iris_config(session) + + # Install the iris-grib source in develop mode. + session.install("--no-deps", "--editable", ".") + + # Determine whether verbose diagnostics have been requested + # from the command line. + verbose = "-v" in session.posargs or "--verbose" in session.posargs + + if verbose: + session.run("conda", "info") + session.run("conda", "list", f"--prefix={venv_dir}") + session.run( + "conda", + "list", + f"--prefix={venv_dir}", + "--explicit", + ) + + +@nox.session(python=PY_VER, venv_backend="conda") +@nox.parametrize("iris_source", IRIS_SOURCE) +def tests(session: nox.sessions.Session, iris_source: str): + """ + Perform iris-grib tests against release and development versions of iris. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + iris_source: str + Either 'conda-forge' if using Iris from conda-forge, or 'source' if + installing Iris from the Iris' main branch. + + """ + prepare_venv(session, iris_source) + + session.run("python", "-m", "eccodes", "selfcheck") + + run_args = [ + "pytest", + "src/iris_grib/tests", # NB *don't* trigger doctests: they are done separately + ] + + if "-c" in session.posargs or "--coverage" in session.posargs: + run_args.extend(["--cov=iris_grib", "--cov-report=xml"]) + session.run(*run_args) + + +@nox.session(python=PY_VER, venv_backend="conda") +def doctest(session: nox.sessions.Session): + """ + Perform iris-grib doc-tests. + + Parameters + ---------- + session: object + A `nox.sessions.Session` object. + + """ + prepare_venv(session) + session.cd("docs") + session.run( + "make", + "clean", + "html", + external=True, + ) + session.run( + "make", + "doctest", + external=True, + ) + + +if __name__ == "__main__": + nox.main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..379d31b3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,453 @@ +# See https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html +# See https://github.com/SciTools/.github/wiki/Linting for common linter rules + +[build-system] +# Defined by PEP 518 +requires = [ + "setuptools>=77.0.3", + "setuptools_scm>=8.0", +] +# Defined by PEP 517 +build-backend = "setuptools.build_meta" + +[project] +authors = [ + {name = "Iris-grib Contributors", email = "scitools.pub@gmail.com"} +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Science/Research", + "Operating System :: MacOS", + "Operating System :: POSIX", + "Operating System :: POSIX :: Linux", + "Operating System :: Unix", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Atmospheric Science", + "Topic :: Scientific/Engineering :: Visualization", +] +description = "Functionality for converting between weather/climate datasets stored as GRIB files and SciTools-Iris Cubes" +dynamic = [ + "dependencies", + "optional-dependencies", + "readme", + "version", +] +keywords = [ + "iris", + "GRIB", + "data-analysis", + "earth-science", + "meteorology", +] +license = "BSD-3-Clause" +license-files = ["LICENSE"] +name = "iris-grib" +requires-python = ">=3.12" + +[project.urls] +Code = "https://github.com/SciTools/iris-grib" +Discussions = "https://github.com/SciTools/iris-grib/discussions" +Documentation = "https://iris-grib.readthedocs.io/en/stable/" +Issues = "https://github.com/SciTools/iris-grib/issues" + +[tool.setuptools.dynamic] +dependencies = {file = "requirements/pypi-core.txt"} +readme = {file = "README.md", content-type = "text/markdown"} + +[tool.setuptools.dynamic.optional-dependencies] +dev = {file = "requirements/pypi-optional-dev.txt"} +test = {file = "requirements/pypi-optional-test.txt"} + +[tool.setuptools.packages.find] +include = ["iris_grib*"] +where = ["src"] + +[tool.setuptools_scm] +write_to = "src/iris_grib/_version.py" +local_scheme = "dirty-tag" +version_scheme = "release-branch-semver" + +#------------------------------------------------------------------------------ + +[tool.coverage.run] +# See https://coverage.readthedocs.io/en/latest/config.html +branch = true +source = [ + "src/iris_grib", +] +omit = [ + "src/iris_grib/tests/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "if __name__ == .__main__.:" +] + +[tool.codespell] +# See https://github.com/codespell-project/codespell/tree/master?tab=readme-ov-file#using-a-config-file +ignore-words-list = "alpha-numeric,assertIn,degreee,discontiguities,lazyness,meaned,nin" +skip = "./CODE_OF_CONDUCT.md,_build,*.css,*.ipynb,*.js,*.html,*.svg,*.xml,.git,generated" + +[tool.check-manifest] +ignore = [ + "src/iris_grib/_version.py", +] + +[tool.mypy] +# See https://mypy.readthedocs.io/en/stable/config_file.html + +# Extra checks we have chosen to enable. +warn_unused_configs = true +warn_unreachable = true +enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] + +# Checks we have temporarily *disabled* +# NOTE: +# * Some of these settings are also disabled by "strict = false" +# * See "$ mypy --help" for which +# * Given these settings, "strict = true" generates no errors +# TODO: all of these should eventually be removed +disallow_any_generics = false # 5 errors +disallow_subclassing_any = false # 14 errors +disallow_untyped_calls = false # 8 errors +disallow_untyped_defs = false # 964 errors +disallow_incomplete_defs = false # 3 errors +check_untyped_defs = false # 100 errors +no_implicit_reexport = false # 134 errors +disallow_untyped_decorators = false # 1 error? + +exclude = [ + 'noxfile\.py', + 'docs/conf\.py' +] +strict = true # Default value, make true when introducing type hinting. + +[tool.pytest.ini_options] +# See https://docs.pytest.org/en/stable/reference/customize.html +addopts = [ + "--doctest-continue-on-failure", + "--doctest-modules", + "-ra", + "--strict-config", + "--strict-markers", + "-v", +] +doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS NUMBER" +# configure settings as recommended by repo-review: +log_cli = "True" +log_cli_level = "INFO" +minversion = "6.0" +testpaths = "src/iris_grib" +xfail_strict = "True" +filterwarnings = ["default"] +log_level = "INFO" + +[tool.repo-review] +ignore = [ + # Not possible to run on the hardware used by the majority of our developers. Might change in future! + "PC180", # Uses prettier. + # https://learn.scientific-python.org/development/guides/packaging-simple#PP006 + "PP006", # Uses dev dependency group +] + +[tool.ruff] +# Exclude the following, in addition to the standard set of exclusions. +# https://docs.astral.sh/ruff/settings/#exclude +line-length = 88 +src = [ + "src", + "docs", +] + +[tool.ruff.format] +docstring-code-format = true +preview = false + +[tool.ruff.lint] +ignore = [ + # The following ruff checks are intended to be *permanently* ignored (by design). + # NOTE: *temporary* ignores are listed separately, below. + + # flake8-commas (COM) + # https://docs.astral.sh/ruff/rules/#flake8-commas-com + "COM812", # Trailing comma missing. + "COM819", # Trailing comma prohibited. + + # flake8-implicit-str-concat (ISC) + # https://docs.astral.sh/ruff/rules/single-line-implicit-string-concatenation/ + # NOTE: This rule may cause conflicts when used with "ruff format". + "ISC001", # Implicitly concatenate string literals on one line. + + # ============================================== + # From here on, we list the *temporary* check disables + # TODO: eventually, remove all these by either + # 1. fixing all code + # 2. adding per-occurence ignores (#qa comments) where needed + # 3. promote the check to the "permanent disables" list above + + # flake8-builtins (A) + # https://docs.astral.sh/ruff/rules/#flake8-builtins-a + "A001", + "A002", + + # flake8-annotations (ANN) + "ANN001", + "ANN002", + "ANN003", + "ANN201", + "ANN202", + "ANN204", + "ANN205", + + # flake8-unused-arguments (ARG) + # https://docs.astral.sh/ruff/rules/#flake8-unused-arguments-arg + "ARG001", + "ARG002", + "ARG005", + + # flake8-bugbear (B) + # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b + "B007", + "B018", + "B028", + "B904", + + # flake8-blind-except (BLE) + # https://docs.astral.sh/ruff/rules/#flake8-blind-except-ble + "BLE001", + + # flake8-comprehensions (C4) + # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4 + "C408", + "C901", + + # pydocstyle (D) + # https://docs.astral.sh/ruff/rules/#pydocstyle-d + "D100", # Missing docstring in public module + "D101", # Missing docstring in public class + "D102", # Missing docstring in public method + "D103", # Missing docstring in public function + "D105", # Missing docstring in magic method + "D200", # One-line docstring should fit on one line + "D202", # No blank lines allowed after function docstring + "D205", # 1 blank line required between summary line and description + "D212", # Multi-line docstring summary should start at the second line + "D300", # Use triple double quotes """ + "D301", # Use r""" if any backslashes in a docstring + "D400", # First line should end with a period + "D401", # First line of docstring should be in imperative mood + "D406", # Section name should end with a newline + "D407", # Missing dashed underline after section + + # flake8-datetimez (DTZ) + # https://docs.astral.sh/ruff/rules/#flake8-datetimez-dtz + "DTZ001", + "DTZ006", + + # flake8-errmsg (EM) + "EM101", + "EM102", + "EM103", + + # eradicate (ERA) + # https://docs.astral.sh/ruff/rules/#eradicate-era + "ERA001", + + # flake8-boolean-trap (FBT) + # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt + "FBT001", + "FBT002", + "FBT003", + + # flake8-fixme (FIX) + # https://docs.astral.sh/ruff/rules/#flake8-fixme-fix + "FIX002", + "FIX003", + + # isort (I) + # https://docs.astral.sh/ruff/rules/#isort-i + "I001", # Import block is un-sorted or un-formatted + + # pep8-naming (N) + # https://docs.astral.sh/ruff/rules/#pep8-naming-n + "N801", + "N802", + "N803", + "N806", + "N999", + + # Numpy-specific rules (NPY) + # https://docs.astral.sh/ruff/rules/#numpy-specific-rules-npy + "NPY002", + + # Perflint (PERF) + # https://docs.astral.sh/ruff/rules/#perflint-perf + "PERF203", + "PERF401", + + # Refactor (R) + # https://docs.astral.sh/ruff/rules/#refactor-r + "PLR0402", + "PLR0912", + "PLR0913", + "PLR0915", + "PLR1714", + "PLR2004", + "PLR5501", + + # Warning (W) + # https://docs.astral.sh/ruff/rules/#warning-w + "PLW0602", + "PLW2901", + + # flake8-pytest-style (PT) + "PT009", + "PT027", + + # flake8-use-pathlib (PTH) + # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth + "PTH100", + "PTH107", + "PTH110", + "PTH111", + "PTH112", + "PTH113", + "PTH118", + "PTH120", + "PTH122", + "PTH123", + + # flake8-pyi (PYI) + # https://docs.astral.sh/ruff/rules/#flake8-pyi-pyi + "PYI024", + + # flake8-return (RET) + # https://docs.astral.sh/ruff/rules/#flake8-return-ret + "RET503", + "RET504", + "RET505", + "RET506", + + # flake8-raise (RSE) + # https://docs.astral.sh/ruff/rules/#flake8-raise-rse + "RSE102", + + # Ruff-specific rules (RUF) + # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf + "RUF005", + "RUF012", + "RUF015", + + # flake8-bandit (S) + # https://docs.astral.sh/ruff/rules/#flake8-bandit-s + "S101", + "S110", + "S603", + "S607", + + # flake8-simplify (SIM) + # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim + "SIM102", + "SIM108", + "SIM115", + "SIM117", + "SIM118", + + # flake8-self (SLF) + # https://docs.astral.sh/ruff/rules/#flake8-self-slf + "SLF001", + + # flake8-print (T20) + # https://docs.astral.sh/ruff/rules/#flake8-print-t20 + "T201", + + # flake8-todos (TD) + # https://docs.astral.sh/ruff/rules/#flake8-todos-td + "TD001", + "TD002", + "TD003", + "TD004", + "TD005", + "TD006", + + # tryceratops (TRY) + # https://docs.astral.sh/ruff/rules/#tryceratops-try + "TRY003", + "TRY004", + "TRY301", + + # pyupgrade (UP) + # https://docs.astral.sh/ruff/rules/#pyupgrade-up + "UP009", + "UP018", + "UP031", + "UP032", + ] +preview = false +select = [ + "ALL", + + # Note: the above "all" disables conflicting rules, if you want that + # rule it needs to be explicitly enabled below: + "D212", # conflicts with D213 : this one is our choice, so enforce it +] + +[tool.ruff.lint.isort] +force-sort-within-sections = true +known-first-party = ["iris_grib"] + +[tool.ruff.lint.pydocstyle] +convention = "numpy" + +[tool.numpydoc_validation] +checks = [ + "all", # Enable all numpydoc validation rules, apart from the following: + + # -> Docstring text (summary) should start in the line immediately + # after the opening quotes (not in the same line, or leaving a + # blank line in between) + "GL01", # Permit summary line on same line as docstring opening quotes. + + # -> Closing quotes should be placed in the line after the last text + # in the docstring (do not close the quotes in the same line as + # the text, or leave a blank line between the last text and the + # quotes) + "GL02", # Permit a blank line before docstring closing quotes. + + # -> Double line break found; please use only one blank line to + # separate sections or paragraphs, and do not leave blank lines + # at the end of docstrings + "GL03", # Ignoring. + + # -> See Also section not found + "SA01", # Not all docstrings require a "See Also" section. + + # -> No extended summary found + "ES01", # Not all docstrings require an "Extended Summary" section. + + # -> No examples section found + "EX01", # Not all docstrings require an "Examples" section. + + # -> No Yields section found + "YD01", # Not all docstrings require a "Yields" section. + + # Record temporarily ignored checks below; will be reviewed at a later date: + # TODO: work to remove these at a later date. + "GL08", # *975 The object does not have a docstring + "PR01", # *149 Parameters ... not documented + "RT01", # *9 No Returns section found +] +exclude = [ + '\.__eq__$', + '\.__ne__$', + '\.__repr__$', +] diff --git a/readthedocs.yml b/readthedocs.yml deleted file mode 100644 index 5d3b36c7..00000000 --- a/readthedocs.yml +++ /dev/null @@ -1,2 +0,0 @@ -conda: - file: environment.yml diff --git a/requirements/iris-grib.yml b/requirements/iris-grib.yml new file mode 120000 index 00000000..05372566 --- /dev/null +++ b/requirements/iris-grib.yml @@ -0,0 +1 @@ +py314.yml \ No newline at end of file diff --git a/requirements/locks/py312-linux-64.lock b/requirements/locks/py312-linux-64.lock new file mode 100644 index 00000000..5ca04ef4 --- /dev/null +++ b/requirements/locks/py312-linux-64.lock @@ -0,0 +1,225 @@ +# Generated by conda-lock. +# platform: linux-64 +# input_hash: fae040897887b28275b44381a11bec8be6c19b1c3e8767ab882fd39f1a971757 +@EXPLICIT +https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda#c3efd25ac4d74b1584d2f7a57195ddf1 +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda#489b8e97e666c93f68fdb35c3c9b957f +https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_3.conda#eb83f3f8cecc3e9bff9e250817fc69b6 +https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda#faac990cb7aedc7f3a2224f2c9b0c26c +https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda#d87ff7921124eccd67248aa483c23fec +https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda#a9f577daf3de00bca7c3c76c0ecbd1de +https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_3.conda#c2bd8055a2e2dce7a7f32cfd02101fb6 +https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda#4a13eeac0b5c8e5b8ab496e6c4ddd829 +https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda#18335a698559cdbcd86150a48bf54ba6 +https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda#57736f29cc2b0ec0b6c2952d3f101b6a +https://conda.anaconda.org/conda-forge/linux-64/aws-c-common-0.13.1-hb03c661_0.conda#f5f0be3aac62d771c3b0cad1d316d8e9 +https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda#d2ffd7602c02f2b316fd921d39876885 +https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda#920bb03579f15389b9e512095ad995b7 +https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda#b38117a3c920364aff79f870c984b4a3 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda#72c8fd1af66bd67bf580645b426513ed +https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda#6c77a605a7a689d17d4819c0f8ac9a00 +https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_0.conda#93764a5ca80616e9c10106cdaec92f74 +https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda#a360c33a5abe61c07959e449fa1453eb +https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda#331ee9b72b9dff570d56b1302c5ab37d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda#85072b0ad177c966294f129b7c04a2d5 +https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda#915f5995e94f60e9a4826e0b0920ee88 +https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.4.1-hb03c661_0.conda#6178c6f2fb254558238ef4e6c56fb782 +https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda#b88d90cad08e6bc8ad540cb310a761fb +https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda#d864d34357c3b65a4b731f78c0801dc4 +https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda#eba48a68a1a2b9d3c0d9511548db85db +https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.2-h0c1763c_0.conda#062b0ac602fb0adf250e3dfa86f221c4 +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda#5794b3bdc38177caf969dabd3af08549 +https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.1-h5347b49_0.conda#7d0a66598195ef00b6efc55aefc7453b +https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda#aea31d2e5b1091feca96fcfe945c3cf9 +https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda#fc21868a1a5aacc937e7a18747acb8a5 +https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda#da1b85b6a87e141f5140bb9924cecab0 +https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda#b3c17d95b5a10c6e64a21fa17573e70e +https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda#cffd3bdd58090148f4cfcd831f4b26ab +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda#b2895afaf55bf96a8c8282a2e47a5de0 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda#1dafce8548e38671bea82e3f5c6ce22f +https://conda.anaconda.org/conda-forge/linux-64/xxhash-0.8.3-hb47aa4a_0.conda#607e13a8caac17f9a664bcab5302ce06 +https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda#a77f85f77be52ff59391544bfe73390a +https://conda.anaconda.org/conda-forge/linux-64/aws-c-cal-0.9.14-h8e43964_1.conda#eb6e8fb306b4025bf9c68b6c16db4e19 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-compression-0.3.2-h16e98cb_1.conda#673828462eb87b76c178b582b6e19824 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-sdkutils-0.2.4-h16e98cb_5.conda#a81045d3ce07a74751541de2bad6fa49 +https://conda.anaconda.org/conda-forge/linux-64/aws-checksums-0.2.10-h16e98cb_1.conda#8d963dc4805936cc294348743201d68e +https://conda.anaconda.org/conda-forge/linux-64/geos-3.14.1-h480dda7_0.conda#4d4efd0645cd556fab54617c4ad477ef +https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda#c80d8a3b84358cb967fa81e7075fbc8a +https://conda.anaconda.org/conda-forge/linux-64/lerc-4.1.0-hdb68285_0.conda#a752488c68f2e7c456bcbd8f16eec275 +https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda#86f7414544ae606282352fa1e116b41f +https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda#366b40a69f0ad6072561c1d09301c886 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda#4ffbb341c8b616aa2494b6afb26a0c5f +https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda#c277e0a4d549b03ac1e9d6cbbe3d017b +https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda#172bf1cd1ff8629f2b1179945ed45055 +https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_0.conda#fb16b4b69e3f1dcfe79d80db8fd0c55d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_19.conda#42bf7eca1a951735fa06c0e3c0d5c8e6 +https://conda.anaconda.org/conda-forge/linux-64/libglu-9.0.3-h5888daf_1.conda#8422fcc9e5e172c91e99aef703b3ce65 +https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda#eecce068c7e4eddeb169591baac20ac4 +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda#e5ce228e579726c07255dbf90dc62101 +https://conda.anaconda.org/conda-forge/linux-64/libudunits2-2.2.28-h40f5838_3.conda#4bdace082e911a3e1f1f0b721bed5b56 +https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda#92ed62436b625154323d40d5f2f11dd7 +https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda#5aa797f8787fe7a17d1b0821485b5adc +https://conda.anaconda.org/conda-forge/linux-64/libzip-1.11.2-h6991a6a_0.conda#a7b27c075c9b7f459f1c022090697cba +https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda#9de5350a85c4a20c685259b889aa6393 +https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda#d7d95fc8287ea7bf33e0e7116d2b95ec +https://conda.anaconda.org/conda-forge/linux-64/s2n-1.7.3-hc5a330e_0.conda#f2bd09e21c5844a12e2f5eefcd075555 +https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda#98b6c9dc80eb87b2519b97bcf7e578dd +https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda#2aadb0d17215603a82a2a6b0afd9a4cb +https://conda.anaconda.org/conda-forge/linux-64/aws-c-io-0.26.3-h955231c_3.conda#a3581835895ca9b7782beb5a49746db7 +https://conda.anaconda.org/conda-forge/linux-64/blosc-1.21.6-he440d0b_1.conda#2c2fae981fd2afd00812c92ac47d023d +https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda#af39b9a8711d4a8d437b52c1d78eb6a1 +https://conda.anaconda.org/conda-forge/linux-64/hdf4-4.2.15-h2a13503_7.conda#bd77f8da987968ec3927990495dc22e4 +https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-ha1258a1_0.conda#fb53fb07ce46a575c5d004bbc96032c2 +https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_0.conda#e289f3d17880e44b633ba911d57a321b +https://conda.anaconda.org/conda-forge/linux-64/libmo_unpack-3.1.2-hf484d3e_1001.tar.bz2#95f32a6a5a666d33886ca5627239f03d +https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda#2a45e7f8af083626f009645a6481f12d +https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.33-pthreads_h94d23a6_0.conda#2d3278b721e40468295ca755c3b84070 +https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927 +https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda#e79d2c2f24b027aa8d5ab1b1ba3061e7 +https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda#7eccb41177e15cc672e1babe9056018e +https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda#353823361b1d27eb3960efb076dfcaf6 +https://conda.anaconda.org/conda-forge/linux-64/sqlite-3.53.2-hbc0de68_0.conda#38d9bf35a4cc83094a327811e548b660 +https://conda.anaconda.org/conda-forge/linux-64/udunits2-2.2.28-h40f5838_3.conda#6bb8deb138f87c9d48320ac21b87e7a1 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda#861fb6ccbc677bb9a9fb2468430b9c6a +https://conda.anaconda.org/conda-forge/noarch/alabaster-1.0.0-pyhd8ed1ab_1.conda#1fd9696649f65fd6611fcdb4ffec738a +https://conda.anaconda.org/conda-forge/noarch/argcomplete-3.6.3-pyhd8ed1ab_0.conda#8f37c8fb7116a18da04e52fa9e2c8df9 +https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda#c6b0543676ecb1fb2d7643941fe375f2 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-http-0.11.0-hcbcd92d_1.conda#333fa38d6dfe23cd68c67e20cb1726c9 +https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda#f1976ce927373500cc19d3c0b2c85177 +https://conda.anaconda.org/conda-forge/linux-64/backports.zstd-1.5.0-py312h90b7ffd_0.conda#b31dba71fe091e7201826e57e0f7b261 +https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda#8ccf913aaba749a5496c17629d859ed1 +https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py312hdb49522_1.conda#64088dffd7413a2dd557ce837b4cbbdb +https://conda.anaconda.org/conda-forge/noarch/cachetools-7.1.4-pyhd8ed1ab_0.conda#fbaa0445bcf8ba9e808c90cbc1235090 +https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda#9fefff2f745ea1cc2ef15211a20c054a +https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda#381bd45fb7aa032691f3063aff47e3a1 +https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda#a9167b9571f3baa9d448faa2139d1089 +https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda#554304a07e581a85891b15e39ea9f268 +https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda#61b8078a0905b12529abc622406cb62c +https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 +https://conda.anaconda.org/conda-forge/noarch/colorlog-6.10.1-pyh707e725_0.conda#33e96df3785bf61676ffee387e5a19e5 +https://conda.anaconda.org/conda-forge/noarch/cpython-3.12.13-py312hd8ed1ab_0.conda#f54c1ffb8ecedb85a8b7fcde3a187212 +https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda#4c2a8fef270f6c69591889b93f9f55c1 +https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.1-pyhcf101f3_1.conda#f8638ae693ee89e9069ed4f3b77a6be9 +https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_1.conda#24c1ca34138ee57de72a943237cde4cc +https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.1-pyhd8ed1ab_0.conda#917880ebad7632e8a52eada085b98ce9 +https://conda.anaconda.org/conda-forge/noarch/findlibs-0.1.2-pyhd8ed1ab_0.conda#fa9e9ec7bf26619a8edd3e11155f15d6 +https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_0.conda#8462b5322567212beeb025f3519fb3e2 +https://conda.anaconda.org/conda-forge/noarch/fsspec-2026.4.0-pyhd8ed1ab_0.conda#2c11aa96ea85ced419de710c1c3a78ff +https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda#0a802cb9888dd14eeefc611f05c40b6e +https://conda.anaconda.org/conda-forge/noarch/humanize-4.15.0-pyhd8ed1ab_0.conda#daddf757c3ecd6067b9af1df1f25d89e +https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda#8e6923fc12f1fe8f8c4e5c9f343256ac +https://conda.anaconda.org/conda-forge/noarch/idna-3.17-pyhcf101f3_0.conda#c75e517ebd7a5c5272fe111e8b162228 +https://conda.anaconda.org/conda-forge/noarch/imagesize-2.0.0-pyhd8ed1ab_0.conda#92617c2ba2847cca7a6ed813b6f4ab79 +https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 +https://conda.anaconda.org/conda-forge/noarch/iris-sample-data-2.5.2-pyhd8ed1ab_0.conda#895f6625dd8a246fece9279fcc12c1de +https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.5.0-py312h0a2e395_0.conda#cd74a9525dc74bbbf93cf8aa2fa9eb5b +https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_1.conda#8b3ce45e929cd8e8e5f4d18586b56d8b +https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-8_h4a7cf45_openblas.conda#00fc660ab1b2f5ca07e92b4900d10c79 +https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.20.0-hcf29cc6_0.conda#c3cc2864f82a944bc90a7beb4d3b0e88 +https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_3.conda#ec3c4350aa0261bf7f87b8ca15c8e80e +https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda#995d8c8bad2a3cc8db14675a153dec2b +https://conda.anaconda.org/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2#91e27ef3d05cc772ce627e51cff111c4 +https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py312h8a5da7c_1.conda#93a4752d42b12943a355b682ee43285b +https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 +https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 +https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda#2c5ef45db85d34799771629bd5860fd7 +https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e +https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda#9c5491066224083c41b6d5635ed7107b +https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0 +https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5 +https://conda.anaconda.org/conda-forge/noarch/pyshp-3.0.9-pyhd8ed1ab_0.conda#d260a4aec1cb8c91e0803afc6224494c +https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda#461219d1a5bd61342293efa2c0c90eac +https://conda.anaconda.org/conda-forge/linux-64/python-xxhash-3.7.0-py312h0d868a3_0.conda#5a2d6c150e20e46919f3810dfeb45e4b +https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py312h8a5da7c_1.conda#15878599a87992e44c059731771591cb +https://conda.anaconda.org/conda-forge/noarch/roman-numerals-4.1.0-pyhd8ed1ab_0.conda#0dc48b4b570931adc8641e55c6c17fe4 +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd +https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 +https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda#03fe290994c5e4ec17293cfb6bdce520 +https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-3.1.1-pyhd8ed1ab_0.conda#46b6abe31482f6bca064b965696ae807 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-pyhd8ed1ab_1.conda#fa839b5ff59e192f411ccc7dae6588bb +https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda#b5325cf06a000c5b14970462ff5e4d58 +https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.2.0-pyhd8ed1ab_0.conda#32e37e8fe9ef45c637ee38ad51377769 +https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda#c07a6153f8306e45794774cf9b13bd32 +https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d +https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.1-py312h4c3975b_0.conda#0b6c506ec1f272b685240e70a29261b8 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda#34e54f03dfea3e7a2dcf1453a85f1085 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda#ba231da7fccf9ea1e768caf5c7099b84 +https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda#ba3dcdc8584155c97c648ae9c044b7a3 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-auth-0.10.3-h3aafcba_1.conda#54f62a10a7dcc7e46b149e2d078299b5 +https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py312h460c074_1.conda#648ee28dcd4e07a1940a17da62eccd40 +https://conda.anaconda.org/conda-forge/linux-64/coverage-7.14.1-py312h8a5da7c_0.conda#6668e2af2de730400bdce9cf2ea132f9 +https://conda.anaconda.org/conda-forge/noarch/dependency-groups-1.3.1-pyhe01879c_0.conda#b36dcdb7288182d4ddadb60c489f3d62 +https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab +https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.63.0-py312h8a5da7c_0.conda#294fb524171e2a2748cb7fe708aba826 +https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda#b8993c19b0c32a2f7b66cbb58ca27069 +https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda#164fc43f0b53b6e3a7bc7dce5e4f1dc9 +https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda#ffc17e785d64e12fc311af9184221839 +https://conda.anaconda.org/conda-forge/noarch/importlib_resources-7.1.0-pyhd8ed1ab_0.conda#0ba6225c279baf7ea9473a62ea0ec9ae +https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda#04558c96691bed63104678757beb4f8d +https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-8_h0358290_openblas.conda#33a413f1095f8325e5c30fde3b0d2445 +https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_3.conda#f25206d7322c0e9648e8b83694d143ab +https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-8_h47877c9_openblas.conda#809be8ba8712c77bc7d44c2d99390dc4 +https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda#eb52d14a901e23c39e9e7b4a1a5c015f +https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda#0badf9c54e24cecfb0ad2f99d680c163 +https://conda.anaconda.org/conda-forge/linux-64/pillow-12.2.0-py312h50c33e8_0.conda#9e5609720e31213d4f39afe377f6217e +https://conda.anaconda.org/conda-forge/linux-64/proj-9.8.1-he0df7b0_0.conda#b23619e5e9009eaa070ead0342034027 +https://conda.anaconda.org/conda-forge/noarch/pyproject-api-1.10.1-pyhcf101f3_0.conda#8685d811ee857a0435d88f75c083646d +https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 +https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.0-pyhcf101f3_0.conda#fb1e5c138e2d933e59b3fa0462acc5e6 +https://conda.anaconda.org/conda-forge/noarch/python-gil-3.12.13-hd8ed1ab_0.conda#32780d6794b8056b78602103a04e90ef +https://conda.anaconda.org/conda-forge/noarch/roman-numerals-py-4.1.0-pyhd8ed1ab_0.conda#28687768633154993d521aecfa4a56ac +https://conda.anaconda.org/conda-forge/noarch/vcs_versioning-1.1.1-pyhd8ed1ab_0.conda#d3874a78e238013db5106c8e31f1ae58 +https://conda.anaconda.org/conda-forge/noarch/wheel-0.47.0-pyhd8ed1ab_0.conda#d0e3b2f0030cf4fca58bde71d246e94c +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.3-hb03c661_0.conda#adba2e334082bb218db806d4c12277c9 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda#665d152b9c6e78da404086088077c844 +https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda#aaa2a381ccc56eac91d63b6c1240312f +https://conda.anaconda.org/conda-forge/noarch/anyio-4.13.0-pyhcf101f3_0.conda#af2df4b9108808da3dc76710fe50eae2 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-s3-0.12.4-h00bea6e_0.conda#3a36b9efa77c11214308630ddb91496a +https://conda.anaconda.org/conda-forge/noarch/dask-core-2026.3.0-pyhc364b38_0.conda#809f4cde7c853f437becc43415a2ecdf +https://conda.anaconda.org/conda-forge/linux-64/freeglut-3.2.2-h215f996_4.conda#b39dccf5af984bcb68ee2aa0f3213ea6 +https://conda.anaconda.org/conda-forge/linux-64/numpy-2.4.6-py312h33ff503_0.conda#6e31d55ee1110fda83b4f4045f4d73ff +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh8b19718_0.conda#511fbc2c63d2c73650ad1755e4d357ba +https://conda.anaconda.org/conda-forge/linux-64/pyproj-3.7.2-py312hbc8341d_4.conda#9d5ea11aefbdf548a2d8c965c1b2ebad +https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda#6a991452eadf2771952f39d43615bb3e +https://conda.anaconda.org/conda-forge/noarch/setuptools-scm-10.0.5-pyhcf101f3_0.conda#730021037e2ce4c65f8eca077e35a821 +https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py312hd9148b4_0.conda#55fd03988b1b1bc6faabbfb5b481ecd7 +https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda#cbb88288f74dbe6ada1c6c7d0a97223e +https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.4.2-pyhcf101f3_0.conda#52434c636a05a06806d0978128d98c19 +https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda#02738ff9855946075cbd1b5274399a41 +https://conda.anaconda.org/conda-forge/linux-64/cftime-1.6.5-py312h4f23490_1.conda#84bf349fad55056ed326fc550671b65c +https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py312h0a2e395_4.conda#43c2bc96af3ae5ed9e8a10ded942aa50 +https://conda.anaconda.org/conda-forge/linux-64/hdf5-2.1.0-nompi_hee75f7e_106.conda#16f05278317ac89d2dbd2898c4f8e3d6 +https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda#4f14640d58e2cc0aa0819d9d8ba125bb +https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda#84a3233b709a289a4ddd7a2fd27dd988 +https://conda.anaconda.org/conda-forge/linux-64/jasper-4.2.9-h1588d4d_1.conda#115ecf05370670f93bc81a8c4f7fd57f +https://conda.anaconda.org/conda-forge/linux-64/mo_pack-0.3.1-py312h4f23490_2.conda#cec5bc5f7d374f8f8095f8e28e31f6cb +https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.1.0-pyhcf101f3_0.conda#67d1790eefa81ed305b89d8e314c7923 +https://conda.anaconda.org/conda-forge/noarch/pytest-mock-3.15.1-pyhd8ed1ab_0.conda#0511afbe860b1a653125d77c719ece53 +https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda#4a85203c1d80c1059086ae860836ffb9 +https://conda.anaconda.org/conda-forge/linux-64/scipy-1.17.1-py312h54fa4ab_1.conda#15995ecb2ef890778ba9a3750190f09d +https://conda.anaconda.org/conda-forge/noarch/setuptools_scm-10.0.5-h8c3c6de_0.conda#3c8ff4c1bc8528d16d151582cba9e3a9 +https://conda.anaconda.org/conda-forge/linux-64/shapely-2.1.2-py312h383787d_2.conda#69e400d3deca12ee7afd4b73a5596905 +https://conda.anaconda.org/conda-forge/noarch/tox-4.55.1-pyhcf101f3_0.conda#b36d5dae98d2d22f1f8b7273f5813849 +https://conda.anaconda.org/conda-forge/linux-64/cf-units-3.3.1-py312h4f23490_0.conda#6aef45ba3c0123547eb7b0f15852cac9 +https://conda.anaconda.org/conda-forge/noarch/codecov-2.1.13-pyhd8ed1ab_1.conda#d924fe46139596ebc3d4d424ec39ed51 +https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda#d6989ead454181f4f9bc987d3dc4e285 +https://conda.anaconda.org/conda-forge/linux-64/libnetcdf-4.10.0-nompi_h3c9b436_104.conda#6d38346d49e4638ec98649121d295d54 +https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.9-py312he3d6523_0.conda#7d499b5b6d150f133800dc3a582771c7 +https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda#7859736b4f8ebe6c8481bf48d91c9a1e +https://conda.anaconda.org/conda-forge/linux-64/cartopy-0.25.0-py312hf79963d_1.conda#6c913a686cb4060cbd7639a36fa144f0 +https://conda.anaconda.org/conda-forge/linux-64/eccodes-2.47.0-ha1d8304_0.conda#3ef42f61d9a66a0749cbe598b5acdeea +https://conda.anaconda.org/conda-forge/linux-64/netcdf4-1.7.4-nompi_py311h498b1eb_107.conda#7f5488cf61943e6221325b454c2c2e9c +https://conda.anaconda.org/conda-forge/noarch/pbs-installer-2026.6.2-pyhd8ed1ab_0.conda#ecdafb6a10df41e1249d707532ccefd9 +https://conda.anaconda.org/conda-forge/noarch/iris-3.15.0-pyha770c72_0.conda#bfe6588cbbd6e2e97e9439f95de02af8 +https://conda.anaconda.org/conda-forge/noarch/nox-2026.4.10-pyhc364b38_0.conda#10df85d0d06301e961c8b27805f66a56 +https://conda.anaconda.org/conda-forge/linux-64/python-eccodes-2.47.0-np2py312hfb8c2c5_0.conda#614309c6287d4edebffa95dc983241d3 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-applehelp-2.0.0-pyhd8ed1ab_1.conda#16e3f039c0aa6446513e94ab18a8784b +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-devhelp-2.0.0-pyhd8ed1ab_1.conda#910f28a05c178feba832f842155cbfff +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.1.0-pyhd8ed1ab_1.conda#e9fb3fe8a5b758b4aff187d434f94f03 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jquery-4.1-pyhd8ed1ab_1.conda#403185829255321ea427333f7773dd1f +https://conda.anaconda.org/conda-forge/noarch/sphinx_rtd_theme-3.1.0-pyha770c72_0.conda#cede6bc99a0253fa676f03cfdc666d57 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-2.0.0-pyhd8ed1ab_1.conda#00534ebcc0375929b45c3039b5ba7636 +https://conda.anaconda.org/conda-forge/noarch/sphinx-8.2.3-pyhd8ed1ab_0.conda#f7af826063ed569bb13f7207d6f949b0 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.10-pyhd8ed1ab_1.conda#3bc61f7161d28137797e038263c04c54 diff --git a/requirements/locks/py313-linux-64.lock b/requirements/locks/py313-linux-64.lock new file mode 100644 index 00000000..4ba5f04c --- /dev/null +++ b/requirements/locks/py313-linux-64.lock @@ -0,0 +1,222 @@ +# Generated by conda-lock. +# platform: linux-64 +# input_hash: aa9ab4ebce2999e04d2a5ba5730179ca016c08c045ebce12a021dd69105ec6c6 +@EXPLICIT +https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda#94305520c52a4aa3f6c2b1ff6008d9f8 +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda#489b8e97e666c93f68fdb35c3c9b957f +https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_3.conda#eb83f3f8cecc3e9bff9e250817fc69b6 +https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda#faac990cb7aedc7f3a2224f2c9b0c26c +https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda#d87ff7921124eccd67248aa483c23fec +https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda#a9f577daf3de00bca7c3c76c0ecbd1de +https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_3.conda#c2bd8055a2e2dce7a7f32cfd02101fb6 +https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda#4a13eeac0b5c8e5b8ab496e6c4ddd829 +https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda#18335a698559cdbcd86150a48bf54ba6 +https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda#57736f29cc2b0ec0b6c2952d3f101b6a +https://conda.anaconda.org/conda-forge/linux-64/aws-c-common-0.13.1-hb03c661_0.conda#f5f0be3aac62d771c3b0cad1d316d8e9 +https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda#d2ffd7602c02f2b316fd921d39876885 +https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda#920bb03579f15389b9e512095ad995b7 +https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda#b38117a3c920364aff79f870c984b4a3 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda#72c8fd1af66bd67bf580645b426513ed +https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda#6c77a605a7a689d17d4819c0f8ac9a00 +https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_0.conda#93764a5ca80616e9c10106cdaec92f74 +https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda#a360c33a5abe61c07959e449fa1453eb +https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda#331ee9b72b9dff570d56b1302c5ab37d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda#85072b0ad177c966294f129b7c04a2d5 +https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda#915f5995e94f60e9a4826e0b0920ee88 +https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.4.1-hb03c661_0.conda#6178c6f2fb254558238ef4e6c56fb782 +https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda#b88d90cad08e6bc8ad540cb310a761fb +https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda#2c21e66f50753a083cbe6b80f38268fa +https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda#eba48a68a1a2b9d3c0d9511548db85db +https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.2-h0c1763c_0.conda#062b0ac602fb0adf250e3dfa86f221c4 +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda#5794b3bdc38177caf969dabd3af08549 +https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.1-h5347b49_0.conda#7d0a66598195ef00b6efc55aefc7453b +https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda#aea31d2e5b1091feca96fcfe945c3cf9 +https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda#fc21868a1a5aacc937e7a18747acb8a5 +https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda#da1b85b6a87e141f5140bb9924cecab0 +https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda#b3c17d95b5a10c6e64a21fa17573e70e +https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda#cffd3bdd58090148f4cfcd831f4b26ab +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda#b2895afaf55bf96a8c8282a2e47a5de0 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda#1dafce8548e38671bea82e3f5c6ce22f +https://conda.anaconda.org/conda-forge/linux-64/xxhash-0.8.3-hb47aa4a_0.conda#607e13a8caac17f9a664bcab5302ce06 +https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda#a77f85f77be52ff59391544bfe73390a +https://conda.anaconda.org/conda-forge/linux-64/aws-c-cal-0.9.14-h8e43964_1.conda#eb6e8fb306b4025bf9c68b6c16db4e19 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-compression-0.3.2-h16e98cb_1.conda#673828462eb87b76c178b582b6e19824 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-sdkutils-0.2.4-h16e98cb_5.conda#a81045d3ce07a74751541de2bad6fa49 +https://conda.anaconda.org/conda-forge/linux-64/aws-checksums-0.2.10-h16e98cb_1.conda#8d963dc4805936cc294348743201d68e +https://conda.anaconda.org/conda-forge/linux-64/geos-3.14.1-h480dda7_0.conda#4d4efd0645cd556fab54617c4ad477ef +https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda#c80d8a3b84358cb967fa81e7075fbc8a +https://conda.anaconda.org/conda-forge/linux-64/lerc-4.1.0-hdb68285_0.conda#a752488c68f2e7c456bcbd8f16eec275 +https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda#86f7414544ae606282352fa1e116b41f +https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda#366b40a69f0ad6072561c1d09301c886 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda#4ffbb341c8b616aa2494b6afb26a0c5f +https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda#c277e0a4d549b03ac1e9d6cbbe3d017b +https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda#172bf1cd1ff8629f2b1179945ed45055 +https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_0.conda#fb16b4b69e3f1dcfe79d80db8fd0c55d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_19.conda#42bf7eca1a951735fa06c0e3c0d5c8e6 +https://conda.anaconda.org/conda-forge/linux-64/libglu-9.0.3-h5888daf_1.conda#8422fcc9e5e172c91e99aef703b3ce65 +https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda#eecce068c7e4eddeb169591baac20ac4 +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda#e5ce228e579726c07255dbf90dc62101 +https://conda.anaconda.org/conda-forge/linux-64/libudunits2-2.2.28-h40f5838_3.conda#4bdace082e911a3e1f1f0b721bed5b56 +https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda#92ed62436b625154323d40d5f2f11dd7 +https://conda.anaconda.org/conda-forge/linux-64/libzip-1.11.2-h6991a6a_0.conda#a7b27c075c9b7f459f1c022090697cba +https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda#9de5350a85c4a20c685259b889aa6393 +https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda#d7d95fc8287ea7bf33e0e7116d2b95ec +https://conda.anaconda.org/conda-forge/linux-64/s2n-1.7.3-hc5a330e_0.conda#f2bd09e21c5844a12e2f5eefcd075555 +https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda#98b6c9dc80eb87b2519b97bcf7e578dd +https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda#2aadb0d17215603a82a2a6b0afd9a4cb +https://conda.anaconda.org/conda-forge/linux-64/aws-c-io-0.26.3-h955231c_3.conda#a3581835895ca9b7782beb5a49746db7 +https://conda.anaconda.org/conda-forge/linux-64/blosc-1.21.6-he440d0b_1.conda#2c2fae981fd2afd00812c92ac47d023d +https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda#af39b9a8711d4a8d437b52c1d78eb6a1 +https://conda.anaconda.org/conda-forge/linux-64/hdf4-4.2.15-h2a13503_7.conda#bd77f8da987968ec3927990495dc22e4 +https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-ha1258a1_0.conda#fb53fb07ce46a575c5d004bbc96032c2 +https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_0.conda#e289f3d17880e44b633ba911d57a321b +https://conda.anaconda.org/conda-forge/linux-64/libmo_unpack-3.1.2-hf484d3e_1001.tar.bz2#95f32a6a5a666d33886ca5627239f03d +https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda#2a45e7f8af083626f009645a6481f12d +https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.33-pthreads_h94d23a6_0.conda#2d3278b721e40468295ca755c3b84070 +https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927 +https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda#e79d2c2f24b027aa8d5ab1b1ba3061e7 +https://conda.anaconda.org/conda-forge/linux-64/python-3.13.13-h6add32d_100_cp313.conda#05051be49267378d2fcd12931e319ac3 +https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda#353823361b1d27eb3960efb076dfcaf6 +https://conda.anaconda.org/conda-forge/linux-64/sqlite-3.53.2-hbc0de68_0.conda#38d9bf35a4cc83094a327811e548b660 +https://conda.anaconda.org/conda-forge/linux-64/udunits2-2.2.28-h40f5838_3.conda#6bb8deb138f87c9d48320ac21b87e7a1 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda#861fb6ccbc677bb9a9fb2468430b9c6a +https://conda.anaconda.org/conda-forge/noarch/alabaster-1.0.0-pyhd8ed1ab_1.conda#1fd9696649f65fd6611fcdb4ffec738a +https://conda.anaconda.org/conda-forge/noarch/argcomplete-3.6.3-pyhd8ed1ab_0.conda#8f37c8fb7116a18da04e52fa9e2c8df9 +https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda#c6b0543676ecb1fb2d7643941fe375f2 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-http-0.11.0-hcbcd92d_1.conda#333fa38d6dfe23cd68c67e20cb1726c9 +https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda#f1976ce927373500cc19d3c0b2c85177 +https://conda.anaconda.org/conda-forge/linux-64/backports.zstd-1.5.0-py313h18e8e13_0.conda#0de0c2c1f2677ea074bdda91de5a4c01 +https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda#8ccf913aaba749a5496c17629d859ed1 +https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py313hf159716_1.conda#6c4d3597cf43f3439a51b2b13e29a4ba +https://conda.anaconda.org/conda-forge/noarch/cachetools-7.1.4-pyhd8ed1ab_0.conda#fbaa0445bcf8ba9e808c90cbc1235090 +https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda#9fefff2f745ea1cc2ef15211a20c054a +https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda#381bd45fb7aa032691f3063aff47e3a1 +https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda#a9167b9571f3baa9d448faa2139d1089 +https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda#554304a07e581a85891b15e39ea9f268 +https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda#61b8078a0905b12529abc622406cb62c +https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 +https://conda.anaconda.org/conda-forge/noarch/colorlog-6.10.1-pyh707e725_0.conda#33e96df3785bf61676ffee387e5a19e5 +https://conda.anaconda.org/conda-forge/noarch/cpython-3.13.13-py313hd8ed1ab_100.conda#3a8a8b87e72f95b54689fb588e154ec9 +https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda#4c2a8fef270f6c69591889b93f9f55c1 +https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.1-pyhcf101f3_1.conda#f8638ae693ee89e9069ed4f3b77a6be9 +https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_1.conda#24c1ca34138ee57de72a943237cde4cc +https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.1-pyhd8ed1ab_0.conda#917880ebad7632e8a52eada085b98ce9 +https://conda.anaconda.org/conda-forge/noarch/findlibs-0.1.2-pyhd8ed1ab_0.conda#fa9e9ec7bf26619a8edd3e11155f15d6 +https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_0.conda#8462b5322567212beeb025f3519fb3e2 +https://conda.anaconda.org/conda-forge/noarch/fsspec-2026.4.0-pyhd8ed1ab_0.conda#2c11aa96ea85ced419de710c1c3a78ff +https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda#0a802cb9888dd14eeefc611f05c40b6e +https://conda.anaconda.org/conda-forge/noarch/humanize-4.15.0-pyhd8ed1ab_0.conda#daddf757c3ecd6067b9af1df1f25d89e +https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda#8e6923fc12f1fe8f8c4e5c9f343256ac +https://conda.anaconda.org/conda-forge/noarch/idna-3.17-pyhcf101f3_0.conda#c75e517ebd7a5c5272fe111e8b162228 +https://conda.anaconda.org/conda-forge/noarch/imagesize-2.0.0-pyhd8ed1ab_0.conda#92617c2ba2847cca7a6ed813b6f4ab79 +https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 +https://conda.anaconda.org/conda-forge/noarch/iris-sample-data-2.5.2-pyhd8ed1ab_0.conda#895f6625dd8a246fece9279fcc12c1de +https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.5.0-py313hc8edb43_0.conda#b81883b9dbf5069821c2fb09a8ba1407 +https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_1.conda#8b3ce45e929cd8e8e5f4d18586b56d8b +https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-8_h4a7cf45_openblas.conda#00fc660ab1b2f5ca07e92b4900d10c79 +https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.20.0-hcf29cc6_0.conda#c3cc2864f82a944bc90a7beb4d3b0e88 +https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_3.conda#ec3c4350aa0261bf7f87b8ca15c8e80e +https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda#995d8c8bad2a3cc8db14675a153dec2b +https://conda.anaconda.org/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2#91e27ef3d05cc772ce627e51cff111c4 +https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py313h3dea7bd_1.conda#aeb9b9da79fd0258b3db091d1fefcd71 +https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 +https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda#733cc07ed34162ac50b936464b163366 +https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda#2c5ef45db85d34799771629bd5860fd7 +https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e +https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda#9c5491066224083c41b6d5635ed7107b +https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0 +https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5 +https://conda.anaconda.org/conda-forge/noarch/pyshp-3.0.9-pyhd8ed1ab_0.conda#d260a4aec1cb8c91e0803afc6224494c +https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda#461219d1a5bd61342293efa2c0c90eac +https://conda.anaconda.org/conda-forge/linux-64/python-xxhash-3.7.0-py313heab5758_0.conda#3e14deae910b2de18153b78a372a4a0e +https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py313h3dea7bd_1.conda#f256753e840c3cd3766488c9437a8f8b +https://conda.anaconda.org/conda-forge/noarch/roman-numerals-4.1.0-pyhd8ed1ab_0.conda#0dc48b4b570931adc8641e55c6c17fe4 +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd +https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 +https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda#03fe290994c5e4ec17293cfb6bdce520 +https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-3.1.1-pyhd8ed1ab_0.conda#46b6abe31482f6bca064b965696ae807 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-pyhd8ed1ab_1.conda#fa839b5ff59e192f411ccc7dae6588bb +https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda#b5325cf06a000c5b14970462ff5e4d58 +https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.2.0-pyhd8ed1ab_0.conda#32e37e8fe9ef45c637ee38ad51377769 +https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda#c07a6153f8306e45794774cf9b13bd32 +https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda#34e54f03dfea3e7a2dcf1453a85f1085 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda#ba231da7fccf9ea1e768caf5c7099b84 +https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda#ba3dcdc8584155c97c648ae9c044b7a3 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-auth-0.10.3-h3aafcba_1.conda#54f62a10a7dcc7e46b149e2d078299b5 +https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py313hf46b229_1.conda#d0616e7935acab407d1543b28c446f6f +https://conda.anaconda.org/conda-forge/linux-64/coverage-7.14.1-py313h3dea7bd_0.conda#86bbb569988f077e5cb30acac5799599 +https://conda.anaconda.org/conda-forge/noarch/dependency-groups-1.3.1-pyhe01879c_0.conda#b36dcdb7288182d4ddadb60c489f3d62 +https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab +https://conda.anaconda.org/conda-forge/linux-64/fonttools-4.63.0-py313h3dea7bd_0.conda#ae83c999b4cfc4c171ce88b99c8b43cc +https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda#b8993c19b0c32a2f7b66cbb58ca27069 +https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda#164fc43f0b53b6e3a7bc7dce5e4f1dc9 +https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda#ffc17e785d64e12fc311af9184221839 +https://conda.anaconda.org/conda-forge/noarch/importlib_resources-7.1.0-pyhd8ed1ab_0.conda#0ba6225c279baf7ea9473a62ea0ec9ae +https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda#04558c96691bed63104678757beb4f8d +https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-8_h0358290_openblas.conda#33a413f1095f8325e5c30fde3b0d2445 +https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_3.conda#f25206d7322c0e9648e8b83694d143ab +https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-8_h47877c9_openblas.conda#809be8ba8712c77bc7d44c2d99390dc4 +https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda#eb52d14a901e23c39e9e7b4a1a5c015f +https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda#0badf9c54e24cecfb0ad2f99d680c163 +https://conda.anaconda.org/conda-forge/linux-64/pillow-12.2.0-py313h80991f8_0.conda#7245f1bbf52ed5e3818d742f51b44a7d +https://conda.anaconda.org/conda-forge/linux-64/proj-9.8.1-he0df7b0_0.conda#b23619e5e9009eaa070ead0342034027 +https://conda.anaconda.org/conda-forge/noarch/pyproject-api-1.10.1-pyhcf101f3_0.conda#8685d811ee857a0435d88f75c083646d +https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 +https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.0-pyhcf101f3_0.conda#fb1e5c138e2d933e59b3fa0462acc5e6 +https://conda.anaconda.org/conda-forge/noarch/python-gil-3.13.13-h4df99d1_100.conda#fd00e4b24ea88093c93f5c9bad27b52f +https://conda.anaconda.org/conda-forge/noarch/roman-numerals-py-4.1.0-pyhd8ed1ab_0.conda#28687768633154993d521aecfa4a56ac +https://conda.anaconda.org/conda-forge/noarch/vcs_versioning-1.1.1-pyhd8ed1ab_0.conda#d3874a78e238013db5106c8e31f1ae58 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.3-hb03c661_0.conda#adba2e334082bb218db806d4c12277c9 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda#665d152b9c6e78da404086088077c844 +https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda#aaa2a381ccc56eac91d63b6c1240312f +https://conda.anaconda.org/conda-forge/noarch/anyio-4.13.0-pyhcf101f3_0.conda#af2df4b9108808da3dc76710fe50eae2 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-s3-0.12.4-h00bea6e_0.conda#3a36b9efa77c11214308630ddb91496a +https://conda.anaconda.org/conda-forge/noarch/dask-core-2026.3.0-pyhc364b38_0.conda#809f4cde7c853f437becc43415a2ecdf +https://conda.anaconda.org/conda-forge/linux-64/freeglut-3.2.2-h215f996_4.conda#b39dccf5af984bcb68ee2aa0f3213ea6 +https://conda.anaconda.org/conda-forge/linux-64/numpy-2.4.6-py313hf6604e3_0.conda#a5fdb80595ec7912e6b1634b2abd4b50 +https://conda.anaconda.org/conda-forge/linux-64/pyproj-3.7.2-py313he648cc1_4.conda#fe69af9ea9bb222c067bca5888e5aabe +https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda#6a991452eadf2771952f39d43615bb3e +https://conda.anaconda.org/conda-forge/noarch/setuptools-scm-10.0.5-pyhcf101f3_0.conda#730021037e2ce4c65f8eca077e35a821 +https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py313h7037e92_0.conda#cb423e0853b3dde2b3738db4dedf5ba2 +https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda#cbb88288f74dbe6ada1c6c7d0a97223e +https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.4.2-pyhcf101f3_0.conda#52434c636a05a06806d0978128d98c19 +https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py313h54dd161_1.conda#710d4663806d0f72b2fb414e936223b5 +https://conda.anaconda.org/conda-forge/linux-64/cftime-1.6.5-py313h29aa505_1.conda#c63d5f9d63fe2f48b0ad75005fcae7ba +https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py313hc8edb43_4.conda#33639459bc29437315d4bff9ed5bc7a7 +https://conda.anaconda.org/conda-forge/linux-64/hdf5-2.1.0-nompi_hee75f7e_106.conda#16f05278317ac89d2dbd2898c4f8e3d6 +https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda#4f14640d58e2cc0aa0819d9d8ba125bb +https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda#84a3233b709a289a4ddd7a2fd27dd988 +https://conda.anaconda.org/conda-forge/linux-64/jasper-4.2.9-h1588d4d_1.conda#115ecf05370670f93bc81a8c4f7fd57f +https://conda.anaconda.org/conda-forge/linux-64/mo_pack-0.3.1-py313h29aa505_2.conda#ad53894d278895bf15c8fc324727d224 +https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.1.0-pyhcf101f3_0.conda#67d1790eefa81ed305b89d8e314c7923 +https://conda.anaconda.org/conda-forge/noarch/pytest-mock-3.15.1-pyhd8ed1ab_0.conda#0511afbe860b1a653125d77c719ece53 +https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda#4a85203c1d80c1059086ae860836ffb9 +https://conda.anaconda.org/conda-forge/linux-64/scipy-1.17.1-py313h4b8bb8b_1.conda#4b098461b0b5edff1a9359c25e675cfd +https://conda.anaconda.org/conda-forge/noarch/setuptools_scm-10.0.5-h8c3c6de_0.conda#3c8ff4c1bc8528d16d151582cba9e3a9 +https://conda.anaconda.org/conda-forge/linux-64/shapely-2.1.2-py313had47c43_2.conda#6e550dd748e9ac9b2925411684e076a1 +https://conda.anaconda.org/conda-forge/noarch/tox-4.55.1-pyhcf101f3_0.conda#b36d5dae98d2d22f1f8b7273f5813849 +https://conda.anaconda.org/conda-forge/linux-64/cf-units-3.3.1-py313h29aa505_0.conda#3942b6a86fe92d0888b3373f2c1e1676 +https://conda.anaconda.org/conda-forge/noarch/codecov-2.1.13-pyhd8ed1ab_1.conda#d924fe46139596ebc3d4d424ec39ed51 +https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda#d6989ead454181f4f9bc987d3dc4e285 +https://conda.anaconda.org/conda-forge/linux-64/libnetcdf-4.10.0-nompi_h3c9b436_104.conda#6d38346d49e4638ec98649121d295d54 +https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.9-py313h683a580_0.conda#4265d85b1d706caba7ac1d73b5f43dee +https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda#7859736b4f8ebe6c8481bf48d91c9a1e +https://conda.anaconda.org/conda-forge/linux-64/cartopy-0.25.0-py313h08cd8bf_1.conda#a0d8dc5c90850d9f1a79f69c98aef0ff +https://conda.anaconda.org/conda-forge/linux-64/eccodes-2.47.0-ha1d8304_0.conda#3ef42f61d9a66a0749cbe598b5acdeea +https://conda.anaconda.org/conda-forge/linux-64/netcdf4-1.7.4-nompi_py311h498b1eb_107.conda#7f5488cf61943e6221325b454c2c2e9c +https://conda.anaconda.org/conda-forge/noarch/pbs-installer-2026.6.2-pyhd8ed1ab_0.conda#ecdafb6a10df41e1249d707532ccefd9 +https://conda.anaconda.org/conda-forge/noarch/iris-3.15.0-pyha770c72_0.conda#bfe6588cbbd6e2e97e9439f95de02af8 +https://conda.anaconda.org/conda-forge/noarch/nox-2026.4.10-pyhc364b38_0.conda#10df85d0d06301e961c8b27805f66a56 +https://conda.anaconda.org/conda-forge/linux-64/python-eccodes-2.47.0-np2py313hc18bace_0.conda#5ac58ad77ebc56ba99f965562315a69c +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-applehelp-2.0.0-pyhd8ed1ab_1.conda#16e3f039c0aa6446513e94ab18a8784b +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-devhelp-2.0.0-pyhd8ed1ab_1.conda#910f28a05c178feba832f842155cbfff +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.1.0-pyhd8ed1ab_1.conda#e9fb3fe8a5b758b4aff187d434f94f03 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jquery-4.1-pyhd8ed1ab_1.conda#403185829255321ea427333f7773dd1f +https://conda.anaconda.org/conda-forge/noarch/sphinx_rtd_theme-3.1.0-pyha770c72_0.conda#cede6bc99a0253fa676f03cfdc666d57 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-2.0.0-pyhd8ed1ab_1.conda#00534ebcc0375929b45c3039b5ba7636 +https://conda.anaconda.org/conda-forge/noarch/sphinx-8.2.3-pyhd8ed1ab_0.conda#f7af826063ed569bb13f7207d6f949b0 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.10-pyhd8ed1ab_1.conda#3bc61f7161d28137797e038263c04c54 diff --git a/requirements/locks/py314-linux-64.lock b/requirements/locks/py314-linux-64.lock new file mode 100644 index 00000000..bba295d3 --- /dev/null +++ b/requirements/locks/py314-linux-64.lock @@ -0,0 +1,223 @@ +# Generated by conda-lock. +# platform: linux-64 +# input_hash: b2648fecfc3f0d0f5e69dd4931944bedc4983c156f236c1a82e0f40f012a7ef8 +@EXPLICIT +https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda#0539938c55b6b1a59b560e843ad864a4 +https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda#ad659d0a2b3e47e38d829aa8cad2d610 +https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.5.20-hbd8a1cb_0.conda#489b8e97e666c93f68fdb35c3c9b957f +https://conda.anaconda.org/conda-forge/linux-64/libglvnd-1.7.0-ha4b6fd6_3.conda#eb83f3f8cecc3e9bff9e250817fc69b6 +https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_19.conda#faac990cb7aedc7f3a2224f2c9b0c26c +https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda#d87ff7921124eccd67248aa483c23fec +https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda#a9f577daf3de00bca7c3c76c0ecbd1de +https://conda.anaconda.org/conda-forge/linux-64/libopengl-1.7.0-ha4b6fd6_3.conda#c2bd8055a2e2dce7a7f32cfd02101fb6 +https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda#4a13eeac0b5c8e5b8ab496e6c4ddd829 +https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_102.conda#18335a698559cdbcd86150a48bf54ba6 +https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_19.conda#57736f29cc2b0ec0b6c2952d3f101b6a +https://conda.anaconda.org/conda-forge/linux-64/aws-c-common-0.13.1-hb03c661_0.conda#f5f0be3aac62d771c3b0cad1d316d8e9 +https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda#d2ffd7602c02f2b316fd921d39876885 +https://conda.anaconda.org/conda-forge/linux-64/c-ares-1.34.6-hb03c661_0.conda#920bb03579f15389b9e512095ad995b7 +https://conda.anaconda.org/conda-forge/linux-64/keyutils-1.6.3-hb9d3cd8_0.conda#b38117a3c920364aff79f870c984b4a3 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-hb03c661_1.conda#72c8fd1af66bd67bf580645b426513ed +https://conda.anaconda.org/conda-forge/linux-64/libdeflate-1.25-h17f619e_0.conda#6c77a605a7a689d17d4819c0f8ac9a00 +https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.1-hecca717_0.conda#93764a5ca80616e9c10106cdaec92f74 +https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda#a360c33a5abe61c07959e449fa1453eb +https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_19.conda#331ee9b72b9dff570d56b1302c5ab37d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-15.2.0-h68bc16d_19.conda#85072b0ad177c966294f129b7c04a2d5 +https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.18-h3b78370_2.conda#915f5995e94f60e9a4826e0b0920ee88 +https://conda.anaconda.org/conda-forge/linux-64/libjpeg-turbo-3.1.4.1-hb03c661_0.conda#6178c6f2fb254558238ef4e6c56fb782 +https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_0.conda#b88d90cad08e6bc8ad540cb310a761fb +https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_1.conda#2c21e66f50753a083cbe6b80f38268fa +https://conda.anaconda.org/conda-forge/linux-64/libpng-1.6.58-h421ea60_0.conda#eba48a68a1a2b9d3c0d9511548db85db +https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.53.2-h0c1763c_0.conda#062b0ac602fb0adf250e3dfa86f221c4 +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_19.conda#5794b3bdc38177caf969dabd3af08549 +https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.1-h5347b49_0.conda#7d0a66598195ef00b6efc55aefc7453b +https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda#aea31d2e5b1091feca96fcfe945c3cf9 +https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_0.conda#fc21868a1a5aacc937e7a18747acb8a5 +https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.2-h35e630c_0.conda#da1b85b6a87e141f5140bb9924cecab0 +https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda#b3c17d95b5a10c6e64a21fa17573e70e +https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda#cffd3bdd58090148f4cfcd831f4b26ab +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxau-1.0.12-hb03c661_1.conda#b2895afaf55bf96a8c8282a2e47a5de0 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxdmcp-1.1.5-hb03c661_1.conda#1dafce8548e38671bea82e3f5c6ce22f +https://conda.anaconda.org/conda-forge/linux-64/xxhash-0.8.3-hb47aa4a_0.conda#607e13a8caac17f9a664bcab5302ce06 +https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h280c20c_3.conda#a77f85f77be52ff59391544bfe73390a +https://conda.anaconda.org/conda-forge/linux-64/aws-c-cal-0.9.14-h8e43964_1.conda#eb6e8fb306b4025bf9c68b6c16db4e19 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-compression-0.3.2-h16e98cb_1.conda#673828462eb87b76c178b582b6e19824 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-sdkutils-0.2.4-h16e98cb_5.conda#a81045d3ce07a74751541de2bad6fa49 +https://conda.anaconda.org/conda-forge/linux-64/aws-checksums-0.2.10-h16e98cb_1.conda#8d963dc4805936cc294348743201d68e +https://conda.anaconda.org/conda-forge/linux-64/geos-3.14.1-h480dda7_0.conda#4d4efd0645cd556fab54617c4ad477ef +https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda#c80d8a3b84358cb967fa81e7075fbc8a +https://conda.anaconda.org/conda-forge/linux-64/lerc-4.1.0-hdb68285_0.conda#a752488c68f2e7c456bcbd8f16eec275 +https://conda.anaconda.org/conda-forge/linux-64/libaec-1.1.5-h088129d_0.conda#86f7414544ae606282352fa1e116b41f +https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-hb03c661_1.conda#366b40a69f0ad6072561c1d09301c886 +https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-hb03c661_1.conda#4ffbb341c8b616aa2494b6afb26a0c5f +https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20250104-pl5321h7949ede_0.conda#c277e0a4d549b03ac1e9d6cbbe3d017b +https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-hd590300_2.conda#172bf1cd1ff8629f2b1179945ed45055 +https://conda.anaconda.org/conda-forge/linux-64/libfreetype6-2.14.3-h73754d4_0.conda#fb16b4b69e3f1dcfe79d80db8fd0c55d +https://conda.anaconda.org/conda-forge/linux-64/libgfortran-15.2.0-h69a702a_19.conda#42bf7eca1a951735fa06c0e3c0d5c8e6 +https://conda.anaconda.org/conda-forge/linux-64/libglu-9.0.3-h5888daf_1.conda#8422fcc9e5e172c91e99aef703b3ce65 +https://conda.anaconda.org/conda-forge/linux-64/libssh2-1.11.1-hcf80075_0.conda#eecce068c7e4eddeb169591baac20ac4 +https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_19.conda#e5ce228e579726c07255dbf90dc62101 +https://conda.anaconda.org/conda-forge/linux-64/libudunits2-2.2.28-h40f5838_3.conda#4bdace082e911a3e1f1f0b721bed5b56 +https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda#92ed62436b625154323d40d5f2f11dd7 +https://conda.anaconda.org/conda-forge/linux-64/libzip-1.11.2-h6991a6a_0.conda#a7b27c075c9b7f459f1c022090697cba +https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda#9de5350a85c4a20c685259b889aa6393 +https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda#d7d95fc8287ea7bf33e0e7116d2b95ec +https://conda.anaconda.org/conda-forge/linux-64/s2n-1.7.3-hc5a330e_0.conda#f2bd09e21c5844a12e2f5eefcd075555 +https://conda.anaconda.org/conda-forge/linux-64/snappy-1.2.2-h03e3b7b_1.conda#98b6c9dc80eb87b2519b97bcf7e578dd +https://conda.anaconda.org/conda-forge/linux-64/zlib-ng-2.3.3-hceb46e0_1.conda#2aadb0d17215603a82a2a6b0afd9a4cb +https://conda.anaconda.org/conda-forge/linux-64/aws-c-io-0.26.3-h955231c_3.conda#a3581835895ca9b7782beb5a49746db7 +https://conda.anaconda.org/conda-forge/linux-64/blosc-1.21.6-he440d0b_1.conda#2c2fae981fd2afd00812c92ac47d023d +https://conda.anaconda.org/conda-forge/linux-64/brotli-bin-1.2.0-hb03c661_1.conda#af39b9a8711d4a8d437b52c1d78eb6a1 +https://conda.anaconda.org/conda-forge/linux-64/hdf4-4.2.15-h2a13503_7.conda#bd77f8da987968ec3927990495dc22e4 +https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-ha1258a1_0.conda#fb53fb07ce46a575c5d004bbc96032c2 +https://conda.anaconda.org/conda-forge/linux-64/libfreetype-2.14.3-ha770c72_0.conda#e289f3d17880e44b633ba911d57a321b +https://conda.anaconda.org/conda-forge/linux-64/libmo_unpack-3.1.2-hf484d3e_1001.tar.bz2#95f32a6a5a666d33886ca5627239f03d +https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h877daf1_0.conda#2a45e7f8af083626f009645a6481f12d +https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.33-pthreads_h94d23a6_0.conda#2d3278b721e40468295ca755c3b84070 +https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927 +https://conda.anaconda.org/conda-forge/linux-64/libxml2-16-2.15.3-hca6bf5a_0.conda#e79d2c2f24b027aa8d5ab1b1ba3061e7 +https://conda.anaconda.org/conda-forge/linux-64/python-3.14.5-habeac84_100_cp314.conda#da92e59ff92f2d5ede4f612af20f583f +https://conda.anaconda.org/conda-forge/linux-64/qhull-2020.2-h434a139_5.conda#353823361b1d27eb3960efb076dfcaf6 +https://conda.anaconda.org/conda-forge/linux-64/sqlite-3.53.2-hbc0de68_0.conda#38d9bf35a4cc83094a327811e548b660 +https://conda.anaconda.org/conda-forge/linux-64/udunits2-2.2.28-h40f5838_3.conda#6bb8deb138f87c9d48320ac21b87e7a1 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libx11-1.8.13-he1eb515_0.conda#861fb6ccbc677bb9a9fb2468430b9c6a +https://conda.anaconda.org/conda-forge/noarch/alabaster-1.0.0-pyhd8ed1ab_1.conda#1fd9696649f65fd6611fcdb4ffec738a +https://conda.anaconda.org/conda-forge/noarch/argcomplete-3.6.3-pyhd8ed1ab_0.conda#8f37c8fb7116a18da04e52fa9e2c8df9 +https://conda.anaconda.org/conda-forge/noarch/attrs-26.1.0-pyhcf101f3_0.conda#c6b0543676ecb1fb2d7643941fe375f2 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-http-0.11.0-hcbcd92d_1.conda#333fa38d6dfe23cd68c67e20cb1726c9 +https://conda.anaconda.org/conda-forge/noarch/babel-2.18.0-pyhcf101f3_1.conda#f1976ce927373500cc19d3c0b2c85177 +https://conda.anaconda.org/conda-forge/noarch/backports.zstd-1.5.0-py314h680f03e_0.conda#1133126d840e75287d83947be3fc3e71 +https://conda.anaconda.org/conda-forge/linux-64/brotli-1.2.0-hed03a55_1.conda#8ccf913aaba749a5496c17629d859ed1 +https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.2.0-py314h3de4e8d_1.conda#8910d2c46f7e7b519129f486e0fe927a +https://conda.anaconda.org/conda-forge/noarch/cachetools-7.1.4-pyhd8ed1ab_0.conda#fbaa0445bcf8ba9e808c90cbc1235090 +https://conda.anaconda.org/conda-forge/noarch/certifi-2026.5.20-pyhd8ed1ab_0.conda#9fefff2f745ea1cc2ef15211a20c054a +https://conda.anaconda.org/conda-forge/noarch/cfgv-3.5.0-pyhd8ed1ab_0.conda#381bd45fb7aa032691f3063aff47e3a1 +https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.4.7-pyhd8ed1ab_0.conda#a9167b9571f3baa9d448faa2139d1089 +https://conda.anaconda.org/conda-forge/noarch/click-8.4.1-pyhc90fa1f_0.conda#554304a07e581a85891b15e39ea9f268 +https://conda.anaconda.org/conda-forge/noarch/cloudpickle-3.1.2-pyhcf101f3_1.conda#61b8078a0905b12529abc622406cb62c +https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_1.conda#962b9857ee8e7018c22f2776ffa0b2d7 +https://conda.anaconda.org/conda-forge/noarch/colorlog-6.10.1-pyh707e725_0.conda#33e96df3785bf61676ffee387e5a19e5 +https://conda.anaconda.org/conda-forge/noarch/cpython-3.14.5-py314hd8ed1ab_100.conda#a749029ce5d0632a913db19d17f944ab +https://conda.anaconda.org/conda-forge/noarch/cycler-0.12.1-pyhcf101f3_2.conda#4c2a8fef270f6c69591889b93f9f55c1 +https://conda.anaconda.org/conda-forge/noarch/distlib-0.4.1-pyhcf101f3_1.conda#f8638ae693ee89e9069ed4f3b77a6be9 +https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_1.conda#24c1ca34138ee57de72a943237cde4cc +https://conda.anaconda.org/conda-forge/noarch/filelock-3.29.1-pyhd8ed1ab_0.conda#917880ebad7632e8a52eada085b98ce9 +https://conda.anaconda.org/conda-forge/noarch/findlibs-0.1.2-pyhd8ed1ab_0.conda#fa9e9ec7bf26619a8edd3e11155f15d6 +https://conda.anaconda.org/conda-forge/linux-64/freetype-2.14.3-ha770c72_0.conda#8462b5322567212beeb025f3519fb3e2 +https://conda.anaconda.org/conda-forge/noarch/fsspec-2026.4.0-pyhd8ed1ab_0.conda#2c11aa96ea85ced419de710c1c3a78ff +https://conda.anaconda.org/conda-forge/noarch/hpack-4.1.0-pyhd8ed1ab_0.conda#0a802cb9888dd14eeefc611f05c40b6e +https://conda.anaconda.org/conda-forge/noarch/humanize-4.15.0-pyhd8ed1ab_0.conda#daddf757c3ecd6067b9af1df1f25d89e +https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.1.0-pyhd8ed1ab_0.conda#8e6923fc12f1fe8f8c4e5c9f343256ac +https://conda.anaconda.org/conda-forge/noarch/idna-3.17-pyhcf101f3_0.conda#c75e517ebd7a5c5272fe111e8b162228 +https://conda.anaconda.org/conda-forge/noarch/imagesize-2.0.0-pyhd8ed1ab_0.conda#92617c2ba2847cca7a6ed813b6f4ab79 +https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.3.0-pyhd8ed1ab_0.conda#9614359868482abba1bd15ce465e3c42 +https://conda.anaconda.org/conda-forge/noarch/iris-sample-data-2.5.2-pyhd8ed1ab_0.conda#895f6625dd8a246fece9279fcc12c1de +https://conda.anaconda.org/conda-forge/linux-64/kiwisolver-1.5.0-py314h97ea11e_0.conda#7397e418cab519b8d789936cf2dde6f6 +https://conda.anaconda.org/conda-forge/linux-64/lcms2-2.19.1-h0c24ade_1.conda#8b3ce45e929cd8e8e5f4d18586b56d8b +https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-8_h4a7cf45_openblas.conda#00fc660ab1b2f5ca07e92b4900d10c79 +https://conda.anaconda.org/conda-forge/linux-64/libcurl-8.20.0-hcf29cc6_0.conda#c3cc2864f82a944bc90a7beb4d3b0e88 +https://conda.anaconda.org/conda-forge/linux-64/libglx-1.7.0-ha4b6fd6_3.conda#ec3c4350aa0261bf7f87b8ca15c8e80e +https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.15.3-h49c6c72_0.conda#995d8c8bad2a3cc8db14675a153dec2b +https://conda.anaconda.org/conda-forge/noarch/locket-1.0.0-pyhd8ed1ab_0.tar.bz2#91e27ef3d05cc772ce627e51cff111c4 +https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.3-py314h67df5f8_1.conda#9a17c4307d23318476d7fbf0fedc0cde +https://conda.anaconda.org/conda-forge/noarch/munkres-1.1.4-pyhd8ed1ab_1.conda#37293a85a0f4f77bbd9cf7aaefc62609 +https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd +https://conda.anaconda.org/conda-forge/noarch/packaging-26.2-pyhc364b38_0.conda#4c06a92e74452cfa53623a81592e8934 +https://conda.anaconda.org/conda-forge/noarch/pip-26.1.2-pyh145f28c_0.conda#733cc07ed34162ac50b936464b163366 +https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.10.0-pyhcf101f3_0.conda#2c5ef45db85d34799771629bd5860fd7 +https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e +https://conda.anaconda.org/conda-forge/noarch/pycparser-3.0-pyhcf101f3_0.conda#9c5491066224083c41b6d5635ed7107b +https://conda.anaconda.org/conda-forge/noarch/pygments-2.20.0-pyhd8ed1ab_0.conda#16c18772b340887160c79a6acc022db0 +https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5 +https://conda.anaconda.org/conda-forge/noarch/pyshp-3.0.9-pyhd8ed1ab_0.conda#d260a4aec1cb8c91e0803afc6224494c +https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda#461219d1a5bd61342293efa2c0c90eac +https://conda.anaconda.org/conda-forge/linux-64/python-xxhash-3.7.0-py314he82b845_0.conda#b7b8156134373dd419399dfbf255daae +https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.3-py314h67df5f8_1.conda#2035f68f96be30dc60a5dfd7452c7941 +https://conda.anaconda.org/conda-forge/noarch/roman-numerals-4.1.0-pyhd8ed1ab_0.conda#0dc48b4b570931adc8641e55c6c17fe4 +https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda#8e194e7b992f99a5015edbd4ebd38efd +https://conda.anaconda.org/conda-forge/noarch/six-1.17.0-pyhe01879c_1.conda#3339e3b65d58accf4ca4fb8748ab16b3 +https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_2.conda#03fe290994c5e4ec17293cfb6bdce520 +https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-3.1.1-pyhd8ed1ab_0.conda#46b6abe31482f6bca064b965696ae807 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-pyhd8ed1ab_1.conda#fa839b5ff59e192f411ccc7dae6588bb +https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.1-pyhcf101f3_0.conda#b5325cf06a000c5b14970462ff5e4d58 +https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.2.0-pyhd8ed1ab_0.conda#32e37e8fe9ef45c637ee38ad51377769 +https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda#c07a6153f8306e45794774cf9b13bd32 +https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.15.0-pyhcf101f3_0.conda#0caa1af407ecff61170c9437a808404d +https://conda.anaconda.org/conda-forge/linux-64/unicodedata2-17.0.1-py314h5bd0f2a_0.conda#494fdf358c152f9fdd0673c128c2f3dd +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxext-1.3.7-hb03c661_0.conda#34e54f03dfea3e7a2dcf1453a85f1085 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxfixes-6.0.2-hb03c661_0.conda#ba231da7fccf9ea1e768caf5c7099b84 +https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.0-pyhcf101f3_0.conda#ba3dcdc8584155c97c648ae9c044b7a3 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-auth-0.10.3-h3aafcba_1.conda#54f62a10a7dcc7e46b149e2d078299b5 +https://conda.anaconda.org/conda-forge/linux-64/cffi-2.0.0-py314h4a8dc5f_1.conda#cf45f4278afd6f4e6d03eda0f435d527 +https://conda.anaconda.org/conda-forge/linux-64/coverage-7.14.1-py314h67df5f8_0.conda#2af0e1fec00680b1b6ef3859585ca8fa +https://conda.anaconda.org/conda-forge/noarch/dependency-groups-1.3.1-pyhe01879c_0.conda#b36dcdb7288182d4ddadb60c489f3d62 +https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.3.1-pyhd8ed1ab_0.conda#8e662bd460bda79b1ea39194e3c4c9ab +https://conda.anaconda.org/conda-forge/noarch/fonttools-4.63.0-pyh7db6752_0.conda#0509ee74d95e5b98eb6fe2a47760e399 +https://conda.anaconda.org/conda-forge/noarch/h11-0.16.0-pyhcf101f3_1.conda#b8993c19b0c32a2f7b66cbb58ca27069 +https://conda.anaconda.org/conda-forge/noarch/h2-4.3.0-pyhcf101f3_0.conda#164fc43f0b53b6e3a7bc7dce5e4f1dc9 +https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-9.0.0-pyhcf101f3_0.conda#ffc17e785d64e12fc311af9184221839 +https://conda.anaconda.org/conda-forge/noarch/importlib_resources-7.1.0-pyhd8ed1ab_0.conda#0ba6225c279baf7ea9473a62ea0ec9ae +https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.6-pyhcf101f3_1.conda#04558c96691bed63104678757beb4f8d +https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-8_h0358290_openblas.conda#33a413f1095f8325e5c30fde3b0d2445 +https://conda.anaconda.org/conda-forge/linux-64/libgl-1.7.0-ha4b6fd6_3.conda#f25206d7322c0e9648e8b83694d143ab +https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-8_h47877c9_openblas.conda#809be8ba8712c77bc7d44c2d99390dc4 +https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.10.0-pyhd8ed1ab_0.conda#eb52d14a901e23c39e9e7b4a1a5c015f +https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda#0badf9c54e24cecfb0ad2f99d680c163 +https://conda.anaconda.org/conda-forge/linux-64/pillow-12.2.0-py314h8ec4b1a_0.conda#76c4757c0ec9d11f969e8eb44899307b +https://conda.anaconda.org/conda-forge/linux-64/proj-9.8.1-he0df7b0_0.conda#b23619e5e9009eaa070ead0342034027 +https://conda.anaconda.org/conda-forge/noarch/pyproject-api-1.10.1-pyhcf101f3_0.conda#8685d811ee857a0435d88f75c083646d +https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8 +https://conda.anaconda.org/conda-forge/noarch/python-discovery-1.4.0-pyhcf101f3_0.conda#fb1e5c138e2d933e59b3fa0462acc5e6 +https://conda.anaconda.org/conda-forge/noarch/python-gil-3.14.5-h4df99d1_100.conda#41954747ba952ec4b01e16c2c9e8d8ff +https://conda.anaconda.org/conda-forge/noarch/roman-numerals-py-4.1.0-pyhd8ed1ab_0.conda#28687768633154993d521aecfa4a56ac +https://conda.anaconda.org/conda-forge/noarch/vcs_versioning-1.1.1-pyhd8ed1ab_0.conda#d3874a78e238013db5106c8e31f1ae58 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxi-1.8.3-hb03c661_0.conda#adba2e334082bb218db806d4c12277c9 +https://conda.anaconda.org/conda-forge/linux-64/xorg-libxxf86vm-1.1.7-hb03c661_0.conda#665d152b9c6e78da404086088077c844 +https://conda.anaconda.org/conda-forge/noarch/_python_abi3_support-1.0-hd8ed1ab_2.conda#aaa2a381ccc56eac91d63b6c1240312f +https://conda.anaconda.org/conda-forge/noarch/anyio-4.13.0-pyhcf101f3_0.conda#af2df4b9108808da3dc76710fe50eae2 +https://conda.anaconda.org/conda-forge/linux-64/aws-c-s3-0.12.4-h00bea6e_0.conda#3a36b9efa77c11214308630ddb91496a +https://conda.anaconda.org/conda-forge/noarch/dask-core-2026.3.0-pyhc364b38_0.conda#809f4cde7c853f437becc43415a2ecdf +https://conda.anaconda.org/conda-forge/linux-64/freeglut-3.2.2-h215f996_4.conda#b39dccf5af984bcb68ee2aa0f3213ea6 +https://conda.anaconda.org/conda-forge/linux-64/numpy-2.4.6-py314h2b28147_0.conda#f49b5f950379e0b97c35ca97682f7c6a +https://conda.anaconda.org/conda-forge/linux-64/pyproj-3.7.2-py314h7ce3bca_4.conda#d5d862492bbd0f4be1f6d57289459066 +https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.3-pyhc364b38_1.conda#6a991452eadf2771952f39d43615bb3e +https://conda.anaconda.org/conda-forge/noarch/setuptools-scm-10.0.5-pyhcf101f3_0.conda#730021037e2ce4c65f8eca077e35a821 +https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.1.0-py314h9891dd4_0.conda#5d3c008e54c7f49592fca9c32896a76f +https://conda.anaconda.org/conda-forge/noarch/urllib3-2.7.0-pyhd8ed1ab_0.conda#cbb88288f74dbe6ada1c6c7d0a97223e +https://conda.anaconda.org/conda-forge/noarch/virtualenv-21.4.2-pyhcf101f3_0.conda#52434c636a05a06806d0978128d98c19 +https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py314h0f05182_1.conda#2930a6e1c7b3bc5f66172e324a8f5fc3 +https://conda.anaconda.org/conda-forge/linux-64/cftime-1.6.5-py314hc02f841_1.conda#552b5d9d8a2a4be882e1c638953e7281 +https://conda.anaconda.org/conda-forge/linux-64/contourpy-1.3.3-py314h97ea11e_4.conda#95bede9cdb7a30a4b611223d52a01aa4 +https://conda.anaconda.org/conda-forge/linux-64/hdf5-2.1.0-nompi_hee75f7e_106.conda#16f05278317ac89d2dbd2898c4f8e3d6 +https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.9-pyh29332c3_0.conda#4f14640d58e2cc0aa0819d9d8ba125bb +https://conda.anaconda.org/conda-forge/noarch/identify-2.6.19-pyhd8ed1ab_0.conda#84a3233b709a289a4ddd7a2fd27dd988 +https://conda.anaconda.org/conda-forge/linux-64/jasper-4.2.9-h1588d4d_1.conda#115ecf05370670f93bc81a8c4f7fd57f +https://conda.anaconda.org/conda-forge/linux-64/mo_pack-0.3.1-py314hc02f841_2.conda#55ac6d85f5dd8ec5e9919e7762fcb31a +https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.1.0-pyhcf101f3_0.conda#67d1790eefa81ed305b89d8e314c7923 +https://conda.anaconda.org/conda-forge/noarch/pytest-mock-3.15.1-pyhd8ed1ab_0.conda#0511afbe860b1a653125d77c719ece53 +https://conda.anaconda.org/conda-forge/noarch/requests-2.34.2-pyhcf101f3_0.conda#4a85203c1d80c1059086ae860836ffb9 +https://conda.anaconda.org/conda-forge/linux-64/scipy-1.17.1-py314hf07bd8e_1.conda#718437171257e579e7d1f3b51c62536f +https://conda.anaconda.org/conda-forge/noarch/setuptools_scm-10.0.5-h8c3c6de_0.conda#3c8ff4c1bc8528d16d151582cba9e3a9 +https://conda.anaconda.org/conda-forge/linux-64/shapely-2.1.2-py314hbe3edd8_2.conda#5963e6ee81772d450a35e6bc95522761 +https://conda.anaconda.org/conda-forge/noarch/tox-4.55.1-pyhcf101f3_0.conda#b36d5dae98d2d22f1f8b7273f5813849 +https://conda.anaconda.org/conda-forge/linux-64/cf-units-3.3.1-py314hc02f841_0.conda#de50a60eab348de04809a33e180b4b01 +https://conda.anaconda.org/conda-forge/noarch/codecov-2.1.13-pyhd8ed1ab_1.conda#d924fe46139596ebc3d4d424ec39ed51 +https://conda.anaconda.org/conda-forge/noarch/httpx-0.28.1-pyhd8ed1ab_0.conda#d6989ead454181f4f9bc987d3dc4e285 +https://conda.anaconda.org/conda-forge/linux-64/libnetcdf-4.10.0-nompi_h3c9b436_104.conda#6d38346d49e4638ec98649121d295d54 +https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.9-py314h1194b4b_0.conda#11a821746ad11e642fcc615c3d66aa44 +https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.6.0-pyha770c72_0.conda#7859736b4f8ebe6c8481bf48d91c9a1e +https://conda.anaconda.org/conda-forge/linux-64/cartopy-0.25.0-py314ha0b5721_1.conda#fe89c5fa422f215b0d75046ecd4667de +https://conda.anaconda.org/conda-forge/linux-64/eccodes-2.47.0-ha1d8304_0.conda#3ef42f61d9a66a0749cbe598b5acdeea +https://conda.anaconda.org/conda-forge/linux-64/netcdf4-1.7.4-nompi_py311h498b1eb_107.conda#7f5488cf61943e6221325b454c2c2e9c +https://conda.anaconda.org/conda-forge/noarch/pbs-installer-2026.6.2-pyhd8ed1ab_0.conda#ecdafb6a10df41e1249d707532ccefd9 +https://conda.anaconda.org/conda-forge/noarch/iris-3.15.0-pyha770c72_0.conda#bfe6588cbbd6e2e97e9439f95de02af8 +https://conda.anaconda.org/conda-forge/noarch/nox-2026.4.10-pyhc364b38_0.conda#10df85d0d06301e961c8b27805f66a56 +https://conda.anaconda.org/conda-forge/linux-64/python-eccodes-2.47.0-np2py314h56abb78_0.conda#a1d80eb92819fdbdc748451545ec95aa +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-applehelp-2.0.0-pyhd8ed1ab_1.conda#16e3f039c0aa6446513e94ab18a8784b +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-devhelp-2.0.0-pyhd8ed1ab_1.conda#910f28a05c178feba832f842155cbfff +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.1.0-pyhd8ed1ab_1.conda#e9fb3fe8a5b758b4aff187d434f94f03 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jquery-4.1-pyhd8ed1ab_1.conda#403185829255321ea427333f7773dd1f +https://conda.anaconda.org/conda-forge/noarch/sphinx_rtd_theme-3.1.0-pyha770c72_0.conda#cede6bc99a0253fa676f03cfdc666d57 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-2.0.0-pyhd8ed1ab_1.conda#00534ebcc0375929b45c3039b5ba7636 +https://conda.anaconda.org/conda-forge/noarch/sphinx-8.2.3-pyhd8ed1ab_0.conda#f7af826063ed569bb13f7207d6f949b0 +https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.10-pyhd8ed1ab_1.conda#3bc61f7161d28137797e038263c04c54 diff --git a/requirements/py312.yml b/requirements/py312.yml new file mode 100644 index 00000000..f606c9c4 --- /dev/null +++ b/requirements/py312.yml @@ -0,0 +1,35 @@ +name: iris-grib-dev + +channels: + - conda-forge + +dependencies: + - python=3.12 + +# Setup dependencies. + - setuptools>=77.0.3 + - setuptools_scm>=8 + +# Core dependencies. + - iris>=3.10 + - python-eccodes>=1.6.1 + - eccodes>=2.33 + +# Optional dependencies. + - mo_pack + - pip + - pre-commit + +# Test dependencies. + - codecov + - filelock + - nox + - requests + - iris-sample-data + - pytest + - pytest-cov + - pytest-mock + +# Documentation dependencies. + - sphinx + - sphinx_rtd_theme diff --git a/requirements/py313.yml b/requirements/py313.yml new file mode 100644 index 00000000..1ecb9018 --- /dev/null +++ b/requirements/py313.yml @@ -0,0 +1,35 @@ +name: iris-grib-dev + +channels: + - conda-forge + +dependencies: + - python=3.13 + +# Setup dependencies. + - setuptools>=77.0.3 + - setuptools_scm>=8 + +# Core dependencies. + - iris>=3.10 + - python-eccodes>=1.6.1 + - eccodes>=2.33 + +# Optional dependencies. + - mo_pack + - pip + - pre-commit + +# Test dependencies. + - codecov + - filelock + - nox + - requests + - iris-sample-data + - pytest + - pytest-cov + - pytest-mock + +# Documentation dependencies. + - sphinx + - sphinx_rtd_theme diff --git a/requirements/py314.yml b/requirements/py314.yml new file mode 100644 index 00000000..c986b671 --- /dev/null +++ b/requirements/py314.yml @@ -0,0 +1,35 @@ +name: iris-grib-dev + +channels: + - conda-forge + +dependencies: + - python=3.14 + +# Setup dependencies. + - setuptools>=77.0.3 + - setuptools_scm>=8 + +# Core dependencies. + - iris>=3.10 + - python-eccodes>=1.6.1 + - eccodes>=2.33 + +# Optional dependencies. + - mo_pack + - pip + - pre-commit + +# Test dependencies. + - codecov + - filelock + - nox + - requests + - iris-sample-data + - pytest + - pytest-cov + - pytest-mock + +# Documentation dependencies. + - sphinx + - sphinx_rtd_theme diff --git a/requirements/pypi-core.txt b/requirements/pypi-core.txt new file mode 100644 index 00000000..cd285b47 --- /dev/null +++ b/requirements/pypi-core.txt @@ -0,0 +1,2 @@ +scitools-iris>=3.10 +eccodes>=1.6.1 diff --git a/requirements/pypi-optional-dev.txt b/requirements/pypi-optional-dev.txt new file mode 100644 index 00000000..a00c05de --- /dev/null +++ b/requirements/pypi-optional-dev.txt @@ -0,0 +1,2 @@ +mo_pack +pre-commit diff --git a/requirements/pypi-optional-test.txt b/requirements/pypi-optional-test.txt new file mode 100644 index 00000000..0fd077b3 --- /dev/null +++ b/requirements/pypi-optional-test.txt @@ -0,0 +1,8 @@ +codecov +filelock +nox +requests +iris-sample-data +pytest +pytest-cov +pytest-mock diff --git a/requirements/readthedocs.yml b/requirements/readthedocs.yml new file mode 120000 index 00000000..05372566 --- /dev/null +++ b/requirements/readthedocs.yml @@ -0,0 +1 @@ +py314.yml \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index b2f37129..00000000 --- a/setup.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python -from __future__ import print_function - -import os -import os.path -from setuptools import setup -import textwrap - - -name = 'iris_grib' - - -LONG_DESCRIPTION = textwrap.dedent(""" - Iris loading of GRIB files - ========================== - - With this package, iris is able to load GRIB files: - - ``` - my_data = iris.load(path_to_grib_file) - ``` - """) - - -here = os.path.abspath(os.path.dirname(__file__)) -pkg_root = os.path.join(here, name) - -packages = [] -for d, _, _ in os.walk(os.path.join(here, name)): - if os.path.exists(os.path.join(d, '__init__.py')): - packages.append(d[len(here)+1:].replace(os.path.sep, '.')) - -package_data = { - 'iris_grib': ['iris_grib/tests/results/*'], -} - - -def extract_version(): - version = None - fdir = os.path.dirname(__file__) - fnme = os.path.join(fdir, 'iris_grib', '__init__.py') - with open(fnme) as fd: - for line in fd: - if (line.startswith('__version__')): - _, version = line.split('=') - version = version.strip()[1:-1] # Remove quotation characters - break - return version - - -setup_args = dict( - name = name, - version = extract_version(), - packages = packages, - package_data = package_data, - description = "GRIB loading for Iris", - long_description = LONG_DESCRIPTION, - url = 'https://github.com/SciTools/iris-grib', - author = 'UK Met Office', - author_email = 'scitools-iris@googlegroups.com', - license = 'LGPL', - platforms = "Linux, Mac OS X, Windows", - keywords = ['iris', 'GRIB'], - classifiers = [ - 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - ], - install_requires = [ - 'iris>=1.9,<2', - # Also: the ECMWF GRIB API - ], - extras_require = { - 'test:python_version=="2.7"': ['mock'], - }, - test_suite = 'iris_grib.tests', -) - - -if __name__ == '__main__': - setup(**setup_args) diff --git a/src/iris_grib/__init__.py b/src/iris_grib/__init__.py new file mode 100644 index 00000000..927918f4 --- /dev/null +++ b/src/iris_grib/__init__.py @@ -0,0 +1,423 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Conversion of cubes to/from GRIB. + +See: `ECMWF ecCodes grib interface `_. + +""" + +import contextlib + +import eccodes +import numpy as np +import threading + +# NOTE: careful here, to avoid circular imports (as iris imports grib) +import iris # noqa: F401 +from iris.exceptions import TranslationError + +from . import _save_rules +from ._grib2_convert import TEMPLATE_RECORD +from ._load_convert import convert as load_convert +from .message import GribMessage + + +try: + from ._version import version as __version__ +except ModuleNotFoundError: + __version__ = "unknown" + +__all__ = [ + # TODO: publish grib1 loading controls, when ready to announce + # "GRIB1_LOADING_MODE", + # "Grib1LoadingMode", + "TEMPLATE_RECORD", + "load_cubes", + "load_pairs_from_fields", + "save_grib2", + "save_messages", + "save_pairs_from_cube", +] + + +class Grib1LoadingMode(threading.local): + """ + A control object selecting between "legacy" and "future" GRIB1 loading methods. + + This is designed to provide the singleton :data:`iris_grib.GRIB1_LOADING_MODE` + object. It provides :meth:`set` and :meth:`context` methods to control a binary + switch to enable either the new, recommended "future" mode, or the legacy code. + + The "legacy" GRIB1 loading mode is based on the + :class:`iris_grib._grib1_legacy.GribWrapper`, and makes extensive use of ecCodes + 'computed keys' facilities for a 'best efforts' approach to loading which does not + rigorously test all metadata to interpret fields. + The newer "strict" loading, by contrast, is based on the + :class:`iris_grib.message.GribMessage` class and will raise errors when encountering + any unsupported or unrecognised metadata elements : this aligns it with the code + which has long since been used to load GRIB2 data. + + .. warning:: + + The legacy implementation is now deprecated and will be removed in due course. + However, for full backwards compatibility, the 'legacy' mode is + **still the default**. + """ + + def __init__(self, *, legacy=True): + self.set(legacy=legacy) + + @property + def use_legacy_grib1_loading(self): + """Tell whether "legacy" GRIB1 loading is in use. + + N.B. read-only property. Use :meth:`set` to change. + """ + return self._use_legacy_grib1_loading + + def set(self, *, legacy): + """ + Permanently set the GRIB1 loading mode. + + Args: + + * legacy : bool + Control whether "legacy" or "future" GRIB1 loading mode is used, for the + duration of the context block. + This keyword is required (must be present, and not positional). + + Example: + + .. testsetup:: + + import iris_grib + try: + from iris.tests._shared_utils import get_data_path + except ImportError: + from iris.tests import get_data_path + # this is a bit of a cheat, as it doesn't include any GRIB1 data + path = get_data_path(("GRIB", "global_t", "global.grib2")) + old_legacy = iris_grib.GRIB1_LOADING_MODE.use_legacy_grib1_loading + + .. doctest:: + + >>> iris_grib.GRIB1_LOADING_MODE.set(legacy=False) + >>> cubes = iris_grib.load_cubes(path) + + .. testcleanup:: + + iris_grib.GRIB1_LOADING_MODE.set(legacy=old_legacy) + + .. note:: + + The legacy implementation is now deprecated and will be removed in due + course. However, for full backwards compatibility, 'legacy' mode is still + the default. + + You are advised to use ``iris_grib.GRIB1_LOADING_MODE.set(legacy=False)`` + at the top of the main script. + + .. warning:: + + Do not set/unset this dynamically to control loading mode for the duration + of some lines of code : this can cause incorrect operation in threaded + loading operations. For dynamic control, use :meth:`context` instead. + + """ + self._use_legacy_grib1_loading = bool(legacy) + + @contextlib.contextmanager + def context(self, *, legacy): + """ + Set the GRIB1 loading mode which applies during a context block. + + Args: + + * legacy : bool + Control whether "legacy" or "future" GRIB1 loading mode is used, for the + duration of the context block. + This keyword is required (must be present, and not positional). + + Example: + + .. testsetup:: + + import iris_grib + try: + from iris.tests._shared_utils import get_data_path + except ImportError: + from iris.tests import get_data_path + path = get_data_path(("GRIB", "global_t", "global.grib2")) + + + .. doctest:: + + >>> with iris_grib.GRIB1_LOADING_MODE.context(legacy=False): + ... cubes = iris_grib.load_cubes(path) + + """ + old_mode = self._use_legacy_grib1_loading + try: + self._use_legacy_grib1_loading = legacy + yield + finally: + self._use_legacy_grib1_loading = old_mode + + def __repr__(self): + return f"GRIB1_LOADING_MODE(legacy={self.use_legacy_grib1_loading})" + + +#: a global singleton object controlling the way in which GRIB1 data is loaded. +GRIB1_LOADING_MODE = Grib1LoadingMode() + + +# Utility routines for the use of dask 'meta' in wrapping proxies +def _aslazydata_has_meta(): + """Work out whether 'iris._lazy_data.as_lazy_data' takes a "meta" kwarg. + + Up to Iris 3.8.0, "as_lazy_data" did not have a 'meta' keyword, but + since https://github.com/SciTools/iris/pull/5801, it now *requires* one, + if the wrapped object is anything other than a numpy or dask array. + """ + from inspect import signature # noqa: PLC0415 + from iris._lazy_data import as_lazy_data # noqa: PLC0415 + + sig = signature(as_lazy_data) + return "meta" in sig.parameters + + +# Work this out just once. +_ASLAZYDATA_NEEDS_META = _aslazydata_has_meta() + + +def _make_dask_meta(shape, dtype, is_masked=True): + """Construct a dask 'meta' object for use in 'dask.array.from_array'. + + A "meta" array is made from the dtype and shape of the array-like to be + wrapped, plus whether it will return masked or unmasked data. + """ + meta_shape = tuple([0 for _ in shape]) + array_class = np.ma if is_masked else np + meta = array_class.zeros(meta_shape, dtype=dtype) + return meta + + +def _load_generate(filename): + messages = GribMessage.messages_from_filename(filename) + for message in messages: + editionNumber = message.sections[0]["editionNumber"] + if editionNumber == 1: + if GRIB1_LOADING_MODE.use_legacy_grib1_loading: + from ._grib1_legacy.grib_wrapper import GribWrapper # noqa: PLC0415 + + has_bitmap = 3 in message.sections + message_id = message._raw_message._message_id + grib_fh = message._file_ref.open_file + message = GribWrapper( + message_id, grib_fh=grib_fh, has_bitmap=has_bitmap + ) + elif editionNumber != 2: + emsg = "GRIB edition {} is not supported by {!r}." + raise TranslationError(emsg.format(editionNumber, type(message).__name__)) + yield message + + +def load_cubes(filenames, callback=None): + """Return an iterator over cubes from the given list of filenames. + + Args: + + * filenames: + One or more GRIB filenames to load from. + + Kwargs: + + * callback: + Function which can be passed on to :func:`iris.io.run_callback`. + + Returns: + An iterator returning Iris cubes loaded from the GRIB files. + + """ + import iris.fileformats.rules as iris_rules # noqa: PLC0415 + + grib_loader = iris_rules.Loader(_load_generate, {}, load_convert) + return iris_rules.load_cubes(filenames, callback, grib_loader) + + +def load_pairs_from_fields(grib_messages): + """Convert an GRIB messages into (Cube, Grib message) tuples. + + Parameters + ---------- + grib_messages : iterable on (cube, message) + An iterable of :class:`GribMessage`. + + Returns + ------- + iterable of (cube, message) + An iterable of (:class:`~iris.Cube`, :class:`GribMessage`), + pairing each message with a corresponding generated cube. + + Notes + ----- + This capability can be used to filter out fields before they are passed to + the load pipeline, and amend the cubes once they are created, using + GRIB metadata conditions. Where the filtering + removes a significant number of fields, the speed up to load can be + significant: + + >>> import iris + >>> from iris_grib import load_pairs_from_fields + >>> from iris_grib.message import GribMessage + >>> filename = iris.sample_data_path("polar_stereo.grib2") + >>> filtered_messages = [] + >>> for message in GribMessage.messages_from_filename(filename): + ... if message.sections[1]["productionStatusOfProcessedData"] == 0: + ... filtered_messages.append(message) + >>> cubes_messages = load_pairs_from_fields(filtered_messages) + >>> for cube, msg in cubes_messages: + ... prod_stat = msg.sections[1]["productionStatusOfProcessedData"] + ... cube.attributes["productionStatusOfProcessedData"] = prod_stat + >>> print(cube.attributes["productionStatusOfProcessedData"]) + 0 + + This capability can also be used to alter fields before they are passed to + the load pipeline. Fields with out of specification header elements can + be cleaned up this way and cubes created: + + >>> from iris_grib import load_pairs_from_fields + >>> cleaned_messages = GribMessage.messages_from_filename(filename) + >>> for message in cleaned_messages: + ... if message.sections[1]["productionStatusOfProcessedData"] == 0: + ... message.sections[1]["productionStatusOfProcessedData"] = 4 + >>> cubes = load_pairs_from_fields(cleaned_messages) + + Args: + + * grib_messages: + An iterable of :class:`iris_grib.message.GribMessage`. + + Returns: + An iterable of tuples of (:class:`iris.cube.Cube`, + :class:`iris_grib.message.GribMessage`). + + """ + import iris.fileformats.rules as iris_rules # noqa: PLC0415 + + return iris_rules.load_pairs_from_fields(grib_messages, load_convert) + + +def save_grib2(cube, target, append=False): + """Save a cube or iterable of cubes to a GRIB2 file. + + Args: + + * cube: + The :class:`iris.cube.Cube`, :class:`iris.cube.CubeList` or list of + cubes to save to a GRIB2 file. + * target: + A filename or open file handle specifying the GRIB2 file to save + to. + + Kwargs: + + * append: + Whether to start a new file afresh or add the cube(s) to the end of + the file. Only applicable when target is a filename, not a file + handle. Default is False. + + """ + messages = (message for _, message in save_pairs_from_cube(cube)) + save_messages(messages, target, append=append) + + +def save_pairs_from_cube(cube): + """Convert one or more cubes to (2D cube, GRIB-message-id) pairs. + + Produces pairs of 2D cubes and GRIB messages, the result of the 2D cube + being processed by the GRIB save rules. + + Args: + + * cube: + A :class:`iris.cube.Cube`, :class:`iris.cube.CubeList` or + list of cubes. + + Returns: + a iterator returning (cube, field) pairs, where each ``cube`` is a 2d + slice of the input and each``field`` is an eccodes message "id". + N.B. the message "id"s are integer handles. + """ + dim_coords = True + x_coords = cube.coords(axis="x", dim_coords=True) + y_coords = cube.coords(axis="y", dim_coords=True) + if not x_coords and not y_coords: + # Try aux-coords if there are no dim-coords : needed for gaussian data + # NB in this case we won't rely on axis logic, but simply assume that the + # required coordinates are always "latitude" and "longitude". + # This excludes rotated gaussian grids, but we don't support those (yet). + dim_coords = False + x_coords = cube.coords("longitude") + y_coords = cube.coords("latitude") + + if len(x_coords) != 1 or len(y_coords) != 1: + raise TranslationError("Did not find one (and only one) x or y coord") + + (x_coord,) = x_coords + (y_coord,) = y_coords + + # Save each latlon slice2D in the cube + slice_param = [y_coord, x_coord] if dim_coords else [x_coord] + for slice2D in cube.slices(slice_param): + if _save_rules.is_grid_definition_template_40(cube, x_coord, y_coord): + template_name = "reduced_gg_sfc_grib2" + else: + template_name = "GRIB2" + grib_message = eccodes.codes_grib_new_from_samples(template_name) + result = _save_rules.run(slice2D, grib_message, cube, x_coord, y_coord) + if result is not None: + grib_message = result + yield (slice2D, grib_message) + + +def save_messages(messages, target, append=False): + """Save messages to a GRIB2 file. + + The messages will be released as part of the save. + + Args: + + * messages: + An iterable of grib_api message IDs. + * target: + A filename or open file handle. + + Kwargs: + + * append: + Whether to start a new file afresh or add the cube(s) to the end of + the file. Only applicable when target is a filename, not a file + handle. Default is False. + + """ + # grib file (this bit is common to the pp and grib savers...) + if isinstance(target, str): + grib_file = open(target, "ab" if append else "wb") + elif hasattr(target, "write"): + if hasattr(target, "mode") and "b" not in target.mode: + raise ValueError("Target not binary") + grib_file = target + else: + raise ValueError("Can only save grib to filename or writable") + + try: + for message in messages: + eccodes.codes_write(message, grib_file) + eccodes.codes_release(message) + finally: + # (this bit is common to the pp and grib savers...) + if isinstance(target, str): + grib_file.close() diff --git a/src/iris_grib/_grib1_legacy/__init__.py b/src/iris_grib/_grib1_legacy/__init__.py new file mode 100644 index 00000000..a69db511 --- /dev/null +++ b/src/iris_grib/_grib1_legacy/__init__.py @@ -0,0 +1,8 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Code for the legacy GRIB1 loader. + +See :data:`iris_grib.GRIB1_LOADING_MODE`. +""" diff --git a/src/iris_grib/_grib1_legacy/grib1_load_rules.py b/src/iris_grib/_grib1_legacy/grib1_load_rules.py new file mode 100644 index 00000000..2d57da5f --- /dev/null +++ b/src/iris_grib/_grib1_legacy/grib1_load_rules.py @@ -0,0 +1,392 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Rules for grib1 loading. + +Used exclusively by the 'legacy' GRIB loader in `_grib1_legacy.grib_wrapper`. +""" + +import numpy as np + +from iris_grib._grib2_convert import ensure_surface_air_pressure_name + +from cf_units import CALENDAR_GREGORIAN, Unit + +from iris.aux_factory import HybridPressureFactory +from iris.coords import AuxCoord, CellMethod, DimCoord +from iris.exceptions import TranslationError +from iris.fileformats.rules import ( + ConversionMetadata, + Factory, + Reference, + ReferenceTarget, +) + + +def grib1_convert(grib): + """ + Convert a GRIB1 message into the corresponding items of Cube metadata. + + Args: + + * grib: + A :class:`~iris_grib.GribWrapper` object. + + Returns: + A :class:`iris.fileformats.rules.ConversionMetadata` object. + + """ + if grib.edition != 1: + emsg = "GRIB edition {} is not supported by {!r}." + raise TranslationError(emsg.format(grib.edition, type(grib).__name__)) + + factories = [] + references = [] + standard_name = None + long_name = None + units = None + attributes = {} + cell_methods = [] + dim_coords_and_dims = [] + aux_coords_and_dims = [] + + ################################################## + # decode GRID -> dim_coords / aux_coords + + if grib.gridType == "reduced_gg": + aux_coords_and_dims.append( + ( + AuxCoord( + grib._y_points, + grib._y_coord_name, + units="degrees", + coord_system=grib._coord_system, + ), + 0, + ) + ) + aux_coords_and_dims.append( + ( + AuxCoord( + grib._x_points, + grib._x_coord_name, + units="degrees", + coord_system=grib._coord_system, + ), + 0, + ) + ) + elif grib.gridType in ("regular_ll", "rotated_ll", "regular_gg"): + j_points_are_consecutive = grib.jPointsAreConsecutive + + if j_points_are_consecutive not in (0, 1): + raise ValueError + + dim_coords_and_dims.append( + ( + DimCoord( + grib._y_points, + grib._y_coord_name, + units="degrees", + coord_system=grib._coord_system, + ), + j_points_are_consecutive, + ) + ) + dim_coords_and_dims.append( + ( + DimCoord( + grib._x_points, + grib._x_coord_name, + units="degrees", + coord_system=grib._coord_system, + circular=grib._x_circular, + ), + int(not j_points_are_consecutive), + ) + ) + + elif grib.gridType in ["polar_stereographic", "lambert"]: + dim_coords_and_dims.append( + ( + DimCoord( + grib._y_points, + grib._y_coord_name, + units="m", + coord_system=grib._coord_system, + ), + 0, + ) + ) + dim_coords_and_dims.append( + ( + DimCoord( + grib._x_points, + grib._x_coord_name, + units="m", + coord_system=grib._coord_system, + ), + 1, + ) + ) + + ################################################## + # decode PHENOMENON -> std_name / long_name / units + + if ( + (grib.table2Version < 128) + and (grib.indicatorOfParameter == 11) + and (grib._cf_data is None) + ): + standard_name = "air_temperature" + units = "kelvin" + + if ( + (grib.table2Version < 128) + and (grib.indicatorOfParameter == 33) + and (grib._cf_data is None) + ): + standard_name = "x_wind" + units = "m s-1" + + if ( + (grib.table2Version < 128) + and (grib.indicatorOfParameter == 34) + and (grib._cf_data is None) + ): + standard_name = "y_wind" + units = "m s-1" + + if grib._cf_data is not None: + standard_name = grib._cf_data.standard_name + long_name = grib._cf_data.standard_name or grib._cf_data.long_name + units = grib._cf_data.units + + # N.B. in addition to the previous cf translated phenomenon info, + # **always** add a GRIB_PARAM attribute to identify the input phenomenon + # identity. + attributes["GRIB_PARAM"] = grib._grib_code + + if ((grib.table2Version >= 128) and (grib._cf_data is None)) or ( + (grib.table2Version == 1) and (grib.indicatorOfParameter >= 128) + ): + long_name = ( + f"UNKNOWN LOCAL PARAM {grib.indicatorOfParameter}.{grib.table2Version}" + ) + units = "???" + + # Special!! + if grib.table2Version == 128 and grib.indicatorOfParameter == 134: + standard_name = "air_pressure" + units = "Pa" + references.append( + ReferenceTarget("ref_surface_pressure", ensure_surface_air_pressure_name) + ) + dim_coords_and_dims.append( + (DimCoord(np.arange(grib._x_points.shape[0]), long_name="gaussian_grid"), 0) + ) + + ################################################## + ##### decode TIME -> aux_coords (=always scalar) + + if grib._phenomenonDateTime != -1.0: + aux_coords_and_dims.append( + ( + DimCoord( + points=grib.startStep, + standard_name="forecast_period", + units=grib._forecastTimeUnit, + ), + None, + ) + ) + aux_coords_and_dims.append( + ( + DimCoord( + points=grib.phenomenon_points("hours"), + standard_name="time", + units=Unit("hours since epoch", CALENDAR_GREGORIAN), + ), + None, + ) + ) + + def add_bounded_time_coords(aux_coords_and_dims, grib): + t_bounds = grib.phenomenon_bounds("hours") + period = t_bounds[1] - t_bounds[0] + aux_coords_and_dims.append( + ( + DimCoord( + standard_name="forecast_period", + units="hours", + points=grib._forecastTime + 0.5 * period, + bounds=[grib._forecastTime, grib._forecastTime + period], + ), + None, + ) + ) + aux_coords_and_dims.append( + ( + DimCoord( + standard_name="time", + units=Unit("hours since epoch", CALENDAR_GREGORIAN), + points=0.5 * (t_bounds[0] + t_bounds[1]), + bounds=t_bounds, + ), + None, + ) + ) + + if grib.timeRangeIndicator == 2: + add_bounded_time_coords(aux_coords_and_dims, grib) + + if grib.timeRangeIndicator == 3: + add_bounded_time_coords(aux_coords_and_dims, grib) + cell_methods.append(CellMethod("mean", coords="time")) + + if grib.timeRangeIndicator == 4: + add_bounded_time_coords(aux_coords_and_dims, grib) + cell_methods.append(CellMethod("sum", coords="time")) + + if grib.timeRangeIndicator == 5: + add_bounded_time_coords(aux_coords_and_dims, grib) + cell_methods.append(CellMethod("_difference", coords="time")) + + if grib.timeRangeIndicator == 51: + add_bounded_time_coords(aux_coords_and_dims, grib) + cell_methods.append(CellMethod("mean", coords="time")) + + if grib.timeRangeIndicator == 113: + add_bounded_time_coords(aux_coords_and_dims, grib) + cell_methods.append(CellMethod("mean", coords="time")) + + if grib.timeRangeIndicator == 114: + add_bounded_time_coords(aux_coords_and_dims, grib) + cell_methods.append(CellMethod("sum", coords="time")) + + if grib.timeRangeIndicator == 115: + add_bounded_time_coords(aux_coords_and_dims, grib) + cell_methods.append(CellMethod("mean", coords="time")) + + if grib.timeRangeIndicator == 116: + add_bounded_time_coords(aux_coords_and_dims, grib) + cell_methods.append(CellMethod("sum", coords="time")) + + if grib.timeRangeIndicator == 117: + add_bounded_time_coords(aux_coords_and_dims, grib) + cell_methods.append(CellMethod("mean", coords="time")) + + if grib.timeRangeIndicator == 118: + add_bounded_time_coords(aux_coords_and_dims, grib) + cell_methods.append(CellMethod("_covariance", coords="time")) + + if grib.timeRangeIndicator == 123: + add_bounded_time_coords(aux_coords_and_dims, grib) + cell_methods.append(CellMethod("mean", coords="time")) + + if grib.timeRangeIndicator == 124: + add_bounded_time_coords(aux_coords_and_dims, grib) + cell_methods.append(CellMethod("sum", coords="time")) + + if grib.timeRangeIndicator == 125: + add_bounded_time_coords(aux_coords_and_dims, grib) + cell_methods.append(CellMethod("standard_deviation", coords="time")) + + ################################################## + # decode VERTICAL -> aux_coords / factories + + if grib.levelType == "pl": + aux_coords_and_dims.append( + (DimCoord(points=grib.level, long_name="pressure", units="hPa"), None) + ) + + if grib.levelType == "sfc": + if grib._cf_data is not None and grib._cf_data.set_height is not None: + aux_coords_and_dims.append( + ( + DimCoord( + points=grib._cf_data.set_height, + long_name="height", + units="m", + attributes={"positive": "up"}, + ), + None, + ) + ) + elif grib.typeOfLevel == "heightAboveGround": # required for NCAR + aux_coords_and_dims.append( + ( + DimCoord( + points=grib.level, + long_name="height", + units="m", + attributes={"positive": "up"}, + ), + None, + ) + ) + + if grib.levelType == "ml" and hasattr(grib, "pv"): + aux_coords_and_dims.append( + ( + AuxCoord( + grib.level, + standard_name="model_level_number", + units=1, + attributes={"positive": "up"}, + ), + None, + ) + ) + aux_coords_and_dims.append( + ( + DimCoord(grib.pv[grib.level], long_name="level_pressure", units="Pa"), + None, + ) + ) + aux_coords_and_dims.append( + ( + AuxCoord( + grib.pv[grib.numberOfCoordinatesValues // 2 + grib.level], + long_name="sigma", + units=1, + ), + None, + ) + ) + factories.append( + Factory( + HybridPressureFactory, + [ + {"long_name": "level_pressure"}, + {"long_name": "sigma"}, + Reference("surface_pressure"), + ], + ) + ) + + if grib._originatingCentre != "unknown": + aux_coords_and_dims.append( + ( + AuxCoord( + points=grib._originatingCentre, + long_name="originating_centre", + units="no_unit", + ), + None, + ) + ) + + return ConversionMetadata( + factories, + references, + standard_name, + long_name, + units, + attributes, + cell_methods, + dim_coords_and_dims, + aux_coords_and_dims, + ) diff --git a/src/iris_grib/_grib1_legacy/grib_wrapper.py b/src/iris_grib/_grib1_legacy/grib_wrapper.py new file mode 100644 index 00000000..b550f3f4 --- /dev/null +++ b/src/iris_grib/_grib1_legacy/grib_wrapper.py @@ -0,0 +1,700 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Legacy GRIB1 loading, based on :class:`GribWrapper` and :class:`GribDataProxy`. + +This loader is now deprecated, the replacement uses the :class:`iris_grib.GribMessage`. +While this mechanism is still in place, the user can switch between the two with the +:data:`iris_grib.GRIB1_LOADING_MODE` switch. +""" + +import datetime +import math # for fmod + +import cartopy.crs as ccrs +import cf_units +import eccodes +import numpy as np +import numpy.ma as ma + +# NOTE: careful here, to avoid circular imports (as iris imports grib) +from iris._lazy_data import as_lazy_data +import iris.coord_systems as coord_systems +from iris.exceptions import TranslationError, NotYetImplementedError + +from iris_grib import _ASLAZYDATA_NEEDS_META, _make_dask_meta +from iris_grib import grib_phenom_translation as gptx + + +CENTRE_TITLES = { + "egrr": "U.K. Met Office - Exeter", + "ecmf": "European Centre for Medium Range Weather Forecasts", + "rjtd": "Tokyo, Japan Meteorological Agency", + "55": "San Francisco", + "kwbc": ( + "US National Weather Service, National Centres for Environmental Prediction" + ), +} + +TIME_RANGE_INDICATORS = { + 0: "none", + 1: "none", + 3: "time mean", + 4: "time sum", + 5: "time _difference", + 10: "none", + # TODO #567 Further exploration of following mappings + 51: "time mean", + 113: "time mean", + 114: "time sum", + 115: "time mean", + 116: "time sum", + 117: "time mean", + 118: "time _covariance", + 123: "time mean", + 124: "time sum", + 125: "time standard_deviation", +} + +PROCESSING_TYPES = { + 0: "time mean", + 1: "time sum", + 2: "time maximum", + 3: "time minimum", + 4: "time _difference", + 5: "time _root mean square", + 6: "time standard_deviation", + 7: "time _convariance", + 8: "time _difference", + 9: "time _ratio", +} + +TIME_CODES_EDITION1 = { + 0: ("minutes", 60), + 1: ("hours", 60 * 60), + 2: ("days", 24 * 60 * 60), + # NOTE: do *not* support calendar-dependent units at all. + # So the following possible keys remain unsupported: + # 3: 'months', + # 4: 'years', + # 5: 'decades', + # 6: '30 years', + # 7: 'century', + 10: ("3 hours", 3 * 60 * 60), + 11: ("6 hours", 6 * 60 * 60), + 12: ("12 hours", 12 * 60 * 60), + 13: ("15 minutes", 15 * 60), + 14: ("30 minutes", 30 * 60), + 254: ("seconds", 1), +} + +unknown_string = "???" + + +class GribDataProxy: + """A reference to the data payload of a single Grib message.""" + + __slots__ = ("dtype", "offset", "path", "shape") + + def __init__(self, shape, dtype, path, offset): + self.shape = shape + self.dtype = dtype + self.path = path + self.offset = offset + + @property + def ndim(self): + return len(self.shape) + + def __getitem__(self, keys): + with open(self.path, "rb") as grib_fh: + grib_fh.seek(self.offset) + grib_message = eccodes.codes_new_from_file( + grib_fh, eccodes.CODES_PRODUCT_GRIB + ) + data = _message_values(grib_message, self.shape) + eccodes.codes_release(grib_message) + + result = data.__getitem__(keys) + + return result + + def __repr__(self): + msg = ( + "<{self.__class__.__name__} shape={self.shape} " + "dtype={self.dtype!r} " + "path={self.path!r} offset={self.offset}>" + ) + return msg.format(self=self) + + def __getstate__(self): + return {attr: getattr(self, attr) for attr in self.__slots__} + + def __setstate__(self, state): + for key, value in state.items(): + setattr(self, key, value) + + +class GribWrapper: + """Contains a pygrib object plus some extra keys of our own. + + The class :class:`iris_grib.message.GribMessage` + provides alternative means of working with GRIB message instances. + + """ + + def __init__(self, grib_message, grib_fh=None, has_bitmap=True): + """Store the grib message and compute our extra keys.""" + self.grib_message = grib_message + + if self.edition != 1: + emsg = "GRIB edition {} is not supported by {!r}." + raise TranslationError(emsg.format(self.edition, type(self).__name__)) + + deferred = grib_fh is not None + + # Store the file pointer and message length from the current + # grib message before it's changed by calls to the grib-api. + if deferred: + offset = eccodes.codes_get_message_offset(grib_message) + + # Initialise the key-extension dictionary. + # NOTE: this attribute *must* exist, or the the __getattr__ overload + # can hit an infinite loop. + self.extra_keys = {} + self._confirm_in_scope() + self._compute_extra_keys() + + # Calculate the data payload shape. + shape = (eccodes.codes_get_long(grib_message, "numberOfValues"),) + + if not self.gridType.startswith("reduced"): + ni, nj = self.Ni, self.Nj + j_fast = eccodes.codes_get_long(grib_message, "jPointsAreConsecutive") + shape = (nj, ni) if j_fast == 0 else (ni, nj) + + if deferred: + # Wrap the reference to the data payload within the data proxy + # in order to support deferred data loading. + dtype = np.dtype(float) # Use default dtype for python float + proxy = GribDataProxy(shape, dtype, grib_fh.name, offset) + as_lazy_kwargs = {} + if _ASLAZYDATA_NEEDS_META: + meta = _make_dask_meta(shape, dtype, is_masked=has_bitmap) + as_lazy_kwargs["meta"] = meta + self._data = as_lazy_data(proxy, **as_lazy_kwargs) + else: + self.data = _message_values(grib_message, shape) + + def _confirm_in_scope(self): + """Ensure we have a grib flavour that we choose to support.""" + + # forbid alternate row scanning + # (uncommon entry from GRIB2 flag table 3.4, also in GRIB1) + if self.alternativeRowScanning == 1: + raise ValueError("alternativeRowScanning == 1 not handled.") + + def __getattr__(self, key): + """Return a grib key, or one of our extra keys.""" + + # is it in the grib message? + try: + # we just get as the type of the "values" + # array...special case here... + if key in ["values", "pv", "latitudes", "longitudes"]: + res = eccodes.codes_get_double_array(self.grib_message, key) + elif key in ("typeOfFirstFixedSurface", "typeOfSecondFixedSurface"): + res = np.int32(eccodes.codes_get_long(self.grib_message, key)) + else: + key_type = eccodes.codes_get_native_type(self.grib_message, key) + if key_type is int: + res = np.int32(eccodes.codes_get_long(self.grib_message, key)) + elif key_type is float: + # Because some computer keys are floats, like + # longitudeOfFirstGridPointInDegrees, a float32 + # is not always enough... + res = np.float64(eccodes.codes_get_double(self.grib_message, key)) + elif key_type is str: + res = eccodes.codes_get_string(self.grib_message, key) + else: + emsg = "Unknown type for {} : {}" + raise ValueError(emsg.format(key, str(key_type))) + except eccodes.CodesInternalError: + res = None + + # ...or is it in our list of extras? + if res is None: + if key in self.extra_keys: + res = self.extra_keys[key] + else: + # must raise an exception for the hasattr() mechanism to work + raise AttributeError("Cannot find GRIB key %s" % key) + + return res + + def _timeunit_detail(self): + """Return the (string, seconds) describing the message time unit.""" + unit_code = self.indicatorOfUnitOfTimeRange + if unit_code not in TIME_CODES_EDITION1: + message = ( + "Unhandled time unit for forecast " + "indicatorOfUnitOfTimeRange : " + str(unit_code) + ) + raise NotYetImplementedError(message) + return TIME_CODES_EDITION1[unit_code] + + def _timeunit_string(self): + """Get the udunits string for the message time unit.""" + return self._timeunit_detail()[0] + + def _timeunit_seconds(self): + """Get the number of seconds in the message time unit.""" + return self._timeunit_detail()[1] + + def _compute_extra_keys(self): + """Compute our extra keys.""" + global unknown_string + + self.extra_keys = {} + forecastTime = self.startStep + + # regular or rotated grid? + try: + longitudeOfSouthernPoleInDegrees = self.longitudeOfSouthernPoleInDegrees + latitudeOfSouthernPoleInDegrees = self.latitudeOfSouthernPoleInDegrees + except AttributeError: + longitudeOfSouthernPoleInDegrees = 0.0 + latitudeOfSouthernPoleInDegrees = 90.0 + + centre = eccodes.codes_get_string(self.grib_message, "centre") + + # default values + self.extra_keys = { + "_referenceDateTime": -1.0, + "_phenomenonDateTime": -1.0, + "_periodStartDateTime": -1.0, + "_periodEndDateTime": -1.0, + "_levelTypeName": unknown_string, + "_levelTypeUnits": unknown_string, + "_firstLevelTypeName": unknown_string, + "_firstLevelTypeUnits": unknown_string, + "_firstLevel": -1.0, + "_secondLevelTypeName": unknown_string, + "_secondLevel": -1.0, + "_originatingCentre": unknown_string, + "_forecastTime": None, + "_forecastTimeUnit": unknown_string, + "_coord_system": None, + "_x_circular": False, + "_x_coord_name": unknown_string, + "_y_coord_name": unknown_string, + # These are here to avoid repetition in the rules + # files, and reduce the very long line lengths. + "_x_points": None, + "_y_points": None, + "_cf_data": None, + "_grib_code": None, + } + + # cf phenomenon translation + # Get centre code (N.B. self.centre has default type = string) + centre_number = eccodes.codes_get_long(self.grib_message, "centre") + # Look for a known grib1-to-cf translation (or None). + cf_data = gptx.grib1_phenom_to_cf_info( + table2_version=self.table2Version, + centre_number=centre_number, + param_number=self.indicatorOfParameter, + ) + self.extra_keys["_cf_data"] = cf_data + + # Record the original parameter encoding + self.extra_keys["_grib_code"] = gptx.GRIBCode( + edition=1, + table_version=self.table2Version, + centre_number=centre_number, + number=self.indicatorOfParameter, + ) + + # reference date + self.extra_keys["_referenceDateTime"] = datetime.datetime( + int(self.year), + int(self.month), + int(self.day), + int(self.hour), + int(self.minute), + ) + + # forecast time with workarounds + self.extra_keys["_forecastTime"] = forecastTime + + # verification date + processingDone = self._get_processing_done() + # time processed? + if processingDone.startswith("time"): + validityDate = str(self.validityDate) + validityTime = "{:04}".format(int(self.validityTime)) + endYear = int(validityDate[:4]) + endMonth = int(validityDate[4:6]) + endDay = int(validityDate[6:8]) + endHour = int(validityTime[:2]) + endMinute = int(validityTime[2:4]) + + # fixed forecastTime in hours + self.extra_keys["_periodStartDateTime"] = self.extra_keys[ + "_referenceDateTime" + ] + datetime.timedelta(hours=int(forecastTime)) + self.extra_keys["_periodEndDateTime"] = datetime.datetime( + endYear, endMonth, endDay, endHour, endMinute + ) + else: + self.extra_keys["_phenomenonDateTime"] = self._get_verification_date() + + # originating centre + # TODO #574 Expand to include sub-centre + self.extra_keys["_originatingCentre"] = CENTRE_TITLES.get( + centre, "unknown centre %s" % centre + ) + + # forecast time unit as a cm string + # TODO #575 Do we want PP or GRIB style forecast delta? + self.extra_keys["_forecastTimeUnit"] = self._timeunit_string() + + # shape of the Earth + oblate_Earth = self.resolutionAndComponentFlags & 0b0100000 + if oblate_Earth: + # Earth assumed oblate spheroidal with size as determined by IAU in + # 1965 (6378.160 km, 6356.775 km, f = 1/297.0) + raise ValueError("Oblate Spheroidal Earth is not supported.") + else: + # Earth assumed spherical with radius 6367.47 km + geoid = coord_systems.GeogCS(semi_major_axis=6367470) + + gridType = eccodes.codes_get_string(self.grib_message, "gridType") + + if gridType in ["regular_ll", "regular_gg", "reduced_ll", "reduced_gg"]: + self.extra_keys["_x_coord_name"] = "longitude" + self.extra_keys["_y_coord_name"] = "latitude" + self.extra_keys["_coord_system"] = geoid + elif gridType == "rotated_ll": + # TODO: Confirm the translation from angleOfRotation to + # north_pole_lon (usually 0 for both) + self.extra_keys["_x_coord_name"] = "grid_longitude" + self.extra_keys["_y_coord_name"] = "grid_latitude" + southPoleLon = longitudeOfSouthernPoleInDegrees + southPoleLat = latitudeOfSouthernPoleInDegrees + self.extra_keys["_coord_system"] = coord_systems.RotatedGeogCS( + -southPoleLat, + math.fmod(southPoleLon + 180.0, 360.0), + self.angleOfRotation, + geoid, + ) + elif gridType == "polar_stereographic": + self.extra_keys["_x_coord_name"] = "projection_x_coordinate" + self.extra_keys["_y_coord_name"] = "projection_y_coordinate" + + if self.projectionCentreFlag == 0: + pole_lat = 90 + elif self.projectionCentreFlag == 1: + pole_lat = -90 + else: + raise TranslationError("Unhandled projectionCentreFlag") + + # Always load PolarStereographic - never Stereographic. + # Stereographic is a CF/Iris concept and not something described + # in GRIB. + # Note: I think the grib api defaults LaDInDegrees to 60 for grib1. + self.extra_keys["_coord_system"] = coord_systems.PolarStereographic( + pole_lat, + self.orientationOfTheGridInDegrees, + 0, + 0, + self.LaDInDegrees, + ellipsoid=geoid, + ) + + elif gridType == "lambert": + self.extra_keys["_x_coord_name"] = "projection_x_coordinate" + self.extra_keys["_y_coord_name"] = "projection_y_coordinate" + + flag_name = "projectionCenterFlag" + + if getattr(self, flag_name) == 0: + pole_lat = 90 + elif getattr(self, flag_name) == 1: + pole_lat = -90 + else: + raise TranslationError("Unhandled projectionCentreFlag") + + LambertConformal = coord_systems.LambertConformal + self.extra_keys["_coord_system"] = LambertConformal( + self.LaDInDegrees, + self.LoVInDegrees, + 0, + 0, + secant_latitudes=(self.Latin1InDegrees, self.Latin2InDegrees), + ellipsoid=geoid, + ) + else: + raise TranslationError("unhandled grid type: {}".format(gridType)) + + match gridType: + case "regular_ll" | "rotated_ll": + self._regular_longitude_common() + j_step = self.jDirectionIncrementInDegrees + if not self.jScansPositively: + j_step = -j_step + self._y_points = ( + np.arange(self.Nj, dtype=np.float64) * j_step + + self.latitudeOfFirstGridPointInDegrees + ) + + case "regular_gg": + # longitude coordinate is straight-forward + self._regular_longitude_common() + # get the distinct latitudes, and make sure they are sorted + # (south-to-north) and then put them in the right direction + # depending on the scan direction + latitude_points = eccodes.codes_get_double_array( + self.grib_message, "distinctLatitudes" + ).astype(np.float64) + latitude_points.sort() + if not self.jScansPositively: + # we require latitudes north-to-south + self._y_points = latitude_points[::-1] + else: + self._y_points = latitude_points + + case "polar_stereographic" | "lambert": + # convert the starting latlon into meters + cartopy_crs = self.extra_keys["_coord_system"].as_cartopy_crs() + x1, y1 = cartopy_crs.transform_point( + self.longitudeOfFirstGridPointInDegrees, + self.latitudeOfFirstGridPointInDegrees, + ccrs.Geodetic(), + ) + + if not np.all(np.isfinite([x1, y1])): + raise TranslationError( + "Could not determine the first latitude" + " and/or longitude grid point." + ) + + self._x_points = x1 + self.DxInMetres * np.arange( + self.Nx, dtype=np.float64 + ) + self._y_points = y1 + self.DyInMetres * np.arange( + self.Ny, dtype=np.float64 + ) + + case "reduced_ll" | "reduced_gg": + self._x_points = self.longitudes + self._y_points = self.latitudes + + case _: + raise TranslationError("unhandled grid type") + + def _regular_longitude_common(self): + """Define a regular longitude dimension.""" + i_step = self.iDirectionIncrementInDegrees + if self.iScansNegatively: + i_step = -i_step + self._x_points = ( + np.arange(self.Ni, dtype=np.float64) * i_step + + self.longitudeOfFirstGridPointInDegrees + ) + if "longitude" in self.extra_keys["_x_coord_name"] and self.Ni > 1: + if _longitude_is_cyclic(self._x_points): + self.extra_keys["_x_circular"] = True + + def _get_processing_done(self): + """Determine the type of processing that was done on the data.""" + + processingDone = "unknown" + timeRangeIndicator = self.timeRangeIndicator + default = "time _grib1_process_unknown_%i" % timeRangeIndicator + processingDone = TIME_RANGE_INDICATORS.get(timeRangeIndicator, default) + + return processingDone + + def _get_verification_date(self): + reference_date_time = self._referenceDateTime + + # calculate start time + time_range_indicator = self.timeRangeIndicator + P1 = self.P1 + P2 = self.P2 + if time_range_indicator == 0: + # Forecast product valid at reference time + P1 P1>0), + # or Uninitialized analysis product for reference time (P1=0). + # Or Image product for reference time (P1=0) + time_diff = P1 + elif time_range_indicator == 1: + # Initialized analysis product for reference time (P1=0). + time_diff = P1 + elif time_range_indicator == 2: + # Product with a valid time ranging between reference time + P1 + # and reference time + P2 + time_diff = (P1 + P2) * 0.5 + elif time_range_indicator == 3: + # Average(reference time + P1 to reference time + P2) + time_diff = (P1 + P2) * 0.5 + elif time_range_indicator == 4: + # Accumulation (reference time + P1 to reference time + P2) + # product considered valid at reference time + P2 + time_diff = P2 + elif time_range_indicator == 5: + # Difference(reference time + P2 minus reference time + P1) + # product considered valid at reference time + P2 + time_diff = P2 + elif time_range_indicator == 10: + # P1 occupies octets 19 and 20; product valid at + # reference time + P1 + time_diff = P1 * 256 + P2 + elif time_range_indicator == 51: + # Climatological Mean Value: multiple year averages of + # quantities which are themselves means over some period of + # time (P2) less than a year. The reference time (R) indicates + # the date and time of the start of a period of time, given by + # R to R + P2, over which a mean is formed; N indicates the number + # of such period-means that are averaged together to form the + # climatological value, assuming that the N period-mean fields + # are separated by one year. The reference time indicates the + # start of the N-year climatology. N is given in octets 22-23 + # of the PDS. If P1 = 0 then the data averaged in the basic + # interval P2 are assumed to be continuous, i.e., all available + # data are simply averaged together. If P1 = 1 (the units of + # time - octet 18, code table 4 - are not relevant here) then + # the data averaged together in the basic interval P2 are valid + # only at the time (hour, minute) given in the reference time, + # for all the days included in the P2 period. The units of P2 + # are given by the contents of octet 18 and Table 4. + raise TranslationError( + "unhandled grib1 timeRangeIndicator = 51 (avg of avgs)" + ) + elif time_range_indicator == 113: + # Average of N forecasts (or initialized analyses); each + # product has forecast period of P1 (P1=0 for initialized + # analyses); products have reference times at intervals of P2, + # beginning at the given reference time. + time_diff = P1 + elif time_range_indicator == 114: + # Accumulation of N forecasts (or initialized analyses); each + # product has forecast period of P1 (P1=0 for initialized + # analyses); products have reference times at intervals of P2, + # beginning at the given reference time. + time_diff = P1 + elif time_range_indicator == 115: + # Average of N forecasts, all with the same reference time; + # the first has a forecast period of P1, the remaining + # forecasts follow at intervals of P2. + time_diff = P1 + elif time_range_indicator == 116: + # Accumulation of N forecasts, all with the same reference + # time; the first has a forecast period of P1, the remaining + # follow at intervals of P2. + time_diff = P1 + elif time_range_indicator == 117: + # Average of N forecasts, the first has a period of P1, the + # subsequent ones have forecast periods reduced from the + # previous one by an interval of P2; the reference time for + # the first is given in octets 13-17, the subsequent ones + # have reference times increased from the previous one by + # an interval of P2. Thus all the forecasts have the same + # valid time, given by the initial reference time + P1. + time_diff = P1 + elif time_range_indicator == 118: + # Temporal variance, or covariance, of N initialized analyses; + # each product has forecast period P1=0; products have + # reference times at intervals of P2, beginning at the given + # reference time. + time_diff = P1 + elif time_range_indicator == 123: + # Average of N uninitialized analyses, starting at the + # reference time, at intervals of P2. + time_diff = P1 + elif time_range_indicator == 124: + # Accumulation of N uninitialized analyses, starting at + # the reference time, at intervals of P2. + time_diff = P1 + else: + raise TranslationError( + "unhandled grib1 timeRangeIndicator = %i" % time_range_indicator + ) + + # Get the timeunit interval. + interval_secs = self._timeunit_seconds() + # Multiply by start-offset and convert to a timedelta. + # NOTE: a 'float' conversion is required here, as time_diff may be + # a numpy scalar, which timedelta will not accept. + interval_delta = datetime.timedelta(seconds=float(time_diff * interval_secs)) + # Return validity_time = (reference_time + start_offset*time_unit). + return reference_date_time + interval_delta + + @property + def bmdi(self): + # Not sure of any cases where GRIB provides a fill value. + # Default for fill value is None. + return None + + def core_data(self): + try: + data = self._data + except AttributeError: + data = self.data + return data + + def phenomenon_points(self, time_unit): + """Return the phenomenon time points. + + As offsets from the epoch time reference, measured in appropriate time units. + + """ + time_reference = "%s since epoch" % time_unit + return float( + cf_units.date2num( + self._phenomenonDateTime, time_reference, cf_units.CALENDAR_GREGORIAN + ) + ) + + def phenomenon_bounds(self, time_unit): + """Return the phenomenon time bounds. + + As offsets from the epoch time reference, measured in appropriate time units. + + """ + # TODO #576 Investigate when it's valid to get phenomenon_bounds + time_reference = "%s since epoch" % time_unit + unit = cf_units.Unit(time_reference, cf_units.CALENDAR_GREGORIAN) + return [ + float(unit.date2num(self._periodStartDateTime)), + float(unit.date2num(self._periodEndDateTime)), + ] + + +def _longitude_is_cyclic(points): + """Work out if a set of longitude points is cyclic.""" + # Is the gap from end to start smaller, or about equal to the max step? + gap = 360.0 - abs(points[-1] - points[0]) + max_step = abs(np.diff(points)).max() + cyclic = False + if gap <= max_step: + cyclic = True + else: + delta = 0.001 + if abs(1.0 - gap / max_step) < delta: + cyclic = True + return cyclic + + +def _message_values(grib_message, shape): + eccodes.codes_set_double(grib_message, "missingValue", np.nan) + data = eccodes.codes_get_double_array(grib_message, "values") + data = data.reshape(shape) + + # Handle missing values in a sensible way. + mask = np.isnan(data) + if mask.any(): + data = ma.array(data, mask=mask, fill_value=np.nan) + return data diff --git a/src/iris_grib/_grib2_convert.py b/src/iris_grib/_grib2_convert.py new file mode 100644 index 00000000..ef1553c0 --- /dev/null +++ b/src/iris_grib/_grib2_convert.py @@ -0,0 +1,2975 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Module to support loading of GRIB2 data. + +Code to convert a GRIB2 message into cube metadata. + +""" + +from collections import namedtuple +from collections.abc import Iterable +from contextlib import contextmanager +from datetime import datetime, timedelta +import math +import threading +import warnings + +import cartopy.crs as ccrs +from cf_units import CALENDAR_GREGORIAN, Unit +import numpy as np +import numpy.ma as ma + +from iris.aux_factory import HybridPressureFactory, HybridHeightFactory +import iris.coord_systems as icoord_systems +from iris.coords import AuxCoord, DimCoord, CellMethod +from iris.exceptions import TranslationError +from . import grib_phenom_translation as itranslation +from .grib_phenom_translation import GRIBCode +from iris.fileformats.rules import ( + Factory, + Reference, + ReferenceTarget, +) +from iris.util import _is_circular + +from iris_grib._load_convert import options + +ScanningMode = namedtuple( + "ScanningMode", ["i_negative", "j_positive", "j_consecutive", "i_alternative"] +) + +ProjectionCentre = namedtuple( + "ProjectionCentre", ["south_pole_on_projection_plane", "bipolar_and_symmetric"] +) + +ResolutionFlags = namedtuple( + "ResolutionFlags", ["i_increments_given", "j_increments_given", "uv_resolved"] +) + +FixedSurface = namedtuple( + "FixedSurface", ["standard_name", "long_name", "units", "point"] +) + +InterpolationParameters = namedtuple( + "InterpolationParameters", + ["interpolation_type", "statistical_process", "number_of_points_used"], +) +# Regulations 92.1.6. +_GRID_ACCURACY_IN_DEGREES = 1e-6 # 1/1,000,000 of a degree + +# Reference Common Code Table C-1. +_CENTRES = {"ecmf": "European Centre for Medium Range Weather Forecasts"} + +# Reference Code Table 1.0 +_CODE_TABLES_MISSING = 255 + +# UDUNITS-2 units time string. Reference GRIB2 Code Table 4.4. +_TIME_RANGE_UNITS = { + 0: "minutes", + 1: "hours", + 2: "days", + # 3: 'months', Unsupported + # 4: 'years', Unsupported + # 5: '10 years', Unsupported + # 6: '30 years', Unsupported + # 7: '100 years', Unsupported + # 8-9 Reserved + 10: "3 hours", + 11: "6 hours", + 12: "12 hours", + 13: "seconds", +} +# Regulation 92.1.4 +_TIME_RANGE_MISSING = 2**32 - 1 + +# Reference Code Table 4.5. Point values are included where these are part of the fixed +# surface definition. None indicates this comes from the GRIB metadata, +# eg. "scaledValueOfFirstFixedSurface". +_FIXED_SURFACE = { + 1: FixedSurface(None, "height", "m", 0.0), # Ground or water surface + 4: FixedSurface(None, "air_temperature", "Celsius", 0.0), # Level of 0C isotherm + 100: FixedSurface(None, "pressure", "Pa", None), # Isobaric surface + 103: FixedSurface(None, "height", "m", None), # Height level above ground +} +_TYPE_OF_FIXED_SURFACE_MISSING = 255 + +# Reference Code Table 6.0 +_BITMAP_CODE_PRESENT = 0 +_BITMAP_CODE_NONE = 255 + +# Reference Code Table 4.10. +_STATISTIC_TYPE_NAMES = { + 0: "mean", + 1: "sum", + 2: "maximum", + 3: "minimum", + 6: "standard_deviation", +} + +# Reference Code Table 4.11. +_STATISTIC_TYPE_OF_TIME_INTERVAL = { + 2: "same start time of forecast, forecast time is incremented" +} +# NOTE: Our test data contains the value 2, which is all we currently support. +# The exact interpretation of this is still unclear. + +# See Code Table 4.15 for full spatial processing type descriptors: +# http://apps.ecmwf.int/codes/grib/format/grib2/ctables/4/15 + +# InterpolationParameters(spatial process descriptor, statistical process +# (octet 35), number of points used in interpolation (octet 37)) +_SPATIAL_PROCESSING_TYPES = { + 0: InterpolationParameters("No interpolation", "cell_method", 0), + 1: InterpolationParameters("Bilinear interpolation", None, 4), + 2: InterpolationParameters("Bicubic interpolation", None, 4), + 3: InterpolationParameters("Nearest neighbour interpolation", None, 1), + 4: InterpolationParameters("Budget interpolation", None, 4), + 5: InterpolationParameters("Spectral interpolation", None, 4), + 6: InterpolationParameters("Neighbour-budget interpolation", None, 4), +} + +# Class containing details of a probability analysis. +Probability = namedtuple("Probability", ("probability_type_name", "threshold")) + +# List of grid definition template numbers which use either (i,j) or (x,y) +# for (lat,lon) +_IJGRIDLENGTH_GDT_NUMBERS = (10,) +_XYGRIDLENGTH_GDT_NUMBERS = (20, 30, 31, 110, 140) + + +# Regulation 92.1.12 +def unscale(value, factor): + """ + Implement Regulation 92.1.12. + + Args: + + * value: + Scaled value or sequence of scaled values. + + * factor: + Scale factor or sequence of scale factors. + + Returns: + For scalar value and factor, the unscaled floating point + result is returned. If either value and/or factor are + MDI, then :data:`numpy.ma.masked` is returned. + + For sequence value and factor, the unscaled floating point + :class:`numpy.ndarray` is returned. If either value and/or + factor contain MDI, then :class:`numpy.ma.core.MaskedArray` + is returned. + + """ + + def _unscale(v, f): + return v / 10.0**f + + if isinstance(value, Iterable) or isinstance(factor, Iterable): + + def _masker(item): + # This is a small work around for an edge case, which is not + # evident in any of our sample GRIB2 messages, where an array + # of header elements contains missing values. + # iris.fileformats.grib.message returns these as None, but they + # are wanted as a numerical masked array, so a temporary mdi + # value is used, selected from a legacy implementation of iris, + # to construct the masked array. The valure is transient, only in + # scope for this function. + numerical_mdi = 2**32 - 1 + item = [numerical_mdi if i is None else i for i in item] + result = ma.masked_equal(item, numerical_mdi) + if ma.count_masked(result): + # Circumvent downstream NumPy "RuntimeWarning" + # of "overflow encountered in power" in _unscale + # for data containing _MDI. Remove transient _MDI value. + result.data[result.mask] = 0 + return result + + value = _masker(value) + factor = _masker(factor) + result = _unscale(value, factor) + if ma.count_masked(result) == 0: + result = result.data + else: + result = ma.masked + if value != _MDI and factor != _MDI: + result = _unscale(value, factor) + return result + + +# Use ECCodes to recognise missing value +_MDI = None + + +class HindcastOverflowWarning(Warning): + pass + + +# Non-standardised usage for negative forecast times. +def _hindcast_fix(forecast_time): + """Return a forecast time interpreted as a possibly negative value.""" + uft = np.array(forecast_time).astype(np.int64) + HIGHBIT = 2**30 + + # Workaround grib api's assumption that forecast time is positive. + # Handles correctly encoded -ve forecast times up to one -1 billion. + if 2 * HIGHBIT < uft < 3 * HIGHBIT: + original_forecast_time = forecast_time + forecast_time = -(uft - 2 * HIGHBIT) + if options.warn_on_unsupported: + msg = "Re-interpreting large grib forecastTime from {} to {}.".format( + original_forecast_time, forecast_time + ) + warnings.warn(msg, HindcastOverflowWarning) + + return forecast_time + + +############################################################################### +# +# Identification Section 1 +# +############################################################################### + + +def reference_time_coord(section): + """ + Translate section 1 reference time according to its significance. + + Reference section 1, year octets 13-14, month octet 15, day octet 16, + hour octet 17, minute octet 18, second octet 19. + + Returns: + The scalar reference time :class:`iris.coords.DimCoord`. + + """ + # Look-up standard name by significanceOfReferenceTime. + _lookup = { + 0: "forecast_reference_time", + 1: "forecast_reference_time", + 2: "time", + 3: "time", + } + + # Calculate the reference time and units. + dt = datetime( + section["year"], + section["month"], + section["day"], + section["hour"], + section["minute"], + section["second"], + ) + # XXX Defaulting to a Gregorian calendar. + # Current GRIBAPI does not cover GRIB Section 1 - Octets 22-nn (optional) + # which are part of GRIB spec v12. + unit = Unit("hours since epoch", calendar=CALENDAR_GREGORIAN) + point = float(unit.date2num(dt)) + + # Reference Code Table 1.2. + significanceOfReferenceTime = section["significanceOfReferenceTime"] + standard_name = _lookup.get(significanceOfReferenceTime) + + if standard_name is None: + msg = ( + "Identificaton section 1 contains an unsupported significance " + "of reference time [{}]".format(significanceOfReferenceTime) + ) + raise TranslationError(msg) + + # Create the associated reference time of data coordinate. + coord = DimCoord(point, standard_name=standard_name, units=unit) + + return coord + + +############################################################################### +# +# Grid Definition Section 3 +# +############################################################################### + + +def projection_centre(projectionCentreFlag): + """ + Translate the projection centre flag bitmask. + + Reference GRIB2 Flag Table 3.5. + + Args: + + * projectionCentreFlag + Message section 3, coded key value. + + Returns: + A :class:`collections.namedtuple` representation. + + """ + south_pole_on_projection_plane = bool(projectionCentreFlag & 0x80) + bipolar_and_symmetric = bool(projectionCentreFlag & 0x40) + return ProjectionCentre(south_pole_on_projection_plane, bipolar_and_symmetric) + + +def scanning_mode(scanningMode): + """ + Translate the scanning mode bitmask. + + Reference GRIB2 Flag Table 3.4. + + Args: + + * scanningMode: + Message section 3, coded key value. + + Returns: + A :class:`collections.namedtuple` representation. + + """ + i_negative = bool(scanningMode & 0x80) + j_positive = bool(scanningMode & 0x40) + j_consecutive = bool(scanningMode & 0x20) + i_alternative = bool(scanningMode & 0x10) + + if i_alternative: + msg = ( + "Grid definition section 3 contains unsupported " + "alternative row scanning mode" + ) + raise TranslationError(msg) + + return ScanningMode(i_negative, j_positive, j_consecutive, i_alternative) + + +def resolution_flags(resolutionAndComponentFlags): + """ + Translate the resolution and component bitmask. + + Reference GRIB2 Flag Table 3.3. + + Args: + + * resolutionAndComponentFlags: + Message section 3, coded key value. + + Returns: + A :class:`collections.namedtuple` representation. + + """ + i_inc_given = bool(resolutionAndComponentFlags & 0x20) + j_inc_given = bool(resolutionAndComponentFlags & 0x10) + uv_resolved = bool(resolutionAndComponentFlags & 0x08) + + return ResolutionFlags(i_inc_given, j_inc_given, uv_resolved) + + +def ellipsoid(shapeOfTheEarth, major, minor, radius): + """ + Translate the shape of the earth code. + + Convert the code to an appropriate Iris coordinate system. + + For MDI set either major and minor or radius to :data:`numpy.ma.masked` + + Reference GRIB2 Code Table 3.2. + + Args: + + * shapeOfTheEarth: + Message section 3, octet 15. + + * major: + Semi-major axis of the oblate spheroid in units determined by + the shapeOfTheEarth. + + * minor: + Semi-minor axis of the oblate spheroid in units determined by + the shapeOfTheEarth. + + * radius: + Radius of sphere (in m). + + Returns: + :class:`iris.coord_systems.CoordSystem` + + """ + # Supported shapeOfTheEarth values. + if shapeOfTheEarth not in (0, 1, 2, 3, 4, 5, 6, 7): + msg = ( + "Grid definition section 3 contains an unsupported " + "shape of the earth [{}]".format(shapeOfTheEarth) + ) + raise TranslationError(msg) + + if shapeOfTheEarth == 0: + # Earth assumed spherical with radius of 6 367 470.0m + result = icoord_systems.GeogCS(6367470) + elif shapeOfTheEarth == 1: + # Earth assumed spherical with radius specified (in m) by + # data producer. + if ma.is_masked(radius): + msg = ( + "Ellipsoid for shape of the earth {} requires a" + "radius to be specified.".format(shapeOfTheEarth) + ) + raise ValueError(msg) + result = icoord_systems.GeogCS(radius) + elif shapeOfTheEarth == 2: + # Earth assumed oblate spheroid with size as determined by IAU in 1965. + result = icoord_systems.GeogCS(6378160, inverse_flattening=297.0) + elif shapeOfTheEarth in [3, 7]: + # Earth assumed oblate spheroid with major and minor axes + # specified (in km)/(in m) by data producer. + emsg_oblate = ( + "Ellipsoid for shape of the earth [{}] requires a" + "semi-{} axis to be specified." + ) + if ma.is_masked(major): + raise ValueError(emsg_oblate.format(shapeOfTheEarth, "major")) + if ma.is_masked(minor): + raise ValueError(emsg_oblate.format(shapeOfTheEarth, "minor")) + # Check whether to convert from km to m. + if shapeOfTheEarth == 3: + major *= 1000 + minor *= 1000 + result = icoord_systems.GeogCS(major, minor) + elif shapeOfTheEarth == 4: + # Earth assumed oblate spheroid as defined in IAG-GRS80 model. + result = icoord_systems.GeogCS(6378137, inverse_flattening=298.257222101) + elif shapeOfTheEarth == 5: + # Earth assumed represented by WGS84 (as used by ICAO since 1998). + result = icoord_systems.GeogCS(6378137, inverse_flattening=298.257223563) + elif shapeOfTheEarth == 6: + # Earth assumed spherical with radius of 6 371 229.0m + result = icoord_systems.GeogCS(6371229) + + return result + + +def ellipsoid_geometry(section): + """ + Calculated the unscaled ellipsoid major-axis, minor-axis and radius. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message. + + Returns: + Tuple containing the major-axis, minor-axis and radius. + + """ + major = unscale( + section["scaledValueOfEarthMajorAxis"], section["scaleFactorOfEarthMajorAxis"] + ) + minor = unscale( + section["scaledValueOfEarthMinorAxis"], section["scaleFactorOfEarthMinorAxis"] + ) + radius = unscale( + section["scaledValueOfRadiusOfSphericalEarth"], + section["scaleFactorOfRadiusOfSphericalEarth"], + ) + return major, minor, radius + + +def _calculate_increment(first_point, last_point, n_increments, mod=math.inf): + """ + Calculate a directional increment. + + Calculate the directional increment from the difference between the grid + point values divided by the total number of increments. Required by + template 0 & 40 when no increment values are provided. + """ + return (last_point - first_point) % mod / n_increments + + +def grid_definition_template_0_and_1(section, metadata, y_name, x_name, cs): + """ + Translate grid definition templates 0 and 1. + + Translate elements common to templates 0 and 1. + These templates represent regularly spaced latitude/longitude + on either a standard or rotated grid. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * y_name: + Name of the Y coordinate, e.g. latitude or grid_latitude. + + * x_name: + Name of the X coordinate, e.g. longitude or grid_longitude. + + * cs: + The :class:`iris.coord_systems.CoordSystem` to use when creating + the X and Y coordinates. + + """ + # Abort if this is a reduced grid, that case isn't handled yet. + if ( + section["numberOfOctectsForNumberOfPoints"] != 0 + or section["interpretationOfNumberOfPoints"] != 0 + ): + msg = "Grid definition section 3 contains unsupported quasi-regular grid" + raise TranslationError(msg) + + scan = scanning_mode(section["scanningMode"]) + + # Set resolution flags + res_flags = resolution_flags(section["resolutionAndComponentFlags"]) + + # Calculate longitude points. + x_inc = ( + section["iDirectionIncrement"] + if res_flags.i_increments_given + else _calculate_increment( + section["longitudeOfFirstGridPoint"], + section["longitudeOfLastGridPoint"], + section["Ni"] - 1, + 360.0 / _GRID_ACCURACY_IN_DEGREES, + ) + ) + x_inc *= _GRID_ACCURACY_IN_DEGREES + x_offset = section["longitudeOfFirstGridPoint"] * _GRID_ACCURACY_IN_DEGREES + x_direction = -1 if scan.i_negative else 1 + Ni = section["Ni"] + x_points = np.arange(Ni, dtype=np.float64) * x_inc * x_direction + x_offset + + # Determine whether the x-points (in degrees) are circular. + circular = _is_circular(x_points, 360.0) + + # Calculate latitude points. + y_inc = ( + section["jDirectionIncrement"] + if res_flags.j_increments_given + else _calculate_increment( + section["latitudeOfFirstGridPoint"], + section["latitudeOfLastGridPoint"], + section["Nj"] - 1, + ) + ) + y_inc *= _GRID_ACCURACY_IN_DEGREES + y_offset = section["latitudeOfFirstGridPoint"] * _GRID_ACCURACY_IN_DEGREES + y_direction = 1 if scan.j_positive else -1 + Nj = section["Nj"] + y_points = np.arange(Nj, dtype=np.float64) * y_inc * y_direction + y_offset + + # Create the lat/lon coordinates. + y_coord = DimCoord(y_points, standard_name=y_name, units="degrees", coord_system=cs) + x_coord = DimCoord( + x_points, + standard_name=x_name, + units="degrees", + coord_system=cs, + circular=circular, + ) + + # Determine the lat/lon dimensions. + y_dim, x_dim = 0, 1 + if scan.j_consecutive: + y_dim, x_dim = 1, 0 + + # Add the lat/lon coordinates to the metadata dim coords. + metadata["dim_coords_and_dims"].append((y_coord, y_dim)) + metadata["dim_coords_and_dims"].append((x_coord, x_dim)) + + +def grid_definition_template_0(section, metadata): + """ + Translate grid definition template 0. + + Template representing regular latitude/longitude grid (regular_ll). + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message + + * metadata: + :class:`collections.OrderedDict` of metadata. + + """ + # Determine the coordinate system. + major, minor, radius = ellipsoid_geometry(section) + cs = ellipsoid(section["shapeOfTheEarth"], major, minor, radius) + grid_definition_template_0_and_1(section, metadata, "latitude", "longitude", cs) + + +def grid_definition_template_1(section, metadata): + """ + Translate grid definition template 1. + + Template representing rotated latitude/longitude grid. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message + + * metadata: + :class:`collections.OrderedDict` of metadata. + + """ + # Determine the coordinate system. + major, minor, radius = ellipsoid_geometry(section) + south_pole_lat = section["latitudeOfSouthernPole"] * _GRID_ACCURACY_IN_DEGREES + south_pole_lon = section["longitudeOfSouthernPole"] * _GRID_ACCURACY_IN_DEGREES + cs = icoord_systems.RotatedGeogCS( + -south_pole_lat, + math.fmod(south_pole_lon + 180, 360), + section["angleOfRotation"], + ellipsoid(section["shapeOfTheEarth"], major, minor, radius), + ) + grid_definition_template_0_and_1( + section, metadata, "grid_latitude", "grid_longitude", cs + ) + + +def grid_definition_template_4_and_5(section, metadata, y_name, x_name, cs): + """ + Translate grid definition templates 4 and 5. + + Translate elements common to templates 4 and 5. + These templates represent variable-resolution latitude/longitude + and common forms of variable-resolution rotated latitude/longitude. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * y_name: + Name of the Y coordinate, e.g. 'latitude' or 'grid_latitude'. + + * x_name: + Name of the X coordinate, e.g. 'longitude' or 'grid_longitude'. + + * cs: + The :class:`iris.coord_systems.CoordSystem` to use when creating + the X and Y coordinates. + + """ + # Determine the (variable) units of resolution. + key = "basicAngleOfTheInitialProductionDomain" + basicAngleOfTheInitialProductDomain = section[key] + subdivisionsOfBasicAngle = section["subdivisionsOfBasicAngle"] + + if basicAngleOfTheInitialProductDomain in [0, _MDI]: + basicAngleOfTheInitialProductDomain = 1.0 + + if subdivisionsOfBasicAngle in [0, _MDI]: + subdivisionsOfBasicAngle = 1.0 / _GRID_ACCURACY_IN_DEGREES + + resolution = np.float64(basicAngleOfTheInitialProductDomain) + resolution /= subdivisionsOfBasicAngle + flags = resolution_flags(section["resolutionAndComponentFlags"]) + + # Grid Definition Template 3.4. Notes (2). + # Flag bits 3-4 are not applicable for this template. + if flags.uv_resolved and options.warn_on_unsupported: + msg = "Unable to translate resolution and component flags." + warnings.warn(msg) + + # Calculate the latitude and longitude points. + x_points = np.array(section["longitudes"], dtype=np.float64) * resolution + y_points = np.array(section["latitudes"], dtype=np.float64) * resolution + + # Determine whether the x-points (in degrees) are circular. + circular = _is_circular(x_points, 360.0) + + # Create the lat/lon coordinates. + y_coord = DimCoord(y_points, standard_name=y_name, units="degrees", coord_system=cs) + x_coord = DimCoord( + x_points, + standard_name=x_name, + units="degrees", + coord_system=cs, + circular=circular, + ) + + scan = scanning_mode(section["scanningMode"]) + + # Determine the lat/lon dimensions. + y_dim, x_dim = 0, 1 + if scan.j_consecutive: + y_dim, x_dim = 1, 0 + + # Add the lat/lon coordinates to the metadata dim coords. + metadata["dim_coords_and_dims"].append((y_coord, y_dim)) + metadata["dim_coords_and_dims"].append((x_coord, x_dim)) + + +def grid_definition_template_4(section, metadata): + """ + Translate grid definition template 4. + + This template represents variable resolution latitude/longitude. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + """ + # Determine the coordinate system. + major, minor, radius = ellipsoid_geometry(section) + cs = ellipsoid(section["shapeOfTheEarth"], major, minor, radius) + grid_definition_template_4_and_5(section, metadata, "latitude", "longitude", cs) + + +def grid_definition_template_5(section, metadata): + """ + Translate grid definition template 5. + + This template represents variable resolution rotated + latitude/longitude. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + """ + # Determine the coordinate system. + major, minor, radius = ellipsoid_geometry(section) + south_pole_lat = section["latitudeOfSouthernPole"] * _GRID_ACCURACY_IN_DEGREES + south_pole_lon = section["longitudeOfSouthernPole"] * _GRID_ACCURACY_IN_DEGREES + cs = icoord_systems.RotatedGeogCS( + -south_pole_lat, + math.fmod(south_pole_lon + 180, 360), + section["angleOfRotation"], + ellipsoid(section["shapeOfTheEarth"], major, minor, radius), + ) + grid_definition_template_4_and_5( + section, metadata, "grid_latitude", "grid_longitude", cs + ) + + +def grid_definition_template_10(section, metadata): + """ + Translate grid definition template 10. + + This template represents a Mercator grid. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + """ + major, minor, radius = ellipsoid_geometry(section) + geog_cs = ellipsoid(section["shapeOfTheEarth"], major, minor, radius) + + # standard_parallel is the latitude at which the Mercator projection + # intersects the Earth + standard_parallel = section["LaD"] * _GRID_ACCURACY_IN_DEGREES + + cs = icoord_systems.Mercator(standard_parallel=standard_parallel, ellipsoid=geog_cs) + + # Create the X and Y coordinates. + x_coord, y_coord, scan = _calculate_proj_coords_from_grid_lengths(section, cs) + + # Determine the lat/lon dimensions. + y_dim, x_dim = 0, 1 + if scan.j_consecutive: + y_dim, x_dim = 1, 0 + + # Add the X and Y coordinates to the metadata dim coords. + metadata["dim_coords_and_dims"].append((y_coord, y_dim)) + metadata["dim_coords_and_dims"].append((x_coord, x_dim)) + + +def grid_definition_template_12(section, metadata): + """ + Translate grid definition template 12. + + This template represents transverse Mercator. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + """ + major, minor, radius = ellipsoid_geometry(section) + geog_cs = ellipsoid(section["shapeOfTheEarth"], major, minor, radius) + + lat = section["latitudeOfReferencePoint"] * _GRID_ACCURACY_IN_DEGREES + lon = section["longitudeOfReferencePoint"] * _GRID_ACCURACY_IN_DEGREES + scale = section["scaleFactorAtReferencePoint"] + CM_TO_M = 0.01 + easting = section["XR"] * CM_TO_M + northing = section["YR"] * CM_TO_M + cs = icoord_systems.TransverseMercator(lat, lon, easting, northing, scale, geog_cs) + + # Fetch grid extents + x1 = section["X1"] + y1 = section["Y1"] + x2 = section["X2"] + y2 = section["Y2"] + + # Rather unhelpfully this grid definition template seems to be + # overspecified, and thus open to inconsistency. But for determining + # the extents the X1, Y1, X2, and Y2 points have the highest + # precision, as opposed to using Di and Dj. + # Check whether Di and Dj are as consistent as possible with that + # interpretation - i.e. they are within 1cm. + def check_range(v1, v2, n, d, axis_name): + small = min(v1, v2) + large = max(v1, v2) + min_last = small + (n - 1) * (d - 1) + max_last = small + (n - 1) * (d + 1) + if not (min_last < large < max_last): + message = ( + f"File grid {axis_name} definition inconsistent: " + f"{v1} to {v2} in {n} steps is incompatible with step-size " + f"{d} ." + ) + raise TranslationError(message) + + check_range(x1, x2, section["Ni"], section["Di"], "X") + check_range(y1, y2, section["Nj"], section["Dj"], "Y") + + # Further over-specification - the sequence of X1 & X2 is enough to + # generate the sequence in the correct direction (also Y1 & Y2). All + # scanningMode can do is add confusion; warn if there is inconsistency. + def validate_scanning(axis: str, stated: bool, encoded: bool): + def scan_str(scanning_bool): + return "positive" if scanning_bool else "negative" + + if stated != encoded: + message = ( + f"File grid {axis} definition inconsistent: " + f"scanningMode = {scan_str(stated)}, actual grid point " + f"direction is {scan_str(encoded)}." + ) + warnings.warn(message) + + scan = scanning_mode(section["scanningMode"]) + validate_scanning("X", not scan.i_negative, x1 < x2) + validate_scanning("Y", scan.j_positive, y1 < y2) + + x_points = np.linspace(x1 * CM_TO_M, x2 * CM_TO_M, section["Ni"]) + y_points = np.linspace(y1 * CM_TO_M, y2 * CM_TO_M, section["Nj"]) + + # Create the X and Y coordinates. + y_coord = DimCoord(y_points, "projection_y_coordinate", units="m", coord_system=cs) + x_coord = DimCoord(x_points, "projection_x_coordinate", units="m", coord_system=cs) + + # Determine the lat/lon dimensions. + y_dim, x_dim = 0, 1 + if scan.j_consecutive: + y_dim, x_dim = 1, 0 + + # Add the X and Y coordinates to the metadata dim coords. + metadata["dim_coords_and_dims"].append((y_coord, y_dim)) + metadata["dim_coords_and_dims"].append((x_coord, x_dim)) + + +def grid_definition_template_20(section, metadata): + """ + Translate grid definition template 20. + + This template represents a Polar Stereographic grid. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + """ + major, minor, radius = ellipsoid_geometry(section) + geog_cs = ellipsoid(section["shapeOfTheEarth"], major, minor, radius) + + proj_centre = projection_centre(section["projectionCentreFlag"]) + if proj_centre.bipolar_and_symmetric: + raise TranslationError( + "Bipolar and symmetric polar stereo projections" + " are not supported by the " + "grid_definition_template_20 translation." + ) + if proj_centre.south_pole_on_projection_plane: + central_lat = -90.0 + else: + central_lat = 90.0 + central_lon = section["orientationOfTheGrid"] * _GRID_ACCURACY_IN_DEGREES + true_scale_lat = section["LaD"] * _GRID_ACCURACY_IN_DEGREES + # Always load PolarStereographic - never Stereographic. + # Stereographic is a CF/Iris concept and not something described + # in GRIB. + cs = icoord_systems.PolarStereographic( + central_lat=central_lat, + central_lon=central_lon, + true_scale_lat=true_scale_lat, + ellipsoid=geog_cs, + ) + x_coord, y_coord, scan = _calculate_proj_coords_from_grid_lengths(section, cs) + + # Determine the order of the dimensions. + y_dim, x_dim = 0, 1 + if scan.j_consecutive: + y_dim, x_dim = 1, 0 + + # Add the projection coordinates to the metadata dim coords. + metadata["dim_coords_and_dims"].append((y_coord, y_dim)) + metadata["dim_coords_and_dims"].append((x_coord, x_dim)) + + +def _calculate_proj_coords_from_grid_lengths(section, cs): + # Construct the coordinate points, the start point is given in millidegrees + # but the distance measurement is in 10-3 m, so a conversion is necessary + # to find the origin in m. + + # Conversion factor millimetres to metres + mm_to_m = 1e-3 + + if section["gridDefinitionTemplateNumber"] in _XYGRIDLENGTH_GDT_NUMBERS: + if section["gridDefinitionTemplateNumber"] == 140: + dx = section["xDirectionGridLengthInMillimetres"] + dy = section["yDirectionGridLengthInMillimetres"] + nx = section["numberOfPointsAlongXAxis"] + ny = section["numberOfPointsAlongYAxis"] + else: + dx = section["Dx"] + dy = section["Dy"] + nx = section["Nx"] + ny = section["Ny"] + elif section["gridDefinitionTemplateNumber"] in _IJGRIDLENGTH_GDT_NUMBERS: + dx = section["Di"] + dy = section["Dj"] + nx = section["Ni"] + ny = section["Nj"] + else: + raise TranslationError("Unsupported lat-lon point parameters") + + scan = scanning_mode(section["scanningMode"]) + lon_0 = section["longitudeOfFirstGridPoint"] * _GRID_ACCURACY_IN_DEGREES + lat_0 = section["latitudeOfFirstGridPoint"] * _GRID_ACCURACY_IN_DEGREES + x0_m, y0_m = cs.as_cartopy_crs().transform_point(lon_0, lat_0, ccrs.Geodetic()) + dx_m = dx * mm_to_m + dy_m = dy * mm_to_m + x_dir = -1 if scan.i_negative else 1 + y_dir = 1 if scan.j_positive else -1 + x_points = x0_m + dx_m * x_dir * np.arange(nx, dtype=np.float64) + y_points = y0_m + dy_m * y_dir * np.arange(ny, dtype=np.float64) + + # Create the dimension coordinates. + x_coord = DimCoord( + x_points, standard_name="projection_x_coordinate", units="m", coord_system=cs + ) + y_coord = DimCoord( + y_points, standard_name="projection_y_coordinate", units="m", coord_system=cs + ) + return x_coord, y_coord, scan + + +def grid_definition_template_30(section, metadata): + """ + Translate grid definition template 30. + + This template represents a Lambert Conformal grid. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + """ + major, minor, radius = ellipsoid_geometry(section) + geog_cs = ellipsoid(section["shapeOfTheEarth"], major, minor, radius) + + central_latitude = section["LaD"] * _GRID_ACCURACY_IN_DEGREES + central_longitude = section["LoV"] * _GRID_ACCURACY_IN_DEGREES + false_easting = 0 + false_northing = 0 + secant_latitudes = ( + section["Latin1"] * _GRID_ACCURACY_IN_DEGREES, + section["Latin2"] * _GRID_ACCURACY_IN_DEGREES, + ) + + cs = icoord_systems.LambertConformal( + central_latitude, + central_longitude, + false_easting, + false_northing, + secant_latitudes=secant_latitudes, + ellipsoid=geog_cs, + ) + + # A projection centre flag is defined for GDT30. However, we don't need to + # know which pole is in the projection plane as Cartopy handles that. The + # Other component of the projection centre flag determines if there are + # multiple projection centres. There is no support for this in Proj4 or + # Cartopy so a translation error is raised if this flag is set. + proj_centre = projection_centre(section["projectionCentreFlag"]) + if proj_centre.bipolar_and_symmetric: + msg = "Unsupported projection centre: Bipolar and symmetric." + raise TranslationError(msg) + + res_flags = resolution_flags(section["resolutionAndComponentFlags"]) + if not res_flags.uv_resolved and options.warn_on_unsupported: + # Vector components are given as relative to east an north, rather than + # relative to the projection coordinates, issue a warning in this case. + # (ideally we need a way to add this information to a cube) + msg = "Unable to translate resolution and component flags." + warnings.warn(msg) + + x_coord, y_coord, scan = _calculate_proj_coords_from_grid_lengths(section, cs) + + # Determine the order of the dimensions. + y_dim, x_dim = 0, 1 + if scan.j_consecutive: + y_dim, x_dim = 1, 0 + + # Add the projection coordinates to the metadata dim coords. + metadata["dim_coords_and_dims"].append((y_coord, y_dim)) + metadata["dim_coords_and_dims"].append((x_coord, x_dim)) + + +def grid_definition_template_40(section, metadata): + """ + Translate an "irregular form" grid definition template 40. + + This template represents a Gaussian grid. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + """ + major, minor, radius = ellipsoid_geometry(section) + cs = ellipsoid(section["shapeOfTheEarth"], major, minor, radius) + + if ( + section["numberOfOctectsForNumberOfPoints"] != 0 + or section["interpretationOfNumberOfPoints"] != 0 + ): + grid_definition_template_40_reduced(section, metadata, cs) + else: + grid_definition_template_40_regular(section, metadata, cs) + + +def grid_definition_template_40_regular(section, metadata, cs): + """ + Translate a "regular form" grid definition template 40. + + This template represents a regular Gaussian grid. + + """ + scan = scanning_mode(section["scanningMode"]) + + # Set resolution flags + res_flags = resolution_flags(section["resolutionAndComponentFlags"]) + + # Calculate longitude points. + x_inc = ( + section["iDirectionIncrement"] + if res_flags.i_increments_given + else _calculate_increment( + section["longitudeOfFirstGridPoint"], + section["longitudeOfLastGridPoint"], + section["Ni"] - 1, + 360.0 / _GRID_ACCURACY_IN_DEGREES, + ) + ) + x_inc *= _GRID_ACCURACY_IN_DEGREES + x_offset = section["longitudeOfFirstGridPoint"] * _GRID_ACCURACY_IN_DEGREES + x_direction = -1 if scan.i_negative else 1 + Ni = section["Ni"] + x_points = np.arange(Ni, dtype=np.float64) * x_inc * x_direction + x_offset + + # Determine whether the x-points (in degrees) are circular. + circular = _is_circular(x_points, 360.0) + + # Get the latitude points. + # + # Gaussian latitudes are defined by Gauss-Legendre quadrature and the Gauss + # quadrature rule (http://en.wikipedia.org/wiki/Gaussian_quadrature). The + # latitudes of a particular Gaussian grid are uniquely defined by the + # number of latitudes between the equator and the pole, N. The latitudes + # are calculated from the roots of a Legendre series which must be + # calculated numerically. This process involves forming a (possibly large) + # companion matrix, computing its eigenvalues, and usually at least one + # application of Newton's method to achieve best results + # (http://en.wikipedia.org/wiki/Newton%27s_method). The latitudes are given + # by the arcsine of the roots converted to degrees. This computation can be + # time-consuming, especially for large grid sizes. + # + # A direct computation would require: + # 1. Reading the coded key 'N' representing the number of latitudes + # between the equator and pole. + # 2. Computing the set of global Gaussian latitudes associated with the + # value of N. + # 3. Determining the direction of the latitude points from the scanning + # mode. + # 4. Producing a subset of the latitudes based on the given first and + # last latitude points, given by the coded keys La1 and La2. + # + # Given the complexity and potential for poor performance of calculating + # the Gaussian latitudes directly, the GRIB-API computed key + # 'distinctLatitudes' is utilised to obtain the latitude points from the + # GRIB2 message. This computed key provides a rapid calculation of the + # monotonic latitude points that form the Gaussian grid, accounting for + # the coverage of the grid. + y_points = section.get_computed_key("distinctLatitudes") + y_points.sort() + if not scan.j_positive: + y_points = y_points[::-1] + + # Create lat/lon coordinates. + x_coord = DimCoord( + x_points, + standard_name="longitude", + units="degrees", + coord_system=cs, + circular=circular, + ) + y_coord = DimCoord( + y_points, standard_name="latitude", units="degrees", coord_system=cs + ) + + # Determine the lat/lon dimensions. + y_dim, x_dim = 0, 1 + if scan.j_consecutive: + y_dim, x_dim = 1, 0 + + # Add the lat/lon coordinates to the metadata dim coords. + metadata["dim_coords_and_dims"].append((y_coord, y_dim)) + metadata["dim_coords_and_dims"].append((x_coord, x_dim)) + + +def grid_definition_template_40_reduced(section, metadata, cs): + """ + Translate a "reduced form" grid definition template 40. + + This template represents a reduced Gaussian grid. + + """ + # Get the latitude and longitude points. + # + # The same comments made in grid_definition_template_40_regular regarding + # computation of Gaussian lattiudes applies here too. Further to this the + # reduced Gaussian grid is not rectangular, the number of points along + # each latitude circle vary with latitude. Whilst it is possible to + # compute the latitudes and longitudes individually for each grid point + # from coded keys, it would be complex and time-consuming compared to + # loading the latitude and longitude arrays directly using the computed + # keys 'latitudes' and 'longitudes'. + x_points = section.get_computed_key("longitudes") + y_points = section.get_computed_key("latitudes") + + # Create lat/lon coordinates. + x_coord = AuxCoord( + x_points, standard_name="longitude", units="degrees", coord_system=cs + ) + y_coord = AuxCoord( + y_points, standard_name="latitude", units="degrees", coord_system=cs + ) + + # Add the lat/lon coordinates to the metadata dim coords. + metadata["aux_coords_and_dims"].append((y_coord, 0)) + metadata["aux_coords_and_dims"].append((x_coord, 0)) + + # In order to enable a reference surface to be attached to a resulting cube + # with a factory, it is necessary for the cubes to have *also* have a DimCoord on + # the unstructured dimension, so they can match their dimensions. + metadata["dim_coords_and_dims"].append( + ( + DimCoord( + np.arange(x_points.shape[0]), long_name="gaussian_grid", units="1" + ), + 0, + ) + ) + + +def grid_definition_template_90(section, metadata): + """ + Translate grid definition template 90. + + This template represents a space view. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + """ + if section["Nr"] == _MDI: + raise TranslationError("Unsupported orthographic grid.") + elif section["Nr"] == 0: + raise TranslationError("Unsupported zero height for space-view.") + if section["orientationOfTheGrid"] != 0: + raise TranslationError("Unsupported space-view orientation.") + + # Determine the coordinate system. + sub_satellite_lat = ( + section["latitudeOfSubSatellitePoint"] * _GRID_ACCURACY_IN_DEGREES + ) + # The subsequent calculations to determine the apparent Earth + # diameters rely on the satellite being over the equator. + if sub_satellite_lat != 0: + raise TranslationError( + "Unsupported non-zero latitude for space-view perspective." + ) + sub_satellite_lon = ( + section["longitudeOfSubSatellitePoint"] * _GRID_ACCURACY_IN_DEGREES + ) + major, minor, radius = ellipsoid_geometry(section) + geog_cs = ellipsoid(section["shapeOfTheEarth"], major, minor, radius) + height_above_centre = geog_cs.semi_major_axis * section["Nr"] / 1e6 + height_above_ellipsoid = height_above_centre - geog_cs.semi_major_axis + + # Figure out how large the Earth would appear in projection coordinates. + # For both the apparent equatorial and polar diameters this is a + # two-step process: + # 1) Determine the angle subtended by the visible surface. + # 2) Convert that angle into projection coordinates. + # NB. The solutions given below assume the satellite is over the + # equator. + # The apparent equatorial angle uses simple, circular geometry. + # But to derive the apparent polar angle we use the auxiliary circle + # parametric form of the ellipse. In this form, the equation for the + # tangent line is given by: + # x cos(psi) y sin(psi) + # ---------- + ---------- = 1 + # a b + # By considering the cases when x=0 and y=0, the apparent polar + # angle (theta) is given by: + # tan(theta) = b / sin(psi) + # ------------ + # a / cos(psi) + # This can be simplified using: cos(psi) = a / height_above_centre + half_apparent_equatorial_angle = math.asin( + geog_cs.semi_major_axis / height_above_centre + ) + parametric_angle = math.acos(geog_cs.semi_major_axis / height_above_centre) + half_apparent_polar_angle = math.atan( + geog_cs.semi_minor_axis / (height_above_centre * math.sin(parametric_angle)) + ) + y_apparent_angular_diameter = 2 * half_apparent_polar_angle + x_apparent_angular_diameter = 2 * half_apparent_equatorial_angle + y_step = y_apparent_angular_diameter / section["dy"] + x_step = x_apparent_angular_diameter / section["dx"] + y_start = y_step * (section["Yo"] - section["Yp"] / 1000) + x_start = x_step * (section["Xo"] - section["Xp"] / 1000) + y_points = y_start + np.arange(section["Ny"]) * y_step + x_points = x_start + np.arange(section["Nx"]) * x_step + + # This has only been tested with -x/+y scanning, so raise an error + # for other permutations. + scan = scanning_mode(section["scanningMode"]) + if scan.i_negative: + x_points = -x_points + else: + raise TranslationError("Unsupported +x scanning") + if not scan.j_positive: + raise TranslationError("Unsupported -y scanning") + + # Make a coordinate system for the X and Y coordinates. + # Note: false_easting/northing are always just zero, as the calculation of + # x_points/y_points takes both Xp/Yp and Xo/Yo into account. + cs = icoord_systems.Geostationary( + latitude_of_projection_origin=sub_satellite_lat, + longitude_of_projection_origin=sub_satellite_lon, + perspective_point_height=height_above_ellipsoid, + sweep_angle_axis="y", + ellipsoid=geog_cs, + ) + + # Create the X and Y coordinates. + y_coord = DimCoord( + y_points, "projection_y_coordinate", units="radians", coord_system=cs + ) + x_coord = DimCoord( + x_points, "projection_x_coordinate", units="radians", coord_system=cs + ) + + # Determine the lat/lon dimensions. + y_dim, x_dim = 0, 1 + if scan.j_consecutive: + y_dim, x_dim = 1, 0 + + # Add the X and Y coordinates to the metadata dim coords. + metadata["dim_coords_and_dims"].append((y_coord, y_dim)) + metadata["dim_coords_and_dims"].append((x_coord, x_dim)) + + +def grid_definition_template_140(section, metadata): + """ + Translate grid definition template 140. + + This template represents Lambert Azimuthal Equal Area. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + """ + # Define the coordinate system + major, minor, radius = ellipsoid_geometry(section) + cs = icoord_systems.LambertAzimuthalEqualArea( + section["standardParallelInMicrodegrees"] * _GRID_ACCURACY_IN_DEGREES, + section["centralLongitudeInMicrodegrees"] * _GRID_ACCURACY_IN_DEGREES, + 0, + 0, + ellipsoid(section["shapeOfTheEarth"], major, minor, radius), + ) + + x_coord, y_coord, scan = _calculate_proj_coords_from_grid_lengths(section, cs) + + # Determine the order of the dimensions. + y_dim, x_dim = 0, 1 + if scan.j_consecutive: + y_dim, x_dim = 1, 0 + + # Add the projection coordinates to the metadata dim coords. + metadata["dim_coords_and_dims"].append((y_coord, y_dim)) + metadata["dim_coords_and_dims"].append((x_coord, x_dim)) + + +class TemplateRecorder(threading.local): + """ + Object to control the recording of grid template number on loading. + + This provides a setting, controlled by `set` and `context` methods, which causes all + loaded cubes to record the grid template definition number as a cube attribute. + + When present, the 'GRIB_GRID_TEMPLATE' attribute is used to control how certain + types of data are saved -- notably, for gaussian grids. + + e.g. ``TEMPLATE_RECORD.set(True)`` or ``with TEMPLATE_RECORD.context(True): ...``. + """ + + def __init__(self): + self._record = False # default to 'off' : not recording template numbers + + def __repr__(self): + content = ", ".join(f"{key}={value}" for key, value in self.__dict__.items()) + msg = f"TemplateRecorder({content})" + return msg + + def set(self, record: bool = False): + self._record = record + + @contextmanager + def context(self, record: bool = False): + old_value = self._record + try: + self._record = record + yield + finally: + self._record = old_value + + def __bool__(self): + return bool(self._record) + + +#: The unique object which controls recording of grid template numbers. +TEMPLATE_RECORD = TemplateRecorder() + + +def grid_definition_section(section, metadata): + """ + Translate section 3 from the GRIB2 message. + + Update the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 3 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + """ + # Reference GRIB2 Code Table 3.0. + value = section["sourceOfGridDefinition"] + if value != 0: + msg = ( + "Grid definition section 3 contains unsupported " + "source of grid definition [{}]".format(value) + ) + raise TranslationError(msg) + + # Reference GRIB2 Code Table 3.1. + template = section["gridDefinitionTemplateNumber"] + + if template == 0: + # Process regular latitude/longitude grid (regular_ll) + grid_definition_template_0(section, metadata) + elif template == 1: + # Process rotated latitude/longitude grid. + grid_definition_template_1(section, metadata) + elif template == 4: + # Process variable resolution latitude/longitude. + grid_definition_template_4(section, metadata) + elif template == 5: + # Process variable resolution rotated latitude/longitude. + grid_definition_template_5(section, metadata) + elif template == 10: + # Process Mercator. + grid_definition_template_10(section, metadata) + elif template == 12: + # Process transverse Mercator. + grid_definition_template_12(section, metadata) + elif template == 20: + # Polar stereographic. + grid_definition_template_20(section, metadata) + elif template == 30: + # Process Lambert conformal: + grid_definition_template_30(section, metadata) + elif template == 40: + grid_definition_template_40(section, metadata) + elif template == 90: + # Process space view. + grid_definition_template_90(section, metadata) + elif template == 140: + # Process Lambert Azimuthal Equal Area. + grid_definition_template_140(section, metadata) + else: + msg = "Grid definition template [{}] is not supported".format(template) + raise TranslationError(msg) + + # If enabled, always record the 'original' grib template number, as an attribute. + if TEMPLATE_RECORD: + # This can also potentially control saving, in some cases. + metadata["attributes"]["GRIB2_GRID_TEMPLATE"] = template + + +############################################################################### +# +# Product Definition Section 4 +# +############################################################################### + + +def translate_phenomenon( + metadata, + discipline, + parameterCategory, + parameterNumber, + typeOfFirstFixedSurface, + scaledValueOfFirstFixedSurface, + typeOfSecondFixedSurface, + probability=None, +): + """ + Translate GRIB2 phenomenon to CF phenomenon. + + Updates the metadata in-place with the translations. + + Args: + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * discipline: + Message section 0, octet 7. + + * parameterCategory: + Message section 4, octet 10. + + * parameterNumber: + Message section 4, octet 11. + + Kwargs: + + * probability (:class:`Probability`): + If present, the data encodes a forecast probability analysis with the + given properties. + + """ + cf = itranslation.grib2_phenom_to_cf_info( + param_discipline=discipline, + param_category=parameterCategory, + param_number=parameterNumber, + ) + if cf is not None: + if probability is None: + metadata["standard_name"] = cf.standard_name + metadata["long_name"] = cf.long_name + metadata["units"] = cf.units + else: + # The basic name+unit info goes into a 'threshold coordinate' which + # encodes probability threshold values. + threshold_coord = DimCoord( + probability.threshold, + standard_name=cf.standard_name, + long_name=cf.long_name, + units=cf.units, + ) + metadata["aux_coords_and_dims"].append((threshold_coord, None)) + # The main cube has an adjusted name, and units of '1'. + base_name = cf.standard_name or cf.long_name + long_name = "probability_of_{}_{}".format( + base_name, probability.probability_type_name + ) + metadata["standard_name"] = None + metadata["long_name"] = long_name + metadata["units"] = Unit(1) + + # Add a standard attribute recording the grib phenomenon identity. + metadata["attributes"]["GRIB_PARAM"] = GRIBCode( + edition=2, + discipline=discipline, + category=parameterCategory, + number=parameterNumber, + ) + + # Identify hybrid height and pressure reference fields. + # Look for fields at surface level first. + if ( + typeOfFirstFixedSurface == 1 + and scaledValueOfFirstFixedSurface in [0, None] + and typeOfSecondFixedSurface == _TYPE_OF_FIXED_SURFACE_MISSING + ): + # Land surface products for model terrain height: + if discipline == 2 and parameterCategory == 0 and parameterNumber == 7: + metadata["references"].append(ReferenceTarget("ref_orography", None)) + # Meteorological mass products for pressure: + elif discipline == 0 and parameterCategory == 3 and parameterNumber == 0: + metadata["references"].append( + ReferenceTarget( + "ref_surface_pressure", ensure_surface_air_pressure_name + ) + ) + + +def ensure_surface_air_pressure_name(cube): + # A 'transform' function for a iris.fileformats.rules.ReferenceTarget, + # instructing the rules code to rename the reference as + # 'surface_air_pressure'. + # + # Needed because the surface-air-pressure (reference) message normally + # loads as a plain 'air_pressure' cube. + # As references for factory construction are identified by .name(), in this + # case that can get confused with the derived coordinate produced by the + # HybridPressureFactory itself, which is also named 'air_pressure'. + # This will cause an infinite loop when building the derived coord (!) + name = cube.name() + # Just check the passed cube is of the sort expected. + expected_names = ("air_pressure", "surface_air_pressure") + if name not in expected_names: + msg = ( + "Unexpected cube name for hybrid-pressure reference data : " + "Expected one of {}, got {!r}." + ) + raise ValueError(msg.format(expected_names, name)) + # Get the caller (in rules.py) to rename it. + return {"standard_name": "surface_air_pressure"} + + +def time_range_unit(indicatorOfUnitForForecastTime): + """ + Translate the time range indicator. + + Translate the time range indicator to an equivalent + :class:`cf_units.Unit`. + + Args: + + * indicatorOfUnitForForecastTime: + Message section 4, octet 18. + + Returns: + :class:`cf_units.Unit`. + + """ + try: + unit = Unit(_TIME_RANGE_UNITS[indicatorOfUnitForForecastTime]) + except (KeyError, ValueError): + msg = ( + "Product definition section 4 contains unsupported " + "time range unit [{}]".format(indicatorOfUnitForForecastTime) + ) + raise TranslationError(msg) + return unit + + +def hybrid_factories(section, metadata): + """ + Translate the section 4 optional hybrid vertical coordinates. + + Updates the metadata in-place with the translations. + + Reference GRIB2 Code Table 4.5. + + Relevant notes: + [3] Hybrid pressure level (119) shall be used instead of Hybrid level (105) + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + """ + NV = section["NV"] + if NV > 0: + typeOfFirstFixedSurface = section["typeOfFirstFixedSurface"] + if typeOfFirstFixedSurface == _TYPE_OF_FIXED_SURFACE_MISSING: + msg = ( + "Product definition section 4 contains missing " + "type of first fixed surface" + ) + raise TranslationError(msg) + + typeOfSecondFixedSurface = section["typeOfSecondFixedSurface"] + if typeOfSecondFixedSurface != _TYPE_OF_FIXED_SURFACE_MISSING: + msg = ( + "Product definition section 4 contains unsupported type " + "of second fixed surface [{}]".format(typeOfSecondFixedSurface) + ) + raise TranslationError(msg) + + if typeOfFirstFixedSurface in [105, 118, 119]: + # Hybrid level (105), Hybrid height level (118) and Hybrid + # pressure level (119). + scaleFactor = section["scaleFactorOfFirstFixedSurface"] + if scaleFactor != 0: + msg = ( + "Product definition section 4 contains invalid scale " + "factor of first fixed surface [{}]".format(scaleFactor) + ) + raise TranslationError(msg) + + # Create the model level number scalar coordinate. + scaledValue = section["scaledValueOfFirstFixedSurface"] + coord = DimCoord( + scaledValue, + standard_name="model_level_number", + units=1, + attributes=dict(positive="up"), + ) + metadata["aux_coords_and_dims"].append((coord, None)) + + if typeOfFirstFixedSurface == 118: + # height + level_value_name = "level_height" + level_value_units = "m" + factory_class = HybridHeightFactory + factory_args = [ + {"long_name": level_value_name}, + {"long_name": "sigma"}, + Reference("ref_orography"), + ] + else: + # pressure + level_value_name = "level_pressure" + level_value_units = "Pa" + factory_class = HybridPressureFactory + factory_args = [ + {"long_name": level_value_name}, + {"long_name": "sigma"}, + Reference("ref_surface_pressure"), + ] + + # Create the level height/pressure scalar coordinate. + # scaledValue represents the level number, which is used to select + # the sigma and delta values as follows: + # sigma, delta = PV[i], PV[NV/2+i] : where i=1..level_number + pv = section["pv"] + offset = scaledValue + coord = DimCoord( + pv[offset], long_name=level_value_name, units=level_value_units + ) + metadata["aux_coords_and_dims"].append((coord, None)) + # Create the sigma scalar coordinate. + offset = NV // 2 + scaledValue + coord = AuxCoord(pv[offset], long_name="sigma", units=1) + metadata["aux_coords_and_dims"].append((coord, None)) + # Create the associated factory reference. + factory = Factory(factory_class, factory_args) + metadata["factories"].append(factory) + + else: + msg = ( + "Product definition section 4 contains unsupported " + "first fixed surface [{}]".format(typeOfFirstFixedSurface) + ) + raise TranslationError(msg) + + +def vertical_coords(section, metadata): + """ + Translate the vertical coordinates or hybrid vertical coordinates. + + Updates the metadata in-place with the translations. + + Reference GRIB2 Code Table 4.5. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + """ + if section["NV"] > 0: + # Generate hybrid vertical coordinates. + hybrid_factories(section, metadata) + else: + # Generate vertical coordinate. + # section is not a true dict and does not support the get method. + try: + typeOfFirstFixedSurface = section["typeOfFirstFixedSurface"] + except KeyError: + typeOfFirstFixedSurface = _TYPE_OF_FIXED_SURFACE_MISSING + try: + typeOfSecondFixedSurface = section["typeOfSecondFixedSurface"] + except KeyError: + typeOfSecondFixedSurface = _TYPE_OF_FIXED_SURFACE_MISSING + + # We treat fixed surface level type=1 as having no vertical coordinate. + # See https://github.com/SciTools/iris/issues/519 + if typeOfFirstFixedSurface in [ + _TYPE_OF_FIXED_SURFACE_MISSING, + 1, + ] and typeOfSecondFixedSurface in [_TYPE_OF_FIXED_SURFACE_MISSING, 1]: + return + fixed_surface_missing = FixedSurface(None, None, None, None) + fixed_surface_first = _FIXED_SURFACE.get( + typeOfFirstFixedSurface, fixed_surface_missing + ) + if fixed_surface_first.point is not None: + # We have a surface type that includes the point in its definition + lower_bound = fixed_surface_first.point + else: + lower_bound = _get_surface_value(section, "First", warn_only=True) + point = lower_bound + + if typeOfSecondFixedSurface != _TYPE_OF_FIXED_SURFACE_MISSING: + fixed_surface_second = _FIXED_SURFACE.get( + typeOfSecondFixedSurface, fixed_surface_missing + ) + if fixed_surface_second.point is not None: + # We have a surface type that includes the point in its definition + upper_bound = fixed_surface_second.point + else: + upper_bound = _get_surface_value(section, "Second") + if upper_bound is not None: + point = 0.5 * (lower_bound + upper_bound) + bounds = [lower_bound, upper_bound] + else: + bounds = None + fixed_surface_second = None + upper_bound = None + if point is None: + return + coords = _build_vertical_coords( + bounds, + fixed_surface_first, + fixed_surface_second, + lower_bound, + point, + upper_bound, + ) + for coord in coords: + # Add the vertical coordinate(s) to metadata aux coords. + if fixed_surface_first == fixed_surface_missing: + coord.attributes["GRIB_fixed_surface_type"] = typeOfFirstFixedSurface + metadata["aux_coords_and_dims"].append((coord, None)) + + +def _build_vertical_coords( + bounds: list[float] | None, + fixed_surface_first: FixedSurface, + fixed_surface_second: FixedSurface | None, + lower_bound: float, + point: float, + upper_bound: float, +) -> list[DimCoord]: + """ + Build the vertical coordinates based on the bounds and fixed surface types. + + If the bounds are not None and the first and second fixed surfaces represent + different quantities, create two coordinates, one for each bound. + Otherwise, create a single coordinate with the point and bounds values. + + Args: + bounds: + List of two floats representing the lower and upper bounds, or None. + fixed_surface_first: + FixedSurface object for the first surface type (lower bound). + fixed_surface_second: + FixedSurface object for the second surface type (upper bound). + lower_bound: + The lower bound value. + point: + The point value to use if bounds are None. + upper_bound: + The upper bound value. + + Returns: + List of Coord objects representing the vertical coordinates. + """ + first_coord_kwargs = fixed_surface_first._asdict() + first_coord_kwargs.pop("point", None) + second_coord_kwargs = fixed_surface_second._asdict() if fixed_surface_second else {} + second_coord_kwargs.pop("point", None) + coords = [] + if bounds and first_coord_kwargs != second_coord_kwargs: + # Create two coords, one for each bound. + first_bounds = np.ma.masked_array(bounds, [False, True]) + coord = AuxCoord(lower_bound, bounds=first_bounds, **first_coord_kwargs) + coords.append(coord) + + second_bounds = np.ma.masked_array(bounds, [True, False]) + coord = AuxCoord(upper_bound, bounds=second_bounds, **second_coord_kwargs) + coords.append(coord) + else: + coord = DimCoord(point, bounds=bounds, **first_coord_kwargs) + coords.append(coord) + return coords + + +def _get_surface_value(section, sub_item, warn_only=False): + key = f"scaledValueOf{sub_item}FixedSurface" + scaledValueOfFixedSurface = section[key] + if scaledValueOfFixedSurface == _MDI: + msg = ( + f"Unable to translate type of {sub_item} fixed " + "surface with missing scaled value." + ) + if warn_only: + if options.warn_on_unsupported: + warnings.warn(msg) + else: + raise TranslationError(msg) + first = None + else: + key = f"scaleFactorOf{sub_item}FixedSurface" + scaleFactorOfFixedSurface = section[key] + first = unscale( + scaledValueOfFixedSurface, + scaleFactorOfFixedSurface, + ) + return first + + +def forecast_period_coord(indicatorOfUnitForForecastTime, forecastTime): + """ + Create the forecast period coordinate. + + Args: + + * indicatorOfUnitForForecastTime: + Message section 4, octets 18. + + * forecastTime: + Message section 4, octets 19-22. + + Returns: + The scalar forecast period :class:`iris.coords.DimCoord`. + + """ + # Determine the forecast period and associated units. + unit = time_range_unit(indicatorOfUnitForForecastTime) + point = unit.convert(forecastTime, "hours") + # Create the forecast period scalar coordinate. + coord = DimCoord(point, standard_name="forecast_period", units="hours") + return coord + + +def statistical_forecast_period_coord(section, frt_coord): + """ + Create a forecast period coordinate for a time-statistic message. + + This applies only with a product definition template 4.8. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * frt_coord: + The scalar forecast reference time :class:`iris.coords.DimCoord`. + + Returns: + The scalar forecast period :class:`iris.coords.DimCoord`, containing a + single, bounded point (period value). + + """ + # Get the period end time as a datetime. + end_time = datetime( + section["yearOfEndOfOverallTimeInterval"], + section["monthOfEndOfOverallTimeInterval"], + section["dayOfEndOfOverallTimeInterval"], + section["hourOfEndOfOverallTimeInterval"], + section["minuteOfEndOfOverallTimeInterval"], + section["secondOfEndOfOverallTimeInterval"], + ) + + # Get forecast reference time (frt) as a datetime. + frt_point = frt_coord.units.num2date(frt_coord.points[0]) + + # Get the period start time (as a timedelta relative to the frt). + forecast_time = section["forecastTime"] + if options.support_hindcast_values: + # Apply the hindcast fix. + forecast_time = _hindcast_fix(forecast_time) + forecast_units = time_range_unit(section["indicatorOfUnitForForecastTime"]) + forecast_seconds = forecast_units.convert(forecast_time, "seconds") + start_time_delta = timedelta(seconds=forecast_seconds) + + # Get the period end time (as a timedelta relative to the frt). + end_time_delta = end_time - frt_point + + # Get the middle of the period (as a timedelta relative to the frt). + # Note: timedelta division in 2.7 is odd. Even though we request integer + # division, it's to the nearest _micro_second. + mid_time_delta = (start_time_delta + end_time_delta) // 2 + + # Create and return the forecast period coordinate. + def timedelta_hours(timedelta): + return timedelta.total_seconds() / 3600.0 + + mid_point_hours = timedelta_hours(mid_time_delta) + bounds_hours = [timedelta_hours(start_time_delta), timedelta_hours(end_time_delta)] + fp_coord = DimCoord( + mid_point_hours, + bounds=bounds_hours, + standard_name="forecast_period", + units="hours", + ) + return fp_coord + + +def other_time_coord(rt_coord, fp_coord): + """ + Make the "other" scalar time DimCoord. + + Return the counterpart to the given scalar 'time' or + 'forecast_reference_time' coordinate, by combining it with the + given forecast_period coordinate. + + Bounds are not supported. + + Args: + + * rt_coord: + The scalar "reference time" :class:`iris.coords.DimCoord`, + as defined by section 1. This must be either a 'time' or + 'forecast_reference_time' coordinate. + + * fp_coord: + The scalar 'forecast_period' :class:`iris.coords.DimCoord`. + + Returns: + The scalar :class:`iris.coords.DimCoord` for either 'time' or + 'forecast_reference_time'. + + """ + if not rt_coord.units.is_time_reference(): + fmt = "Invalid unit for reference time coord: {}" + raise ValueError(fmt.format(rt_coord.units)) + if not fp_coord.units.is_time(): + fmt = "Invalid unit for forecast_period coord: {}" + raise ValueError(fmt.format(fp_coord.units)) + if rt_coord.has_bounds() or fp_coord.has_bounds(): + raise ValueError("Coordinate bounds are not supported") + if rt_coord.shape != (1,) or fp_coord.shape != (1,): + raise ValueError("Vector coordinates are not supported") + + if rt_coord.standard_name == "time": + rt_base_unit = str(rt_coord.units).split(" since ")[0] + fp = fp_coord.units.convert(fp_coord.points[0], rt_base_unit) + frt = rt_coord.points[0] - fp + return DimCoord(frt, "forecast_reference_time", units=rt_coord.units) + elif rt_coord.standard_name == "forecast_reference_time": + return validity_time_coord(rt_coord, fp_coord) + else: + fmt = "Unexpected reference time coordinate: {}" + raise ValueError(fmt.format(rt_coord.name())) + + +def validity_time_coord(frt_coord, fp_coord): + """ + Create the validity or phenomenon time coordinate. + + Args: + + * frt_coord: + The scalar forecast reference time :class:`iris.coords.DimCoord`. + + * fp_coord: + The scalar forecast period :class:`iris.coords.DimCoord`. + + Returns: + The scalar time :class:`iris.coords.DimCoord`. + It has bounds if the period coord has them, otherwise not. + + """ + if frt_coord.shape != (1,): + msg = ( + "Expected scalar forecast reference time coordinate when " + "calculating validity time, got shape {!r}".format(frt_coord.shape) + ) + raise ValueError(msg) + + if fp_coord.shape != (1,): + msg = ( + "Expected scalar forecast period coordinate when " + "calculating validity time, got shape {!r}".format(fp_coord.shape) + ) + raise ValueError(msg) + + def coord_timedelta(coord, value): + # Helper to convert a time coordinate value into a timedelta. + seconds = coord.units.convert(value, "seconds") + return timedelta(seconds=seconds) + + # Calculate validity (phenomenon) time in forecast-reference-time units. + frt_point = frt_coord.units.num2date(frt_coord.points[0]) + point_delta = coord_timedelta(fp_coord, fp_coord.points[0]) + point = float(frt_coord.units.date2num(frt_point + point_delta)) + + # Calculate bounds (if any) in the same way. + if fp_coord.bounds is None: + bounds = None + else: + bounds_deltas = [ + coord_timedelta(fp_coord, bound_point) for bound_point in fp_coord.bounds[0] + ] + bounds = [ + float(frt_coord.units.date2num(frt_point + delta)) + for delta in bounds_deltas + ] + + # Create the time scalar coordinate. + coord = DimCoord(point, bounds=bounds, standard_name="time", units=frt_coord.units) + return coord + + +def time_coords(section, metadata, rt_coord): + if "forecastTime" in section.keys(): + forecast_time = section["forecastTime"] + # ecCodes encodes the forecast time as 'startStep' for pdt 4.4x; + # product_definition_template_40 makes use of this function. The + # following will be removed once the suspected bug is fixed. + elif "startStep" in section.keys(): + forecast_time = section["startStep"] + + # Calculate the forecast period coordinate. + fp_coord = forecast_period_coord( + section["indicatorOfUnitForForecastTime"], forecast_time + ) + # Add the forecast period coordinate to the metadata aux coords. + metadata["aux_coords_and_dims"].append((fp_coord, None)) + # Calculate the "other" time coordinate - i.e. whichever of 'time' + # or 'forecast_reference_time' we don't already have. + other_coord = other_time_coord(rt_coord, fp_coord) + # Add the time coordinate to the metadata aux coords. + metadata["aux_coords_and_dims"].append((other_coord, None)) + # Add the reference time coordinate to the metadata aux coords. + metadata["aux_coords_and_dims"].append((rt_coord, None)) + + +def generating_process(section, include_forecast_process=True): + if options.warn_on_unsupported: + # Reference Code Table 4.3. + warnings.warn("Unable to translate type of generating process.") + warnings.warn("Unable to translate background generating process identifier.") + if include_forecast_process: + warnings.warn("Unable to translate forecast generating process identifier.") + + +def data_cutoff(hoursAfterDataCutoff, minutesAfterDataCutoff): + """ + Handle the after reference time data cutoff. + + Args: + + * hoursAfterDataCutoff: + Message section 4, octets 15-16. + + * minutesAfterDataCutoff: + Message section 4, octet 17. + + """ + if hoursAfterDataCutoff != _MDI or minutesAfterDataCutoff != _MDI: + if options.warn_on_unsupported: + warnings.warn( + 'Unable to translate "hours and/or minutes after data cutoff".' + ) + + +def statistical_method_name(section): + # Decode the type of statistic as a cell_method 'method' string. + # Templates 8, 9, 10, 11 and 15 all use this type code, which is defined + # in table 4.10. + # However, the actual keyname is different for template 15. + section_number = section["productDefinitionTemplateNumber"] + if section_number in (8, 9, 10, 11): + stat_keyname = "typeOfStatisticalProcessing" + elif section_number == 15: + stat_keyname = "statisticalProcess" + else: + # This should *never* happen, as only called by pdt 8 and 15. + msg = ( + "Internal error: can't get statistical method for unsupported pdt : 4.{:d}." + ) + raise ValueError(msg.format(section_number)) + statistic_code = section[stat_keyname] + statistic_name = _STATISTIC_TYPE_NAMES.get(statistic_code) + if statistic_name is None: + msg = ( + "Product definition section 4 contains an unsupported " + "statistical process type [{}] " + ) + raise TranslationError(msg.format(statistic_code)) + return statistic_name + + +def statistical_cell_method(section): + """ + Create a cell method representing a time statistic. + + This applies only with a product definition template 4.8. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + Returns: + A cell method over 'time'. + + """ + # Handle the number of time ranges -- we currently only support one. + n_time_ranges = section["numberOfTimeRange"] + if n_time_ranges != 1: + if n_time_ranges == 0: + msg = ( + "Product definition section 4 specifies aggregation over " + '"0 time ranges".' + ) + raise TranslationError(msg) + else: + msg = ( + "Product definition section 4 specifies aggregation over " + "multiple time ranges [{}], which is not yet " + "supported.".format(n_time_ranges) + ) + raise TranslationError(msg) + + # Decode the type of statistic (aggregation method). + statistic_name = statistical_method_name(section) + + # Decode the type of time increment. + increment_typecode = section["typeOfTimeIncrement"] + if increment_typecode not in (2, 255): + # NOTE: All our current test data seems to contain the value 2, which + # is all we currently support. + # The exact interpretation of this is still unclear so we also accept + # a missing value. + msg = "grib statistic time-increment type [{}] is not supported.".format( + increment_typecode + ) + raise TranslationError(msg) + + interval_number = section["timeIncrement"] + if interval_number in (0, _TIME_RANGE_MISSING): + intervals_string = None + else: + units_string = _TIME_RANGE_UNITS[section["indicatorOfUnitForTimeIncrement"]] + intervals_string = "{} {}".format(interval_number, units_string) + + # Create a cell method to represent the time aggregation. + cell_method = CellMethod( + method=statistic_name, coords="time", intervals=intervals_string + ) + return cell_method + + +def ensemble_identifier(section): + if options.warn_on_unsupported: + # Reference Code Table 4.6. + warnings.warn("Unable to translate type of ensemble forecast.") + warnings.warn("Unable to translate number of forecasts in ensemble.") + + # Create the realization coordinates. + realization = DimCoord( + section["perturbationNumber"], standard_name="realization", units="no_unit" + ) + return realization + + +def product_definition_template_0(section, metadata, rt_coord): + """ + Translate product definition template 0. + + This template represents an analysis or forecast at a horizontal + level or in a horizontal layer at a point in time. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * rt_coord: + The scalar "reference time" :class:`iris.coords.DimCoord`. + This will be either 'time' or 'forecast_reference_time'. + + """ + # Handle generating process details. + generating_process(section) + + # Handle the data cutoff. + data_cutoff(section["hoursAfterDataCutoff"], section["minutesAfterDataCutoff"]) + + time_coords(section, metadata, rt_coord) + + # Check for vertical coordinates. + vertical_coords(section, metadata) + + +def product_definition_template_1(section, metadata, frt_coord): + """ + Translate product definition template 1. + + This template represents individual ensemble forecast, control + and perturbed, at a horizontal level or in a horizontal layer at a + point in time. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * frt_coord: + The scalar forecast reference time :class:`iris.coords.DimCoord`. + + """ + # Perform identical message processing. + product_definition_template_0(section, metadata, frt_coord) + + realization = ensemble_identifier(section) + + # Add the realization coordinate to the metadata aux coords. + metadata["aux_coords_and_dims"].append((realization, None)) + + +def product_definition_template_5(section, metadata, frt_coord): + """ + Translate product definition template 5. + + This template represents a probability forecast, + at a horizontal level or in a horizontal layer at a + point in time. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * frt_coord: + The scalar forecast reference time :class:`iris.coords.DimCoord`. + + """ + # Perform identical message processing. + product_definition_template_0(section, metadata, frt_coord) + + probability_type = _probability_type(section) + return probability_type + + +def product_definition_template_6(section, metadata, frt_coord): + """ + Translate product definition template 6. + + This template represents a percentile forecast, + at a horizontal level or in a horizontal layer at a + point in time. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * frt_coord: + The scalar forecast reference time :class:`iris.coords.DimCoord`. + + """ + # Perform identical message processing. + product_definition_template_0(section, metadata, frt_coord) + + percentile = DimCoord(section["percentileValue"], long_name="percentile", units="%") + + # Add the percentile coordinate to the metadata aux coords. + metadata["aux_coords_and_dims"].append((percentile, None)) + + +def product_definition_template_8(section, metadata, frt_coord): + """ + Translate product definition template 8. + + This template represents average, accumulation and/or extreme values + or other statistically processed values at a horizontal level or in a + horizontal layer in a continuous or non-continuous time interval. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * frt_coord: + The scalar forecast reference time :class:`iris.coords.DimCoord`. + + """ + # Handle generating process details. + generating_process(section) + + # Handle the data cutoff. + data_cutoff(section["hoursAfterDataCutoff"], section["minutesAfterDataCutoff"]) + + # Create a cell method to represent the time statistic. + time_statistic_cell_method = statistical_cell_method(section) + # Add the forecast cell method to the metadata. + metadata["cell_methods"].append(time_statistic_cell_method) + + # Add the forecast reference time coordinate to the metadata aux coords, + # if it is a forecast reference time, not a time coord, as defined by + # significanceOfReferenceTime. + if frt_coord.name() != "time": + metadata["aux_coords_and_dims"].append((frt_coord, None)) + + # Add a bounded forecast period coordinate. + fp_coord = statistical_forecast_period_coord(section, frt_coord) + metadata["aux_coords_and_dims"].append((fp_coord, None)) + + # Calculate a bounded validity time coord matching the forecast period. + t_coord = validity_time_coord(frt_coord, fp_coord) + # Add the time coordinate to the metadata aux coords. + metadata["aux_coords_and_dims"].append((t_coord, None)) + + # Check for vertical coordinates. + vertical_coords(section, metadata) + + +def product_definition_template_9(section, metadata, frt_coord): + """ + Translate product definition template 9. + + This template represents probability forecasts at a + horizontal level or in a horizontal layer in a continuous or + non-continuous time interval. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * frt_coord: + The scalar forecast reference time :class:`iris.coords.DimCoord`. + + """ + # Start by calling PDT8 as all elements of that are common to this. + product_definition_template_8(section, metadata, frt_coord) + + # Remove the cell_method encoding the underlying statistic, as CF does not + # currently support this type of representation. + (_cell_method,) = metadata["cell_methods"] # ERROR here if there is more than one. + metadata["cell_methods"] = [] + # NOTE: we currently don't record the nature of the underlying statistic, + # as we don't have an agreed way of representing that in CF. + + probability_type = _probability_type(section) + + return probability_type + + +def _probability_type(section): + # Return a probability object to control the production of a probability + # result. This is done once the underlying phenomenon type is determined, + # in 'translate_phenomenon'. + probability_typecode = section["probabilityType"] + # Note that GRIB provides separate "above lower threshold" and "above + # upper threshold" probability types. This naming style doesn't + # recognise that distinction. For now, assume this is not important. + match probability_typecode: + case 0: + # Type is "below lower level". + threshold = _probability_threshold(section, "lower") + probability_type = Probability("below_threshold", threshold) + case 1: + # Type is "above upper level". + threshold = _probability_threshold(section, "upper") + probability_type = Probability("above_threshold", threshold) + case 3: + # Type is "above lower level". + threshold = _probability_threshold(section, "lower") + probability_type = Probability("above_threshold", threshold) + case 4: + # Type is "below upper level". + threshold = _probability_threshold(section, "upper") + probability_type = Probability("below_threshold", threshold) + case _: + msg = ( + "Product definition section 4 contains an unsupported " + "probability type [{}]".format(probability_typecode) + ) + raise TranslationError(msg) + return probability_type + + +def _probability_threshold(section, limit_type): + """Get the unscaled probability threshold value for the given limit type.""" + value_key = f"scaledValueOf{limit_type.capitalize()}Limit" + scale_key = f"scaleFactorOf{limit_type.capitalize()}Limit" + msg = f"Product definition section 4 has missing {{item}} of {limit_type} limit" + + threshold_value = section[value_key] + if threshold_value == _MDI: + raise TranslationError(msg.format(item="scaled value")) + threshold_scaling = section[scale_key] + if threshold_scaling == _MDI: + raise TranslationError(msg.format(item="scale factor")) + threshold = unscale(threshold_value, threshold_scaling) + return threshold + + +def product_definition_template_10(section, metadata, frt_coord): + """ + Translate product definition template 10. + + This template represents percentile forecasts at a horizontal level + or in a horizontal layer in a continuous or non-continuous time interval. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * frt_coord: + The scalar forecast reference time :class:`iris.coords.DimCoord`. + + """ + product_definition_template_8(section, metadata, frt_coord) + + percentile = DimCoord( + section["percentileValue"], long_name="percentile_over_time", units="no_unit" + ) + + # Add the percentile data info + metadata["aux_coords_and_dims"].append((percentile, None)) + + +def product_definition_template_11(section, metadata, frt_coord): + """ + Translate product definition template 11. + + This template represents individual ensemble forecast, control + or perturbed; average, accumulation and/or extreme values + or other statistically processed values at a horizontal level or in a + horizontal layer in a continuous or non-continuous time interval. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * frt_coord: + The scalar forecast reference time :class:`iris.coords.DimCoord`. + + """ + product_definition_template_8(section, metadata, frt_coord) + + realization = ensemble_identifier(section) + + # Add the realization coordinate to the metadata aux coords. + metadata["aux_coords_and_dims"].append((realization, None)) + + +def product_definition_template_15(section, metadata, frt_coord): + """ + Translate product definition template 15. + + This template represents : "average, accumulation, extreme values, + or other statistically processed values over a spatial area at a + horizontal level or in a horizontal layer at a point in time". + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * frt_coord: + The scalar forecast reference time :class:`iris.coords.DimCoord`. + + """ + # Check unique keys for this template. + spatial_processing_code = section["spatialProcessing"] + + # Only a limited number of spatial processing codes are supported + if spatial_processing_code not in _SPATIAL_PROCESSING_TYPES.keys(): + msg = ( + "Product definition section 4 contains an unsupported " + "spatial processing type [{}]".format(spatial_processing_code) + ) + raise TranslationError(msg) + + # Process parts in common with PDT 4.0. + product_definition_template_0(section, metadata, frt_coord) + + # Add spatial processing type as an attribute. + metadata["attributes"]["spatial_processing_type"] = _SPATIAL_PROCESSING_TYPES[ + spatial_processing_code + ][0] + + # Add a cell method if the spatial processing type supports a + # statistical process. + if _SPATIAL_PROCESSING_TYPES[spatial_processing_code][1] == "cell_method": + # Decode the statistical method name. + cell_method_name = statistical_method_name(section) + + # Record an 'area' cell-method using this statistic. + metadata["cell_methods"] = [ + CellMethod(coords=("area",), method=cell_method_name) + ] + + +def satellite_common(section, metadata): + # Number of contributing spectral bands. + NB = section["NB"] + + if NB > 0: + # Create the satellite series coordinate. + satelliteSeries = section["satelliteSeries"] + coord = AuxCoord(satelliteSeries, long_name="satellite_series", units=1) + # Add the satellite series coordinate to the metadata aux coords. + metadata["aux_coords_and_dims"].append((coord, None)) + + # Create the satellite number coordinate. + satelliteNumber = section["satelliteNumber"] + coord = AuxCoord(satelliteNumber, long_name="satellite_number", units=1) + # Add the satellite number coordinate to the metadata aux coords. + metadata["aux_coords_and_dims"].append((coord, None)) + + # Create the satellite instrument type coordinate. + instrumentType = section["instrumentType"] + coord = AuxCoord(instrumentType, long_name="instrument_type", units=1) + # Add the instrument type coordinate to the metadata aux coords. + metadata["aux_coords_and_dims"].append((coord, None)) + + # Create the central wave number coordinate. + scaleFactor = section["scaleFactorOfCentralWaveNumber"] + scaledValue = section["scaledValueOfCentralWaveNumber"] + wave_number = unscale(scaledValue, scaleFactor) + standard_name = "sensor_band_central_radiation_wavenumber" + coord = AuxCoord(wave_number, standard_name=standard_name, units=Unit("m-1")) + # Add the central wave number coordinate to the metadata aux coords. + metadata["aux_coords_and_dims"].append((coord, None)) + + +def product_definition_template_31(section, metadata, rt_coord): + """ + Translate product definition template 31. + + This template represents a satellite product. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * rt_coord: + The scalar observation time :class:`iris.coords.DimCoord'. + + """ + generating_process(section, include_forecast_process=False) + + satellite_common(section, metadata) + + # Add the observation time coordinate. + metadata["aux_coords_and_dims"].append((rt_coord, None)) + + +def product_definition_template_32(section, metadata, rt_coord): + """ + Translate product definition template 32. + + This template represents an analysis or forecast at a horizontal + level or in a horizontal layer at a point in time for simulated (synthetic) + satellite data. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * rt_coord: + The scalar observation time :class:`iris.coords.DimCoord'. + + """ + generating_process(section, include_forecast_process=False) + + # Handle the data cutoff. + data_cutoff(section["hoursAfterDataCutoff"], section["minutesAfterDataCutoff"]) + + time_coords(section, metadata, rt_coord) + + satellite_common(section, metadata) + + +def product_definition_template_40(section, metadata, frt_coord): + """ + Translate product definition template 40. + + This template represents an analysis or forecast at a horizontal + level or in a horizontal layer at a point in time for atmospheric chemical + constituents. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * frt_coord: + The scalar forecast reference time :class:`iris.coords.DimCoord`. + + """ + # Perform identical message processing. + product_definition_template_0(section, metadata, frt_coord) + + # Reference GRIB2 Code Table 4.230. + constituent_type = section["constituentType"] + + # Add the constituent type as an attribute. + metadata["attributes"]["WMO_constituent_type"] = constituent_type + + +def product_definition_section(section, metadata, discipline, tablesVersion, rt_coord): + """ + Translate section 4 from the GRIB2 message. + + Updates the metadata in-place with the translations. + + Args: + + * section: + Dictionary of coded key/value pairs from section 4 of the message. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + * discipline: + Message section 0, octet 7. + + * tablesVersion: + Message section 1, octet 10. + + * rt_coord: + The scalar reference time :class:`iris.coords.DimCoord`. + + """ + # Reference GRIB2 Code Table 4.0. + template = section["productDefinitionTemplateNumber"] + + probability = None + includes_fixed_surface_keys = True + if template == 0: + # Process analysis or forecast at a horizontal level or + # in a horizontal layer at a point in time. + product_definition_template_0(section, metadata, rt_coord) + elif template == 1: + # Process individual ensemble forecast, control and perturbed, at + # a horizontal level or in a horizontal layer at a point in time. + product_definition_template_1(section, metadata, rt_coord) + elif template == 5: + # Process percentile forecast, at a horizontal level or in a horizontal + # layer at a point in time. + probability = product_definition_template_5(section, metadata, rt_coord) + elif template == 6: + # Process percentile forecast, at a horizontal level or in a horizontal + # layer at a point in time. + product_definition_template_6(section, metadata, rt_coord) + elif template == 8: + # Process statistically processed values at a horizontal level or in a + # horizontal layer in a continuous or non-continuous time interval. + product_definition_template_8(section, metadata, rt_coord) + elif template == 9: + probability = product_definition_template_9(section, metadata, rt_coord) + elif template == 10: + product_definition_template_10(section, metadata, rt_coord) + elif template == 11: + product_definition_template_11(section, metadata, rt_coord) + elif template == 15: + product_definition_template_15(section, metadata, rt_coord) + elif template == 31: + # Process satellite product. + includes_fixed_surface_keys = False + product_definition_template_31(section, metadata, rt_coord) + elif template == 32: + includes_fixed_surface_keys = False + product_definition_template_32(section, metadata, rt_coord) + elif template == 40: + product_definition_template_40(section, metadata, rt_coord) + else: + msg = "Product definition template [{}] is not supported".format(template) + raise TranslationError(msg) + + # Translate GRIB2 phenomenon to CF phenomenon. + if tablesVersion != _CODE_TABLES_MISSING: + translation_kwargs = { + "metadata": metadata, + "discipline": discipline, + "parameterCategory": section["parameterCategory"], + "parameterNumber": section["parameterNumber"], + "probability": probability, + } + + # Won't always be able to populate the below arguments - + # missing from some template definitions. + fixed_surface_keys = [ + "typeOfFirstFixedSurface", + "scaledValueOfFirstFixedSurface", + "typeOfSecondFixedSurface", + ] + + for section_key in fixed_surface_keys: + translation_kwargs[section_key] = ( + section[section_key] if includes_fixed_surface_keys else None + ) + + translate_phenomenon(**translation_kwargs) + + +############################################################################### +# +# Data Representation Section 5 +# +############################################################################### + + +def data_representation_section(section): + """ + Translate section 5 from the GRIB2 message. + + Grid point template decoding is fully provided by the ECMWF GRIB API, + all grid point and spectral templates are supported, the data payload + is returned from the GRIB API already unpacked. + + """ + # Reference GRIB2 Code Table 5.0. + template = section["dataRepresentationTemplateNumber"] + + # Supported templates for both grid point and spectral data: + grid_point_templates = (0, 1, 2, 3, 4, 40, 41, 42, 61) + spectral_templates = (50, 51) + supported_templates = grid_point_templates + spectral_templates + + if template not in supported_templates: + msg = "Data Representation Section Template [{}] is not supported".format( + template + ) + raise TranslationError(msg) + + +############################################################################### +# +# Bitmap Section 6 +# +############################################################################### + + +def bitmap_section(section): + """ + Translate section 6 from the GRIB2 message. + + The bitmap can take the following values: + + * 0: Bitmap applies to the data and is specified in this section + of this message. + * 1-253: Bitmap applies to the data, is specified by originating + centre and is not specified in section 6 of this message. + * 254: Bitmap applies to the data, is specified in an earlier + section 6 of this message and is not specified in this + section 6 of this message. + * 255: Bitmap does not apply to the data. + + Only values 0 and 255 are supported. + + """ + # Reference GRIB2 Code Table 6.0. + bitMapIndicator = section["bitMapIndicator"] + + if bitMapIndicator not in [_BITMAP_CODE_NONE, _BITMAP_CODE_PRESENT]: + msg = "Bitmap Section 6 contains unsupported bitmap indicator [{}]".format( + bitMapIndicator + ) + raise TranslationError(msg) + + +############################################################################### + + +def grib2_convert(field, metadata): + """ + Translate the GRIB2 message into the appropriate cube metadata. + + Updates the metadata in-place with the translations. + + Args: + + * field: + GRIB2 message to be translated. + + * metadata: + :class:`collections.OrderedDict` of metadata. + + """ + # Section 1 - Identification Section. + centre = _CENTRES.get(field.sections[1]["centre"]) + if centre is not None: + metadata["attributes"]["centre"] = centre + rt_coord = reference_time_coord(field.sections[1]) + + # Section 3 - Grid Definition Section (Grid Definition Template) + grid_definition_section(field.sections[3], metadata) + + # Section 4 - Product Definition Section (Product Definition Template) + product_definition_section( + field.sections[4], + metadata, + field.sections[0]["discipline"], + field.sections[1]["tablesVersion"], + rt_coord, + ) + + # Section 5 - Data Representation Section (Data Representation Template) + data_representation_section(field.sections[5]) + + # Section 6 - Bitmap Section. + bitmap_section(field.sections[6]) diff --git a/src/iris_grib/_grib_cf_map.py b/src/iris_grib/_grib_cf_map.py new file mode 100644 index 00000000..35783573 --- /dev/null +++ b/src/iris_grib/_grib_cf_map.py @@ -0,0 +1,335 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. + +""" +Provides GRIB/CF phenomenon translations. + +""" + +from collections import namedtuple + + +CFName = namedtuple("CFName", "standard_name long_name units") + +DimensionCoordinate = namedtuple("DimensionCoordinate", "standard_name units points") + +G1LocalParam = namedtuple("G1LocalParam", "edition t2version centre iParam") +G2Param = namedtuple("G2Param", "edition discipline category number") + + +GRIB1_LOCAL_TO_CF_CONSTRAINED = { + G1LocalParam(1, 128, 98, 165): ( + CFName("x_wind", None, "m s-1"), + DimensionCoordinate("height", "m", (10,)), + ), + G1LocalParam(1, 128, 98, 166): ( + CFName("y_wind", None, "m s-1"), + DimensionCoordinate("height", "m", (10,)), + ), + G1LocalParam(1, 128, 98, 167): ( + CFName("air_temperature", None, "K"), + DimensionCoordinate("height", "m", (2,)), + ), + G1LocalParam(1, 128, 98, 168): ( + CFName("dew_point_temperature", None, "K"), + DimensionCoordinate("height", "m", (2,)), + ), +} + +GRIB1_LOCAL_TO_CF = { + G1LocalParam(1, 128, 98, 31): CFName("sea_ice_area_fraction", None, "1"), + G1LocalParam(1, 128, 98, 34): CFName("sea_surface_temperature", None, "K"), + G1LocalParam(1, 128, 98, 59): CFName( + "atmosphere_specific_convective_available_potential_energy", None, "J kg-1" + ), + G1LocalParam(1, 128, 98, 129): CFName("geopotential", None, "m2 s-2"), + G1LocalParam(1, 128, 98, 130): CFName("air_temperature", None, "K"), + G1LocalParam(1, 128, 98, 131): CFName("x_wind", None, "m s-1"), + G1LocalParam(1, 128, 98, 132): CFName("y_wind", None, "m s-1"), + G1LocalParam(1, 128, 98, 135): CFName( + "lagrangian_tendency_of_air_pressure", None, "Pa s-1" + ), + G1LocalParam(1, 128, 98, 141): CFName("thickness_of_snowfall_amount", None, "m"), + G1LocalParam(1, 128, 98, 151): CFName("air_pressure_at_sea_level", None, "Pa"), + G1LocalParam(1, 128, 98, 157): CFName("relative_humidity", None, "%"), + G1LocalParam(1, 128, 98, 164): CFName("cloud_area_fraction", None, "1"), + G1LocalParam(1, 128, 98, 173): CFName("surface_roughness_length", None, "m"), + G1LocalParam(1, 128, 98, 174): CFName(None, "grib_physical_atmosphere_albedo", "1"), + G1LocalParam(1, 128, 98, 186): CFName("low_type_cloud_area_fraction", None, "1"), + G1LocalParam(1, 128, 98, 187): CFName("medium_type_cloud_area_fraction", None, "1"), + G1LocalParam(1, 128, 98, 188): CFName("high_type_cloud_area_fraction", None, "1"), + G1LocalParam(1, 128, 98, 235): CFName(None, "grib_skin_temperature", "K"), +} + +GRIB2_TO_CF = { + G2Param(2, 0, 0, 0): CFName("air_temperature", None, "K"), + G2Param(2, 0, 0, 2): CFName("air_potential_temperature", None, "K"), + G2Param(2, 0, 0, 6): CFName("dew_point_temperature", None, "K"), + G2Param(2, 0, 0, 10): CFName("surface_upward_latent_heat_flux", None, "W m-2"), + G2Param(2, 0, 0, 11): CFName("surface_upward_sensible_heat_flux", None, "W m-2"), + G2Param(2, 0, 0, 17): CFName("surface_temperature", None, "K"), + G2Param(2, 0, 0, 32): CFName("wet_bulb_potential_temperature", None, "K"), + G2Param(2, 0, 1, 0): CFName("specific_humidity", None, "kg kg-1"), + G2Param(2, 0, 1, 1): CFName("relative_humidity", None, "%"), + G2Param(2, 0, 1, 2): CFName("humidity_mixing_ratio", None, "kg kg-1"), + G2Param(2, 0, 1, 3): CFName(None, "precipitable_water", "kg m-2"), + G2Param(2, 0, 1, 7): CFName("precipitation_flux", None, "kg m-2 s-1"), + G2Param(2, 0, 1, 8): CFName("lwe_thickness_of_precipitation_amount", None, "m"), + G2Param(2, 0, 1, 9): CFName( + "stratiform_rainfall_amount", + "Large-scale precipitation (non-convective)", + "kg m-2", + ), + G2Param(2, 0, 1, 10): CFName( + "convective_rainfall_amount", "Convective precipitation", "kg m-2" + ), + G2Param(2, 0, 1, 11): CFName("thickness_of_snowfall_amount", None, "m"), + G2Param(2, 0, 1, 13): CFName( + "liquid_water_content_of_surface_snow", None, "kg m-2" + ), + G2Param(2, 0, 1, 15): CFName( + "stratiform_snowfall_amount", "Large-scale snow", "kg m-2" + ), + G2Param(2, 0, 1, 22): CFName(None, "cloud_mixing_ratio", "kg kg-1"), + G2Param(2, 0, 1, 37): CFName( + "convective_rainfall_flux", "Convective precipitation rate", "kg m-2 s-1" + ), + G2Param(2, 0, 1, 49): CFName("precipitation_amount", None, "kg m-2"), + G2Param(2, 0, 1, 51): CFName("atmosphere_mass_content_of_water", None, "kg m-2"), + G2Param(2, 0, 1, 53): CFName("snowfall_flux", None, "kg m-2 s-1"), + G2Param(2, 0, 1, 58): CFName( + "convective_snowfall_flux", "Convective snowfall rate", "kg m-2 s-1" + ), + G2Param(2, 0, 1, 59): CFName( + "stratiform_snowfall_flux", "Large scale snowfall rate", "kg m-2 s-1" + ), + G2Param(2, 0, 1, 60): CFName("snowfall_amount", None, "kg m-2"), + G2Param(2, 0, 1, 64): CFName( + "atmosphere_mass_content_of_water_vapor", None, "kg m-2" + ), + G2Param(2, 0, 1, 77): CFName( + "stratiform_rainfall_flux", "Large scale rain rate", "kg m-2 s-1" + ), + G2Param(2, 0, 1, 83): CFName( + "mass_fraction_of_cloud_liquid_water_in_air", None, "kg kg-1" + ), + G2Param(2, 0, 1, 84): CFName("mass_fraction_of_cloud_ice_in_air", None, "kg kg-1"), + G2Param(2, 0, 2, 0): CFName("wind_from_direction", None, "degrees"), + G2Param(2, 0, 2, 1): CFName("wind_speed", None, "m s-1"), + G2Param(2, 0, 2, 2): CFName("x_wind", None, "m s-1"), + G2Param(2, 0, 2, 3): CFName("y_wind", None, "m s-1"), + G2Param(2, 0, 2, 8): CFName("lagrangian_tendency_of_air_pressure", None, "Pa s-1"), + G2Param(2, 0, 2, 9): CFName( + "upward_air_velocity", "Vertical velocity (geometric)", "m s-1" + ), + G2Param(2, 0, 2, 10): CFName("atmosphere_absolute_vorticity", None, "s-1"), + G2Param(2, 0, 2, 12): CFName( + "atmosphere_relative_vorticity", "Relative vorticity", "s-1" + ), + G2Param(2, 0, 2, 14): CFName(None, "ertel_potential_velocity", "K m2 kg-1 s-1"), + G2Param(2, 0, 2, 22): CFName("wind_speed_of_gust", None, "m s-1"), + G2Param(2, 0, 3, 0): CFName("air_pressure", None, "Pa"), + G2Param(2, 0, 3, 1): CFName("air_pressure_at_sea_level", None, "Pa"), + G2Param(2, 0, 3, 3): CFName(None, "icao_standard_atmosphere_reference_height", "m"), + G2Param(2, 0, 3, 4): CFName("geopotential", None, "m2 s-2"), + G2Param(2, 0, 3, 5): CFName("geopotential_height", None, "m"), + G2Param(2, 0, 3, 6): CFName("altitude", None, "m"), + G2Param(2, 0, 3, 9): CFName("geopotential_height_anomaly", None, "m"), + G2Param(2, 0, 4, 7): CFName( + "surface_downwelling_shortwave_flux_in_air", None, "W m-2" + ), + G2Param(2, 0, 4, 9): CFName("surface_net_downward_shortwave_flux", None, "W m-2"), + G2Param(2, 0, 5, 3): CFName( + "surface_downwelling_longwave_flux_in_air", None, "W m-2" + ), + G2Param(2, 0, 5, 4): CFName( + "toa_outgoing_longwave_flux", "Upward long-wave radiation flux", "W m-2" + ), + G2Param(2, 0, 5, 5): CFName("surface_net_downward_longwave_flux", None, "W m-2"), + G2Param(2, 0, 6, 1): CFName( + None, "cloud_area_fraction_assuming_maximum_random_overlap", "1" + ), + G2Param(2, 0, 6, 3): CFName("low_type_cloud_area_fraction", None, "%"), + G2Param(2, 0, 6, 4): CFName("medium_type_cloud_area_fraction", None, "%"), + G2Param(2, 0, 6, 5): CFName("high_type_cloud_area_fraction", None, "%"), + G2Param(2, 0, 6, 6): CFName( + "atmosphere_mass_content_of_cloud_liquid_water", None, "kg m-2" + ), + G2Param(2, 0, 6, 7): CFName("cloud_area_fraction_in_atmosphere_layer", None, "%"), + G2Param(2, 0, 6, 11): CFName("cloud_base_altitude", None, "m"), + G2Param(2, 0, 6, 25): CFName(None, "WAFC_CB_horizontal_extent", "1"), + G2Param(2, 0, 6, 26): CFName(None, "WAFC_ICAO_height_at_cloud_base", "m"), + G2Param(2, 0, 6, 27): CFName(None, "WAFC_ICAO_height_at_cloud_top", "m"), + G2Param(2, 0, 7, 6): CFName( + "atmosphere_specific_convective_available_potential_energy", None, "J kg-1" + ), + G2Param(2, 0, 7, 7): CFName(None, "convective_inhibition", "J kg-1"), + G2Param(2, 0, 7, 8): CFName(None, "storm_relative_helicity", "J kg-1"), + G2Param(2, 0, 14, 0): CFName("atmosphere_mole_content_of_ozone", None, "Dobson"), + G2Param(2, 0, 19, 0): CFName("visibility_in_air", None, "m"), + G2Param(2, 0, 19, 1): CFName(None, "grib_physical_atmosphere_albedo", "%"), + G2Param(2, 0, 19, 20): CFName(None, "WAFC_icing_potential", "1"), + G2Param(2, 0, 19, 21): CFName(None, "WAFC_in-cloud_turb_potential", "1"), + G2Param(2, 0, 19, 22): CFName(None, "WAFC_CAT_potential", "1"), + G2Param(2, 2, 0, 0): CFName("land_binary_mask", None, "1"), + G2Param(2, 2, 0, 0): CFName("land_area_fraction", None, "1"), + G2Param(2, 2, 0, 1): CFName("surface_roughness_length", None, "m"), + G2Param(2, 2, 0, 2): CFName("soil_temperature", None, "K"), + G2Param(2, 2, 0, 3): CFName( + "soil_moisture_content", "Soil moisture content", "kg m-2" + ), + G2Param(2, 2, 0, 7): CFName("surface_altitude", None, "m"), + G2Param(2, 2, 0, 22): CFName("moisture_content_of_soil_layer", None, "kg m-2"), + G2Param(2, 2, 0, 34): CFName("surface_runoff_flux", None, "kg m-2 s-1"), + G2Param(2, 10, 1, 2): CFName("sea_water_x_velocity", None, "m s-1"), + G2Param(2, 10, 1, 3): CFName("sea_water_y_velocity", None, "m s-1"), + G2Param(2, 10, 2, 0): CFName("sea_ice_area_fraction", None, "1"), + G2Param(2, 10, 3, 0): CFName("sea_surface_temperature", None, "K"), +} + +CF_CONSTRAINED_TO_GRIB1_LOCAL = { + ( + CFName("air_temperature", None, "K"), + DimensionCoordinate("height", "m", (2,)), + ): G1LocalParam(1, 128, 98, 167), + ( + CFName("dew_point_temperature", None, "K"), + DimensionCoordinate("height", "m", (2,)), + ): G1LocalParam(1, 128, 98, 168), + ( + CFName("x_wind", None, "m s-1"), + DimensionCoordinate("height", "m", (10,)), + ): G1LocalParam(1, 128, 98, 165), + ( + CFName("y_wind", None, "m s-1"), + DimensionCoordinate("height", "m", (10,)), + ): G1LocalParam(1, 128, 98, 166), +} + +CF_TO_GRIB1_LOCAL = { + CFName(None, "grib_physical_atmosphere_albedo", "1"): G1LocalParam(1, 128, 98, 174), + CFName(None, "grib_skin_temperature", "K"): G1LocalParam(1, 128, 98, 235), + CFName("air_pressure_at_sea_level", None, "Pa"): G1LocalParam(1, 128, 98, 151), + CFName("air_temperature", None, "K"): G1LocalParam(1, 128, 98, 130), + CFName( + "atmosphere_specific_convective_available_potential_energy", None, "J kg-1" + ): G1LocalParam(1, 128, 98, 59), + CFName("cloud_area_fraction", None, "1"): G1LocalParam(1, 128, 98, 164), + CFName("geopotential", None, "m2 s-2"): G1LocalParam(1, 128, 98, 129), + CFName("high_type_cloud_area_fraction", None, "1"): G1LocalParam(1, 128, 98, 188), + CFName("lagrangian_tendency_of_air_pressure", None, "Pa s-1"): G1LocalParam( + 1, 128, 98, 135 + ), + CFName("low_type_cloud_area_fraction", None, "1"): G1LocalParam(1, 128, 98, 186), + CFName("medium_type_cloud_area_fraction", None, "1"): G1LocalParam(1, 128, 98, 187), + CFName("relative_humidity", None, "%"): G1LocalParam(1, 128, 98, 157), + CFName("sea_ice_area_fraction", None, "1"): G1LocalParam(1, 128, 98, 31), + CFName("sea_surface_temperature", None, "K"): G1LocalParam(1, 128, 98, 34), + CFName("surface_roughness_length", None, "m"): G1LocalParam(1, 128, 98, 173), + CFName("thickness_of_snowfall_amount", None, "m"): G1LocalParam(1, 128, 98, 141), + CFName("x_wind", None, "m s-1"): G1LocalParam(1, 128, 98, 131), + CFName("y_wind", None, "m s-1"): G1LocalParam(1, 128, 98, 132), +} + +CF_TO_GRIB2 = { + CFName(None, "WAFC_CAT_potential", "1"): G2Param(2, 0, 19, 22), + CFName(None, "WAFC_CB_horizontal_extent", "1"): G2Param(2, 0, 6, 25), + CFName(None, "WAFC_ICAO_height_at_cloud_base", "m"): G2Param(2, 0, 6, 26), + CFName(None, "WAFC_ICAO_height_at_cloud_top", "m"): G2Param(2, 0, 6, 27), + CFName(None, "WAFC_icing_potential", "1"): G2Param(2, 0, 19, 20), + CFName(None, "WAFC_in-cloud_turb_potential", "1"): G2Param(2, 0, 19, 21), + CFName(None, "cloud_area_fraction_assuming_maximum_random_overlap", "1"): G2Param( + 2, 0, 6, 1 + ), + CFName(None, "cloud_mixing_ratio", "kg kg-1"): G2Param(2, 0, 1, 22), + CFName(None, "convective_inhibition", "J kg-1"): G2Param(2, 0, 7, 7), + CFName(None, "ertel_potential_velocity", "K m2 kg-1 s-1"): G2Param(2, 0, 2, 14), + CFName(None, "grib_physical_atmosphere_albedo", "%"): G2Param(2, 0, 19, 1), + CFName(None, "icao_standard_atmosphere_reference_height", "m"): G2Param(2, 0, 3, 3), + CFName(None, "precipitable_water", "kg m-2"): G2Param(2, 0, 1, 3), + CFName(None, "storm_relative_helicity", "J kg-1"): G2Param(2, 0, 7, 8), + CFName("air_potential_temperature", None, "K"): G2Param(2, 0, 0, 2), + CFName("air_pressure", None, "Pa"): G2Param(2, 0, 3, 0), + CFName("air_pressure_at_sea_level", None, "Pa"): G2Param(2, 0, 3, 0), + CFName("air_pressure_at_sea_level", None, "Pa"): G2Param(2, 0, 3, 1), + CFName("air_temperature", None, "K"): G2Param(2, 0, 0, 0), + CFName("altitude", None, "m"): G2Param(2, 0, 3, 6), + CFName("atmosphere_absolute_vorticity", None, "s-1"): G2Param(2, 0, 2, 10), + CFName("atmosphere_mass_content_of_cloud_liquid_water", None, "kg m-2"): G2Param( + 2, 0, 6, 6 + ), + CFName("atmosphere_mass_content_of_water", None, "kg m-2"): G2Param(2, 0, 1, 51), + CFName("atmosphere_mass_content_of_water_vapor", None, "kg m-2"): G2Param( + 2, 0, 1, 64 + ), + CFName("atmosphere_mole_content_of_ozone", None, "Dobson"): G2Param(2, 0, 14, 0), + CFName("atmosphere_relative_vorticity", None, "s-1"): G2Param(2, 0, 2, 12), + CFName( + "atmosphere_specific_convective_available_potential_energy", None, "J kg-1" + ): G2Param(2, 0, 7, 6), + CFName("convective_rainfall_amount", None, "kg m-2"): G2Param(2, 0, 1, 10), + CFName("convective_rainfall_flux", None, "kg m-2 s-1"): G2Param(2, 0, 1, 37), + CFName("convective_snowfall_flux", None, "kg m-2 s-1"): G2Param(2, 0, 1, 58), + CFName("cloud_area_fraction_in_atmosphere_layer", None, "%"): G2Param(2, 0, 6, 7), + CFName("dew_point_temperature", None, "K"): G2Param(2, 0, 0, 6), + CFName("geopotential", None, "m2 s-2"): G2Param(2, 0, 3, 4), + CFName("geopotential_height", None, "m"): G2Param(2, 0, 3, 5), + CFName("geopotential_height_anomaly", None, "m"): G2Param(2, 0, 3, 9), + CFName("high_type_cloud_area_fraction", None, "%"): G2Param(2, 0, 6, 5), + CFName("humidity_mixing_ratio", None, "kg kg-1"): G2Param(2, 0, 1, 2), + CFName("lagrangian_tendency_of_air_pressure", None, "Pa s-1"): G2Param(2, 0, 2, 8), + CFName("land_area_fraction", None, "1"): G2Param(2, 2, 0, 0), + CFName("land_binary_mask", None, "1"): G2Param(2, 2, 0, 0), + CFName("liquid_water_content_of_surface_snow", None, "kg m-2"): G2Param( + 2, 0, 1, 13 + ), + CFName("low_type_cloud_area_fraction", None, "%"): G2Param(2, 0, 6, 3), + CFName("mass_fraction_of_cloud_ice_in_air", None, "kg kg-1"): G2Param(2, 0, 1, 84), + CFName("mass_fraction_of_cloud_liquid_water_in_air", None, "kg kg-1"): G2Param( + 2, 0, 1, 83 + ), + CFName("medium_type_cloud_area_fraction", None, "%"): G2Param(2, 0, 6, 4), + CFName("moisture_content_of_soil_layer", None, "kg m-2"): G2Param(2, 2, 0, 22), + CFName("precipitation_amount", None, "kg m-2"): G2Param(2, 0, 1, 49), + CFName("precipitation_flux", None, "kg m-2 s-1"): G2Param(2, 0, 1, 7), + CFName("relative_humidity", None, "%"): G2Param(2, 0, 1, 1), + CFName("sea_ice_area_fraction", None, "1"): G2Param(2, 10, 2, 0), + CFName("sea_surface_temperature", None, "K"): G2Param(2, 10, 3, 0), + CFName("sea_water_x_velocity", None, "m s-1"): G2Param(2, 10, 1, 2), + CFName("sea_water_y_velocity", None, "m s-1"): G2Param(2, 10, 1, 3), + CFName("snowfall_amount", None, "kg m-2"): G2Param(2, 0, 1, 60), + CFName("snowfall_flux", None, "kg m-2 s-1"): G2Param(2, 0, 1, 53), + CFName("soil_moisture_content", None, "kg m-2"): G2Param(2, 2, 0, 3), + CFName("soil_temperature", None, "K"): G2Param(2, 2, 0, 2), + CFName("specific_humidity", None, "kg kg-1"): G2Param(2, 0, 1, 0), + CFName("stratiform_rainfall_amount", None, "kg m-2"): G2Param(2, 0, 1, 9), + CFName("stratiform_rainfall_flux", None, "kg m-2 s-1"): G2Param(2, 0, 1, 77), + CFName("stratiform_snowfall_amount", None, "kg m-2"): G2Param(2, 0, 1, 15), + CFName("stratiform_snowfall_flux", None, "kg m-2 s-1"): G2Param(2, 0, 1, 59), + CFName("surface_air_pressure", None, "Pa"): G2Param(2, 0, 3, 0), + CFName("surface_altitude", None, "m"): G2Param(2, 2, 0, 7), + CFName("surface_downwelling_longwave_flux_in_air", None, "W m-2"): G2Param( + 2, 0, 5, 3 + ), + CFName("surface_downwelling_shortwave_flux_in_air", None, "W m-2"): G2Param( + 2, 0, 4, 7 + ), + CFName("surface_net_downward_longwave_flux", None, "W m-2"): G2Param(2, 0, 5, 5), + CFName("surface_net_downward_shortwave_flux", None, "W m-2"): G2Param(2, 0, 4, 9), + CFName("surface_roughness_length", None, "m"): G2Param(2, 2, 0, 1), + CFName("surface_runoff_flux", None, "kg m-2 s-1"): G2Param(2, 2, 0, 34), + CFName("surface_temperature", None, "K"): G2Param(2, 0, 0, 17), + CFName("surface_upward_latent_heat_flux", None, "W m-2"): G2Param(2, 0, 0, 10), + CFName("surface_upward_sensible_heat_flux", None, "W m-2"): G2Param(2, 0, 0, 11), + CFName("toa_outgoing_longwave_flux", None, "W m-2"): G2Param(2, 0, 5, 4), + CFName("thickness_of_snowfall_amount", None, "m"): G2Param(2, 0, 1, 11), + CFName("upward_air_velocity", None, "m s-1"): G2Param(2, 0, 2, 9), + CFName("wet_bulb_potential_temperature", None, "K"): G2Param(2, 0, 0, 32), + CFName("wind_from_direction", None, "degrees"): G2Param(2, 0, 2, 0), + CFName("wind_speed", None, "m s-1"): G2Param(2, 0, 2, 1), + CFName("wind_speed_of_gust", None, "m s-1"): G2Param(2, 0, 2, 22), + CFName("x_wind", None, "m s-1"): G2Param(2, 0, 2, 2), + CFName("y_wind", None, "m s-1"): G2Param(2, 0, 2, 3), +} diff --git a/src/iris_grib/_load_convert.py b/src/iris_grib/_load_convert.py new file mode 100644 index 00000000..ae414f18 --- /dev/null +++ b/src/iris_grib/_load_convert.py @@ -0,0 +1,70 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Module to support loading of GRIB data. + +Code to convert a GRIB GribMessage or GribWrapper into cube metadata. + +""" + +from argparse import Namespace +from collections import OrderedDict + +from iris.exceptions import TranslationError +from iris.fileformats.rules import ConversionMetadata + + +options = Namespace(warn_on_unsupported=False, support_hindcast_values=True) + + +def convert(field): + """ + Translate the GRIB message into the appropriate cube metadata. + + Args: + + * field: + GRIB message to be translated. + + Returns: + A :class:`iris.fileformats.rules.ConversionMetadata` object. + + """ + from ._grib1_legacy.grib1_load_rules import grib1_convert as old_grib1_convert # noqa: PLC0415 + from ._grib2_convert import grib2_convert # noqa: PLC0415 + + if hasattr(field, "sections"): + editionNumber = field.sections[0]["editionNumber"] + + if editionNumber != 2: + emsg = "GRIB edition {} is not supported by {!r}." + raise TranslationError(emsg.format(editionNumber, type(field).__name__)) + + # Initialise the cube metadata. + metadata = OrderedDict() + metadata["factories"] = [] + metadata["references"] = [] + metadata["standard_name"] = None + metadata["long_name"] = None + metadata["units"] = None + metadata["attributes"] = {} + metadata["cell_methods"] = [] + metadata["dim_coords_and_dims"] = [] + metadata["aux_coords_and_dims"] = [] + + # Convert GRIB2 message to cube metadata. + grib2_convert(field, metadata) + + result = ConversionMetadata._make(metadata.values()) + else: + editionNumber = field.edition + + if editionNumber != 1: + emsg = "GRIB edition {} is not supported by {!r}." + raise TranslationError(emsg.format(editionNumber, type(field).__name__)) + + result = old_grib1_convert(field) + + return result diff --git a/src/iris_grib/_save_rules.py b/src/iris_grib/_save_rules.py new file mode 100644 index 00000000..830fb55b --- /dev/null +++ b/src/iris_grib/_save_rules.py @@ -0,0 +1,1998 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Grib save implementation. + +:mod:`iris_grib._save_rules` is a private module with no public API. +It is invoked from :meth:`iris_grib.save_grib2`. + +""" + +import warnings + +import cf_units +import eccodes +from eccodes import CODES_MISSING_LONG as GRIB_MISSING_LONG +import numpy as np +import numpy.ma as ma + +import iris +from iris.aux_factory import HybridHeightFactory, HybridPressureFactory +from iris.coord_systems import ( + GeogCS, + RotatedGeogCS, + Mercator, + TransverseMercator, + LambertConformal, + LambertAzimuthalEqualArea, + Stereographic, +) +from iris.exceptions import TranslationError + + +from . import grib_phenom_translation as gptx +from ._grib2_convert import ( + _STATISTIC_TYPE_NAMES, + _TIME_RANGE_UNITS, + _SPATIAL_PROCESSING_TYPES, +) +from .grib_phenom_translation import GRIBCode +from iris.util import is_regular, regular_step + + +# Invert code tables from :mod:`iris_grib._load_convert`. +_STATISTIC_TYPE_NAMES_INVERTED = { + val: key for key, val in _STATISTIC_TYPE_NAMES.items() +} +_TIME_RANGE_UNITS_INVERTED = {val: key for key, val in _TIME_RANGE_UNITS.items()} + + +############################################################################### +# +# Constants +# +############################################################################### + +# Reference Flag Table 3.3 +_RESOLUTION_AND_COMPONENTS_GRID_WINDS_BIT = 3 # NB "bit5", from MSB=1. + +# Reference Regulation 92.1.6 +_DEFAULT_DEGREES_UNITS = 1.0e-6 + + +############################################################################### +# +# Identification Section 1 +# +############################################################################### + + +def centre(cube, grib): + # TODO: read centre from cube + eccodes.codes_set_long(grib, "centre", 74) # UKMO + eccodes.codes_set_long(grib, "subCentre", 0) # exeter is not in the spec + + +def reference_time(cube, grib): + # Set the reference time. + # (analysis, forecast start, verify time, obs time, etc) + try: + fp_coord = cube.coord("forecast_period") + except iris.exceptions.CoordinateNotFoundError: + fp_coord = None + + if fp_coord is not None: + rt, rt_meaning, _, _ = _non_missing_forecast_period(cube) + else: + rt, rt_meaning, _, _ = _missing_forecast_period(cube) + + eccodes.codes_set_long(grib, "significanceOfReferenceTime", rt_meaning) + eccodes.codes_set_long( + grib, "dataDate", "%04d%02d%02d" % (rt.year, rt.month, rt.day) + ) + eccodes.codes_set_long(grib, "dataTime", "%02d%02d" % (rt.hour, rt.minute)) + + # TODO: Set the calendar, when we find out what happened to the proposal! + # http://tinyurl.com/oefqgv6 + # I was sure it was approved for pre-operational use but it's not there. + + +def identification(cube, grib): + centre(cube, grib) + reference_time(cube, grib) + + # operational product, operational test, research product, etc + # (missing for now) + eccodes.codes_set_long(grib, "productionStatusOfProcessedData", 255) + + # Code table 1.4 + # analysis, forecast, processed satellite, processed radar, + if cube.coords("realization"): + # assume realization will always have 1 and only 1 point + # as cubes saving to GRIB2 a 2D horizontal slices + if cube.coord("realization").points[0] != 0: + eccodes.codes_set_long(grib, "typeOfProcessedData", 4) + else: + eccodes.codes_set_long(grib, "typeOfProcessedData", 3) + else: + eccodes.codes_set_long(grib, "typeOfProcessedData", 2) + + +############################################################################### +# +# Grid Definition Section 3 +# +############################################################################### + + +def shape_of_the_earth(cube, grib, cs=None): + # assume latlon + if cs is None: + cs = cube.coord(dimensions=[0]).coord_system + + def _set_octets( + earth_shape: int | None = None, + radius_scale_factor: int | None = None, + radius_scaled_value: int | None = None, + major_scale_factor: int | None = None, + major_scaled_value: int | None = None, + minor_scale_factor: int | None = None, + minor_scaled_value: int | None = None, + ): + key_map = { + "shapeOfTheEarth": earth_shape, + "scaleFactorOfRadiusOfSphericalEarth": radius_scale_factor, + "scaledValueOfRadiusOfSphericalEarth": radius_scaled_value, + "scaleFactorOfEarthMajorAxis": major_scale_factor, + "scaledValueOfEarthMajorAxis": major_scaled_value, + "scaleFactorOfEarthMinorAxis": minor_scale_factor, + "scaledValueOfEarthMinorAxis": minor_scaled_value, + } + for key, value in key_map.items(): + if value is not None: + eccodes.codes_set_long(grib, key, value) + + # Initially set shape_of_earth keys to missing (255 for byte). + _set_octets( + earth_shape=None, + radius_scale_factor=255, + radius_scaled_value=GRIB_MISSING_LONG, + major_scale_factor=255, + major_scaled_value=GRIB_MISSING_LONG, + minor_scale_factor=255, + minor_scaled_value=GRIB_MISSING_LONG, + ) + + if isinstance(cs, GeogCS): + ellipsoid = cs + else: + ellipsoid = cs.ellipsoid + if ellipsoid is None: + msg = ( + "Could not determine shape of the earth from coord system " + "of horizontal grid." + ) + raise TranslationError(msg) + + # Spherical earth. + if ellipsoid.inverse_flattening == 0.0: + if ellipsoid.semi_major_axis == 6371229.0: + # Fixed defined radius. + _set_octets( + earth_shape=6, + radius_scale_factor=0, + radius_scaled_value=0, + major_scale_factor=0, + major_scaled_value=0, + minor_scale_factor=0, + minor_scaled_value=0, + ) + else: + # Specified radius. + _set_octets( + earth_shape=1, + radius_scale_factor=0, + radius_scaled_value=ellipsoid.semi_major_axis, + major_scale_factor=0, + major_scaled_value=0, + minor_scale_factor=0, + minor_scaled_value=0, + ) + # Oblate spheroid earth. + else: + _set_octets( + earth_shape=7, + major_scale_factor=0, + major_scaled_value=ellipsoid.semi_major_axis, + minor_scale_factor=0, + minor_scaled_value=ellipsoid.semi_minor_axis, + ) + + +def grid_dims(x_coord, y_coord, grib, x_str, y_str): + eccodes.codes_set_long(grib, x_str, x_coord.shape[0]) + eccodes.codes_set_long(grib, y_str, y_coord.shape[0]) + + +def latlon_first_last(x_coord, y_coord, grib): + if x_coord.has_bounds() or y_coord.has_bounds(): + warnings.warn("Ignoring xy bounds") + + def scaled_minmax_points(coord, wrap_360=False): + """Get coord scaled min+max values. + + Return numpy integer values, in millionths. + Promote values to double-precision before rounding. + """ + point0, point1 = coord.points[[0, -1]] + if wrap_360: + point0, point1 = [point % 360 for point in [point0, point1]] + int0, int1 = [int(flt.astype("f8") * 1000000) for flt in (point0, point1)] + return int0, int1 + + lat0, lat1 = scaled_minmax_points(y_coord) + eccodes.codes_set_long(grib, "latitudeOfFirstGridPoint", lat0) + eccodes.codes_set_long(grib, "latitudeOfLastGridPoint", lat1) + lon0, lon1 = scaled_minmax_points(x_coord, wrap_360=True) + eccodes.codes_set_long(grib, "longitudeOfFirstGridPoint", lon0) + eccodes.codes_set_long(grib, "longitudeOfLastGridPoint", lon1) + + +def dx_dy(x_coord, y_coord, grib): + x_step = regular_step(x_coord) + y_step = regular_step(y_coord) + # Set x and y step. For degrees, this is encoded as an integer: + # 1 * 10^6 * floating point value. + # WMO Manual on Codes regulation 92.1.6 + if x_coord.units == "degrees": + eccodes.codes_set(grib, "iDirectionIncrement", round(1e6 * float(abs(x_step)))) + else: + raise ValueError( + "X coordinate must be in degrees, not {}.".format(x_coord.units) + ) + if y_coord.units == "degrees": + eccodes.codes_set(grib, "jDirectionIncrement", round(1e6 * float(abs(y_step)))) + else: + raise ValueError( + "Y coordinate must be in degrees, not {}.".format(y_coord.units) + ) + + +def scanning_mode_flags(x_coord, y_coord, grib): + x_positive = x_coord.points[1] - x_coord.points[0] > 0 + y_positive = y_coord.points[1] - y_coord.points[0] > 0 + scanningMode = 0 + if not x_positive: + scanningMode |= 0x80 # "bit 1" has negative sense : set=decreasing + if y_positive: + scanningMode |= 0x40 # "bit2" has positive sense : set=increasing + eccodes.codes_set_long(grib, "scanningMode", scanningMode) + + +def horizontal_grid_common(cube, grib, xy=False): + nx_str, ny_str = ("Nx", "Ny") if xy else ("Ni", "Nj") + # Grib encoding of the sequences of X and Y points. + y_coord = cube.coord(dimensions=[0]) + x_coord = cube.coord(dimensions=[1]) + shape_of_the_earth(cube, grib) + grid_dims(x_coord, y_coord, grib, nx_str, ny_str) # ni,nj / nx,ny + scanning_mode_flags(x_coord, y_coord, grib) + + +def latlon_points_regular(cube, grib): + y_coord = cube.coord(dimensions=[0]) + x_coord = cube.coord(dimensions=[1]) + latlon_first_last(x_coord, y_coord, grib) + dx_dy(x_coord, y_coord, grib) + + +def latlon_points_irregular(cube, grib): + y_coord = cube.coord(dimensions=[0]) + x_coord = cube.coord(dimensions=[1]) + + # Distinguish between true-north and grid-oriented vectors. + is_grid_wind = cube.name() in ( + "x_wind", + "y_wind", + "grid_eastward_wind", + "grid_northward_wind", + ) + # Encode in bit "5" of 'resolutionAndComponentFlags' (other bits unused). + component_flags = 0 + if is_grid_wind: + component_flags |= 2**_RESOLUTION_AND_COMPONENTS_GRID_WINDS_BIT + eccodes.codes_set(grib, "resolutionAndComponentFlags", component_flags) + + # Record the X and Y coordinate values. + # NOTE: there is currently a bug in the gribapi which means that the size + # of the longitudes array does not equal 'Nj', as it should. + # See : https://software.ecmwf.int/issues/browse/SUP-1096 + # So, this only works at present if the x and y dimensions are **equal**. + lon_values = x_coord.points / _DEFAULT_DEGREES_UNITS + lat_values = y_coord.points / _DEFAULT_DEGREES_UNITS + eccodes.codes_set_array( + grib, "longitude", np.array(np.round(lon_values), dtype=np.int64) + ) + eccodes.codes_set_array( + grib, "latitude", np.array(np.round(lat_values), dtype=np.int64) + ) + + +def rotated_pole(cube, grib): + # Grib encoding of a rotated pole coordinate system. + cs = cube.coord(dimensions=[0]).coord_system + + if cs.north_pole_grid_longitude != 0.0: + raise TranslationError( + "Grib save does not yet support Rotated-pole coordinates with " + "a rotated prime meridian." + ) + # XXX Pending #1125 + # eccodes.codes_set_double(grib, "latitudeOfSouthernPoleInDegrees", + # float(cs.n_pole.latitude)) + # eccodes.codes_set_double(grib, "longitudeOfSouthernPoleInDegrees", + # float(cs.n_pole.longitude)) + # eccodes.codes_set_double(grib, "angleOfRotationInDegrees", 0) + # WORKAROUND + latitude = cs.grid_north_pole_latitude / _DEFAULT_DEGREES_UNITS + longitude = ((cs.grid_north_pole_longitude + 180) % 360) / _DEFAULT_DEGREES_UNITS + eccodes.codes_set(grib, "latitudeOfSouthernPole", -round(latitude)) + eccodes.codes_set(grib, "longitudeOfSouthernPole", round(longitude)) + eccodes.codes_set(grib, "angleOfRotation", 0) + + +def points_in_unit(coord, unit): + points = coord.units.convert(coord.points, unit) + points = np.around(points).astype(int) + return points + + +def step(points, atol): + diffs = points[1:] - points[:-1] + mean_diff = np.mean(diffs).astype(points.dtype) + if not np.allclose(diffs, mean_diff, atol=atol): + raise ValueError() + return int(mean_diff) + + +def grid_definition_template_0(cube, grib): + """ + Write grid definition template 0. + + Set keys within the provided grib message based on + Grid Definition Template 3.0. + + Template 3.0 is used to represent "latitude/longitude (or equidistant + cylindrical, or Plate Carree)". + The coordinates are regularly spaced, true latitudes and longitudes. + + """ + # Constant resolution, aka 'regular' true lat-lon grid. + eccodes.codes_set_long(grib, "gridDefinitionTemplateNumber", 0) + horizontal_grid_common(cube, grib) + latlon_points_regular(cube, grib) + + +def grid_definition_template_1(cube, grib): + """ + Write grid definition template 1. + + Set keys within the provided grib message based on + Grid Definition Template 3.1. + + Template 3.1 is used to represent "rotated latitude/longitude (or + equidistant cylindrical, or Plate Carree)". + The coordinates are regularly spaced, rotated latitudes and longitudes. + + """ + # Constant resolution, aka 'regular' rotated lat-lon grid. + eccodes.codes_set_long(grib, "gridDefinitionTemplateNumber", 1) + + # Record details of the rotated coordinate system. + rotated_pole(cube, grib) + + # Encode the lat/lon points. + horizontal_grid_common(cube, grib) + latlon_points_regular(cube, grib) + + +def grid_definition_template_4(cube, grib): + """ + Write grid definition template 4. + + Set keys within the provided grib message based on + Grid Definition Template 3.4. + + Template 3.4 is used to represent "variable resolution latitude/longitude". + The coordinates are irregularly spaced latitudes and longitudes. + + """ + # XXX: will we need to set `Ni` and `Nj`? + eccodes.codes_set(grib, "gridDefinitionTemplateNumber", 4) + horizontal_grid_common(cube, grib) + latlon_points_irregular(cube, grib) + + +def grid_definition_template_5(cube, grib): + """ + Write grid definition template 5. + + Set keys within the provided grib message based on + Grid Definition Template 3.5. + + Template 3.5 is used to represent "variable resolution rotated + latitude/longitude". + The coordinates are irregularly spaced, rotated latitudes and longitudes. + + """ + # NOTE: we must set Ni=Nj=1 before establishing the template. + # Without this, setting "gridDefinitionTemplateNumber" = 5 causes an + # immediate error. + # See: https://software.ecmwf.int/issues/browse/SUP-1095 + # This is acceptable, as the subsequent call to 'horizontal_grid_common' + # will set these to the correct horizontal dimensions + # (by calling 'grid_dims'). + eccodes.codes_set(grib, "Ni", 1) + eccodes.codes_set(grib, "Nj", 1) + eccodes.codes_set(grib, "gridDefinitionTemplateNumber", 5) + + # Record details of the rotated coordinate system. + rotated_pole(cube, grib) + # Encode the lat/lon points. + horizontal_grid_common(cube, grib) + latlon_points_irregular(cube, grib) + + +def grid_definition_template_10(cube, grib): + """ + Write grid definition template 10. + + Set keys within the provided grib message based on + Grid Definition Template 3.10. + + Template 3.10 is used to represent a Mercator grid. + + """ + eccodes.codes_set(grib, "gridDefinitionTemplateNumber", 10) + + # Retrieve some information from the cube. + y_coord = cube.coord(dimensions=[0]) + x_coord = cube.coord(dimensions=[1]) + cs = y_coord.coord_system + + # Normalise the coordinate values to millimetres - the resolution + # used in the GRIB message. + y_mm = points_in_unit(y_coord, "mm") + x_mm = points_in_unit(x_coord, "mm") + + # Encode the horizontal points. + + # NB. Since we're already in millimetres, our tolerance for + # discrepancy in the differences is 1. + try: + x_step = step(x_mm, atol=1) + y_step = step(y_mm, atol=1) + except ValueError: + msg = "Irregular coordinates not supported for Mercator." + raise TranslationError(msg) + + eccodes.codes_set(grib, "Di", abs(x_step)) + eccodes.codes_set(grib, "Dj", abs(y_step)) + + horizontal_grid_common(cube, grib) + + # Transform first and last points into geographic CS. + geog = cs.ellipsoid if cs.ellipsoid is not None else GeogCS(1) + ( + first_x, + first_y, + ) = geog.as_cartopy_crs().transform_point( + x_coord.points[0], y_coord.points[0], cs.as_cartopy_crs() + ) + last_x, last_y = geog.as_cartopy_crs().transform_point( + x_coord.points[-1], y_coord.points[-1], cs.as_cartopy_crs() + ) + first_x = first_x % 360 + last_x = last_x % 360 + + eccodes.codes_set( + grib, + "latitudeOfFirstGridPoint", + int(np.round(first_y / _DEFAULT_DEGREES_UNITS)), + ) + eccodes.codes_set( + grib, + "longitudeOfFirstGridPoint", + int(np.round(first_x / _DEFAULT_DEGREES_UNITS)), + ) + eccodes.codes_set( + grib, "latitudeOfLastGridPoint", int(np.round(last_y / _DEFAULT_DEGREES_UNITS)) + ) + eccodes.codes_set( + grib, "longitudeOfLastGridPoint", int(np.round(last_x / _DEFAULT_DEGREES_UNITS)) + ) + + # Encode the latitude at which the projection intersects the Earth. + eccodes.codes_set(grib, "LaD", cs.standard_parallel / _DEFAULT_DEGREES_UNITS) + + # Encode resolution and component flags + eccodes.codes_set( + grib, + "resolutionAndComponentFlags", + 0x1 << _RESOLUTION_AND_COMPONENTS_GRID_WINDS_BIT, + ) + + +def grid_definition_template_12(cube, grib): + """ + Write grid definition template 12. + + Set keys within the provided grib message based on + Grid Definition Template 3.12. + + Template 3.12 is used to represent a Transverse Mercator grid. + + """ + eccodes.codes_set(grib, "gridDefinitionTemplateNumber", 12) + + # Retrieve some information from the cube. + y_coord = cube.coord(dimensions=[0]) + x_coord = cube.coord(dimensions=[1]) + cs = y_coord.coord_system + + # Normalise the coordinate values to centimetres - the resolution + # used in the GRIB message. + y_cm = points_in_unit(y_coord, "cm") + x_cm = points_in_unit(x_coord, "cm") + + # Set some keys specific to GDT12. + # Encode the horizontal points. + + # NB. Since we're already in centimetres, our tolerance for + # discrepancy in the differences is 1. + try: + x_step = step(x_cm, atol=1) + y_step = step(y_cm, atol=1) + except ValueError: + msg = "Irregular coordinates not supported for Transverse Mercator." + raise TranslationError(msg) + eccodes.codes_set(grib, "Di", abs(x_step)) + eccodes.codes_set(grib, "Dj", abs(y_step)) + horizontal_grid_common(cube, grib) + eccodes.codes_set(grib, "Y1", int(y_cm[0])) + eccodes.codes_set(grib, "Y2", int(y_cm[-1])) + eccodes.codes_set(grib, "X1", int(x_cm[0])) + eccodes.codes_set(grib, "X2", int(x_cm[-1])) + + # Lat and lon of reference point are measured in millionths of a degree. + eccodes.codes_set( + grib, + "latitudeOfReferencePoint", + cs.latitude_of_projection_origin / _DEFAULT_DEGREES_UNITS, + ) + eccodes.codes_set( + grib, + "longitudeOfReferencePoint", + cs.longitude_of_central_meridian / _DEFAULT_DEGREES_UNITS, + ) + + # Convert a value in metres into the closest integer number of + # centimetres. + def m_to_cm(value): + return round(value * 100) + + # False easting and false northing are measured in units of (10^-2)m. + eccodes.codes_set(grib, "XR", m_to_cm(cs.false_easting)) + eccodes.codes_set(grib, "YR", m_to_cm(cs.false_northing)) + value = cs.scale_factor_at_central_meridian + eccodes.codes_set(grib, "scaleFactorAtReferencePoint", value) + + +def _perspective_projection_common(cube, grib): + """ + Write grid definition templates 20 and 30. + + Common functionality for setting grid keys for the perspective projection + grid definition templates (20 and 30; Polar Stereographic and Lambert + Conformal. + + """ + # Retrieve some information from the cube. + y_coord = cube.coord(dimensions=[0]) + x_coord = cube.coord(dimensions=[1]) + cs = y_coord.coord_system + cs_name = cs.grid_mapping_name.replace("_", " ").title() + + both_falses_zero = all( + np.isclose(f, 0.0, atol=1e-6) for f in (cs.false_easting, cs.false_northing) + ) + if not both_falses_zero: + message = ( + f"{cs.false_easting=}, {cs.false_northing=} . Non-zero " + f"unsupported for {cs_name}." + ) + raise TranslationError(message) + + # Normalise the coordinate values to millimetres - the resolution + # used in the GRIB message. + y_mm = points_in_unit(y_coord, "mm") + x_mm = points_in_unit(x_coord, "mm") + + # Encode the horizontal points. + # NB. Since we're already in millimetres, our tolerance for + # discrepancy in the differences is 1. + try: + x_step = step(x_mm, atol=1) + y_step = step(y_mm, atol=1) + except ValueError: + msg = "Irregular coordinates not supported for {!r}." + raise TranslationError(msg.format(cs_name)) + eccodes.codes_set(grib, "Dx", abs(x_step)) + eccodes.codes_set(grib, "Dy", abs(y_step)) + + horizontal_grid_common(cube, grib, xy=True) + + eccodes.codes_set( + grib, + "resolutionAndComponentFlags", + 0x1 << _RESOLUTION_AND_COMPONENTS_GRID_WINDS_BIT, + ) + + # Transform first point into geographic CS. + geog = cs.ellipsoid if cs.ellipsoid is not None else GeogCS(1) + first_x, first_y = geog.as_cartopy_crs().transform_point( + x_coord.points[0], y_coord.points[0], cs.as_cartopy_crs() + ) + first_x = first_x % 360 + central_lon = cs.central_lon % 360 + + eccodes.codes_set( + grib, + "latitudeOfFirstGridPoint", + int(np.round(first_y / _DEFAULT_DEGREES_UNITS)), + ) + eccodes.codes_set( + grib, + "longitudeOfFirstGridPoint", + int(np.round(first_x / _DEFAULT_DEGREES_UNITS)), + ) + eccodes.codes_set(grib, "LaD", cs.central_lat / _DEFAULT_DEGREES_UNITS) + eccodes.codes_set(grib, "LoV", central_lon / _DEFAULT_DEGREES_UNITS) + + +def grid_definition_template_20(cube, grib): + """ + Write grid definition template 20. + + Set keys within the provided GRIB message based on + Grid Definition Template 3.20. + + Template 3.20 is used to represent a Polar Stereographic grid. + + """ + eccodes.codes_set(grib, "gridDefinitionTemplateNumber", 20) + + # Retrieve the cube coord system. + cs = cube.coord(dimensions=[0]).coord_system + + _perspective_projection_common(cube, grib) + + # Is this a north or south polar stereographic projection? + if np.isclose(cs.central_lat, -90.0): + centre_flag = 0x80 + elif np.isclose(cs.central_lat, 90.0): + centre_flag = 0x0 + else: + message = ( + f"{cs.central_lat=} . GRIB Template 3.20 only supports the polar " + "stereographic projection; central_lat must be 90.0 or -90.0." + ) + raise TranslationError(message) + + def non_standard_scale_factor_error(message: str): + message = f"Non-standard scale factor specified. {message}" + raise TranslationError(message) + + if cs.true_scale_lat and cs.true_scale_lat != cs.central_lat: + non_standard_scale_factor_error( + f"{cs.true_scale_lat=}, {cs.central_lat=} . iris_grib can " + "only write a GRIB Template 3.20 file where these are identical." + ) + + if cs.scale_factor_at_projection_origin and not np.isclose( + cs.scale_factor_at_projection_origin, 1.0 + ): + non_standard_scale_factor_error( + f"{cs.scale_factor_at_projection_origin=} . iris_grib cannot " + "write scale_factor_at_projection_origin to a GRIB Template 3.20 " + "file." + ) + + eccodes.codes_set(grib, "projectionCentreFlag", centre_flag) + + +def grid_definition_template_30(cube, grib): + """ + Write grid definition template 30. + + Set keys within the provided grib message based on + Grid Definition Template 3.30. + + Template 3.30 is used to represent a Lambert Conformal grid. + + """ + + eccodes.codes_set(grib, "gridDefinitionTemplateNumber", 30) + + # Retrieve the cube coord system. + cs = cube.coord(dimensions=[0]).coord_system + + _perspective_projection_common(cube, grib) + + # Which pole are the parallels closest to? That is the direction + # that the cone converges. + latin1, latin2 = cs.secant_latitudes + eccodes.codes_set(grib, "Latin1", latin1 / _DEFAULT_DEGREES_UNITS) + eccodes.codes_set(grib, "Latin2", latin2 / _DEFAULT_DEGREES_UNITS) + + # Which pole are the parallels closest to? That is the direction + # that the cone converges. + poliest_secant = latin1 if abs(latin1) > abs(latin2) else latin2 + centre_flag = 0x0 if poliest_secant > 0 else 0x80 + eccodes.codes_set(grib, "projectionCentreFlag", centre_flag) + + eccodes.codes_set(grib, "latitudeOfSouthernPole", 0) + eccodes.codes_set(grib, "longitudeOfSouthernPole", 0) + + +def grid_definition_template_140(cube, grib): + """ + Write grid definition template 140. + + Set keys within the provided grib message based on + Grid Definition Template 3.140. + + Template 3.140 is used to represent a Lambert Azimuthual Equal Area grid. + + """ + + eccodes.codes_set(grib, "gridDefinitionTemplateNumber", 140) + + # Retrieve some information from the cube. + y_coord = cube.coord(dimensions=[0]) + x_coord = cube.coord(dimensions=[1]) + cs = y_coord.coord_system + + # Normalise the coordinate values to millimetres - the resolution + # used in the GRIB message. + y_mm = points_in_unit(y_coord, "mm") + x_mm = points_in_unit(x_coord, "mm") + + # Encode the horizontal points. + + # NB. Since we're already in millimetres, our tolerance for + # discrepancy in the differences is 1. + try: + x_step = step(x_mm, atol=1) + y_step = step(y_mm, atol=1) + except ValueError: + msg = "Irregular coordinates not supported for Lambert Azimuthal Equal Area." + raise TranslationError(msg) + eccodes.codes_set(grib, "Dx", abs(x_step)) + eccodes.codes_set(grib, "Dy", abs(y_step)) + + horizontal_grid_common(cube, grib, xy=True) + + # Transform first point into geographic CS + geog = cs.ellipsoid if cs.ellipsoid is not None else GeogCS(1) + first_x, first_y = geog.as_cartopy_crs().transform_point( + x_coord.points[0], y_coord.points[0], cs.as_cartopy_crs() + ) + first_x = first_x % 360 + central_lon = cs.longitude_of_projection_origin % 360 + central_lat = cs.latitude_of_projection_origin + + eccodes.codes_set( + grib, + "latitudeOfFirstGridPoint", + int(np.round(first_y / _DEFAULT_DEGREES_UNITS)), + ) + eccodes.codes_set( + grib, + "longitudeOfFirstGridPoint", + int(np.round(first_x / _DEFAULT_DEGREES_UNITS)), + ) + eccodes.codes_set( + grib, "standardParallelInMicrodegrees", central_lat / _DEFAULT_DEGREES_UNITS + ) + eccodes.codes_set( + grib, "centralLongitudeInMicrodegrees", central_lon / _DEFAULT_DEGREES_UNITS + ) + eccodes.codes_set( + grib, + "resolutionAndComponentFlags", + 0x1 << _RESOLUTION_AND_COMPONENTS_GRID_WINDS_BIT, + ) + if not (np.isclose(cs.false_easting, 0.0, atol=1e-6)) or not ( + np.isclose(cs.false_northing, 0.0, atol=1e-6) + ): + msg = ( + "non zero false easting ({:.2f}) or " + "non zero false northing ({:.2f})" + "; unsupported by GRIB Template 3.140" + "" + ).format(cs.false_easting, cs.false_northing) + raise TranslationError(msg) + + +def is_grid_definition_template_40(cube, x_coord, y_coord): + """Work out whether this cube will save with GDT 3.40 (gaussian grid).""" + # Only allow saving to GDT3.40 when the enabling attribute is present. + template = cube.attributes.get("GRIB2_GRID_TEMPLATE", "") + ok = isinstance(template, int) and template == 40 # nothing else will do ! + + if ok: + # Initial basic check that we have 1-D coords. + xco_dims = cube.coord_dims(x_coord) + yco_dims = cube.coord_dims(y_coord) + ok = yco_dims == xco_dims and len(xco_dims) == 1 + + if ok: + lons, lats = x_coord.points, y_coord.points + ydiffs = np.diff(lats) + # Latitudes should have some repeated values. + ok = np.any(ydiffs == 0) + + if ok: + # Latitudes should be monotonic + yd_min = ydiffs.min() + yd_max = ydiffs.max() + lats_increasing = yd_max > 0 + ok = (lats_increasing and yd_min >= 0) or (not lats_increasing and yd_max <= 0) + + if ok: + # Check the expected structure of the data: + # each lat-val occupies a contiguous block, + # within which the lon vals are (strictly) monotonic. + lat_vals = np.array(sorted(set(lats))) + n_lat_vals = len(lat_vals) + # ought to always divide by 2, since N parallels either side of equator + ok = n_lat_vals % 2 == 0 + + if ok: + if not lats_increasing: + # Put the set of latitude values into the expected order + lat_vals = lat_vals[::-1] + lons = x_coord.points + lons_increasing = np.diff(lons[lats == lat_vals[0]]).min() >= 0 + + prev_maxind = -1 + for this_lat in lat_vals: + thislat_inds = np.where(lats == this_lat)[0] + n_thislat = len(thislat_inds) + i_min, i_max = thislat_inds[[0, -1]] + # Check that latitudes are contiguous in one direction + # (effectively, monotonic and "lat_vals" is in order of occurrence) + if i_min != prev_maxind + 1: + ok = False + break + prev_maxind = i_max + if (i_max - i_min + 1) != n_thislat: + ok = False + break + + thislat_lons = lons[i_min : i_max + 1] + if (lons_increasing and np.diff(thislat_lons).min() <= 0) or ( + not lons_increasing and np.diff(thislat_lons).max() >= 0 + ): + ok = False + break + + return ok + + +def grid_definition_template_40(cube, grib, x_coord, y_coord): + # For the gaussian grids, for now it appears that to function the message must be + # based on a different template. + # grib = eccodes.codes_grib_new_from_samples("reduced_gg_sfc_grib2") + # --OR-- possibly also, can set the "computed key" 'gridType = gg' ?? + # + # **FOR NOW:** this is handled as a special case by the caller: + # 'grid_definition_section' --> 'is_grid_definition_template_40' + + # NB some code here is also duplicated from the 'is..' routine : TODO improve DRY?? + + eccodes.codes_set(grib, "gridDefinitionTemplateNumber", 40) + + lons, lats = x_coord.points, y_coord.points + lats_increasing = np.diff(lats).min() >= 0 + lat_vals = sorted(set(lats)) + n_lat_vals = len(lat_vals) + if not lats_increasing: + lat_vals = lat_vals[::-1] + + # We want to do the equivalent of 'horizontal_common', but we can't call that as it + # calls "grid_dims" to set Ni/Nj, which we need to control differently. + # So we here call its other workers : "shape_of_the_earth" + "scanning_mode_flags" + shape_of_the_earth(cube, grib, cs=x_coord.coord_system) + scanning_mode_flags(x_coord, y_coord, grib) + + eccodes.codes_set( + grib, "latitudeOfFirstGridPoint", int(1.0e6 * lats[0]) + ) # in micro degrees + eccodes.codes_set(grib, "latitudeOfLastGridPoint", int(1.0e6 * lats[-1])) + + lons_increasing = np.diff(lons[lats == lat_vals[0]]).min() >= 0 + if lons_increasing: + lon0, lon1 = lons.min(), lons.max() + else: + lon0, lon1 = lons.max(), lons.min() + + eccodes.codes_set(grib, "longitudeOfFirstGridPoint", int(1.0e6 * lon0)) + eccodes.codes_set(grib, "longitudeOfLastGridPoint", int(1.0e6 * lon1)) + + eccodes.codes_set(grib, "N", n_lat_vals / 2) + eccodes.codes_set(grib, "Nj", n_lat_vals) + eccodes.codes_set_missing(grib, "Ni") + + eccodes.codes_set_long(grib, "basicAngleOfTheInitialProductionDomain", 0) + eccodes.codes_set_long(grib, "resolutionAndComponentFlags", 0) + eccodes.codes_set_missing(grib, "iDirectionIncrement") + + lon_repeats = [np.sum(lats == onelat) for onelat in lat_vals] + eccodes.codes_set(grib, "numberOfOctectsForNumberOfPoints", 2) + eccodes.codes_set(grib, "interpretationOfNumberOfPoints", 1) + eccodes.codes_set_array(grib, "pl", list(lon_repeats)) + + # # Check the latitude values calculation + # # ??? but apparently, this can't be called in this way ??? + # y_points = eccodes.codes_get_array(grib, "distinctLatitudes") + # tolerance = (lats.max() - lats.min()) * 0.05 / n_lat_vals + # if not np.all(np.abs(y_points - lats) < tolerance): + # return result + + return grib + + +def grid_definition_section(cube, grib, x_coord=None, y_coord=None): + """ + Write the grid definition template. + + Set keys within the grid definition section of the provided grib message, + based on the properties of the cube. + + """ + # NB x_coord and y_coord are only optional because of legacy testing code + if x_coord is None and y_coord is None: + x_coord = cube.coord(dimensions=[1]) + y_coord = cube.coord(dimensions=[0]) + + # Get coord system + # N.B. caller already checked that it exists and is same for x and y. + cs = x_coord.coord_system + regular_x_and_y = is_regular(x_coord) and is_regular(y_coord) + + if isinstance(cs, GeogCS): + if regular_x_and_y: + grid_definition_template_0(cube, grib) + else: + # Check for gaussian lats+lons, and if not fallback on GDT3.4 + if is_grid_definition_template_40(cube, x_coord, y_coord): + grid_definition_template_40(cube, grib, x_coord, y_coord) + else: + grid_definition_template_4(cube, grib) + + elif isinstance(cs, RotatedGeogCS): + # Rotated coordinate system cases. + # Choose between GDT 3.1 and 3.5 according to coordinate regularity. + if regular_x_and_y: + grid_definition_template_1(cube, grib) + else: + # N.B. we could here be dealing with data on a "rotated gaussion" grid, + # (GDT 3.41 or 3.43), but we don't yet support those. + grid_definition_template_5(cube, grib) + + elif isinstance(cs, Mercator): + # Mercator coordinate system (template 3.10) + grid_definition_template_10(cube, grib) + + elif isinstance(cs, TransverseMercator): + # Transverse Mercator coordinate system (template 3.12). + grid_definition_template_12(cube, grib) + + elif isinstance(cs, Stereographic): + # Stereographic coordinate system (template 3.20). + # This will also handle the PolarStereographic subclass. + grid_definition_template_20(cube, grib) + + elif isinstance(cs, LambertConformal): + # Lambert Conformal coordinate system (template 3.30). + grid_definition_template_30(cube, grib) + + elif isinstance(cs, LambertAzimuthalEqualArea): + # Lambert Azimuthal Equal Area coordinate system (template 3.140). + grid_definition_template_140(cube, grib) + + else: + name = cs.grid_mapping_name.replace("_", " ").title() + emsg = "Grib saving is not supported for coordinate system {!r}" + raise ValueError(emsg.format(name)) + + +############################################################################### +# +# Product Definition Section 4 +# +############################################################################### + + +def set_discipline_and_parameter(cube, grib): + # Default values for parameter identity keys = effectively "MISSING". + discipline, category, number = 255, 255, 255 + identity_found = False + + # First, see if we can find and interpret a 'GRIB_PARAM' attribute. + attr = cube.attributes.get("GRIB_PARAM", None) + if attr: + try: + # Convert to standard tuple-derived form. + gc = GRIBCode(attr) + if gc.edition == 2: + discipline = gc.discipline + category = gc.category + number = gc.number + identity_found = True + except Exception: + pass + + if not identity_found: + # Else, translate a cube phenomenon, if possible. + # NOTE: for now, can match by *either* standard_name or long_name. + # This allows workarounds for data with no identified standard_name. + grib2_info = gptx.cf_phenom_to_grib2_info(cube.standard_name, cube.long_name) + if grib2_info is not None: + discipline = grib2_info.discipline + category = grib2_info.category + number = grib2_info.number + identity_found = True + + if not identity_found: + warnings.warn( + "Unable to determine Grib2 parameter code for cube.\n" + "discipline, parameterCategory and parameterNumber " + 'have been set to "missing".' + ) + + eccodes.codes_set(grib, "discipline", discipline) + eccodes.codes_set(grib, "parameterCategory", category) + eccodes.codes_set(grib, "parameterNumber", number) + + +def _non_missing_forecast_period(cube): + """ + Calculate "model start time" to use as the reference time. + + In the case that a forecast-period coord already exists. + """ + fp_coord = cube.coord("forecast_period") + + # Convert fp and t to hours so we can subtract to calculate R. + cf_fp_hrs = fp_coord.units.convert(fp_coord.points[0], "hours") + t_coord = cube.coord("time").copy() + hours_since = cf_units.Unit("hours since epoch", calendar=t_coord.units.calendar) + t_coord.convert_units(hours_since) + + rt_num = t_coord.points[0] - cf_fp_hrs + rt = hours_since.num2date(rt_num) + rt_meaning = 1 # "start of forecast" + + # Forecast period + if fp_coord.units == cf_units.Unit("hours"): + grib_time_code = 1 + elif fp_coord.units == cf_units.Unit("minutes"): + grib_time_code = 0 + elif fp_coord.units == cf_units.Unit("seconds"): + grib_time_code = 13 + else: + raise TranslationError( + "Unexpected units for 'forecast_period' : %s" % fp_coord.units + ) + + if not t_coord.has_bounds(): + fp = fp_coord.points[0] + else: + if not fp_coord.has_bounds(): + raise TranslationError( + "bounds on 'time' coordinate requires bounds on 'forecast_period'." + ) + fp = fp_coord.bounds[0][0] + + if fp - int(fp): + warnings.warn("forecast_period encoding problem: scaling required.") + fp = int(fp) + + return rt, rt_meaning, fp, grib_time_code + + +def _missing_forecast_period(cube): + """ + Create time coords when there is no forecast-period coordinate. + + Returns a reference time and significance code together with a forecast + period and corresponding units type code. + + """ + t_coord = cube.coord("time") + + if cube.coords("forecast_reference_time"): + # Make copies and convert them to common "hours since" units. + hours_since = cf_units.Unit( + "hours since epoch", calendar=t_coord.units.calendar + ) + frt_coord = cube.coord("forecast_reference_time").copy() + frt_coord.convert_units(hours_since) + t_coord = t_coord.copy() + t_coord.convert_units(hours_since) + # Extract values. + t = t_coord.bounds[0, 0] if t_coord.has_bounds() else t_coord.points[0] + frt = frt_coord.points[0] + # Calculate GRIB parameters. + rt = frt_coord.units.num2date(frt) + rt_meaning = 1 # Forecast reference time. + fp = t - frt + integer_fp = int(fp) + if integer_fp != fp: + msg = "Truncating floating point forecast period {} to integer value {}" + warnings.warn(msg.format(fp, integer_fp)) + fp = integer_fp + fp_meaning = 1 # Hours + else: + # With no forecast period or forecast reference time set assume a + # reference time significance of "Observation time" and set the + # forecast period to 0h. + t = t_coord.bounds[0, 0] if t_coord.has_bounds() else t_coord.points[0] + rt = t_coord.units.num2date(t) + rt_meaning = 3 # Observation time + fp = 0 + fp_meaning = 1 # Hours + + return rt, rt_meaning, fp, fp_meaning + + +def set_forecast_time(cube, grib): + """ + Write the forecast time. + + Set message keys based on the forecast_period coordinate. In + the absence of a forecast_period and forecast_reference_time, + the forecast time is set to zero. + + """ + try: + fp_coord = cube.coord("forecast_period") + except iris.exceptions.CoordinateNotFoundError: + fp_coord = None + + if fp_coord is not None: + _, _, fp, grib_time_code = _non_missing_forecast_period(cube) + else: + _, _, fp, grib_time_code = _missing_forecast_period(cube) + + eccodes.codes_set(grib, "indicatorOfUnitForForecastTime", grib_time_code) + eccodes.codes_set(grib, "forecastTime", fp) + + +def set_fixed_surfaces(cube, grib, full3d_cube=None): + # Look for something we can export + v_coord = grib_v_code = output_unit = None + + # Detect factories for hybrid vertical coordinates. + hybrid_factories = [ + factory + for factory in cube.aux_factories + if isinstance(factory, HybridHeightFactory | HybridPressureFactory) + ] + if not hybrid_factories: + hybrid_factory = None + elif len(hybrid_factories) > 1: + msg = "Data contains >1 vertical coordinate factory : {}" + raise ValueError(msg.format(hybrid_factories)) + else: + factory = hybrid_factories[0] + # Fetch the matching 'complete' factory from the *full* 3d cube, so we + # have all the level information. + hybrid_factory = full3d_cube.aux_factory(factory.name()) + + # Handle various different styles of vertical coordinate. + # hybrid height / pressure + if hybrid_factory is not None: + # N.B. in this case, there are additional operations, besides just + # encoding v_coord : see below at end .. + v_coord = cube.coord("model_level_number") + output_unit = cf_units.Unit("1") + if isinstance(hybrid_factory, HybridHeightFactory): + grib_v_code = 118 + elif isinstance(hybrid_factory, HybridPressureFactory): + grib_v_code = 119 + else: + msg = "Unrecognised factory type : {}" + raise ValueError(msg.format(hybrid_factory)) + + # pressure + elif cube.coords("air_pressure") or cube.coords("pressure"): + grib_v_code = 100 + output_unit = cf_units.Unit("Pa") + v_coord = (cube.coords("air_pressure") or cube.coords("pressure"))[0] + + # altitude + elif cube.coords("altitude"): + grib_v_code = 102 + output_unit = cf_units.Unit("m") + v_coord = cube.coord("altitude") + + # height + elif cube.coords("height"): + grib_v_code = 103 + output_unit = cf_units.Unit("m") + v_coord = cube.coord("height") + + # depth + elif cube.coords("depth"): + grib_v_code = 106 + output_unit = cf_units.Unit("m") + v_coord = cube.coord("depth") + + elif cube.coords("air_potential_temperature"): + grib_v_code = 107 + output_unit = cf_units.Unit("K") + v_coord = cube.coord("air_potential_temperature") + + # unknown / absent + else: + fs_v_coords = [ + coord + for coord in cube.coords() + if "GRIB_fixed_surface_type" in coord.attributes + ] + if len(fs_v_coords) > 1: + fs_types = [c.attributes["GRIB_fixed_surface_type"] for c in fs_v_coords] + raise ValueError( + "Multiple vertical-axis coordinates were found " + f"of fixed surface type: {fs_types}" + ) + elif len(fs_v_coords) == 1: + v_coord = fs_v_coords[0] + grib_v_code = v_coord.attributes["GRIB_fixed_surface_type"] + else: + # check for *ANY* height coords at all... + v_coords = cube.coords(axis="z") + if v_coords: + # There are vertical coordinate(s), but we don't understand + # them... + v_coords_str = " ,".join(["'{}'".format(c.name()) for c in v_coords]) + raise TranslationError( + "The vertical-axis coordinate(s) ({}) " + "are not recognised or handled.".format(v_coords_str) + ) + + # What did we find? + if v_coord is None: + # No vertical coordinate: record as 'surface' level (levelType=1). + # NOTE: may *not* be truly correct, but seems to be common practice. + # Still under investigation : + # See https://github.com/SciTools/iris/issues/519 + eccodes.codes_set(grib, "typeOfFirstFixedSurface", 1) + eccodes.codes_set(grib, "scaleFactorOfFirstFixedSurface", 0) + eccodes.codes_set(grib, "scaledValueOfFirstFixedSurface", 0) + # Set secondary surface = 'missing'. + eccodes.codes_set(grib, "typeOfSecondFixedSurface", 255) + eccodes.codes_set(grib, "scaleFactorOfSecondFixedSurface", GRIB_MISSING_LONG) + eccodes.codes_set(grib, "scaledValueOfSecondFixedSurface", GRIB_MISSING_LONG) + elif not v_coord.has_bounds(): + # No second surface + output_v = v_coord.points[0] + if output_unit: + output_v = v_coord.units.convert(output_v, output_unit) + if output_v - abs(output_v): + warnings.warn("Vertical level encoding problem: scaling required.") + output_v = round(output_v) + + eccodes.codes_set(grib, "typeOfFirstFixedSurface", grib_v_code) + eccodes.codes_set(grib, "scaleFactorOfFirstFixedSurface", 0) + eccodes.codes_set(grib, "scaledValueOfFirstFixedSurface", output_v) + eccodes.codes_set(grib, "typeOfSecondFixedSurface", 255) + eccodes.codes_set(grib, "scaleFactorOfSecondFixedSurface", GRIB_MISSING_LONG) + eccodes.codes_set(grib, "scaledValueOfSecondFixedSurface", GRIB_MISSING_LONG) + else: + # bounded : set lower+upper surfaces + output_v = v_coord.bounds[0] + if output_unit: + output_v = v_coord.units.convert(output_v, output_unit) + if output_v[0] - abs(output_v[0]) or output_v[1] - abs(output_v[1]): + warnings.warn("Vertical level encoding problem: scaling required.") + eccodes.codes_set(grib, "typeOfFirstFixedSurface", grib_v_code) + eccodes.codes_set(grib, "typeOfSecondFixedSurface", grib_v_code) + eccodes.codes_set(grib, "scaleFactorOfFirstFixedSurface", 0) + eccodes.codes_set(grib, "scaleFactorOfSecondFixedSurface", 0) + eccodes.codes_set(grib, "scaledValueOfFirstFixedSurface", round(output_v[0])) + eccodes.codes_set(grib, "scaledValueOfSecondFixedSurface", round(output_v[1])) + + if hybrid_factory is not None: + # Need to record ALL the level coefficients in a 'PV' vector. + level_delta_coord = hybrid_factory.delta + sigma_coord = hybrid_factory.sigma + model_levels = full3d_cube.coord("model_level_number").points + # Just check these make some kind of sense (!) + if model_levels.dtype.kind not in "iu": + msg = "model_level_number is not an integer: dtype={}." + raise ValueError(msg.format(model_levels.dtype)) + if np.min(model_levels) < 1: + msg = "model_level_number must be > 0: minimum value = {}." + raise ValueError(msg.format(np.min(model_levels))) + # Need to save enough levels for indexes up to [max(model_levels)] + n_levels = np.max(model_levels) + max_valid_nlevels = 9999 + if n_levels > max_valid_nlevels: + msg = "model_level_number values are > {} : maximum value = {}." + raise ValueError(msg.format(max_valid_nlevels, n_levels)) + # In sample data we have seen, there seems to be an extra missing data + # value *before* each set of n-levels coefficients. + # Note: values are indexed according to model_level_number, + # I.E. sigma, delta = PV[i], PV[NV/2+i] : where i=1..n_levels + n_coeffs = n_levels + 1 + coeffs_array = np.zeros(n_coeffs * 2, dtype=np.float32) + for n_lev, height, sigma in zip( + model_levels, level_delta_coord.points, sigma_coord.points, strict=False + ): + # Record all the level coefficients coming from the 'full' cube. + # Note: if some model levels are missing, we must still have the + # coeffs at the correct index according to the model_level_number + # value, i.e. at [level] and [NV // 2 + level]. + # Thus, we can *not* paste the values in a block: each one needs to + # go in the index corresponding to its 'model_level_number' value. + coeffs_array[n_lev] = height + coeffs_array[n_coeffs + n_lev] = sigma + pv_values = [float(el) for el in coeffs_array] + # eccodes does not support writing numpy.int64, cast to python int + eccodes.codes_set(grib, "NV", int(n_coeffs * 2)) + eccodes.codes_set_array(grib, "pv", pv_values) + + +def set_time_range(time_coord, grib): + """ + Write the time range. + + Set the time range keys in the specified message + based on the bounds of the provided time coordinate. + + """ + if len(time_coord.points) != 1: + msg = "Expected length one time coordinate, got {} points" + raise ValueError(msg.format(len(time_coord.points))) + + if time_coord.nbounds != 2: + msg = "Expected time coordinate with two bounds, got {} bounds" + raise ValueError(msg.format(time_coord.nbounds)) + + # Set type to hours and convert period to this unit. + eccodes.codes_set( + grib, "indicatorOfUnitForTimeRange", _TIME_RANGE_UNITS_INVERTED["hours"] + ) + hours_since_units = cf_units.Unit( + "hours since epoch", calendar=time_coord.units.calendar + ) + start_hours, end_hours = time_coord.units.convert( + time_coord.bounds[0], hours_since_units + ) + # Cast from np.float to Python int. The lengthOfTimeRange key is a + # 4 byte integer so we cast to highlight truncation of any floating + # point value. The grib_api will do the cast from float to int, but it + # cannot handle numpy floats. + time_range_in_hours = end_hours - start_hours + integer_hours = int(time_range_in_hours) + if integer_hours != time_range_in_hours: + msg = "Truncating floating point lengthOfTimeRange {} to integer value {}" + warnings.warn(msg.format(time_range_in_hours, integer_hours)) + eccodes.codes_set(grib, "lengthOfTimeRange", integer_hours) + + +def set_time_increment(cell_method, grib): + """ + Write the time increment. + + Set the time increment keys in the specified message + based on the provided cell method. + + """ + # Type of time increment, e.g incrementing forecast period, incrementing + # forecast reference time, etc. Set to missing, but we could use the + # cell method coord to infer a value (see code table 4.11). + eccodes.codes_set(grib, "typeOfTimeIncrement", 255) + + # Default values for the time increment value and units type. + inc = 0 + units_type = 255 + # Attempt to determine time increment from cell method intervals string. + intervals = cell_method.intervals + if intervals is not None and len(intervals) == 1: + (interval,) = intervals + try: + inc, units = interval.split() + inc = float(inc) + if units in ("hr", "hour", "hours"): + units_type = _TIME_RANGE_UNITS_INVERTED["hours"] + else: + raise ValueError("Unable to parse units of interval") + except ValueError: + # Problem interpreting the interval string. + inc = 0 + units_type = 255 + else: + # Cast to int as timeIncrement key is a 4 byte integer. + integer_inc = int(inc) + if integer_inc != inc: + warnings.warn( + "Truncating floating point timeIncrement {} to " + "integer value {}".format(inc, integer_inc) + ) + inc = integer_inc + + eccodes.codes_set(grib, "indicatorOfUnitForTimeIncrement", units_type) + eccodes.codes_set(grib, "timeIncrement", inc) + + +def _cube_is_time_statistic(cube): + """ + Test whether we can identify this cube as a statistic over time. + + We need to know whether our cube represents a time statistic. This is + almost always captured in the cell methods. The exception is when a + percentage statistic has been calculated (i.e. for PDT10). This is + captured in a `percentage_over_time` scalar coord, which must be handled + here too. + + """ + result = False + stat_coord_name = "percentile_over_time" + cube_coord_names = [coord.name() for coord in cube.coords()] + + # Check our cube for time statistic indicators. + has_percentile_statistic = stat_coord_name in cube_coord_names + has_cell_methods = cube.cell_methods + + # Determine whether we have a time statistic. + if has_percentile_statistic: + result = True + elif has_cell_methods: + # Define accepted time names, including from coord_categorisations. + recognised_time_names = ["time", "year", "month", "day", "weekday", "season"] + latest_coordnames = cube.cell_methods[-1].coord_names + if len(latest_coordnames) != 1: + result = False + else: + coord_name = latest_coordnames[0] + result = coord_name in recognised_time_names + else: + result = False + + return result + + +def _spatial_statistic(cube): + """ + Gather and return the spatial statistic of the cube if it is present. + + """ + spatial_cell_methods = [ + cell_method + for cell_method in cube.cell_methods + if "area" in cell_method.coord_names + ] + + if len(spatial_cell_methods) > 1: + raise ValueError("Cannot handle multiple 'area' cell methods") + elif len(spatial_cell_methods[0].coord_names) > 1: + raise ValueError( + "Cannot handle multiple coordinate names in " + "the spatial processing related cell method. " + "Expected ('area',), got {!r}".format(spatial_cell_methods[0].coord_names) + ) + + return spatial_cell_methods + + +def statistical_method_code(cell_method_name): + """ + Decode cell_method string as statistic code integer. + """ + statistic_code = _STATISTIC_TYPE_NAMES_INVERTED.get(cell_method_name) + if statistic_code is None: + msg = ( + "Product definition section 4 contains an unsupported " + "statistical process type [{}] " + ) + raise TranslationError(msg.format(statistic_code)) + + return statistic_code + + +def get_spatial_process_code(spatial_processing_type): + """ + Decode spatial_processing_type string as spatial process code integer. + """ + spatial_processing_code = None + + for code, interp_params in _SPATIAL_PROCESSING_TYPES.items(): + if interp_params.interpolation_type == spatial_processing_type: + spatial_processing_code = code + break + + if spatial_processing_code is None: + msg = ( + "Product definition section 4 contains an unsupported " + "spatial processing or interpolation type: {} " + ) + raise TranslationError(msg.format(spatial_processing_type)) + + return spatial_processing_code + + +def set_ensemble(cube, grib): + """ + Write the ensemble number. + + Set keys in the provided grib based message relating to ensemble + information. + + """ + if not (cube.coords("realization") and len(cube.coord("realization").points) == 1): + raise ValueError( + "A cube 'realization' coordinate with one " + "point is required, but not present" + ) + eccodes.codes_set( + grib, "perturbationNumber", int(cube.coord("realization").points[0]) + ) + # no encoding at present in iris-grib, set to missing + eccodes.codes_set(grib, "numberOfForecastsInEnsemble", 255) + eccodes.codes_set(grib, "typeOfEnsembleForecast", 255) + + +def product_definition_template_common(cube, grib, full3d_cube=None): + """ + Write common parts of the product definition template. + + Set keys within the provided grib message that are common across + all of the supported product definition templates. + + """ + set_discipline_and_parameter(cube, grib) + + # Various missing values. + eccodes.codes_set(grib, "typeOfGeneratingProcess", 255) + eccodes.codes_set(grib, "backgroundProcess", 255) + eccodes.codes_set(grib, "generatingProcessIdentifier", 255) + + # Generic time handling. + set_forecast_time(cube, grib) + + # Handle vertical coords. + set_fixed_surfaces(cube, grib, full3d_cube) + + +def product_definition_template_0(cube, grib, full3d_cube=None): + """ + Write product definition template 0. + + Set keys within the provided grib message based on Product + Definition Template 4.0. + + Template 4.0 is used to represent an analysis or forecast at + a horizontal level at a point in time. + + """ + eccodes.codes_set_long(grib, "productDefinitionTemplateNumber", 0) + product_definition_template_common(cube, grib, full3d_cube) + + +def product_definition_template_1(cube, grib, full3d_cube=None): + """ + Write product definition template 1. + + Set keys within the provided grib message based on Product + Definition Template 4.1. + + Template 4.1 is used to represent an individual ensemble forecast, control + and perturbed, at a horizontal level or in a horizontal layer at a point + in time. + + """ + eccodes.codes_set(grib, "productDefinitionTemplateNumber", 1) + product_definition_template_common(cube, grib, full3d_cube) + set_ensemble(cube, grib) + + +def product_definition_template_6(cube, grib, full3d_cube=None): + """ + Write product definition template 6. + + Set keys within the provided grib message based on Product Definition + Template 4.6. + + Template 4.6 is used to represent a percentile forecast at a point in time + interval. + + """ + eccodes.codes_set(grib, "productDefinitionTemplateNumber", 6) + product_definition_template_common(cube, grib, full3d_cube) + if not (cube.coords("percentile") and len(cube.coord("percentile").points) == 1): + raise ValueError( + "A cube 'percentile' coordinate with one " + "point is required, but not present." + ) + eccodes.codes_set(grib, "percentileValue", int(cube.coord("percentile").points[0])) + + +def product_definition_template_8(cube, grib, full3d_cube=None): + """ + Write product definition template 8. + + Set keys within the provided grib message based on Product + Definition Template 4.8. + + Template 4.8 is used to represent an aggregation over a time + interval. + + """ + eccodes.codes_set(grib, "productDefinitionTemplateNumber", 8) + _product_definition_template_8_10_and_11(cube, grib) + + +def product_definition_template_10(cube, grib, full3d_cube=None): + """ + Write product definition template 10. + + Set keys within the provided grib message based on Product Definition + Template 4.10. + + Template 4.10 is used to represent a percentile forecast over a time + interval. + + """ + eccodes.codes_set(grib, "productDefinitionTemplateNumber", 10) + if not ( + cube.coords("percentile_over_time") + and len(cube.coord("percentile_over_time").points) == 1 + ): + raise ValueError( + "A cube 'percentile_over_time' coordinate with one " + "point is required, but not present." + ) + eccodes.codes_set( + grib, "percentileValue", int(cube.coord("percentile_over_time").points[0]) + ) + _product_definition_template_8_10_and_11(cube, grib) + + +def product_definition_template_11(cube, grib, full3d_cube=None): + """ + Write product definition template 11. + + Set keys within the provided grib message based on Product + Definition Template 4.11. + + Template 4.11 is used to represent an aggregation over a time + interval for an ensemble member. + + """ + eccodes.codes_set(grib, "productDefinitionTemplateNumber", 11) + set_ensemble(cube, grib) + _product_definition_template_8_10_and_11(cube, grib) + + +def _product_definition_template_8_10_and_11(cube, grib, full3d_cube=None): + """ + Write product definition template 8, 10 and 11. + + Set keys within the provided grib message based on common aspects of + Product Definition Templates 4.8 and 4.11. + + Templates 4.8 and 4.11 are used to represent aggregations over a time + interval. + + """ + product_definition_template_common(cube, grib, full3d_cube) + + # Check for time coordinate. + time_coord = cube.coord("time") + + if len(time_coord.points) != 1: + msg = "Expected length one time coordinate, got {} points" + raise ValueError(msg.format(time_coord.points)) + + if time_coord.nbounds != 2: + msg = "Expected time coordinate with two bounds, got {} bounds" + raise ValueError(msg.format(time_coord.nbounds)) + + # Extract the datetime-like object corresponding to the end of + # the overall processing interval. + end = time_coord.units.num2date(time_coord.bounds[0, -1]) + + # Set the associated keys for the end of the interval (octets 35-41 + # in section 4). + eccodes.codes_set(grib, "yearOfEndOfOverallTimeInterval", end.year) + eccodes.codes_set(grib, "monthOfEndOfOverallTimeInterval", end.month) + eccodes.codes_set(grib, "dayOfEndOfOverallTimeInterval", end.day) + eccodes.codes_set(grib, "hourOfEndOfOverallTimeInterval", end.hour) + eccodes.codes_set(grib, "minuteOfEndOfOverallTimeInterval", end.minute) + eccodes.codes_set(grib, "secondOfEndOfOverallTimeInterval", end.second) + + # Only one time range specification. If there were a series of aggregations + # (e.g. the mean of an accumulation) one might set this to a higher value, + # but we currently only handle a single time related cell method. + eccodes.codes_set(grib, "numberOfTimeRange", 1) + eccodes.codes_set(grib, "numberOfMissingInStatisticalProcess", 0) + + # Period over which statistical processing is performed. + set_time_range(time_coord, grib) + + # Check that there is one and only one cell method related to the + # time coord. + if cube.cell_methods: + time_cell_methods = [ + cell_method + for cell_method in cube.cell_methods + if "time" in cell_method.coord_names + ] + if not time_cell_methods: + raise ValueError("Expected a cell method with a coordinate name of 'time'") + if len(time_cell_methods) > 1: + raise ValueError("Cannot handle multiple 'time' cell methods") + (cell_method,) = time_cell_methods + + if len(cell_method.coord_names) > 1: + raise ValueError( + "Cannot handle multiple coordinate names in " + "the time related cell method. Expected " + "('time',), got {!r}".format(cell_method.coord_names) + ) + + # Type of statistical process (see code table 4.10) + statistic_type = _STATISTIC_TYPE_NAMES_INVERTED.get(cell_method.method, 255) + eccodes.codes_set(grib, "typeOfStatisticalProcessing", statistic_type) + + # Time increment i.e. interval of cell method (if any) + set_time_increment(cell_method, grib) + + +def product_definition_template_15(cube, grib, full3d_cube=None): + """ + Write product definition template 15. + + Set keys within the provided grib message based on Product + Definition Template 4.15. + + Template 4.15 represents the type of spatial processing used to + arrive at the given data value from the source data. + + """ + # Encode type of spatial processing (see code table 4.15) + spatial_processing_type = cube.attributes["spatial_processing_type"] + spatial_processing = get_spatial_process_code(spatial_processing_type) + + # Encode statistical process and number of points + # (see template definition 4.15) + statistical_process = _SPATIAL_PROCESSING_TYPES[spatial_processing][1] + number_of_points = _SPATIAL_PROCESSING_TYPES[spatial_processing][2] + + # Only a limited number of spatial processing types are supported. + if spatial_processing not in _SPATIAL_PROCESSING_TYPES.keys(): + msg = ( + "Cannot save Product Definition Type 4.15 with spatial " + "processing type {}".format(spatial_processing) + ) + raise ValueError(msg) + + if statistical_process is not None: + # Check the cube for statistical cell methods over area + spatial_stats = _spatial_statistic(cube) + # Identify the statistical method (e.g. 'mean') and encode it. + if len(spatial_stats) > 0: + cell_method_name = spatial_stats[0].method + statistical_process = statistical_method_code(cell_method_name) + else: + raise ValueError( + "Could not find a suitable cell_method to save " + "as a spatial statistical process." + ) + + # Set GRIB messages + eccodes.codes_set(grib, "productDefinitionTemplateNumber", 15) + product_definition_template_common(cube, grib, full3d_cube) + eccodes.codes_set(grib, "spatialProcessing", spatial_processing) + if number_of_points is not None: + eccodes.codes_set(grib, "numberOfPointsUsed", number_of_points) + if statistical_process is not None: + eccodes.codes_set(grib, "statisticalProcess", statistical_process) + + +def product_definition_template_40(cube, grib, full3d_cube=None): + """ + Write product definition template 40. + + Set keys within the provided grib message based on Product + Definition Template 4.40. + + Template 4.40 is used to represent an analysis or forecast at a horizontal + level or in a horizontal layer at a point in time for atmospheric chemical + constituents. + + """ + eccodes.codes_set(grib, "productDefinitionTemplateNumber", 40) + product_definition_template_common(cube, grib) + constituent_type = cube.attributes["WMO_constituent_type"] + eccodes.codes_set(grib, "constituentType", constituent_type) + + +def product_definition_section(cube, grib, full3d_cube=None): + """ + Write the product definition template. + + Set keys within the product definition section of the provided + grib message based on the properties of the cube. + + """ + if not cube.coord("time").has_bounds(): + if cube.coords("realization"): + # ensemble forecast (template 4.1) + pdt = product_definition_template_1(cube, grib, full3d_cube) + elif "WMO_constituent_type" in cube.attributes: + # forecast for atmospheric chemical constiuent (template 4.40) + product_definition_template_40(cube, grib, full3d_cube) + elif "spatial_processing_type" in cube.attributes: + # spatial process (template 4.15) + product_definition_template_15(cube, grib, full3d_cube) + elif cube.coords("percentile"): + product_definition_template_6(cube, grib, full3d_cube) + else: + # forecast (template 4.0) + product_definition_template_0(cube, grib, full3d_cube) + elif _cube_is_time_statistic(cube): + if cube.coords("realization"): + # time processed (template 4.11) + pdt = product_definition_template_11 + elif cube.coords("percentile_over_time"): + # time processed as percentile (template 4.10) + pdt = product_definition_template_10 + else: + # time processed (template 4.8) + pdt = product_definition_template_8 + try: + pdt(cube, grib, full3d_cube) + except ValueError as e: + raise ValueError( + "Saving to GRIB2 failed: the cube is not suitable" + " for saving as a time processed statistic GRIB" + " message. {}".format(e) + ) + else: + # Don't know how to handle this kind of data + msg = "A suitable product template could not be deduced" + raise TranslationError(msg) + + +############################################################################### +# +# Data Representation Section 5 +# +############################################################################### + + +def data_section(cube, grib): + # Masked data? + if ma.isMaskedArray(cube.data): + if not np.isnan(cube.data.fill_value): + # Use the data's fill value. + fill_value = float(cube.data.fill_value) + else: + # We can't use the cube's fill value if it's NaN, + # the GRIB API doesn't like it. + # Calculate an MDI outside the data range. + min, max = cube.data.min(), cube.data.max() + fill_value = min - (max - min) * 0.1 + # Prepare the unmasked data array, using fill_value as the MDI. + data = cube.data.filled(fill_value) + else: + fill_value = None + data = cube.data + + # units scaling + grib2_info = gptx.cf_phenom_to_grib2_info(cube.standard_name, cube.long_name) + if grib2_info is None: + # for now, just allow this + warnings.warn( + "Unable to determine Grib2 parameter code for cube.\n" + "Message data may not be correctly scaled." + ) + else: + if cube.units != grib2_info.units: + data = cube.units.convert(data, grib2_info.units) + if fill_value is not None: + fill_value = cube.units.convert(fill_value, grib2_info.units) + + if fill_value is None: + # Disable missing values in the grib message. + eccodes.codes_set(grib, "bitmapPresent", 0) + else: + # Enable missing values in the grib message. + eccodes.codes_set(grib, "bitmapPresent", 1) + eccodes.codes_set_double(grib, "missingValue", fill_value) + + eccodes.codes_set_double_array(grib, "values", data.flatten()) + + # todo: check packing accuracy? + + +# print("packingError", eccodes.get_get_double(grib, "packingError")) + + +############################################################################### + + +def gribbability_check(cube, x_coord, y_coord): + "We always need the following things for grib saving." + + # GeogCS exists? + cs0 = x_coord.coord_system + cs1 = y_coord.coord_system + if cs0 is None or cs1 is None: + raise TranslationError("CoordSystem not present on both X and Y coord") + if cs0 != cs1: + raise TranslationError("Inconsistent CoordSystems between X and Y coords") + + # Time period exists? + if not cube.coords("time"): + raise TranslationError("time coord not found") + + +def run(slice2d_cube, grib, full3d_cube, x_coord, y_coord): + """ + Set the keys of the grib message based on the contents of the slice2d_cube. + + Args: + + * slice2d_cube: + A :class:`iris.slice2d_cube.Cube` representing a 2d field. + + * grib_message_id: + ID of a grib message in memory. This is typically the return value of + :func:`eccodes.codes_grib_new_from_samples`. + + * full3d_cube: + A :class:`iris.slice2d_cube.Cube` representing the entire save cube. + This is required to write data with hybrid vertical coordinates, as + the GRIB2 spec records hybrid coefficients for *all* the levels in + every message. + + * x_coord: + The selected x-coordinate + + * y_coord: + The selected y-coordinate + + """ + gribbability_check(slice2d_cube, x_coord, y_coord) + + # Section 1 - Identification Section. + identification(slice2d_cube, grib) + + # Section 3 - Grid Definition Section (Grid Definition Template) + grid_definition_section(slice2d_cube, grib, x_coord, y_coord) + + # Section 4 - Product Definition Section (Product Definition Template) + product_definition_section(slice2d_cube, grib, full3d_cube) + + # Section 5 - Data Representation Section (Data Representation Template) + data_section(slice2d_cube, grib) diff --git a/iris_grib/grib_phenom_translation.py b/src/iris_grib/grib_phenom_translation/__init__.py similarity index 52% rename from iris_grib/grib_phenom_translation.py rename to src/iris_grib/grib_phenom_translation/__init__.py index 48c31565..d32bcaca 100644 --- a/iris_grib/grib_phenom_translation.py +++ b/src/iris_grib/grib_phenom_translation/__init__.py @@ -1,47 +1,40 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office +# Copyright iris-grib contributors # -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . -''' +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" Provide grib 1 and 2 phenomenon translations to + from CF terms. This is done by wrapping '_grib_cf_map.py', which is in a format provided by the metadata translation project. + Currently supports only these ones: * grib1 --> cf * grib2 --> cf * cf --> grib2 -''' +""" -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa -import six - -import collections +from collections import namedtuple import warnings import cf_units -from . import _grib_cf_map as grcf +from iris_grib import _grib_cf_map as grcf +from iris_grib.grib_phenom_translation._gribcode import GRIBCode import iris.std_names +__all__ = [ + "GRIBCode", + "cf_phenom_to_grib2_info", + "grib1_phenom_to_cf_info", + "grib2_phenom_to_cf_info", +] + -class LookupTable(dict): +class _LookupTable(dict): """ Specialised dictionary object for making lookup tables. @@ -50,8 +43,9 @@ class LookupTable(dict): (but it is still possible to remove keys) """ + def __init__(self, *args, **kwargs): - self._super = super(LookupTable, self) + self._super = super() self._super.__init__(*args, **kwargs) def __getitem__(self, key): @@ -61,32 +55,42 @@ def __getitem__(self, key): def __setitem__(self, key, value): if key in self and self[key] is not value: - raise KeyError('Attempted to set dict[{}] = {}, ' - 'but this is already set to {}.'.format( - key, value, self[key])) + raise KeyError( + "Attempted to set dict[{}] = {}, but this is already set to {}.".format( + key, value, self[key] + ) + ) self._super.__setitem__(key, value) # Define namedtuples for keys+values of the Grib1 lookup table. -_Grib1ToCfKeyClass = collections.namedtuple( - 'Grib1CfKey', - ('table2_version', 'centre_number', 'param_number')) +Grib1CfKey = namedtuple( + "Grib1CfKey", ("table2_version", "centre_number", "param_number") +) # NOTE: this form is currently used for both Grib1 *and* Grib2 -_GribToCfDataClass = collections.namedtuple( - 'Grib1CfData', - ('standard_name', 'long_name', 'units', 'set_height')) +Grib1CfData = namedtuple( + "Grib1CfData", ("standard_name", "long_name", "units", "set_height") +) # Create the grib1-to-cf lookup table. -def _make_grib1_cf_table(): - """ Build the Grib1 to CF phenomenon translation table. """ - table = LookupTable() - def _make_grib1_cf_entry(table2_version, centre_number, param_number, - standard_name, long_name, units, set_height=None): +def _make_grib1_cf_table(): + """Build the Grib1 to CF phenomenon translation table.""" + table = _LookupTable() + + def _make_grib1_cf_entry( + table2_version, + centre_number, + param_number, + standard_name, + long_name, + units, + set_height=None, + ): """ Check data, convert types and create a new _GRIB1_CF_TABLE key/value. @@ -95,24 +99,31 @@ def _make_grib1_cf_entry(table2_version, centre_number, param_number, e.g. "2-metre tempererature". """ - grib1_key = _Grib1ToCfKeyClass(table2_version=int(table2_version), - centre_number=int(centre_number), - param_number=int(param_number)) + grib1_key = Grib1CfKey( + table2_version=int(table2_version), + centre_number=int(centre_number), + param_number=int(param_number), + ) if standard_name is not None: if standard_name not in iris.std_names.STD_NAMES: - warnings.warn('{} is not a recognised CF standard name ' - '(skipping).'.format(standard_name)) + warnings.warn( + "{} is not a recognised CF standard name (skipping).".format( + standard_name + ) + ) return None # convert units string to iris Unit (i.e. mainly, check it is good) a_cf_unit = cf_units.Unit(units) - cf_data = _GribToCfDataClass(standard_name=standard_name, - long_name=long_name, - units=a_cf_unit, - set_height=set_height) + cf_data = Grib1CfData( + standard_name=standard_name, + long_name=long_name, + units=a_cf_unit, + set_height=set_height, + ) return (grib1_key, cf_data) # Interpret the imported Grib1-to-CF table. - for (grib1data, cfdata) in six.iteritems(grcf.GRIB1_LOCAL_TO_CF): + for grib1data, cfdata in grcf.GRIB1_LOCAL_TO_CF.items(): assert grib1data.edition == 1 association_entry = _make_grib1_cf_entry( table2_version=grib1data.t2version, @@ -120,27 +131,36 @@ def _make_grib1_cf_entry(table2_version, centre_number, param_number, param_number=grib1data.iParam, standard_name=cfdata.standard_name, long_name=cfdata.long_name, - units=cfdata.units) + units=cfdata.units, + ) if association_entry is not None: key, value = association_entry table[key] = value # Do the same for special Grib1 codes that include an implied height level. - for (grib1data, (cfdata, extra_dimcoord)) \ - in six.iteritems(grcf.GRIB1_LOCAL_TO_CF_CONSTRAINED): + for grib1data, ( + cfdata, + extra_dimcoord, + ) in grcf.GRIB1_LOCAL_TO_CF_CONSTRAINED.items(): assert grib1data.edition == 1 - if extra_dimcoord.standard_name != 'height': - raise ValueError('Got implied dimension coord of "{}", ' - 'currently can only handle "height".'.format( - extra_dimcoord.standard_name)) - if extra_dimcoord.units != 'm': - raise ValueError('Got implied dimension units of "{}", ' - 'currently can only handle "m".'.format( - extra_dimcoord.units)) + if extra_dimcoord.standard_name != "height": + raise ValueError( + 'Got implied dimension coord of "{}", ' + 'currently can only handle "height".'.format( + extra_dimcoord.standard_name + ) + ) + if extra_dimcoord.units != "m": + raise ValueError( + 'Got implied dimension units of "{}", ' + 'currently can only handle "m".'.format(extra_dimcoord.units) + ) if len(extra_dimcoord.points) != 1: - raise ValueError('Implied dimension has {} points, ' - 'currently can only handle 1.'.format( - len(extra_dimcoord.points))) + raise ValueError( + "Implied dimension has {} points, currently can only handle 1.".format( + len(extra_dimcoord.points) + ) + ) association_entry = _make_grib1_cf_entry( table2_version=int(grib1data.t2version), centre_number=int(grib1data.centre), @@ -148,7 +168,8 @@ def _make_grib1_cf_entry(table2_version, centre_number, param_number, standard_name=cfdata.standard_name, long_name=cfdata.long_name, units=cfdata.units, - set_height=extra_dimcoord.points[0]) + set_height=extra_dimcoord.points[0], + ) if association_entry is not None: key, value = association_entry table[key] = value @@ -161,19 +182,21 @@ def _make_grib1_cf_entry(table2_version, centre_number, param_number, # Define a namedtuple for the keys of the Grib2 lookup table. -_Grib2ToCfKeyClass = collections.namedtuple( - 'Grib2CfKey', - ('param_discipline', 'param_category', 'param_number')) +Grib2CfKey = namedtuple( + "Grib2CfKey", ("param_discipline", "param_category", "param_number") +) # Create the grib2-to-cf lookup table. + def _make_grib2_to_cf_table(): - """ Build the Grib2 to CF phenomenon translation table. """ - table = LookupTable() + """Build the Grib2 to CF phenomenon translation table.""" + table = _LookupTable() - def _make_grib2_cf_entry(param_discipline, param_category, param_number, - standard_name, long_name, units): + def _make_grib2_cf_entry( + param_discipline, param_category, param_number, standard_name, long_name, units + ): """ Check data, convert types and make a _GRIB2_CF_TABLE key/value pair. @@ -182,24 +205,31 @@ def _make_grib2_cf_entry(param_discipline, param_category, param_number, e.g. "2-metre tempererature". """ - grib2_key = _Grib2ToCfKeyClass(param_discipline=int(param_discipline), - param_category=int(param_category), - param_number=int(param_number)) + grib2_key = Grib2CfKey( + param_discipline=int(param_discipline), + param_category=int(param_category), + param_number=int(param_number), + ) if standard_name is not None: if standard_name not in iris.std_names.STD_NAMES: - warnings.warn('{} is not a recognised CF standard name ' - '(skipping).'.format(standard_name)) + warnings.warn( + "{} is not a recognised CF standard name (skipping).".format( + standard_name + ) + ) return None # convert units string to iris Unit (i.e. mainly, check it is good) a_cf_unit = cf_units.Unit(units) - cf_data = _GribToCfDataClass(standard_name=standard_name, - long_name=long_name, - units=a_cf_unit, - set_height=None) + cf_data = Grib1CfData( + standard_name=standard_name, + long_name=long_name, + units=a_cf_unit, + set_height=None, + ) return (grib2_key, cf_data) # Interpret the grib2 info from grib_cf_map - for grib2data, cfdata in six.iteritems(grcf.GRIB2_TO_CF): + for grib2data, cfdata in grcf.GRIB2_TO_CF.items(): assert grib2data.edition == 2 association_entry = _make_grib2_cf_entry( param_discipline=grib2data.discipline, @@ -207,7 +237,8 @@ def _make_grib2_cf_entry(param_discipline, param_category, param_number, param_number=grib2data.number, standard_name=cfdata.standard_name, long_name=cfdata.long_name, - units=cfdata.units) + units=cfdata.units, + ) if association_entry is not None: key, value = association_entry table[key] = value @@ -220,24 +251,21 @@ def _make_grib2_cf_entry(param_discipline, param_category, param_number, # Define namedtuples for key+values of the cf-to-grib2 lookup table. -_CfToGrib2KeyClass = collections.namedtuple( - 'CfGrib2Key', - ('standard_name', 'long_name')) +CfGrib2Key = namedtuple("CfGrib2Key", ("standard_name", "long_name")) -_CfToGrib2DataClass = collections.namedtuple( - 'CfGrib2Data', - ('discipline', 'category', 'number', 'units')) +CfGrib2Data = namedtuple("CfGrib2Data", ("discipline", "category", "number", "units")) # Create the cf-to-grib2 lookup table. + def _make_cf_to_grib2_table(): - """ Build the Grib1 to CF phenomenon translation table. """ - table = LookupTable() + """Build the Grib1 to CF phenomenon translation table.""" + table = _LookupTable() - def _make_cf_grib2_entry(standard_name, long_name, - param_discipline, param_category, param_number, - units): + def _make_cf_grib2_entry( + standard_name, long_name, param_discipline, param_category, param_number, units + ): """ Check data, convert types and make a new _CF_TABLE key/value pair. @@ -246,20 +274,25 @@ def _make_cf_grib2_entry(standard_name, long_name, if standard_name is not None: long_name = None if standard_name not in iris.std_names.STD_NAMES: - warnings.warn('{} is not a recognised CF standard name ' - '(skipping).'.format(standard_name)) + warnings.warn( + "{} is not a recognised CF standard name (skipping).".format( + standard_name + ) + ) return None - cf_key = _CfToGrib2KeyClass(standard_name, long_name) + cf_key = CfGrib2Key(standard_name, long_name) # convert units string to iris Unit (i.e. mainly, check it is good) a_cf_unit = cf_units.Unit(units) - grib2_data = _CfToGrib2DataClass(discipline=int(param_discipline), - category=int(param_category), - number=int(param_number), - units=a_cf_unit) + grib2_data = CfGrib2Data( + discipline=int(param_discipline), + category=int(param_category), + number=int(param_number), + units=a_cf_unit, + ) return (cf_key, grib2_data) # Interpret the imported CF-to-Grib2 table into a lookup table - for cfdata, grib2data in six.iteritems(grcf.CF_TO_GRIB2): + for cfdata, grib2data in grcf.CF_TO_GRIB2.items(): assert grib2data.edition == 2 a_cf_unit = cf_units.Unit(cfdata.units) association_entry = _make_cf_grib2_entry( @@ -268,18 +301,21 @@ def _make_cf_grib2_entry(standard_name, long_name, param_discipline=grib2data.discipline, param_category=grib2data.category, param_number=grib2data.number, - units=a_cf_unit) + units=a_cf_unit, + ) if association_entry is not None: key, value = association_entry table[key] = value return table + _CF_GRIB2_TABLE = _make_cf_to_grib2_table() # Interface functions for translation lookup + def grib1_phenom_to_cf_info(table2_version, centre_number, param_number): """ Lookup grib-1 parameter --> cf_data or None. @@ -292,9 +328,11 @@ def grib1_phenom_to_cf_info(table2_version, centre_number, param_number): * set_height : a scalar 'height' value , or None """ - grib1_key = _Grib1ToCfKeyClass(table2_version=table2_version, - centre_number=centre_number, - param_number=param_number) + grib1_key = Grib1CfKey( + table2_version=table2_version, + centre_number=centre_number, + param_number=param_number, + ) return _GRIB1_CF_TABLE[grib1_key] @@ -309,9 +347,11 @@ def grib2_phenom_to_cf_info(param_discipline, param_category, param_number): * units : a :class:`cf_units.Unit` """ - grib2_key = _Grib2ToCfKeyClass(param_discipline=int(param_discipline), - param_category=int(param_category), - param_number=int(param_number)) + grib2_key = Grib2CfKey( + param_discipline=int(param_discipline), + param_category=int(param_category), + param_number=int(param_number), + ) return _GRIB2_CF_TABLE[grib2_key] diff --git a/src/iris_grib/grib_phenom_translation/_gribcode.py b/src/iris_grib/grib_phenom_translation/_gribcode.py new file mode 100644 index 00000000..78d007cd --- /dev/null +++ b/src/iris_grib/grib_phenom_translation/_gribcode.py @@ -0,0 +1,191 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Provide object to represent grib phenomena. + +For use as cube attributes, freely convertible to+from strings +""" + +from __future__ import annotations +from dataclasses import dataclass +import re + + +def _invalid_edition(edition): + msg = f"Invalid grib edition, {edition!r}, for GRIBcode : can only be 1 or 2." + raise ValueError(msg) + + +def _invalid_nargs(args): + nargs = len(args) + msg = ( + f"Cannot create GRIBCode from {nargs} arguments, " + f"GRIBCode({args!r}) : expects either 1 or 4 arguments." + ) + raise ValueError(msg) + + +# Regexp to extract four integers from a string: +# - for four times ... +# - match any non-digits (including none) and discard +# - then match any digits (including none), and return as a "group" +_RE_PARSE_FOURNUMS = re.compile(4 * r"[^\d]*(\d*)") + + +def _fournums_from_gribcode_string(grib_param_string): + parsed_ok = True + # get the numbers.. + match_groups = _RE_PARSE_FOURNUMS.match(grib_param_string).groups() + # N.B. always produces 4 "strings of digits", but some can be empty + try: + nums = [int(grp) for grp in match_groups] + except ValueError: + parsed_ok = False + + if not parsed_ok: + msg = ( + "Invalid argument for GRIBCode creation, " + '"GRIBCode({!r})" : ' + "requires 4 numbers, separated by non-numerals." + ) + raise ValueError(msg.format(grib_param_string)) + + return nums + + +def GRIBCode(edition, *args, **kwargs): + """ + Make an object representing a specific Grib phenomenon identity. + + The class of the result, and the list of its properties, depend on whether + 'edition' is 1 or 2. + + One of : + + * GRIBCode(edition=1, table_version, centre_number, number) + * GRIBCode(edition=2, discipline, category, number) + + Either provides a string representation, and supports creation from: + keywords, another similar object; a tuple of numbers; or any string with 4 + separate decimal numbers in it. + """ + if edition is None: + _invalid_nargs(()) + + # Convert single argument to *args + if not args and not kwargs: + # Convert to a string and extract 4 integers. + # NOTE: this also allows input from a GRIBCode, or a plain tuple. + edition_string = str(edition) + edition, arg2, arg3, arg4 = _fournums_from_gribcode_string(edition_string) + args = [arg2, arg3, arg4] + + # Check edition + select the relevant keywords for the edition + if edition not in (1, 2): + _invalid_edition(edition) + + # Choose which actual type we will return. This also determines the + # argument (keyword) names. + instance_cls = {1: GRIBCode1, 2: GRIBCode2}[edition] + + # Convert all of (edition, *args) into **kwargs + if not args: + # Ignore that edition= is a required arg -- make it a kwarg + kwargs["edition"] = edition + else: + # Include edition, which just makes it simpler + args = tuple([edition] + list(args)) + nargs = len(args) + if nargs != 4: + _invalid_nargs(args) + + for i_arg, (arg, name) in enumerate( + zip(args, instance_cls.argnames, strict=False) + ): + if name in kwargs: + msg = ( + f"Keyword {name!r}={kwargs[name]!r} " + f"is not compatible with a {i_arg + 1}th argument." + ) + raise ValueError(msg) + else: + kwargs[name] = arg + + result = instance_cls(**kwargs) + return result + + +@dataclass +class GenericConcreteGRIBCode: + """ + Common behaviour for GRIBCode1 and GRIBCode2. + + GRIBCode1 and GRIBCode2 inherit this, making both dataclasses. + They contain different data properties. + """ + + def __init__(self, **kwargs): + # Note : only support creation with kargs. In GRIBCode(), any args + # get translated into kwargs + # Check against "_edition", defined by the specific subclass. + assert kwargs["edition"] == self._edition + for key, value in kwargs.items(): + setattr(self, key, value) + + def _broken_repr(self): + result = f"<{self.__class__.__name__} with invalid content: {self.__dict__}>" + return result + + def __str__(self): + edition = self.edition + try: + # NB fallback to "invalid" if edition not one of (1, 2) + format = { + 1: "GRIB{:1d}:t{:03d}c{:03d}n{:03d}", + 2: "GRIB{:1d}:d{:03d}c{:03d}n{:03d}", + }[edition] + arg_values = [getattr(self, argname) for argname in self.argnames] + # NB fallback to "invalid" if format fails + result = format.format(*arg_values) + except Exception: + # Invalid content somewhere : fall back on default repr + result = self._broken_repr() + + return result + + def __repr__(self): + edition = self.edition + try: + assert edition in (1, 2) + key_value_strings = [] + for argname in self.argnames: + value = getattr(self, argname, None) + assert isinstance(value, int) + key_value_strings.append(f"{argname}={value}") + inner_text = ", ".join(key_value_strings) + result = f"GRIBCode({inner_text})" + except Exception: + # Invalid content somewhere : fall back on a default repr + result = self._broken_repr() + + return result + + +class GRIBCode1(GenericConcreteGRIBCode): + edition: int = 1 + table_version: int | None = None + centre_number: int | None = None + number: int | None = None + argnames = ["edition", "table_version", "centre_number", "number"] + _edition = 1 # Constructor argument should always match this + + +class GRIBCode2(GenericConcreteGRIBCode): + edition: int = 2 + discipline: int | None = None + category: int | None = None + number: int | None = None + argnames = ["edition", "discipline", "category", "number"] + _edition = 2 # Constructor argument should always match this diff --git a/iris_grib/message.py b/src/iris_grib/message.py similarity index 51% rename from iris_grib/message.py rename to src/iris_grib/message.py index f2863fb1..c61212f5 100644 --- a/iris_grib/message.py +++ b/src/iris_grib/message.py @@ -1,43 +1,45 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office +# Copyright iris-grib contributors # -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. """ Defines a lightweight wrapper class to wrap a single GRIB message. """ -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa -import six - from collections import namedtuple import re -import biggus -import gribapi +import eccodes import numpy as np +import numpy.ma as ma +from iris._lazy_data import as_lazy_data from iris.exceptions import TranslationError +_SUPPORTED_GRID_DEFINITIONS = (0, 1, 5, 10, 12, 20, 30, 40, 90, 140) + +# Alias names for eccodes spatial computed keys. +KEY_ALIAS = { + "latitude": "latitudes", + "longitude": "longitudes", + "latitudes": "latitude", + "longitudes": "longitude", + # Support older form of key which used to exist before eccodes 2v36 + "indicatorOfUnitForForecastTime": "indicatorOfUnitOfTimeRange", + # Support older name which became an alias at eccodes 2v38 + "numberOfTimeRange": "numberOfTimeRanges", +} + -class _OpenFileRef(object): +class _OpenFileRef: """ - A reference to an open file that ensures that the file is closed + A reference to an open file. + + This object ensures that the file is closed when the object is garbage collected. """ + def __init__(self, open_file): self.open_file = open_file @@ -46,10 +48,11 @@ def __del__(self): self.open_file.close() -class GribMessage(object): +class GribMessage: """ - An in-memory representation of a GribMessage, providing - access to the :meth:`~GribMessage.data` payload and the metadata + An in-memory representation of a GribMessage. + + Provides access to the :meth:`~GribMessage.data` payload and the metadata elements by section via the :meth:`~GribMessage.sections` property. """ @@ -57,6 +60,8 @@ class GribMessage(object): @staticmethod def messages_from_filename(filename): """ + Return the messages in a file. + Return a generator of :class:`GribMessage` instances; one for each message in the supplied GRIB file. @@ -65,30 +70,33 @@ def messages_from_filename(filename): * filename (string): Name of the file to generate fields from. + """ - grib_fh = open(filename, 'rb') + grib_fh = open(filename, "rb") # create an _OpenFileRef to manage the closure of the file handle file_ref = _OpenFileRef(grib_fh) while True: - offset = grib_fh.tell() - grib_id = gribapi.grib_new_from_file(grib_fh) + grib_id = eccodes.codes_new_from_file(grib_fh, eccodes.CODES_PRODUCT_GRIB) if grib_id is None: break + offset = eccodes.codes_get_message_offset(grib_id) raw_message = _RawGribMessage(grib_id) recreate_raw = _MessageLocation(filename, offset) yield GribMessage(raw_message, recreate_raw, file_ref=file_ref) def __init__(self, raw_message, recreate_raw, file_ref=None): """ - It is recommended to obtain GribMessage instance from the static method + Create a GribMessage. + + It is recommended to obtain GribMessage instances from the static method :meth:`~GribMessage.messages_from_filename`, rather than creating them directly. """ - # A RawGribMessage giving gribapi access to the original grib message. + # A RawGribMessage giving ecCodes access to the original grib message. self._raw_message = raw_message - # A _MessageLocation which biggus uses to read the message data array, + # A _MessageLocation which dask uses to read the message data array, # by which time this message may be dead and the original grib file # closed. self._recreate_raw = recreate_raw @@ -99,8 +107,9 @@ def __init__(self, raw_message, recreate_raw, file_ref=None): @property def sections(self): """ - Return the key-value pairs of the message keys, grouped by containing - section. + Return the key-value pairs of the message keys. + + The key-value pairs are grouped by containing section. Sections in a message are indexed by GRIB section-number, and values in a section are indexed by key strings. @@ -113,10 +122,19 @@ def sections(self): """ return self._raw_message.sections + @property + def bmdi(self): + # Not sure of any cases where GRIB provides a fill value. + # Default for fill value is None. + return None + + def core_data(self): + return self.data + @property def data(self): """ - The data array from the GRIB message as a biggus Array. + The data array from the GRIB message as a dask Array. The shape of the array will match the logical shape of the message's grid. For example, a simple global grid would be @@ -125,36 +143,58 @@ def data(self): """ sections = self.sections grid_section = sections[3] - if grid_section['sourceOfGridDefinition'] != 0: + if grid_section["sourceOfGridDefinition"] != 0: raise TranslationError( - 'Unsupported source of grid definition: {}'.format( - grid_section['sourceOfGridDefinition'])) - - reduced = (grid_section['numberOfOctectsForNumberOfPoints'] != 0 or - grid_section['interpretationOfNumberOfPoints'] != 0) - template = grid_section['gridDefinitionTemplateNumber'] - if reduced and template not in (40,): - raise TranslationError('Grid definition Section 3 contains ' - 'unsupported quasi-regular grid.') + "Unsupported source of grid definition: {}".format( + grid_section["sourceOfGridDefinition"] + ) + ) + + reduced = ( + grid_section["numberOfOctectsForNumberOfPoints"] != 0 + or grid_section["interpretationOfNumberOfPoints"] != 0 + ) + template = grid_section["gridDefinitionTemplateNumber"] + if reduced and template != 40: + raise TranslationError( + "Grid definition Section 3 contains unsupported quasi-regular grid." + ) - if template in (0, 1, 5, 12, 20, 30, 40, 90): + if template in _SUPPORTED_GRID_DEFINITIONS: # We can ignore the first two bits (i-neg, j-pos) because # that is already captured in the coordinate values. - if grid_section['scanningMode'] & 0x3f: - msg = 'Unsupported scanning mode: {}'.format( - grid_section['scanningMode']) + if grid_section["scanningMode"] & 0x3F: + msg = "Unsupported scanning mode: {}".format( + grid_section["scanningMode"] + ) raise TranslationError(msg) if template in (20, 30, 90): - shape = (grid_section['Ny'], grid_section['Nx']) + shape = (grid_section["Ny"], grid_section["Nx"]) + elif template == 140: + shape = ( + grid_section["numberOfPointsAlongYAxis"], + grid_section["numberOfPointsAlongXAxis"], + ) elif template == 40 and reduced: - shape = (grid_section['numberOfDataPoints'],) + shape = (grid_section["numberOfDataPoints"],) else: - shape = (grid_section['Nj'], grid_section['Ni']) - proxy = _DataProxy(shape, np.dtype('f8'), np.nan, - self._recreate_raw) - data = biggus.NumpyArrayAdapter(proxy) + shape = (grid_section["Nj"], grid_section["Ni"]) + + dtype = np.dtype("f8") + proxy = _DataProxy(shape, dtype, self._recreate_raw) + + as_lazy_kwargs = {} + from . import _ASLAZYDATA_NEEDS_META, _make_dask_meta # noqa: PLC0415 + + if _ASLAZYDATA_NEEDS_META: + has_bitmap = 6 in sections + meta = _make_dask_meta(shape, dtype, is_masked=has_bitmap) + as_lazy_kwargs["meta"] = meta + + data = as_lazy_data(proxy, **as_lazy_kwargs) + else: - fmt = 'Grid definition template {} is not supported' + fmt = "Grid definition template {} is not supported" raise TranslationError(fmt.format(template)) return data @@ -168,7 +208,7 @@ def __getstate__(self): return self -class _MessageLocation(namedtuple('_MessageLocation', 'filename offset')): +class _MessageLocation(namedtuple("_MessageLocation", "filename offset")): """A reference to a specific GRIB message within a file.""" __slots__ = () @@ -177,15 +217,14 @@ def __call__(self): return _RawGribMessage.from_file_offset(self.filename, self.offset) -class _DataProxy(object): +class _DataProxy: """A reference to the data payload of a single GRIB message.""" - __slots__ = ('shape', 'dtype', 'fill_value', 'recreate_raw') + __slots__ = ("dtype", "recreate_raw", "shape") - def __init__(self, shape, dtype, fill_value, recreate_raw): + def __init__(self, shape, dtype, recreate_raw): self.shape = shape self.dtype = dtype - self.fill_value = fill_value self.recreate_raw = recreate_raw @property @@ -194,8 +233,9 @@ def ndim(self): def _bitmap(self, bitmap_section): """ - Get the bitmap for the data from the message. The GRIB spec defines - that the bitmap is composed of values 0 or 1, where: + Get the bitmap for the data from the message. + + The GRIB spec defines that the bitmap is composed of values 0 or 1, where: * 0: no data value at corresponding data point (data point masked). * 1: data value at corresponding data point (data point unmasked). @@ -218,26 +258,43 @@ def _bitmap(self, bitmap_section): """ # Reference GRIB2 Code Table 6.0. - bitMapIndicator = bitmap_section['bitMapIndicator'] + bitMapIndicator = bitmap_section["bitMapIndicator"] if bitMapIndicator == 0: - bitmap = bitmap_section['bitmap'] + bitmap = bitmap_section["bitmap"] elif bitMapIndicator == 255: bitmap = None else: - msg = 'Bitmap Section 6 contains unsupported ' \ - 'bitmap indicator [{}]'.format(bitMapIndicator) + msg = "Bitmap Section 6 contains unsupported bitmap indicator [{}]".format( + bitMapIndicator + ) raise TranslationError(msg) return bitmap def __getitem__(self, keys): - # NB. Currently assumes that the validity of this interpretation + # N.B. Assumes that the validity of this interpretation # is checked before this proxy is created. + message = self.recreate_raw() sections = message.sections + data = None + + if 5 in sections: + # Data Representation Section. + if sections[5]["bitsPerValue"] == 0: + # Auto-generate zero data of the expected shape and dtype, as + # there is no data stored within the Data Section of this GRIB + # message. Also flatten the result to 1-D for potential bitmap + # post-processing. + data = np.ravel(np.zeros(self.shape, dtype=self.dtype)) + + if data is None: + # Data Section. + data = sections[7]["codedValues"] + + # Bit-map Section. bitmap_section = sections[6] bitmap = self._bitmap(bitmap_section) - data = sections[7]['codedValues'] if bitmap is not None: # Note that bitmap and data are both 1D arrays at this point. @@ -245,51 +302,61 @@ def __getitem__(self, keys): # Only the non-masked values are included in codedValues. _data = np.empty(shape=bitmap.shape) _data[bitmap.astype(bool)] = data - # `np.ma.masked_array` masks where input = 1, the opposite of + # `ma.masked_array` masks where input = 1, the opposite of # the behaviour specified by the GRIB spec. - data = np.ma.masked_array(_data, mask=np.logical_not(bitmap)) + data = ma.masked_array( + _data, mask=np.logical_not(bitmap), fill_value=np.nan + ) else: - msg = 'Shapes of data and bitmap do not match.' + msg = "Shapes of data and bitmap do not match." raise TranslationError(msg) data = data.reshape(self.shape) - return data.__getitem__(keys) + result = data.__getitem__(keys) + + return result def __repr__(self): - msg = '<{self.__class__.__name__} shape={self.shape} ' \ - 'dtype={self.dtype!r} fill_value={self.fill_value!r} ' \ - 'recreate_raw={self.recreate_raw!r} ' + msg = ( + "<{self.__class__.__name__} shape={self.shape} " + "dtype={self.dtype!r} recreate_raw={self.recreate_raw!r} " + ) return msg.format(self=self) def __getstate__(self): return {attr: getattr(self, attr) for attr in self.__slots__} def __setstate__(self, state): - for key, value in six.iteritems(state): + for key, value in state.items(): setattr(self, key, value) -class _RawGribMessage(object): +class _RawGribMessage: """ - Lightweight GRIB message wrapper, containing **only** the coded keys - of the input GRIB message. + Lightweight GRIB message wrapper. + + This contains **only** the coded keys of the input GRIB message. + I.E. excluding any "computed" keys. """ - _NEW_SECTION_KEY_MATCHER = re.compile(r'section([0-9]{1})Length') + + _NEW_SECTION_KEY_MATCHER = re.compile(r"section([0-9]{1})Length") @staticmethod def from_file_offset(filename, offset): - with open(filename, 'rb') as f: + with open(filename, "rb") as f: f.seek(offset) - message_id = gribapi.grib_new_from_file(f) + message_id = eccodes.codes_new_from_file(f, eccodes.CODES_PRODUCT_GRIB) if message_id is None: - fmt = 'Invalid GRIB message: {} @ {}' + fmt = "Invalid GRIB message: {} @ {}" raise RuntimeError(fmt.format(filename, offset)) return _RawGribMessage(message_id) def __init__(self, message_id): """ - A _RawGribMessage object contains the **coded** keys from a + Create a _RawGribMessage object. + + This contains the **coded** keys from a GRIB message that is identified by the input message id. Args: @@ -304,16 +371,17 @@ def __init__(self, message_id): def __del__(self): """ - Release the gribapi reference to the message at end of object's life. + Release the ecCodes reference to the message at end of object's life. """ - gribapi.grib_release(self._message_id) + eccodes.codes_release(self._message_id) @property def sections(self): """ - Return the key-value pairs of the message keys, grouped by containing - section. + Return the key-value pairs of the message keys. + + The key-value pairs are grouped by containing section. Key-value pairs are collected into a dictionary of :class:`Section` objects. One such object is made for @@ -327,13 +395,13 @@ def sections(self): return self._sections def _get_message_keys(self): - """Creates a generator of all the keys in the message.""" + """Create a generator of all the keys in the message.""" - keys_itr = gribapi.grib_keys_iterator_new(self._message_id) - gribapi.grib_skip_computed(keys_itr) - while gribapi.grib_keys_iterator_next(keys_itr): - yield gribapi.grib_keys_iterator_get_name(keys_itr) - gribapi.grib_keys_iterator_delete(keys_itr) + keys_itr = eccodes.codes_keys_iterator_new(self._message_id) + eccodes.codes_skip_computed(keys_itr) + while eccodes.codes_keys_iterator_next(keys_itr): + yield eccodes.codes_keys_iterator_get_name(keys_itr) + eccodes.codes_keys_iterator_delete(keys_itr) def _get_message_sections(self): """ @@ -358,11 +426,10 @@ def _get_message_sections(self): key_match = re.match(self._NEW_SECTION_KEY_MATCHER, key_name) if key_match is not None: new_section = int(key_match.group(1)) - elif key_name == '7777': + elif key_name == "7777": new_section = 8 if section != new_section: - sections[section] = Section(self._message_id, section, - section_keys) + sections[section] = Section(self._message_id, section, section_keys) section_keys = [] section = new_section section_keys.append(key_name) @@ -370,15 +437,17 @@ def _get_message_sections(self): return sections -class Section(object): +class Section: """ - A Section of a GRIB message, supporting dictionary like access to - attributes using gribapi key strings. + A Section of a GRIB message. + + This supports dictionary-like access to key values, using gribapi key strings. Values for keys may be changed using assignment but this does not write to the file. """ + # Keys are read from the file as required and values are cached. # Within GribMessage instances all keys will have been fetched @@ -391,30 +460,40 @@ def __init__(self, message_id, number, keys): def __repr__(self): items = [] for key in self._keys: - value = self._cache.get(key, '?') - items.append('{}={}'.format(key, value)) - return '<{} {}: {}>'.format(type(self).__name__, self._number, - ', '.join(items)) + value = self._cache.get(key, "?") + items.append("{}={}".format(key, value)) + return "<{} {}: {}>".format(type(self).__name__, self._number, ", ".join(items)) + + def _valid_key(self, key): + if key not in self._keys: + key2 = KEY_ALIAS.get(key) + if key2 and key2 in self._keys: + key = key2 + else: + emsg = f"{key!r} is not a valid key for section {self._number}." + raise KeyError(emsg) + return key def __getitem__(self, key): if key not in self._cache: - if key == 'numberOfSection': + if key == "numberOfSection": value = self._number - elif key not in self._keys: - raise KeyError('{!r} not defined in section {}'.format( - key, self._number)) else: + key = self._valid_key(key) value = self._get_key_value(key) + self._cache[key] = value + return self._cache[key] def __setitem__(self, key, value): - # Allow the overwriting of any entry already in the _cache. - if key in self._cache: - self._cache[key] = value + # Allow the overwriting of any valid key. + if key == "numberOfSection": + emsg = "Cannot write 'numberOfSection' key." + raise KeyError(emsg) else: - raise KeyError('{!r} cannot be redefined in ' - 'section {}'.format(key, self._number)) + key = self._valid_key(key) + self._cache[key] = value def _get_key_value(self, key): """ @@ -429,30 +508,39 @@ def _get_key_value(self, key): message. """ - vector_keys = ('codedValues', 'pv', 'satelliteSeries', - 'satelliteNumber', 'instrumentType', - 'scaleFactorOfCentralWaveNumber', - 'scaledValueOfCentralWaveNumber', - 'longitudes', 'latitudes') + vector_keys = ( + "codedValues", + "pv", + "satelliteSeries", + "satelliteNumber", + "instrumentType", + "scaleFactorOfCentralWaveNumber", + "scaledValueOfCentralWaveNumber", + "longitude", + "latitude", + "longitudes", + "latitudes", + ) if key in vector_keys: - res = gribapi.grib_get_array(self._message_id, key) - elif key == 'bitmap': + res = eccodes.codes_get_array(self._message_id, key) + elif key == "bitmap": # The bitmap is stored as contiguous boolean bits, one bit for each - # data point. GRIBAPI returns these as strings, so it must be - # type-cast to return an array of ints (0, 1). - res = gribapi.grib_get_array(self._message_id, key, int) - elif key in ('typeOfFirstFixedSurface', 'typeOfSecondFixedSurface'): + # data point. ecCodes returns these as strings, so it must be type-cast + # to return an array of ints (0, 1). + res = eccodes.codes_get_array(self._message_id, key, int) + elif key in ("typeOfFirstFixedSurface", "typeOfSecondFixedSurface"): # By default these values are returned as unhelpful strings but # we can use int representation to compare against instead. - res = gribapi.grib_get(self._message_id, key, int) + res = self._get_value_or_missing(key, use_int=True) else: - res = gribapi.grib_get(self._message_id, key) + res = self._get_value_or_missing(key) return res def get_computed_key(self, key): """ - Get the computed value associated with the given key in the GRIB - message. + Get the computed value for a given key. + + Returns the value associated with the given key in the GRIB message. Args: @@ -463,13 +551,29 @@ def get_computed_key(self, key): message. """ - vector_keys = ('longitudes', 'latitudes', 'distinctLatitudes') + vector_keys = ("longitudes", "latitudes", "distinctLatitudes") if key in vector_keys: - res = gribapi.grib_get_array(self._message_id, key) + res = eccodes.codes_get_array(self._message_id, key) else: - res = gribapi.grib_get(self._message_id, key) + res = self._get_value_or_missing(key) return res def keys(self): """Return coded keys available in this Section.""" return self._keys + + def _get_value_or_missing(self, key, use_int=False): + """ + Return value of header element, or None if value is encoded as missing. + + Implementation of Regulations 92.1.4 and 92.1.5 via ECCodes. + + """ + if eccodes.codes_is_missing(self._message_id, key): + result = None + else: + if use_int: + result = eccodes.codes_get(self._message_id, key, int) + else: + result = eccodes.codes_get(self._message_id, key) + return result diff --git a/src/iris_grib/tests/__init__.py b/src/iris_grib/tests/__init__.py new file mode 100644 index 00000000..a0427b3e --- /dev/null +++ b/src/iris_grib/tests/__init__.py @@ -0,0 +1,232 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Provides testing capabilities and customisations specific to iris-grib. + +This imports iris.tests, which requires to be done before anything else for +plot control reasons : see documentation there. + +""" + +import iris.tests # noqa: F401 + +import inspect +import os +import os.path +import unittest + +import numpy as np + +try: + from iris.tests import IrisTest_nometa as IrisTest +except ImportError: + from iris.tests import IrisTest + +from iris.tests import main, skip_data, get_data_path # noqa: F401 + +from iris_grib.message import GribMessage + + +#: Basepath for iris-grib test results. +_RESULT_PATH = os.path.join(os.path.dirname(__file__), "results") + +#: Basepath for iris-grib loadable test files. +_TESTDATA_PATH = os.path.join(os.path.dirname(__file__), "testdata") + +override = os.environ.get("GRIB_TEST_DATA_PATH") +if override: + if os.path.isdir(os.path.expanduser(override)): + _TESTDATA_PATH = os.path.abspath(override) + + +def skip_grib_data(fn): + """ + Decorator to choose whether to run tests, based on the availability of + external data. + + Example usage: + @skip_data + class MyDataTests(tests.IrisGribTest): + ... + + """ + dpath = _TESTDATA_PATH + evar = "GRIB_TEST_NO_DATA" + no_data = not os.path.isdir(dpath) or os.environ.get(evar) + reason = "Test(s) require missing external GRIB test data." + + skip = unittest.skipIf(condition=no_data, reason=reason) + + return skip(fn) + + +class IrisGribTest(IrisTest): + # A specialised version of an IrisTest that implements the correct + # automatic paths for test results in iris-grib. + + @staticmethod + def get_result_path(relative_path): + """ + Returns the absolute path to a result file when given the relative path + as a string, or sequence of strings. + + """ + if not isinstance(relative_path, str): + relative_path = os.path.join(*relative_path) + return os.path.abspath(os.path.join(_RESULT_PATH, relative_path)) + + def result_path(self, basename=None, ext=""): + """ + Return the full path to a test result, generated from the \ + calling file, class and, optionally, method. + + Optional kwargs : + + * basename - File basename. If omitted, this is \ + generated from the calling method. + * ext - Appended file extension. + + """ + if ext and not ext.startswith("."): + ext = "." + ext + + # Generate the folder name from the calling file name. + path = os.path.abspath(inspect.getfile(self.__class__)) + path = os.path.splitext(path)[0] + sub_path = path.rsplit("iris_grib", 1)[1].split("tests", 1)[1][1:] + + # Generate the file name from the calling function name? + if basename is None: + stack = inspect.stack() + for frame in stack[1:]: + if "test_" in frame[3]: + basename = frame[3].replace("test_", "") + break + filename = basename + ext + + result = os.path.join( + self.get_result_path(""), + sub_path.replace("test_", ""), + self.__class__.__name__.replace("Test_", ""), + filename, + ) + return result + + @staticmethod + def get_testdata_path(relative_path): + """ + Returns the absolute path to a loadable test data file, when given the + relative path as a string, or sequence of strings. + + """ + if not isinstance(relative_path, str): + relative_path = os.path.join(*relative_path) + return os.path.abspath(os.path.join(_TESTDATA_PATH, relative_path)) + + +class TestGribMessage(IrisGribTest): + def assertGribMessageContents(self, filename, contents): + """ + Evaluate whether all messages in a GRIB2 file contain the provided + contents. + + * filename (string) + The path on disk of an existing GRIB file + + * contents + An iterable of GRIB message keys and expected values. + + """ + messages = GribMessage.messages_from_filename(filename) + for message in messages: + for element in contents: + section, key, val = element + self.assertEqual(message.sections[section][key], val) + + def assertGribMessageDifference( + self, filename1, filename2, diffs, skip_keys=(), skip_sections=() + ): + """ + Evaluate that the two messages only differ in the ways specified. + + * filename[0|1] (string) + The path on disk of existing GRIB files + + * diffs + An dictionary of GRIB message keys and expected diff values: + {key: (m1val, m2val),...} . + + * skip_keys + An iterable of key names to ignore during comparison. + + * skip_sections + An iterable of section numbers to ignore during comparison. + + """ + messages1 = list(GribMessage.messages_from_filename(filename1)) + messages2 = list(GribMessage.messages_from_filename(filename2)) + self.assertEqual(len(messages1), len(messages2)) + for m1, m2 in zip(messages1, messages2, strict=False): + m1_sect = set(m1.sections.keys()) + m2_sect = set(m2.sections.keys()) + + for missing_section in m1_sect ^ m2_sect: + what = "introduced" if missing_section in m1_sect else "removed" + # Assert that an introduced section is in the diffs. + self.assertIn( + missing_section, + skip_sections, + msg="Section {} {}".format(missing_section, what), + ) + + for section in m1_sect & m2_sect: + # For each section, check that the differences are + # known diffs. + m1_keys = set(m1.sections[section]._keys) + m2_keys = set(m2.sections[section]._keys) + + difference = m1_keys ^ m2_keys + unexpected_differences = difference - set(skip_keys) + if unexpected_differences: + self.fail( + "There were keys in section {} which \n" + "weren't in both messages and which weren't " + "skipped.\n{}" + "".format(section, ", ".join(unexpected_differences)) + ) + + keys_to_compare = m1_keys & m2_keys - set(skip_keys) + + for key in keys_to_compare: + m1_value = m1.sections[section][key] + m2_value = m2.sections[section][key] + msg = "{} {} != {}" + if key not in diffs: + # We have a key which we expect to be the same for + # both messages. + if isinstance(m1_value, np.ndarray): + # A large tolerance appears to be required for + # gribapi 1.12, but not for 1.14. + self.assertArrayAlmostEqual(m1_value, m2_value, decimal=2) + else: + self.assertEqual( + m1_value, + m2_value, + msg=msg.format(key, m1_value, m2_value), + ) + else: + # We have a key which we expect to be different + # for each message. + self.assertEqual( + m1_value, + diffs[key][0], + msg=msg.format(key, m1_value, diffs[key][0]), + ) + + self.assertEqual( + m2_value, + diffs[key][1], + msg=msg.format(key, m2_value, diffs[key][1]), + ) diff --git a/src/iris_grib/tests/integration/__init__.py b/src/iris_grib/tests/integration/__init__.py new file mode 100644 index 00000000..1fed643a --- /dev/null +++ b/src/iris_grib/tests/integration/__init__.py @@ -0,0 +1,5 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Integration tests for the :mod:`iris_grib` package.""" diff --git a/src/iris_grib/tests/integration/format_interop/__init__.py b/src/iris_grib/tests/integration/format_interop/__init__.py new file mode 100644 index 00000000..9693b501 --- /dev/null +++ b/src/iris_grib/tests/integration/format_interop/__init__.py @@ -0,0 +1,11 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Integration tests for format interoperability. + +Checks that specific features of cubes loaded from other formats are correctly +represented by grib saving. + +""" diff --git a/src/iris_grib/tests/integration/format_interop/test_name_grib.py b/src/iris_grib/tests/integration/format_interop/test_name_grib.py new file mode 100644 index 00000000..16f50a59 --- /dev/null +++ b/src/iris_grib/tests/integration/format_interop/test_name_grib.py @@ -0,0 +1,124 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Integration tests for NAME to GRIB2 interoperability.""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import numpy as np +import warnings + +import iris + + +def name_cb(cube, field, filename): + # NAME files give the time point at the end of the range but Iris' + # GRIB loader creates it in the middle (the GRIB file itself doesn't + # encode a time point). Here we make them consistent so we can + # easily compare them. + t_coord = cube.coord("time") + t_coord.points = t_coord.bounds[0][1] + fp_coord = cube.coord("forecast_period") + fp_coord.points = fp_coord.bounds[0][1] + # NAME contains extra vertical meta-data. + z_coord = cube.coords("height") + if z_coord: + z_coord[0].standard_name = "height" + z_coord[0].long_name = "height above ground level" + z_coord[0].attributes = {"positive": "up"} + + +class TestNameToGRIB(tests.IrisGribTest): + def check_common(self, name_cube, grib_cube): + self.assertTrue(np.allclose(name_cube.data, grib_cube.data)) + self.assertTrue( + np.allclose( + name_cube.coord("latitude").points, + grib_cube.coord("latitude").points, + ) + ) + self.assertTrue( + np.allclose( + name_cube.coord("longitude").points, + grib_cube.coord("longitude").points - 360, + ) + ) + + for c in ["height", "time"]: + if name_cube.coords(c): + self.assertEqual(name_cube.coord(c), grib_cube.coord(c)) + + @tests.skip_data + def test_name2_field(self): + filepath = tests.get_data_path(("NAME", "NAMEII_field.txt")) + name_cubes = iris.load(filepath) + + # There is a known load/save problem with numerous + # gribapi/eccodes versions and + # zero only data, where min == max. + # This may be a problem with data scaling. + for i, name_cube in enumerate(name_cubes): + data = name_cube.data + if np.min(data) == np.max(data): + msg = ( + 'NAMEII cube #{}, "{}" has empty data : ' + "SKIPPING test for this cube, as save/load will " + "not currently work." + ) + warnings.warn(msg.format(i, name_cube.name())) + continue + + # Iris>=3.2 loads in an extra 'z' coordinate which cannot currently + # be save to GRIB. + if name_cube.coords("z"): + name_cube.remove_coord("z") + + with self.temp_filename(".grib2") as temp_filename: + iris.save(name_cube, temp_filename) + grib_cube = iris.load_cube(temp_filename, callback=name_cb) + self.check_common(name_cube, grib_cube) + self.assertCML( + grib_cube, + self.get_result_path( + ( + "integration", + "name_grib", + "NAMEII", + "{}_{}.cml".format(i, name_cube.name()), + ) + ), + ) + + @tests.skip_data + def test_name3_field(self): + filepath = tests.get_data_path(("NAME", "NAMEIII_field.txt")) + name_cubes = iris.load(filepath) + for i, name_cube in enumerate(name_cubes): + # Iris>=3.2 loads in an extra 'z' coordinate which cannot currently + # be save to GRIB. + if name_cube.coord("z") is not None: + name_cube.remove_coord("z") + + with self.temp_filename(".grib2") as temp_filename: + iris.save(name_cube, temp_filename) + grib_cube = iris.load_cube(temp_filename, callback=name_cb) + + self.check_common(name_cube, grib_cube) + self.assertCML( + grib_cube, + self.get_result_path( + ( + "integration", + "name_grib", + "NAMEIII", + "{}_{}.cml".format(i, name_cube.name()), + ) + ), + ) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/integration/format_interop/test_pp_grib.py b/src/iris_grib/tests/integration/format_interop/test_pp_grib.py new file mode 100644 index 00000000..f5b9b2fe --- /dev/null +++ b/src/iris_grib/tests/integration/format_interop/test_pp_grib.py @@ -0,0 +1,44 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Integration tests for PP/GRIB interoperability.""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import iris + + +class TestBoundedTime(tests.IrisTest): + @tests.skip_data + def test_time_and_forecast_period_round_trip(self): + pp_path = tests.get_data_path(("PP", "meanMaxMin", "200806081200__qwpb.T24.pp")) + # Choose the first time-bounded Cube in the PP dataset. + original = [ + cube for cube in iris.load(pp_path) if cube.coord("time").has_bounds() + ][0] + # Save it to GRIB2 and re-load. + with self.temp_filename(".grib2") as grib_path: + iris.save(original, grib_path) + from_grib = iris.load_cube(grib_path) + # Avoid the downcasting warning when saving to PP. + from_grib.data = from_grib.data.astype("f4") + # Re-save to PP and re-load. + with self.temp_filename(".pp") as pp_path: + iris.save(from_grib, pp_path) + from_pp = iris.load_cube(pp_path) + self.assertEqual(original.coord("time"), from_grib.coord("time")) + self.assertEqual( + original.coord("forecast_period"), + from_grib.coord("forecast_period"), + ) + self.assertEqual(original.coord("time"), from_pp.coord("time")) + self.assertEqual( + original.coord("forecast_period"), from_pp.coord("forecast_period") + ) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/integration/iris_integration/__init__.py b/src/iris_grib/tests/integration/iris_integration/__init__.py new file mode 100644 index 00000000..3ef39cbf --- /dev/null +++ b/src/iris_grib/tests/integration/iris_integration/__init__.py @@ -0,0 +1,9 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Integration tests confirming that iris functionality is working with GRIB +files. + +""" diff --git a/src/iris_grib/tests/integration/iris_integration/test_callback.py b/src/iris_grib/tests/integration/iris_integration/test_callback.py new file mode 100644 index 00000000..c523de3a --- /dev/null +++ b/src/iris_grib/tests/integration/iris_integration/test_callback.py @@ -0,0 +1,42 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test that iris load callbacks work with iris-grib. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + + +import iris +import iris.coords + + +class TestCallbacks(tests.IrisGribTest): + def test_load_callback(self): + # What this actually tests: + # 1. iris.load works with grib (though the GRIB picker is in Iris) + # 2. callbacks work with the grib loader + # 3. grib loaded result matches a cube snapshot + def load_callback(cube, field, filename): + # GRIB2 loader callback : 'field' is a GribMessage, which + # has 'sections'. + cube.add_aux_coord( + iris.coords.AuxCoord( + field.sections[1]["year"], + long_name="extra_year_number_coord", + units="no_unit", + ) + ) + + fname = tests.get_data_path(("GRIB", "global_t", "global.grib2")) + cube = iris.load_cube(fname, callback=load_callback) + self.assertCML(cube) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/integration/iris_integration/test_nc_attribute_handlers.py b/src/iris_grib/tests/integration/iris_integration/test_nc_attribute_handlers.py new file mode 100644 index 00000000..fcd95a3c --- /dev/null +++ b/src/iris_grib/tests/integration/iris_integration/test_nc_attribute_handlers.py @@ -0,0 +1,54 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Integration tests for reading/writing GRIB_PARAM attributes in netcdf files. + +N.B. the relevant support code and classes (including testing) are all part of in Iris, +along with the ones for "STASH" and "ukmo__process_flags" ones. + +But all the testing there is optional, and skipped if iris-grib is not installed, so +that the Iris repo doesn't have to maintain iris-grib as a test dependency. + +So, we import + run the relevant tests here instead. + +""" + +import pytest + +# Start by attempting the key imports from Iris, and skipping all tests if any fail. +# Needed to run with older Iris (releases) prior to the tests being added. +# Unfortunately we can't actually use the returned items, as dynamic typing upsets MyPy. +pytest.importorskip("iris.tests.integration.netcdf.test_load_managed_attributes") +pytest.importorskip("iris.tests.integration.netcdf.test_save_managed_attributes") +pytest.importorskip("iris.tests.unit.fileformats.netcdf.attribute_handlers") + + +from iris.tests.integration.netcdf.test_load_managed_attributes import ( + TestGribParam as LoadGribParamTests, +) + +from iris.tests.integration.netcdf.test_save_managed_attributes import ( + TestGribParam as SaveGribParamTests, +) + +from iris.tests.unit.fileformats.netcdf.attribute_handlers import ( + test_GribParamHandler as tgp, +) + + +class TestLoadGribParam_actual(LoadGribParamTests): + pass + + +class TestSaveGribParam_actual(SaveGribParamTests): + pass + + +class TestGribParamHandler_Encode(tgp.TestEncodeObject): + pass + + +class TestGribParamHandler_Decode(tgp.TestDecodeAttribute): + pass diff --git a/src/iris_grib/tests/integration/load_convert/__init__.py b/src/iris_grib/tests/integration/load_convert/__init__.py new file mode 100644 index 00000000..c732201a --- /dev/null +++ b/src/iris_grib/tests/integration/load_convert/__init__.py @@ -0,0 +1,5 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Integration tests for the :mod:`iris_grib._load_convert` package.""" diff --git a/src/iris_grib/tests/integration/load_convert/test_data_section.py b/src/iris_grib/tests/integration/load_convert/test_data_section.py new file mode 100644 index 00000000..8e5e7431 --- /dev/null +++ b/src/iris_grib/tests/integration/load_convert/test_data_section.py @@ -0,0 +1,84 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Integration tests to confirm data is loaded correctly. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import numpy as np +import numpy.ma as ma + +from iris import load_cube + + +class TestImport(tests.IrisGribTest): + def test_gdt1(self): + path = tests.get_data_path(("GRIB", "rotated_nae_t", "sensible_pole.grib2")) + cube = load_cube(path) + self.assertCMLApproxData(cube) + + def test_gdt90_with_bitmap(self): + path = tests.get_data_path(("GRIB", "umukv", "ukv_chan9.grib2")) + cube = load_cube(path) + # Pay particular attention to the orientation. + self.assertIsNot(cube.data[0, 0], ma.masked) + self.assertIs(cube.data[-1, 0], ma.masked) + self.assertIs(cube.data[0, -1], ma.masked) + self.assertIs(cube.data[-1, -1], ma.masked) + x = cube.coord("projection_x_coordinate").points + y = cube.coord("projection_y_coordinate").points + self.assertGreater(x[0], x[-1]) # Decreasing X coordinate + self.assertLess(y[0], y[-1]) # Increasing Y coordinate + # Check everything else. + self.assertCMLApproxData(cube) + + +class TestGDT30(tests.IrisGribTest): + def test_lambert(self): + path = tests.get_data_path(("GRIB", "lambert", "lambert.grib2")) + cube = load_cube(path) + self.assertCMLApproxData(cube) + + +class TestGDT40(tests.IrisGribTest): + def test_regular(self): + path = tests.get_data_path(("GRIB", "gaussian", "regular_gg.grib2")) + cube = load_cube(path) + self.assertCMLApproxData(cube) + + def test_reduced(self): + path = tests.get_data_path(("GRIB", "reduced", "reduced_gg.grib2")) + cube = load_cube(path) + self.assertCMLApproxData(cube) + + +class TestDRT3(tests.IrisGribTest): + def test_grid_complex_spatial_differencing(self): + path = tests.get_data_path(("GRIB", "missing_values", "missing_values.grib2")) + cube = load_cube(path) + self.assertCMLApproxData(cube) + + +class TestDataProxy(tests.IrisGribTest): + def test_data_representation__no_bitsPerValue(self): + """Ensures that zero data is auto-generated.""" + # This test file contains one GRIB2 message with no data payload + # in Data Section [7], but has "bitsPerValue=0" in the Data + # Representation Section [5] to trigger auto-generation of zero + # data payload by the DataProxy. + path = tests.get_data_path( + ("GRIB", "missing_values", "ice_severity__no_bitsPerValue.grib2") + ) + cube = load_cube(path) + self.assertCML(cube) + self.assertEqual(0, np.sum(cube.data)) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/integration/load_convert/test_load_hybrid_coords.py b/src/iris_grib/tests/integration/load_convert/test_load_hybrid_coords.py new file mode 100644 index 00000000..af267b16 --- /dev/null +++ b/src/iris_grib/tests/integration/load_convert/test_load_hybrid_coords.py @@ -0,0 +1,88 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Integration test for loading hybrid height data. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + + +from iris import load_cube +from iris.aux_factory import HybridHeightFactory, HybridPressureFactory + + +@tests.skip_grib_data +class TestHybridHeight(tests.IrisGribTest): + def setUp(self): + filepath = self.get_testdata_path("faked_sample_hh_grib_data.grib2") + self.testdata_cube = load_cube(filepath, "air_temperature") + + def test_load_hybrid_height(self): + # Check that it loads right, and creates a factory. + self.assertIsInstance(self.testdata_cube.aux_factories[0], HybridHeightFactory) + + def test_hybrid_height_coords_values(self): + cube = self.testdata_cube + + # check actual model level values. + self.assertArrayEqual(cube.coord("model_level_number").points, [1, 11, 21]) + + # check sigma values correctly loaded from indices 1, 11, 21. + self.assertArrayAllClose( + cube.coord("sigma").points, [0.998, 0.894, 0.667], atol=0.001 + ) + + # check height values too. + self.assertArrayAllClose( + cube.coord("level_height").points, [20.0, 953.3, 3220.0], atol=0.5 + ) + + +@tests.skip_grib_data +class TestHybridPressure(tests.IrisGribTest): + def setUp(self): + filepath = self.get_testdata_path("faked_sample_hp_grib_data.grib2") + self.testdata_cube = load_cube(filepath, "air_temperature") + + def test_load_hybrid_pressure(self): + # Check that it loads right, and creates a factory. + self.assertIsInstance( + self.testdata_cube.aux_factories[0], HybridPressureFactory + ) + + def test_hybrid_pressure_coords_values(self): + cube = self.testdata_cube + + # Check existence, and some values, of the loaded coefficients. + self.assertArrayEqual(cube.coord("model_level_number").points, [1, 51, 91]) + self.assertArrayAllClose( + cube.coord("sigma").points, [0.0, 0.045, 1.0], atol=0.001 + ) + self.assertArrayAllClose( + cube.coord("level_pressure").points, [2.00004, 18716.9688, 0.0], rtol=0.0001 + ) + self.assertArrayAllClose( + cube.coord("surface_air_pressure")[:2, :3].points, + [[103493.8, 103493.8, 103493.8], [103401.0, 103407.4, 103412.2]], + atol=0.1, + ) + + # Also check a few values from the derived coord. + self.assertArrayAllClose( + cube.coord("air_pressure")[:, :3, 0].points, + [ + [2.0, 2.0, 2.0], + [23389.3, 23385.1, 23379.9], + [103493.8, 103401.0, 103285.8], + ], + atol=0.1, + ) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/integration/load_convert/test_product_definition_section.py b/src/iris_grib/tests/integration/load_convert/test_product_definition_section.py new file mode 100644 index 00000000..a9431db2 --- /dev/null +++ b/src/iris_grib/tests/integration/load_convert/test_product_definition_section.py @@ -0,0 +1,69 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Integration tests for loading various production definitions. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + + +from iris import load_cube + + +class TestPDT8(tests.IrisGribTest): + def setUp(self): + # Load from the test file. + file_path = tests.get_data_path(("GRIB", "time_processed", "time_bound.grib2")) + self.cube = load_cube(file_path) + + def test_coords(self): + # Check the result has main coordinates as expected. + for name, shape, is_bounded in [ + ("forecast_reference_time", (1,), False), + ("time", (1,), True), + ("forecast_period", (1,), True), + ("pressure", (1,), False), + ("latitude", (73,), False), + ("longitude", (96,), False), + ]: + coords = self.cube.coords(name) + self.assertEqual( + len(coords), + 1, + "expected one {!r} coord, found {}".format(name, len(coords)), + ) + (coord,) = coords + self.assertEqual( + coord.shape, + shape, + "coord {!r} shape is {} instead of {!r}.".format( + name, coord.shape, shape + ), + ) + self.assertEqual( + coord.has_bounds(), + is_bounded, + "coord {!r} has_bounds={}, expected {}.".format( + name, coord.has_bounds(), is_bounded + ), + ) + + def test_cell_method(self): + # Check the result has the expected cell method. + cell_methods = self.cube.cell_methods + self.assertEqual( + len(cell_methods), + 1, + "result has {} cell methods, expected one.".format(len(cell_methods)), + ) + (cell_method,) = cell_methods + self.assertEqual(cell_method.coord_names, ("time",)) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/integration/load_convert/test_sample_file_loads.py b/src/iris_grib/tests/integration/load_convert/test_sample_file_loads.py new file mode 100644 index 00000000..1f2075da --- /dev/null +++ b/src/iris_grib/tests/integration/load_convert/test_sample_file_loads.py @@ -0,0 +1,319 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Integration tests for grib2 file loading. + +These tests load various files from iris-test-data, and compare the cube with a +reference CML file, to catch any unexpected changes over time. + +""" + +from __future__ import annotations + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from pathlib import Path + +import eccodes +import iris +import pytest + + +_RESULTDIR_PREFIX = ("integration", "load_convert", "sample_file_loads") + + +@tests.skip_data +class TestBasicLoad(tests.IrisGribTest): + def test_load_rotated(self): + cubes = iris.load( + tests.get_data_path(("GRIB", "rotated_uk", "uk_wrongparam.grib1")) + ) + self.assertCML(cubes, _RESULTDIR_PREFIX + ("rotated.cml",)) + + def test_load_time_bound(self): + cubes = iris.load( + tests.get_data_path(("GRIB", "time_processed", "time_bound.grib1")) + ) + self.assertCML(cubes, _RESULTDIR_PREFIX + ("time_bound_grib1.cml",)) + + def test_load_time_processed(self): + cubes = iris.load( + tests.get_data_path(("GRIB", "time_processed", "time_bound.grib2")) + ) + self.assertCML(cubes, _RESULTDIR_PREFIX + ("time_bound_grib2.cml",)) + + def test_load_3_layer(self): + cubes = iris.load(tests.get_data_path(("GRIB", "3_layer_viz", "3_layer.grib2"))) + cubes = iris.cube.CubeList([cubes[1], cubes[0], cubes[2]]) + self.assertCML(cubes, _RESULTDIR_PREFIX + ("3_layer.cml",)) + + def test_load_masked(self): + gribfile = tests.get_data_path( + ("GRIB", "missing_values", "missing_values.grib2") + ) + cubes = iris.load(gribfile) + self.assertCML(cubes, _RESULTDIR_PREFIX + ("missing_values_grib2.cml",)) + + def test_polar_stereo_grib1(self): + cube = iris.load_cube( + tests.get_data_path(("GRIB", "polar_stereo", "ST4.2013052210.01h")) + ) + self.assertCML(cube, _RESULTDIR_PREFIX + ("polar_stereo_grib1.cml",)) + + def test_polar_stereo_grib2_grid_definition(self): + cube = iris.load_cube( + tests.get_data_path( + ( + "GRIB", + "polar_stereo", + "CMC_glb_TMP_ISBL_1015_ps30km_2013052000_P006.grib2", + ) + ) + ) + self.assertEqual(cube.shape, (200, 247)) + pxc = cube.coord("projection_x_coordinate") + self.assertAlmostEqual(pxc.points.max(), 4769905.5125, places=4) + self.assertAlmostEqual(pxc.points.min(), -2610094.4875, places=4) + pyc = cube.coord("projection_y_coordinate") + self.assertAlmostEqual(pyc.points.max(), -216.1459, places=4) + self.assertAlmostEqual(pyc.points.min(), -5970216.1459, places=4) + self.assertEqual(pyc.coord_system, pxc.coord_system) + self.assertEqual( + pyc.coord_system.grid_mapping_name, + "polar_stereographic", + ) + self.assertEqual(pyc.coord_system.central_lat, 90.0) + self.assertEqual(pyc.coord_system.central_lon, 249.0) + self.assertEqual(pyc.coord_system.false_easting, 0.0) + self.assertEqual(pyc.coord_system.false_northing, 0.0) + self.assertEqual(pyc.coord_system.true_scale_lat, 60.0) + + def test_lambert_grib1(self): + cube = iris.load_cube(tests.get_data_path(("GRIB", "lambert", "lambert.grib1"))) + self.assertCML(cube, _RESULTDIR_PREFIX + ("lambert_grib1.cml",)) + + def test_lambert_grib2(self): + cube = iris.load_cube(tests.get_data_path(("GRIB", "lambert", "lambert.grib2"))) + self.assertCML(cube, _RESULTDIR_PREFIX + ("lambert_grib2.cml",)) + + def test_regular_gg_grib1(self): + cube = iris.load_cube( + tests.get_data_path(("GRIB", "gaussian", "regular_gg.grib1")) + ) + self.assertCML(cube, _RESULTDIR_PREFIX + ("regular_gg_grib1.cml",)) + + def test_regular_gg_grib2(self): + cube = iris.load_cube( + tests.get_data_path(("GRIB", "gaussian", "regular_gg.grib2")) + ) + self.assertCML(cube, _RESULTDIR_PREFIX + ("regular_gg_grib2.cml",)) + + def test_reduced_ll(self): + cube = iris.load_cube( + tests.get_data_path(("GRIB", "reduced", "reduced_ll.grib1")) + ) + self.assertCML(cube, _RESULTDIR_PREFIX + ("reduced_ll_grib1.cml",)) + + def test_reduced_gg_grib1(self): + cube = iris.load_cube( + Path(eccodes.codes_samples_path()) / "reduced_gg_ml_grib1.tmpl" + ) + self.assertCML(cube, _RESULTDIR_PREFIX + ("reduced_gg_grib1.cml",)) + + def test_reduced_gg_grib2(self): + cube = iris.load_cube( + tests.get_data_path(("GRIB", "reduced", "reduced_gg.grib2")) + ) + self.assertCML(cube, _RESULTDIR_PREFIX + ("reduced_gg_grib2.cml",)) + + def test_second_order_packing(self): + cube = iris.load_cube( + tests.get_data_path( + ("GRIB", "grib1_second_order_packing", "GRIB_00008_FRANX01") + ) + ) + self.assertCML(cube, _RESULTDIR_PREFIX + ("second_order_packing.cml",)) + + def test_bulletin_headers(self): + for byte_len in (40, 41): + cube = iris.load_cube( + tests.get_data_path(("GRIB", "bulletin", f"{byte_len}bytes.grib")) + ) + self.assertCML(cube, _RESULTDIR_PREFIX + (f"bulletin_{byte_len}bytes.cml",)) + + +@tests.skip_data +class TestIjDirections(tests.IrisGribTest): + @staticmethod + def _old_compat_load(name): + filepath = tests.get_data_path(("GRIB", "ij_directions", name)) + cube = iris.load_cube(filepath) + return cube + + def test_ij_directions_ipos_jpos(self): + cubes = self._old_compat_load("ipos_jpos.grib2") + self.assertCML(cubes, _RESULTDIR_PREFIX + ("ipos_jpos.cml",)) + + def test_ij_directions_ipos_jneg(self): + cubes = self._old_compat_load("ipos_jneg.grib2") + self.assertCML(cubes, _RESULTDIR_PREFIX + ("ipos_jneg.cml",)) + + def test_ij_directions_ineg_jneg(self): + cubes = self._old_compat_load("ineg_jneg.grib2") + self.assertCML(cubes, _RESULTDIR_PREFIX + ("ineg_jneg.cml",)) + + def test_ij_directions_ineg_jpos(self): + cubes = self._old_compat_load("ineg_jpos.grib2") + self.assertCML(cubes, _RESULTDIR_PREFIX + ("ineg_jpos.cml",)) + + +@tests.skip_data +class TestShapeOfEarth(tests.IrisGribTest): + @staticmethod + def _old_compat_load(name): + filepath = tests.get_data_path(("GRIB", "shape_of_earth", name)) + cube = iris.load_cube(filepath) + return cube + + def test_shape_of_earth_basic(self): + # pre-defined sphere + cube = self._old_compat_load("0.grib2") + self.assertCML(cube, _RESULTDIR_PREFIX + ("earth_shape_0.cml",)) + + def test_shape_of_earth_custom_1(self): + # custom sphere + cube = self._old_compat_load("1.grib2") + self.assertCML(cube, _RESULTDIR_PREFIX + ("earth_shape_1.cml",)) + + def test_shape_of_earth_IAU65(self): + # IAU65 oblate sphere + cube = self._old_compat_load("2.grib2") + self.assertCML(cube, _RESULTDIR_PREFIX + ("earth_shape_2.cml",)) + + def test_shape_of_earth_custom_3(self): + # custom oblate spheroid (km) + cube = self._old_compat_load("3.grib2") + self.assertCML(cube, _RESULTDIR_PREFIX + ("earth_shape_3.cml",)) + + def test_shape_of_earth_IAG_GRS80(self): + # IAG-GRS80 oblate spheroid + cube = self._old_compat_load("4.grib2") + self.assertCML(cube, _RESULTDIR_PREFIX + ("earth_shape_4.cml",)) + + def test_shape_of_earth_WGS84(self): + # WGS84 + cube = self._old_compat_load("5.grib2") + self.assertCML(cube, _RESULTDIR_PREFIX + ("earth_shape_5.cml",)) + + def test_shape_of_earth_pre_6(self): + # pre-defined sphere + cube = self._old_compat_load("6.grib2") + self.assertCML(cube, _RESULTDIR_PREFIX + ("earth_shape_6.cml",)) + + def test_shape_of_earth_custom_7(self): + # custom oblate spheroid (m) + cube = self._old_compat_load("7.grib2") + self.assertCML(cube, _RESULTDIR_PREFIX + ("earth_shape_7.cml",)) + + def test_shape_of_earth_grib1(self): + # grib1 - same as grib2 shape 6, above + cube = self._old_compat_load("global.grib1") + self.assertCML(cube, _RESULTDIR_PREFIX + ("earth_shape_grib1.cml",)) + + +class TestTimesGrib1: + # Our codebase has support for many timeRangeIndicator values in GRIB1 + # files, but we do not have files demonstrating these. This class + # generates appropriate GRIB1 files so that we can fully refactor the + # loading code while ensuring continued support for all possible + # scenarios. + + @pytest.fixture(params=[1, 2, 3, 4, 5, 10, 113, 118, 123, 124], autouse=True) + # Note that the following values are not supported by Eccodes - it can + # not provide a startStep value unless the timeRangeIndicator is present + # in eccodes/definitions/grib1/localConcepts/edzw/stepType.def: + # [51, 114, 115, 116, 117, 125]. + def _get_time_range_file(self, request, tmp_path): + save_file = tmp_path / "TestTimes.grib1" + time_range_indicator = request.param + + # Make a file with 10 ascending time steps. + with save_file.open("wb") as open_file: + for time_step in range(10): + grib_message = eccodes.codes_grib_new_from_samples("GRIB1") + eccodes.codes_set_long(grib_message, "P1", time_step) + eccodes.codes_set_long(grib_message, "P2", time_step + 1) + eccodes.codes_set_long( + grib_message, "timeRangeIndicator", time_range_indicator + ) + eccodes.codes_write(grib_message, open_file) + eccodes.codes_release(grib_message) + + self.time_range_indicator = time_range_indicator + self.save_file = save_file + + def test_time_range(self): + cube = iris.load_cube(self.save_file) + tests.IrisGribTest().assertCML( + cube, _RESULTDIR_PREFIX + (f"time_range_{self.time_range_indicator}.cml",) + ) + + +class TestFullCoverageGrib1: + # We do not have a full set of GRIB1 files that exercise our entire GRIB1 + # loading code. This class generates files to cover those gaps. We use CML + # testing since, in the absence of bug reports after years of use (time + # of writing: 2024), we must assume that current functionality is + # satisfactory. Integration tests of this sort will allow us to fully + # refactor GRIB1 loading without any change in user experience. + + id_path_codes = [ + ( + "polar_stereo_south", + tests.get_data_path(("GRIB", "polar_stereo", "ST4.2013052210.01h")), + [("projectionCentreFlag", 1)], + ), + ( + "y_wind", + tests.get_data_path(("GRIB", "gaussian", "regular_gg.grib1")), + [("indicatorOfParameter", 34)], + ), + ( + "mapped_cf_data", + Path(eccodes.codes_samples_path()) / "GRIB1.tmpl", + [("table2Version", 128), ("centre", 98), ("indicatorOfParameter", 34)], + ), + ] + + @pytest.fixture( + params=id_path_codes, + ids=[id for id, path, codes in id_path_codes], + autouse=True, + ) + def _get_grib1_file(self, request, tmp_path): + id_, path, codes = request.param + path_original = Path(path) + path_modified = tmp_path / "tmp_file.grib1" + with path_original.open("rb") as file_original: + gid = eccodes.codes_grib_new_from_file(file_original) + for key, value in codes: + eccodes.codes_set(gid, key, value) + with path_modified.open("wb") as file_modified: + eccodes.codes_write(gid, file_modified) + eccodes.codes_release(gid) + self.id_ = id_ + self.file_path = path_modified + + def test_grib1(self): + cube = iris.load_cube(self.file_path) + tests.IrisGribTest().assertCML( + cube, _RESULTDIR_PREFIX + (f"{self.id_}_grib1.cml",) + ) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/integration/round_trip/__init__.py b/src/iris_grib/tests/integration/round_trip/__init__.py new file mode 100644 index 00000000..f14beb58 --- /dev/null +++ b/src/iris_grib/tests/integration/round_trip/__init__.py @@ -0,0 +1,5 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Round-trip integration tests for the :mod:`iris-grib` package.""" diff --git a/src/iris_grib/tests/integration/round_trip/test_WAFC_mapping_round_trip.py b/src/iris_grib/tests/integration/round_trip/test_WAFC_mapping_round_trip.py new file mode 100644 index 00000000..b106474b --- /dev/null +++ b/src/iris_grib/tests/integration/round_trip/test_WAFC_mapping_round_trip.py @@ -0,0 +1,69 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Integration test for round-trip loading and saving of hybrid height and +hybrid pressure cubes. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from iris import load_cube, load, save +from iris.cube import Cube + + +@tests.skip_grib_data +class TestWAFCCodes(tests.IrisGribTest): + def setUp(self): + self.cat = self.get_testdata_path("CAT_T+24_0600.grib2") + self.cb = self.get_testdata_path("CB_T+24_0600.grib2") + self.icing = self.get_testdata_path("ICING_T+24_0600.grib2") + self.turb = self.get_testdata_path("INCLDTURB_T+24_0600.grib2") + + def test_WAFC_CAT_round_trip(self): + cubelist = load(self.cat, "WAFC_CAT_potential") + cube = cubelist[0] + self.assertIsInstance(cube, Cube) + + with self.temp_filename() as tmp_save_path: + save(cube, tmp_save_path, saver="grib2") + saved_cube = load_cube(tmp_save_path) + self.assertEqual(saved_cube.metadata, cube.metadata) + + def test_WAFC_CB_round_trip(self): + cubelist = load(self.cb) + cube = cubelist[0] + self.assertIsInstance(cube, Cube) + + with self.temp_filename() as tmp_save_path: + save(cube, tmp_save_path, saver="grib2") + saved_cube = load_cube(tmp_save_path) + self.assertEqual(saved_cube.metadata, cube.metadata) + + def test_WAFC_icing_round_trip(self): + cubelist = load(self.icing) + cube = cubelist[0] + self.assertIsInstance(cube, Cube) + + with self.temp_filename() as tmp_save_path: + save(cube, tmp_save_path, saver="grib2") + saved_cube = load_cube(tmp_save_path) + self.assertEqual(saved_cube.metadata, cube.metadata) + + def test_WAFC_turb_round_trip(self): + cubelist = load(self.turb) + cube = cubelist[0] + self.assertIsInstance(cube, Cube) + + with self.temp_filename() as tmp_save_path: + save(cube, tmp_save_path, saver="grib2") + saved_cube = load_cube(tmp_save_path) + self.assertEqual(saved_cube.metadata, cube.metadata) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/integration/round_trip/test_grid_definition_section.py b/src/iris_grib/tests/integration/round_trip/test_grid_definition_section.py new file mode 100644 index 00000000..6b9eb52e --- /dev/null +++ b/src/iris_grib/tests/integration/round_trip/test_grid_definition_section.py @@ -0,0 +1,220 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Integration test for round-trip loading and saving of various grids. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from typing import Literal + +import numpy as np +import pytest + +from iris import load_cube, save +from iris.coord_systems import GeogCS, PolarStereographic, RotatedGeogCS, Stereographic +from iris.coords import DimCoord +from iris.cube import Cube +from iris.exceptions import CoordinateNotFoundError +from iris.util import is_regular + +from iris_grib import TEMPLATE_RECORD +from iris_grib.grib_phenom_translation import GRIBCode +from iris_grib.message import GribMessage + + +def assert_grib_message_contents(filename, contents): + for message in GribMessage.messages_from_filename(filename): + for section, key, expected in contents: + assert message.sections[section][key] == expected + + +def test_gdt5_save_load(tmp_path): + # Load sample UKV data (variable-resolution rotated grid). + path = tests.get_data_path(("PP", "ukV1", "ukVpmslont.pp")) + cube = load_cube(path) + + # Extract a single 2D field, for simplicity. + assert cube.ndim == 3 + assert cube.coord_dims("time") == (0,) + cube = cube[0] + + # Check that it has a rotated-pole variable-spaced grid, as expected. + x_coord = cube.coord(axis="x") + assert isinstance(x_coord.coord_system, RotatedGeogCS) + assert not is_regular(x_coord) + + temp_file_path = tmp_path / "ukv_sample.grib2" + save(cube, temp_file_path) + + expect_values = ( + (0, "editionNumber", 2), + (3, "gridDefinitionTemplateNumber", 5), + (3, "Ni", cube.shape[-1]), + (3, "Nj", cube.shape[-2]), + (3, "shapeOfTheEarth", 6), + (3, "scaledValueOfRadiusOfSphericalEarth", 0), + (3, "resolutionAndComponentFlags", 0), + (3, "latitudeOfSouthernPole", -37500000), + (3, "longitudeOfSouthernPole", 357500000), + (3, "angleOfRotation", 0), + ) + assert_grib_message_contents(temp_file_path, expect_values) + + cube_loaded_from_saved = load_cube(temp_file_path) + _ = cube_loaded_from_saved.data + + # The re-loaded result will not match the original in every respect: + # * cube attributes are discarded + # * horizontal coordinates are rounded to an integer representation + # * bounds on horizontal coords are lost + # Thus the following "equivalence tests" are rather piecemeal. + for test_cube in (cube, cube_loaded_from_saved): + assert test_cube.standard_name == "air_pressure_at_sea_level" + assert test_cube.units == "Pa" + assert test_cube.shape == (928, 744) + assert test_cube.cell_methods == () + + assert cube_loaded_from_saved.attributes == { + "GRIB_PARAM": GRIBCode("GRIB2:d000c003n001") + } + + co_names = [coord.name() for coord in cube.coords()] + co_names_reload = [coord.name() for coord in cube_loaded_from_saved.coords()] + assert sorted(co_names_reload) == sorted(co_names) + + for coord_name in co_names: + co_orig = cube.coord(coord_name) + co_load = cube_loaded_from_saved.coord(coord_name) + assert co_load.shape == co_orig.shape, ( + f'Shape of re-loaded "{coord_name}" coord is {co_load.shape} ' + f"instead of {co_orig.shape}" + ) + np.testing.assert_allclose(co_load.points, co_orig.points, rtol=1.0e-6) + + # Grib does not store x/y bounds, so all re-loaded coords are unbounded. + assert co_load.bounds is None + + np.testing.assert_allclose(cube.data, cube_loaded_from_saved.data) + + +@pytest.mark.parametrize( + ("coord_system_class", "pole"), + [ + (Stereographic, "north"), + (Stereographic, "south"), + (PolarStereographic, "north"), + (PolarStereographic, "south"), + ], +) +def test_gdt20_save_load( + coord_system_class: type[Stereographic], + pole: Literal["north", "south"], + tmp_path, +): + central_lat = 90 if pole == "north" else -90 + coord_system_kwargs = dict( + central_lat=central_lat, + central_lon=325, + true_scale_lat=central_lat, + ellipsoid=GeogCS(6378169.0), + ) + coord_system = coord_system_class(**coord_system_kwargs) + + coord_kwargs = dict(units="m", coord_system=coord_system) + coord_x = DimCoord( + np.linspace(-2250000, 6750192, 256, endpoint=False), + standard_name="projection_x_coordinate", + **coord_kwargs, + ) + coord_y = DimCoord( + np.linspace(-980000, -6600000, 160, endpoint=False), + standard_name="projection_y_coordinate", + **coord_kwargs, + ) + coord_t = DimCoord(0, standard_name="time", units="hours since 1970-01-01 00:00:00") + coord_fp = DimCoord(0, standard_name="forecast_period", units="hours") + coord_frt = DimCoord( + 0, standard_name="forecast_reference_time", units=coord_t.units + ) + + shape = (coord_y.shape[0], coord_x.shape[0]) + cube = Cube( + np.arange(np.prod(shape), dtype=float).reshape(shape), + dim_coords_and_dims=[(coord_y, 0), (coord_x, 1)], + aux_coords_and_dims=[(coord_t, None), (coord_fp, None), (coord_frt, None)], + ) + + temp_file_path = tmp_path / "polar_stereo.grib2" + save(cube, temp_file_path) + cube_reloaded = load_cube(temp_file_path) + _ = cube_reloaded.data + + cube_expected = cube.copy() + for coord in cube_expected.dim_coords: + # GRIB only describes PolarStereographic, so we always expect that + # system even when we started with Stereographic. + coord.coord_system = PolarStereographic(**coord_system_kwargs) + + del cube_reloaded.attributes["GRIB_PARAM"] + for coord in cube_reloaded.dim_coords: + coord.points = np.round(coord.points) + + assert cube_expected == cube_reloaded + + +@pytest.fixture(params=[True, False], ids=["recordGDT", "norecordGDT"]) +def record(request): + record = bool(request.param) + with TEMPLATE_RECORD.context(record=record): + yield record + + +def test_gdt40_loadsave(tmp_path, record): + loadpath = tests.get_data_path(("GRIB", "reduced", "reduced_gg.grib2")) + cube = load_cube(loadpath) + print(cube) + + # This kludge is needed for now to make it saveable + # - ASIS: fails to add pressure factory on load, so has no vertical coord + cube.coord("level_pressure").rename("pressure") + # Removing these *also* allows the "coord_dims_names" check to match. + cube.remove_coord("model_level_number") + cube.remove_coord("sigma") + + if record: + assert cube.attributes["GRIB2_GRID_TEMPLATE"] == 40 + else: + assert "GRIB2_GRID_TEMPLATE" not in cube.attributes + + savepath = tmp_path / "tmp.grib2" + if not record: + msg = ( + "Expected to find exactly 1 coordinate, but found 3. " + "They were: gaussian_grid, latitude, longitude." + ) + with pytest.raises(CoordinateNotFoundError, match=msg): + save(cube, savepath) + else: + save(cube, savepath) + cube_reloaded = load_cube(savepath) + + def coord_dims_names(cube): + return [ + [co.name() for co in cube.coords(dimensions=i_dim)] + for i_dim in list(range(cube.ndim)) + [()] + ] + + print("\nORIGINAL:") + print(cube) + print(coord_dims_names(cube)) + print("\n\nRELOADED:") + print(cube_reloaded) + print(coord_dims_names(cube_reloaded)) + assert coord_dims_names(cube_reloaded) == coord_dims_names(cube) + assert np.all(cube_reloaded.data == cube.data) diff --git a/src/iris_grib/tests/integration/round_trip/test_hybrid_coords_round_trip.py b/src/iris_grib/tests/integration/round_trip/test_hybrid_coords_round_trip.py new file mode 100644 index 00000000..25dc38c7 --- /dev/null +++ b/src/iris_grib/tests/integration/round_trip/test_hybrid_coords_round_trip.py @@ -0,0 +1,66 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Integration test for round-trip loading and saving of hybrid height and +hybrid pressure cubes. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from iris import load_cube, load_cubes, save + +# Try except allows compatibility with current Iris (2.4) and also master. +# TODO: simplify to just the iris.util import once we drop support for any +# Iris versions with iris.experimental.equalise_cubes import +try: + from iris.util import equalise_attributes +except ImportError: + from iris.experimental.equalise_cubes import equalise_attributes + + +@tests.skip_grib_data +class TestHybridHeightRoundTrip(tests.IrisGribTest): + def test_hh_round_trip(self): + filepath = self.get_testdata_path("faked_sample_hh_grib_data.grib2") + # Load and save temperature cube and reference (orography) cube + # separately because this is the only way to save the hybrid height + # coordinate. + cube, ref_cube = load_cubes(filepath, ("air_temperature", "surface_altitude")) + + with self.temp_filename() as tmp_save_path: + save([cube, ref_cube], tmp_save_path, saver="grib2") + # Only need to reload temperature cube to compare with unsaved + # temperature cube. + saved_cube = load_cube(tmp_save_path, "air_temperature") + self.assertTrue(saved_cube == cube) + + +@tests.skip_grib_data +class TestHybridPressureRoundTrip(tests.IrisGribTest): + def test_hybrid_pressure(self): + filepath = self.get_testdata_path("faked_sample_hp_grib_data.grib2") + # Load and save temperature cube and reference (air_pressure at + # surface) cube separately because this is the only way to save the + # hybrid pressure coordinate. + cube, ref_cube = load_cubes(filepath, ("air_temperature", "air_pressure")) + + with self.temp_filename() as tmp_save_path: + save([cube, ref_cube], tmp_save_path, saver="grib2") + # Only need to reload temperature cube to compare with unsaved + # temperature cube. + saved_cube = load_cube(tmp_save_path, "air_temperature") + + # Currently all attributes are lost when saving to grib, so we must + # equalise them in order to successfully compare all other aspects. + equalise_attributes([saved_cube, cube]) + + self.assertTrue(saved_cube == cube) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/integration/round_trip/test_product_definition_section.py b/src/iris_grib/tests/integration/round_trip/test_product_definition_section.py new file mode 100644 index 00000000..e5f28fb1 --- /dev/null +++ b/src/iris_grib/tests/integration/round_trip/test_product_definition_section.py @@ -0,0 +1,76 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Integration tests for round-trip loading and saving various product +definitions. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from cf_units import Unit +from iris import load_cube, save +import iris.coords +import iris.coord_systems + +import iris.tests.stock as stock + +from iris_grib.grib_phenom_translation import GRIBCode + + +class TestPDT11(tests.TestGribMessage): + def test_perturbation(self): + path = tests.get_data_path( + ("NetCDF", "global", "xyt", "SMALL_hires_wind_u_for_ipcc4.nc") + ) + cube = load_cube(path) + # trim to 1 time and regular lats + cube = cube[0, 12:144, :] + crs = iris.coord_systems.GeogCS(6371229) + cube.coord("latitude").coord_system = crs + cube.coord("longitude").coord_system = crs + # add a realization coordinate + cube.add_aux_coord( + iris.coords.DimCoord(points=1, standard_name="realization", units="1") + ) + with self.temp_filename("testPDT11.GRIB2") as temp_file_path: + iris.save(cube, temp_file_path) + + # Check that various aspects of the saved file are as expected. + expect_values = ( + (0, "editionNumber", 2), + (3, "gridDefinitionTemplateNumber", 0), + (4, "productDefinitionTemplateNumber", 11), + (4, "perturbationNumber", 1), + (4, "typeOfStatisticalProcessing", 0), + (4, "numberOfForecastsInEnsemble", 255), + ) + self.assertGribMessageContents(temp_file_path, expect_values) + + +class TestPDT40(tests.IrisTest): + def test_save_load(self): + cube = stock.lat_lon_cube() + cube.rename("atmosphere_mole_content_of_ozone") + cube.units = Unit("Dobson") + tcoord = iris.coords.DimCoord( + 23, "time", units=Unit("days since epoch", calendar="standard") + ) + fpcoord = iris.coords.DimCoord(24, "forecast_period", units=Unit("hours")) + cube.add_aux_coord(tcoord) + cube.add_aux_coord(fpcoord) + cube.attributes["WMO_constituent_type"] = 0 + cube.attributes["GRIB_PARAM"] = GRIBCode("GRIB2:d000c014n000") + + with self.temp_filename("test_grib_pdt40.grib2") as temp_file_path: + save(cube, temp_file_path) + loaded = load_cube(temp_file_path) + self.assertEqual(loaded.attributes, cube.attributes) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/integration/save_rules/__init__.py b/src/iris_grib/tests/integration/save_rules/__init__.py new file mode 100644 index 00000000..c1cf7ae8 --- /dev/null +++ b/src/iris_grib/tests/integration/save_rules/__init__.py @@ -0,0 +1,5 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for the :mod:`iris_grib._save_rules` package.""" diff --git a/src/iris_grib/tests/integration/save_rules/test_grib_save.py b/src/iris_grib/tests/integration/save_rules/test_grib_save.py new file mode 100644 index 00000000..1df156d6 --- /dev/null +++ b/src/iris_grib/tests/integration/save_rules/test_grib_save.py @@ -0,0 +1,292 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Tests for specific implementation aspects of the grib saver. +Ported here from 'iris.tests.integration.test_grib_save'. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else +import iris_grib.tests as tests + +import os +import datetime + +import cf_units +import numpy as np + +import iris +import iris.cube +import iris.coord_systems +import iris.coords +import iris.exceptions +import iris.util + +import eccodes +from iris_grib._grib2_convert import _MDI as MDI + + +class TestLoadSave(tests.TestGribMessage): + def setUp(self): + self.skip_keys = [] + + def test_latlon_forecast_plev(self): + source_grib = tests.get_data_path(("GRIB", "uk_t", "uk_t.grib2")) + cubes = iris.load(source_grib) + with self.temp_filename(suffix=".grib2") as temp_file_path: + iris.save(cubes, temp_file_path) + expect_diffs = { + "totalLength": (4837, 4832), + "productionStatusOfProcessedData": (0, 255), + "scaleFactorOfRadiusOfSphericalEarth": (MDI, 0), + "shapeOfTheEarth": (0, 1), + "scaledValueOfRadiusOfSphericalEarth": (MDI, 6367470), + "scaledValueOfEarthMajorAxis": (MDI, 0), + "scaleFactorOfEarthMajorAxis": (MDI, 0), + "scaledValueOfEarthMinorAxis": (MDI, 0), + "scaleFactorOfEarthMinorAxis": (MDI, 0), + "typeOfGeneratingProcess": (0, 255), + "generatingProcessIdentifier": (128, 255), + } + self.assertGribMessageDifference( + source_grib, + temp_file_path, + expect_diffs, + self.skip_keys, + skip_sections=[2], + ) + + def test_rotated_latlon(self): + source_grib = tests.get_data_path( + ("GRIB", "rotated_nae_t", "sensible_pole.grib2") + ) + cubes = iris.load(source_grib) + with self.temp_filename(suffix=".grib2") as temp_file_path: + iris.save(cubes, temp_file_path) + expect_diffs = { + "totalLength": (648196, 648191), + "productionStatusOfProcessedData": (0, 255), + "scaleFactorOfRadiusOfSphericalEarth": (MDI, 0), + "shapeOfTheEarth": (0, 1), + "scaledValueOfRadiusOfSphericalEarth": (MDI, 6367470), + "scaledValueOfEarthMajorAxis": (MDI, 0), + "scaleFactorOfEarthMajorAxis": (MDI, 0), + "scaledValueOfEarthMinorAxis": (MDI, 0), + "scaleFactorOfEarthMinorAxis": (MDI, 0), + "longitudeOfLastGridPoint": (392109982, 32106370), + "latitudeOfLastGridPoint": (19419996, 19419285), + "typeOfGeneratingProcess": (0, 255), + "generatingProcessIdentifier": (128, 255), + } + self.assertGribMessageDifference( + source_grib, + temp_file_path, + expect_diffs, + self.skip_keys, + skip_sections=[2], + ) + + def test_time_mean(self): + # This test for time-mean fields also tests negative forecast time. + source_grib = tests.get_data_path( + ("GRIB", "time_processed", "time_bound.grib2") + ) + cubes = iris.load(source_grib) + expect_diffs = { + "totalLength": (21232, 21227), + "productionStatusOfProcessedData": (0, 255), + "scaleFactorOfRadiusOfSphericalEarth": (MDI, 0), + "shapeOfTheEarth": (0, 1), + "scaledValueOfRadiusOfSphericalEarth": (MDI, 6367470), + "scaledValueOfEarthMajorAxis": (MDI, 0), + "scaleFactorOfEarthMajorAxis": (MDI, 0), + "scaledValueOfEarthMinorAxis": (MDI, 0), + "scaleFactorOfEarthMinorAxis": (MDI, 0), + "longitudeOfLastGridPoint": (356249908, 356249809), + "latitudeOfLastGridPoint": (-89999938, -89999944), + "typeOfGeneratingProcess": (0, 255), + "generatingProcessIdentifier": (128, 255), + "typeOfTimeIncrement": (2, 255), + } + self.skip_keys.append("stepType") + self.skip_keys.append("stepTypeInternal") + with self.temp_filename(suffix=".grib2") as temp_file_path: + iris.save(cubes, temp_file_path) + self.assertGribMessageDifference( + source_grib, + temp_file_path, + expect_diffs, + self.skip_keys, + skip_sections=[2], + ) + + +class TestCubeSave(tests.IrisGribTest): + # save fabricated cubes + + def _load_basic(self): + path = tests.get_data_path(("GRIB", "uk_t", "uk_t.grib2")) + return iris.load_cube(path) + + def test_params(self): + # TODO + pass + + def test_originating_centre(self): + # TODO + pass + + def test_irregular(self): + cube = self._load_basic() + lat_coord = cube.coord("latitude") + cube.remove_coord("latitude") + + new_lats = np.append(lat_coord.points[:-1], lat_coord.points[0]) # Irregular + cube.add_aux_coord( + iris.coords.AuxCoord( + new_lats, + "latitude", + units="degrees", + coord_system=lat_coord.coord_system, + ), + 0, + ) + + saved_grib = iris.util.create_temp_filename(suffix=".grib2") + self.assertRaises(iris.exceptions.TranslationError, iris.save, cube, saved_grib) + os.remove(saved_grib) + + def test_non_latlon(self): + cube = self._load_basic() + cube.coord(dimensions=[0]).coord_system = None + saved_grib = iris.util.create_temp_filename(suffix=".grib2") + self.assertRaises(iris.exceptions.TranslationError, iris.save, cube, saved_grib) + os.remove(saved_grib) + + def test_forecast_period(self): + # unhandled unit + cube = self._load_basic() + cube.coord("forecast_period").units = cf_units.Unit("years") + saved_grib = iris.util.create_temp_filename(suffix=".grib2") + self.assertRaises(iris.exceptions.TranslationError, iris.save, cube, saved_grib) + os.remove(saved_grib) + + def test_unhandled_vertical(self): + # unhandled level type + cube = self._load_basic() + # Adjust the 'pressure' coord to make it into an "unrecognised Z coord" + p_coord = cube.coord("pressure") + p_coord.rename("not the messiah") + p_coord.units = "K" + p_coord.attributes["positive"] = "up" + saved_grib = iris.util.create_temp_filename(suffix=".grib2") + with self.assertRaises(iris.exceptions.TranslationError): + iris.save(cube, saved_grib) + os.remove(saved_grib) + + def test_scalar_int32_pressure(self): + # Make sure we can save a scalar int32 coordinate with unit conversion. + cube = self._load_basic() + cube.coord("pressure").points = np.array([200], dtype=np.int32) + cube.coord("pressure").units = "hPa" + with self.temp_filename(".grib2") as testfile: + iris.save(cube, testfile) + + def test_bounded_level(self): + cube = iris.load_cube(tests.get_data_path(("GRIB", "uk_t", "uk_t.grib2"))) + with self.temp_filename(".grib2") as testfile: + iris.save(cube, testfile) + with open(testfile, "rb") as saved_file: + g = eccodes.codes_new_from_file(saved_file, eccodes.CODES_PRODUCT_GRIB) + self.assertEqual( + eccodes.codes_get_double(g, "scaledValueOfFirstFixedSurface"), + 0.0, + ) + self.assertEqual( + eccodes.codes_get_double(g, "scaledValueOfSecondFixedSurface"), + 2147483647.0, + ) + + +class TestHandmade(tests.IrisGribTest): + def _lat_lon_cube_no_time(self): + """ + Returns a cube with a latitude and longitude suitable for testing + saving to GRIB. + + """ + cube = iris.cube.Cube(np.arange(12, dtype=np.int32).reshape((3, 4))) + cs = iris.coord_systems.GeogCS(6371229) + cube.add_dim_coord( + iris.coords.DimCoord( + np.arange(4) * 90 + -180, + "longitude", + units="degrees", + coord_system=cs, + ), + 1, + ) + cube.add_dim_coord( + iris.coords.DimCoord( + np.arange(3) * 45 + -90, + "latitude", + units="degrees", + coord_system=cs, + ), + 0, + ) + + return cube + + def _cube_time_no_forecast(self): + cube = self._lat_lon_cube_no_time() + unit = cf_units.Unit("hours since epoch", calendar=cf_units.CALENDAR_GREGORIAN) + dt = datetime.datetime(2010, 12, 31, 12, 0) + cube.add_aux_coord( + iris.coords.AuxCoord( + np.array([unit.date2num(dt)], dtype=np.float64), + "time", + units=unit, + ) + ) + return cube + + def _cube_with_forecast(self): + cube = self._cube_time_no_forecast() + cube.add_aux_coord( + iris.coords.AuxCoord( + np.array([6], dtype=np.int32), "forecast_period", units="hours" + ) + ) + return cube + + def _cube_with_pressure(self): + cube = self._cube_with_forecast() + cube.add_aux_coord( + iris.coords.DimCoord(np.int32(10), "air_pressure", units="Pa") + ) + return cube + + def _cube_with_time_bounds(self): + cube = self._cube_with_pressure() + cube.coord("time").bounds = np.array([[0, 100]]) + return cube + + def test_no_time_cube(self): + cube = self._lat_lon_cube_no_time() + saved_grib = iris.util.create_temp_filename(suffix=".grib2") + self.assertRaises(iris.exceptions.TranslationError, iris.save, cube, saved_grib) + os.remove(saved_grib) + + def test_cube_with_time_bounds(self): + cube = self._cube_with_time_bounds() + saved_grib = iris.util.create_temp_filename(suffix=".grib2") + self.assertRaises(iris.exceptions.TranslationError, iris.save, cube, saved_grib) + os.remove(saved_grib) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/integration/save_rules/test_grid_definition_section.py b/src/iris_grib/tests/integration/save_rules/test_grid_definition_section.py new file mode 100644 index 00000000..0ca62403 --- /dev/null +++ b/src/iris_grib/tests/integration/save_rules/test_grid_definition_section.py @@ -0,0 +1,152 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Integration tests for :func:`iris_grib._save_rules.grid_definition_section` +to confirm that the correct grid_definition_template is being selected. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from iris.coord_systems import ( + GeogCS, + RotatedGeogCS, + Mercator, + TransverseMercator, + LambertConformal, + AlbersEqualArea, + LambertAzimuthalEqualArea, + Stereographic, + PolarStereographic, +) +import numpy as np + +from iris_grib._save_rules import grid_definition_section +from iris_grib.tests.unit.save_rules import GdtTestMixin + + +class Test(tests.IrisGribTest, GdtTestMixin): + def setUp(self): + GdtTestMixin.setUp(self) + self.ellipsoid = GeogCS(6371200) + + def test_grid_definition_template_0(self): + # Regular lat/lon (Plate Carree). + x_points = np.arange(3) + y_points = np.arange(3) + coord_units = "degrees" + cs = self.ellipsoid + test_cube = self._make_test_cube(cs, x_points, y_points, coord_units) + grid_definition_section(test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 0) + + def test_grid_definition_template_1(self): + # Rotated lat/lon (Plate Carree). + x_points = np.arange(3) + y_points = np.arange(3) + coord_units = "degrees" + cs = RotatedGeogCS(34.0, 117.0, ellipsoid=self.ellipsoid) + test_cube = self._make_test_cube(cs, x_points, y_points, coord_units) + grid_definition_section(test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 1) + + def test_grid_definition_template_4(self): + # Irregular (variable resolution) lat/lon grid. + x_points = np.array([0, 2, 7]) + y_points = np.array([1, 3, 6]) + coord_units = "1" + cs = self.ellipsoid + test_cube = self._make_test_cube(cs, x_points, y_points, coord_units) + grid_definition_section(test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 4) + + def test_grid_definition_template_5(self): + # Irregular (variable resolution) rotated lat/lon grid. + x_points = np.array([0, 2, 7]) + y_points = np.array([1, 3, 6]) + coord_units = "1" + cs = RotatedGeogCS(34.0, 117.0, ellipsoid=self.ellipsoid) + test_cube = self._make_test_cube(cs, x_points, y_points, coord_units) + grid_definition_section(test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 5) + + def test_grid_definition_template_10(self): + # Mercator grid. + x_points = np.arange(3) + y_points = np.arange(3) + coord_units = "m" + cs = Mercator(ellipsoid=self.ellipsoid) + test_cube = self._make_test_cube(cs, x_points, y_points, coord_units) + grid_definition_section(test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 10) + + def test_grid_definition_template_12(self): + # Transverse Mercator grid. + x_points = np.arange(3) + y_points = np.arange(3) + coord_units = "m" + cs = TransverseMercator(0, 0, 0, 0, 1, ellipsoid=self.ellipsoid) + test_cube = self._make_test_cube(cs, x_points, y_points, coord_units) + grid_definition_section(test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 12) + + def grid_definition_template_20_common(self, coord_system: type[Stereographic]): + # Stereographic grid. + # Common code to allow testing PolarStereographic and Stereographic. + if not issubclass(coord_system, Stereographic): + raise ValueError("coord_system must be Stereographic type.") + + x_points = np.arange(3) + y_points = np.arange(3) + coord_units = "m" + cs = coord_system(90.0, 0, ellipsoid=self.ellipsoid) + test_cube = self._make_test_cube(cs, x_points, y_points, coord_units) + grid_definition_section(test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 20) + + def test_grid_definition_template_20_s(self): + self.grid_definition_template_20_common(Stereographic) + + def test_grid_definition_template_20_ps(self): + self.grid_definition_template_20_common(PolarStereographic) + + def test_grid_definition_template_30(self): + # Lambert Conformal grid. + x_points = np.arange(3) + y_points = np.arange(3) + coord_units = "m" + cs = LambertConformal(ellipsoid=self.ellipsoid) + test_cube = self._make_test_cube(cs, x_points, y_points, coord_units) + grid_definition_section(test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 30) + + def test_grid_definition_template_140(self): + # Lambert Conformal grid. + x_points = np.arange(3) + y_points = np.arange(3) + coord_units = "m" + cs = LambertAzimuthalEqualArea(ellipsoid=self.ellipsoid) + test_cube = self._make_test_cube(cs, x_points, y_points, coord_units) + grid_definition_section(test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 140) + + def test_coord_system_not_supported(self): + # Test an unsupported grid - let's choose Albers Equal Area. + x_points = np.arange(3) + y_points = np.arange(3) + coord_units = "1" + cs = AlbersEqualArea(ellipsoid=self.ellipsoid) + test_cube = self._make_test_cube(cs, x_points, y_points, coord_units) + + exp_name = cs.grid_mapping_name.replace("_", " ").title() + exp_emsg = "not supported for coordinate system {!r}".format(exp_name) + with self.assertRaisesRegex(ValueError, exp_emsg): + grid_definition_section(test_cube, self.mock_grib) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/integration/save_rules/test_save_hybrid_coords.py b/src/iris_grib/tests/integration/save_rules/test_save_hybrid_coords.py new file mode 100644 index 00000000..a77c2f55 --- /dev/null +++ b/src/iris_grib/tests/integration/save_rules/test_save_hybrid_coords.py @@ -0,0 +1,122 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :func:`iris_grib._save_rules.data_section`. + +""" + +# import iris_grib.tests first so that some things can be initialised before +# importing anything else +import iris_grib.tests as tests + +import numpy as np + +import iris + +from iris_grib import save_pairs_from_cube, save_messages, GribMessage + + +@tests.skip_grib_data +class TestSaveHybridHeight(tests.IrisGribTest): + def setUp(self): + reference_data_filepath = self.get_testdata_path("hybrid_height.nc") + if hasattr(iris.FUTURE, "netcdf_promote") and not iris.FUTURE.netcdf_promote: + iris.FUTURE.netcdf_promote = True + data_cube = iris.load_cube(reference_data_filepath, "air_potential_temperature") + # Use only 3 (non-contiguous) levels, and a single timestep. + data_cube = data_cube[0, :6:2] + self.test_hh_data_cube = data_cube + + def test_save(self): + # Get save-pairs for the test data. + save_pairs = save_pairs_from_cube(self.test_hh_data_cube) + + # Check there are 3 of them (and nothing failed !) + save_pairs = list(save_pairs) + self.assertEqual(len(save_pairs), 3) + + # Get a list of just the messages. + msgs = [pair[1] for pair in save_pairs] + + # Save the messages to a temporary file. + with self.temp_filename() as temp_path: + save_messages(msgs, temp_path, append=True) + + # Read back as GribMessage-s. + msgs = list(GribMessage.messages_from_filename(temp_path)) + + # Check 3 messages were saved. + self.assertEqual(len(msgs), 3) + + # Check that the PV vector (same in all messages) is as expected. + # Note: gaps here are because we took model levels = (1, 3, 5). + self.assertArrayAllClose( + msgs[0].sections[4]["pv"], + [0, 5.0, 0, 45.0, 0, 111.667, 0, 0.999, 0, 0.995, 0, 0.987], + atol=0.0015, + ) + + # Check message #2-of-3 has the correctly encoded hybrid height. + msg = msgs[1] + # first surface type = 118 (i.e. hybrid height). + self.assertEqual(msg.sections[4]["typeOfFirstFixedSurface"], 118) + # first surface scaling = 0. + self.assertEqual(msg.sections[4]["scaleFactorOfFirstFixedSurface"], 0) + # first surface value = 3 -- i.e. #2 of (1, 3, 5). + self.assertEqual(msg.sections[4]["scaledValueOfFirstFixedSurface"], 3) + # second surface type = "NONE" -- i.e. unbounded level. + self.assertEqual(msg.sections[4]["typeOfSecondFixedSurface"], 255) + + +@tests.skip_grib_data +class TestSaveHybridPressure(tests.IrisGribTest): + def setUp(self): + reference_data_filepath = self.get_testdata_path("hybrid_pressure.nc") + data_cube = iris.load_cube(reference_data_filepath, "air_temperature") + self.test_hp_data_cube = data_cube + + def test_save(self): + # Get save-pairs for the test data. + save_pairs = save_pairs_from_cube(self.test_hp_data_cube) + + # Check there are 3 of them (and nothing failed !) + save_pairs = list(save_pairs) + self.assertEqual(len(save_pairs), 3) + + # Get a list of just the messages. + msgs = [pair[1] for pair in save_pairs] + + # Save the messages to a temporary file. + with self.temp_filename() as temp_path: + save_messages(msgs, temp_path, append=True) + + # Read back as GribMessage-s. + msgs = list(GribMessage.messages_from_filename(temp_path)) + + # Check 3 messages were saved. + self.assertEqual(len(msgs), 3) + + # Check that the PV vector (same in all messages) is as expected. + # Note: HUGE gaps here because we took model levels = (1, 51, 91). + self.assertEqual(msgs[0].sections[4]["NV"], 184) + pv_expected = np.zeros(184, dtype=np.float64) + pv_expected[[1, 51, 91]] = [0.0, 18191.03, 0.003] + pv_expected[[93, 143, 183]] = [0.0, 0.036, 0.998] + self.assertArrayAllClose(msgs[0].sections[4]["pv"], pv_expected, atol=0.001) + + # Check message #2-of-3 has the correctly encoded hybrid pressure. + msg = msgs[1] + # first surface type = 119 (i.e. hybrid pressure). + self.assertEqual(msg.sections[4]["typeOfFirstFixedSurface"], 119) + # first surface scaling = 0. + self.assertEqual(msg.sections[4]["scaleFactorOfFirstFixedSurface"], 0) + # first surface value = 3 -- i.e. #2 of (1, 3, 5). + self.assertEqual(msg.sections[4]["scaledValueOfFirstFixedSurface"], 51) + # second surface type = "NONE" -- i.e. unbounded level. + self.assertEqual(msg.sections[4]["typeOfSecondFixedSurface"], 255) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/results/integration/iris_integration/callback/TestCallbacks/load_callback.cml b/src/iris_grib/tests/results/integration/iris_integration/callback/TestCallbacks/load_callback.cml new file mode 100644 index 00000000..77275cf1 --- /dev/null +++ b/src/iris_grib/tests/results/integration/iris_integration/callback/TestCallbacks/load_callback.cml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/data_section/TestDRT3/grid_complex_spatial_differencing.cml b/src/iris_grib/tests/results/integration/load_convert/data_section/TestDRT3/grid_complex_spatial_differencing.cml new file mode 100644 index 00000000..e6191c14 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/data_section/TestDRT3/grid_complex_spatial_differencing.cml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/data_section/TestDRT3/grid_complex_spatial_differencing.data.0.json b/src/iris_grib/tests/results/integration/load_convert/data_section/TestDRT3/grid_complex_spatial_differencing.data.0.json new file mode 100644 index 00000000..f42d355d --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/data_section/TestDRT3/grid_complex_spatial_differencing.data.0.json @@ -0,0 +1 @@ +{"std": 7.798695691713748, "min": -34.43, "max": 33.009999999999998, "shape": [73, 144], "masked": true, "mean": 2.3147813807531383} \ No newline at end of file diff --git a/src/iris_grib/tests/results/integration/load_convert/data_section/TestDataProxy/data_representation__no_bitsPerValue.cml b/src/iris_grib/tests/results/integration/load_convert/data_section/TestDataProxy/data_representation__no_bitsPerValue.cml new file mode 100644 index 00000000..55eab1e1 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/data_section/TestDataProxy/data_representation__no_bitsPerValue.cml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT30/lambert.cml b/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT30/lambert.cml new file mode 100644 index 00000000..b0033869 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT30/lambert.cml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT30/lambert.data.0.json b/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT30/lambert.data.0.json new file mode 100644 index 00000000..99d58c5e --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT30/lambert.data.0.json @@ -0,0 +1 @@ +{"std": 5.3916288115779398, "min": 265.550048828125, "max": 300.862548828125, "shape": [799, 1199], "masked": false, "mean": 287.6306666328037} \ No newline at end of file diff --git a/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT40/reduced.cml b/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT40/reduced.cml new file mode 100644 index 00000000..661d6f31 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT40/reduced.cml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT40/reduced.data.0.json b/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT40/reduced.data.0.json new file mode 100644 index 00000000..4cd44b53 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT40/reduced.data.0.json @@ -0,0 +1 @@ +{"std": 6295.5250434859099, "min": -6419.0146484375, "max": 55403.9853515625, "shape": [13280], "masked": false, "mean": 2446.3044780685241} \ No newline at end of file diff --git a/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT40/regular.cml b/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT40/regular.cml new file mode 100644 index 00000000..94d6a732 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT40/regular.cml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT40/regular.data.0.json b/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT40/regular.data.0.json new file mode 100644 index 00000000..9595f90e --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/data_section/TestGDT40/regular.data.0.json @@ -0,0 +1 @@ +{"std": 290.49181302067751, "min": 4388.16162109375, "max": 5576.53662109375, "shape": [96, 192], "masked": false, "mean": 5210.564598931207} \ No newline at end of file diff --git a/src/iris_grib/tests/results/integration/load_convert/data_section/TestImport/gdt1.cml b/src/iris_grib/tests/results/integration/load_convert/data_section/TestImport/gdt1.cml new file mode 100644 index 00000000..b5d42292 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/data_section/TestImport/gdt1.cml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/data_section/TestImport/gdt1.data.0.json b/src/iris_grib/tests/results/integration/load_convert/data_section/TestImport/gdt1.data.0.json new file mode 100644 index 00000000..2b416f6a --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/data_section/TestImport/gdt1.data.0.json @@ -0,0 +1 @@ +{"std": 6.1629553729365423, "min": 266.625, "max": 302.25, "shape": [360, 600], "masked": false, "mean": 284.43164236111113} \ No newline at end of file diff --git a/src/iris_grib/tests/results/integration/load_convert/data_section/TestImport/gdt90_with_bitmap.cml b/src/iris_grib/tests/results/integration/load_convert/data_section/TestImport/gdt90_with_bitmap.cml new file mode 100644 index 00000000..c1a87056 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/data_section/TestImport/gdt90_with_bitmap.cml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/data_section/TestImport/gdt90_with_bitmap.data.0.json b/src/iris_grib/tests/results/integration/load_convert/data_section/TestImport/gdt90_with_bitmap.data.0.json new file mode 100644 index 00000000..08c3cb82 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/data_section/TestImport/gdt90_with_bitmap.data.0.json @@ -0,0 +1 @@ +{"std": 13.780125280995835, "min": 208.90541992187502, "max": 287.25541992187499, "shape": [227, 390], "masked": true, "mean": 266.3984425053925} \ No newline at end of file diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/3_layer.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/3_layer.cml new file mode 100644 index 00000000..d1ea768e --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/3_layer.cml @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/bulletin_40bytes.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/bulletin_40bytes.cml new file mode 100644 index 00000000..bd647be5 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/bulletin_40bytes.cml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/bulletin_41bytes.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/bulletin_41bytes.cml new file mode 100644 index 00000000..e16224ab --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/bulletin_41bytes.cml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_0.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_0.cml new file mode 100644 index 00000000..a8227f04 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_0.cml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_1.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_1.cml new file mode 100644 index 00000000..a8a5c0b4 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_1.cml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_2.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_2.cml new file mode 100644 index 00000000..881c8480 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_2.cml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_3.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_3.cml new file mode 100644 index 00000000..8e11eac3 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_3.cml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_4.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_4.cml new file mode 100644 index 00000000..1aaa91b1 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_4.cml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_5.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_5.cml new file mode 100644 index 00000000..07900456 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_5.cml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_6.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_6.cml new file mode 100644 index 00000000..294049a1 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_6.cml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_7.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_7.cml new file mode 100644 index 00000000..7da7934f --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_7.cml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_grib1.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_grib1.cml new file mode 100644 index 00000000..3ee70285 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/earth_shape_grib1.cml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/ineg_jneg.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/ineg_jneg.cml new file mode 100644 index 00000000..45fb86c7 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/ineg_jneg.cml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/ineg_jpos.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/ineg_jpos.cml new file mode 100644 index 00000000..3f02eeea --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/ineg_jpos.cml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/ipos_jneg.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/ipos_jneg.cml new file mode 100644 index 00000000..a8227f04 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/ipos_jneg.cml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/ipos_jpos.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/ipos_jpos.cml new file mode 100644 index 00000000..2e2015f3 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/ipos_jpos.cml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/lambert_grib1.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/lambert_grib1.cml new file mode 100644 index 00000000..7c0b4d05 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/lambert_grib1.cml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/lambert_grib2.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/lambert_grib2.cml new file mode 100644 index 00000000..45e18f80 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/lambert_grib2.cml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/mapped_cf_data_grib1.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/mapped_cf_data_grib1.cml new file mode 100644 index 00000000..c70edbe9 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/mapped_cf_data_grib1.cml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/missing_values_grib2.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/missing_values_grib2.cml new file mode 100644 index 00000000..41c8f46c --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/missing_values_grib2.cml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/polar_stereo_grib1.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/polar_stereo_grib1.cml new file mode 100644 index 00000000..27a002fb --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/polar_stereo_grib1.cml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/polar_stereo_south_grib1.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/polar_stereo_south_grib1.cml new file mode 100644 index 00000000..695c0e4e --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/polar_stereo_south_grib1.cml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/reduced_gg_grib1.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/reduced_gg_grib1.cml new file mode 100644 index 00000000..572e56d9 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/reduced_gg_grib1.cml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/reduced_gg_grib2.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/reduced_gg_grib2.cml new file mode 100644 index 00000000..b435f659 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/reduced_gg_grib2.cml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/reduced_ll_grib1.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/reduced_ll_grib1.cml new file mode 100644 index 00000000..65c5483f --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/reduced_ll_grib1.cml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/regular_gg_grib1.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/regular_gg_grib1.cml new file mode 100644 index 00000000..0fab6aa5 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/regular_gg_grib1.cml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/regular_gg_grib2.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/regular_gg_grib2.cml new file mode 100644 index 00000000..8cb2fdfe --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/regular_gg_grib2.cml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/rotated.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/rotated.cml new file mode 100644 index 00000000..f464429a --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/rotated.cml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/second_order_packing.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/second_order_packing.cml new file mode 100644 index 00000000..9d89d7c5 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/second_order_packing.cml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_bound_grib1.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_bound_grib1.cml new file mode 100644 index 00000000..977fe028 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_bound_grib1.cml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_bound_grib2.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_bound_grib2.cml new file mode 100644 index 00000000..a8227f04 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_bound_grib2.cml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_0.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_0.cml new file mode 100644 index 00000000..1a63cce1 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_0.cml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_1.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_1.cml new file mode 100644 index 00000000..1a63cce1 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_1.cml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_10.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_10.cml new file mode 100644 index 00000000..779c8b9e --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_10.cml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_113.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_113.cml new file mode 100644 index 00000000..96b259c8 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_113.cml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_118.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_118.cml new file mode 100644 index 00000000..41dd27dd --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_118.cml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_123.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_123.cml new file mode 100644 index 00000000..96b259c8 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_123.cml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_124.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_124.cml new file mode 100644 index 00000000..729cbed1 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_124.cml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_2.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_2.cml new file mode 100644 index 00000000..baf9ccd4 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_2.cml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_3.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_3.cml new file mode 100644 index 00000000..88be2d21 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_3.cml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_4.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_4.cml new file mode 100644 index 00000000..e6be9da0 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_4.cml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_5.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_5.cml new file mode 100644 index 00000000..857ce756 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/time_range_5.cml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/y_wind_grib1.cml b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/y_wind_grib1.cml new file mode 100644 index 00000000..c8eb5d60 --- /dev/null +++ b/src/iris_grib/tests/results/integration/load_convert/sample_file_loads/y_wind_grib1.cml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/name_grib/NAMEII/0_TRACER_AIR_CONCENTRATION.cml b/src/iris_grib/tests/results/integration/name_grib/NAMEII/0_TRACER_AIR_CONCENTRATION.cml new file mode 100644 index 00000000..54ad7c8b --- /dev/null +++ b/src/iris_grib/tests/results/integration/name_grib/NAMEII/0_TRACER_AIR_CONCENTRATION.cml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/name_grib/NAMEII/1_TRACER_DOSAGE.cml b/src/iris_grib/tests/results/integration/name_grib/NAMEII/1_TRACER_DOSAGE.cml new file mode 100644 index 00000000..4d2ab1e4 --- /dev/null +++ b/src/iris_grib/tests/results/integration/name_grib/NAMEII/1_TRACER_DOSAGE.cml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/name_grib/NAMEII/3_TRACER_DRY_DEPOSITION.cml b/src/iris_grib/tests/results/integration/name_grib/NAMEII/3_TRACER_DRY_DEPOSITION.cml new file mode 100644 index 00000000..f6621d97 --- /dev/null +++ b/src/iris_grib/tests/results/integration/name_grib/NAMEII/3_TRACER_DRY_DEPOSITION.cml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/name_grib/NAMEII/4_TRACER_TOTAL_DEPOSITION.cml b/src/iris_grib/tests/results/integration/name_grib/NAMEII/4_TRACER_TOTAL_DEPOSITION.cml new file mode 100644 index 00000000..f6621d97 --- /dev/null +++ b/src/iris_grib/tests/results/integration/name_grib/NAMEII/4_TRACER_TOTAL_DEPOSITION.cml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/name_grib/NAMEIII/0_TRACER_AIR_CONCENTRATION.cml b/src/iris_grib/tests/results/integration/name_grib/NAMEIII/0_TRACER_AIR_CONCENTRATION.cml new file mode 100644 index 00000000..c9771765 --- /dev/null +++ b/src/iris_grib/tests/results/integration/name_grib/NAMEIII/0_TRACER_AIR_CONCENTRATION.cml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/name_grib/NAMEIII/1_TRACER_AIR_CONCENTRATION.cml b/src/iris_grib/tests/results/integration/name_grib/NAMEIII/1_TRACER_AIR_CONCENTRATION.cml new file mode 100644 index 00000000..5ffe86fb --- /dev/null +++ b/src/iris_grib/tests/results/integration/name_grib/NAMEIII/1_TRACER_AIR_CONCENTRATION.cml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/name_grib/NAMEIII/2_TRACER_DRY_DEPOSITION.cml b/src/iris_grib/tests/results/integration/name_grib/NAMEIII/2_TRACER_DRY_DEPOSITION.cml new file mode 100644 index 00000000..f7d7b8ff --- /dev/null +++ b/src/iris_grib/tests/results/integration/name_grib/NAMEIII/2_TRACER_DRY_DEPOSITION.cml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/name_grib/NAMEIII/3_TRACER_WET_DEPOSITION.cml b/src/iris_grib/tests/results/integration/name_grib/NAMEIII/3_TRACER_WET_DEPOSITION.cml new file mode 100644 index 00000000..65c9a85f --- /dev/null +++ b/src/iris_grib/tests/results/integration/name_grib/NAMEIII/3_TRACER_WET_DEPOSITION.cml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/integration/name_grib/NAMEIII/4_TRACER_DEPOSITION.cml b/src/iris_grib/tests/results/integration/name_grib/NAMEIII/4_TRACER_DEPOSITION.cml new file mode 100644 index 00000000..193b0325 --- /dev/null +++ b/src/iris_grib/tests/results/integration/name_grib/NAMEIII/4_TRACER_DEPOSITION.cml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/results/unit/load_cubes/load_cubes/reduced_raw.cml b/src/iris_grib/tests/results/unit/load_cubes/load_cubes/reduced_raw.cml new file mode 100644 index 00000000..4825995c --- /dev/null +++ b/src/iris_grib/tests/results/unit/load_cubes/load_cubes/reduced_raw.cml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/iris_grib/tests/test_license_headers.py b/src/iris_grib/tests/test_license_headers.py new file mode 100644 index 00000000..61c086c1 --- /dev/null +++ b/src/iris_grib/tests/test_license_headers.py @@ -0,0 +1,127 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for iris-grib license header conformance. + +""" + +from datetime import datetime +from fnmatch import fnmatch +import os +import subprocess +import unittest + +import iris_grib + + +LICENSE_TEMPLATE = """# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details.""" + +# Guess iris-grib repo directory of Iris-grib - realpath is used to mitigate +# against Python finding the iris-grib package via a symlink. +IRIS_GRIB_DIR = os.path.realpath(os.path.dirname(iris_grib.__file__)) +REPO_DIR = os.path.dirname(IRIS_GRIB_DIR) + + +class TestLicenseHeaders(unittest.TestCase): + @staticmethod + def whatchanged_parse(whatchanged_output): + """ + Returns a generator of tuples of data parsed from + "git whatchanged --pretty='TIME:%at". The tuples are of the form + ``(filename, last_commit_datetime)`` + + Sample input:: + + [ + "TIME:1366884020", + "", + ":000000 100644 0000000... 5862ced... A\tlib/iris/cube.py", + ] + + """ + dt = None + for line in whatchanged_output: + if not line.strip(): + continue + elif line.startswith("TIME:"): + dt = datetime.fromtimestamp(int(line[5:])) + else: + # Non blank, non date, line -> must be the lines + # containing the file info. + fname = " ".join(line.split("\t")[1:]) + yield fname, dt + + @staticmethod + def last_change_by_fname(): + """ + Return a dictionary of all the files under git which maps to + the datetime of their last modification in the git history. + + .. note:: + + This function raises a ValueError if the repo root does + not have a ".git" folder. If git is not installed on the system, + or cannot be found by subprocess, an IOError may also be raised. + + """ + # Check the ".git" folder exists at the repo dir. + if not os.path.isdir(os.path.join(REPO_DIR, ".git")): + raise ValueError("{} is not a git repository.".format(REPO_DIR)) + + # Call "git whatchanged" to get the details of all the files and when + # they were last changed. + output = subprocess.check_output( + ["git", "whatchanged", "--pretty=TIME:%ct"], cwd=REPO_DIR + ) + output = output.decode().split("\n") + res = {} + for fname, dt in TestLicenseHeaders.whatchanged_parse(output): + if fname not in res or dt > res[fname]: + res[fname] = dt + + return res + + def test_license_headers(self): + exclude_patterns = ( + "setup.py", + "build/*", + "dist/*", + "docs/*", + "iris_grib/tests/unit/results/*", + "iris_grib.egg-info/*", + ) + + try: + last_change_by_fname = self.last_change_by_fname() + except ValueError: + # Caught the case where this is not a git repo. + return self.skipTest("Iris-grib installation did not look like a git repo.") + + failed = False + for fname, last_change in sorted(last_change_by_fname.items()): + full_fname = os.path.join(REPO_DIR, fname) + if ( + full_fname.endswith(".py") + and os.path.isfile(full_fname) + and not any(fnmatch(fname, pat) for pat in exclude_patterns) + ): + with open(full_fname) as fh: + content = fh.read() + if not content.startswith(LICENSE_TEMPLATE): + print( + "The file {} does not start with the required " + "license header.".format(fname) + ) + failed = True + + if failed: + raise ValueError("There were license header failures. See stdout.") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/iris_grib/tests/testdata/CAT_T+24_0600.grib2 b/src/iris_grib/tests/testdata/CAT_T+24_0600.grib2 new file mode 100644 index 00000000..2a1f5b77 Binary files /dev/null and b/src/iris_grib/tests/testdata/CAT_T+24_0600.grib2 differ diff --git a/src/iris_grib/tests/testdata/CB_T+24_0600.grib2 b/src/iris_grib/tests/testdata/CB_T+24_0600.grib2 new file mode 100644 index 00000000..d24f9097 Binary files /dev/null and b/src/iris_grib/tests/testdata/CB_T+24_0600.grib2 differ diff --git a/src/iris_grib/tests/testdata/ICING_T+24_0600.grib2 b/src/iris_grib/tests/testdata/ICING_T+24_0600.grib2 new file mode 100644 index 00000000..2196d394 Binary files /dev/null and b/src/iris_grib/tests/testdata/ICING_T+24_0600.grib2 differ diff --git a/src/iris_grib/tests/testdata/INCLDTURB_T+24_0600.grib2 b/src/iris_grib/tests/testdata/INCLDTURB_T+24_0600.grib2 new file mode 100644 index 00000000..c77f7098 Binary files /dev/null and b/src/iris_grib/tests/testdata/INCLDTURB_T+24_0600.grib2 differ diff --git a/src/iris_grib/tests/testdata/faked_sample_hh_grib_data.grib2 b/src/iris_grib/tests/testdata/faked_sample_hh_grib_data.grib2 new file mode 100644 index 00000000..4ad27f61 Binary files /dev/null and b/src/iris_grib/tests/testdata/faked_sample_hh_grib_data.grib2 differ diff --git a/src/iris_grib/tests/testdata/faked_sample_hp_grib_data.grib2 b/src/iris_grib/tests/testdata/faked_sample_hp_grib_data.grib2 new file mode 100644 index 00000000..730a977b Binary files /dev/null and b/src/iris_grib/tests/testdata/faked_sample_hp_grib_data.grib2 differ diff --git a/src/iris_grib/tests/testdata/hybrid_height.nc b/src/iris_grib/tests/testdata/hybrid_height.nc new file mode 100644 index 00000000..ecdd85db Binary files /dev/null and b/src/iris_grib/tests/testdata/hybrid_height.nc differ diff --git a/src/iris_grib/tests/testdata/hybrid_pressure.nc b/src/iris_grib/tests/testdata/hybrid_pressure.nc new file mode 100644 index 00000000..4e7f787a Binary files /dev/null and b/src/iris_grib/tests/testdata/hybrid_pressure.nc differ diff --git a/iris_grib/tests/unit/__init__.py b/src/iris_grib/tests/unit/__init__.py similarity index 50% rename from iris_grib/tests/unit/__init__.py rename to src/iris_grib/tests/unit/__init__.py index 524295a3..561b5f17 100644 --- a/iris_grib/tests/unit/__init__.py +++ b/src/iris_grib/tests/unit/__init__.py @@ -1,31 +1,16 @@ -# (C) British Crown Copyright 2013 - 2016, Met Office +# Copyright iris-grib contributors # -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. """Unit tests for the :mod:`iris_grib` package.""" -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - # import iris_grib.tests first so that some things can be initialised # before importing anything else. import iris_grib.tests as tests -import gribapi -import mock +import eccodes import numpy as np +from unittest import mock import iris @@ -39,9 +24,9 @@ def _make_test_message(sections): return GribMessage(raw_message, recreate_raw) -def _mock_gribapi_fetch(message, key): +def _mock_eccodes_fetch(message, key): """ - Fake the gribapi key-fetch. + Fake the ecCodes key-fetch. Fetch key-value from the fake message (dictionary). If the key is not present, raise the diagnostic exception. @@ -50,22 +35,22 @@ def _mock_gribapi_fetch(message, key): if key in message: return message[key] else: - raise _mock_gribapi.GribInternalError + raise _mock_eccodes.CodesInternalError -def _mock_gribapi__grib_is_missing(grib_message, keyname): +def _mock_eccodes__codes_is_missing(grib_message, keyname): """ - Fake the gribapi key-existence enquiry. + Fake the ecCodes key-existence enquiry. Return whether the key exists in the fake message (dictionary). """ - return (keyname not in grib_message) + return keyname not in grib_message -def _mock_gribapi__grib_get_native_type(grib_message, keyname): +def _mock_eccodes__codes_get_native_type(grib_message, keyname): """ - Fake the gribapi type-discovery operation. + Fake the ecCodes type-discovery operation. Return type of key-value in the fake message (dictionary). If the key is not present, raise the diagnostic exception. @@ -73,22 +58,21 @@ def _mock_gribapi__grib_get_native_type(grib_message, keyname): """ if keyname in grib_message: return type(grib_message[keyname]) - raise _mock_gribapi.GribInternalError(keyname) + raise _mock_eccodes.CodesInternalError(keyname) -# Construct a mock object to mimic the gribapi for GribWrapper testing. -_mock_gribapi = mock.Mock(spec=gribapi) -_mock_gribapi.GribInternalError = Exception +# Construct a mock object to mimic the eccodes for GribWrapper testing. +_mock_eccodes = mock.Mock(spec=eccodes) +_mock_eccodes.CodesInternalError = Exception -_mock_gribapi.grib_get_long = mock.Mock(side_effect=_mock_gribapi_fetch) -_mock_gribapi.grib_get_string = mock.Mock(side_effect=_mock_gribapi_fetch) -_mock_gribapi.grib_get_double = mock.Mock(side_effect=_mock_gribapi_fetch) -_mock_gribapi.grib_get_double_array = mock.Mock( - side_effect=_mock_gribapi_fetch) -_mock_gribapi.grib_is_missing = mock.Mock( - side_effect=_mock_gribapi__grib_is_missing) -_mock_gribapi.grib_get_native_type = mock.Mock( - side_effect=_mock_gribapi__grib_get_native_type) +_mock_eccodes.codes_get_long = mock.Mock(side_effect=_mock_eccodes_fetch) +_mock_eccodes.codes_get_string = mock.Mock(side_effect=_mock_eccodes_fetch) +_mock_eccodes.codes_get_double = mock.Mock(side_effect=_mock_eccodes_fetch) +_mock_eccodes.codes_get_double_array = mock.Mock(side_effect=_mock_eccodes_fetch) +_mock_eccodes.codes_is_missing = mock.Mock(side_effect=_mock_eccodes__codes_is_missing) +_mock_eccodes.codes_get_native_type = mock.Mock( + side_effect=_mock_eccodes__codes_get_native_type +) class FakeGribMessage(dict): @@ -98,6 +82,7 @@ class FakeGribMessage(dict): Behaves as a dictionary, containing key-values for message keys. """ + def __init__(self, **kwargs): """ Create a fake message object. @@ -109,7 +94,7 @@ def __init__(self, **kwargs): # Start with a bare dictionary dict.__init__(self) # Extract specially-recognised keys. - time_code = kwargs.pop('time_code', None) + time_code = kwargs.pop("time_code", None) # Set the minimally required keys. self._init_minimal_message() # Also set a time-code, if given. @@ -120,58 +105,71 @@ def __init__(self, **kwargs): def _init_minimal_message(self): # Set values for all the required keys. - self.update({ - 'edition': 1, - 'Ni': 1, - 'Nj': 1, - 'numberOfValues': 1, - 'alternativeRowScanning': 0, - 'centre': 'ecmf', - 'year': 2007, - 'month': 3, - 'day': 23, - 'hour': 12, - 'minute': 0, - 'indicatorOfUnitOfTimeRange': 1, - 'shapeOfTheEarth': 6, - 'gridType': 'rotated_ll', - 'angleOfRotation': 0.0, - 'iDirectionIncrementInDegrees': 0.036, - 'jDirectionIncrementInDegrees': 0.036, - 'iScansNegatively': 0, - 'jScansPositively': 1, - 'longitudeOfFirstGridPointInDegrees': -5.70, - 'latitudeOfFirstGridPointInDegrees': -4.452, - 'jPointsAreConsecutive': 0, - 'values': np.array([[1.0]]), - 'indicatorOfParameter': 9999, - 'parameterNumber': 9999, - 'startStep': 24, - 'timeRangeIndicator': 1, - 'P1': 2, 'P2': 0, - # time unit - needed AS WELL as 'indicatorOfUnitOfTimeRange' - 'unitOfTime': 1, - 'table2Version': 9999, - }) + self.update( + { + "edition": 1, + "Ni": 1, + "Nj": 1, + "numberOfValues": 1, + "alternativeRowScanning": 0, + "centre": "ecmf", + "year": 2007, + "month": 3, + "day": 23, + "hour": 12, + "minute": 0, + "indicatorOfUnitOfTimeRange": 1, + "gridType": "rotated_ll", + "angleOfRotation": 0.0, + "resolutionAndComponentFlags": 128, + "iDirectionIncrementInDegrees": 0.036, + "jDirectionIncrementInDegrees": 0.036, + "iScansNegatively": 0, + "jScansPositively": 1, + "longitudeOfFirstGridPointInDegrees": -5.70, + "latitudeOfFirstGridPointInDegrees": -4.452, + "jPointsAreConsecutive": 0, + "values": np.array([[1.0]]), + "indicatorOfParameter": 9999, + "parameterNumber": 9999, + "startStep": 24, + "timeRangeIndicator": 1, + "P1": 2, + "P2": 0, + # time unit - needed AS WELL as 'indicatorOfUnitOfTimeRange' + "unitOfTime": 1, + "table2Version": 9999, + } + ) def set_timeunit_code(self, timecode): - self['indicatorOfUnitOfTimeRange'] = timecode + self["indicatorOfUnitOfTimeRange"] = timecode # for some odd reason, GRIB1 code uses *both* of these # NOTE kludge -- the 2 keys are really the same thing - self['unitOfTime'] = timecode + self["unitOfTime"] = timecode class TestField(tests.IrisGribTest): - def _test_for_coord(self, field, convert, coord_predicate, expected_points, - expected_bounds): - (factories, references, standard_name, long_name, units, - attributes, cell_methods, dim_coords_and_dims, - aux_coords_and_dims) = convert(field) + def _test_for_coord( + self, field, convert, coord_predicate, expected_points, expected_bounds + ): + ( + _factories, + _references, + _standard_name, + _long_name, + _units, + _attributes, + _cell_methods, + dim_coords_and_dims, + aux_coords_and_dims, + ) = convert(field) # Check for one and only one matching coordinate. coords_and_dims = dim_coords_and_dims + aux_coords_and_dims - matching_coords = [coord for coord, _ in coords_and_dims if - coord_predicate(coord)] + matching_coords = [ + coord for coord, _ in coords_and_dims if coord_predicate(coord) + ] self.assertEqual(len(matching_coords), 1, str(matching_coords)) coord = matching_coords[0] @@ -184,8 +182,9 @@ def _test_for_coord(self, field, convert, coord_predicate, expected_points, else: self.assertArrayEqual(coord.bounds, expected_bounds) - def assertCoordsAndDimsListsMatch(self, coords_and_dims_got, - coords_and_dims_expected): + def assertCoordsAndDimsListsMatch( + self, coords_and_dims_got, coords_and_dims_expected + ): """ Check that coords_and_dims lists are equivalent. @@ -195,17 +194,18 @@ def assertCoordsAndDimsListsMatch(self, coords_and_dims_got, It also checks that the coordinate types (DimCoord/AuxCoord) match. """ + def sorted_by_coordname(list): return sorted(list, key=lambda item: item[0].name()) coords_and_dims_got = sorted_by_coordname(coords_and_dims_got) - coords_and_dims_expected = sorted_by_coordname( - coords_and_dims_expected) + coords_and_dims_expected = sorted_by_coordname(coords_and_dims_expected) self.assertEqual(coords_and_dims_got, coords_and_dims_expected) # Also check coordinate type equivalences (as Coord.__eq__ does not). self.assertEqual( [type(coord) for coord, dims in coords_and_dims_got], - [type(coord) for coord, dims in coords_and_dims_expected]) + [type(coord) for coord, dims in coords_and_dims_expected], + ) class TestGribSimple(tests.IrisGribTest): @@ -226,9 +226,10 @@ def mock_grib(self): def cube_from_message(self, grib): # Parameter translation now uses the GribWrapper, so we must convert # the Mock-based fake message to a FakeGribMessage. - with mock.patch('iris_grib.gribapi', _mock_gribapi): - grib_message = FakeGribMessage(**grib.__dict__) - wrapped_msg = iris_grib.GribWrapper(grib_message) - cube, _, _ = iris.fileformats.rules._make_cube( - wrapped_msg, iris_grib.load_rules.grib1_convert) + with mock.patch("iris_grib.eccodes", _mock_eccodes): + grib_message = FakeGribMessage(**grib.__dict__) + wrapped_msg = iris_grib.GribWrapper(grib_message) + cube, _, _ = iris.fileformats.rules._make_cube( + wrapped_msg, iris_grib._grib1_load_rules.grib1_convert + ) return cube diff --git a/src/iris_grib/tests/unit/grib1_load_rules/__init__.py b/src/iris_grib/tests/unit/grib1_load_rules/__init__.py new file mode 100644 index 00000000..6cefe241 --- /dev/null +++ b/src/iris_grib/tests/unit/grib1_load_rules/__init__.py @@ -0,0 +1,5 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for the :mod:`iris_grib._grib1_load_rules` module.""" diff --git a/src/iris_grib/tests/unit/grib1_load_rules/test_grib1_convert.py b/src/iris_grib/tests/unit/grib1_load_rules/test_grib1_convert.py new file mode 100644 index 00000000..e8f2c2f9 --- /dev/null +++ b/src/iris_grib/tests/unit/grib1_load_rules/test_grib1_convert.py @@ -0,0 +1,179 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for :func:`iris_grib._grib1_load_rules.grib1_convert`.""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else +import iris_grib.tests as tests + +import eccodes +from unittest import mock + +from iris.aux_factory import HybridPressureFactory +from iris.exceptions import TranslationError +from iris.fileformats.rules import Reference + +from iris_grib._grib1_legacy.grib_wrapper import GribWrapper +from iris_grib._grib1_legacy.grib1_load_rules import grib1_convert +from iris_grib.tests.unit import TestField + + +class TestBadEdition(tests.IrisGribTest): + def test(self): + message = mock.Mock(edition=2) + emsg = "GRIB edition 2 is not supported" + with self.assertRaisesRegex(TranslationError, emsg): + grib1_convert(message) + + +class TestBoundedTime(TestField): + @staticmethod + def is_forecast_period(coord): + return coord.standard_name == "forecast_period" and coord.units == "hours" + + @staticmethod + def is_time(coord): + return coord.standard_name == "time" and coord.units == "hours since epoch" + + def assert_bounded_message(self, **kwargs): + attributes = { + "productDefinitionTemplateNumber": 0, + "edition": 1, + "_forecastTime": 15, + "_forecastTimeUnit": "hours", + "phenomenon_bounds": lambda u: (80, 120), + "_phenomenonDateTime": -1, + "table2Version": 9999, + "_originatingCentre": "xxx", + } + attributes.update(kwargs) + message = mock.Mock(**attributes) + self._test_for_coord( + message, + grib1_convert, + self.is_forecast_period, + expected_points=[35], + expected_bounds=[[15, 55]], + ) + self._test_for_coord( + message, + grib1_convert, + self.is_time, + expected_points=[100], + expected_bounds=[[80, 120]], + ) + + def assert_bounded_message_3hours(self, **kwargs): + attributes = { + "productDefinitionTemplateNumber": 0, + "edition": 1, + "_forecastTime": 252, + "_forecastTimeUnit": "3 hours", + "phenomenon_bounds": lambda u: (252, 258), + "_phenomenonDateTime": -1, + "table2Version": 9999, + "_originatingCentre": "xxx", + } + attributes.update(kwargs) + message = mock.Mock(**attributes) + self._test_for_coord( + message, + grib1_convert, + self.is_forecast_period, + expected_points=[255], + expected_bounds=[[252, 258]], + ) + self._test_for_coord( + message, + grib1_convert, + self.is_time, + expected_points=[255], + expected_bounds=[[252, 258]], + ) + + def test_time_range_indicator_2(self): + self.assert_bounded_message(timeRangeIndicator=2) + self.assert_bounded_message_3hours(timeRangeIndicator=2) + + def test_time_range_indicator_3(self): + self.assert_bounded_message(timeRangeIndicator=3) + self.assert_bounded_message_3hours(timeRangeIndicator=3) + + def test_time_range_indicator_4(self): + self.assert_bounded_message(timeRangeIndicator=4) + self.assert_bounded_message_3hours(timeRangeIndicator=4) + + def test_time_range_indicator_5(self): + self.assert_bounded_message(timeRangeIndicator=5) + self.assert_bounded_message_3hours(timeRangeIndicator=5) + + def test_time_range_indicator_51(self): + self.assert_bounded_message(timeRangeIndicator=51) + self.assert_bounded_message_3hours(timeRangeIndicator=51) + + def test_time_range_indicator_113(self): + self.assert_bounded_message(timeRangeIndicator=113) + self.assert_bounded_message_3hours(timeRangeIndicator=113) + + def test_time_range_indicator_114(self): + self.assert_bounded_message(timeRangeIndicator=114) + self.assert_bounded_message_3hours(timeRangeIndicator=114) + + def test_time_range_indicator_115(self): + self.assert_bounded_message(timeRangeIndicator=115) + self.assert_bounded_message_3hours(timeRangeIndicator=115) + + def test_time_range_indicator_116(self): + self.assert_bounded_message(timeRangeIndicator=116) + self.assert_bounded_message_3hours(timeRangeIndicator=116) + + def test_time_range_indicator_117(self): + self.assert_bounded_message(timeRangeIndicator=117) + self.assert_bounded_message_3hours(timeRangeIndicator=117) + + def test_time_range_indicator_118(self): + self.assert_bounded_message(timeRangeIndicator=118) + self.assert_bounded_message_3hours(timeRangeIndicator=118) + + def test_time_range_indicator_123(self): + self.assert_bounded_message(timeRangeIndicator=123) + self.assert_bounded_message_3hours(timeRangeIndicator=123) + + def test_time_range_indicator_124(self): + self.assert_bounded_message(timeRangeIndicator=124) + self.assert_bounded_message_3hours(timeRangeIndicator=124) + + def test_time_range_indicator_125(self): + self.assert_bounded_message(timeRangeIndicator=125) + self.assert_bounded_message_3hours(timeRangeIndicator=125) + + +class Test_GribLevels(tests.IrisTest): + def test_grib1_hybrid_height(self): + gm = eccodes.codes_grib_new_from_samples("regular_gg_ml_grib1") + gw = GribWrapper(gm) + results = grib1_convert(gw) + + (factory,) = results[0] + self.assertEqual(factory.factory_class, HybridPressureFactory) + delta, sigma, ref = factory.args + self.assertEqual(delta, {"long_name": "level_pressure"}) + self.assertEqual(sigma, {"long_name": "sigma"}) + self.assertEqual(ref, Reference(name="surface_pressure")) + + coords_and_dims = results[8] + (coord,) = [ + co for co, _ in coords_and_dims if co.name() == "model_level_number" + ] + self.assertEqual(coord.units, "1") + self.assertEqual(coord.attributes["positive"], "up") + (coord,) = [co for co, _ in coords_and_dims if co.name() == "level_pressure"] + self.assertEqual(coord.units, "Pa") + (coord,) = [co for co, _ in coords_and_dims if co.name() == "sigma"] + self.assertEqual(coord.units, "1") + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib1_load_rules/test_grib1_load_translations.py b/src/iris_grib/tests/unit/grib1_load_rules/test_grib1_load_translations.py new file mode 100644 index 00000000..35ad9548 --- /dev/null +++ b/src/iris_grib/tests/unit/grib1_load_rules/test_grib1_load_translations.py @@ -0,0 +1,376 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Tests for specific implementation aspects of the grib loaders. +Old, and GRIB-1 specific. +Ported here from 'iris.tests.test_grib_load_translations'. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else +import iris_grib.tests as tests + +import datetime +from unittest import mock + +import cf_units +import numpy as np + +import iris +import iris.exceptions + +import eccodes +import iris.fileformats +import iris_grib + +from iris_grib._grib1_legacy.grib_wrapper import GribWrapper + + +def _mock_eccodes_fetch(message, key): + """ + Fake the ecCodes key-fetch. + + Fetch key-value from the fake message (dictionary). + If the key is not present, raise the diagnostic exception. + + """ + if key in message: + return message[key] + else: + raise _mock_eccodes.CodesInternalError + + +def _mock_eccodes__codes_is_missing(grib_message, keyname): + """ + Fake the ecCodes key-existence enquiry. + + Return whether the key exists in the fake message (dictionary). + + """ + return keyname not in grib_message + + +def _mock_eccodes__codes_get_native_type(grib_message, keyname): + """ + Fake the ecCodes type-discovery operation. + + Return type of key-value in the fake message (dictionary). + If the key is not present, raise the diagnostic exception. + + """ + if keyname in grib_message: + return type(grib_message[keyname]) + raise _mock_eccodes.CodesInternalError(keyname) + + +# Construct a mock object to mimic the ecCodes for GribWrapper testing. +_mock_eccodes = mock.Mock(spec=eccodes) +_mock_eccodes.CodesInternalError = Exception + +_mock_eccodes.codes_get_long = mock.Mock(side_effect=_mock_eccodes_fetch) +_mock_eccodes.codes_get_string = mock.Mock(side_effect=_mock_eccodes_fetch) +_mock_eccodes.codes_get_double = mock.Mock(side_effect=_mock_eccodes_fetch) +_mock_eccodes.codes_get_double_array = mock.Mock(side_effect=_mock_eccodes_fetch) +_mock_eccodes.codes_is_missing = mock.Mock(side_effect=_mock_eccodes__codes_is_missing) +_mock_eccodes.codes_get_native_type = mock.Mock( + side_effect=_mock_eccodes__codes_get_native_type +) + +# define seconds in an hour, for general test usage +_hour_secs = 3600.0 + + +class FakeGribMessage(dict): + """ + A 'fake grib message' object, for testing GribWrapper construction. + + Behaves as a dictionary, containing key-values for message keys. + + """ + + def __init__(self, **kwargs): + """ + Create a fake message object. + + General keys can be set/add as required via **kwargs. + The keys 'edition' and 'time_code' are specially managed. + + """ + # Start with a bare dictionary + dict.__init__(self) + # Extract specially-recognised keys. + edition = kwargs.pop("edition", 1) + # This testing is only for old-style Grib-1 code. + assert edition == 1 + time_code = kwargs.pop("time_code", None) + # Set the minimally required keys. + self._init_minimal_message(edition=edition) + # Also set a time-code, if given. + if time_code is not None: + self.set_timeunit_code(time_code) + # Finally, add any remaining passed key-values. + self.update(**kwargs) + + def _init_minimal_message(self, edition=1): + # Set values for all the required keys. + # 'edition' controls the edition-specific keys. + self.update( + { + "Ni": 1, + "Nj": 1, + "numberOfValues": 1, + "alternativeRowScanning": 0, + "centre": 74, # the UKMO centre id + "year": 2007, + "month": 3, + "day": 23, + "hour": 12, + "minute": 0, + "indicatorOfUnitOfTimeRange": 1, + "gridType": "rotated_ll", + "angleOfRotation": 0.0, + "resolutionAndComponentFlags": 128, + "iDirectionIncrementInDegrees": 0.036, + "jDirectionIncrementInDegrees": 0.036, + "iScansNegatively": 0, + "jScansPositively": 1, + "longitudeOfFirstGridPointInDegrees": -5.70, + "latitudeOfFirstGridPointInDegrees": -4.452, + "jPointsAreConsecutive": 0, + "values": np.array([[1.0]]), + "indicatorOfParameter": 9999, + "parameterNumber": 9999, + "startStep": 24, + "timeRangeIndicator": 1, + "P1": 2, + "P2": 0, + # time unit - needed AS WELL as 'indicatorOfUnitOfTimeRange' + "unitOfTime": 1, + "table2Version": 9999, + } + ) + # Add edition-dependent settings. + self["edition"] = edition + + def set_timeunit_code(self, timecode): + # Do timecode setting (somewhat edition-dependent). + self["indicatorOfUnitOfTimeRange"] = timecode + # for some odd reason, GRIB1 code uses *both* of these + # NOTE kludge -- the 2 keys are really the same thing + self["unitOfTime"] = timecode + + +class TestGribTimecodes(tests.IrisTest): + def _run_timetests(self, test_set): + # Check the unit-handling for given units-codes and editions. + + # Operates on lists of cases for various time-units and grib-editions. + # Format: (edition, code, expected-exception, + # equivalent-seconds, description-string) + with mock.patch("iris_grib._grib1_legacy.grib_wrapper.eccodes", _mock_eccodes): + for test_controls in test_set: + ( + grib_edition, + timeunit_codenum, + expected_error, + timeunit_secs, + timeunit_str, + ) = test_controls + + # Construct a suitable fake test message. + message = FakeGribMessage( + edition=grib_edition, time_code=timeunit_codenum + ) + + if expected_error: + # Expect GribWrapper construction to fail. + with self.assertRaises(type(expected_error)) as ar_context: + _ = GribWrapper(message) + self.assertEqual(ar_context.exception.args, expected_error.args) + continue + + # 'ELSE'... + # Expect the wrapper construction to work. + # Make a GribWrapper object and test it. + wrapped_msg = GribWrapper(message) + + # Check the units string. + forecast_timeunit = wrapped_msg._forecastTimeUnit + self.assertEqual( + forecast_timeunit, + timeunit_str, + "Bad unit string for edition={ed:01d}, " + "unitcode={code:01d} : " + 'expected="{wanted}" GOT="{got}"'.format( + ed=grib_edition, + code=timeunit_codenum, + wanted=timeunit_str, + got=forecast_timeunit, + ), + ) + + # Check the data-starttime calculation. + interval_start_to_end = ( + wrapped_msg._phenomenonDateTime - wrapped_msg._referenceDateTime + ) + if grib_edition == 1: + interval_from_units = wrapped_msg.P1 + else: + interval_from_units = wrapped_msg.forecastTime + interval_from_units *= datetime.timedelta(0, timeunit_secs) + self.assertEqual( + interval_start_to_end, + interval_from_units, + "Inconsistent start time offset for edition={ed:01d}, " + "unitcode={code:01d} : " + 'from-unit="{unit_str}" ' + 'from-phenom-minus-ref="{e2e_str}"'.format( + ed=grib_edition, + code=timeunit_codenum, + unit_str=interval_from_units, + e2e_str=interval_start_to_end, + ), + ) + + # Test groups of testcases for various time-units and grib-editions. + # Format: (edition, code, expected-exception, + # equivalent-seconds, description-string) + def test_timeunits_common(self): + tests = ( + (1, 0, None, 60.0, "minutes"), + (1, 1, None, _hour_secs, "hours"), + (1, 2, None, 24.0 * _hour_secs, "days"), + (1, 10, None, 3.0 * _hour_secs, "3 hours"), + (1, 11, None, 6.0 * _hour_secs, "6 hours"), + (1, 12, None, 12.0 * _hour_secs, "12 hours"), + ) + TestGribTimecodes._run_timetests(self, tests) + + @staticmethod + def _err_bad_timeunit(code): + return iris.exceptions.NotYetImplementedError( + "Unhandled time unit for forecast " + "indicatorOfUnitOfTimeRange : {code}".format(code=code) + ) + + def test_timeunits_grib1_specific(self): + tests = ( + (1, 13, None, 0.25 * _hour_secs, "15 minutes"), + (1, 14, None, 0.5 * _hour_secs, "30 minutes"), + (1, 254, None, 1.0, "seconds"), + (1, 111, TestGribTimecodes._err_bad_timeunit(111), 1.0, "??"), + ) + TestGribTimecodes._run_timetests(self, tests) + + def test_timeunits_calendar(self): + tests = ( + (1, 3, TestGribTimecodes._err_bad_timeunit(3), 0.0, "months"), + (1, 4, TestGribTimecodes._err_bad_timeunit(4), 0.0, "years"), + (1, 5, TestGribTimecodes._err_bad_timeunit(5), 0.0, "decades"), + (1, 6, TestGribTimecodes._err_bad_timeunit(6), 0.0, "30 years"), + (1, 7, TestGribTimecodes._err_bad_timeunit(7), 0.0, "centuries"), + ) + TestGribTimecodes._run_timetests(self, tests) + + def test_timeunits_invalid(self): + tests = ((1, 111, TestGribTimecodes._err_bad_timeunit(111), 1.0, "??"),) + TestGribTimecodes._run_timetests(self, tests) + + def test_warn_unknown_pdts(self): + # Test loading of an unrecognised GRIB Product Definition Template. + + # Get a temporary file by name (deleted afterward by context). + with self.temp_filename() as temp_gribfile_path: + # Write a test grib message to the temporary file. + with open(temp_gribfile_path, "wb") as temp_gribfile: + grib_message = eccodes.codes_grib_new_from_samples("GRIB2") + # Set the PDT to something unexpected. + eccodes.codes_set_long( + grib_message, "productDefinitionTemplateNumber", 99 + ) + eccodes.codes_write(grib_message, temp_gribfile) + + # Load the message from the file as a cube. + cube_generator = iris_grib.load_cubes(temp_gribfile_path) + with self.assertRaises(iris.exceptions.TranslationError) as t_err: + _ = next(cube_generator) + self.assertEqual( + "Product definition template [99] is not supported", + str(t_err.exception), + ) + + +class TestGrib1LoadPhenomenon(tests.IrisTest): + # Test recognition of grib phenomenon types. + def mock_grib(self): + grib = mock.Mock() + grib.edition = 1 + grib.startStep = 0 + grib.phenomenon_points = lambda unit: 3 + grib._forecastTimeUnit = "hours" + grib.productDefinitionTemplateNumber = 0 + # define a level type (NB these 2 are effectively the same) + grib.levelType = 1 + grib.typeOfFirstFixedSurface = 1 + grib.typeOfSecondFixedSurface = 1 + return grib + + def cube_from_message(self, grib): + # Parameter translation now uses the GribWrapper, so we must convert + # the Mock-based fake message to a FakeGribMessage. + with mock.patch("iris_grib._grib1_legacy.grib_wrapper.eccodes", _mock_eccodes): + grib_message = FakeGribMessage(**grib.__dict__) + wrapped_msg = GribWrapper(grib_message) + cube, _, _ = iris.fileformats.rules._make_cube( + wrapped_msg, iris_grib._grib1_legacy.grib1_load_rules.grib1_convert + ) + return cube + + def test_grib1_unknownparam(self): + grib = self.mock_grib() + grib.table2Version = 0 + grib.indicatorOfParameter = 9999 + cube = self.cube_from_message(grib) + self.assertEqual(cube.standard_name, None) + self.assertEqual(cube.long_name, None) + self.assertEqual(cube.units, cf_units.Unit("???")) + + def test_grib1_unknown_local_param(self): + grib = self.mock_grib() + grib.table2Version = 128 + grib.indicatorOfParameter = 999 + cube = self.cube_from_message(grib) + self.assertEqual(cube.standard_name, None) + self.assertEqual(cube.long_name, "UNKNOWN LOCAL PARAM 999.128") + self.assertEqual(cube.units, cf_units.Unit("???")) + + def test_grib1_unknown_standard_param(self): + grib = self.mock_grib() + grib.table2Version = 1 + grib.indicatorOfParameter = 975 + cube = self.cube_from_message(grib) + self.assertEqual(cube.standard_name, None) + self.assertEqual(cube.long_name, "UNKNOWN LOCAL PARAM 975.1") + self.assertEqual(cube.units, cf_units.Unit("???")) + + def known_grib1(self, param, standard_str, units_str): + grib = self.mock_grib() + grib.table2Version = 1 + grib.indicatorOfParameter = param + cube = self.cube_from_message(grib) + self.assertEqual(cube.standard_name, standard_str) + self.assertEqual(cube.long_name, None) + self.assertEqual(cube.units, cf_units.Unit(units_str)) + + def test_grib1_known_standard_params(self): + # at present, there are just a very few of these + self.known_grib1(11, "air_temperature", "kelvin") + self.known_grib1(33, "x_wind", "m s-1") + self.known_grib1(34, "y_wind", "m s-1") + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/__init__.py b/src/iris_grib/tests/unit/grib2_convert/__init__.py new file mode 100644 index 00000000..9a46adf4 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/__init__.py @@ -0,0 +1,34 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for the :mod:`iris_grib._grib2_convert` package.""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from collections import OrderedDict + + +def empty_metadata(): + metadata = OrderedDict() + metadata["factories"] = [] + metadata["references"] = [] + metadata["standard_name"] = None + metadata["long_name"] = None + metadata["units"] = None + metadata["attributes"] = {} + metadata["cell_methods"] = [] + metadata["dim_coords_and_dims"] = [] + metadata["aux_coords_and_dims"] = [] + return metadata + + +class LoadConvertTest(tests.IrisGribTest): + def assertMetadataEqual(self, result, expected): + # Compare two metadata dictionaries. Gives slightly more + # helpful error message than: self.assertEqual(result, expected) + self.assertEqual(result.keys(), expected.keys()) + for key in result.keys(): + self.assertEqual(result[key], expected[key]) diff --git a/src/iris_grib/tests/unit/grib2_convert/test__hindcast_fix.py b/src/iris_grib/tests/unit/grib2_convert/test__hindcast_fix.py new file mode 100644 index 00000000..aabd19d8 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test__hindcast_fix.py @@ -0,0 +1,56 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Tests for function :func:`iris_grib._grib2_convert._hindcast_fix`. + +""" + +from collections import namedtuple +import warnings + +import pytest + +from iris_grib._grib2_convert import _hindcast_fix as hindcast_fix +from iris_grib._grib2_convert import HindcastOverflowWarning + + +FixTest = namedtuple("FixTest", ("given", "fixable", "fixed")) +HINDCAST_TESTCASES = { + "zero_x": FixTest(0, False, None), + "n100_x": FixTest(100, False, None), + "n2^31m1_x": FixTest(2 * 2**30 - 1, False, None), + "n2^31_x": FixTest(2 * 2**30, False, None), + "n2^31p1_m1": FixTest(2 * 2**30 + 1, True, -1), + "n2^31p2_m2": FixTest(2 * 2**30 + 2, True, -2), + "n3x2^30m1_m2^30m1": FixTest(3 * 2**30 - 1, True, -(2**30 - 1)), + "n3x2^30^30_x": FixTest(3 * 2**30, False, None), +} + + +class TestHindcastFix: + @pytest.mark.parametrize( + "testval", + list(HINDCAST_TESTCASES.values()), + ids=list(HINDCAST_TESTCASES.keys()), + ) + def test_fix(self, testval): + # Check hindcast fixing. + given, fixable, fixed = testval + result = hindcast_fix(given) + expected = fixed if fixable else given + assert result == expected + + def test_fix_warning(self, mocker): + # Check warning appears when enabled. + mocker.patch("iris_grib._grib2_convert.options.warn_on_unsupported", True) + msg = "Re-interpreting large grib forecastTime" + with pytest.warns(HindcastOverflowWarning, match=msg): + hindcast_fix(2 * 2**30 + 5) + + def test_fix_warning_disabled(self): + # Default is no warning. + with warnings.catch_warnings(): + warnings.simplefilter(category=HindcastOverflowWarning, action="error") + hindcast_fix(2 * 2**30 + 5) diff --git a/src/iris_grib/tests/unit/grib2_convert/test_bitmap_section.py b/src/iris_grib/tests/unit/grib2_convert/test_bitmap_section.py new file mode 100644 index 00000000..c80aaf66 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_bitmap_section.py @@ -0,0 +1,31 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.bitmap_section.` + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from iris.exceptions import TranslationError + +from iris_grib._grib2_convert import bitmap_section +from iris_grib.tests.unit import _make_test_message + + +class Test(tests.IrisGribTest): + def test_bitmap_unsupported(self): + # bitMapIndicator in range 1-254. + # Note that bitMapIndicator = 1-253 and bitMapIndicator = 254 mean two + # different things, but load_convert treats them identically. + message = _make_test_message({6: {"bitMapIndicator": 100, "bitmap": None}}) + with self.assertRaisesRegex(TranslationError, "unsupported bitmap"): + bitmap_section(message.sections[6]) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_calculate_increment.py b/src/iris_grib/tests/unit/grib2_convert/test_calculate_increment.py new file mode 100644 index 00000000..646c8da7 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_calculate_increment.py @@ -0,0 +1,32 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for `iris_grib._grib2_convert._calculate_increment`. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from iris_grib._grib2_convert import _calculate_increment + + +class Test(tests.IrisGribTest): + def test_negative(self): + result = _calculate_increment(-15, -5, 10) + self.assertEqual(result, 1) + + def test_positive(self): + result = _calculate_increment(-5, 5, 10) + self.assertEqual(result, 1) + + def test_with_mod(self): + result = _calculate_increment(355, 5, 10, 360) + self.assertEqual(result, 1) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_convert.py b/src/iris_grib/tests/unit/grib2_convert/test_convert.py new file mode 100644 index 00000000..12933c7c --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_convert.py @@ -0,0 +1,63 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Test function :func:`iris_grib._grib2_convert.convert`.""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from iris.exceptions import TranslationError + +from iris_grib._load_convert import convert +from iris_grib.tests.unit import _make_test_message + + +class TestGribMessage(tests.IrisGribTest): + def test_edition_2(self): + def func(field, metadata): + return metadata["factories"].append(factory) + + sections = [{"editionNumber": 2}] + field = _make_test_message(sections) + this = "iris_grib._grib2_convert.grib2_convert" + factory = mock.sentinel.factory + with mock.patch(this, side_effect=func) as grib2_convert: + # The call being tested. + result = convert(field) + self.assertTrue(grib2_convert.called) + metadata = ([factory], [], None, None, None, {}, [], [], []) + self.assertEqual(result, metadata) + + def test_edition_1_bad(self): + sections = [{"editionNumber": 1}] + field = _make_test_message(sections) + emsg = "edition 1 is not supported" + with self.assertRaisesRegex(TranslationError, emsg): + convert(field) + + +class TestGribWrapper(tests.IrisGribTest): + def test_edition_2_bad(self): + # Test object with no '.sections', and '.edition' ==2. + field = mock.Mock(edition=2, spec=("edition")) + emsg = "edition 2 is not supported" + with self.assertRaisesRegex(TranslationError, emsg): + convert(field) + + def test_edition_1(self): + # Test object with no '.sections', and '.edition' ==1. + field = mock.Mock(edition=1, spec=("edition")) + func = "iris_grib._grib1_legacy.grib1_load_rules.grib1_convert" + metadata = mock.sentinel.metadata + with mock.patch(func, return_value=metadata) as grib1_convert: + result = convert(field) + grib1_convert.assert_called_once_with(field) + self.assertEqual(result, metadata) + + +if __name__ == "__main__": + tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_data_cutoff.py b/src/iris_grib/tests/unit/grib2_convert/test_data_cutoff.py similarity index 50% rename from iris_grib/tests/unit/load_convert/test_data_cutoff.py rename to src/iris_grib/tests/unit/grib2_convert/test_data_cutoff.py index 3ddb1c05..b1c03343 100644 --- a/iris_grib/tests/unit/load_convert/test_data_cutoff.py +++ b/src/iris_grib/tests/unit/grib2_convert/test_data_cutoff.py @@ -1,51 +1,36 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office +# Copyright iris-grib contributors # -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. """ -Tests for function :func:`iris_grib._load_convert.data_cutoff`. +Tests for function :func:`iris_grib._grib2_convert.data_cutoff`. """ -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - # import iris_grib.tests first so that some things can be initialised # before importing anything else. import iris_grib.tests as tests -import mock +from unittest import mock -from iris_grib._load_convert import _MDI as MDI -from iris_grib._load_convert import data_cutoff +from iris_grib._grib2_convert import _MDI as MDI +from iris_grib._grib2_convert import data_cutoff class TestDataCutoff(tests.IrisGribTest): def _check(self, hours, minutes, request_warning, expect_warning=False): # Setup the environment. - patch_target = 'iris_grib._load_convert.options' + patch_target = "iris_grib._grib2_convert.options" with mock.patch(patch_target) as options: options.warn_on_unsupported = request_warning - with mock.patch('warnings.warn') as warn: + with mock.patch("warnings.warn") as warn: # The call being tested. data_cutoff(hours, minutes) # Check the result. if expect_warning: self.assertEqual(len(warn.mock_calls), 1) - args, kwargs = warn.call_args - self.assertIn('data cutoff', args[0]) + args, _kwargs = warn.call_args + self.assertIn("data cutoff", args[0]) else: self.assertEqual(len(warn.mock_calls), 0) @@ -74,5 +59,5 @@ def test_hours_and_minutes_warning(self): self._check(30, 40, True, True) -if __name__ == '__main__': +if __name__ == "__main__": tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_data_representation_section.py b/src/iris_grib/tests/unit/grib2_convert/test_data_representation_section.py new file mode 100644 index 00000000..8ffe2831 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_data_representation_section.py @@ -0,0 +1,37 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.data_representation_section.` + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from iris.exceptions import TranslationError + +from iris_grib._grib2_convert import data_representation_section +from iris_grib.tests.unit import _make_test_message + + +class Test(tests.IrisGribTest): + def test_supported_templates(self): + template_nums = [0, 1, 2, 3, 4, 40, 41, 42, 50, 51, 61] + for template_num in template_nums: + message = _make_test_message( + {5: {"dataRepresentationTemplateNumber": template_num}} + ) + data_representation_section(message.sections[5]) + + def test_unsupported_template(self): + message = _make_test_message({5: {"dataRepresentationTemplateNumber": 5}}) + err_msg = r"Template \[5\] is not supported" + with self.assertRaisesRegex(TranslationError, err_msg): + data_representation_section(message.sections[5]) + + +if __name__ == "__main__": + tests.main() diff --git a/iris_grib/tests/unit/load_convert/test_ellipsoid.py b/src/iris_grib/tests/unit/grib2_convert/test_ellipsoid.py similarity index 51% rename from iris_grib/tests/unit/load_convert/test_ellipsoid.py rename to src/iris_grib/tests/unit/grib2_convert/test_ellipsoid.py index c0401bbd..bd14bf6c 100644 --- a/iris_grib/tests/unit/load_convert/test_ellipsoid.py +++ b/src/iris_grib/tests/unit/grib2_convert/test_ellipsoid.py @@ -1,27 +1,12 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office +# Copyright iris-grib contributors # -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. """ -Test function :func:`iris_grib._load_convert.ellipsoid. +Test function :func:`iris_grib._grib2_convert.ellipsoid. """ -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - # import iris_grib.tests first so that some things can be initialised # before importing anything else. import iris_grib.tests as tests @@ -31,7 +16,7 @@ import iris.coord_systems as icoord_systems from iris.exceptions import TranslationError -from iris_grib._load_convert import ellipsoid +from iris_grib._grib2_convert import ellipsoid # Reference GRIB2 Code Table 3.2 - Shape of the Earth. @@ -42,23 +27,25 @@ class Test(tests.IrisGribTest): def test_shape_unsupported(self): - unsupported = [2, 4, 5, 8, 9, 10, MDI] - emsg = 'unsupported shape of the earth' + unsupported = [8, 9, 10, MDI] + emsg = "unsupported shape of the earth" for shape in unsupported: - with self.assertRaisesRegexp(TranslationError, emsg): + with self.assertRaisesRegex(TranslationError, emsg): ellipsoid(shape, MDI, MDI, MDI) def test_spherical_default_supported(self): - cs_by_shape = {0: icoord_systems.GeogCS(6367470), - 6: icoord_systems.GeogCS(6371229)} + cs_by_shape = { + 0: icoord_systems.GeogCS(6367470), + 6: icoord_systems.GeogCS(6371229), + } for shape, expected in cs_by_shape.items(): result = ellipsoid(shape, MDI, MDI, MDI) self.assertEqual(result, expected) def test_spherical_shape_1_no_radius(self): shape = 1 - emsg = 'radius to be specified' - with self.assertRaisesRegexp(ValueError, emsg): + emsg = "radius to be specified" + with self.assertRaisesRegex(ValueError, emsg): ellipsoid(shape, MDI, MDI, MDI) def test_spherical_shape_1(self): @@ -70,20 +57,20 @@ def test_spherical_shape_1(self): def test_oblate_shape_3_7_no_axes(self): for shape in [3, 7]: - emsg = 'axis to be specified' - with self.assertRaisesRegexp(ValueError, emsg): + emsg = "axis to be specified" + with self.assertRaisesRegex(ValueError, emsg): ellipsoid(shape, MDI, MDI, MDI) def test_oblate_shape_3_7_no_major(self): for shape in [3, 7]: - emsg = 'major axis to be specified' - with self.assertRaisesRegexp(ValueError, emsg): + emsg = "major axis to be specified" + with self.assertRaisesRegex(ValueError, emsg): ellipsoid(shape, MDI, 1, MDI) def test_oblate_shape_3_7_no_minor(self): for shape in [3, 7]: - emsg = 'minor axis to be specified' - with self.assertRaisesRegexp(ValueError, emsg): + emsg = "minor axis to be specified" + with self.assertRaisesRegex(ValueError, emsg): ellipsoid(shape, 1, MDI, MDI) def test_oblate_shape_3_7(self): @@ -98,5 +85,5 @@ def test_oblate_shape_3_7(self): self.assertEqual(result, expected) -if __name__ == '__main__': +if __name__ == "__main__": tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_ellipsoid_geometry.py b/src/iris_grib/tests/unit/grib2_convert/test_ellipsoid_geometry.py new file mode 100644 index 00000000..374e82e0 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_ellipsoid_geometry.py @@ -0,0 +1,34 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.ellipsoid_geometry. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from iris_grib._grib2_convert import ellipsoid_geometry + + +class Test(tests.IrisGribTest): + def setUp(self): + self.section = { + "scaledValueOfEarthMajorAxis": 10, + "scaleFactorOfEarthMajorAxis": 1, + "scaledValueOfEarthMinorAxis": 100, + "scaleFactorOfEarthMinorAxis": 2, + "scaledValueOfRadiusOfSphericalEarth": 1000, + "scaleFactorOfRadiusOfSphericalEarth": 3, + } + + def test_geometry(self): + result = ellipsoid_geometry(self.section) + self.assertEqual(result, (1.0, 1.0, 1.0)) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_ensemble_identifier.py b/src/iris_grib/tests/unit/grib2_convert/test_ensemble_identifier.py new file mode 100644 index 00000000..8138fc63 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_ensemble_identifier.py @@ -0,0 +1,57 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function +:func:`iris_grib._grib2_convert.ensemble_identifier`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from unittest import mock +import warnings + +from iris.coords import DimCoord + +from iris_grib._grib2_convert import ensemble_identifier + + +class Test(tests.IrisGribTest): + def setUp(self): + self.patch("warnings.warn") + + def _check(self, request_warning): + section = {"perturbationNumber": 17} + this = "iris_grib._grib2_convert.options" + with mock.patch(this, warn_on_unsupported=request_warning): + realization = ensemble_identifier(section) + expected = DimCoord( + section["perturbationNumber"], + standard_name="realization", + units="no_unit", + ) + self.assertEqual(realization, expected) + + if request_warning: + warn_msgs = [mcall[1][0] for mcall in warnings.warn.mock_calls] + expected_msgs = ["type of ensemble", "number of forecasts"] + for emsg in expected_msgs: + matches = [wmsg for wmsg in warn_msgs if emsg in wmsg] + self.assertEqual(len(matches), 1) + warn_msgs.remove(matches[0]) + else: + self.assertEqual(len(warnings.warn.mock_calls), 0) + + def test_ens_no_warn(self): + self._check(False) + + def test_ens_warn(self): + self._check(True) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_forecast_period_coord.py b/src/iris_grib/tests/unit/grib2_convert/test_forecast_period_coord.py new file mode 100644 index 00000000..f837942c --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_forecast_period_coord.py @@ -0,0 +1,43 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.forecast_period_coord. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from iris.coords import DimCoord + +from iris_grib._grib2_convert import forecast_period_coord + + +class Test(tests.IrisGribTest): + def test(self): + # (indicatorOfUnitForForecastTime, forecastTime, expected-hours) + times = [ + (0, 60, 1), # minutes + (1, 2, 2), # hours + (2, 1, 24), # days + (10, 2, 6), # 3 hours + (11, 3, 18), # 6 hours + (12, 2, 24), # 12 hours + (13, 3600, 1), + ] # seconds + + for indicatorOfUnitForForecastTime, forecastTime, hours in times: + coord = forecast_period_coord(indicatorOfUnitForForecastTime, forecastTime) + self.assertIsInstance(coord, DimCoord) + self.assertEqual(coord.standard_name, "forecast_period") + self.assertEqual(coord.units, "hours") + self.assertEqual(coord.shape, (1,)) + self.assertEqual(coord.points[0], hours) + self.assertFalse(coord.has_bounds()) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_generating_process.py b/src/iris_grib/tests/unit/grib2_convert/test_generating_process.py new file mode 100644 index 00000000..eacd4b4f --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_generating_process.py @@ -0,0 +1,54 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Tests for function +:func:`iris_grib._grib2_convert.generating_process`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from iris_grib._grib2_convert import generating_process + + +class TestGeneratingProcess(tests.IrisGribTest): + def setUp(self): + self.warn_patch = self.patch("warnings.warn") + + def test_nowarn(self): + generating_process(None) + self.assertEqual(self.warn_patch.call_count, 0) + + def _check_warnings(self, with_forecast=True): + module = "iris_grib._grib2_convert" + self.patch(module + ".options.warn_on_unsupported", True) + call_args = [None] + call_kwargs = {} + expected_fragments = [ + "Unable to translate type of generating process", + "Unable to translate background generating process", + ] + if with_forecast: + expected_fragments.append("Unable to translate forecast generating process") + else: + call_kwargs["include_forecast_process"] = False + generating_process(*call_args, **call_kwargs) + got_msgs = [call[0][0] for call in self.warn_patch.call_args_list] + for got_msg, expected_fragment in zip( + sorted(got_msgs), sorted(expected_fragments), strict=False + ): + self.assertIn(expected_fragment, got_msg) + + def test_warn_full(self): + self._check_warnings() + + def test_warn_no_forecast(self): + self._check_warnings(with_forecast=False) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_grib2_convert.py b/src/iris_grib/tests/unit/grib2_convert/test_grib2_convert.py new file mode 100644 index 00000000..9a1dc6d2 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_grib2_convert.py @@ -0,0 +1,73 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Test function :func:`iris_grib._grib2_convert.grib2_convert`.""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import copy +from unittest import mock + +import iris_grib +from iris_grib._grib2_convert import grib2_convert +from iris_grib.tests.unit import _make_test_message + + +class Test(tests.IrisGribTest): + def setUp(self): + this = "iris_grib._grib2_convert" + self.patch("{}.reference_time_coord".format(this), return_value=None) + self.patch("{}.grid_definition_section".format(this)) + self.patch("{}.product_definition_section".format(this)) + self.patch("{}.data_representation_section".format(this)) + self.patch("{}.bitmap_section".format(this)) + + def test(self): + sections = [ + {"discipline": mock.sentinel.discipline}, # section 0 + { + "centre": "ecmf", # section 1 + "tablesVersion": mock.sentinel.tablesVersion, + }, + None, # section 2 + mock.sentinel.grid_definition_section, # section 3 + mock.sentinel.product_definition_section, # section 4 + mock.sentinel.data_representation_section, # section 5 + mock.sentinel.bitmap_section, + ] # section 6 + field = _make_test_message(sections) + metadata = { + "factories": [], + "references": [], + "standard_name": None, + "long_name": None, + "units": None, + "attributes": {}, + "cell_methods": [], + "dim_coords_and_dims": [], + "aux_coords_and_dims": [], + } + expected = copy.deepcopy(metadata) + centre = "European Centre for Medium Range Weather Forecasts" + expected["attributes"] = {"centre": centre} + # The call being tested. + grib2_convert(field, metadata) + self.assertEqual(metadata, expected) + this = iris_grib._grib2_convert + this.reference_time_coord.assert_called_with(sections[1]) + this.grid_definition_section.assert_called_with(sections[3], expected) + args = ( + sections[4], + expected, + sections[0]["discipline"], + sections[1]["tablesVersion"], + None, + ) + this.product_definition_section.assert_called_with(*args) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_0_and_1.py b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_0_and_1.py new file mode 100644 index 00000000..8335e9aa --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_0_and_1.py @@ -0,0 +1,168 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function +:func:`iris_grib._grib2_convert.grid_definition_template_0_and_1`. + +""" + +# Import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import numpy as np + +import iris.coord_systems +import iris.coords + +from iris.exceptions import TranslationError + +from iris_grib.tests.unit.grib2_convert import empty_metadata + +from iris_grib._grib2_convert import grid_definition_template_0_and_1 + + +class _Section(dict): + def get_computed_key(self, key): + return self.get(key) + + +class Test_resolution_flags(tests.IrisGribTest): + def section_3(self): + section = _Section( + { + "Ni": 6, + "Nj": 6, + "latitudeOfFirstGridPoint": 0, + "longitudeOfFirstGridPoint": 0, + "resolutionAndComponentFlags": 0, + "latitudeOfLastGridPoint": 5000000, + "longitudeOfLastGridPoint": 5000000, + "iDirectionIncrement": 0, + "jDirectionIncrement": 0, + "scanningMode": 0b01000000, + "numberOfOctectsForNumberOfPoints": 0, + "interpretationOfNumberOfPoints": 0, + } + ) + return section + + def expected(self, x_dim, y_dim, x_points, y_points, x_neg=True, y_neg=True): + # Prepare the expectation. + expected = empty_metadata() + cs = iris.coord_systems.GeogCS(6367470) + if x_neg: + x_points = x_points[::-1] + x = iris.coords.DimCoord( + x_points, standard_name="longitude", units="degrees", coord_system=cs + ) + if y_neg: + y_points = y_points[::-1] + y = iris.coords.DimCoord( + y_points, standard_name="latitude", units="degrees", coord_system=cs + ) + expected["dim_coords_and_dims"].append((y, y_dim)) + expected["dim_coords_and_dims"].append((x, x_dim)) + return expected + + def test_without_increments(self): + section = self.section_3() + metadata = empty_metadata() + cs = iris.coord_systems.GeogCS(6367470) + grid_definition_template_0_and_1(section, metadata, "latitude", "longitude", cs) + x_points = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + y_points = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + expected = self.expected(1, 0, x_points, y_points, x_neg=False, y_neg=False) + self.assertEqual(metadata, expected) + + def test_with_increments(self): + section = self.section_3() + section["resolutionAndComponentFlags"] = 48 + section["iDirectionIncrement"] = 1000000 + section["jDirectionIncrement"] = 1000000 + metadata = empty_metadata() + cs = iris.coord_systems.GeogCS(6367470) + grid_definition_template_0_and_1(section, metadata, "latitude", "longitude", cs) + x_points = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + y_points = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + expected = self.expected(1, 0, x_points, y_points, x_neg=False, y_neg=False) + self.assertEqual(metadata, expected) + + def test_with_i_not_j_increment(self): + section = self.section_3() + section["resolutionAndComponentFlags"] = 32 + section["iDirectionIncrement"] = 1000000 + metadata = empty_metadata() + cs = iris.coord_systems.GeogCS(6367470) + grid_definition_template_0_and_1(section, metadata, "latitude", "longitude", cs) + x_points = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + y_points = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + expected = self.expected(1, 0, x_points, y_points, x_neg=False, y_neg=False) + self.assertEqual(metadata, expected) + + def test_with_j_not_i_increment(self): + section = self.section_3() + section["resolutionAndComponentFlags"] = 16 + section["jDirectionIncrement"] = 1000000 + metadata = empty_metadata() + cs = iris.coord_systems.GeogCS(6367470) + grid_definition_template_0_and_1(section, metadata, "latitude", "longitude", cs) + x_points = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + y_points = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + expected = self.expected(1, 0, x_points, y_points, x_neg=False, y_neg=False) + self.assertEqual(metadata, expected) + + def test_without_increments_crossing_0_lon(self): + section = self.section_3() + section["longitudeOfFirstGridPoint"] = 355000000 + section["Ni"] = 11 + metadata = empty_metadata() + cs = iris.coord_systems.GeogCS(6367470) + grid_definition_template_0_and_1(section, metadata, "latitude", "longitude", cs) + x_points = np.array( + [ + 355.0, + 356.0, + 357.0, + 358.0, + 359.0, + 360.0, + 361.0, + 362.0, + 363.0, + 364.0, + 365.0, + ] + ) + y_points = np.array([0.0, 1.0, 2.0, 3.0, 4.0, 5.0]) + expected = self.expected(1, 0, x_points, y_points, x_neg=False, y_neg=False) + self.assertEqual(metadata, expected) + + +class Test(tests.IrisGribTest): + def test_unsupported_quasi_regular__number_of_octets(self): + section = {"numberOfOctectsForNumberOfPoints": 1} + cs = None + metadata = None + with self.assertRaisesRegex(TranslationError, "quasi-regular"): + grid_definition_template_0_and_1( + section, metadata, "latitude", "longitude", cs + ) + + def test_unsupported_quasi_regular__interpretation(self): + section = { + "numberOfOctectsForNumberOfPoints": 1, + "interpretationOfNumberOfPoints": 1, + } + cs = None + metadata = None + with self.assertRaisesRegex(TranslationError, "quasi-regular"): + grid_definition_template_0_and_1( + section, metadata, "latitude", "longitude", cs + ) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_10.py b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_10.py new file mode 100644 index 00000000..858996e4 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_10.py @@ -0,0 +1,88 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for +:func:`iris_grib._grib2_convert.grid_definition_template_10`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import numpy as np + +import iris.coord_systems +import iris.coords +import iris.exceptions + +from iris_grib.tests.unit.grib2_convert import empty_metadata + +from iris_grib._grib2_convert import grid_definition_template_10 + + +class Test(tests.IrisGribTest): + def section_3(self): + section = { + "gridDefinitionTemplateNumber": 10, + "shapeOfTheEarth": 1, + "scaleFactorOfRadiusOfSphericalEarth": 0, + "scaledValueOfRadiusOfSphericalEarth": 6371200, + "scaleFactorOfEarthMajorAxis": 0, + "scaledValueOfEarthMajorAxis": 0, + "scaleFactorOfEarthMinorAxis": 0, + "scaledValueOfEarthMinorAxis": 0, + "Ni": 181, + "Nj": 213, + "latitudeOfFirstGridPoint": 2351555, + "latitudeOfLastGridPoint": 25088204, + "LaD": 14000000, + "longitudeOfFirstGridPoint": 114990304, + "longitudeOfLastGridPoint": 135009712, + "resolutionAndComponentFlags": 56, + "scanningMode": 64, + "Di": 12000000, + "Dj": 12000000, + } + return section + + def expected(self, y_dim, x_dim): + # Prepare the expectation. + expected = empty_metadata() + ellipsoid = iris.coord_systems.GeogCS(6371200.0) + cs = iris.coord_systems.Mercator(standard_parallel=14.0, ellipsoid=ellipsoid) + nx = 181 + x_origin = 12406918.990644248 + dx = 12000 + x = iris.coords.DimCoord( + np.arange(nx) * dx + x_origin, + "projection_x_coordinate", + units="m", + coord_system=cs, + ) + ny = 213 + y_origin = 253793.10903714459 + + dy = 12000 + y = iris.coords.DimCoord( + np.arange(ny) * dy + y_origin, + "projection_y_coordinate", + units="m", + coord_system=cs, + ) + expected["dim_coords_and_dims"].append((y, y_dim)) + expected["dim_coords_and_dims"].append((x, x_dim)) + return expected + + def test(self): + section = self.section_3() + metadata = empty_metadata() + grid_definition_template_10(section, metadata) + expected = self.expected(y_dim=0, x_dim=1) + self.assertEqual(metadata, expected) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_12.py b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_12.py new file mode 100644 index 00000000..8999f6ef --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_12.py @@ -0,0 +1,189 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for +:func:`iris_grib._grib2_convert.grid_definition_template_12`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import numpy as np +import warnings + +import iris.coord_systems +import iris.coords +import iris.exceptions + +from iris_grib.tests.unit.grib2_convert import empty_metadata +from iris_grib._grib2_convert import _MDI as MDI + +from iris_grib._grib2_convert import grid_definition_template_12 + + +class Test(tests.IrisGribTest): + def section_3(self): + section = { + "shapeOfTheEarth": 7, + "scaleFactorOfRadiusOfSphericalEarth": MDI, + "scaledValueOfRadiusOfSphericalEarth": MDI, + "scaleFactorOfEarthMajorAxis": 3, + "scaledValueOfEarthMajorAxis": 6377563396, + "scaleFactorOfEarthMinorAxis": 3, + "scaledValueOfEarthMinorAxis": 6356256909, + "Ni": 4, + "Nj": 3, + "latitudeOfReferencePoint": 49000000, + "longitudeOfReferencePoint": -2000000, + "resolutionAndComponentFlags": 0, + "scaleFactorAtReferencePoint": 0.9996012717, + "XR": 40000000, + "YR": -10000000, + "scanningMode": 64, + "Di": 200000, + "Dj": 100000, + "X1": 29300000, + "Y1": 9200000, + "X2": 29900000, + "Y2": 9400000, + } + return section + + def expected(self, y_dim, x_dim, x_negative=False, y_negative=False): + # Prepare the expectation. + expected = empty_metadata() + ellipsoid = iris.coord_systems.GeogCS(6377563.396, 6356256.909) + cs = iris.coord_systems.TransverseMercator( + 49, -2, 400000, -100000, 0.9996012717, ellipsoid + ) + nx = 4 + x_origin = 293000 + dx = 2000 + x_array = np.arange(nx) * dx + x_origin + if x_negative: + x_array = np.flip(x_array) + x = iris.coords.DimCoord( + x_array, "projection_x_coordinate", units="m", coord_system=cs + ) + ny = 3 + y_origin = 92000 + dy = 1000 + y_array = np.arange(ny) * dy + y_origin + if y_negative: + y_array = np.flip(y_array) + y = iris.coords.DimCoord( + y_array, "projection_y_coordinate", units="m", coord_system=cs + ) + expected["dim_coords_and_dims"].append((y, y_dim)) + expected["dim_coords_and_dims"].append((x, x_dim)) + return expected + + def test(self): + section = self.section_3() + metadata = empty_metadata() + grid_definition_template_12(section, metadata) + expected = self.expected(0, 1) + self.assertEqual(metadata, expected) + + def test_spherical(self): + section = self.section_3() + section["shapeOfTheEarth"] = 0 + metadata = empty_metadata() + grid_definition_template_12(section, metadata) + expected = self.expected(0, 1) + cs = expected["dim_coords_and_dims"][0][0].coord_system + cs.ellipsoid = iris.coord_systems.GeogCS(6367470) + self.assertEqual(metadata, expected) + + def test_negative_x(self): + section = self.section_3() + section["scanningMode"] = 0b11000000 + section["X1"], section["X2"] = section["X2"], section["X1"] + metadata = empty_metadata() + grid_definition_template_12(section, metadata) + expected = self.expected(0, 1, x_negative=True) + self.assertEqual(metadata, expected) + + def test_x_inconsistent_direction(self): + section = self.section_3() + section["scanningMode"] = 0b11000000 + metadata = empty_metadata() + with warnings.catch_warnings(record=True) as warn: + grid_definition_template_12(section, metadata) + self.assertEqual(len(warn), 1) + message = "X definition inconsistent: scanningMode" + self.assertRegex(str(warn[0].message), message) + expected = self.expected(0, 1) + self.assertEqual(metadata, expected) + + def test_x_inconsistent_steps(self): + section = self.section_3() + section["Ni"] += 1 + metadata = empty_metadata() + expected_regex = "X definition inconsistent: .* incompatible with step-size" + with self.assertRaisesRegex(iris.exceptions.TranslationError, expected_regex): + grid_definition_template_12(section, metadata) + + def test_negative_y(self): + section = self.section_3() + section["scanningMode"] = 0b00000000 + section["Y1"], section["Y2"] = section["Y2"], section["Y1"] + metadata = empty_metadata() + grid_definition_template_12(section, metadata) + expected = self.expected(0, 1, y_negative=True) + self.assertEqual(metadata, expected) + + def test_y_inconsistent_direction(self): + section = self.section_3() + section["scanningMode"] = 0b00000000 + metadata = empty_metadata() + with warnings.catch_warnings(record=True) as warn: + grid_definition_template_12(section, metadata) + self.assertEqual(len(warn), 1) + message = "Y definition inconsistent: scanningMode" + self.assertRegex(str(warn[0].message), message) + expected = self.expected(0, 1) + self.assertEqual(metadata, expected) + + def test_y_inconsistent_steps(self): + section = self.section_3() + section["Nj"] += 1 + metadata = empty_metadata() + expected_regex = "Y definition inconsistent: .* incompatible with step-size" + with self.assertRaisesRegex(iris.exceptions.TranslationError, expected_regex): + grid_definition_template_12(section, metadata) + + def test_transposed(self): + section = self.section_3() + section["scanningMode"] = 0b01100000 + metadata = empty_metadata() + grid_definition_template_12(section, metadata) + expected = self.expected(1, 0) + self.assertEqual(metadata, expected) + + def test_di_tolerance(self): + # Even though Ni * Di doesn't exactly match X1 to X2 it should + # be close enough to allow the translation. + section = self.section_3() + section["X2"] += 1 + metadata = empty_metadata() + grid_definition_template_12(section, metadata) + expected = self.expected(0, 1) + x = expected["dim_coords_and_dims"][1][0] + x.points = np.linspace(293000, 299000.01, 4) + self.assertEqual(metadata, expected) + + def test_incompatible_grid_extent(self): + section = self.section_3() + section["X2"] += 100 + metadata = empty_metadata() + with self.assertRaisesRegex(iris.exceptions.TranslationError, "grid"): + grid_definition_template_12(section, metadata) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_140.py b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_140.py new file mode 100644 index 00000000..5068a41c --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_140.py @@ -0,0 +1,92 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for +:func:`iris_grib._grib2_convert.grid_definition_template_140`. +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import cartopy.crs as ccrs +import numpy as np + +import iris.coord_systems +import iris.coords + +from iris_grib.tests.unit.grib2_convert import empty_metadata +from iris_grib._grib2_convert import _MDI as MDI + +from iris_grib._grib2_convert import grid_definition_template_140 + + +class Test(tests.IrisGribTest): + def section_3(self): + section = { + "gridDefinitionTemplateNumber": 140, + "shapeOfTheEarth": 4, + "scaleFactorOfRadiusOfSphericalEarth": MDI, + "scaledValueOfRadiusOfSphericalEarth": MDI, + "scaleFactorOfEarthMajorAxis": MDI, + "scaledValueOfEarthMajorAxis": MDI, + "scaleFactorOfEarthMinorAxis": MDI, + "scaledValueOfEarthMinorAxis": MDI, + "numberOfPointsAlongXAxis": 2, + "numberOfPointsAlongYAxis": 2, + "latitudeOfFirstGridPoint": 53988880, + "longitudeOfFirstGridPoint": -4027984, + "standardParallelInMicrodegrees": 54900000, + "centralLongitudeInMicrodegrees": -2500000, + "resolutionAndComponentFlags": 0b00110000, + "xDirectionGridLengthInMillimetres": 2000000, + "yDirectionGridLengthInMillimetres": 2000000, + "scanningMode": 0b01000000, + } + return section + + def expected(self, y_dim, x_dim): + # Prepare the expectation. + expected = empty_metadata() + ellipsoid = iris.coord_systems.GeogCS(6378137, inverse_flattening=298.257222101) + cs = iris.coord_systems.LambertAzimuthalEqualArea( + latitude_of_projection_origin=54.9, + longitude_of_projection_origin=-2.5, + false_easting=0, + false_northing=0, + ellipsoid=ellipsoid, + ) + lon0 = -4027984 * 1e-6 + lat0 = 53988880 * 1e-6 + x0m, y0m = cs.as_cartopy_crs().transform_point(lon0, lat0, ccrs.Geodetic()) + dxm = dym = 2000.0 + x_points = x0m + dxm * np.arange(2) + y_points = y0m + dym * np.arange(2) + x = iris.coords.DimCoord( + x_points, + standard_name="projection_x_coordinate", + units="m", + coord_system=cs, + ) + y = iris.coords.DimCoord( + y_points, + standard_name="projection_y_coordinate", + units="m", + coord_system=cs, + ) + expected["dim_coords_and_dims"].append((y, y_dim)) + expected["dim_coords_and_dims"].append((x, x_dim)) + return expected + + def test(self): + section = self.section_3() + metadata = empty_metadata() + grid_definition_template_140(section, metadata) + expected = self.expected(0, 1) + self.assertEqual(metadata, expected) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_20.py b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_20.py new file mode 100644 index 00000000..b2d2687c --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_20.py @@ -0,0 +1,98 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for +:func:`iris_grib._grib2_convert.grid_definition_template_20`. +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import cartopy.crs as ccrs +import numpy as np + +import iris.coord_systems +import iris.coords + +from iris_grib.tests.unit.grib2_convert import empty_metadata +from iris_grib._grib2_convert import _MDI as MDI + +from iris_grib._grib2_convert import grid_definition_template_20 + + +class Test(tests.IrisGribTest): + def section_3(self): + section = { + "gridDefinitionTemplateNumber": 20, + "shapeOfTheEarth": 0, + "scaleFactorOfRadiusOfSphericalEarth": 0, + "scaledValueOfRadiusOfSphericalEarth": 6367470, + "scaleFactorOfEarthMajorAxis": 0, + "scaledValueOfEarthMajorAxis": MDI, + "scaleFactorOfEarthMinorAxis": 0, + "scaledValueOfEarthMinorAxis": MDI, + "Nx": 15, + "Ny": 10, + "latitudeOfFirstGridPoint": 32549114, + "longitudeOfFirstGridPoint": 225385728, + "resolutionAndComponentFlags": 0b00001000, + "LaD": 60000000, + "orientationOfTheGrid": 262000000, + "Dx": 320000000, + "Dy": 320000000, + "projectionCentreFlag": 0b00000000, + "scanningMode": 0b01000000, + } + return section + + def expected(self, y_dim, x_dim): + # Prepare the expectation. + expected = empty_metadata() + ellipsoid = iris.coord_systems.GeogCS(6367470) + # Always expect PolarStereographic - never Stereographic. + # Stereographic is a CF/Iris concept and not something described in + # GRIB. + cs = iris.coord_systems.PolarStereographic( + central_lat=90.0, + central_lon=262.0, + false_easting=0, + false_northing=0, + true_scale_lat=60.0, + ellipsoid=ellipsoid, + ) + lon0 = 225385728 * 1e-6 + lat0 = 32549114 * 1e-6 + x0m, y0m = cs.as_cartopy_crs().transform_point(lon0, lat0, ccrs.Geodetic()) + dxm = dym = 320000.0 + x_points = x0m + dxm * np.arange(15) + y_points = y0m + dym * np.arange(10) + x = iris.coords.DimCoord( + x_points, + standard_name="projection_x_coordinate", + units="m", + coord_system=cs, + circular=False, + ) + y = iris.coords.DimCoord( + y_points, + standard_name="projection_y_coordinate", + units="m", + coord_system=cs, + ) + expected["dim_coords_and_dims"].append((y, y_dim)) + expected["dim_coords_and_dims"].append((x, x_dim)) + return expected + + def test(self): + section = self.section_3() + metadata = empty_metadata() + grid_definition_template_20(section, metadata) + expected = self.expected(0, 1) + self.assertEqual(metadata, expected) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_30.py b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_30.py new file mode 100644 index 00000000..d040f91b --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_30.py @@ -0,0 +1,97 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for +:func:`iris_grib._grib2_convert.grid_definition_template_30`. +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import cartopy.crs as ccrs +import numpy as np + +import iris.coord_systems +import iris.coords + +from iris_grib.tests.unit.grib2_convert import empty_metadata +from iris_grib._grib2_convert import _MDI as MDI + +from iris_grib._grib2_convert import grid_definition_template_30 + + +class Test(tests.IrisGribTest): + def section_3(self): + section = { + "gridDefinitionTemplateNumber": 30, + "shapeOfTheEarth": 0, + "scaleFactorOfRadiusOfSphericalEarth": 0, + "scaledValueOfRadiusOfSphericalEarth": 6367470, + "scaleFactorOfEarthMajorAxis": 0, + "scaledValueOfEarthMajorAxis": MDI, + "scaleFactorOfEarthMinorAxis": 0, + "scaledValueOfEarthMinorAxis": MDI, + "Nx": 15, + "Ny": 10, + "longitudeOfFirstGridPoint": 239550000, + "latitudeOfFirstGridPoint": 21641000, + "resolutionAndComponentFlags": 0b00001000, + "LaD": 60000000, + "LoV": 262000000, + "Dx": 320000000, + "Dy": 320000000, + "projectionCentreFlag": 0b00000000, + "scanningMode": 0b01000000, + "Latin1": 60000000, + "Latin2": 30000000, + } + return section + + def expected(self, y_dim, x_dim): + # Prepare the expectation. + expected = empty_metadata() + cs = iris.coord_systems.GeogCS(6367470) + cs = iris.coord_systems.LambertConformal( + central_lat=60.0, + central_lon=262.0, + false_easting=0, + false_northing=0, + secant_latitudes=(60.0, 30.0), + ellipsoid=iris.coord_systems.GeogCS(6367470), + ) + lon0 = 239.55 + lat0 = 21.641 + x0m, y0m = cs.as_cartopy_crs().transform_point(lon0, lat0, ccrs.Geodetic()) + dxm = dym = 320000.0 + x_points = x0m + dxm * np.arange(15) + y_points = y0m + dym * np.arange(10) + x = iris.coords.DimCoord( + x_points, + standard_name="projection_x_coordinate", + units="m", + coord_system=cs, + circular=False, + ) + y = iris.coords.DimCoord( + y_points, + standard_name="projection_y_coordinate", + units="m", + coord_system=cs, + ) + expected["dim_coords_and_dims"].append((y, y_dim)) + expected["dim_coords_and_dims"].append((x, x_dim)) + return expected + + def test(self): + section = self.section_3() + metadata = empty_metadata() + grid_definition_template_30(section, metadata) + expected = self.expected(0, 1) + self.assertEqual(metadata, expected) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_40.py b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_40.py new file mode 100644 index 00000000..76e18416 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_40.py @@ -0,0 +1,202 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for +:func:`iris_grib._grib2_convert.grid_definition_template_40`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import numpy as np + +import iris.coord_systems +import iris.coords + +from iris_grib.tests.unit.grib2_convert import empty_metadata +from iris_grib._grib2_convert import _MDI as MDI + +from iris_grib._grib2_convert import grid_definition_template_40 + + +class _Section(dict): + def get_computed_key(self, key): + return self.get(key) + + +class Test_regular(tests.IrisGribTest): + def section_3(self): + section = _Section( + { + "shapeOfTheEarth": 0, + "scaleFactorOfRadiusOfSphericalEarth": 0, + "scaledValueOfRadiusOfSphericalEarth": 6367470, + "scaleFactorOfEarthMajorAxis": 0, + "scaledValueOfEarthMajorAxis": MDI, + "scaleFactorOfEarthMinorAxis": 0, + "scaledValueOfEarthMinorAxis": MDI, + "iDirectionIncrement": 22500000, + "longitudeOfFirstGridPoint": 0, + "resolutionAndComponentFlags": 32, + "Ni": 16, + "scanningMode": 0b01000000, + "distinctLatitudes": np.array( + [ + -73.79921363, + -52.81294319, + -31.70409175, + -10.56988231, + 10.56988231, + 31.70409175, + 52.81294319, + 73.79921363, + ] + ), + "numberOfOctectsForNumberOfPoints": 0, + "interpretationOfNumberOfPoints": 0, + } + ) + return section + + def expected(self, y_dim, x_dim, y_neg=True): + # Prepare the expectation. + expected = empty_metadata() + cs = iris.coord_systems.GeogCS(6367470) + nx = 16 + dx = 22.5 + x_origin = 0 + x = iris.coords.DimCoord( + np.arange(nx) * dx + x_origin, + standard_name="longitude", + units="degrees_east", + coord_system=cs, + circular=True, + ) + y_points = np.array( + [ + 73.79921363, + 52.81294319, + 31.70409175, + 10.56988231, + -10.56988231, + -31.70409175, + -52.81294319, + -73.79921363, + ] + ) + if not y_neg: + y_points = y_points[::-1] + y = iris.coords.DimCoord( + y_points, standard_name="latitude", units="degrees_north", coord_system=cs + ) + expected["dim_coords_and_dims"].append((y, y_dim)) + expected["dim_coords_and_dims"].append((x, x_dim)) + return expected + + def test(self): + section = self.section_3() + metadata = empty_metadata() + grid_definition_template_40(section, metadata) + expected = self.expected(0, 1, y_neg=False) + self.assertEqual(metadata, expected) + + def test_transposed(self): + section = self.section_3() + section["scanningMode"] = 0b01100000 + metadata = empty_metadata() + grid_definition_template_40(section, metadata) + expected = self.expected(1, 0, y_neg=False) + self.assertEqual(metadata, expected) + + def test_reverse_latitude(self): + section = self.section_3() + section["scanningMode"] = 0b00000000 + metadata = empty_metadata() + grid_definition_template_40(section, metadata) + expected = self.expected(0, 1, y_neg=True) + self.assertEqual(metadata, expected) + + +class Test_reduced(tests.IrisGribTest): + def section_3(self): + section = _Section( + { + "shapeOfTheEarth": 0, + "scaleFactorOfRadiusOfSphericalEarth": 0, + "scaledValueOfRadiusOfSphericalEarth": 6367470, + "scaleFactorOfEarthMajorAxis": 0, + "scaledValueOfEarthMajorAxis": MDI, + "scaleFactorOfEarthMinorAxis": 0, + "scaledValueOfEarthMinorAxis": MDI, + "longitudes": np.array( + [0.0, 180.0, 0.0, 120.0, 240.0, 0.0, 120.0, 240.0, 0.0, 180.0] + ), + "latitudes": np.array( + [ + -59.44440829, + -59.44440829, + -19.87571915, + -19.87571915, + -19.87571915, + 19.87571915, + 19.87571915, + 19.87571915, + 59.44440829, + 59.44440829, + ] + ), + "numberOfOctectsForNumberOfPoints": 1, + "interpretationOfNumberOfPoints": 1, + } + ) + return section + + def expected(self): + # Prepare the expectation. + expected = empty_metadata() + cs = iris.coord_systems.GeogCS(6367470) + x_points = np.array( + [0.0, 180.0, 0.0, 120.0, 240.0, 0.0, 120.0, 240.0, 0.0, 180.0] + ) + y_points = np.array( + [ + -59.44440829, + -59.44440829, + -19.87571915, + -19.87571915, + -19.87571915, + 19.87571915, + 19.87571915, + 19.87571915, + 59.44440829, + 59.44440829, + ] + ) + grid = iris.coords.DimCoord( + np.arange(10, dtype=int), long_name="gaussian_grid", units="1" + ) + expected["dim_coords_and_dims"].append((grid, 0)) + x = iris.coords.AuxCoord( + x_points, standard_name="longitude", units="degrees_east", coord_system=cs + ) + y = iris.coords.AuxCoord( + y_points, standard_name="latitude", units="degrees_north", coord_system=cs + ) + expected["aux_coords_and_dims"].append((y, 0)) + expected["aux_coords_and_dims"].append((x, 0)) + return expected + + def test(self): + section = self.section_3() + metadata = empty_metadata() + expected = self.expected() + grid_definition_template_40(section, metadata) + self.assertEqual(metadata, expected) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_4_and_5.py b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_4_and_5.py new file mode 100644 index 00000000..fdc472ea --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_4_and_5.py @@ -0,0 +1,142 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function +:func:`iris_grib._grib2_convert.grid_definition_template_4_and_5`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from copy import deepcopy +from unittest import mock +import warnings + +from iris.coords import DimCoord +import numpy as np + +from iris_grib._grib2_convert import grid_definition_template_4_and_5, _MDI as MDI + + +RESOLUTION = 1e6 + + +class Test(tests.IrisGribTest): + def setUp(self): + self.patch("warnings.warn") + self.patch("iris_grib._grib2_convert._is_circular", return_value=False) + self.metadata = { + "factories": [], + "references": [], + "standard_name": None, + "long_name": None, + "units": None, + "attributes": {}, + "cell_methods": [], + "dim_coords_and_dims": [], + "aux_coords_and_dims": [], + } + self.cs = mock.sentinel.coord_system + self.data = np.arange(10, dtype=np.float64) + + def _check(self, section, request_warning, expect_warning=False, y_dim=0, x_dim=1): + this = "iris_grib._grib2_convert.options" + with mock.patch(this, warn_on_unsupported=request_warning): + metadata = deepcopy(self.metadata) + # The called being tested. + grid_definition_template_4_and_5( + section, metadata, "latitude", "longitude", self.cs + ) + expected = deepcopy(self.metadata) + coord = DimCoord( + self.data, + standard_name="latitude", + units="degrees", + coord_system=self.cs, + ) + expected["dim_coords_and_dims"].append((coord, y_dim)) + coord = DimCoord( + self.data, + standard_name="longitude", + units="degrees", + coord_system=self.cs, + ) + expected["dim_coords_and_dims"].append((coord, x_dim)) + self.assertEqual(metadata, expected) + if expect_warning: + self.assertEqual(len(warnings.warn.mock_calls), 1) + args, _kwargs = warnings.warn.call_args + self.assertIn("resolution and component flags", args[0]) + else: + self.assertEqual(len(warnings.warn.mock_calls), 0) + + def test_resolution_default_0(self): + for request_warn in [False, True]: + section = { + "basicAngleOfTheInitialProductionDomain": 0, + "subdivisionsOfBasicAngle": 0, + "resolutionAndComponentFlags": 0, + "longitudes": self.data * RESOLUTION, + "latitudes": self.data * RESOLUTION, + "scanningMode": 0, + } + self._check(section, request_warn) + + def test_resolution_default_mdi(self): + for request_warn in [False, True]: + section = { + "basicAngleOfTheInitialProductionDomain": MDI, + "subdivisionsOfBasicAngle": MDI, + "resolutionAndComponentFlags": 0, + "longitudes": self.data * RESOLUTION, + "latitudes": self.data * RESOLUTION, + "scanningMode": 0, + } + self._check(section, request_warn) + + def test_resolution(self): + angle = 10 + for request_warn in [False, True]: + section = { + "basicAngleOfTheInitialProductionDomain": 1, + "subdivisionsOfBasicAngle": angle, + "resolutionAndComponentFlags": 0, + "longitudes": self.data * angle, + "latitudes": self.data * angle, + "scanningMode": 0, + } + self._check(section, request_warn) + + def test_uv_resolved_warn(self): + angle = 100 + for warn in [False, True]: + section = { + "basicAngleOfTheInitialProductionDomain": 1, + "subdivisionsOfBasicAngle": angle, + "resolutionAndComponentFlags": 0x08, + "longitudes": self.data * angle, + "latitudes": self.data * angle, + "scanningMode": 0, + } + self._check(section, warn, expect_warning=warn) + + def test_j_consecutive(self): + angle = 1000 + for request_warn in [False, True]: + section = { + "basicAngleOfTheInitialProductionDomain": 1, + "subdivisionsOfBasicAngle": angle, + "resolutionAndComponentFlags": 0, + "longitudes": self.data * angle, + "latitudes": self.data * angle, + "scanningMode": 0x20, + } + self._check(section, request_warn, y_dim=1, x_dim=0) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_5.py b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_5.py new file mode 100644 index 00000000..c32b1a9e --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_5.py @@ -0,0 +1,103 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function +:func:`iris_grib._grib2_convert.grid_definition_template_5`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from copy import deepcopy +from unittest import mock + +from iris_grib._grib2_convert import grid_definition_template_5 + + +class Test(tests.IrisGribTest): + def setUp(self): + def func(s, m, y, x, c): + return m["dim_coords_and_dims"].append(item) + + module = "iris_grib._grib2_convert" + + self.major = mock.sentinel.major + self.minor = mock.sentinel.minor + self.radius = mock.sentinel.radius + + mfunc = "{}.ellipsoid_geometry".format(module) + return_value = (self.major, self.minor, self.radius) + self.patch(mfunc, return_value=return_value) + + mfunc = "{}.ellipsoid".format(module) + self.ellipsoid = mock.sentinel.ellipsoid + self.patch(mfunc, return_value=self.ellipsoid) + + mfunc = "{}.grid_definition_template_4_and_5".format(module) + self.coord = mock.sentinel.coord + self.dim = mock.sentinel.dim + item = (self.coord, self.dim) + self.patch(mfunc, side_effect=func) + + mclass = "iris.coord_systems.RotatedGeogCS" + self.cs = mock.sentinel.cs + self.patch(mclass, return_value=self.cs) + + self.metadata = { + "factories": [], + "references": [], + "standard_name": None, + "long_name": None, + "units": None, + "attributes": {}, + "cell_methods": [], + "dim_coords_and_dims": [], + "aux_coords_and_dims": [], + } + + def test(self): + metadata = deepcopy(self.metadata) + angleOfRotation = mock.sentinel.angleOfRotation + shapeOfTheEarth = mock.sentinel.shapeOfTheEarth + section = { + "latitudeOfSouthernPole": 45000000, + "longitudeOfSouthernPole": 90000000, + "angleOfRotation": angleOfRotation, + "shapeOfTheEarth": shapeOfTheEarth, + } + + # The called being tested. + grid_definition_template_5(section, metadata) + + # Note: import certain functions *dynamically*, so that we pickup the patched + # versions established by the setUp fixture. + from iris_grib._grib2_convert import ( # noqa: PLC0415 + ellipsoid_geometry, + ellipsoid, + grid_definition_template_4_and_5 as gdt_4_5, + ) + + from iris.coord_systems import RotatedGeogCS # noqa: PLC0415 + + self.assertEqual(ellipsoid_geometry.call_count, 1) + ellipsoid.assert_called_once_with( + shapeOfTheEarth, self.major, self.minor, self.radius + ) + + RotatedGeogCS.assert_called_once_with( + -45.0, 270.0, angleOfRotation, self.ellipsoid + ) + gdt_4_5.assert_called_once_with( + section, metadata, "grid_latitude", "grid_longitude", self.cs + ) + expected = deepcopy(self.metadata) + expected["dim_coords_and_dims"].append((self.coord, self.dim)) + self.assertEqual(metadata, expected) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_90.py b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_90.py new file mode 100644 index 00000000..eb0a4780 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_grid_definition_template_90.py @@ -0,0 +1,219 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for +:func:`iris_grib._grib2_convert.grid_definition_template_90`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import numpy as np + +import iris.coord_systems +import iris.coords +import iris.exceptions + +from iris_grib.tests.unit.grib2_convert import empty_metadata +from iris_grib._grib2_convert import _MDI as MDI + +from iris_grib._grib2_convert import grid_definition_template_90 + + +class Test(tests.IrisGribTest): + def uk(self): + section = { + "shapeOfTheEarth": 3, + "scaleFactorOfRadiusOfSphericalEarth": MDI, + "scaledValueOfRadiusOfSphericalEarth": MDI, + "scaleFactorOfEarthMajorAxis": 4, + "scaledValueOfEarthMajorAxis": 63781688, + "scaleFactorOfEarthMinorAxis": 4, + "scaledValueOfEarthMinorAxis": 63565840, + "Nx": 390, + "Ny": 227, + "latitudeOfSubSatellitePoint": 0, + "longitudeOfSubSatellitePoint": 0, + "resolutionAndComponentFlags": 0, + "dx": 3622, + "dy": 3610, + "Xp": 1856000, + "Yp": 1856000, + "scanningMode": 192, + "orientationOfTheGrid": 0, + "Nr": 6610674, + "Xo": 1733, + "Yo": 3320, + } + return section + + def expected_uk(self, y_dim, x_dim): + # Prepare the expectation. + expected = empty_metadata() + major = 6378168.8 + ellipsoid = iris.coord_systems.GeogCS(major, 6356584.0) + height = (6610674e-6 - 1) * major + lat = lon = 0 + easting = northing = 0 + cs = iris.coord_systems.Geostationary( + latitude_of_projection_origin=lat, + longitude_of_projection_origin=lon, + perspective_point_height=height, + sweep_angle_axis="y", + false_easting=easting, + false_northing=northing, + ellipsoid=ellipsoid, + ) + nx = 390 + x_origin = 0.010313624253429191 + dx = -8.38506036864162e-05 + x = iris.coords.DimCoord( + np.arange(nx) * dx + x_origin, + "projection_x_coordinate", + units="radians", + coord_system=cs, + ) + ny = 227 + y_origin = 0.12275487535118533 + dy = 8.384895857321404e-05 + y = iris.coords.DimCoord( + np.arange(ny) * dy + y_origin, + "projection_y_coordinate", + units="radians", + coord_system=cs, + ) + expected["dim_coords_and_dims"].append((y, y_dim)) + expected["dim_coords_and_dims"].append((x, x_dim)) + return expected + + def compare(self, metadata, expected): + # Compare the result with the expectation. + self.assertEqual( + len(metadata["dim_coords_and_dims"]), len(expected["dim_coords_and_dims"]) + ) + for result_pair, expected_pair in zip( + metadata["dim_coords_and_dims"], + expected["dim_coords_and_dims"], + strict=False, + ): + result_coord, result_dims = result_pair + expected_coord, expected_dims = expected_pair + # Take copies for safety, as we are going to modify them. + result_coord, expected_coord = [ + co.copy() for co in (result_coord, expected_coord) + ] + # Ensure the dims match. + self.assertEqual(result_dims, expected_dims) + # Ensure the coordinate systems match (allowing for precision). + result_cs = result_coord.coord_system + expected_cs = expected_coord.coord_system + self.assertEqual(type(result_cs), type(expected_cs)) + self.assertEqual( + result_cs.latitude_of_projection_origin, + expected_cs.latitude_of_projection_origin, + ) + self.assertEqual( + result_cs.longitude_of_projection_origin, + expected_cs.longitude_of_projection_origin, + ) + self.assertAlmostEqual( + result_cs.perspective_point_height, expected_cs.perspective_point_height + ) + self.assertEqual(result_cs.false_easting, expected_cs.false_easting) + self.assertEqual(result_cs.false_northing, expected_cs.false_northing) + self.assertAlmostEqual( + result_cs.ellipsoid.semi_major_axis, + expected_cs.ellipsoid.semi_major_axis, + ) + self.assertEqual( + result_cs.ellipsoid.semi_minor_axis, + expected_cs.ellipsoid.semi_minor_axis, + ) + # Now we can ignore the coordinate systems and compare the + # rest of the coordinate attributes. + result_coord.coord_system = None + expected_coord.coord_system = None + + # Likewise, first compare the points (and optional bounds) + # *approximately*, then force those equal + compare other aspects. + self.assertArrayAlmostEqual(result_coord.points, expected_coord.points) + result_coord.points = expected_coord.points + if result_coord.has_bounds() and expected_coord.has_bounds(): + self.assertArrayAlmostEqual(result_coord.bounds, expected_coord.bounds) + result_coord.bounds = expected_coord.bounds + + # Compare the coords, having equalised the array values. + self.assertEqual(result_coord, expected_coord) + + # Ensure no other metadata was created. + for name in expected.keys(): + if name == "dim_coords_and_dims": + continue + self.assertEqual(metadata[name], expected[name]) + + def test_uk(self): + section = self.uk() + metadata = empty_metadata() + grid_definition_template_90(section, metadata) + expected = self.expected_uk(0, 1) + self.compare(metadata, expected) + + def test_uk_transposed(self): + section = self.uk() + section["scanningMode"] = 0b11100000 + metadata = empty_metadata() + grid_definition_template_90(section, metadata) + expected = self.expected_uk(1, 0) + self.compare(metadata, expected) + + def test_non_zero_latitude(self): + section = self.uk() + section["latitudeOfSubSatellitePoint"] = 1 + metadata = empty_metadata() + with self.assertRaisesRegex( + iris.exceptions.TranslationError, "non-zero latitude" + ): + grid_definition_template_90(section, metadata) + + def test_rotated_meridian(self): + section = self.uk() + section["orientationOfTheGrid"] = 1 + metadata = empty_metadata() + with self.assertRaisesRegex(iris.exceptions.TranslationError, "orientation"): + grid_definition_template_90(section, metadata) + + def test_zero_height(self): + section = self.uk() + section["Nr"] = 0 + metadata = empty_metadata() + with self.assertRaisesRegex(iris.exceptions.TranslationError, "zero"): + grid_definition_template_90(section, metadata) + + def test_orthographic(self): + section = self.uk() + section["Nr"] = MDI + metadata = empty_metadata() + with self.assertRaisesRegex(iris.exceptions.TranslationError, "orthographic"): + grid_definition_template_90(section, metadata) + + def test_scanning_mode_positive_x(self): + section = self.uk() + section["scanningMode"] = 0b01000000 + metadata = empty_metadata() + with self.assertRaisesRegex(iris.exceptions.TranslationError, r"\+x"): + grid_definition_template_90(section, metadata) + + def test_scanning_mode_negative_y(self): + section = self.uk() + section["scanningMode"] = 0b10000000 + metadata = empty_metadata() + with self.assertRaisesRegex(iris.exceptions.TranslationError, "-y"): + grid_definition_template_90(section, metadata) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_other_time_coord.py b/src/iris_grib/tests/unit/grib2_convert/test_other_time_coord.py new file mode 100644 index 00000000..519d088e --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_other_time_coord.py @@ -0,0 +1,110 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.other_time_coord. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import iris.coords + +from iris_grib._grib2_convert import other_time_coord + + +class TestValid(tests.IrisGribTest): + def test_t(self): + rt = iris.coords.DimCoord(48, "time", units="hours since epoch") + fp = iris.coords.DimCoord(6, "forecast_period", units="hours") + result = other_time_coord(rt, fp) + expected = iris.coords.DimCoord( + 42, "forecast_reference_time", units="hours since epoch" + ) + self.assertEqual(result, expected) + + def test_frt(self): + rt = iris.coords.DimCoord( + 48, "forecast_reference_time", units="hours since epoch" + ) + fp = iris.coords.DimCoord(6, "forecast_period", units="hours") + result = other_time_coord(rt, fp) + expected = iris.coords.DimCoord(54, "time", units="hours since epoch") + self.assertEqual(result, expected) + + +class TestInvalid(tests.IrisGribTest): + def test_t_with_bounds(self): + rt = iris.coords.DimCoord( + 48, "time", units="hours since epoch", bounds=[36, 60] + ) + fp = iris.coords.DimCoord(6, "forecast_period", units="hours") + with self.assertRaisesRegex(ValueError, "bounds"): + other_time_coord(rt, fp) + + def test_frt_with_bounds(self): + rt = iris.coords.DimCoord( + 48, "forecast_reference_time", units="hours since epoch", bounds=[42, 54] + ) + fp = iris.coords.DimCoord(6, "forecast_period", units="hours") + with self.assertRaisesRegex(ValueError, "bounds"): + other_time_coord(rt, fp) + + def test_fp_with_bounds(self): + rt = iris.coords.DimCoord(48, "time", units="hours since epoch") + fp = iris.coords.DimCoord(6, "forecast_period", units="hours", bounds=[3, 9]) + with self.assertRaisesRegex(ValueError, "bounds"): + other_time_coord(rt, fp) + + def test_vector_t(self): + rt = iris.coords.DimCoord([0, 3], "time", units="hours since epoch") + fp = iris.coords.DimCoord(6, "forecast_period", units="hours") + with self.assertRaisesRegex(ValueError, "Vector"): + other_time_coord(rt, fp) + + def test_vector_frt(self): + rt = iris.coords.DimCoord( + [0, 3], "forecast_reference_time", units="hours since epoch" + ) + fp = iris.coords.DimCoord(6, "forecast_period", units="hours") + with self.assertRaisesRegex(ValueError, "Vector"): + other_time_coord(rt, fp) + + def test_vector_fp(self): + rt = iris.coords.DimCoord(48, "time", units="hours since epoch") + fp = iris.coords.DimCoord([6, 12], "forecast_period", units="hours") + with self.assertRaisesRegex(ValueError, "Vector"): + other_time_coord(rt, fp) + + def test_invalid_rt_name(self): + rt = iris.coords.DimCoord(1, "height") + fp = iris.coords.DimCoord(6, "forecast_period", units="hours") + with self.assertRaisesRegex(ValueError, "reference time"): + other_time_coord(rt, fp) + + def test_invalid_t_unit(self): + rt = iris.coords.DimCoord(1, "time", units="Pa") + fp = iris.coords.DimCoord(6, "forecast_period", units="hours") + with self.assertRaisesRegex(ValueError, "unit.*Pa"): + other_time_coord(rt, fp) + + def test_invalid_frt_unit(self): + rt = iris.coords.DimCoord(1, "forecast_reference_time", units="km") + fp = iris.coords.DimCoord(6, "forecast_period", units="hours") + with self.assertRaisesRegex(ValueError, "unit.*km"): + other_time_coord(rt, fp) + + def test_invalid_fp_unit(self): + rt = iris.coords.DimCoord( + 48, "forecast_reference_time", units="hours since epoch" + ) + fp = iris.coords.DimCoord(6, "forecast_period", units="kg") + with self.assertRaisesRegex(ValueError, "unit.*kg"): + other_time_coord(rt, fp) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_product_definition_section.py b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_section.py new file mode 100644 index 00000000..cca60501 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_section.py @@ -0,0 +1,134 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Tests for `iris_grib._grib2_convert.product_definition_section`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from itertools import product +from unittest import mock + +from iris.coords import DimCoord + +from iris_grib._grib2_convert import product_definition_section +from iris_grib.tests.unit.grib2_convert import empty_metadata +from iris_grib.tests.unit.grib2_convert.test_product_definition_template_0 import ( + section_4 as pdt_0_section_4, +) +from iris_grib.tests.unit.grib2_convert.test_product_definition_template_31 import ( + section_4 as pdt_31_section_4, +) + + +class TestFixedSurfaces(tests.IrisGribTest): + """ + Tests focussing on the handling of fixed surface elements in section 4. + Expects/ignores depending on the template number. + """ + + def setUp(self): + self.patch("warnings.warn") + self.translate_phenomenon_patch = self.patch( + "iris_grib._grib2_convert.translate_phenomenon" + ) + + # Prep placeholder variables for product_definition_section. + self.discipline = mock.sentinel.discipline + self.tablesVersion = mock.sentinel.tablesVersion + self.rt_coord = DimCoord( + 24, "forecast_reference_time", units="hours since epoch" + ) + self.metadata = empty_metadata() + + self.templates = {0: pdt_0_section_4(), 31: pdt_31_section_4()} + self.fixed_surface_keys = [ + "typeOfFirstFixedSurface", + "scaledValueOfFirstFixedSurface", + "typeOfSecondFixedSurface", + ] + + def _check_fixed_surface(self, fs_is_expected, fs_is_present): + """ + Whether or not fixed surface elements are expected/present in the + section 4 keys, most of the code is shared so we are using a single + function with parameters. + """ + + # Use the section 4 from either product_definition_section #1 or #31. + # #0 contains fixed surface elements, #31 does not. + template_number = 0 if fs_is_expected else 31 + section_4 = self.templates[template_number] + section_4.update( + { + "productDefinitionTemplateNumber": template_number, + "parameterCategory": None, + "parameterNumber": None, + } + ) + + for key in self.fixed_surface_keys: + # Force the presence or absence of the fixed surface elements even + # when they're respectively ignored or expected. + if fs_is_present and key not in section_4: + section_4[key] = pdt_0_section_4()[key] + elif (not fs_is_present) and key in section_4: + del section_4[key] + + def run_function(): + # For reuse in every type of test below. + product_definition_section( + section_4, + self.metadata, + self.discipline, + self.tablesVersion, + self.rt_coord, + ) + + if fs_is_expected and not fs_is_present: + # Should error since the expected keys are missing. + error_message = "FixedSurface" + with self.assertRaisesRegex(KeyError, error_message): + run_function() + else: + # Should have a successful run for all other circumstances. + + # Translate_phenomenon_patch is the end of the function, + # and should be able to accept None for the fixed surface + # arguments. So should always have run. + previous_call_count = self.translate_phenomenon_patch.call_count + run_function() + self.assertEqual( + self.translate_phenomenon_patch.call_count, previous_call_count + 1 + ) + phenom_call_args = self.translate_phenomenon_patch.call_args[1] + for key in self.fixed_surface_keys: + # Check whether None or actual values have been passed for + # the fixed surface arguments. + if fs_is_expected: + self.assertEqual(phenom_call_args[key], section_4[key]) + else: + self.assertIsNone(phenom_call_args[key]) + + def test_all_combinations(self): + """ + Test all combinations of fixed surface being expected/present + + a. Expected and Present - standard behaviour for most templates + b. Expected and Absent - unplanned combination, should error + c. Unexpected and Present - unplanned combination, should be handled + identically to (d) + d. Unexpected and Absent - standard behaviour for a few templates + e.g. #31 + """ + for pair in product([True, False], repeat=2): + self._check_fixed_surface(*pair) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_0.py b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_0.py new file mode 100644 index 00000000..e945a4b0 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_0.py @@ -0,0 +1,105 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function +:func:`iris_grib._grib2_convert.product_definition_template_0`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from iris import FUTURE as IRIS_FUTURE +import iris.coords + +import iris_grib +from iris_grib.tests.unit.grib2_convert import empty_metadata +from iris_grib.tests.unit.grib2_convert import LoadConvertTest +from iris_grib._grib2_convert import _MDI as MDI + +from iris_grib._grib2_convert import product_definition_template_0 + + +def section_4(): + return { + "hoursAfterDataCutoff": MDI, + "minutesAfterDataCutoff": MDI, + "indicatorOfUnitForForecastTime": 0, # minutes + "forecastTime": 360, + "NV": 0, + "typeOfFirstFixedSurface": 103, + "scaleFactorOfFirstFixedSurface": 0, + "scaledValueOfFirstFixedSurface": 9999, + "typeOfSecondFixedSurface": 255, + } + + +class Test(LoadConvertTest): + def test_given_frt(self): + metadata = empty_metadata() + rt_coord = iris.coords.DimCoord( + 24, "forecast_reference_time", units="hours since epoch" + ) + product_definition_template_0(section_4(), metadata, rt_coord) + expected = empty_metadata() + aux = expected["aux_coords_and_dims"] + aux.append((iris.coords.DimCoord(6, "forecast_period", units="hours"), None)) + aux.append((iris.coords.DimCoord(30, "time", units="hours since epoch"), None)) + aux.append((rt_coord, None)) + aux.append((iris.coords.DimCoord(9999, long_name="height", units="m"), None)) + self.assertMetadataEqual(metadata, expected) + + def test_given_t(self): + metadata = empty_metadata() + rt_coord = iris.coords.DimCoord(24, "time", units="hours since epoch") + product_definition_template_0(section_4(), metadata, rt_coord) + expected = empty_metadata() + aux = expected["aux_coords_and_dims"] + aux.append((iris.coords.DimCoord(6, "forecast_period", units="hours"), None)) + aux.append( + ( + iris.coords.DimCoord( + 18, "forecast_reference_time", units="hours since epoch" + ), + None, + ) + ) + aux.append((rt_coord, None)) + aux.append((iris.coords.DimCoord(9999, long_name="height", units="m"), None)) + self.assertMetadataEqual(metadata, expected) + + def test_generating_process_warnings(self): + metadata = empty_metadata() + rt_coord = iris.coords.DimCoord( + 24, "forecast_reference_time", units="hours since epoch" + ) + convert_options = iris_grib._grib2_convert.options + emit_warnings = convert_options.warn_on_unsupported + try: + convert_options.warn_on_unsupported = True + future_kwargs = {} + if hasattr(IRIS_FUTURE, "date_microseconds"): + # Avoid an additional awkward warning + # TODO: convert regime to pytest + tidy, but that is a lot of work ! + future_kwargs["date_microseconds"] = True + with IRIS_FUTURE.context(**future_kwargs): + with mock.patch("warnings.warn") as warn: + product_definition_template_0(section_4(), metadata, rt_coord) + warn_msgs = [call[1][0] for call in warn.mock_calls] + expected = [ + "Unable to translate type of generating process.", + "Unable to translate background generating process identifier.", + "Unable to translate forecast generating process identifier.", + ] + self.assertEqual(warn_msgs, expected) + finally: + convert_options.warn_on_unsupported = emit_warnings + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_1.py b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_1.py new file mode 100644 index 00000000..eaf6f1a3 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_1.py @@ -0,0 +1,80 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function +:func:`iris_grib._grib2_convert.product_definition_template_1`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from copy import deepcopy +from unittest import mock +import warnings + +from iris.coords import DimCoord + +from iris_grib._grib2_convert import product_definition_template_1 + + +class Test(tests.IrisGribTest): + def setUp(self): + def func(s, m, f): + return m["cell_methods"].append(self.cell_method) + + module = "iris_grib._grib2_convert" + self.patch("warnings.warn") + this = "{}.product_definition_template_0".format(module) + self.cell_method = mock.sentinel.cell_method + self.patch(this, side_effect=func) + self.metadata = { + "factories": [], + "references": [], + "standard_name": None, + "long_name": None, + "units": None, + "attributes": {}, + "cell_methods": [], + "dim_coords_and_dims": [], + "aux_coords_and_dims": [], + } + + def _check(self, request_warning): + this = "iris_grib._grib2_convert.options" + with mock.patch(this, warn_on_unsupported=request_warning): + metadata = deepcopy(self.metadata) + perturbationNumber = 666 + section = {"perturbationNumber": perturbationNumber} + forecast_reference_time = mock.sentinel.forecast_reference_time + # The called being tested. + product_definition_template_1(section, metadata, forecast_reference_time) + expected = deepcopy(self.metadata) + expected["cell_methods"].append(self.cell_method) + realization = DimCoord( + perturbationNumber, standard_name="realization", units="no_unit" + ) + expected["aux_coords_and_dims"].append((realization, None)) + self.assertEqual(metadata, expected) + if request_warning: + warn_msgs = [mcall[1][0] for mcall in warnings.warn.mock_calls] + expected_msgs = ["type of ensemble", "number of forecasts"] + for emsg in expected_msgs: + matches = [wmsg for wmsg in warn_msgs if emsg in wmsg] + self.assertEqual(len(matches), 1) + warn_msgs.remove(matches[0]) + else: + self.assertEqual(len(warnings.warn.mock_calls), 0) + + def test_pdt_no_warn(self): + self._check(False) + + def test_pdt_warn(self): + self._check(True) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_10.py b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_10.py new file mode 100644 index 00000000..4da9e4b5 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_10.py @@ -0,0 +1,68 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.product_definition_template_10`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from iris.coords import DimCoord +from iris_grib._grib2_convert import product_definition_template_10 +from iris_grib.tests.unit.grib2_convert import empty_metadata + + +class Test(tests.IrisGribTest): + def setUp(self): + module = "iris_grib._grib2_convert" + self.patch_statistical_fp_coord = self.patch( + module + ".statistical_forecast_period_coord", + return_value=mock.sentinel.dummy_fp_coord, + ) + self.patch_time_coord = self.patch( + module + ".validity_time_coord", return_value=mock.sentinel.dummy_time_coord + ) + self.patch_vertical_coords = self.patch(module + ".vertical_coords") + + def test_percentile_coord(self): + metadata = empty_metadata() + percentileValue = 75 + section = { + "productDefinitionTemplateNumber": 10, + "percentileValue": percentileValue, + "hoursAfterDataCutoff": 1, + "minutesAfterDataCutoff": 1, + "numberOfTimeRange": 1, + "typeOfStatisticalProcessing": 1, + "typeOfTimeIncrement": 2, + "timeIncrement": 0, + "yearOfEndOfOverallTimeInterval": 2000, + "monthOfEndOfOverallTimeInterval": 1, + "dayOfEndOfOverallTimeInterval": 1, + "hourOfEndOfOverallTimeInterval": 1, + "minuteOfEndOfOverallTimeInterval": 0, + "secondOfEndOfOverallTimeInterval": 1, + } + forecast_reference_time = mock.Mock() + # The called being tested. + product_definition_template_10(section, metadata, forecast_reference_time) + + expected = {"aux_coords_and_dims": []} + percentile = DimCoord( + percentileValue, long_name="percentile_over_time", units="no_unit" + ) + expected["aux_coords_and_dims"].append((percentile, None)) + + self.assertEqual( + metadata["aux_coords_and_dims"][-1], expected["aux_coords_and_dims"][0] + ) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_11.py b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_11.py new file mode 100644 index 00000000..41f04e99 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_11.py @@ -0,0 +1,107 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.product_definition_template_11`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from copy import deepcopy +from unittest import mock +import warnings + +from iris.coords import DimCoord, CellMethod + +from iris_grib._grib2_convert import product_definition_template_11 + + +class Test(tests.IrisGribTest): + def setUp(self): + def func(s, m, f): + return m["cell_methods"].append(self.cell_method) + + module = "iris_grib._grib2_convert" + self.patch("warnings.warn") + this_module = "{}.product_definition_template_11".format(module) + self.cell_method = mock.sentinel.cell_method + self.patch(this_module, side_effect=func) + self.patch_statistical_fp_coord = self.patch( + module + ".statistical_forecast_period_coord", + return_value=mock.sentinel.dummy_fp_coord, + ) + self.patch_time_coord = self.patch( + module + ".validity_time_coord", return_value=mock.sentinel.dummy_time_coord + ) + self.patch_vertical_coords = self.patch(module + ".vertical_coords") + self.metadata = { + "factories": [], + "references": [], + "standard_name": None, + "long_name": None, + "units": None, + "attributes": {}, + "cell_methods": [], + "dim_coords_and_dims": [], + "aux_coords_and_dims": [], + } + + def _check(self, request_warning): + grib_lconv_opt = "iris_grib._grib2_convert.options" + with mock.patch(grib_lconv_opt, warn_on_unsupported=request_warning): + metadata = deepcopy(self.metadata) + perturbationNumber = 666 + section = { + "productDefinitionTemplateNumber": 11, + "perturbationNumber": perturbationNumber, + "hoursAfterDataCutoff": 1, + "minutesAfterDataCutoff": 1, + "numberOfTimeRange": 1, + "typeOfStatisticalProcessing": 1, + "typeOfTimeIncrement": 2, + "timeIncrement": 0, + "yearOfEndOfOverallTimeInterval": 2000, + "monthOfEndOfOverallTimeInterval": 1, + "dayOfEndOfOverallTimeInterval": 1, + "hourOfEndOfOverallTimeInterval": 1, + "minuteOfEndOfOverallTimeInterval": 0, + "secondOfEndOfOverallTimeInterval": 1, + } + forecast_reference_time = mock.Mock() + # The called being tested. + product_definition_template_11(section, metadata, forecast_reference_time) + expected = {"cell_methods": [], "aux_coords_and_dims": []} + expected["cell_methods"].append(CellMethod(method="sum", coords=("time",))) + realization = DimCoord( + perturbationNumber, standard_name="realization", units="no_unit" + ) + expected["aux_coords_and_dims"].append((realization, None)) + self.maxDiff = None + self.assertEqual( + metadata["aux_coords_and_dims"][-1], expected["aux_coords_and_dims"][0] + ) + self.assertEqual(metadata["cell_methods"][-1], expected["cell_methods"][0]) + + if request_warning: + warn_msgs = [mcall[1][0] for mcall in warnings.warn.mock_calls] + expected_msgs = ["type of ensemble", "number of forecasts"] + for emsg in expected_msgs: + matches = [wmsg for wmsg in warn_msgs if emsg in wmsg] + self.assertEqual(len(matches), 1) + warn_msgs.remove(matches[0]) + else: + self.assertEqual(len(warnings.warn.mock_calls), 0) + + def test_pdt_no_warn(self): + self._check(False) + + def test_pdt_warn(self): + self._check(True) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_15.py b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_15.py new file mode 100644 index 00000000..634109f9 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_15.py @@ -0,0 +1,99 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function +:func:`iris_grib._grib2_convert.product_definition_template_15`. + +This basically copies code from 'test_product_definition_template_0', and adds +testing for the statistical method and spatial-processing type. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import iris.coords +from iris.exceptions import TranslationError +from iris.coords import CellMethod, DimCoord + +from iris_grib.tests.unit.grib2_convert import empty_metadata +from iris_grib.tests.unit.grib2_convert import LoadConvertTest +from iris_grib._grib2_convert import _MDI as MDI + +from iris_grib._grib2_convert import product_definition_template_15 + + +def section_4_sample(): + # Create a dictionary representing a sample section 4 from a grib file. + return { + "productDefinitionTemplateNumber": 15, + "hoursAfterDataCutoff": MDI, + "minutesAfterDataCutoff": MDI, + "indicatorOfUnitForForecastTime": 0, # minutes + "forecastTime": 360, + "NV": 0, + "typeOfFirstFixedSurface": 103, + "scaleFactorOfFirstFixedSurface": 0, + "scaledValueOfFirstFixedSurface": 9999, + "typeOfSecondFixedSurface": 255, + "statisticalProcess": 2, # method = maximum + "spatialProcessing": 0, # from source grid, no interpolation + "numberOfPointsUsed": 0, # no points used because no interpolation + } + + +class Test(LoadConvertTest): + def setUp(self): + self.time_coord = DimCoord(24, "time", units="hours since epoch") + self.forecast_period_coord = DimCoord(6, "forecast_period", units="hours") + self.forecast_ref_time_coord = DimCoord( + 18, "forecast_reference_time", units="hours since epoch" + ) + self.height_coord = iris.coords.DimCoord(9999, long_name="height", units="m") + + def _translate(self, section): + # Use pdt 4.15 to populate a metadata dict from the section 4 keys + metadata = empty_metadata() + product_definition_template_15(section, metadata, self.time_coord) + return metadata + + def test_translation(self): + # Generate metadata from running our sample section through pdt 4.15. + metadata = self._translate(section_4_sample()) + + # Generate a fresh metadata dict and manually populate it with metadata + # that we expect will be generated from our sample section. + expected = empty_metadata() + aux = expected["aux_coords_and_dims"] + aux.append((self.forecast_period_coord, None)) + aux.append((self.forecast_ref_time_coord, None)) + aux.append((self.time_coord, None)) + aux.append((self.height_coord, None)) + + expected["cell_methods"] = [CellMethod(coords=("area",), method="maximum")] + expected["attributes"]["spatial_processing_type"] = "No interpolation" + + # Now check that the section conversion produces the metadata we + # expect. + self.assertMetadataEqual(metadata, expected) + + def test_bad_statistic_method(self): + section = section_4_sample() + section["statisticalProcess"] = 999 + msg = r"unsupported statistical process type \[999\]" + with self.assertRaisesRegex(TranslationError, msg): + self._translate(section) + + def test_bad_spatial_processing_code(self): + section = section_4_sample() + section["spatialProcessing"] = 999 + msg = r"unsupported spatial processing type \[999\]" + with self.assertRaisesRegex(TranslationError, msg): + self._translate(section) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_31.py b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_31.py new file mode 100644 index 00000000..1b6873ed --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_31.py @@ -0,0 +1,64 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Tests for `iris_grib._grib2_convert.product_definition_template_31`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from iris_grib.tests.unit.grib2_convert import empty_metadata + +from iris_grib._grib2_convert import product_definition_template_31 + + +def section_4(): + # Also needed for test_product_definition_section.py. + series = mock.sentinel.satelliteSeries + number = mock.sentinel.satelliteNumber + instrument = mock.sentinel.instrumentType + return { + "NB": 1, + "satelliteSeries": series, + "satelliteNumber": number, + "instrumentType": instrument, + "scaleFactorOfCentralWaveNumber": 1, + "scaledValueOfCentralWaveNumber": 12, + } + + +class Test(tests.IrisGribTest): + def setUp(self): + self.patch("warnings.warn") + self.satellite_common_patch = self.patch( + "iris_grib._grib2_convert.satellite_common" + ) + self.generating_process_patch = self.patch( + "iris_grib._grib2_convert.generating_process" + ) + + def test(self): + # Prepare the arguments. + rt_coord = mock.sentinel.observation_time + section = section_4() + + # Call the function. + metadata = empty_metadata() + product_definition_template_31(section, metadata, rt_coord) + + # Check that 'satellite_common' was called. + self.assertEqual(self.satellite_common_patch.call_count, 1) + # Check that 'generating_process' was called. + self.assertEqual(self.generating_process_patch.call_count, 1) + # Check that the scalar time coord was added in. + self.assertIn((rt_coord, None), metadata["aux_coords_and_dims"]) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_32.py b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_32.py new file mode 100644 index 00000000..eea51943 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_32.py @@ -0,0 +1,64 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Tests for `iris_grib._grib2_convert.product_definition_template_32`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from iris_grib.tests.unit.grib2_convert import empty_metadata +from iris_grib._grib2_convert import product_definition_template_32 + + +class Test(tests.IrisGribTest): + def setUp(self): + self.patch("warnings.warn") + self.generating_process_patch = self.patch( + "iris_grib._grib2_convert.generating_process" + ) + self.satellite_common_patch = self.patch( + "iris_grib._grib2_convert.satellite_common" + ) + self.time_coords_patch = self.patch("iris_grib._grib2_convert.time_coords") + self.data_cutoff_patch = self.patch("iris_grib._grib2_convert.data_cutoff") + + def test(self): + # Prepare the arguments. + series = mock.sentinel.satelliteSeries + number = mock.sentinel.satelliteNumber + instrument = mock.sentinel.instrumentType + rt_coord = mock.sentinel.observation_time + section = { + "NB": 1, + "hoursAfterDataCutoff": None, + "minutesAfterDataCutoff": None, + "satelliteSeries": series, + "satelliteNumber": number, + "instrumentType": instrument, + "scaleFactorOfCentralWaveNumber": 1, + "scaledValueOfCentralWaveNumber": 12, + } + + # Call the function. + metadata = empty_metadata() + product_definition_template_32(section, metadata, rt_coord) + + # Check that 'satellite_common' was called. + self.assertEqual(self.satellite_common_patch.call_count, 1) + # Check that 'generating_process' was called. + self.assertEqual(self.generating_process_patch.call_count, 1) + # Check that 'data_cutoff' was called. + self.assertEqual(self.data_cutoff_patch.call_count, 1) + # Check that 'time_coords' was called. + self.assertEqual(self.time_coords_patch.call_count, 1) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_40.py b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_40.py new file mode 100644 index 00000000..07b99ea2 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_40.py @@ -0,0 +1,47 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.product_definition_template_40`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import iris.coords + +from iris_grib._grib2_convert import product_definition_template_40, _MDI +from iris_grib.tests.unit.grib2_convert import empty_metadata + + +class Test(tests.IrisGribTest): + def setUp(self): + self.section_4 = { + "hoursAfterDataCutoff": _MDI, + "minutesAfterDataCutoff": _MDI, + "constituentType": 1, + "indicatorOfUnitForForecastTime": 0, # minutes + "startStep": 360, + "NV": 0, + "typeOfFirstFixedSurface": 103, + "scaleFactorOfFirstFixedSurface": 0, + "scaledValueOfFirstFixedSurface": 9999, + "typeOfSecondFixedSurface": 255, + } + + def test_constituent_type(self): + metadata = empty_metadata() + rt_coord = iris.coords.DimCoord( + 24, "forecast_reference_time", units="hours since epoch" + ) + product_definition_template_40(self.section_4, metadata, rt_coord) + expected = empty_metadata() + expected["attributes"]["WMO_constituent_type"] = 1 + self.assertEqual(metadata["attributes"], expected["attributes"]) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_5.py b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_5.py new file mode 100644 index 00000000..b3557275 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_5.py @@ -0,0 +1,128 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Tests for function +:func:`iris_grib._grib2_convert.product_definition_template_5`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from iris.exceptions import TranslationError +from iris.coords import DimCoord + +from iris_grib._grib2_convert import product_definition_template_5 +from iris_grib._grib2_convert import Probability, _MDI +from .test_product_definition_template_0 import section_4 + + +class Test(tests.IrisGribTest): + def setUp(self): + # Create patches for called routines + module = "iris_grib._grib2_convert" + self.patch_pdt0_call = self.patch(module + ".product_definition_template_0") + # Construct dummy call arguments + self.section = section_4() + self.section["probabilityType"] = 1 + self.section["scaledValueOfUpperLimit"] = 53 + self.section["scaleFactorOfUpperLimit"] = 1 + self.frt_coord = DimCoord( + 24, "forecast_reference_time", units="hours since epoch" + ) + self.metadata = { + "cell_methods": [], + "aux_coords_and_dims": [], + } + + def test_basic(self): + result = product_definition_template_5( + self.section, self.metadata, self.frt_coord + ) + # Check expected functions were called. + self.assertEqual( + self.patch_pdt0_call.call_args_list, + [mock.call(self.section, self.metadata, self.frt_coord)], + ) + # Check metadata content (N.B. cell_method has been removed!). + self.assertEqual(self.metadata, {"cell_methods": [], "aux_coords_and_dims": []}) + # Check result. + self.assertEqual(result, Probability("above_threshold", 5.3)) + + def test_below_upper_threshold(self): + self.section["probabilityType"] = 4 + result = product_definition_template_5( + self.section, self.metadata, self.frt_coord + ) + # Check result. + self.assertEqual(result, Probability("below_threshold", 5.3)) + + def test_above_lower_threshold(self): + self.section["probabilityType"] = 3 + self.section["scaledValueOfUpperLimit"] = None + self.section["scaleFactorOfUpperLimit"] = None + self.section["scaledValueOfLowerLimit"] = 53 + self.section["scaleFactorOfLowerLimit"] = 1 + result = product_definition_template_5( + self.section, self.metadata, self.frt_coord + ) + # Check result. + self.assertEqual(result, Probability("above_threshold", 5.3)) + + def test_below_lower_threshold(self): + self.section["probabilityType"] = 0 + self.section["scaledValueOfUpperLimit"] = None + self.section["scaleFactorOfUpperLimit"] = None + self.section["scaledValueOfLowerLimit"] = 53 + self.section["scaleFactorOfLowerLimit"] = 1 + + result = product_definition_template_5( + self.section, self.metadata, self.frt_coord + ) + # Check result. + self.assertEqual(result, Probability("below_threshold", 5.3)) + + def test_fail_bad_probability_type(self): + self.section["probabilityType"] = 17 + with self.assertRaisesRegex(TranslationError, "unsupported probability type"): + product_definition_template_5(self.section, self.metadata, self.frt_coord) + + def test_fail_bad_threshold_value(self): + self.section["scaledValueOfUpperLimit"] = _MDI + with self.assertRaisesRegex( + TranslationError, "missing scaled value of upper limit" + ): + product_definition_template_5(self.section, self.metadata, self.frt_coord) + + def test_fail_bad_threshold_scalefactor(self): + self.section["scaleFactorOfUpperLimit"] = _MDI + with self.assertRaisesRegex( + TranslationError, "missing scale factor of upper limit" + ): + product_definition_template_5(self.section, self.metadata, self.frt_coord) + + def test_fail_bad_lower_threshold_value(self): + self.section["probabilityType"] = 0 + self.section["scaledValueOfLowerLimit"] = _MDI + with self.assertRaisesRegex( + TranslationError, "missing scaled value of lower limit" + ): + product_definition_template_5(self.section, self.metadata, self.frt_coord) + + def test_fail_bad_lower_threshold_scalefactor(self): + self.section["probabilityType"] = 0 + self.section["scaledValueOfLowerLimit"] = 1 + self.section["scaleFactorOfLowerLimit"] = _MDI + with self.assertRaisesRegex( + TranslationError, "missing scale factor of lower limit" + ): + product_definition_template_5(self.section, self.metadata, self.frt_coord) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_6.py b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_6.py new file mode 100644 index 00000000..4d011ece --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_6.py @@ -0,0 +1,68 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function +:func:`iris_grib._grib2_convert.product_definition_template_6`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from copy import deepcopy +from unittest import mock + +from iris.coords import DimCoord + +from iris_grib._grib2_convert import product_definition_template_6 + + +class Test(tests.IrisGribTest): + def setUp(self): + def func(s, m, f): + return m["cell_methods"].append(self.cell_method) + + module = "iris_grib._grib2_convert" + self.patch("warnings.warn") + this = "{}.product_definition_template_0".format(module) + self.cell_method = mock.sentinel.cell_method + self.patch(this, side_effect=func) + self.metadata = { + "factories": [], + "references": [], + "standard_name": None, + "long_name": None, + "units": None, + "attributes": {}, + "cell_methods": [], + "dim_coords_and_dims": [], + "aux_coords_and_dims": [], + } + + def _check(self, request_warning): + this = "iris_grib._grib2_convert.options" + with mock.patch(this, warn_on_unsupported=request_warning): + metadata = deepcopy(self.metadata) + percentile = 50 + section = {"percentileValue": percentile} + forecast_reference_time = mock.sentinel.forecast_reference_time + # The called being tested. + product_definition_template_6(section, metadata, forecast_reference_time) + expected = deepcopy(self.metadata) + expected["cell_methods"].append(self.cell_method) + percentile = DimCoord(percentile, long_name="percentile", units="%") + expected["aux_coords_and_dims"].append((percentile, None)) + self.assertEqual(metadata, expected) + + def test_pdt_no_warn(self): + self._check(False) + + def test_pdt_warn(self): + self._check(True) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_8.py b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_8.py new file mode 100644 index 00000000..39c9e242 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_8.py @@ -0,0 +1,73 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Tests for function +:func:`iris_grib._grib2_convert.product_definition_template_8`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from iris_grib._grib2_convert import product_definition_template_8 + + +class Test(tests.IrisGribTest): + def setUp(self): + module = "iris_grib._grib2_convert" + self.module = module + # Create patches for called routines + self.patch_generating_process = self.patch(module + ".generating_process") + self.patch_data_cutoff = self.patch(module + ".data_cutoff") + self.patch_statistical_cell_method = self.patch( + module + ".statistical_cell_method", + return_value=mock.sentinel.dummy_cell_method, + ) + self.patch_statistical_fp_coord = self.patch( + module + ".statistical_forecast_period_coord", + return_value=mock.sentinel.dummy_fp_coord, + ) + self.patch_time_coord = self.patch( + module + ".validity_time_coord", return_value=mock.sentinel.dummy_time_coord + ) + self.patch_vertical_coords = self.patch(module + ".vertical_coords") + # Construct dummy call arguments + self.section = {} + self.section["hoursAfterDataCutoff"] = mock.sentinel.cutoff_hours + self.section["minutesAfterDataCutoff"] = mock.sentinel.cutoff_mins + self.frt_coord = mock.Mock() + self.metadata = {"cell_methods": [], "aux_coords_and_dims": []} + + def test_basic(self): + product_definition_template_8(self.section, self.metadata, self.frt_coord) + # Check all expected functions were called just once. + self.assertEqual(self.patch_generating_process.call_count, 1) + self.assertEqual(self.patch_data_cutoff.call_count, 1) + self.assertEqual(self.patch_statistical_cell_method.call_count, 1) + self.assertEqual(self.patch_statistical_fp_coord.call_count, 1) + self.assertEqual(self.patch_time_coord.call_count, 1) + self.assertEqual(self.patch_vertical_coords.call_count, 1) + # Check metadata content. + self.assertEqual( + sorted(self.metadata.keys()), ["aux_coords_and_dims", "cell_methods"] + ) + self.assertEqual( + self.metadata["cell_methods"], [mock.sentinel.dummy_cell_method] + ) + self.assertCountEqual( + self.metadata["aux_coords_and_dims"], + [ + (self.frt_coord, None), + (mock.sentinel.dummy_fp_coord, None), + (mock.sentinel.dummy_time_coord, None), + ], + ) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_9.py b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_9.py new file mode 100644 index 00000000..ae2a1ca0 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_product_definition_template_9.py @@ -0,0 +1,91 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Tests for function +:func:`iris_grib._grib2_convert.product_definition_template_9`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from iris.exceptions import TranslationError + +from iris_grib._grib2_convert import product_definition_template_9 +from iris_grib._grib2_convert import Probability, _MDI + + +class Test(tests.IrisGribTest): + def setUp(self): + # Create patches for called routines + module = "iris_grib._grib2_convert" + self.patch_pdt8_call = self.patch(module + ".product_definition_template_8") + # Construct dummy call arguments + self.section = {} + self.section["probabilityType"] = 1 + self.section["scaledValueOfUpperLimit"] = 53 + self.section["scaleFactorOfUpperLimit"] = 1 + self.frt_coord = mock.sentinel.frt_coord + self.metadata = { + "cell_methods": [mock.sentinel.cell_method], + "aux_coords_and_dims": [], + } + + def test_basic(self): + result = product_definition_template_9( + self.section, self.metadata, self.frt_coord + ) + # Check expected function was called. + self.assertEqual( + self.patch_pdt8_call.call_args_list, + [mock.call(self.section, self.metadata, self.frt_coord)], + ) + # Check metadata content (N.B. cell_method has been removed!). + self.assertEqual(self.metadata, {"cell_methods": [], "aux_coords_and_dims": []}) + # Check result. + self.assertEqual(result, Probability("above_threshold", 5.3)) + + def test_fail_bad_probability_type(self): + self.section["probabilityType"] = 17 + with self.assertRaisesRegex(TranslationError, "unsupported probability type"): + product_definition_template_9(self.section, self.metadata, self.frt_coord) + + def test_fail_bad_threshold_value(self): + self.section["scaledValueOfUpperLimit"] = _MDI + with self.assertRaisesRegex( + TranslationError, "missing scaled value of upper limit" + ): + product_definition_template_9(self.section, self.metadata, self.frt_coord) + + def test_fail_bad_threshold_scalefactor(self): + self.section["scaleFactorOfUpperLimit"] = _MDI + with self.assertRaisesRegex( + TranslationError, "missing scale factor of upper limit" + ): + product_definition_template_9(self.section, self.metadata, self.frt_coord) + + def test_fail_bad_lower_threshold_value(self): + self.section["probabilityType"] = 0 + self.section["scaledValueOfLowerLimit"] = _MDI + with self.assertRaisesRegex( + TranslationError, "missing scaled value of lower limit" + ): + product_definition_template_9(self.section, self.metadata, self.frt_coord) + + def test_fail_bad_lower_threshold_scalefactor(self): + self.section["probabilityType"] = 0 + self.section["scaledValueOfLowerLimit"] = 1 + self.section["scaleFactorOfLowerLimit"] = _MDI + with self.assertRaisesRegex( + TranslationError, "missing scale factor of lower limit" + ): + product_definition_template_9(self.section, self.metadata, self.frt_coord) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_projection_centre.py b/src/iris_grib/tests/unit/grib2_convert/test_projection_centre.py new file mode 100644 index 00000000..58e3a2dd --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_projection_centre.py @@ -0,0 +1,36 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.projection centre. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from iris_grib._grib2_convert import projection_centre, ProjectionCentre + + +class Test(tests.IrisGribTest): + def test_unset(self): + expected = ProjectionCentre(False, False) + self.assertEqual(projection_centre(0x0), expected) + + def test_bipolar_and_symmetric(self): + expected = ProjectionCentre(False, True) + self.assertEqual(projection_centre(0x40), expected) + + def test_south_pole_on_projection_plane(self): + expected = ProjectionCentre(True, False) + self.assertEqual(projection_centre(0x80), expected) + + def test_both(self): + expected = ProjectionCentre(True, True) + self.assertEqual(projection_centre(0xC0), expected) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_reference_time_coord.py b/src/iris_grib/tests/unit/grib2_convert/test_reference_time_coord.py new file mode 100644 index 00000000..5a9efbde --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_reference_time_coord.py @@ -0,0 +1,78 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.reference_time_coord. + +Reference Code Table 1.2. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from copy import deepcopy +from datetime import datetime + +from cf_units import CALENDAR_GREGORIAN, Unit + +from iris.coords import DimCoord +from iris.exceptions import TranslationError + +from iris_grib._grib2_convert import reference_time_coord + + +class Test(tests.IrisGribTest): + def setUp(self): + self.section = { + "year": 2007, + "month": 1, + "day": 15, + "hour": 0, + "minute": 3, + "second": 0, + } + self.unit = Unit("hours since epoch", calendar=CALENDAR_GREGORIAN) + dt = datetime( + self.section["year"], + self.section["month"], + self.section["day"], + self.section["hour"], + self.section["minute"], + self.section["second"], + ) + self.point = self.unit.date2num(dt) + + def _check(self, section, standard_name=None): + expected = DimCoord(self.point, standard_name=standard_name, units=self.unit) + # The call being tested. + coord = reference_time_coord(section) + self.assertEqual(coord, expected) + + def test_start_of_forecast__0(self): + section = deepcopy(self.section) + section["significanceOfReferenceTime"] = 0 + self._check(section, "forecast_reference_time") + + def test_start_of_forecast__1(self): + section = deepcopy(self.section) + section["significanceOfReferenceTime"] = 1 + self._check(section, "forecast_reference_time") + + def test_observation_time(self): + section = deepcopy(self.section) + section["significanceOfReferenceTime"] = 3 + self._check(section, "time") + + def test_unknown_significance(self): + section = deepcopy(self.section) + section["significanceOfReferenceTime"] = 5 + emsg = "unsupported significance" + with self.assertRaisesRegex(TranslationError, emsg): + self._check(section) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_resolution_flags.py b/src/iris_grib/tests/unit/grib2_convert/test_resolution_flags.py new file mode 100644 index 00000000..9c738b8e --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_resolution_flags.py @@ -0,0 +1,36 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.resolution_flags.` + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from iris_grib._grib2_convert import resolution_flags, ResolutionFlags + + +class Test(tests.IrisGribTest): + def test_unset(self): + expected = ResolutionFlags(False, False, False) + self.assertEqual(resolution_flags(0x0), expected) + + def test_i_increments_given(self): + expected = ResolutionFlags(True, False, False) + self.assertEqual(resolution_flags(0x20), expected) + + def test_j_increments_given(self): + expected = ResolutionFlags(False, True, False) + self.assertEqual(resolution_flags(0x10), expected) + + def test_uv_resolved(self): + expected = ResolutionFlags(False, False, True) + self.assertEqual(resolution_flags(0x08), expected) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_satellite_common.py b/src/iris_grib/tests/unit/grib2_convert/test_satellite_common.py new file mode 100644 index 00000000..c5309441 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_satellite_common.py @@ -0,0 +1,70 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Tests for `iris_grib._grib2_convert.satellite_common`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from iris.coords import AuxCoord +import numpy as np + +from iris_grib.tests.unit.grib2_convert import empty_metadata + +from iris_grib._grib2_convert import satellite_common + + +class Test(tests.IrisGribTest): + def _check(self, factors=1, values=111): + # Prepare the arguments. + series = mock.sentinel.satelliteSeries + number = mock.sentinel.satelliteNumber + instrument = mock.sentinel.instrumentType + section = { + "NB": 1, + "satelliteSeries": series, + "satelliteNumber": number, + "instrumentType": instrument, + "scaleFactorOfCentralWaveNumber": factors, + "scaledValueOfCentralWaveNumber": values, + } + + # Call the function. + metadata = empty_metadata() + satellite_common(section, metadata) + + # Check the result. + expected = empty_metadata() + coord = AuxCoord(series, long_name="satellite_series", units=1) + expected["aux_coords_and_dims"].append((coord, None)) + coord = AuxCoord(number, long_name="satellite_number", units=1) + expected["aux_coords_and_dims"].append((coord, None)) + coord = AuxCoord(instrument, long_name="instrument_type", units=1) + expected["aux_coords_and_dims"].append((coord, None)) + standard_name = "sensor_band_central_radiation_wavenumber" + coord = AuxCoord( + values / (10.0**factors), standard_name=standard_name, units="m-1" + ) + expected["aux_coords_and_dims"].append((coord, None)) + self.assertEqual(metadata, expected) + + def test_basic(self): + self._check() + + def test_multiple_wavelengths(self): + # Check with multiple values, and several different scaling factors. + values = np.array([1, 11, 123, 1975]) + for i_factor in (-3, -1, 0, 1, 3): + factors = np.ones(values.shape) * i_factor + self._check(values=values, factors=factors) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_scanning_mode.py b/src/iris_grib/tests/unit/grib2_convert/test_scanning_mode.py new file mode 100644 index 00000000..1c6089d5 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_scanning_mode.py @@ -0,0 +1,48 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.scanning_mode. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from iris.exceptions import TranslationError + +from iris_grib._grib2_convert import scanning_mode, ScanningMode + + +class Test(tests.IrisGribTest): + def test_unset(self): + expected = ScanningMode(False, False, False, False) + self.assertEqual(scanning_mode(0x0), expected) + + def test_i_negative(self): + expected = ScanningMode( + i_negative=True, j_positive=False, j_consecutive=False, i_alternative=False + ) + self.assertEqual(scanning_mode(0x80), expected) + + def test_j_positive(self): + expected = ScanningMode( + i_negative=False, j_positive=True, j_consecutive=False, i_alternative=False + ) + self.assertEqual(scanning_mode(0x40), expected) + + def test_j_consecutive(self): + expected = ScanningMode( + i_negative=False, j_positive=False, j_consecutive=True, i_alternative=False + ) + self.assertEqual(scanning_mode(0x20), expected) + + def test_i_alternative(self): + with self.assertRaises(TranslationError): + scanning_mode(0x10) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_statistical_cell_method.py b/src/iris_grib/tests/unit/grib2_convert/test_statistical_cell_method.py new file mode 100644 index 00000000..cedd173e --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_statistical_cell_method.py @@ -0,0 +1,120 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Tests for function +:func:`iris_grib._grib2_convert.statistical_cell_method`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from iris.coords import CellMethod +from iris.exceptions import TranslationError + +from iris_grib._grib2_convert import statistical_cell_method + + +class Test(tests.IrisGribTest): + def setUp(self): + self.section = {} + self.section["productDefinitionTemplateNumber"] = 8 + self.section["numberOfTimeRange"] = 1 + self.section["typeOfStatisticalProcessing"] = 0 + self.section["typeOfTimeIncrement"] = 2 + self.section["timeIncrement"] = 0 + + def expected_cell_method(self, coords=("time",), method="mean", intervals=None): + keys = dict(coords=coords, method=method, intervals=intervals) + cell_method = CellMethod(**keys) + return cell_method + + def test_basic(self): + cell_method = statistical_cell_method(self.section) + self.assertEqual(cell_method, self.expected_cell_method()) + + def test_intervals(self): + self.section["timeIncrement"] = 3 + self.section["indicatorOfUnitForTimeIncrement"] = 1 + cell_method = statistical_cell_method(self.section) + self.assertEqual(cell_method, self.expected_cell_method(intervals=("3 hours",))) + + def test_increment_missing(self): + self.section["timeIncrement"] = 2**32 - 1 + self.section["indicatorOfUnitForTimeIncrement"] = 255 + cell_method = statistical_cell_method(self.section) + self.assertEqual(cell_method, self.expected_cell_method()) + + def test_different_statistic(self): + self.section["typeOfStatisticalProcessing"] = 6 + cell_method = statistical_cell_method(self.section) + self.assertEqual( + cell_method, self.expected_cell_method(method="standard_deviation") + ) + + def test_fail_bad_ranges(self): + self.section["numberOfTimeRange"] = 0 + with self.assertRaisesRegex( + TranslationError, 'aggregation over "0 time ranges"' + ): + statistical_cell_method(self.section) + + def test_fail_multiple_ranges(self): + self.section["numberOfTimeRange"] = 2 + with self.assertRaisesRegex(TranslationError, r"multiple time ranges \[2\]"): + statistical_cell_method(self.section) + + def test_fail_unknown_statistic(self): + self.section["typeOfStatisticalProcessing"] = 17 + with self.assertRaisesRegex( + TranslationError, r"contains an unsupported statistical process type \[17\]" + ): + statistical_cell_method(self.section) + + def test_fail_bad_increment_type(self): + self.section["typeOfTimeIncrement"] = 7 + with self.assertRaisesRegex( + TranslationError, r"time-increment type \[7\] is not supported" + ): + statistical_cell_method(self.section) + + def test_pdt_9(self): + # Should behave the same as PDT 4.8. + self.section["productDefinitionTemplateNumber"] = 9 + cell_method = statistical_cell_method(self.section) + self.assertEqual(cell_method, self.expected_cell_method()) + + def test_pdt_10(self): + # Should behave the same as PDT 4.8. + self.section["productDefinitionTemplateNumber"] = 10 + cell_method = statistical_cell_method(self.section) + self.assertEqual(cell_method, self.expected_cell_method()) + + def test_pdt_11(self): + # Should behave the same as PDT 4.8. + self.section["productDefinitionTemplateNumber"] = 11 + cell_method = statistical_cell_method(self.section) + self.assertEqual(cell_method, self.expected_cell_method()) + + def test_pdt_15(self): + # Encoded slightly differently to PDT 4.8. + self.section["productDefinitionTemplateNumber"] = 15 + test_code = self.section["typeOfStatisticalProcessing"] + del self.section["typeOfStatisticalProcessing"] + self.section["statisticalProcess"] = test_code + cell_method = statistical_cell_method(self.section) + self.assertEqual(cell_method, self.expected_cell_method()) + + def test_fail_unsupported_pdt(self): + # Rejects PDTs other than the ones tested above. + self.section["productDefinitionTemplateNumber"] = 101 + msg = "can't get statistical method for unsupported pdt : 4.101" + with self.assertRaisesRegex(ValueError, msg): + statistical_cell_method(self.section) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_statistical_forecast_period_coord.py b/src/iris_grib/tests/unit/grib2_convert/test_statistical_forecast_period_coord.py new file mode 100644 index 00000000..1438d661 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_statistical_forecast_period_coord.py @@ -0,0 +1,62 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Tests for function :func:`iris_grib._grib2_convert.statistical_forecast_period`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import datetime +from unittest import mock + +from iris_grib._grib2_convert import statistical_forecast_period_coord + + +class Test(tests.IrisGribTest): + def setUp(self): + module = "iris_grib._grib2_convert" + self.module = module + self.patch_hindcast = self.patch(module + "._hindcast_fix") + self.forecast_seconds = 0.0 + self.forecast_units = mock.Mock() + self.forecast_units.convert = lambda x, y: self.forecast_seconds + self.patch(module + ".time_range_unit", return_value=self.forecast_units) + self.frt_coord = mock.Mock() + self.frt_coord.points = [1] + self.frt_coord.units.num2date = mock.Mock( + return_value=datetime.datetime(2010, 2, 3) + ) + self.section = {} + self.section["yearOfEndOfOverallTimeInterval"] = 2010 + self.section["monthOfEndOfOverallTimeInterval"] = 2 + self.section["dayOfEndOfOverallTimeInterval"] = 3 + self.section["hourOfEndOfOverallTimeInterval"] = 8 + self.section["minuteOfEndOfOverallTimeInterval"] = 0 + self.section["secondOfEndOfOverallTimeInterval"] = 0 + self.section["forecastTime"] = mock.Mock() + self.section["indicatorOfUnitForForecastTime"] = mock.Mock() + + def test_basic(self): + coord = statistical_forecast_period_coord(self.section, self.frt_coord) + self.assertEqual(coord.standard_name, "forecast_period") + self.assertEqual(coord.units, "hours") + self.assertArrayAlmostEqual(coord.points, [4.0]) + self.assertArrayAlmostEqual(coord.bounds, [[0.0, 8.0]]) + + def test_with_hindcast(self): + _ = statistical_forecast_period_coord(self.section, self.frt_coord) + self.assertEqual(self.patch_hindcast.call_count, 1) + + def test_no_hindcast(self): + self.patch(self.module + ".options.support_hindcast_values", False) + _ = statistical_forecast_period_coord(self.section, self.frt_coord) + self.assertEqual(self.patch_hindcast.call_count, 0) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_time_range_unit.py b/src/iris_grib/tests/unit/grib2_convert/test_time_range_unit.py new file mode 100644 index 00000000..1ed0fac6 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_time_range_unit.py @@ -0,0 +1,44 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.time_range_unit. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from cf_units import Unit +from iris.exceptions import TranslationError + +from iris_grib._grib2_convert import time_range_unit + + +class Test(tests.IrisGribTest): + def setUp(self): + self.unit_by_indicator = { + 0: Unit("minutes"), + 1: Unit("hours"), + 2: Unit("days"), + 10: Unit("3 hours"), + 11: Unit("6 hours"), + 12: Unit("12 hours"), + 13: Unit("seconds"), + } + + def test_units(self): + for indicator, unit in self.unit_by_indicator.items(): + result = time_range_unit(indicator) + self.assertEqual(result, unit) + + def test_bad_indicator(self): + emsg = "unsupported time range" + with self.assertRaisesRegex(TranslationError, emsg): + time_range_unit(-1) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_translate_phenomenon.py b/src/iris_grib/tests/unit/grib2_convert/test_translate_phenomenon.py new file mode 100644 index 00000000..a4104834 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_translate_phenomenon.py @@ -0,0 +1,70 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Tests for function :func:`iris_grib._grib2_convert.translate_phenomenon`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from cf_units import Unit +from iris.coords import DimCoord + +from iris_grib._grib2_convert import Probability, translate_phenomenon +from iris_grib.grib_phenom_translation import Grib1CfData, GRIBCode + + +class Test_probability(tests.IrisGribTest): + def setUp(self): + # Patch inner call to return a given phenomenon type. + target_module = "iris_grib._grib2_convert" + self.phenom_lookup_patch = self.patch( + target_module + ".itranslation.grib2_phenom_to_cf_info", + return_value=Grib1CfData("air_temperature", "", "K", None), + ) + # Construct dummy call arguments + self.probability = Probability("", 22.0) + self.metadata = {"aux_coords_and_dims": [], "attributes": {}} + + def test_basic(self): + translate_phenomenon( + self.metadata, 7, 8, 9, None, None, None, probability=self.probability + ) + # Check metadata. + thresh_coord = DimCoord( + [22.0], standard_name="air_temperature", long_name="", units="K" + ) + self.assertEqual( + self.metadata, + { + "standard_name": None, + "long_name": "probability_of_air_temperature_", + "units": Unit(1), + "aux_coords_and_dims": [(thresh_coord, None)], + "attributes": {"GRIB_PARAM": GRIBCode(2, 7, 8, 9)}, + }, + ) + + def test_no_phenomenon(self): + self.phenom_lookup_patch.return_value = None + expected_metadata = self.metadata.copy() + translate_phenomenon( + self.metadata, + discipline=7, + parameterCategory=77, + parameterNumber=777, + typeOfFirstFixedSurface=None, + scaledValueOfFirstFixedSurface=None, + typeOfSecondFixedSurface=None, + probability=self.probability, + ) + expected_metadata["attributes"]["GRIB_PARAM"] = GRIBCode(2, 7, 77, 777) + self.assertEqual(self.metadata, expected_metadata) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_unscale.py b/src/iris_grib/tests/unit/grib2_convert/test_unscale.py new file mode 100644 index 00000000..90211a86 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_unscale.py @@ -0,0 +1,54 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.unscale. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +import numpy as np +import numpy.ma as ma + +from iris_grib._grib2_convert import unscale, _MDI as MDI + +# Reference GRIB2 Regulation 92.1.12. + + +class Test(tests.IrisGribTest): + def test_single(self): + self.assertEqual(unscale(123, 1), 12.3) + self.assertEqual(unscale(123, -1), 1230.0) + self.assertEqual(unscale(123, 2), 1.23) + self.assertEqual(unscale(123, -2), 12300.0) + + def test_single_mdi(self): + self.assertIs(unscale(10, MDI), ma.masked) + self.assertIs(unscale(MDI, 1), ma.masked) + + def test_array(self): + items = [ + [1, [0.1, 1.2, 12.3, 123.4]], + [-1, [10.0, 120.0, 1230.0, 12340.0]], + [2, [0.01, 0.12, 1.23, 12.34]], + [-2, [100.0, 1200.0, 12300.0, 123400.0]], + ] + values = np.array([1, 12, 123, 1234]) + for factor, expected in items: + result = unscale(values, [factor] * values.size) + self.assertFalse(ma.isMaskedArray(result)) + np.testing.assert_array_equal(result, np.array(expected)) + + def test_array_mdi(self): + result = unscale([1, MDI, 100, 1000], [1, 1, 1, MDI]) + self.assertTrue(ma.isMaskedArray(result)) + expected = ma.masked_values([0.1, 0, 10.0, 0], 0) + np.testing.assert_array_almost_equal(result, expected) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_validity_time_coord.py b/src/iris_grib/tests/unit/grib2_convert/test_validity_time_coord.py new file mode 100644 index 00000000..deb805e1 --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_validity_time_coord.py @@ -0,0 +1,71 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.validity_time_coord. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from cf_units import Unit +from iris.coords import DimCoord +import numpy as np + +from iris_grib._grib2_convert import validity_time_coord + + +class Test(tests.IrisGribTest): + def setUp(self): + self.fp = DimCoord(5, standard_name="forecast_period", units="hours") + self.fp_test_bounds = np.array([[1.0, 9.0]]) + self.unit = Unit("hours since epoch") + self.frt = DimCoord( + 10, standard_name="forecast_reference_time", units=self.unit + ) + + def test_frt_shape(self): + frt = mock.Mock(shape=(2,)) + fp = mock.Mock(shape=(1,)) + emsg = "scalar forecast reference time" + with self.assertRaisesRegex(ValueError, emsg): + validity_time_coord(frt, fp) + + def test_fp_shape(self): + frt = mock.Mock(shape=(1,)) + fp = mock.Mock(shape=(2,)) + emsg = "scalar forecast period" + with self.assertRaisesRegex(ValueError, emsg): + validity_time_coord(frt, fp) + + def test(self): + coord = validity_time_coord(self.frt, self.fp) + self.assertIsInstance(coord, DimCoord) + self.assertEqual(coord.standard_name, "time") + self.assertEqual(coord.units, self.unit) + self.assertEqual(coord.shape, (1,)) + point = self.frt.points[0] + self.fp.points[0] + self.assertEqual(coord.points[0], point) + self.assertFalse(coord.has_bounds()) + + def test_bounded(self): + self.fp.bounds = self.fp_test_bounds + coord = validity_time_coord(self.frt, self.fp) + self.assertIsInstance(coord, DimCoord) + self.assertEqual(coord.standard_name, "time") + self.assertEqual(coord.units, self.unit) + self.assertEqual(coord.shape, (1,)) + point = self.frt.points[0] + self.fp.points[0] + self.assertEqual(coord.points[0], point) + self.assertTrue(coord.has_bounds()) + bounds = self.frt.points[0] + self.fp_test_bounds + self.assertArrayAlmostEqual(coord.bounds, bounds) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib2_convert/test_vertical_coords.py b/src/iris_grib/tests/unit/grib2_convert/test_vertical_coords.py new file mode 100644 index 00000000..bfc574ff --- /dev/null +++ b/src/iris_grib/tests/unit/grib2_convert/test_vertical_coords.py @@ -0,0 +1,327 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Test function :func:`iris_grib._grib2_convert.vertical_coords`. + +""" + +# import iris_grib.tests first so that some things can be initialised +# before importing anything else. +import iris_grib.tests as tests +import numpy as np + +from copy import deepcopy +from unittest import mock + +from iris.coords import AuxCoord, DimCoord +from iris.exceptions import TranslationError + +from iris_grib._grib2_convert import vertical_coords +from iris_grib._grib2_convert import ( + _TYPE_OF_FIXED_SURFACE_MISSING as MISSING_SURFACE, + _MDI as MISSING_LEVEL, +) + + +class Test(tests.IrisGribTest): + def setUp(self): + self.metadata = { + "factories": [], + "references": [], + "standard_name": None, + "long_name": None, + "units": None, + "attributes": {}, + "cell_methods": [], + "dim_coords_and_dims": [], + "aux_coords_and_dims": [], + } + + def test_hybrid_factories(self): + def func(section, metadata): + return metadata["factories"].append(factory) + + metadata = deepcopy(self.metadata) + section = {"NV": 1} + this = "iris_grib._grib2_convert.hybrid_factories" + factory = mock.sentinel.factory + with mock.patch(this, side_effect=func) as hybrid_factories: + vertical_coords(section, metadata) + self.assertTrue(hybrid_factories.called) + self.assertEqual(metadata["factories"], [factory]) + + def test_no_first_fixed_surface(self): + metadata = deepcopy(self.metadata) + section = { + "NV": 0, + "typeOfFirstFixedSurface": MISSING_SURFACE, + "scaledValueOfFirstFixedSurface": MISSING_LEVEL, + } + vertical_coords(section, metadata) + self.assertEqual(metadata, self.metadata) + + def test_fixed_surface_type_1(self): + metadata = deepcopy(self.metadata) + section = { + "NV": 0, + "typeOfFirstFixedSurface": 1, + "scaledValueOfFirstFixedSurface": 0, + "scaleFactorOfFirstFixedSurface": 0, + "typeOfSecondFixedSurface": 255, + } + vertical_coords(section, metadata) + # No metadata change, as surfaceType=1 translates to "no vertical + # coord" without error or warning. + self.assertEqual(metadata, self.metadata) + + def test_fixed_surface_type_1_missing_scaled_value(self): + """The missing scaled value is correctly ignored for ground level which + produces no coord + """ + metadata = deepcopy(self.metadata) + section = { + "NV": 0, + "typeOfFirstFixedSurface": 1, + "scaledValueOfFirstFixedSurface": MISSING_LEVEL, + "scaleFactorOfFirstFixedSurface": 0, + "typeOfSecondFixedSurface": 255, + } + vertical_coords(section, metadata) + # No metadata change, as surfaceType=1 translates to "no vertical + # coord" without error or warning. + self.assertEqual(metadata, self.metadata) + + def test_fixed_lower_surface_type_4_missing_scaled_value(self): + """The missing scaled value is correctly ignored for a lower fixed surface that + produces a coord + """ + metadata = deepcopy(self.metadata) + section = { + "NV": 0, + "typeOfFirstFixedSurface": 4, + "scaledValueOfFirstFixedSurface": MISSING_LEVEL, + "scaleFactorOfFirstFixedSurface": 0, + "typeOfSecondFixedSurface": 255, + } + vertical_coords(section, metadata) + coord = DimCoord(0.0, long_name="air_temperature", units="Celsius") + expected = deepcopy(self.metadata) + expected["aux_coords_and_dims"].append((coord, None)) + self.assertEqual(metadata, expected) + + def test_fixed_upper_surface_type_4_missing_scaled_value(self): + """The missing scaled value is correctly ignored for an upper fixed surface that + produces a coord + """ + metadata = deepcopy(self.metadata) + section = { + "NV": 0, + "typeOfSecondFixedSurface": 4, + "scaledValueOfSecondFixedSurface": MISSING_LEVEL, + "scaleFactorOfSecondFixedSurface": 0, + "typeOfFirstFixedSurface": 1, # ground level + } + vertical_coords(section, metadata) + expected = deepcopy(self.metadata) + coord = AuxCoord( + 0.0, + long_name="height", + units="m", + bounds=np.ma.masked_array([0.0, 0.0], [False, True]), + ) + expected["aux_coords_and_dims"].append((coord, None)) + coord = AuxCoord( + 0.0, + long_name="air_temperature", + units="Celsius", + bounds=np.ma.masked_array([0.0, 0.0], [True, False]), + ) + expected["aux_coords_and_dims"].append((coord, None)) + self.assertEqual(metadata, expected) + + def test_unknown_first_fixed_surface_with_missing_scaled_value(self): + this = "iris_grib._grib2_convert.options" + with mock.patch("warnings.warn") as warn: + with mock.patch(this) as options: + for request_warning in [False, True]: + options.warn_on_unsupported = request_warning + metadata = deepcopy(self.metadata) + section = { + "NV": 0, + "typeOfFirstFixedSurface": 0, + "scaledValueOfFirstFixedSurface": MISSING_LEVEL, + } + # The call being tested. + vertical_coords(section, metadata) + self.assertEqual(metadata, self.metadata) + if request_warning: + self.assertEqual(len(warn.mock_calls), 1) + args, _ = warn.call_args + self.assertIn("surface with missing scaled value", args[0]) + else: + self.assertEqual(len(warn.mock_calls), 0) + + def test_unknown_first_fixed_surface(self): + metadata = deepcopy(self.metadata) + expected = deepcopy(self.metadata) + coord = DimCoord(600.0, attributes={"GRIB_fixed_surface_type": 106}) + expected["aux_coords_and_dims"].append((coord, None)) + + section = { + "NV": 0, + "typeOfFirstFixedSurface": 106, + "scaledValueOfFirstFixedSurface": 600, + "scaleFactorOfFirstFixedSurface": 0, + "typeOfSecondFixedSurface": MISSING_SURFACE, + } + vertical_coords(section, metadata) + self.assertEqual(metadata, expected) + + def test_unknown_first_fixed_surface_with_second_fixed_surface(self): + metadata = deepcopy(self.metadata) + expected = deepcopy(self.metadata) + coord = DimCoord( + 9000.0, bounds=[18000, 0], attributes={"GRIB_fixed_surface_type": 108} + ) + expected["aux_coords_and_dims"].append((coord, None)) + + section = { + "NV": 0, + "typeOfFirstFixedSurface": 108, + "scaledValueOfFirstFixedSurface": 18000, + "scaleFactorOfFirstFixedSurface": 0, + "typeOfSecondFixedSurface": 108, + "scaledValueOfSecondFixedSurface": 0, + "scaleFactorOfSecondFixedSurface": 0, + } + vertical_coords(section, metadata) + self.assertEqual(metadata, expected) + + def test_pressure_with_no_second_fixed_surface(self): + metadata = deepcopy(self.metadata) + section = { + "NV": 0, + "typeOfFirstFixedSurface": 100, # pressure / Pa + "scaledValueOfFirstFixedSurface": 10, + "scaleFactorOfFirstFixedSurface": 1, + "typeOfSecondFixedSurface": MISSING_SURFACE, + } + vertical_coords(section, metadata) + coord = DimCoord(1.0, long_name="pressure", units="Pa") + expected = deepcopy(self.metadata) + expected["aux_coords_and_dims"].append((coord, None)) + self.assertEqual(metadata, expected) + + def test_height_with_no_second_fixed_surface(self): + metadata = deepcopy(self.metadata) + section = { + "NV": 0, + "typeOfFirstFixedSurface": 103, # height / m + "scaledValueOfFirstFixedSurface": 100, + "scaleFactorOfFirstFixedSurface": 2, + "typeOfSecondFixedSurface": MISSING_SURFACE, + } + vertical_coords(section, metadata) + coord = DimCoord(1.0, long_name="height", units="m") + expected = deepcopy(self.metadata) + expected["aux_coords_and_dims"].append((coord, None)) + self.assertEqual(metadata, expected) + + def test_different_fixed_surfaces_same_parameter(self): + metadata = deepcopy(self.metadata) + section = { + "NV": 0, + "typeOfFirstFixedSurface": 103, + "scaledValueOfFirstFixedSurface": 10, + "scaleFactorOfFirstFixedSurface": 1, + "typeOfSecondFixedSurface": 1, + } + vertical_coords(section, metadata) + coord = DimCoord(0.5, long_name="height", units="m", bounds=[1.0, 0.0]) + expected = deepcopy(self.metadata) + expected["aux_coords_and_dims"].append((coord, None)) + self.assertEqual(metadata, expected) + + def test_different_fixed_surfaces_different_parameter(self): + metadata = deepcopy(self.metadata) + section = { + "NV": 0, + "typeOfFirstFixedSurface": 100, + "scaledValueOfFirstFixedSurface": 10, + "scaleFactorOfFirstFixedSurface": 1, + "typeOfSecondFixedSurface": 1, + } + vertical_coords(section, metadata) + coords = [ + AuxCoord( + 1.0, + long_name="pressure", + units="Pa", + bounds=np.ma.masked_array([1.0, 0.0], [False, True]), + ), + AuxCoord( + 0.0, + long_name="height", + units="m", + bounds=np.ma.masked_array([1.0, 0.0], [True, False]), + ), + ] + expected = deepcopy(self.metadata) + [expected["aux_coords_and_dims"].append((coord, None)) for coord in coords] + self.assertEqual(metadata, expected) + + def test_same_fixed_surfaces_missing_second_scaled_value(self): + section = { + "NV": 0, + "typeOfFirstFixedSurface": 100, + "scaledValueOfFirstFixedSurface": 10, + "scaleFactorOfFirstFixedSurface": 1, + "typeOfSecondFixedSurface": 100, + "scaledValueOfSecondFixedSurface": MISSING_LEVEL, + } + emsg = ( + "Unable to translate type of Second fixed surface with missing scaled " + "value." + ) + with self.assertRaisesRegex(TranslationError, emsg): + vertical_coords(section, None) + + def test_pressure_with_second_fixed_surface(self): + metadata = deepcopy(self.metadata) + section = { + "NV": 0, + "typeOfFirstFixedSurface": 100, + "scaledValueOfFirstFixedSurface": 10, + "scaleFactorOfFirstFixedSurface": 1, + "typeOfSecondFixedSurface": 100, + "scaledValueOfSecondFixedSurface": 30, + "scaleFactorOfSecondFixedSurface": 1, + } + vertical_coords(section, metadata) + coord = DimCoord(2.0, long_name="pressure", units="Pa", bounds=[1.0, 3.0]) + expected = deepcopy(self.metadata) + expected["aux_coords_and_dims"].append((coord, None)) + self.assertEqual(metadata, expected) + + def test_height_with_second_fixed_surface(self): + metadata = deepcopy(self.metadata) + section = { + "NV": 0, + "typeOfFirstFixedSurface": 103, + "scaledValueOfFirstFixedSurface": 1000, + "scaleFactorOfFirstFixedSurface": 2, + "typeOfSecondFixedSurface": 103, + "scaledValueOfSecondFixedSurface": 3000, + "scaleFactorOfSecondFixedSurface": 2, + } + vertical_coords(section, metadata) + coord = DimCoord(20.0, long_name="height", units="m", bounds=[10.0, 30.0]) + expected = deepcopy(self.metadata) + expected["aux_coords_and_dims"].append((coord, None)) + self.assertEqual(metadata, expected) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/grib_phenom_translation/__init__.py b/src/iris_grib/tests/unit/grib_phenom_translation/__init__.py new file mode 100644 index 00000000..a0167a36 --- /dev/null +++ b/src/iris_grib/tests/unit/grib_phenom_translation/__init__.py @@ -0,0 +1,5 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for the :mod:`iris_grib.grib_phenom_translation` package.""" diff --git a/src/iris_grib/tests/unit/grib_phenom_translation/test_grib_phenom_translation.py b/src/iris_grib/tests/unit/grib_phenom_translation/test_grib_phenom_translation.py new file mode 100644 index 00000000..a8fafb8b --- /dev/null +++ b/src/iris_grib/tests/unit/grib_phenom_translation/test_grib_phenom_translation.py @@ -0,0 +1,407 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for the mod:`iris_grib.grib_phenom_translation` module. + +Carried over from old iris/tests/test_grib_phenom_translation.py. +Code is out of step with current test conventions and standards. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import cf_units + +import iris_grib.grib_phenom_translation as gptx +from iris_grib.grib_phenom_translation import GRIBCode + + +class TestGribLookupTableType(tests.IrisTest): + def test_lookuptable_type(self): + ll = gptx._LookupTable([("a", 1), ("b", 2)]) + self.assertEqual(1, ll["a"]) + self.assertIsNone(ll["q"]) + ll["q"] = 15 + self.assertEqual(15, ll["q"]) + ll["q"] = 15 + self.assertEqual(15, ll["q"]) + with self.assertRaises(KeyError): + ll["q"] = 7 + del ll["q"] + ll["q"] = 7 + self.assertEqual(7, ll["q"]) + + +class TestGribPhenomenonLookup(tests.IrisTest): + def test_grib1_cf_lookup(self): + def check_grib1_cf( + param, + standard_name, + long_name, + units, + height=None, + t2version=128, + centre=98, + expect_none=False, + ): + a_cf_unit = cf_units.Unit(units) + cfdata = gptx.grib1_phenom_to_cf_info( + param_number=param, table2_version=t2version, centre_number=centre + ) + if expect_none: + self.assertIsNone(cfdata) + else: + self.assertEqual(cfdata.standard_name, standard_name) + self.assertEqual(cfdata.long_name, long_name) + self.assertEqual(cfdata.units, a_cf_unit) + if height is None: + self.assertIsNone(cfdata.set_height) + else: + self.assertEqual(cfdata.set_height, float(height)) + + check_grib1_cf(165, "x_wind", None, "m s-1", 10.0) + check_grib1_cf(168, "dew_point_temperature", None, "K", 2) + check_grib1_cf(130, "air_temperature", None, "K") + check_grib1_cf(235, None, "grib_skin_temperature", "K") + check_grib1_cf( + 235, None, "grib_skin_temperature", "K", t2version=9999, expect_none=True + ) + check_grib1_cf( + 235, None, "grib_skin_temperature", "K", centre=9999, expect_none=True + ) + check_grib1_cf(9999, None, "grib_skin_temperature", "K", expect_none=True) + + def test_grib2_cf_lookup(self): + def check_grib2_cf( + discipline, + category, + number, + standard_name, + long_name, + units, + expect_none=False, + ): + a_cf_unit = cf_units.Unit(units) + cfdata = gptx.grib2_phenom_to_cf_info( + param_discipline=discipline, + param_category=category, + param_number=number, + ) + if expect_none: + self.assertIsNone(cfdata) + else: + self.assertEqual(cfdata.standard_name, standard_name) + self.assertEqual(cfdata.long_name, long_name) + self.assertEqual(cfdata.units, a_cf_unit) + + # These should work + check_grib2_cf(0, 0, 2, "air_potential_temperature", None, "K") + check_grib2_cf(0, 19, 1, None, "grib_physical_atmosphere_albedo", "%") + check_grib2_cf(2, 0, 2, "soil_temperature", None, "K") + check_grib2_cf(10, 2, 0, "sea_ice_area_fraction", None, 1) + check_grib2_cf(2, 0, 0, "land_area_fraction", None, 1) + check_grib2_cf(0, 19, 1, None, "grib_physical_atmosphere_albedo", "%") + check_grib2_cf( + 0, 1, 64, "atmosphere_mass_content_of_water_vapor", None, "kg m-2" + ) + check_grib2_cf(2, 0, 7, "surface_altitude", None, "m") + + # These should fail + check_grib2_cf(9999, 2, 0, "sea_ice_area_fraction", None, 1, expect_none=True) + check_grib2_cf(10, 9999, 0, "sea_ice_area_fraction", None, 1, expect_none=True) + check_grib2_cf(10, 2, 9999, "sea_ice_area_fraction", None, 1, expect_none=True) + + def test_cf_grib2_lookup(self): + def check_cf_grib2( + standard_name, + long_name, + discipline, + category, + number, + units, + expect_none=False, + ): + a_cf_unit = cf_units.Unit(units) + gribdata = gptx.cf_phenom_to_grib2_info(standard_name, long_name) + if expect_none: + self.assertIsNone(gribdata) + else: + self.assertEqual(gribdata.discipline, discipline) + self.assertEqual(gribdata.category, category) + self.assertEqual(gribdata.number, number) + self.assertEqual(gribdata.units, a_cf_unit) + + # These should work + check_cf_grib2("sea_surface_temperature", None, 10, 3, 0, "K") + check_cf_grib2("air_temperature", None, 0, 0, 0, "K") + check_cf_grib2("soil_temperature", None, 2, 0, 2, "K") + check_cf_grib2("land_area_fraction", None, 2, 0, 0, "1") + check_cf_grib2("land_binary_mask", None, 2, 0, 0, "1") + check_cf_grib2( + "atmosphere_mass_content_of_water_vapor", None, 0, 1, 64, "kg m-2" + ) + check_cf_grib2("surface_altitude", None, 2, 0, 7, "m") + + # These should fail + check_cf_grib2("air_temperature", "user_long_UNRECOGNISED", 0, 0, 0, "K") + check_cf_grib2( + "air_temperature_UNRECOGNISED", None, 0, 0, 0, "K", expect_none=True + ) + check_cf_grib2(None, "user_long_UNRECOGNISED", 0, 0, 0, "K", expect_none=True) + check_cf_grib2(None, "precipitable_water", 0, 1, 3, "kg m-2") + check_cf_grib2( + "invalid_unknown", "precipitable_water", 0, 1, 3, "kg m-2", expect_none=True + ) + check_cf_grib2(None, None, 0, 0, 0, "", expect_none=True) + + +class TestGRIBcode(tests.IrisTest): + # GRIBCode is basically a namedtuple, so not all behaviour needs testing. + # However, creation is a bit special so exercise all those cases. + + # TODO: convert to pytest + replace duplications with parameterisation + # (mostly grib1/grib2, but also in one case str/repr) + def test_create_from_keys__grib2(self): + gribcode = GRIBCode(edition=2, discipline=7, category=4, number=199) + self.assertEqual(gribcode.edition, 2) + self.assertEqual(gribcode.discipline, 7) + self.assertEqual(gribcode.category, 4) + self.assertEqual(gribcode.number, 199) + + def test_create_from_keys__grib1(self): + gribcode = GRIBCode(edition=1, table_version=7, centre_number=4, number=199) + self.assertEqual(gribcode.edition, 1) + self.assertEqual(gribcode.table_version, 7) + self.assertEqual(gribcode.centre_number, 4) + self.assertEqual(gribcode.number, 199) + + def test_create_from_args__grib2(self): + gribcode = GRIBCode(2, 3, 12, 99) + self.assertEqual(gribcode.edition, 2) + self.assertEqual(gribcode.discipline, 3) + self.assertEqual(gribcode.category, 12) + self.assertEqual(gribcode.number, 99) + + def test_create_from_args__grib1(self): + gribcode = GRIBCode(1, 3, 12, 99) + self.assertEqual(gribcode.edition, 1) + self.assertEqual(gribcode.table_version, 3) + self.assertEqual(gribcode.centre_number, 12) + self.assertEqual(gribcode.number, 99) + + def check_create_is_copy(self, edition): + gribcode1 = GRIBCode(edition, 3, 12, 99) + gribcode2 = GRIBCode(edition, 3, 12, 99) + self.assertEqual(gribcode1, gribcode2) + self.assertIsNot(gribcode1, gribcode2) + + def test_create_is_copy__grib1(self): + self.check_create_is_copy(edition=1) + + def test_create_is_copy__grib2(self): + self.check_create_is_copy(edition=2) + + def check_create_from_gribcode(self, edition): + gribcode1 = GRIBCode((edition, 3, 2, 1)) + gribcode2 = GRIBCode(gribcode1) + self.assertEqual(gribcode1, gribcode2) + # NOTE: *not* passthrough : it creates a copy + # (though maybe not too significant, as it is immutable anyway?) + self.assertIsNot(gribcode1, gribcode2) + + def test_create_from_gribcode__grib1(self): + self.check_create_from_gribcode(edition=1) + + def test_create_from_gribcode__grib2(self): + self.check_create_from_gribcode(edition=2) + + def check_create_from_string(self, edition): + gribcode = GRIBCode(f"xxx{edition}xs-34 -5,678qqqq") + # NOTE: args 2 and 3 are *not* negative. + self.assertEqual(gribcode, GRIBCode(edition, 34, 5, 678)) + + def test_create_from_string__grib1(self): + self.check_create_from_string(edition=1) + + def test_create_from_string__grib2(self): + self.check_create_from_string(edition=2) + + def check_create_from_own_string(self, string_function, edition): + # Check that GRIBCode string reprs are valid as create arguments. + gribcode = GRIBCode(edition, 17, 94, 231) + grib_param_string = string_function(gribcode) + newcode = GRIBCode(grib_param_string) + self.assertEqual(newcode, gribcode) + + def test_create_from_own_string__str__grib1(self): + self.check_create_from_own_string(str, edition=1) + + def test_create_from_own_string__str__grib2(self): + self.check_create_from_own_string(str, edition=2) + + def test_create_from_own_string__repr__grib1(self): + self.check_create_from_own_string(repr, edition=1) + + def test_create_from_own_string__repr__grib2(self): + self.check_create_from_own_string(repr, edition=2) + + def check_create_from_tuple(self, edition): + gribcode = GRIBCode((edition, 3, 2, 1)) + expected = GRIBCode(edition, 3, 2, 1) + self.assertEqual(expected, gribcode) + + def test_create_from_tuple__grib1(self): + self.check_create_from_tuple(edition=1) + + def test_create_from_tuple__grib2(self): + self.check_create_from_tuple(edition=2) + + def test_create_bad_nargs(self): + # Between 1 and 4 args is not invalid call syntax, but it should fail. + msg = ( + "Cannot create.* from 2 arguments.*" + r"GRIBCode\(\(1, 2\)\).*" + "expects either 1 or 4 arguments" + ) + with self.assertRaisesRegex(ValueError, msg): + GRIBCode(1, 2) + + def test_create_bad_single_arg_None(self): + msg = ( + "Cannot create GRIBCode from 0 arguments.*" + r"GRIBCode\(\(\)\).*" + "expects either 1 or 4 arguments" + ) + with self.assertRaisesRegex(ValueError, msg): + GRIBCode(None) + + def test_create_bad_single_arg_empty_string(self): + msg = ( + "Invalid argument for GRIBCode creation.*" + r"GRIBCode\(''\).*" + "requires 4 numbers, separated by non-numerals" + ) + with self.assertRaisesRegex(ValueError, msg): + GRIBCode("") + + def test_create_bad_single_arg_nonums(self): + msg = ( + "Invalid argument for GRIBCode creation.*" + r"GRIBCode\('saas- dsa- '\).*" + "requires 4 numbers, separated by non-numerals" + ) + with self.assertRaisesRegex(ValueError, msg): + GRIBCode("saas- dsa- ") + + def test_create_bad_single_arg_less_than_4_nums(self): + msg = ( + "Invalid argument for GRIBCode creation.*" + r"GRIBCode\('1,2,3'\).*" + "requires 4 numbers, separated by non-numerals" + ) + with self.assertRaisesRegex(ValueError, msg): + GRIBCode("1,2,3") + + def test_create_bad_single_arg_number(self): + msg = ( + "Invalid argument for GRIBCode creation.*" + r"GRIBCode\('4'\).*" + "requires 4 numbers, separated by non-numerals" + ) + with self.assertRaisesRegex(ValueError, msg): + GRIBCode(4) + + def test_create_bad_single_arg_single_numeric(self): + msg = ( + "Invalid argument for GRIBCode creation.*" + r"GRIBCode\('44'\).*" + "requires 4 numbers, separated by non-numerals" + ) + with self.assertRaisesRegex(ValueError, msg): + GRIBCode("44") + + def test_create_string_more_than_4_nums(self): + # Note: does not error, just discards the extra. + gribcode = GRIBCode("1,2,3,4,5,6,7,8") + self.assertEqual(gribcode, GRIBCode(1, 2, 3, 4)) + + def check__str__(self, edition): + result = str(GRIBCode(edition, 17, 3, 123)) + arg1_char = {1: "t", 2: "d"}[edition] + expected = f"GRIB{edition}:{arg1_char}017c003n123" + self.assertEqual(expected, result) + + def test__str__grib1(self): + self.check__str__(edition=1) + + def test__str__grib2(self): + self.check__str__(edition=2) + + def check__repr__(self, edition): + result = repr(GRIBCode(edition, 17, 3, 123)) + if edition == 1: + expected = ( + "GRIBCode(edition=1, table_version=17, centre_number=3, number=123)" + ) + elif edition == 2: + expected = "GRIBCode(edition=2, discipline=17, category=3, number=123)" + self.assertEqual(result, expected) + + def test__repr__grib1(self): + self.check__repr__(edition=1) + + def test__repr__grib2(self): + self.check__repr__(edition=2) + + def test_bad_content__str_repr__badedition(self): + gribcode = GRIBCode(2, 11, 12, 13) + gribcode.edition = 77 + str_result = str(gribcode) + expected = ( + "" + ) + self.assertEqual(expected, str_result) + repr_result = repr(gribcode) + self.assertEqual(str_result, repr_result) + + def test_bad_content__str_repr__badmembervalue(self): + gribcode = GRIBCode(2, 11, 12, 13) + gribcode.discipline = None + str_result = str(gribcode) + expected = ( + "" + ) + self.assertEqual(expected, str_result) + repr_result = repr(gribcode) + self.assertEqual(str_result, repr_result) + + def test_bad_content__str_repr__missingmember(self): + gribcode = GRIBCode(2, 11, 12, 13) + del gribcode.category + str_result = str(gribcode) + expected = ( + "" + ) + self.assertEqual(expected, str_result) + repr_result = repr(gribcode) + self.assertEqual(str_result, repr_result) + + def test_bad_create__invalid_edition(self): + with self.assertRaisesRegex(ValueError, "Invalid grib edition"): + GRIBCode(77, 1, 2, 3) + + def test_bad_create__arg_and_kwarg(self): + msg = "Keyword 'number'=7 is not compatible with a 4th argument." + with self.assertRaisesRegex(ValueError, msg): + GRIBCode(1, 2, 3, 4, number=7) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/message/__init__.py b/src/iris_grib/tests/unit/message/__init__.py new file mode 100644 index 00000000..64aa75a8 --- /dev/null +++ b/src/iris_grib/tests/unit/message/__init__.py @@ -0,0 +1,5 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for the :mod:`iris_grib.message` package.""" diff --git a/src/iris_grib/tests/unit/message/test_GribMessage.py b/src/iris_grib/tests/unit/message/test_GribMessage.py new file mode 100644 index 00000000..fbc8d286 --- /dev/null +++ b/src/iris_grib/tests/unit/message/test_GribMessage.py @@ -0,0 +1,303 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for the `iris_grib.message.GribMessage` class. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from abc import ABCMeta, abstractmethod +from unittest import mock + +import numpy as np +import numpy.ma as ma + +from iris._lazy_data import as_concrete_data, is_lazy_data +from iris.exceptions import TranslationError + +from iris_grib.message import GribMessage +from iris_grib.tests.unit import _make_test_message + + +SECTION_6_NO_BITMAP = {"bitMapIndicator": 255, "bitmap": None} + + +@tests.skip_data +class Test_messages_from_filename(tests.IrisGribTest): + def test(self): + filename = tests.get_data_path(("GRIB", "3_layer_viz", "3_layer.grib2")) + messages = list(GribMessage.messages_from_filename(filename)) + self.assertEqual(len(messages), 3) + + def test_release_file(self): + filename = tests.get_data_path(("GRIB", "3_layer_viz", "3_layer.grib2")) + my_file = open(filename) + + import builtins # noqa: F401, PLC0415 + + self.patch("builtins.open", mock.Mock(return_value=my_file)) + + messages = list(GribMessage.messages_from_filename(filename)) + self.assertFalse(my_file.closed) + del messages + self.assertTrue(my_file.closed) + + +class Test_sections(tests.IrisGribTest): + def test(self): + # Check that the `sections` attribute defers to the `sections` + # attribute on the underlying _RawGribMessage. + message = _make_test_message(mock.sentinel.SECTIONS) + self.assertIs(message.sections, mock.sentinel.SECTIONS) + + +class Test_data__masked(tests.IrisGribTest): + def setUp(self): + self.bitmap = np.array([0, 0, 1, 1, 0, 0, 0, 1, 0, 1, 0, 1]) + self.shape = (3, 4) + self._section_3 = { + "sourceOfGridDefinition": 0, + "numberOfOctectsForNumberOfPoints": 0, + "interpretationOfNumberOfPoints": 0, + "gridDefinitionTemplateNumber": 0, + "scanningMode": 0, + "Nj": self.shape[0], + "Ni": self.shape[1], + } + + def test_no_bitmap(self): + values = np.arange(12) + message = _make_test_message( + {3: self._section_3, 6: SECTION_6_NO_BITMAP, 7: {"codedValues": values}} + ) + result = as_concrete_data(message.data) + expected = values.reshape(self.shape) + self.assertEqual(result.shape, self.shape) + self.assertArrayEqual(result, expected) + + def test_bitmap_present(self): + # Test the behaviour where bitmap and codedValues shapes + # are not equal. + input_values = np.arange(5) + output_values = np.array([-1, -1, 0, 1, -1, -1, -1, 2, -1, 3, -1, 4]) + message = _make_test_message( + { + 3: self._section_3, + 6: {"bitMapIndicator": 0, "bitmap": self.bitmap}, + 7: {"codedValues": input_values}, + } + ) + result = as_concrete_data(message.data) + expected = ma.masked_array(output_values, np.logical_not(self.bitmap)) + expected = expected.reshape(self.shape) + self.assertMaskedArrayEqual(result, expected) + + def test_bitmap__shapes_mismatch(self): + # Test the behaviour where bitmap and codedValues shapes do not match. + # Too many or too few unmasked values in codedValues will cause this. + values = np.arange(6) + message = _make_test_message( + { + 3: self._section_3, + 6: {"bitMapIndicator": 0, "bitmap": self.bitmap}, + 7: {"codedValues": values}, + } + ) + with self.assertRaisesRegex(TranslationError, "do not match"): + as_concrete_data(message.data) + + def test_bitmap__invalid_indicator(self): + values = np.arange(12) + message = _make_test_message( + { + 3: self._section_3, + 6: {"bitMapIndicator": 100, "bitmap": None}, + 7: {"codedValues": values}, + } + ) + with self.assertRaisesRegex(TranslationError, "unsupported bitmap"): + as_concrete_data(message.data) + + +class Test_data__unsupported(tests.IrisGribTest): + def test_unsupported_grid_definition(self): + message = _make_test_message( + {3: {"sourceOfGridDefinition": 1}, 6: SECTION_6_NO_BITMAP} + ) + with self.assertRaisesRegex(TranslationError, "source"): + message.data + + def test_unsupported_quasi_regular__number_of_octets(self): + message = _make_test_message( + { + 3: { + "sourceOfGridDefinition": 0, + "numberOfOctectsForNumberOfPoints": 1, + "gridDefinitionTemplateNumber": 0, + }, + 6: SECTION_6_NO_BITMAP, + } + ) + with self.assertRaisesRegex(TranslationError, "quasi-regular"): + message.data + + def test_unsupported_quasi_regular__interpretation(self): + message = _make_test_message( + { + 3: { + "sourceOfGridDefinition": 0, + "numberOfOctectsForNumberOfPoints": 0, + "interpretationOfNumberOfPoints": 1, + "gridDefinitionTemplateNumber": 0, + }, + 6: SECTION_6_NO_BITMAP, + } + ) + with self.assertRaisesRegex(TranslationError, "quasi-regular"): + message.data + + def test_unsupported_template(self): + message = _make_test_message( + { + 3: { + "sourceOfGridDefinition": 0, + "numberOfOctectsForNumberOfPoints": 0, + "interpretationOfNumberOfPoints": 0, + "gridDefinitionTemplateNumber": 2, + } + } + ) + with self.assertRaisesRegex(TranslationError, "template"): + message.data + + +# Abstract, mix-in class for testing the `data` attribute for various +# grid definition templates. +class Mixin_data__grid_template(metaclass=ABCMeta): + @abstractmethod + def section_3(self, scanning_mode): + raise NotImplementedError() + + def test_unsupported_scanning_mode(self): + message = _make_test_message({3: self.section_3(1), 6: SECTION_6_NO_BITMAP}) + with self.assertRaisesRegex(TranslationError, "scanning mode"): + message.data + + def _test(self, scanning_mode): + message = _make_test_message( + { + 3: self.section_3(scanning_mode), + 6: SECTION_6_NO_BITMAP, + 7: {"codedValues": np.arange(12)}, + } + ) + data = message.data + self.assertTrue(is_lazy_data(data)) + self.assertEqual(data.shape, (3, 4)) + self.assertEqual(data.dtype, np.float64) + self.assertArrayEqual(as_concrete_data(data), np.arange(12).reshape(3, 4)) + + def test_regular_mode_0(self): + self._test(0) + + def test_regular_mode_64(self): + self._test(64) + + def test_regular_mode_128(self): + self._test(128) + + def test_regular_mode_64_128(self): + self._test(64 | 128) + + +def _example_section_3(grib_definition_template_number, scanning_mode): + return { + "sourceOfGridDefinition": 0, + "numberOfOctectsForNumberOfPoints": 0, + "interpretationOfNumberOfPoints": 0, + "gridDefinitionTemplateNumber": grib_definition_template_number, + "scanningMode": scanning_mode, + "Nj": 3, + "Ni": 4, + } + + +class Test_data__grid_template_0(tests.IrisGribTest, Mixin_data__grid_template): + def section_3(self, scanning_mode): + return _example_section_3(0, scanning_mode) + + +class Test_data__grid_template_1(tests.IrisGribTest, Mixin_data__grid_template): + def section_3(self, scanning_mode): + return _example_section_3(1, scanning_mode) + + +class Test_data__grid_template_5(tests.IrisGribTest, Mixin_data__grid_template): + def section_3(self, scanning_mode): + return _example_section_3(5, scanning_mode) + + +class Test_data__grid_template_12(tests.IrisGribTest, Mixin_data__grid_template): + def section_3(self, scanning_mode): + return _example_section_3(12, scanning_mode) + + +class Test_data__grid_template_30(tests.IrisGribTest, Mixin_data__grid_template): + def section_3(self, scanning_mode): + section_3 = _example_section_3(30, scanning_mode) + # Dimensions are 'Nx' + 'Ny' instead of 'Ni' + 'Nj'. + section_3["Nx"] = section_3["Ni"] + section_3["Ny"] = section_3["Nj"] + del section_3["Ni"] + del section_3["Nj"] + return section_3 + + +class Test_data__grid_template_40_regular( + tests.IrisGribTest, Mixin_data__grid_template +): + def section_3(self, scanning_mode): + return _example_section_3(40, scanning_mode) + + +class Test_data__grid_template_90(tests.IrisGribTest, Mixin_data__grid_template): + def section_3(self, scanning_mode): + section_3 = _example_section_3(90, scanning_mode) + # Exceptionally, dimensions are 'Nx' + 'Ny' instead of 'Ni' + 'Nj'. + section_3["Nx"] = section_3["Ni"] + section_3["Ny"] = section_3["Nj"] + del section_3["Ni"] + del section_3["Nj"] + return section_3 + + +class Test_data__grid_template_140(tests.IrisGribTest, Mixin_data__grid_template): + def section_3(self, scanning_mode): + section_3 = _example_section_3(140, scanning_mode) + section_3["numberOfPointsAlongXAxis"] = section_3["Ni"] + section_3["numberOfPointsAlongYAxis"] = section_3["Nj"] + del section_3["Ni"] + del section_3["Nj"] + return section_3 + + +class Test_data__unknown_grid_template(tests.IrisGribTest): + def test(self): + message = _make_test_message( + { + 3: _example_section_3(999, 0), + 6: SECTION_6_NO_BITMAP, + 7: {"codedValues": np.arange(12)}, + } + ) + with self.assertRaisesRegex(TranslationError, "template 999 is not supported"): + _ = message.data + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/message/test_Section.py b/src/iris_grib/tests/unit/message/test_Section.py new file mode 100644 index 00000000..c65f98f8 --- /dev/null +++ b/src/iris_grib/tests/unit/message/test_Section.py @@ -0,0 +1,144 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for `iris_grib.message.Section`. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + + +import eccodes +import numpy as np + +from iris_grib.message import Section + + +@tests.skip_data +class Test___getitem__(tests.IrisGribTest): + def setUp(self): + filename = tests.get_data_path(("GRIB", "uk_t", "uk_t.grib2")) + with open(filename, "rb") as grib_fh: + self.grib_id = eccodes.codes_new_from_file( + grib_fh, eccodes.CODES_PRODUCT_GRIB + ) + + def test_scalar(self): + section = Section(self.grib_id, None, ["Ni"]) + self.assertEqual(section["Ni"], 47) + + def test_array(self): + section = Section(self.grib_id, None, ["codedValues"]) + codedValues = section["codedValues"] + self.assertEqual(codedValues.shape, (1551,)) + self.assertArrayAlmostEqual( + codedValues[:3], [-1.78140259, -1.53140259, -1.28140259] + ) + + def test_typeOfFirstFixedSurface(self): + section = Section(self.grib_id, None, ["typeOfFirstFixedSurface"]) + self.assertEqual(section["typeOfFirstFixedSurface"], 100) + + def test_numberOfSection(self): + n = 4 + section = Section(self.grib_id, n, ["numberOfSection"]) + self.assertEqual(section["numberOfSection"], n) + + def test_alias(self): + # Note: I think this particular alias is really 'the wrong way around' + # TODO: resolve? see https://github.com/SciTools/iris-grib/issues/765 + section = Section(self.grib_id, None, ["indicatorOfUnitOfTimeRange"]) + self.assertEqual(section["indicatorOfUnitForForecastTime"], 1) + self.assertEqual(section["indicatorOfUnitOfTimeRange"], 1) + + def test_invalid(self): + section = Section(self.grib_id, None, ["Ni"]) + with self.assertRaisesRegex(KeyError, "Nii"): + section["Nii"] + + +@tests.skip_data +class Test___setitem__(tests.IrisGribTest): + """Check writing keys, including error modes.""" + + def setUp(self): + # Open test file. Don't need writeable, as we only 'write' our own values. + filename = tests.get_data_path(("GRIB", "uk_t", "uk_t.grib2")) + with open(filename, "rb") as grib_fh: + self.grib_id = eccodes.codes_new_from_file( + grib_fh, eccodes.CODES_PRODUCT_GRIB + ) + + def test_withread_first(self): + section = Section(self.grib_id, None, ["Ni"]) + self.assertEqual(section["Ni"], 47) + section["Ni"] = 99 + self.assertEqual(section["Ni"], 99) + + def test_noread_first(self): + section = Section(self.grib_id, None, ["Ni"]) + section["Ni"] = 99 + self.assertEqual(section["Ni"], 99) + + def test_badkey_error(self): + section = Section(self.grib_id, 333, ["Ni"]) + msg = "'unknown' is not a valid key for section 333" + with self.assertRaisesRegex(KeyError, msg): + section["unknown"] = 99 + + def test_numberOfSection_error(self): + section = Section(self.grib_id, 3, ["Ni"]) + msg = "Cannot write 'numberOfSection'" + with self.assertRaisesRegex(KeyError, msg): + section["numberOfSection"] = 4 + + def test_write_alias(self): + # "indicatorOfUnitForForecastTime": "indicatorOfUnitOfTimeRange", + section = Section(self.grib_id, 101, ["indicatorOfUnitOfTimeRange"]) + section["indicatorOfUnitForForecastTime"] = 202 + self.assertEqual(section["indicatorOfUnitOfTimeRange"], 202) + + +@tests.skip_data +class Test__getitem___pdt_31(tests.IrisGribTest): + def setUp(self): + filename = tests.get_data_path(("GRIB", "umukv", "ukv_chan9.grib2")) + with open(filename, "rb") as grib_fh: + self.grib_id = eccodes.codes_new_from_file( + grib_fh, eccodes.CODES_PRODUCT_GRIB + ) + self.keys = [ + "satelliteSeries", + "satelliteNumber", + "instrumentType", + "scaleFactorOfCentralWaveNumber", + "scaledValueOfCentralWaveNumber", + ] + + def test_array(self): + section = Section(self.grib_id, None, self.keys) + for key in self.keys: + value = section[key] + self.assertIsInstance(value, np.ndarray) + self.assertEqual(value.shape, (1,)) + + +@tests.skip_data +class Test_get_computed_key(tests.IrisGribTest): + def test_gdt40_computed(self): + fname = tests.get_data_path(("GRIB", "gaussian", "regular_gg.grib2")) + with open(fname, "rb") as grib_fh: + self.grib_id = eccodes.codes_new_from_file( + grib_fh, eccodes.CODES_PRODUCT_GRIB + ) + section = Section(self.grib_id, None, []) + latitudes = section.get_computed_key("latitudes") + self.assertTrue(88.55 < latitudes[0] < 88.59) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/message/test__DataProxy.py b/src/iris_grib/tests/unit/message/test__DataProxy.py new file mode 100644 index 00000000..48fdc1fb --- /dev/null +++ b/src/iris_grib/tests/unit/message/test__DataProxy.py @@ -0,0 +1,43 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for the `iris.message._DataProxy` class. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from numpy.random import randint + +from iris.exceptions import TranslationError + +from iris_grib.message import _DataProxy + + +class Test__bitmap(tests.IrisGribTest): + def test_no_bitmap(self): + section_6 = {"bitMapIndicator": 255, "bitmap": None} + data_proxy = _DataProxy(0, 0, 0) + result = data_proxy._bitmap(section_6) + self.assertIsNone(result) + + def test_bitmap_present(self): + bitmap = randint(2, size=(12)) + section_6 = {"bitMapIndicator": 0, "bitmap": bitmap} + data_proxy = _DataProxy(0, 0, 0) + result = data_proxy._bitmap(section_6) + self.assertArrayEqual(bitmap, result) + + def test_bitmap__invalid_indicator(self): + section_6 = {"bitMapIndicator": 100, "bitmap": None} + data_proxy = _DataProxy(0, 0, 0) + with self.assertRaisesRegex(TranslationError, "unsupported bitmap"): + data_proxy._bitmap(section_6) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/message/test__MessageLocation.py b/src/iris_grib/tests/unit/message/test__MessageLocation.py new file mode 100644 index 00000000..8ff5d6ee --- /dev/null +++ b/src/iris_grib/tests/unit/message/test__MessageLocation.py @@ -0,0 +1,33 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for the `iris.message._MessageLocation` class. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from iris_grib.message import _MessageLocation + + +class Test(tests.IrisGribTest): + def test(self): + message_location = _MessageLocation( + mock.sentinel.filename, mock.sentinel.location + ) + patch_target = "iris_grib.message._RawGribMessage.from_file_offset" + expected = mock.sentinel.message + with mock.patch(patch_target, return_value=expected) as rgm: + result = message_location() + rgm.assert_called_once_with(mock.sentinel.filename, mock.sentinel.location) + self.assertIs(result, expected) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/message/test__RawGribMessage.py b/src/iris_grib/tests/unit/message/test__RawGribMessage.py new file mode 100644 index 00000000..f8b90ea2 --- /dev/null +++ b/src/iris_grib/tests/unit/message/test__RawGribMessage.py @@ -0,0 +1,52 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for the `iris_grib.message._RawGribMessage` class. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import eccodes + +from iris_grib.message import _RawGribMessage + + +@tests.skip_data +class Test(tests.IrisGribTest): + def setUp(self): + filename = tests.get_data_path(("GRIB", "uk_t", "uk_t.grib2")) + with open(filename, "rb") as grib_fh: + grib_id = eccodes.codes_new_from_file(grib_fh, eccodes.CODES_PRODUCT_GRIB) + self.message = _RawGribMessage(grib_id) + + def test_sections__set(self): + # Test that sections writes into the _sections attribute. + _ = self.message.sections + self.assertNotEqual(self.message._sections, None) + + def test_sections__indexing(self): + res = self.message.sections[3]["scanningMode"] + expected = 64 + self.assertEqual(expected, res) + + def test__get_message_sections__section_numbers(self): + res = list(self.message.sections.keys()) + self.assertEqual(res, list(range(9))) + + def test_sections__numberOfSection_value(self): + # The key `numberOfSection` is repeated in every section meaning that + # if requested using ecCodes it always defaults to its last value (7). + # This tests that the `_RawGribMessage._get_message_sections` + # override is functioning. + section_number = 4 + res = self.message.sections[section_number]["numberOfSection"] + self.assertEqual(res, section_number) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/message/test_pickle_message.py b/src/iris_grib/tests/unit/message/test_pickle_message.py new file mode 100644 index 00000000..9d8626d5 --- /dev/null +++ b/src/iris_grib/tests/unit/message/test_pickle_message.py @@ -0,0 +1,48 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Check that a GribMessage can be pickled.""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import pickle + +import pytest +from iris_grib.message import GribMessage + +MIN_PICKLE_PROTOCOL = 4 + +TESTED_PROTOCOLS = list(range(MIN_PICKLE_PROTOCOL, pickle.HIGHEST_PROTOCOL + 1)) + + +@tests.skip_data +class TestPickleGribMessage: + @pytest.fixture + def messages(self): + path = tests.get_data_path(("GRIB", "fp_units", "hours.grib2")) + return GribMessage.messages_from_filename(path) + + def pickle_obj(self, obj, protocol, tmp_path): + # NOTE: Neither GribMessage, nor its lazy ".data", read back from + # a pickled obj. Currently, this only checks that they can be pickled + # successfully. + filename = tmp_path / ".pkl" + with open(filename, "wb") as f: + pickle.dump(obj, f, protocol) + # TODO: resolve this or remove the test. + # with open(filename, "rb") as f: + # nobj = pickle.load(f) + # assert nobj == obj + + @pytest.mark.parametrize("protocol", TESTED_PROTOCOLS) + def test_message(self, protocol, messages, tmp_path): + obj = next(messages) + self.pickle_obj(obj, protocol, tmp_path) + + @pytest.mark.parametrize("protocol", TESTED_PROTOCOLS) + def test_message_data(self, protocol, messages, tmp_path): + obj = next(messages).data + self.pickle_obj(obj, protocol, tmp_path) diff --git a/src/iris_grib/tests/unit/save_rules/__init__.py b/src/iris_grib/tests/unit/save_rules/__init__.py new file mode 100644 index 00000000..cc2c8b99 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/__init__.py @@ -0,0 +1,96 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for the :mod:`iris_grib.grib_save_rules` module.""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests # noqa: F401 +from unittest import mock + +import numpy as np + +from iris.coords import DimCoord +from iris.coord_systems import GeogCS +from iris.cube import Cube +from iris.fileformats.pp import EARTH_RADIUS as PP_DEFAULT_EARTH_RADIUS + + +class GdtTestMixin: + """Some handy common test capabilities for grib grid-definition tests.""" + + TARGET_MODULE = "iris_grib._save_rules" + + def setUp(self): + # Patch the ecCodes of the tested module. + self.mock_eccodes = self.patch(self.TARGET_MODULE + ".eccodes") + + # Fix the mock ecCodes to record key assignments. + def codes_set_trap(grib, name, value): + # Record a key setting on the mock passed as the 'grib message id'. + grib.keys[name] = value + + self.mock_eccodes.codes_set = codes_set_trap + self.mock_eccodes.codes_set_long = codes_set_trap + self.mock_eccodes.codes_set_float = codes_set_trap + self.mock_eccodes.codes_set_double = codes_set_trap + self.mock_eccodes.codes_set_long_array = codes_set_trap + self.mock_eccodes.codes_set_array = codes_set_trap + + # Create a mock 'grib message id', with a 'keys' dict for settings. + self.mock_grib = mock.Mock(keys={}) + + # Initialise the test cube and its coords to something barely usable. + self.test_cube = self._make_test_cube() + + def _default_coord_system(self): + return GeogCS(PP_DEFAULT_EARTH_RADIUS) + + def _default_x_points(self): + # Define simple, regular coordinate points. + return [1.0, 2.0, 3.0] + + def _default_y_points(self): + return [7.0, 8.0] # N.B. is_regular will *fail* on length-1 coords. + + def _make_test_cube( + self, cs=None, x_points=None, y_points=None, coord_units="degrees" + ): + # Create a cube with given properties, or minimal defaults. + if cs is None: + cs = self._default_coord_system() + if x_points is None: + x_points = self._default_x_points() + if y_points is None: + y_points = self._default_y_points() + + x_coord = DimCoord( + x_points, long_name="longitude", units=coord_units, coord_system=cs + ) + y_coord = DimCoord( + y_points, long_name="latitude", units=coord_units, coord_system=cs + ) + test_cube = Cube(np.zeros((len(y_points), len(x_points)))) + test_cube.add_dim_coord(y_coord, 0) + test_cube.add_dim_coord(x_coord, 1) + return test_cube + + def _check_key(self, name, value): + # Test that a specific grib key assignment occurred. + msg_fmt = 'Expected grib setting "{}" = {}, got {}' + found = self.mock_grib.keys.get(name) + if found is None: + self.assertEqual(0, 1, msg_fmt.format(name, value, "((UNSET))")) + else: + self.assertArrayEqual(found, value, msg_fmt.format(name, value, found)) + + def _check_scanmode(self, x_direction, y_direction): + expected = 0 + if x_direction < 0: + # "bit 1" set if x scans negatively + expected |= 0x80 + if y_direction >= 0: + # "bit 2" set if y does *not* scan negatively + expected |= 0x40 + self._check_key("scanningMode", expected) diff --git a/src/iris_grib/tests/unit/save_rules/test__missing_forecast_period.py b/src/iris_grib/tests/unit/save_rules/test__missing_forecast_period.py new file mode 100644 index 00000000..ffbc9986 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test__missing_forecast_period.py @@ -0,0 +1,81 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :func:`iris_grib._save_rules._missing_forecast_period.` + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from iris.cube import Cube +from iris.coords import DimCoord + +from iris_grib._save_rules import _missing_forecast_period + + +class TestNoForecastReferenceTime(tests.IrisGribTest): + def test_no_bounds(self): + t_coord = DimCoord(15, "time", units="hours since epoch") + cube = Cube(23) + cube.add_aux_coord(t_coord) + + res = _missing_forecast_period(cube) + expected_rt = t_coord.units.num2date(15) + expected_rt_type = 3 + expected_fp = 0 + expected_fp_type = 1 + expected = (expected_rt, expected_rt_type, expected_fp, expected_fp_type) + self.assertEqual(res, expected) + + def test_with_bounds(self): + t_coord = DimCoord(15, "time", bounds=[14, 16], units="hours since epoch") + cube = Cube(23) + cube.add_aux_coord(t_coord) + + res = _missing_forecast_period(cube) + expected_rt = t_coord.units.num2date(14) + expected_rt_type = 3 + expected_fp = 0 + expected_fp_type = 1 + expected = (expected_rt, expected_rt_type, expected_fp, expected_fp_type) + self.assertEqual(res, expected) + + +class TestWithForecastReferenceTime(tests.IrisGribTest): + def test_no_bounds(self): + t_coord = DimCoord(3, "time", units="days since epoch") + frt_coord = DimCoord(8, "forecast_reference_time", units="hours since epoch") + cube = Cube(23) + cube.add_aux_coord(t_coord) + cube.add_aux_coord(frt_coord) + + res = _missing_forecast_period(cube) + expected_rt = frt_coord.units.num2date(8) + expected_rt_type = 1 + expected_fp = 3 * 24 - 8 + expected_fp_type = 1 + expected = (expected_rt, expected_rt_type, expected_fp, expected_fp_type) + self.assertEqual(res, expected) + + def test_with_bounds(self): + t_coord = DimCoord(3, "time", bounds=[2, 4], units="days since epoch") + frt_coord = DimCoord(8, "forecast_reference_time", units="hours since epoch") + cube = Cube(23) + cube.add_aux_coord(t_coord) + cube.add_aux_coord(frt_coord) + + res = _missing_forecast_period(cube) + expected_rt = frt_coord.units.num2date(8) + expected_rt_type = 1 + expected_fp = 2 * 24 - 8 + expected_fp_type = 1 + expected = (expected_rt, expected_rt_type, expected_fp, expected_fp_type) + self.assertEqual(res, expected) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test__non_missing_forecast_period.py b/src/iris_grib/tests/unit/save_rules/test__non_missing_forecast_period.py new file mode 100644 index 00000000..33a1c0f3 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test__non_missing_forecast_period.py @@ -0,0 +1,50 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for module-level functions.""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import iris + +from iris_grib._save_rules import _non_missing_forecast_period + + +class Test(tests.IrisGribTest): + def _cube(self, t_bounds=False): + time_coord = iris.coords.DimCoord( + 15, standard_name="time", units="hours since epoch" + ) + fp_coord = iris.coords.DimCoord( + 10, standard_name="forecast_period", units="hours" + ) + if t_bounds: + time_coord.bounds = [[8, 100]] + fp_coord.bounds = [[3, 95]] + cube = iris.cube.Cube([23]) + cube.add_dim_coord(time_coord, 0) + cube.add_aux_coord(fp_coord, 0) + return cube + + def test_time_point(self): + cube = self._cube() + _rt, rt_meaning, fp, fp_meaning = _non_missing_forecast_period(cube) + self.assertEqual((rt_meaning, fp, fp_meaning), (1, 10, 1)) + + def test_time_bounds(self): + cube = self._cube(t_bounds=True) + _rt, rt_meaning, fp, fp_meaning = _non_missing_forecast_period(cube) + self.assertEqual((rt_meaning, fp, fp_meaning), (1, 3, 1)) + + def test_time_bounds_in_minutes(self): + cube = self._cube(t_bounds=True) + cube.coord("forecast_period").convert_units("minutes") + _rt, rt_meaning, fp, fp_meaning = _non_missing_forecast_period(cube) + self.assertEqual((rt_meaning, fp, fp_meaning), (1, 180, 0)) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test__product_definition_template_8_10_and_11.py b/src/iris_grib/tests/unit/save_rules/test__product_definition_template_8_10_and_11.py new file mode 100644 index 00000000..2d903226 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test__product_definition_template_8_10_and_11.py @@ -0,0 +1,194 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for +:func:`iris_grib._save_rules._product_definition_template_8_10_and_11` + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from cf_units import Unit +import eccodes + +from iris.coords import CellMethod, DimCoord +import iris.tests.stock as stock + +from iris_grib._save_rules import _product_definition_template_8_10_and_11 + + +class TestTypeOfStatisticalProcessing(tests.IrisGribTest): + def setUp(self): + self.cube = stock.lat_lon_cube() + # Rename cube to avoid warning about unknown discipline/parameter. + self.cube.rename("air_temperature") + coord = DimCoord( + 23, + "time", + bounds=[0, 100], + units=Unit("days since epoch", calendar="standard"), + ) + self.cube.add_aux_coord(coord) + + @mock.patch.object(eccodes, "codes_set") + def test_sum(self, mock_set): + cube = self.cube + cell_method = CellMethod(method="sum", coords=["time"]) + cube.add_cell_method(cell_method) + + _product_definition_template_8_10_and_11(cube, mock.sentinel.grib) + mock_set.assert_any_call(mock.sentinel.grib, "typeOfStatisticalProcessing", 1) + + @mock.patch.object(eccodes, "codes_set") + def test_unrecognised(self, mock_set): + cube = self.cube + cell_method = CellMethod(method="95th percentile", coords=["time"]) + cube.add_cell_method(cell_method) + + _product_definition_template_8_10_and_11(cube, mock.sentinel.grib) + mock_set.assert_any_call(mock.sentinel.grib, "typeOfStatisticalProcessing", 255) + + @mock.patch.object(eccodes, "codes_set") + def test_multiple_cell_method_coords(self, mock_set): + cube = self.cube + cell_method = CellMethod(method="sum", coords=["time", "forecast_period"]) + cube.add_cell_method(cell_method) + with self.assertRaisesRegex( + ValueError, "Cannot handle multiple coordinate name" + ): + _product_definition_template_8_10_and_11(cube, mock.sentinel.grib) + + @mock.patch.object(eccodes, "codes_set") + def test_cell_method_coord_name_fail(self, mock_set): + cube = self.cube + cell_method = CellMethod(method="mean", coords=["season"]) + cube.add_cell_method(cell_method) + with self.assertRaisesRegex( + ValueError, "Expected a cell method with a coordinate name of 'time'" + ): + _product_definition_template_8_10_and_11(cube, mock.sentinel.grib) + + +class TestTimeCoordPrerequisites(tests.IrisGribTest): + def setUp(self): + self.cube = stock.lat_lon_cube() + # Rename cube to avoid warning about unknown discipline/parameter. + self.cube.rename("air_temperature") + + @mock.patch.object(eccodes, "codes_set") + def test_multiple_points(self, mock_set): + # Add time coord with multiple points. + coord = DimCoord( + [23, 24, 25], + "time", + bounds=[[22, 23], [23, 24], [24, 25]], + units=Unit("days since epoch", calendar="standard"), + ) + self.cube.add_aux_coord(coord, 0) + with self.assertRaisesRegex(ValueError, "Expected length one time coordinate"): + _product_definition_template_8_10_and_11(self.cube, mock.sentinel.grib) + + @mock.patch.object(eccodes, "codes_set") + def test_no_bounds(self, mock_set): + # Add time coord with no bounds. + coord = DimCoord( + 23, "time", units=Unit("days since epoch", calendar="standard") + ) + self.cube.add_aux_coord(coord) + with self.assertRaisesRegex( + ValueError, "Expected time coordinate with two bounds, got 0 bounds" + ): + _product_definition_template_8_10_and_11(self.cube, mock.sentinel.grib) + + @mock.patch.object(eccodes, "codes_set") + def test_more_than_two_bounds(self, mock_set): + # Add time coord with more than two bounds. + coord = DimCoord( + 23, + "time", + bounds=[21, 22, 23], + units=Unit("days since epoch", calendar="standard"), + ) + self.cube.add_aux_coord(coord) + with self.assertRaisesRegex( + ValueError, "Expected time coordinate with two bounds, got 3 bounds" + ): + _product_definition_template_8_10_and_11(self.cube, mock.sentinel.grib) + + +class TestEndOfOverallTimeInterval(tests.IrisGribTest): + def setUp(self): + self.cube = stock.lat_lon_cube() + # Rename cube to avoid warning about unknown discipline/parameter. + self.cube.rename("air_temperature") + cell_method = CellMethod(method="sum", coords=["time"]) + self.cube.add_cell_method(cell_method) + + @mock.patch.object(eccodes, "codes_set") + def test_default_calendar(self, mock_set): + cube = self.cube + # End bound is 1972-04-26 10:27:07. + coord = DimCoord( + 23.0, "time", bounds=[0.452, 20314.452], units=Unit("hours since epoch") + ) + cube.add_aux_coord(coord) + + grib = mock.sentinel.grib + _product_definition_template_8_10_and_11(cube, grib) + + mock_set.assert_any_call(grib, "yearOfEndOfOverallTimeInterval", 1972) + mock_set.assert_any_call(grib, "monthOfEndOfOverallTimeInterval", 4) + mock_set.assert_any_call(grib, "dayOfEndOfOverallTimeInterval", 26) + mock_set.assert_any_call(grib, "hourOfEndOfOverallTimeInterval", 10) + mock_set.assert_any_call(grib, "minuteOfEndOfOverallTimeInterval", 27) + mock_set.assert_any_call(grib, "secondOfEndOfOverallTimeInterval", 7) + + @mock.patch.object(eccodes, "codes_set") + def test_360_day_calendar(self, mock_set): + cube = self.cube + # End bound is 1972-05-07 10:27:07 + coord = DimCoord( + 23.0, + "time", + bounds=[0.452, 20314.452], + units=Unit("hours since epoch", calendar="360_day"), + ) + cube.add_aux_coord(coord) + + grib = mock.sentinel.grib + _product_definition_template_8_10_and_11(cube, grib) + + mock_set.assert_any_call(grib, "yearOfEndOfOverallTimeInterval", 1972) + mock_set.assert_any_call(grib, "monthOfEndOfOverallTimeInterval", 5) + mock_set.assert_any_call(grib, "dayOfEndOfOverallTimeInterval", 7) + mock_set.assert_any_call(grib, "hourOfEndOfOverallTimeInterval", 10) + mock_set.assert_any_call(grib, "minuteOfEndOfOverallTimeInterval", 27) + mock_set.assert_any_call(grib, "secondOfEndOfOverallTimeInterval", 7) + + +class TestNumberOfTimeRange(tests.IrisGribTest): + @mock.patch.object(eccodes, "codes_set") + def test_other_cell_methods(self, mock_set): + cube = stock.lat_lon_cube() + # Rename cube to avoid warning about unknown discipline/parameter. + cube.rename("air_temperature") + coord = DimCoord(23, "time", bounds=[0, 24], units=Unit("hours since epoch")) + cube.add_aux_coord(coord) + # Add one time cell method and another unrelated one. + cell_method = CellMethod(method="mean", coords=["elephants"]) + cube.add_cell_method(cell_method) + cell_method = CellMethod(method="sum", coords=["time"]) + cube.add_cell_method(cell_method) + + _product_definition_template_8_10_and_11(cube, mock.sentinel.grib) + mock_set.assert_any_call(mock.sentinel.grib, "numberOfTimeRange", 1) + + +if __name__ == "__main__": + tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_data_section.py b/src/iris_grib/tests/unit/save_rules/test_data_section.py similarity index 54% rename from iris_grib/tests/unit/save_rules/test_data_section.py rename to src/iris_grib/tests/unit/save_rules/test_data_section.py index d038c39c..5860ce58 100644 --- a/iris_grib/tests/unit/save_rules/test_data_section.py +++ b/src/iris_grib/tests/unit/save_rules/test_data_section.py @@ -1,83 +1,69 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office +# Copyright iris-grib contributors # -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. """ Unit tests for :func:`iris_grib._save_rules.data_section`. """ -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - # import iris_grib.tests first so that some things can be initialised before # importing anything else import iris_grib.tests as tests -import mock -import numpy as np +from unittest import mock +import eccodes import iris.cube +import numpy as np from iris_grib._save_rules import data_section -GRIB_API = 'iris_grib._save_rules.gribapi' +GRIB_API = "iris_grib._save_rules.eccodes" GRIB_MESSAGE = mock.sentinel.GRIB_MESSAGE class TestMDI(tests.IrisGribTest): def assertBitmapOff(self, grib_api): # Check the use of a mask has been turned off via: - # gribapi.grib_set(grib_message, 'bitmapPresent', 0) - grib_api.grib_set.assert_called_once_with(GRIB_MESSAGE, - 'bitmapPresent', 0) + # eccodes.codes_set(grib_message, 'bitmapPresent', 0) + grib_api.codes_set.assert_called_once_with(GRIB_MESSAGE, "bitmapPresent", 0) def assertBitmapOn(self, grib_api, fill_value): # Check the use of a mask has been turned on via: - # gribapi.grib_set(grib_message, 'bitmapPresent', 1) - # gribapi.grib_set_double(grib_message, 'missingValue', fill_value) - grib_api.grib_set.assert_called_once_with(GRIB_MESSAGE, - 'bitmapPresent', 1) - grib_api.grib_set_double.assert_called_once_with(GRIB_MESSAGE, - 'missingValue', - fill_value) + # eccodes.codes_set(grib_message, 'bitmapPresent', 1) + # eccodes.codes_set_double(grib_message, 'missingValue', fill_value) + grib_api.codes_set.assert_called_once_with(GRIB_MESSAGE, "bitmapPresent", 1) + grib_api.codes_set_double.assert_called_once_with( + GRIB_MESSAGE, "missingValue", fill_value + ) def assertBitmapRange(self, grib_api, min_data, max_data): # Check the use of a mask has been turned on via: - # gribapi.grib_set(grib_message, 'bitmapPresent', 1) - # gribapi.grib_set_double(grib_message, 'missingValue', ...) + # eccodes.codes_set(grib_message, 'bitmapPresent', 1) + # eccodes.codes_set_double(grib_message, 'missingValue', ...) # and that a suitable fill value has been chosen. - grib_api.grib_set.assert_called_once_with(GRIB_MESSAGE, - 'bitmapPresent', 1) - args, = grib_api.grib_set_double.call_args_list - (message, key, fill_value), kwargs = args + grib_api.codes_set.assert_called_once_with(GRIB_MESSAGE, "bitmapPresent", 1) + (args,) = grib_api.codes_set_double.call_args_list + (message, key, fill_value), _kwargs = args self.assertIs(message, GRIB_MESSAGE) - self.assertEqual(key, 'missingValue') - self.assertTrue(fill_value < min_data or fill_value > max_data, - 'Fill value {} is not outside data range ' - '{} to {}.'.format(fill_value, min_data, max_data)) + self.assertEqual(key, "missingValue") + self.assertTrue( + fill_value < min_data or fill_value > max_data, + "Fill value {} is not outside data range {} to {}.".format( + fill_value, min_data, max_data + ), + ) return fill_value def assertValues(self, grib_api, values): # Check the correct data values have been set via: - # gribapi.grib_set_double_array(grib_message, 'values', ...) - args, = grib_api.grib_set_double_array.call_args_list + # eccodes.codes_set_double_array(grib_message, 'values', ...) + (args,) = grib_api.codes_set_double_array.call_args_list (message, key, values), kwargs = args self.assertIs(message, GRIB_MESSAGE) - self.assertEqual(key, 'values') + self.assertEqual(key, "values") self.assertArrayEqual(values, values) self.assertEqual(kwargs, {}) @@ -93,9 +79,11 @@ def test_simple(self): self.assertValues(grib_api, np.arange(5)) def test_masked_with_finite_fill_value(self): - cube = iris.cube.Cube(np.ma.MaskedArray([1.0, 2.0, 3.0, 1.0, 2.0, 3.0], - mask=[0, 0, 0, 1, 1, 1], - fill_value=2000)) + cube = iris.cube.Cube( + np.ma.MaskedArray( + [1.0, 2.0, 3.0, 1.0, 2.0, 3.0], mask=[0, 0, 0, 1, 1, 1], fill_value=2000 + ) + ) grib_message = mock.sentinel.GRIB_MESSAGE with mock.patch(GRIB_API) as grib_api: data_section(cube, grib_message) @@ -106,9 +94,13 @@ def test_masked_with_finite_fill_value(self): self.assertValues(grib_api, [1, 2, 3, FILL, FILL, FILL]) def test_masked_with_nan_fill_value(self): - cube = iris.cube.Cube(np.ma.MaskedArray([1.0, 2.0, 3.0, 1.0, 2.0, 3.0], - mask=[0, 0, 0, 1, 1, 1], - fill_value=np.nan)) + cube = iris.cube.Cube( + np.ma.MaskedArray( + [1.0, 2.0, 3.0, 1.0, 2.0, 3.0], + mask=[0, 0, 0, 1, 1, 1], + fill_value=np.nan, + ) + ) grib_message = mock.sentinel.GRIB_MESSAGE with mock.patch(GRIB_API) as grib_api: data_section(cube, grib_message) @@ -121,8 +113,9 @@ def test_masked_with_nan_fill_value(self): def test_scaled(self): # If the Cube's units don't match the units required by GRIB # ensure the data values are scaled correctly. - cube = iris.cube.Cube(np.arange(5), - standard_name='geopotential_height', units='km') + cube = iris.cube.Cube( + np.arange(5), standard_name="geopotential_height", units="km" + ) grib_message = mock.sentinel.GRIB_MESSAGE with mock.patch(GRIB_API) as grib_api: data_section(cube, grib_message) @@ -134,10 +127,13 @@ def test_scaled(self): def test_scaled_with_finite_fill_value(self): # When re-scaling masked data with a finite fill value, ensure # the fill value and any filled values are also re-scaled. - cube = iris.cube.Cube(np.ma.MaskedArray([1.0, 2.0, 3.0, 1.0, 2.0, 3.0], - mask=[0, 0, 0, 1, 1, 1], - fill_value=2000), - standard_name='geopotential_height', units='km') + cube = iris.cube.Cube( + np.ma.MaskedArray( + [1.0, 2.0, 3.0, 1.0, 2.0, 3.0], mask=[0, 0, 0, 1, 1, 1], fill_value=2000 + ), + standard_name="geopotential_height", + units="km", + ) grib_message = mock.sentinel.GRIB_MESSAGE with mock.patch(GRIB_API) as grib_api: data_section(cube, grib_message) @@ -151,10 +147,13 @@ def test_scaled_with_nan_fill_value(self): # When re-scaling masked data with a NaN fill value, ensure # a fill value is chosen which allows for the scaling, and any # filled values match the chosen fill value. - cube = iris.cube.Cube(np.ma.MaskedArray([-1.0, 2.0, -1.0, 2.0], - mask=[0, 0, 1, 1], - fill_value=np.nan), - standard_name='geopotential_height', units='km') + cube = iris.cube.Cube( + np.ma.MaskedArray( + [-1.0, 2.0, -1.0, 2.0], mask=[0, 0, 1, 1], fill_value=np.nan + ), + standard_name="geopotential_height", + units="km", + ) grib_message = mock.sentinel.GRIB_MESSAGE with mock.patch(GRIB_API) as grib_api: data_section(cube, grib_message) @@ -165,5 +164,26 @@ def test_scaled_with_nan_fill_value(self): self.assertValues(grib_api, [-1000, 2000, FILL, FILL]) +class TestNonDoubleData(tests.IrisGribTest): + # When saving to GRIB, data that is not float64 is cast to float64. This + # test checks that non-float64 data is saved without raising a segmentation + # fault. + def check(self, dtype): + data = np.random.random(1920 * 2560).astype(dtype) + cube = iris.cube.Cube(data, standard_name="geopotential_height", units="km") + grib_message = eccodes.codes_grib_new_from_samples("GRIB2") + data_section(cube, grib_message) + eccodes.codes_release(grib_message) + + def test_float32(self): + self.check(dtype=np.float32) + + def test_int32(self): + self.check(dtype=np.int32) + + def test_int64(self): + self.check(dtype=np.int64) + + if __name__ == "__main__": tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_0.py b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_0.py new file mode 100644 index 00000000..af9be1d1 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_0.py @@ -0,0 +1,85 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :meth:`iris_grib._save_rules.grid_definition_template_0`. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import numpy as np + +from iris.coord_systems import GeogCS + +from iris_grib._save_rules import grid_definition_template_0 +from iris_grib.tests.unit.save_rules import GdtTestMixin + + +class Test(tests.IrisGribTest, GdtTestMixin): + def setUp(self): + GdtTestMixin.setUp(self) + + def test__template_number(self): + grid_definition_template_0(self.test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 0) + + def test__shape_of_earth_spherical(self): + cs = GeogCS(semi_major_axis=1.23) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template_0(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 1) + self._check_key("scaleFactorOfRadiusOfSphericalEarth", 0) + self._check_key("scaledValueOfRadiusOfSphericalEarth", 1.23) + + def test__shape_of_earth_flattened(self): + cs = GeogCS(semi_major_axis=1.456, semi_minor_axis=1.123) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template_0(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 7) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 1.456) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 1.123) + + def test__shape_of_earth_fixed_6(self): + cs = GeogCS(semi_major_axis=6371229) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template_0(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 6) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 0) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 0) + + def test__grid_shape(self): + test_cube = self._make_test_cube(x_points=np.arange(13), y_points=np.arange(6)) + grid_definition_template_0(test_cube, self.mock_grib) + self._check_key("Ni", 13) + self._check_key("Nj", 6) + + def test__grid_points(self): + test_cube = self._make_test_cube(x_points=[1, 3, 5, 7], y_points=[4, 9]) + grid_definition_template_0(test_cube, self.mock_grib) + self._check_key("longitudeOfFirstGridPoint", 1000000) + self._check_key("longitudeOfLastGridPoint", 7000000) + self._check_key("latitudeOfFirstGridPoint", 4000000) + self._check_key("latitudeOfLastGridPoint", 9000000) + self._check_key("iDirectionIncrement", 2000000) + self._check_key("jDirectionIncrement", 5000000) + + def test__scanmode(self): + grid_definition_template_0(self.test_cube, self.mock_grib) + self._check_scanmode(+1, +1) + + def test__scanmode_reverse(self): + test_cube = self._make_test_cube(x_points=np.arange(7, 0, -1)) + grid_definition_template_0(test_cube, self.mock_grib) + self._check_scanmode(-1, +1) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_1.py b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_1.py new file mode 100644 index 00000000..e24c9c9f --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_1.py @@ -0,0 +1,137 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :meth:`iris_grib._save_rules.grid_definition_template_1`. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import numpy as np + +from iris.coord_systems import GeogCS, RotatedGeogCS +from iris.exceptions import TranslationError +from iris.fileformats.pp import EARTH_RADIUS as PP_DEFAULT_EARTH_RADIUS + +from iris_grib._save_rules import grid_definition_template_1 +from iris_grib.tests.unit.save_rules import GdtTestMixin + + +class Test(tests.IrisGribTest, GdtTestMixin): + def setUp(self): + self.default_ellipsoid = GeogCS(PP_DEFAULT_EARTH_RADIUS) + GdtTestMixin.setUp(self) + + def _default_coord_system(self): + # Define an alternate, rotated coordinate system to test. + cs = RotatedGeogCS( + grid_north_pole_latitude=90.0, + grid_north_pole_longitude=0.0, + ellipsoid=self.default_ellipsoid, + ) + return cs + + def test__template_number(self): + grid_definition_template_1(self.test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 1) + + def test__shape_of_earth_spherical(self): + ellipsoid = GeogCS(1.23) + cs = RotatedGeogCS( + grid_north_pole_latitude=90.0, + grid_north_pole_longitude=0.0, + ellipsoid=ellipsoid, + ) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template_1(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 1) + self._check_key("scaleFactorOfRadiusOfSphericalEarth", 0) + self._check_key("scaledValueOfRadiusOfSphericalEarth", 1.23) + + def test__shape_of_earth_flattened(self): + ellipsoid = GeogCS(semi_major_axis=1.456, semi_minor_axis=1.123) + cs = RotatedGeogCS( + grid_north_pole_latitude=90.0, + grid_north_pole_longitude=0.0, + ellipsoid=ellipsoid, + ) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template_1(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 7) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 1.456) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 1.123) + + def test__shape_of_earth_fixed_6(self): + ellipsoid = GeogCS(semi_major_axis=6371229) + cs = RotatedGeogCS( + grid_north_pole_latitude=90.0, + grid_north_pole_longitude=0.0, + ellipsoid=ellipsoid, + ) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template_1(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 6) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 0) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 0) + + def test__grid_shape(self): + test_cube = self._make_test_cube(x_points=np.arange(13), y_points=np.arange(6)) + grid_definition_template_1(test_cube, self.mock_grib) + self._check_key("Ni", 13) + self._check_key("Nj", 6) + + def test__grid_points(self): + test_cube = self._make_test_cube(x_points=[1, 3, 5, 7], y_points=[4, 9]) + grid_definition_template_1(test_cube, self.mock_grib) + self._check_key("longitudeOfFirstGridPoint", 1000000) + self._check_key("longitudeOfLastGridPoint", 7000000) + self._check_key("latitudeOfFirstGridPoint", 4000000) + self._check_key("latitudeOfLastGridPoint", 9000000) + self._check_key("iDirectionIncrement", 2000000) + self._check_key("jDirectionIncrement", 5000000) + + def test__scanmode(self): + grid_definition_template_1(self.test_cube, self.mock_grib) + self._check_scanmode(+1, +1) + + def test__scanmode_reverse(self): + test_cube = self._make_test_cube(x_points=np.arange(7, 0, -1)) + grid_definition_template_1(test_cube, self.mock_grib) + self._check_scanmode(-1, +1) + + def test__rotated_pole(self): + cs = RotatedGeogCS( + grid_north_pole_latitude=75.3, + grid_north_pole_longitude=54.321, + ellipsoid=self.default_ellipsoid, + ) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template_1(test_cube, self.mock_grib) + self._check_key("latitudeOfSouthernPole", -75300000) + self._check_key("longitudeOfSouthernPole", 234321000) + self._check_key("angleOfRotation", 0) + + def test__fail_rotated_pole_nonstandard_meridian(self): + cs = RotatedGeogCS( + grid_north_pole_latitude=90.0, + grid_north_pole_longitude=0.0, + north_pole_grid_longitude=22.5, + ellipsoid=self.default_ellipsoid, + ) + test_cube = self._make_test_cube(cs=cs) + with self.assertRaisesRegex( + TranslationError, "not yet support .* rotated prime meridian." + ): + grid_definition_template_1(test_cube, self.mock_grib) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_10.py b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_10.py new file mode 100644 index 00000000..cb2be94e --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_10.py @@ -0,0 +1,97 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :meth:`iris_grib._save_rules.grid_definition_template_10`. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import numpy as np + +from iris.coord_systems import GeogCS, Mercator + +from iris_grib._save_rules import grid_definition_template_10 +from iris_grib.tests.unit.save_rules import GdtTestMixin + + +class Test(tests.IrisGribTest, GdtTestMixin): + def setUp(self): + self.default_ellipsoid = GeogCS(semi_major_axis=6371200.0) + self.mercator_test_cube = self._make_test_cube(coord_units="m") + + GdtTestMixin.setUp(self) + + def _default_coord_system(self): + return Mercator(standard_parallel=14.0, ellipsoid=self.default_ellipsoid) + + def test__template_number(self): + grid_definition_template_10(self.mercator_test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 10) + + def test__shape_of_earth(self): + grid_definition_template_10(self.mercator_test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 1) + self._check_key("scaleFactorOfRadiusOfSphericalEarth", 0) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 0) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 0) + + def test__shape_of_earth_fixed_6(self): + cs = self._default_coord_system() + cs.ellipsoid = GeogCS(semi_major_axis=6371229) + test_cube = self._make_test_cube(cs=cs, coord_units="m") + grid_definition_template_10(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 6) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 0) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 0) + + def test__grid_shape(self): + n_x_points = 13 + n_y_points = 6 + test_cube = self._make_test_cube( + x_points=np.arange(n_x_points), + y_points=np.arange(n_y_points), + coord_units="m", + ) + grid_definition_template_10(test_cube, self.mock_grib) + self._check_key("Ni", n_x_points) + self._check_key("Nj", n_y_points) + + def test__grid_points(self): + test_cube = self._make_test_cube( + x_points=[1e6, 3e6, 5e6, 7e6], y_points=[4e6, 9e6], coord_units="m" + ) + grid_definition_template_10(test_cube, self.mock_grib) + self._check_key("latitudeOfFirstGridPoint", 34727738) + self._check_key("longitudeOfFirstGridPoint", 9268240) + self._check_key("latitudeOfLastGridPoint", 63746266) + self._check_key("longitudeOfLastGridPoint", 64877681) + self._check_key("Di", 2e9) + self._check_key("Dj", 5e9) + + def test__template_specifics(self): + grid_definition_template_10(self.mercator_test_cube, self.mock_grib) + self._check_key("LaD", 14e6) + + def test__scanmode(self): + grid_definition_template_10(self.mercator_test_cube, self.mock_grib) + self._check_scanmode(+1, +1) + + def test__scanmode_reverse(self): + test_cube = self._make_test_cube( + x_points=np.arange(7e6, 0, -1e6), coord_units="m" + ) + grid_definition_template_10(test_cube, self.mock_grib) + self._check_scanmode(-1, +1) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_12.py b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_12.py new file mode 100644 index 00000000..ce5f78a4 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_12.py @@ -0,0 +1,149 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :meth:`iris_grib._save_rules.grid_definition_template_12`. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import numpy as np + +import iris.coords +from iris.coord_systems import GeogCS, TransverseMercator + +from iris_grib._save_rules import grid_definition_template_12 +from iris_grib.tests.unit.save_rules import GdtTestMixin + + +class FakeGribError(Exception): + pass + + +class Test(tests.IrisGribTest, GdtTestMixin): + def setUp(self): + self.default_ellipsoid = GeogCS( + semi_major_axis=6377563.396, semi_minor_axis=6356256.909 + ) + self.test_cube = self._make_test_cube() + + GdtTestMixin.setUp(self) + + def _make_test_cube(self, cs=None, x_points=None, y_points=None): + # Create a cube with given properties, or minimal defaults. + if cs is None: + cs = self._default_coord_system() + if x_points is None: + x_points = self._default_x_points() + if y_points is None: + y_points = self._default_y_points() + + x_coord = iris.coords.DimCoord( + x_points, "projection_x_coordinate", units="m", coord_system=cs + ) + y_coord = iris.coords.DimCoord( + y_points, "projection_y_coordinate", units="m", coord_system=cs + ) + test_cube = iris.cube.Cube(np.zeros((len(y_points), len(x_points)))) + test_cube.add_dim_coord(y_coord, 0) + test_cube.add_dim_coord(x_coord, 1) + return test_cube + + def _default_coord_system(self): + # This defines an OSGB coord system. + cs = TransverseMercator( + latitude_of_projection_origin=49.0, + longitude_of_central_meridian=-2.0, + false_easting=400000.0, + false_northing=-100000.0, + scale_factor_at_central_meridian=0.9996012717, + ellipsoid=self.default_ellipsoid, + ) + return cs + + def test__template_number(self): + grid_definition_template_12(self.test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 12) + + def test__shape_of_earth(self): + grid_definition_template_12(self.test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 7) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 6377563.396) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 6356256.909) + + def test__shape_of_earth_fixed_6(self): + cs = self._default_coord_system() + cs.ellipsoid = GeogCS(semi_major_axis=6371229) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template_12(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 6) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 0) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 0) + + def test__grid_shape(self): + test_cube = self._make_test_cube(x_points=np.arange(13), y_points=np.arange(6)) + grid_definition_template_12(test_cube, self.mock_grib) + self._check_key("Ni", 13) + self._check_key("Nj", 6) + + def test__grid_points_exact(self): + test_cube = self._make_test_cube(x_points=[1, 3, 5, 7], y_points=[4, 9]) + grid_definition_template_12(test_cube, self.mock_grib) + self._check_key("X1", 100) + self._check_key("X2", 700) + self._check_key("Y1", 400) + self._check_key("Y2", 900) + self._check_key("Di", 200) + self._check_key("Dj", 500) + + def test__grid_points_approx(self): + test_cube = self._make_test_cube( + x_points=[1.001, 3.003, 5.005, 7.007], y_points=[4, 9] + ) + grid_definition_template_12(test_cube, self.mock_grib) + self._check_key("X1", 100) + self._check_key("X2", 701) + self._check_key("Y1", 400) + self._check_key("Y2", 900) + self._check_key("Di", 200) + self._check_key("Dj", 500) + + def test__negative_grid_points_eccodes_fixed(self): + test_cube = self._make_test_cube(x_points=[-1, 1, 3, 5, 7], y_points=[-4, 9]) + grid_definition_template_12(test_cube, self.mock_grib) + self._check_key("X1", -100) + self._check_key("X2", 700) + self._check_key("Y1", -400) + self._check_key("Y2", 900) + + def test__template_specifics(self): + grid_definition_template_12(self.test_cube, self.mock_grib) + self._check_key("latitudeOfReferencePoint", 49000000.0) + self._check_key("longitudeOfReferencePoint", -2000000.0) + self._check_key("XR", 40000000.0) + self._check_key("YR", -10000000.0) + + def test__scale_factor(self): + grid_definition_template_12(self.test_cube, self.mock_grib) + self._check_key("scaleFactorAtReferencePoint", 0.9996012717) + + def test__scanmode(self): + grid_definition_template_12(self.test_cube, self.mock_grib) + self._check_scanmode(+1, +1) + + def test__scanmode_reverse(self): + test_cube = self._make_test_cube(x_points=np.arange(7, 0, -1)) + grid_definition_template_12(test_cube, self.mock_grib) + self._check_scanmode(-1, +1) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_140.py b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_140.py new file mode 100644 index 00000000..8ffaa57c --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_140.py @@ -0,0 +1,147 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :meth:`iris_grib._save_rules.grid_definition_template_140`. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import numpy as np + +import iris.coords +from iris.coord_systems import GeogCS, LambertAzimuthalEqualArea +from iris.exceptions import TranslationError + +from iris_grib._save_rules import ( + grid_definition_template_140 as grid_definition_template, +) +from iris_grib.tests.unit.save_rules import GdtTestMixin + + +class FakeGribError(Exception): + pass + + +class Test(tests.IrisGribTest, GdtTestMixin): + def setUp(self): + self.default_ellipsoid = GeogCS( + semi_major_axis=6377563.396, semi_minor_axis=6356256.909 + ) + self.test_cube = self._make_test_cube() + + GdtTestMixin.setUp(self) + + def _make_test_cube(self, cs=None, x_points=None, y_points=None): + # Create a cube with given properties, or minimal defaults. + if cs is None: + cs = self._default_coord_system() + if x_points is None: + x_points = self._default_x_points() + if y_points is None: + y_points = self._default_y_points() + + x_coord = iris.coords.DimCoord( + x_points, "projection_x_coordinate", units="m", coord_system=cs + ) + y_coord = iris.coords.DimCoord( + y_points, "projection_y_coordinate", units="m", coord_system=cs + ) + test_cube = iris.cube.Cube(np.zeros((len(y_points), len(x_points)))) + test_cube.add_dim_coord(y_coord, 0) + test_cube.add_dim_coord(x_coord, 1) + return test_cube + + def _default_coord_system(self, false_easting=0, false_northing=0): + return LambertAzimuthalEqualArea( + latitude_of_projection_origin=54.9, + longitude_of_projection_origin=-2.5, + false_easting=false_easting, + false_northing=false_northing, + ellipsoid=self.default_ellipsoid, + ) + + def test__template_number(self): + grid_definition_template(self.test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 140) + + def test__shape_of_earth(self): + grid_definition_template(self.test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 7) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 6377563.396) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 6356256.909) + + def test__shape_of_earth_fixed_6(self): + cs = self._default_coord_system() + cs.ellipsoid = GeogCS(semi_major_axis=6371229) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 6) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 0) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 0) + + def test__grid_shape(self): + test_cube = self._make_test_cube(x_points=np.arange(13), y_points=np.arange(6)) + grid_definition_template(test_cube, self.mock_grib) + self._check_key("Nx", 13) + self._check_key("Ny", 6) + + def test__grid_points(self): + test_cube = self._make_test_cube( + x_points=[1e6, 3e6, 5e6, 7e6], y_points=[4e6, 9e6] + ) + grid_definition_template(test_cube, self.mock_grib) + self._check_key("latitudeOfFirstGridPoint", 81330008) + self._check_key("longitudeOfFirstGridPoint", 98799008) + self._check_key("Dx", 2e9) + self._check_key("Dy", 5e9) + + # specific to grid_definition_template_140 + def test__template_specifics(self): + grid_definition_template(self.test_cube, self.mock_grib) + self._check_key("standardParallelInMicrodegrees", 54900000) + self._check_key("centralLongitudeInMicrodegrees", 357500000) + + def test__scanmode(self): + grid_definition_template(self.test_cube, self.mock_grib) + self._check_scanmode(+1, +1) + + def test__scanmode_reverse(self): + test_cube = self._make_test_cube(x_points=np.arange(7e6, 0, -1e6)) + grid_definition_template(test_cube, self.mock_grib) + self._check_scanmode(-1, +1) + + def __fail_false_easting_northing(self, false_easting, false_northing): + cs = self._default_coord_system( + false_easting=false_easting, false_northing=false_northing + ) + test_cube = self._make_test_cube(cs=cs) + msg = ( + r"non zero false easting \(\d*\.\d{2}\) or " + r"non zero false northing \(\d*\.\d{2}\)" + r"; unsupported by GRIB Template 3\.140" + r"" + ) + with self.assertRaisesRegex(TranslationError, msg): + grid_definition_template(test_cube, self.mock_grib) + + def test__fail_false_easting(self): + self.__fail_false_easting_northing(10.0, 0.0) + + def test__fail_false_northing(self): + self.__fail_false_easting_northing(0.0, 10.0) + + def test__fail_false_easting_northing(self): + self.__fail_false_easting_northing(10.0, 10.0) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_20.py b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_20.py new file mode 100644 index 00000000..535338b8 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_20.py @@ -0,0 +1,197 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :meth:`iris_grib._save_rules.grid_definition_template_20`. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from iris.coord_systems import GeogCS, PolarStereographic, Stereographic +from iris.coords import AuxCoord +from iris.exceptions import TranslationError +import numpy as np + +from iris_grib._save_rules import grid_definition_template_20 +from iris_grib.tests.unit.save_rules import GdtTestMixin + + +class Test(tests.IrisGribTest, GdtTestMixin): + def setUp(self): + self.default_ellipsoid = GeogCS(semi_major_axis=6371200.0) + self.stereo_test_cube = self._make_test_cube(coord_units="m") + + GdtTestMixin.setUp(self) + + def _default_coord_system(self, false_easting=0, false_northing=0): + return PolarStereographic( + 90.0, + 0, + false_easting=false_easting, + false_northing=false_northing, + ellipsoid=self.default_ellipsoid, + ) + + def test__template_number(self): + grid_definition_template_20(self.stereo_test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 20) + + def test__shape_of_earth(self): + grid_definition_template_20(self.stereo_test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 1) + self._check_key("scaleFactorOfRadiusOfSphericalEarth", 0) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 0) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 0) + + def test__shape_of_earth_fixed_6(self): + cs = self._default_coord_system() + cs.ellipsoid = GeogCS(semi_major_axis=6371229) + test_cube = self._make_test_cube(cs=cs, coord_units="m") + grid_definition_template_20(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 6) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 0) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 0) + + def test__grid_shape(self): + stereo_test_cube = self._make_test_cube( + x_points=np.arange(13), y_points=np.arange(6), coord_units="m" + ) + grid_definition_template_20(stereo_test_cube, self.mock_grib) + self._check_key("Nx", 13) + self._check_key("Ny", 6) + + def test__grid_points(self): + stereo_test_cube = self._make_test_cube( + x_points=[1e6, 3e6, 5e6, 7e6], y_points=[4e6, 9e6], coord_units="m" + ) + grid_definition_template_20(stereo_test_cube, self.mock_grib) + self._check_key("latitudeOfFirstGridPoint", 54139565) + self._check_key("longitudeOfFirstGridPoint", 165963757) + self._check_key("Dx", 2e9) + self._check_key("Dy", 5e9) + + def test__template_specifics(self): + grid_definition_template_20(self.stereo_test_cube, self.mock_grib) + self._check_key("LaD", 90e6) + self._check_key("LoV", 0) + + def test__scanmode(self): + grid_definition_template_20(self.stereo_test_cube, self.mock_grib) + self._check_scanmode(+1, +1) + + def test__scanmode_reverse(self): + stereo_test_cube = self._make_test_cube( + x_points=np.arange(7e6, 0, -1e6), coord_units="m" + ) + grid_definition_template_20(stereo_test_cube, self.mock_grib) + self._check_scanmode(-1, +1) + + def test_projection_centre(self): + grid_definition_template_20(self.stereo_test_cube, self.mock_grib) + self._check_key("projectionCentreFlag", 0) + + def test_projection_centre_south_pole(self): + cs = PolarStereographic(-90.0, 0, ellipsoid=self.default_ellipsoid) + stereo_test_cube = self._make_test_cube(cs=cs, coord_units="m") + grid_definition_template_20(stereo_test_cube, self.mock_grib) + self._check_key("projectionCentreFlag", 128) + + def test_projection_centre_south_pole_parent(self): + # Saving should be able to handle either class (PolarStereographic + # being a subclass of Stereographic). + cs = Stereographic(-90.0, 0, ellipsoid=self.default_ellipsoid) + stereo_test_cube = self._make_test_cube(cs=cs, coord_units="m") + grid_definition_template_20(stereo_test_cube, self.mock_grib) + self._check_key("projectionCentreFlag", 128) + + def test_projection_centre_bad(self): + cs = Stereographic(0, 0, ellipsoid=self.default_ellipsoid) + stereo_test_cube = self._make_test_cube(cs=cs, coord_units="m") + exp_emsg = "must be 90.0 or -90.0" + with self.assertRaisesRegex(TranslationError, exp_emsg): + grid_definition_template_20(stereo_test_cube, self.mock_grib) + + def __fail_false_easting_northing(self, false_easting, false_northing): + cs = self._default_coord_system( + false_easting=false_easting, false_northing=false_northing + ) + test_cube = self._make_test_cube(cs=cs) + message = "Non-zero unsupported" + with self.assertRaisesRegex(TranslationError, message): + grid_definition_template_20(test_cube, self.mock_grib) + + def test__fail_false_easting(self): + self.__fail_false_easting_northing(10.0, 0.0) + + def test__fail_false_northing(self): + self.__fail_false_easting_northing(0.0, 10.0) + + def test__fail_false_easting_northing(self): + self.__fail_false_easting_northing(10.0, 10.0) + + def __fail_irregular_coords(self, x=False, y=False): + def irregular_coord(coord): + coord = AuxCoord.from_coord(coord) + coord.points[1] = coord.points[0] + return coord + + test_cube = self._make_test_cube( + # Make the Y dimension longer to make irregularity is possible. + y_points=[7.0, 8.0, 9.0], + coord_units="m", + ) + coord_lon = test_cube.coord("longitude") + coord_lat = test_cube.coord("latitude") + if x: + test_cube.replace_coord(irregular_coord(coord_lon)) + if y: + test_cube.replace_coord(irregular_coord(coord_lat)) + + message = "Irregular coordinates not supported" + with self.assertRaisesRegex(TranslationError, message): + grid_definition_template_20(test_cube, self.mock_grib) + + def test__fail_irregular_x_coords(self): + self.__fail_irregular_coords(x=True) + + def test__fail_irregular_y_coords(self): + self.__fail_irregular_coords(y=True) + + def test__fail_irregular_coords(self): + self.__fail_irregular_coords(x=True, y=True) + + def test__fail_non_identical_lats(self): + coord_system = PolarStereographic( + 90.0, + 0, + true_scale_lat=60.0, + ellipsoid=self.default_ellipsoid, + ) + test_cube = self._make_test_cube(cs=coord_system, coord_units="m") + message = "only write a GRIB Template 3.20 file where these are identical" + with self.assertRaisesRegex(TranslationError, message): + grid_definition_template_20(test_cube, self.mock_grib) + + def test__fail_scale_factor(self): + coord_system = PolarStereographic( + 90.0, + 0, + scale_factor_at_projection_origin=0.5, + ellipsoid=self.default_ellipsoid, + ) + test_cube = self._make_test_cube(cs=coord_system, coord_units="m") + message = "cannot write scale_factor_at_projection_origin" + with self.assertRaisesRegex(TranslationError, message): + grid_definition_template_20(test_cube, self.mock_grib) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_30.py b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_30.py new file mode 100644 index 00000000..993db0f0 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_30.py @@ -0,0 +1,191 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :meth:`iris_grib._save_rules.grid_definition_template_30`. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import numpy as np + +import iris.coords +from iris.coord_systems import GeogCS, LambertConformal +from iris.exceptions import TranslationError + +from iris_grib._save_rules import grid_definition_template_30 +from iris_grib.tests.unit.save_rules import GdtTestMixin + + +class FakeGribError(Exception): + pass + + +class Test(tests.IrisGribTest, GdtTestMixin): + def setUp(self): + self.default_ellipsoid = GeogCS( + semi_major_axis=6377563.396, semi_minor_axis=6356256.909 + ) + self.test_cube = self._make_test_cube() + + GdtTestMixin.setUp(self) + + def _make_test_cube(self, cs=None, x_points=None, y_points=None): + # Create a cube with given properties, or minimal defaults. + if cs is None: + cs = self._default_coord_system() + if x_points is None: + x_points = self._default_x_points() + if y_points is None: + y_points = self._default_y_points() + + x_coord = iris.coords.DimCoord( + x_points, "projection_x_coordinate", units="m", coord_system=cs + ) + y_coord = iris.coords.DimCoord( + y_points, "projection_y_coordinate", units="m", coord_system=cs + ) + test_cube = iris.cube.Cube(np.zeros((len(y_points), len(x_points)))) + test_cube.add_dim_coord(y_coord, 0) + test_cube.add_dim_coord(x_coord, 1) + return test_cube + + def _default_coord_system(self, false_easting=0.0, false_northing=0.0): + return LambertConformal( + central_lat=39.0, + central_lon=-96.0, + false_easting=false_easting, + false_northing=false_northing, + secant_latitudes=(33, 45), + ellipsoid=self.default_ellipsoid, + ) + + def test__template_number(self): + grid_definition_template_30(self.test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 30) + + def test__shape_of_earth(self): + grid_definition_template_30(self.test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 7) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 6377563.396) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 6356256.909) + + def test__shape_of_earth_fixed_6(self): + cs = self._default_coord_system() + cs.ellipsoid = GeogCS(semi_major_axis=6371229) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template_30(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 6) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 0) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 0) + + def test__grid_shape(self): + test_cube = self._make_test_cube(x_points=np.arange(13), y_points=np.arange(6)) + grid_definition_template_30(test_cube, self.mock_grib) + self._check_key("Nx", 13) + self._check_key("Ny", 6) + + def test__grid_points(self): + test_cube = self._make_test_cube( + x_points=[1e6, 3e6, 5e6, 7e6], y_points=[4e6, 9e6] + ) + grid_definition_template_30(test_cube, self.mock_grib) + self._check_key("latitudeOfFirstGridPoint", 71676530) + self._check_key("longitudeOfFirstGridPoint", 287218188) + self._check_key("Dx", 2e9) + self._check_key("Dy", 5e9) + + def test__template_specifics(self): + grid_definition_template_30(self.test_cube, self.mock_grib) + self._check_key("LaD", 39e6) + self._check_key("LoV", 264e6) + self._check_key("Latin1", 33e6) + self._check_key("Latin2", 45e6) + self._check_key("latitudeOfSouthernPole", 0) + self._check_key("longitudeOfSouthernPole", 0) + + def test__scanmode(self): + grid_definition_template_30(self.test_cube, self.mock_grib) + self._check_scanmode(+1, +1) + + def test__scanmode_reverse(self): + test_cube = self._make_test_cube(x_points=np.arange(7e6, 0, -1e6)) + grid_definition_template_30(test_cube, self.mock_grib) + self._check_scanmode(-1, +1) + + def test_projection_centre(self): + grid_definition_template_30(self.test_cube, self.mock_grib) + self._check_key("projectionCentreFlag", 0) + + def test_projection_centre_south_pole(self): + cs = LambertConformal( + central_lat=39.0, + central_lon=-96.0, + false_easting=0.0, + false_northing=0.0, + secant_latitudes=(-33, -45), + ellipsoid=self.default_ellipsoid, + ) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template_30(test_cube, self.mock_grib) + self._check_key("projectionCentreFlag", 128) + + def __fail_false_easting_northing(self, false_easting, false_northing): + cs = self._default_coord_system( + false_easting=false_easting, false_northing=false_northing + ) + test_cube = self._make_test_cube(cs=cs) + message = "Non-zero unsupported" + with self.assertRaisesRegex(TranslationError, message): + grid_definition_template_30(test_cube, self.mock_grib) + + def test__fail_false_easting(self): + self.__fail_false_easting_northing(10.0, 0.0) + + def test__fail_false_northing(self): + self.__fail_false_easting_northing(0.0, 10.0) + + def test__fail_false_easting_northing(self): + self.__fail_false_easting_northing(10.0, 10.0) + + def __fail_irregular_coords(self, x=False, y=False): + def irregular_coord(coord): + coord = iris.coords.AuxCoord.from_coord(coord) + coord.points[1] = coord.points[0] + return coord + + test_cube = self._make_test_cube( + # Make the Y dimension longer to make irregularity is possible. + y_points=[7.0, 8.0, 9.0], + ) + coord_x = test_cube.coord("projection_x_coordinate") + coord_y = test_cube.coord("projection_y_coordinate") + if x: + test_cube.replace_coord(irregular_coord(coord_x)) + if y: + test_cube.replace_coord(irregular_coord(coord_y)) + + message = "Irregular coordinates not supported" + with self.assertRaisesRegex(TranslationError, message): + grid_definition_template_30(test_cube, self.mock_grib) + + def test__fail_irregular_x_coords(self): + self.__fail_irregular_coords(x=True) + + def test__fail_irregular_y_coords(self): + self.__fail_irregular_coords(y=True) + + def test__fail_irregular_coords(self): + self.__fail_irregular_coords(x=True, y=True) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_4.py b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_4.py new file mode 100644 index 00000000..14ded96a --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_4.py @@ -0,0 +1,86 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :func:`iris_grib._save_rules.grid_definition_template_4`. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import numpy as np + +from iris.coord_systems import GeogCS + +from iris_grib._save_rules import grid_definition_template_4 +from iris_grib.tests.unit.save_rules import GdtTestMixin + + +class Test(tests.IrisGribTest, GdtTestMixin): + def setUp(self): + GdtTestMixin.setUp(self) + + def test__template_number(self): + grid_definition_template_4(self.test_cube, self.mock_grib) + self._check_key("gridDefinitionTemplateNumber", 4) + + def test__shape_of_earth_spherical(self): + cs = GeogCS(semi_major_axis=1.23) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template_4(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 1) + self._check_key("scaleFactorOfRadiusOfSphericalEarth", 0) + self._check_key("scaledValueOfRadiusOfSphericalEarth", 1.23) + + def test__shape_of_earth_flattened(self): + cs = GeogCS(semi_major_axis=1.456, semi_minor_axis=1.123) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template_4(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 7) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 1.456) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 1.123) + + def test__shape_of_earth_fixed_6(self): + cs = GeogCS(semi_major_axis=6371229) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template_4(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 6) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 0) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 0) + + def test__grid_shape(self): + test_cube = self._make_test_cube(x_points=np.arange(13), y_points=np.arange(6)) + grid_definition_template_4(test_cube, self.mock_grib) + self._check_key("Ni", 13) + self._check_key("Nj", 6) + + def test__grid_points(self): + x_floats = np.array([11.0, 12.0, 167.0]) + # TODO: reduce Y to 2 points, when gribapi nx=ny limitation is gone. + y_floats = np.array([20.0, 21.0, 22.0]) + test_cube = self._make_test_cube(x_points=x_floats, y_points=y_floats) + grid_definition_template_4(test_cube, self.mock_grib) + x_longs = np.array(np.round(1e6 * x_floats), dtype=int) + y_longs = np.array(np.round(1e6 * y_floats), dtype=int) + self._check_key("longitude", x_longs) + self._check_key("latitude", y_longs) + + def test__scanmode(self): + grid_definition_template_4(self.test_cube, self.mock_grib) + self._check_scanmode(+1, +1) + + def test__scanmode_reverse(self): + test_cube = self._make_test_cube(x_points=np.arange(7, 0, -1)) + grid_definition_template_4(test_cube, self.mock_grib) + self._check_scanmode(-1, +1) + + +if __name__ == "__main__": + tests.main() diff --git a/iris_grib/tests/unit/save_rules/test_grid_definition_template_5.py b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_5.py similarity index 51% rename from iris_grib/tests/unit/save_rules/test_grid_definition_template_5.py rename to src/iris_grib/tests/unit/save_rules/test_grid_definition_template_5.py index b9ccf8e5..62831030 100644 --- a/iris_grib/tests/unit/save_rules/test_grid_definition_template_5.py +++ b/src/iris_grib/tests/unit/save_rules/test_grid_definition_template_5.py @@ -1,27 +1,12 @@ -# (C) British Crown Copyright 2014 - 2016, Met Office +# Copyright iris-grib contributors # -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. """ Unit tests for :meth:`iris_grib._save_rules.grid_definition_template_5`. """ -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa - # Import iris_grib.tests first so that some things can be initialised before # importing anything else. import iris_grib.tests as tests @@ -43,9 +28,11 @@ def setUp(self): def _default_coord_system(self): # Define an alternate, rotated coordinate system to test." self.default_ellipsoid = GeogCS(PP_DEFAULT_EARTH_RADIUS) - cs = RotatedGeogCS(grid_north_pole_latitude=90.0, - grid_north_pole_longitude=0.0, - ellipsoid=self.default_ellipsoid) + cs = RotatedGeogCS( + grid_north_pole_latitude=90.0, + grid_north_pole_longitude=0.0, + ellipsoid=self.default_ellipsoid, + ) return cs def _default_x_points(self): @@ -54,53 +41,71 @@ def _default_x_points(self): def test__template_number(self): grid_definition_template_5(self.test_cube, self.mock_grib) - self._check_key('gridDefinitionTemplateNumber', 5) + self._check_key("gridDefinitionTemplateNumber", 5) def test__shape_of_earth_spherical(self): - cs = RotatedGeogCS(grid_north_pole_latitude=90.0, - grid_north_pole_longitude=0.0, - ellipsoid=GeogCS(52431.0)) + cs = RotatedGeogCS( + grid_north_pole_latitude=90.0, + grid_north_pole_longitude=0.0, + ellipsoid=GeogCS(52431.0), + ) test_cube = self._make_test_cube(cs=cs) grid_definition_template_5(test_cube, self.mock_grib) - self._check_key('shapeOfTheEarth', 1) - self._check_key('scaleFactorOfRadiusOfSphericalEarth', 0) - self._check_key('scaledValueOfRadiusOfSphericalEarth', 52431.0) + self._check_key("shapeOfTheEarth", 1) + self._check_key("scaleFactorOfRadiusOfSphericalEarth", 0) + self._check_key("scaledValueOfRadiusOfSphericalEarth", 52431.0) def test__shape_of_earth_flattened(self): ellipsoid = GeogCS(semi_major_axis=1456.0, semi_minor_axis=1123.0) - cs = RotatedGeogCS(grid_north_pole_latitude=90.0, - grid_north_pole_longitude=0.0, - ellipsoid=ellipsoid) + cs = RotatedGeogCS( + grid_north_pole_latitude=90.0, + grid_north_pole_longitude=0.0, + ellipsoid=ellipsoid, + ) + test_cube = self._make_test_cube(cs=cs) + grid_definition_template_5(test_cube, self.mock_grib) + self._check_key("shapeOfTheEarth", 7) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 1456.0) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 1123.0) + + def test__shape_of_earth_fixed_6(self): + ellipsoid = GeogCS(semi_major_axis=6371229) + cs = RotatedGeogCS( + grid_north_pole_latitude=90.0, + grid_north_pole_longitude=0.0, + ellipsoid=ellipsoid, + ) test_cube = self._make_test_cube(cs=cs) grid_definition_template_5(test_cube, self.mock_grib) - self._check_key('shapeOfTheEarth', 7) - self._check_key('scaleFactorOfEarthMajorAxis', 0) - self._check_key('scaledValueOfEarthMajorAxis', 1456.0) - self._check_key('scaleFactorOfEarthMinorAxis', 0) - self._check_key('scaledValueOfEarthMinorAxis', 1123.0) + self._check_key("shapeOfTheEarth", 6) + self._check_key("scaleFactorOfEarthMajorAxis", 0) + self._check_key("scaledValueOfEarthMajorAxis", 0) + self._check_key("scaleFactorOfEarthMinorAxis", 0) + self._check_key("scaledValueOfEarthMinorAxis", 0) def test__grid_shape(self): - test_cube = self._make_test_cube(x_points=np.arange(13), - y_points=np.arange(6)) + test_cube = self._make_test_cube(x_points=np.arange(13), y_points=np.arange(6)) grid_definition_template_5(test_cube, self.mock_grib) - self._check_key('Ni', 13) - self._check_key('Nj', 6) + self._check_key("Ni", 13) + self._check_key("Nj", 6) def test__scanmode(self): grid_definition_template_5(self.test_cube, self.mock_grib) - self._check_key('iScansPositively', 1) - self._check_key('jScansPositively', 1) + self._check_scanmode(+1, +1) def test__scanmode_reverse(self): test_cube = self._make_test_cube(y_points=[5.0, 2.0]) grid_definition_template_5(test_cube, self.mock_grib) - self._check_key('iScansPositively', 1) - self._check_key('jScansPositively', 0) + self._check_scanmode(+1, -1) def test__rotated_pole(self): - cs = RotatedGeogCS(grid_north_pole_latitude=75.3, - grid_north_pole_longitude=54.321, - ellipsoid=self.default_ellipsoid) + cs = RotatedGeogCS( + grid_north_pole_latitude=75.3, + grid_north_pole_longitude=54.321, + ellipsoid=self.default_ellipsoid, + ) test_cube = self._make_test_cube(cs=cs) grid_definition_template_5(test_cube, self.mock_grib) self._check_key("latitudeOfSouthernPole", -75300000) @@ -108,14 +113,16 @@ def test__rotated_pole(self): self._check_key("angleOfRotation", 0) def test__fail_rotated_pole_nonstandard_meridian(self): - cs = RotatedGeogCS(grid_north_pole_latitude=90.0, - grid_north_pole_longitude=0.0, - north_pole_grid_longitude=22.5, - ellipsoid=self.default_ellipsoid) + cs = RotatedGeogCS( + grid_north_pole_latitude=90.0, + grid_north_pole_longitude=0.0, + north_pole_grid_longitude=22.5, + ellipsoid=self.default_ellipsoid, + ) test_cube = self._make_test_cube(cs=cs) - with self.assertRaisesRegexp( - TranslationError, - 'not yet support .* rotated prime meridian.'): + with self.assertRaisesRegex( + TranslationError, "not yet support .* rotated prime meridian." + ): grid_definition_template_5(test_cube, self.mock_grib) def test__grid_points(self): @@ -126,20 +133,20 @@ def test__grid_points(self): grid_definition_template_5(test_cube, self.mock_grib) x_longs = np.array(np.round(1e6 * x_floats), dtype=int) y_longs = np.array(np.round(1e6 * y_floats), dtype=int) - self._check_key("longitudes", x_longs) - self._check_key("latitudes", y_longs) + self._check_key("longitude", x_longs) + self._check_key("latitude", y_longs) def test__true_winds_orientation(self): - self.test_cube.rename('eastward_wind') + self.test_cube.rename("eastward_wind") grid_definition_template_5(self.test_cube, self.mock_grib) - flags = self.mock_grib.keys['resolutionAndComponentFlags'] & 255 + flags = self.mock_grib.keys["resolutionAndComponentFlags"] & 255 flags_expected = 0b00000000 self.assertEqual(flags, flags_expected) def test__grid_winds_orientation(self): - self.test_cube.rename('x_wind') + self.test_cube.rename("x_wind") grid_definition_template_5(self.test_cube, self.mock_grib) - flags = self.mock_grib.keys['resolutionAndComponentFlags'] & 255 + flags = self.mock_grib.keys["resolutionAndComponentFlags"] & 255 flags_expected = 0b00001000 self.assertEqual(flags, flags_expected) diff --git a/src/iris_grib/tests/unit/save_rules/test_identification.py b/src/iris_grib/tests/unit/save_rules/test_identification.py new file mode 100644 index 00000000..d09dc52b --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_identification.py @@ -0,0 +1,70 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for `iris_grib.grib_save_rules.identification`.""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +import eccodes + +import iris +import iris.tests.stock as stock + +from iris_grib._save_rules import identification +from iris_grib.tests.unit import TestGribSimple + + +GRIB_API = "iris_grib._save_rules.eccodes" + + +class Test(TestGribSimple): + @tests.skip_data + def test_no_realization(self): + cube = stock.simple_pp() + grib = mock.Mock() + mock_eccodes = mock.Mock(spec=eccodes) + with mock.patch(GRIB_API, mock_eccodes): + identification(cube, grib) + + mock_eccodes.assert_has_calls( + [mock.call.codes_set_long(grib, "typeOfProcessedData", 2)] + ) + + @tests.skip_data + def test_realization_0(self): + cube = stock.simple_pp() + realisation = iris.coords.AuxCoord((0,), standard_name="realization", units="1") + cube.add_aux_coord(realisation) + + grib = mock.Mock() + mock_eccodes = mock.Mock(spec=eccodes) + with mock.patch(GRIB_API, mock_eccodes): + identification(cube, grib) + + mock_eccodes.assert_has_calls( + [mock.call.codes_set_long(grib, "typeOfProcessedData", 3)] + ) + + @tests.skip_data + def test_realization_n(self): + cube = stock.simple_pp() + realisation = iris.coords.AuxCoord((2,), standard_name="realization", units="1") + cube.add_aux_coord(realisation) + + grib = mock.Mock() + mock_eccodes = mock.Mock(spec=eccodes) + with mock.patch(GRIB_API, mock_eccodes): + identification(cube, grib) + + mock_eccodes.assert_has_calls( + [mock.call.codes_set_long(grib, "typeOfProcessedData", 4)] + ) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_latlon_first_last.py b/src/iris_grib/tests/unit/save_rules/test_latlon_first_last.py new file mode 100644 index 00000000..1ddc9e72 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_latlon_first_last.py @@ -0,0 +1,51 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for `iris_grib.grib_save_rules.identification`.""" + +import numpy as np +import pytest + +from iris.coords import AuxCoord + +from iris_grib._save_rules import latlon_first_last + + +class Test: + """Check operation for specific test values. + + The specific point is that coord values should be promoted to "double" to optimise + rounding error when converting to integer. + In effect, this was the default with numpy 1, but changed in numpy 2. + See : https://github.com/SciTools/iris-grib/issues/686 + """ + + # Numbers from a real testcase, where naive non-promoted calculation got it "wrong". + lbrow = 1920 + lbnpt = 2560 + bzx = -0.070312500000000000000000000000 + bdx = 0.140625000000000000000000000000 + bzy = -90.046875000000000000000000000000 + bdy = 0.093750000000000000000000000000 + + @pytest.mark.parametrize("wraplons", ["wrapped", "nowrap"]) + def test_latlons(self, wraplons, mocker): + patch = mocker.patch("iris_grib._save_rules.eccodes.codes_set_long") + x_points = np.array( + [self.bzx + self.bdx, self.bzx + self.lbnpt * self.bdx], dtype=np.float32 + ) + if wraplons == "wrapped": + # Drive longitudes negative : results should be just the same. + x_points -= 720 + y_points = np.array( + [self.bzy + self.bdy, self.bzy + self.lbrow * self.bdy], dtype=np.float32 + ) + x_coord = AuxCoord(x_points) + y_coord = AuxCoord(y_points) + latlon_first_last(x_coord, y_coord, None) + cal = patch.call_args_list + assert len(cal) == 4 + results = [call.args[2] for call in cal] + expected = [-89953125, 89953125, 70312, 359929687] + assert results == expected diff --git a/src/iris_grib/tests/unit/save_rules/test_product_definition_template_1.py b/src/iris_grib/tests/unit/save_rules/test_product_definition_template_1.py new file mode 100644 index 00000000..c6904c3d --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_product_definition_template_1.py @@ -0,0 +1,61 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :func:`iris_grib._save_rules.product_definition_template_1` + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from cf_units import Unit +import eccodes + +from iris.coords import DimCoord +import iris.tests.stock as stock + +from iris_grib._save_rules import product_definition_template_1 + + +class TestRealizationIdentifier(tests.IrisGribTest): + def setUp(self): + self.cube = stock.lat_lon_cube() + # Rename cube to avoid warning about unknown discipline/parameter. + self.cube.rename("air_temperature") + coord = DimCoord( + [45], "time", units=Unit("days since epoch", calendar="standard") + ) + self.cube.add_aux_coord(coord) + + @mock.patch.object(eccodes, "codes_set") + def test_realization(self, mock_set): + cube = self.cube + coord = DimCoord(10, "realization", units="1") + cube.add_aux_coord(coord) + + product_definition_template_1(cube, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "productDefinitionTemplateNumber", 1 + ) + mock_set.assert_any_call(mock.sentinel.grib, "perturbationNumber", 10) + mock_set.assert_any_call(mock.sentinel.grib, "numberOfForecastsInEnsemble", 255) + mock_set.assert_any_call(mock.sentinel.grib, "typeOfEnsembleForecast", 255) + + @mock.patch.object(eccodes, "codes_set") + def test_multiple_realization_values(self, mock_set): + cube = self.cube + coord = DimCoord([8, 9, 10], "realization", units="1") + cube.add_aux_coord(coord, 0) + + msg = "'realization' coordinate with one point is required" + with self.assertRaisesRegex(ValueError, msg): + product_definition_template_1(cube, mock.sentinel.grib) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_product_definition_template_10.py b/src/iris_grib/tests/unit/save_rules/test_product_definition_template_10.py new file mode 100644 index 00000000..230a1d73 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_product_definition_template_10.py @@ -0,0 +1,61 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :func:`iris_grib._save_rules.product_definition_template_10` + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from cf_units import Unit +import eccodes + +from iris.coords import DimCoord +import iris.tests.stock as stock + +from iris_grib._save_rules import product_definition_template_10 + + +class TestPercentileValueIdentifier(tests.IrisGribTest): + def setUp(self): + self.cube = stock.lat_lon_cube() + # Rename cube to avoid warning about unknown discipline/parameter. + self.cube.rename("y_wind") + time_coord = DimCoord( + 20, + "time", + bounds=[0, 40], + units=Unit("days since epoch", calendar="julian"), + ) + self.cube.add_aux_coord(time_coord) + + @mock.patch.object(eccodes, "codes_set") + def test_percentile_value(self, mock_set): + cube = self.cube + percentile_coord = DimCoord(95, long_name="percentile_over_time") + cube.add_aux_coord(percentile_coord) + + product_definition_template_10(cube, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "productDefinitionTemplateNumber", 10 + ) + mock_set.assert_any_call(mock.sentinel.grib, "percentileValue", 95) + + @mock.patch.object(eccodes, "codes_set") + def test_multiple_percentile_value(self, mock_set): + cube = self.cube + percentile_coord = DimCoord([5, 10, 15], long_name="percentile_over_time") + cube.add_aux_coord(percentile_coord, 0) + err_msg = "A cube 'percentile_over_time' coordinate with one point is required" + with self.assertRaisesRegex(ValueError, err_msg): + product_definition_template_10(cube, mock.sentinel.grib) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_product_definition_template_11.py b/src/iris_grib/tests/unit/save_rules/test_product_definition_template_11.py new file mode 100644 index 00000000..64e27a06 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_product_definition_template_11.py @@ -0,0 +1,56 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :func:`iris_grib._save_rules.product_definition_template_11` + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from cf_units import Unit +import eccodes + +from iris.coords import CellMethod, DimCoord +import iris.tests.stock as stock + +from iris_grib._save_rules import product_definition_template_11 + + +class TestRealizationIdentifier(tests.IrisGribTest): + def setUp(self): + self.cube = stock.lat_lon_cube() + # Rename cube to avoid warning about unknown discipline/parameter. + self.cube.rename("air_temperature") + coord = DimCoord( + 23, + "time", + bounds=[0, 100], + units=Unit("days since epoch", calendar="standard"), + ) + self.cube.add_aux_coord(coord) + coord = DimCoord(4, "realization", units="1") + self.cube.add_aux_coord(coord) + + @mock.patch.object(eccodes, "codes_set") + def test_realization(self, mock_set): + cube = self.cube + cell_method = CellMethod(method="sum", coords=["time"]) + cube.add_cell_method(cell_method) + + product_definition_template_11(cube, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "productDefinitionTemplateNumber", 11 + ) + mock_set.assert_any_call(mock.sentinel.grib, "perturbationNumber", 4) + mock_set.assert_any_call(mock.sentinel.grib, "numberOfForecastsInEnsemble", 255) + mock_set.assert_any_call(mock.sentinel.grib, "typeOfEnsembleForecast", 255) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_product_definition_template_15.py b/src/iris_grib/tests/unit/save_rules/test_product_definition_template_15.py new file mode 100644 index 00000000..814562b8 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_product_definition_template_15.py @@ -0,0 +1,146 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :func:`iris_grib._save_rules.product_definition_template_15` + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from cf_units import Unit +import eccodes + +from iris.coords import CellMethod, DimCoord +import iris.tests.stock as stock + +from iris_grib._save_rules import product_definition_template_15 + + +class TestSpatialProcessingIdentifiers(tests.IrisGribTest): + def setUp(self): + self.cube = stock.lat_lon_cube() + # Add scalar time coord so that product_definition_template_common + # doesn't get upset. + t_coord = DimCoord( + [424854.0], + standard_name="time", + units=Unit("hours since 1970-01-01 00:00:00", calendar="gregorian"), + ) + self.cube.add_aux_coord(t_coord) + # Rename cube to avoid warning about unknown discipline/parameter. + self.cube.rename("air_temperature") + + @mock.patch.object(eccodes, "codes_set") + def test_cell_method(self, mock_set): + cube_0 = self.cube + cube_0.attributes = dict(spatial_processing_type="No interpolation") + cell_method = CellMethod(method="mean", coords=["area"]) + cube_0.add_cell_method(cell_method) + + # If the cube has a cell method attached then it should not have any + # interpolation on the data, so spatial processing code should be 0 and + # number of points used should be 0. + product_definition_template_15(cube_0, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "productDefinitionTemplateNumber", 15 + ) + mock_set.assert_any_call(mock.sentinel.grib, "spatialProcessing", 0) + mock_set.assert_any_call(mock.sentinel.grib, "statisticalProcess", 0) + + @mock.patch.object(eccodes, "codes_set") + def test_bilinear_interpolation(self, mock_set): + cube_1 = self.cube + cube_1.attributes = dict(spatial_processing_type="Bilinear interpolation") + + # If the cube has a bilinear interpolation attribute, spatial + # processing code should be 1 and number of points used should be 4. + product_definition_template_15(cube_1, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "productDefinitionTemplateNumber", 15 + ) + mock_set.assert_any_call(mock.sentinel.grib, "spatialProcessing", 1) + mock_set.assert_any_call(mock.sentinel.grib, "numberOfPointsUsed", 4) + + @mock.patch.object(eccodes, "codes_set") + def test_bicubic_interpolation(self, mock_set): + cube_2 = self.cube + cube_2.attributes = dict(spatial_processing_type="Bicubic interpolation") + + # If the cube has a bicubic interpolation attribute, spatial + # processing code should be 2 and number of points used should be 4. + product_definition_template_15(cube_2, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "productDefinitionTemplateNumber", 15 + ) + mock_set.assert_any_call(mock.sentinel.grib, "spatialProcessing", 2) + mock_set.assert_any_call(mock.sentinel.grib, "numberOfPointsUsed", 4) + + @mock.patch.object(eccodes, "codes_set") + def test_nearest_neighbour_interpolation(self, mock_set): + cube_3 = self.cube + cube_3.attributes = dict( + spatial_processing_type="Nearest neighbour interpolation" + ) + + # If the cube has a nearest neighbour interpolation attribute, spatial + # processing code should be 3 and number of points used should be 1. + product_definition_template_15(cube_3, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "productDefinitionTemplateNumber", 15 + ) + mock_set.assert_any_call(mock.sentinel.grib, "spatialProcessing", 3) + mock_set.assert_any_call(mock.sentinel.grib, "numberOfPointsUsed", 1) + + @mock.patch.object(eccodes, "codes_set") + def test_budget_interpolation(self, mock_set): + cube_4 = self.cube + cube_4.attributes = dict(spatial_processing_type="Budget interpolation") + + # If the cube has a budget interpolation attribute, spatial + # processing code should be 4 and number of points used should be 4. + product_definition_template_15(cube_4, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "productDefinitionTemplateNumber", 15 + ) + mock_set.assert_any_call(mock.sentinel.grib, "spatialProcessing", 4) + mock_set.assert_any_call(mock.sentinel.grib, "numberOfPointsUsed", 4) + + @mock.patch.object(eccodes, "codes_set") + def test_spectral_interpolation(self, mock_set): + cube_5 = self.cube + cube_5.attributes = dict(spatial_processing_type="Spectral interpolation") + + # If the cube has a spectral interpolation attribute, spatial + # processing code should be 5 and number of points used should be 4. + product_definition_template_15(cube_5, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "productDefinitionTemplateNumber", 15 + ) + mock_set.assert_any_call(mock.sentinel.grib, "spatialProcessing", 5) + mock_set.assert_any_call(mock.sentinel.grib, "numberOfPointsUsed", 4) + + @mock.patch.object(eccodes, "codes_set") + def test_neighbour_budget_interpolation(self, mock_set): + cube_6 = self.cube + cube_6.attributes = dict( + spatial_processing_type="Neighbour-budget interpolation" + ) + + # If the cube has a neighbour-budget interpolation attribute, spatial + # processing code should be 6 and number of points used should be 4. + product_definition_template_15(cube_6, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "productDefinitionTemplateNumber", 15 + ) + mock_set.assert_any_call(mock.sentinel.grib, "spatialProcessing", 6) + mock_set.assert_any_call(mock.sentinel.grib, "numberOfPointsUsed", 4) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_product_definition_template_40.py b/src/iris_grib/tests/unit/save_rules/test_product_definition_template_40.py new file mode 100644 index 00000000..d0df74cb --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_product_definition_template_40.py @@ -0,0 +1,48 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :func:`iris_grib._save_rules.product_definition_template_40` + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from cf_units import Unit +import eccodes + +from iris.coords import DimCoord +import iris.tests.stock as stock + +from iris_grib._save_rules import product_definition_template_40 + + +class TestChemicalConstituentIdentifier(tests.IrisGribTest): + def setUp(self): + self.cube = stock.lat_lon_cube() + # Rename cube to avoid warning about unknown discipline/parameter. + self.cube.rename("atmosphere_mole_content_of_ozone") + coord = DimCoord( + 24, "time", units=Unit("days since epoch", calendar="standard") + ) + self.cube.add_aux_coord(coord) + self.cube.attributes["WMO_constituent_type"] = 0 + + @mock.patch.object(eccodes, "codes_set") + def test_constituent_type(self, mock_set): + cube = self.cube + + product_definition_template_40(cube, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "productDefinitionTemplateNumber", 40 + ) + mock_set.assert_any_call(mock.sentinel.grib, "constituentType", 0) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_product_definition_template_6.py b/src/iris_grib/tests/unit/save_rules/test_product_definition_template_6.py new file mode 100644 index 00000000..ae9859fc --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_product_definition_template_6.py @@ -0,0 +1,59 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :func:`iris_grib._save_rules.product_definition_template_6` + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from cf_units import Unit +import eccodes + +from iris.coords import DimCoord +import iris.tests.stock as stock + +from iris_grib._save_rules import product_definition_template_6 + + +class TestRealizationIdentifier(tests.IrisGribTest): + def setUp(self): + self.cube = stock.lat_lon_cube() + # Rename cube to avoid warning about unknown discipline/parameter. + self.cube.rename("air_temperature") + coord = DimCoord( + [45], "time", units=Unit("days since epoch", calendar="standard") + ) + self.cube.add_aux_coord(coord) + + @mock.patch.object(eccodes, "codes_set") + def test_percentile(self, mock_set): + cube = self.cube + coord = DimCoord(10, long_name="percentile", units="%") + cube.add_aux_coord(coord) + + product_definition_template_6(cube, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "productDefinitionTemplateNumber", 6 + ) + mock_set.assert_any_call(mock.sentinel.grib, "percentileValue", 10) + + @mock.patch.object(eccodes, "codes_set") + def test_multiple_percentile_values(self, mock_set): + cube = self.cube + coord = DimCoord([8, 9, 10], long_name="percentile", units="%") + cube.add_aux_coord(coord, 0) + + msg = "'percentile' coordinate with one point is required" + with self.assertRaisesRegex(ValueError, msg): + product_definition_template_6(cube, mock.sentinel.grib) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_product_definition_template_8.py b/src/iris_grib/tests/unit/save_rules/test_product_definition_template_8.py new file mode 100644 index 00000000..bcb46114 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_product_definition_template_8.py @@ -0,0 +1,81 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :func:`iris_grib._save_rules.product_definition_template_8` + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +from cf_units import Unit +import eccodes +import numpy as np + +from iris.coords import CellMethod, DimCoord +import iris.cube +import iris.tests.stock as stock + +from iris_grib._save_rules import product_definition_template_8 + + +class TestProductDefinitionIdentifier(tests.IrisGribTest): + def setUp(self): + self.cube = stock.lat_lon_cube() + # Rename cube to avoid warning about unknown discipline/parameter. + self.cube.rename("air_temperature") + coord = DimCoord( + 23, + "time", + bounds=[0, 100], + units=Unit("days since epoch", calendar="standard"), + ) + self.cube.add_aux_coord(coord) + + @mock.patch.object(eccodes, "codes_set") + def test_product_definition(self, mock_set): + cube = self.cube + cell_method = CellMethod(method="sum", coords=["time"]) + cube.add_cell_method(cell_method) + + product_definition_template_8(cube, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "productDefinitionTemplateNumber", 8 + ) + + +class Test_type_of_statistical_processing(tests.IrisTest): + @mock.patch.object(eccodes, "codes_set") + def test_stats_type_min(self, mock_set): + grib = None + cube = iris.cube.Cube(np.array([1.0])) + time_unit = Unit("hours since 1970-01-01 00:00:00") + time_coord = iris.coords.DimCoord( + [0.0], bounds=[0.0, 1], standard_name="time", units=time_unit + ) + cube.add_aux_coord(time_coord, ()) + cube.add_cell_method(iris.coords.CellMethod("maximum", time_coord)) + product_definition_template_8(cube, grib) + mock_set.assert_any_call(grib, "typeOfStatisticalProcessing", 2) + + @mock.patch.object(eccodes, "codes_set") + def test_stats_type_max(self, mock_set): + grib = None + cube = iris.cube.Cube(np.array([1.0])) + time_unit = Unit("hours since 1970-01-01 00:00:00") + time_coord = iris.coords.DimCoord( + [0.0], bounds=[0.0, 1], standard_name="time", units=time_unit + ) + cube.add_aux_coord(time_coord, ()) + cube.add_cell_method(iris.coords.CellMethod("minimum", time_coord)) + product_definition_template_8(cube, grib) + mock_set.assert_any_call(grib, "typeOfStatisticalProcessing", 3) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_reference_time.py b/src/iris_grib/tests/unit/save_rules/test_reference_time.py new file mode 100644 index 00000000..02e59347 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_reference_time.py @@ -0,0 +1,51 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for `iris_grib.grib_save_rules.reference_time`.""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +import eccodes + +from iris_grib import load_cubes +from iris_grib._save_rules import reference_time + + +class Test(tests.IrisGribTest): + def _test(self, cube): + grib = mock.Mock() + mock_eccodes = mock.Mock(spec=eccodes) + with mock.patch("iris_grib._save_rules.eccodes", mock_eccodes): + reference_time(cube, grib) + + mock_eccodes.assert_has_calls( + [ + mock.call.codes_set_long(grib, "significanceOfReferenceTime", 1), + mock.call.codes_set_long(grib, "dataDate", "19980306"), + mock.call.codes_set_long(grib, "dataTime", "0300"), + ] + ) + + @tests.skip_data + def test_forecast_period(self): + # The stock cube has a non-compliant forecast_period. + fname = tests.get_data_path(("GRIB", "global_t", "global.grib2")) + [cube] = load_cubes(fname) + self._test(cube) + + @tests.skip_data + def test_no_forecast_period(self): + # The stock cube has a non-compliant forecast_period. + fname = tests.get_data_path(("GRIB", "global_t", "global.grib2")) + [cube] = load_cubes(fname) + cube.remove_coord("forecast_period") + self._test(cube) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_set_discipline_and_parameter.py b/src/iris_grib/tests/unit/save_rules/test_set_discipline_and_parameter.py new file mode 100644 index 00000000..1aa899f5 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_set_discipline_and_parameter.py @@ -0,0 +1,83 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for `iris_grib.grib_save_rules.set_discipline_and_parameter`.""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests +from unittest import mock + +from iris.cube import Cube + +from iris_grib.grib_phenom_translation import GRIBCode + +from iris_grib._save_rules import set_discipline_and_parameter + + +class TestPhenomenonCoding(tests.IrisGribTest): + def setUp(self): + # A mock cube with empty phenomenon-specifying metadata. + self.mock_cube = mock.Mock( + spec=Cube, standard_name=None, long_name=None, attributes={} + ) + + def _check_coding(self, cube, discipline, paramCategory, paramNumber): + # Check that encoding 'cube' writes the expected phenomenon keys. + codes_set_patch = self.patch("iris_grib._save_rules.eccodes.codes_set") + mock_message = mock.sentinel.grib2_message + + set_discipline_and_parameter(cube, mock_message) + + expected_calls = [ + mock.call(mock_message, "discipline", discipline), + mock.call(mock_message, "parameterCategory", paramCategory), + mock.call(mock_message, "parameterNumber", paramNumber), + ] + + self.assertEqual(codes_set_patch.call_args_list, expected_calls) + + def test_unknown_phenomenon(self): + cube = self.mock_cube + self._check_coding(cube, 255, 255, 255) + + def test_known_standard_name(self): + cube = self.mock_cube + cube.standard_name = "sea_water_y_velocity" + self._check_coding(cube, 10, 1, 3) # as seen in _grib_cf_map.py + + def test_known_long_name(self): + cube = self.mock_cube + cube.long_name = "cloud_mixing_ratio" + self._check_coding(cube, 0, 1, 22) + + def test_gribcode_attribute_object(self): + cube = self.mock_cube + cube.attributes = {"GRIB_PARAM": GRIBCode(2, 7, 12, 99)} + self._check_coding(cube, 7, 12, 99) + + def test_gribcode_attribute_string(self): + cube = self.mock_cube + cube.attributes = {"GRIB_PARAM": "2, 9, 33, 177"} + self._check_coding(cube, 9, 33, 177) + + def test_gribcode_attribute_tuple(self): + cube = self.mock_cube + cube.attributes = {"GRIB_PARAM": (2, 33, 4, 12)} + self._check_coding(cube, 33, 4, 12) + + def test_gribcode_attribute_not_edition_2(self): + cube = self.mock_cube + cube.attributes = {"GRIB_PARAM": GRIBCode(1, 7, 12, 99)} + self._check_coding(cube, 255, 255, 255) + + def test_gribcode_attribute_overrides_phenomenon(self): + cube = self.mock_cube + cube.standard_name = "sea_water_y_velocity" + cube.attributes = {"GRIB_PARAM": "2, 9, 33, 177"} + self._check_coding(cube, 9, 33, 177) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_set_fixed_surfaces.py b/src/iris_grib/tests/unit/save_rules/test_set_fixed_surfaces.py new file mode 100644 index 00000000..7b979130 --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_set_fixed_surfaces.py @@ -0,0 +1,229 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :func:`iris_grib._save_rules.set_fixed_surfaces`. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +import eccodes +from eccodes import CODES_MISSING_LONG as GRIB_MISSING_LONG +import numpy as np + +import iris.cube +import iris.coords +from iris.exceptions import TranslationError + +from iris_grib._save_rules import set_fixed_surfaces + + +class Test(tests.IrisGribTest): + def test_bounded_altitude_feet(self): + cube = iris.cube.Cube([0]) + cube.add_aux_coord( + iris.coords.AuxCoord( + 1500.0, + long_name="altitude", + units="ft", + bounds=np.array([1000.0, 2000.0]), + ) + ) + grib = eccodes.codes_grib_new_from_samples("GRIB2") + set_fixed_surfaces(cube, grib) + self.assertEqual( + eccodes.codes_get_double(grib, "scaledValueOfFirstFixedSurface"), 305.0 + ) # precise ~304.8 + self.assertEqual( + eccodes.codes_get_double(grib, "scaledValueOfSecondFixedSurface"), 610.0 + ) # precise ~609.6 + self.assertEqual(eccodes.codes_get_long(grib, "typeOfFirstFixedSurface"), 102) + self.assertEqual(eccodes.codes_get_long(grib, "typeOfSecondFixedSurface"), 102) + + def test_theta_level(self): + cube = iris.cube.Cube([0]) + cube.add_aux_coord( + iris.coords.AuxCoord( + 230.0, + standard_name="air_potential_temperature", + units="K", + attributes={"positive": "up"}, + bounds=np.array([220.0, 240.0]), + ) + ) + grib = eccodes.codes_grib_new_from_samples("GRIB2") + set_fixed_surfaces(cube, grib) + self.assertEqual( + eccodes.codes_get_double(grib, "scaledValueOfFirstFixedSurface"), 220.0 + ) + self.assertEqual( + eccodes.codes_get_double(grib, "scaledValueOfSecondFixedSurface"), 240.0 + ) + self.assertEqual(eccodes.codes_get_long(grib, "typeOfFirstFixedSurface"), 107) + self.assertEqual(eccodes.codes_get_long(grib, "typeOfSecondFixedSurface"), 107) + + def test_depth(self): + cube = iris.cube.Cube([0]) + cube.add_aux_coord( + iris.coords.AuxCoord( + 1, + long_name="depth", + units="m", + bounds=np.array([0.0, 2]), + attributes={"positive": "down"}, + ) + ) + grib = eccodes.codes_grib_new_from_samples("GRIB2") + set_fixed_surfaces(cube, grib) + self.assertEqual( + eccodes.codes_get_double(grib, "scaledValueOfFirstFixedSurface"), 0.0 + ) + self.assertEqual( + eccodes.codes_get_double(grib, "scaledValueOfSecondFixedSurface"), 2 + ) + self.assertEqual(eccodes.codes_get_long(grib, "typeOfFirstFixedSurface"), 106) + self.assertEqual(eccodes.codes_get_long(grib, "typeOfSecondFixedSurface"), 106) + + @mock.patch.object(eccodes, "codes_set") + def test_altitude_point(self, mock_set): + grib = None + cube = iris.cube.Cube([1, 2, 3, 4, 5]) + cube.add_aux_coord(iris.coords.AuxCoord([12345], "altitude", units="m")) + + set_fixed_surfaces(cube, grib) + + mock_set.assert_any_call(grib, "typeOfFirstFixedSurface", 102) + mock_set.assert_any_call(grib, "scaleFactorOfFirstFixedSurface", 0) + mock_set.assert_any_call(grib, "scaledValueOfFirstFixedSurface", 12345) + mock_set.assert_any_call(grib, "typeOfSecondFixedSurface", 255) + mock_set.assert_any_call( + grib, "scaleFactorOfSecondFixedSurface", GRIB_MISSING_LONG + ) + mock_set.assert_any_call( + grib, "scaledValueOfSecondFixedSurface", GRIB_MISSING_LONG + ) + + @mock.patch.object(eccodes, "codes_set") + def test_height_point(self, mock_set): + grib = None + cube = iris.cube.Cube([1, 2, 3, 4, 5]) + cube.add_aux_coord(iris.coords.AuxCoord([12345], "height", units="m")) + + set_fixed_surfaces(cube, grib) + + mock_set.assert_any_call(grib, "typeOfFirstFixedSurface", 103) + mock_set.assert_any_call(grib, "scaleFactorOfFirstFixedSurface", 0) + mock_set.assert_any_call(grib, "scaledValueOfFirstFixedSurface", 12345) + mock_set.assert_any_call(grib, "typeOfSecondFixedSurface", 255) + mock_set.assert_any_call( + grib, "scaleFactorOfSecondFixedSurface", GRIB_MISSING_LONG + ) + mock_set.assert_any_call( + grib, "scaledValueOfSecondFixedSurface", GRIB_MISSING_LONG + ) + + def test_unknown_vertical_unbounded(self): + cube = iris.cube.Cube([0]) + cube.add_aux_coord( + iris.coords.AuxCoord([1], attributes={"GRIB_fixed_surface_type": 151}) + ) + grib = eccodes.codes_grib_new_from_samples("GRIB2") + set_fixed_surfaces(cube, grib) + + self.assertEqual(eccodes.codes_get_long(grib, "typeOfFirstFixedSurface"), 151) + self.assertEqual( + eccodes.codes_get_double(grib, "scaledValueOfFirstFixedSurface"), 1 + ) + self.assertEqual( + eccodes.codes_get_double(grib, "scaleFactorOfFirstFixedSurface"), 0 + ) + self.assertEqual(eccodes.codes_get_long(grib, "typeOfSecondFixedSurface"), 255) + self.assertEqual( + eccodes.codes_get_long(grib, "scaledValueOfSecondFixedSurface"), + GRIB_MISSING_LONG, + ) + self.assertEqual( + eccodes.codes_get_long(grib, "scaleFactorOfSecondFixedSurface"), + GRIB_MISSING_LONG, + ) + + def test_unknown_vertical_bounded(self): + cube = iris.cube.Cube([0]) + cube.add_aux_coord( + iris.coords.AuxCoord( + [700], + bounds=np.array([900.0, 500.0]), + attributes={"GRIB_fixed_surface_type": 108}, + ) + ) + grib = eccodes.codes_grib_new_from_samples("GRIB2") + set_fixed_surfaces(cube, grib) + + self.assertEqual(eccodes.codes_get_long(grib, "typeOfFirstFixedSurface"), 108) + self.assertEqual( + eccodes.codes_get_double(grib, "scaledValueOfFirstFixedSurface"), 900 + ) + self.assertEqual( + eccodes.codes_get_double(grib, "scaleFactorOfFirstFixedSurface"), 0 + ) + self.assertEqual(eccodes.codes_get_long(grib, "typeOfSecondFixedSurface"), 108) + self.assertEqual( + eccodes.codes_get_long(grib, "scaledValueOfSecondFixedSurface"), 500 + ) + self.assertEqual( + eccodes.codes_get_long(grib, "scaleFactorOfSecondFixedSurface"), 0 + ) + + def test_multiple_unknown_vertical_coords(self): + grib = None + cube = iris.cube.Cube([0]) + cube.add_aux_coord( + iris.coords.AuxCoord([1], attributes={"GRIB_fixed_surface_type": 151}) + ) + cube.add_aux_coord( + iris.coords.AuxCoord( + [450], + bounds=np.array([900.0, 0.0]), + attributes={"GRIB_fixed_surface_type": 108}, + ) + ) + msg = r"coordinates were found of fixed surface type: \[151, 108\]" + with self.assertRaisesRegex(ValueError, msg): + set_fixed_surfaces(cube, grib) + + def test_unhandled_vertical_axis(self): + grib = None + cube = iris.cube.Cube([0]) + cube.add_aux_coord(iris.coords.AuxCoord([450], attributes={"positive": "up"})) + msg = ( + r"vertical-axis coordinate\(s\) \('unknown'\) are not " + "recognised or handled." + ) + with self.assertRaisesRegex(TranslationError, msg): + set_fixed_surfaces(cube, grib) + + @mock.patch.object(eccodes, "codes_set") + def test_no_vertical(self, mock_set): + grib = None + cube = iris.cube.Cube([1, 2, 3, 4, 5]) + set_fixed_surfaces(cube, grib) + mock_set.assert_any_call(grib, "typeOfFirstFixedSurface", 1) + mock_set.assert_any_call(grib, "scaleFactorOfFirstFixedSurface", 0) + mock_set.assert_any_call(grib, "scaledValueOfFirstFixedSurface", 0) + mock_set.assert_any_call(grib, "typeOfSecondFixedSurface", 255) + mock_set.assert_any_call( + grib, "scaleFactorOfSecondFixedSurface", GRIB_MISSING_LONG + ) + mock_set.assert_any_call( + grib, "scaledValueOfSecondFixedSurface", GRIB_MISSING_LONG + ) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_set_time_increment.py b/src/iris_grib/tests/unit/save_rules/test_set_time_increment.py new file mode 100644 index 00000000..643855ae --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_set_time_increment.py @@ -0,0 +1,93 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :func:`iris_grib._save_rules.set_time_increment` + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from unittest import mock + +import eccodes + +from iris.coords import CellMethod + +from iris_grib._save_rules import set_time_increment + + +class Test(tests.IrisGribTest): + @mock.patch.object(eccodes, "codes_set") + def test_no_intervals(self, mock_set): + cell_method = CellMethod("sum", "time") + set_time_increment(cell_method, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "indicatorOfUnitForTimeIncrement", 255 + ) + mock_set.assert_any_call(mock.sentinel.grib, "timeIncrement", 0) + + @mock.patch.object(eccodes, "codes_set") + def test_area(self, mock_set): + cell_method = CellMethod("sum", "area", "25 km") + set_time_increment(cell_method, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "indicatorOfUnitForTimeIncrement", 255 + ) + mock_set.assert_any_call(mock.sentinel.grib, "timeIncrement", 0) + + @mock.patch.object(eccodes, "codes_set") + def test_multiple_intervals(self, mock_set): + cell_method = CellMethod("sum", "time", ("1 hour", "24 hour")) + set_time_increment(cell_method, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "indicatorOfUnitForTimeIncrement", 255 + ) + mock_set.assert_any_call(mock.sentinel.grib, "timeIncrement", 0) + + @mock.patch.object(eccodes, "codes_set") + def test_hr(self, mock_set): + cell_method = CellMethod("sum", "time", "23 hr") + set_time_increment(cell_method, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "indicatorOfUnitForTimeIncrement", 1 + ) + mock_set.assert_any_call(mock.sentinel.grib, "timeIncrement", 23) + + @mock.patch.object(eccodes, "codes_set") + def test_hour(self, mock_set): + cell_method = CellMethod("sum", "time", "24 hour") + set_time_increment(cell_method, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "indicatorOfUnitForTimeIncrement", 1 + ) + mock_set.assert_any_call(mock.sentinel.grib, "timeIncrement", 24) + + @mock.patch.object(eccodes, "codes_set") + def test_hours(self, mock_set): + cell_method = CellMethod("sum", "time", "25 hours") + set_time_increment(cell_method, mock.sentinel.grib) + mock_set.assert_any_call( + mock.sentinel.grib, "indicatorOfUnitForTimeIncrement", 1 + ) + mock_set.assert_any_call(mock.sentinel.grib, "timeIncrement", 25) + + @mock.patch.object(eccodes, "codes_set") + def test_fractional_hours(self, mock_set): + cell_method = CellMethod("sum", "time", "25.9 hours") + with mock.patch("warnings.warn") as warn: + set_time_increment(cell_method, mock.sentinel.grib) + warn.assert_called_once_with( + "Truncating floating point timeIncrement 25.9 to integer value 25" + ) + mock_set.assert_any_call( + mock.sentinel.grib, "indicatorOfUnitForTimeIncrement", 1 + ) + mock_set.assert_any_call(mock.sentinel.grib, "timeIncrement", 25) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/save_rules/test_set_time_range.py b/src/iris_grib/tests/unit/save_rules/test_set_time_range.py new file mode 100644 index 00000000..8ba5bfff --- /dev/null +++ b/src/iris_grib/tests/unit/save_rules/test_set_time_range.py @@ -0,0 +1,100 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for :func:`iris_grib._save_rules.set_time_range` + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +from unittest import mock +import warnings + +from cf_units import Unit +import eccodes + +from iris.coords import DimCoord + +from iris_grib._save_rules import set_time_range + + +class Test(tests.IrisGribTest): + def setUp(self): + self.coord = DimCoord( + 0, "time", units=Unit("hours since epoch", calendar="standard") + ) + + def test_no_bounds(self): + with self.assertRaisesRegex( + ValueError, "Expected time coordinate with two bounds, got 0 bounds" + ): + set_time_range(self.coord, mock.sentinel.grib) + + def test_three_bounds(self): + self.coord.bounds = [0, 1, 2] + with self.assertRaisesRegex( + ValueError, "Expected time coordinate with two bounds, got 3 bounds" + ): + set_time_range(self.coord, mock.sentinel.grib) + + def test_non_scalar(self): + coord = DimCoord( + [0, 1], + "time", + bounds=[[0, 1], [1, 2]], + units=Unit("hours since epoch", calendar="standard"), + ) + with self.assertRaisesRegex( + ValueError, "Expected length one time coordinate, got 2 points" + ): + set_time_range(coord, mock.sentinel.grib) + + @mock.patch.object(eccodes, "codes_set") + def test_hours(self, mock_set): + lower = 10 + upper = 20 + self.coord.bounds = [lower, upper] + set_time_range(self.coord, mock.sentinel.grib) + mock_set.assert_any_call(mock.sentinel.grib, "indicatorOfUnitForTimeRange", 1) + mock_set.assert_any_call(mock.sentinel.grib, "lengthOfTimeRange", upper - lower) + + @mock.patch.object(eccodes, "codes_set") + def test_days(self, mock_set): + lower = 4 + upper = 6 + self.coord.bounds = [lower, upper] + self.coord.units = Unit("days since epoch", calendar="standard") + set_time_range(self.coord, mock.sentinel.grib) + mock_set.assert_any_call(mock.sentinel.grib, "indicatorOfUnitForTimeRange", 1) + mock_set.assert_any_call( + mock.sentinel.grib, "lengthOfTimeRange", (upper - lower) * 24 + ) + + @mock.patch.object(eccodes, "codes_set") + def test_fractional_hours(self, mock_set_long): + lower = 10.0 + upper = 20.9 + self.coord.bounds = [lower, upper] + with warnings.catch_warnings(record=True) as warn: + warnings.simplefilter("always") + set_time_range(self.coord, mock.sentinel.grib) + self.assertEqual(len(warn), 1) + msg = ( + r"Truncating floating point lengthOfTimeRange 10\.8?9+ " + "to integer value 10" + ) + self.assertRegex(str(warn[0].message), msg) + mock_set_long.assert_any_call( + mock.sentinel.grib, "indicatorOfUnitForTimeRange", 1 + ) + mock_set_long.assert_any_call( + mock.sentinel.grib, "lengthOfTimeRange", int(upper - lower) + ) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/test_Grib1LoadingMode.py b/src/iris_grib/tests/unit/test_Grib1LoadingMode.py new file mode 100644 index 00000000..0433d386 --- /dev/null +++ b/src/iris_grib/tests/unit/test_Grib1LoadingMode.py @@ -0,0 +1,90 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for the :class:`iris_grib.Grib1LoadingMode` class. +""" + +import pytest + +from iris_grib import Grib1LoadingMode + + +@pytest.fixture(autouse=True) +def mode_obj(): + test_object = Grib1LoadingMode() + return test_object + + +class TestInit: + def test_default(self): + result = Grib1LoadingMode() + assert result.use_legacy_grib1_loading + + def test_kwarg(self): + result = Grib1LoadingMode(legacy=False) + assert not result.use_legacy_grib1_loading + + def test_arg_fail(self): + msg = "takes 1 positional argument but 2 were given" + with pytest.raises(TypeError, match=msg): + Grib1LoadingMode(1) + + +class TestLegacyProperty: + def test_get_legacy(self, mode_obj): + assert mode_obj.use_legacy_grib1_loading + + def test_set_legacy_fail(self, mode_obj): + msg1 = "'use_legacy_grib1_loading' of 'Grib1LoadingMode' object has no setter" + # NOTE: message was different in Python <= 3.10 + msg2 = "can't set attribute 'use_legacy_grib1_loading'" + try: + mode_obj.use_legacy_grib1_loading = False + except AttributeError as err: + result = str(err) + assert msg1 in result or msg2 in result + + +class TestSet: + def test_set_unset(self, mode_obj): + assert mode_obj.use_legacy_grib1_loading + mode_obj.set(legacy=False) + assert not mode_obj.use_legacy_grib1_loading + mode_obj.set(legacy=True) + assert mode_obj.use_legacy_grib1_loading + + def test_arg_fail(self, mode_obj): + msg = "takes 1 positional argument but 2 were given" + with pytest.raises(TypeError, match=msg): + mode_obj.set(False) + + +class TestContext: + def test_context(self, mode_obj): + assert mode_obj.use_legacy_grib1_loading + with mode_obj.context(legacy=False): + assert not mode_obj.use_legacy_grib1_loading + with mode_obj.context(legacy=True): + assert mode_obj.use_legacy_grib1_loading + + assert not mode_obj.use_legacy_grib1_loading + + assert mode_obj.use_legacy_grib1_loading + + def test_arg_fail(self, mode_obj): + msg = "takes 1 positional argument but 2 were given" + with pytest.raises(TypeError, match=msg): + mode_obj.context(False) + + +class TestStrRepr: + @pytest.mark.parametrize("func", ["str", "repr"]) + def test_strrepr(self, mode_obj, func): + func = {"str": str, "repr": repr}[func] + result = func(mode_obj) + assert result == "GRIB1_LOADING_MODE(legacy=True)" + mode_obj.set(legacy=False) + result2 = func(mode_obj) + assert result2 == "GRIB1_LOADING_MODE(legacy=False)" diff --git a/src/iris_grib/tests/unit/test_GribWrapper.py b/src/iris_grib/tests/unit/test_GribWrapper.py new file mode 100644 index 00000000..bfba803c --- /dev/null +++ b/src/iris_grib/tests/unit/test_GribWrapper.py @@ -0,0 +1,134 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +""" +Unit tests for the `iris_grib.GribWrapper` class. + +""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import numpy as np +from unittest import mock + +from iris._lazy_data import is_lazy_data +from iris.exceptions import TranslationError + +from iris_grib._grib1_legacy.grib_wrapper import GribWrapper +from iris_grib import _load_generate + + +_offset = 0 +_message_length = 1000 + + +def _mock_codes_get_long(grib_message, key): + lookup = dict( + totalLength=_message_length, + numberOfValues=200, + jPointsAreConsecutive=0, + Ni=20, + Nj=10, + edition=1, + ) + try: + result = lookup[key] + except KeyError: + msg = "Mock codes_get_long unknown key: {!r}".format(key) + raise AttributeError(msg) + return result + + +def _mock_codes_get_string(grib_message, key): + return grib_message + + +def _mock_codes_get_native_type(grib_message, key): + result = int + if key == "gridType": + result = str + return result + + +def _mock_codes_get_message_offset(grib_message): + return _offset + + +_TGT_GRIBWRAPPER_CLASS = "iris_grib._grib1_legacy.grib_wrapper.GribWrapper" +_TGT_GRIBDATAPROXY_CLASS = "iris_grib._grib1_legacy.grib_wrapper.GribDataProxy" + + +class Test_edition(tests.IrisGribTest): + def setUp(self): + self.patch(_TGT_GRIBWRAPPER_CLASS + "._confirm_in_scope") + self.patch(_TGT_GRIBWRAPPER_CLASS + "._compute_extra_keys") + self.patch("eccodes.codes_get_long", _mock_codes_get_long) + self.patch("eccodes.codes_get_string", _mock_codes_get_string) + self.patch("eccodes.codes_get_native_type", _mock_codes_get_native_type) + self.patch("eccodes.codes_get_message_offset", _mock_codes_get_message_offset) + + def test_not_edition_1(self): + def func(grib_message, key): + return 2 + + emsg = "GRIB edition 2 is not supported by 'GribWrapper'" + with mock.patch("eccodes.codes_get_long", func): + with self.assertRaisesRegex(TranslationError, emsg): + GribWrapper(None) + + def test_edition_1(self): + grib_message = "regular_ll" + grib_fh = mock.Mock() + wrapper = GribWrapper(grib_message, grib_fh) + self.assertEqual(wrapper.grib_message, grib_message) + + +@tests.skip_data +class Test_deferred_data(tests.IrisTest): + def test_regular_data(self): + filename = tests.get_data_path(("GRIB", "gaussian", "regular_gg.grib1")) + messages = list(_load_generate(filename)) + self.assertTrue(is_lazy_data(messages[0]._data)) + + def test_reduced_data(self): + filename = tests.get_data_path(("GRIB", "reduced", "reduced_ll.grib1")) + messages = list(_load_generate(filename)) + self.assertTrue(is_lazy_data(messages[0]._data)) + + +class Test_deferred_proxy_args(tests.IrisTest): + def setUp(self): + self.patch(_TGT_GRIBWRAPPER_CLASS + "._confirm_in_scope") + self.patch(_TGT_GRIBWRAPPER_CLASS + "._compute_extra_keys") + self.patch("eccodes.codes_get_long", _mock_codes_get_long) + self.patch("eccodes.codes_get_string", _mock_codes_get_string) + self.patch("eccodes.codes_get_native_type", _mock_codes_get_native_type) + self.patch("eccodes.codes_get_message_offset", _mock_codes_get_message_offset) + self.expected = np.atleast_1d(_offset) + self.grib_fh = mock.Mock() + self.dtype = np.float64 + self.path = self.grib_fh.name + self.lookup = _mock_codes_get_long + + def test_regular_proxy_args(self): + grib_message = "regular_ll" + shape = (self.lookup(grib_message, "Nj"), self.lookup(grib_message, "Ni")) + for offset in self.expected: + with mock.patch(_TGT_GRIBDATAPROXY_CLASS) as mock_gdp: + _ = GribWrapper(grib_message, self.grib_fh) + mock_gdp.assert_called_once_with(shape, self.dtype, self.path, offset) + + def test_reduced_proxy_args(self): + grib_message = "reduced_gg" + shape = self.lookup(grib_message, "numberOfValues") + for offset in self.expected: + with mock.patch(_TGT_GRIBDATAPROXY_CLASS) as mock_gdp: + _ = GribWrapper(grib_message, self.grib_fh) + mock_gdp.assert_called_once_with((shape,), self.dtype, self.path, offset) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/test__load_generate.py b/src/iris_grib/tests/unit/test__load_generate.py new file mode 100644 index 00000000..e9eea062 --- /dev/null +++ b/src/iris_grib/tests/unit/test__load_generate.py @@ -0,0 +1,63 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for the `iris_grib._load_generate` function.""" + +import iris_grib.tests as tests + +from unittest import mock + +from iris.exceptions import TranslationError + +from iris_grib._grib1_legacy.grib_wrapper import GribWrapper +from iris_grib import _load_generate +from iris_grib.message import GribMessage + + +class Test(tests.IrisGribTest): + def setUp(self): + self.fname = mock.sentinel.fname + self.message_id = mock.sentinel.message_id + self.grib_fh = mock.sentinel.grib_fh + + def _make_test_message(self, sections): + raw_message = mock.Mock(sections=sections, _message_id=self.message_id) + file_ref = mock.Mock(open_file=self.grib_fh) + return GribMessage(raw_message, None, file_ref=file_ref) + + def test_grib1(self): + sections = [{"editionNumber": 1}] + message = self._make_test_message(sections) + mfunc = "iris_grib.GribMessage.messages_from_filename" + mclass = "iris_grib._grib1_legacy.grib_wrapper.GribWrapper" + with mock.patch(mfunc, return_value=[message]) as mock_func: + with mock.patch(mclass, spec=GribWrapper) as mock_wrapper: + field = next(_load_generate(self.fname)) + mock_func.assert_called_once_with(self.fname) + self.assertIsInstance(field, GribWrapper) + mock_wrapper.assert_called_once_with( + self.message_id, grib_fh=self.grib_fh, has_bitmap=False + ) + + def test_grib2(self): + sections = [{"editionNumber": 2}] + message = self._make_test_message(sections) + mfunc = "iris_grib.GribMessage.messages_from_filename" + with mock.patch(mfunc, return_value=[message]) as mock_func: + field = next(_load_generate(self.fname)) + mock_func.assert_called_once_with(self.fname) + self.assertEqual(field, message) + + def test_grib_unknown(self): + sections = [{"editionNumber": 0}] + message = self._make_test_message(sections) + mfunc = "iris_grib.GribMessage.messages_from_filename" + emsg = "GRIB edition 0 is not supported" + with mock.patch(mfunc, return_value=[message]): + with self.assertRaisesRegex(TranslationError, emsg): + next(_load_generate(self.fname)) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/test_load_cubes.py b/src/iris_grib/tests/unit/test_load_cubes.py new file mode 100644 index 00000000..d4f3016d --- /dev/null +++ b/src/iris_grib/tests/unit/test_load_cubes.py @@ -0,0 +1,44 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for the `iris_grib.load_cubes` function.""" + +import iris_grib.tests as tests + +from unittest import mock + +from iris.fileformats.rules import Loader + +import iris_grib +from iris_grib import load_cubes + + +class Test(tests.IrisGribTest): + def test(self): + generator = iris_grib._load_generate + converter = iris_grib._load_convert.convert + files = mock.sentinel.FILES + callback = mock.sentinel.CALLBACK + expected_result = mock.sentinel.RESULT + with mock.patch("iris.fileformats.rules.load_cubes") as rules_load: + rules_load.return_value = expected_result + result = load_cubes(files, callback) + kwargs = {} + loader = Loader(generator, kwargs, converter) + rules_load.assert_called_once_with(files, callback, loader) + self.assertIs(result, expected_result) + + +@tests.skip_data +class Test_load_cubes(tests.IrisGribTest): + def test_reduced_raw(self): + # Loading a GRIB message defined on a reduced grid without + # interpolating to a regular grid. + gribfile = tests.get_data_path(("GRIB", "reduced", "reduced_gg.grib2")) + grib_generator = load_cubes(gribfile) + self.assertCML(next(grib_generator)) + + +if __name__ == "__main__": + tests.main() diff --git a/src/iris_grib/tests/unit/test_load_pairs_from_fields.py b/src/iris_grib/tests/unit/test_load_pairs_from_fields.py new file mode 100644 index 00000000..344e893b --- /dev/null +++ b/src/iris_grib/tests/unit/test_load_pairs_from_fields.py @@ -0,0 +1,51 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for the `iris_grib.load_pairs_from_fields` function.""" + +import iris_grib.tests as tests + +from iris_grib import load_pairs_from_fields +from iris_grib.message import GribMessage + + +class TestAsCubes(tests.IrisTest): + def setUp(self): + # Load from the test file. + self.file_path = tests.get_data_path( + ("GRIB", "time_processed", "time_bound.grib2") + ) + + def test_year_filter(self): + msgs = GribMessage.messages_from_filename(self.file_path) + chosen_messages = [] + for gmsg in msgs: + if gmsg.sections[1]["year"] == 1998: + chosen_messages.append(gmsg) + cubes_msgs = list(load_pairs_from_fields(chosen_messages)) + self.assertEqual(len(cubes_msgs), 1) + + def test_year_filter_none(self): + msgs = GribMessage.messages_from_filename(self.file_path) + chosen_messages = [] + for gmsg in msgs: + if gmsg.sections[1]["year"] == 1958: + chosen_messages.append(gmsg) + cubes_msgs = list(load_pairs_from_fields(chosen_messages)) + self.assertEqual(len(cubes_msgs), 0) + + def test_as_pairs(self): + messages = GribMessage.messages_from_filename(self.file_path) + cubes = [] + cube_msg_pairs = load_pairs_from_fields(messages) + for cube, gmsg in cube_msg_pairs: + if gmsg.sections[1]["year"] == 1998: + cube.attributes["the year is"] = gmsg.sections[1]["year"] + cubes.append(cube) + self.assertEqual(len(cubes), 1) + self.assertEqual(cubes[0].attributes["the year is"], 1998) + + +if __name__ == "__main__": + tests.main() diff --git a/iris_grib/tests/unit/test_save_grib2.py b/src/iris_grib/tests/unit/test_save_grib2.py similarity index 50% rename from iris_grib/tests/unit/test_save_grib2.py rename to src/iris_grib/tests/unit/test_save_grib2.py index 5fc382fc..fad92b3c 100644 --- a/iris_grib/tests/unit/test_save_grib2.py +++ b/src/iris_grib/tests/unit/test_save_grib2.py @@ -1,30 +1,14 @@ -# (C) British Crown Copyright 2016, Met Office +# Copyright iris-grib contributors # -# This file is part of iris-grib. -# -# iris-grib is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the -# Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# iris-grib is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with iris-grib. If not, see . +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. """Unit tests for the `iris_grib.save_grib2` function.""" -from __future__ import (absolute_import, division, print_function) -from six.moves import (filter, input, map, range, zip) # noqa -import six - # Import iris_grib.tests first so that some things can be initialised before # importing anything else. import iris_grib.tests as tests -import mock +from unittest import mock import iris_grib @@ -33,17 +17,17 @@ class TestSaveGrib2(tests.IrisGribTest): def setUp(self): self.cube = mock.sentinel.cube self.target = mock.sentinel.target - func = 'iris_grib.save_pairs_from_cube' + func = "iris_grib.save_pairs_from_cube" self.messages = list(range(10)) slices = self.messages - side_effect = [zip(slices, self.messages)] + side_effect = [zip(slices, self.messages, strict=False)] self.save_pairs_from_cube = self.patch(func, side_effect=side_effect) - func = 'iris_grib.save_messages' + func = "iris_grib.save_messages" self.save_messages = self.patch(func) def _check(self, append=False): iris_grib.save_grib2(self.cube, self.target, append=append) - self.save_pairs_from_cube.called_once_with(self.cube) + self.save_pairs_from_cube.assert_called_once_with(self.cube) args, kwargs = self.save_messages.call_args self.assertEqual(len(args), 2) messages, target = args diff --git a/src/iris_grib/tests/unit/test_save_messages.py b/src/iris_grib/tests/unit/test_save_messages.py new file mode 100644 index 00000000..c8a1263c --- /dev/null +++ b/src/iris_grib/tests/unit/test_save_messages.py @@ -0,0 +1,36 @@ +# Copyright iris-grib contributors +# +# This file is part of iris-grib and is released under the BSD license. +# See LICENSE in the root of the repository for full licensing details. +"""Unit tests for the `iris_grib.save_messages` function.""" + +# Import iris_grib.tests first so that some things can be initialised before +# importing anything else. +import iris_grib.tests as tests + +import eccodes +from unittest import mock + +import iris_grib + + +class TestSaveMessages(tests.IrisGribTest): + def setUp(self): + # Create a test object to stand in for a real PPField. + self.grib_message = eccodes.codes_grib_new_from_samples("GRIB2") + + def test_save(self): + m = mock.mock_open() + with mock.patch("builtins.open", m, create=True): + iris_grib.save_messages([self.grib_message], "foo.grib2") + self.assertTrue(mock.call("foo.grib2", "wb") in m.mock_calls) + + def test_save_append(self): + m = mock.mock_open() + with mock.patch("builtins.open", m, create=True): + iris_grib.save_messages([self.grib_message], "foo.grib2", append=True) + self.assertTrue(mock.call("foo.grib2", "ab") in m.mock_calls) + + +if __name__ == "__main__": + tests.main()