diff --git a/.codecov.yml b/.codecov.yml index db01d813986..c3c24e637d1 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,7 +1,3 @@ -codecov: - notify: - after_n_builds: 9 - coverage: status: patch: diff --git a/.github/actions/install-pypi/action.yml b/.github/actions/install-pypi/action.yml index 933444ce526..54cd9fe4b05 100644 --- a/.github/actions/install-pypi/action.yml +++ b/.github/actions/install-pypi/action.yml @@ -1,16 +1,16 @@ name: 'Install Using PyPI' description: 'Setup environment and install using the PyPI-based toolchain' inputs: - need-cartopy: - description: 'Whether Cartopy is needed' - required: true - default: 'true' + need-extras: + description: 'Whether to install the extras' + required: false + default: 'false' type: description: 'Whether test or doc build' required: true version-file: description: 'Name of the version file to use for installation' - required: true + required: false default: 'requirements.txt' python-version: description: 'What version of Python to use' @@ -27,31 +27,43 @@ runs: cache-dependency-path: | ci/${{ inputs.type }}_requirements.txt ci/${{ inputs.version-file }} + ci/extra_requirements.txt # This installs the stuff needed to build and install Shapely and CartoPy from source. - name: Install CartoPy build dependencies - if: ${{ inputs.need-cartopy == 'true' }} + if: ${{ inputs.need-extras == 'true' }} shell: bash run: sudo apt-get install libgeos-dev - name: Disable Shapely Wheels + if: ${{ inputs.need-extras == 'true' }} shell: bash run: echo "PIP_NO_BINARY=shapely" >> $GITHUB_ENV - - name: Install dependencies + - name: Set dependency groups for install shell: bash - run: python -m pip install -r ci/${{ inputs.type }}_requirements.txt -c ci/${{ inputs.version-file }} + run: | + if [[ ${{ inputs.need-extras }} == 'true' ]] + then + echo "DEP_GROUPS=${{ inputs.type }},extras" >> $GITHUB_ENV + else + echo "DEP_GROUPS=${{ inputs.type }}" >> $GITHUB_ENV + fi - - name: Install extra dependencies - if: ${{ inputs.need-cartopy == 'true' }} + - name: Install shell: bash - run: python -m pip install -r ci/extra_requirements.txt -c ci/${{ inputs.version-file }} + run: > + python -m pip install .[${{ env.DEP_GROUPS }}] + -c ci/${{ inputs.version-file }} -c ci/${{ inputs.type }}_requirements.txt -c ci/extra_requirements.txt + + - name: Install additional test tools + shell: bash + run: > + python -m pip install coverage + -c ci/${{ inputs.version-file }} -c ci/${{ inputs.type }}_requirements.txt -c ci/extra_requirements.txt - name: Download Cartopy Maps - if: ${{ inputs.need-cartopy == 'true' }} + if: ${{ inputs.need-extras == 'true' }} shell: bash run: ci/download_cartopy_maps.py - - name: Install - shell: bash - run: python -m pip install -c ci/${{ inputs.version-file }} . diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index ead48b1736e..88c831fb77b 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -45,8 +45,10 @@ runs: name: ${{ inputs.key }}-images path: test_output/ - - name: Upload coverage - if: ${{ always() && inputs.upload-coverage == 'true' }} - uses: codecov/codecov-action@v3 + - name: Upload coverage artifact + if: ${{ inputs.upload-coverage == 'true' }} + uses: actions/upload-artifact@v3 with: name: ${{ inputs.key }} + path: coverage.xml + retention-days: 7 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8665f1cfba6..c6e54528fca 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,6 +19,12 @@ updates: commit-message: prefix: "CI: " include: "scope" + groups: + flake8: + patterns: + - "flake8*" + - "pycodestyle" + - "pyflakes" # Update GitHub Actions versions in workflows - package-ecosystem: "github-actions" diff --git a/.github/workflows/backport-prs.yml b/.github/workflows/backport-prs.yml index 5246fdb47a3..f93bfbc9b70 100644 --- a/.github/workflows/backport-prs.yml +++ b/.github/workflows/backport-prs.yml @@ -16,7 +16,7 @@ jobs: if: github.event.pull_request.merged && contains( github.event.pull_request.labels.*.name, 'backport' ) steps: - name: Checkout PR HEAD - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} fetch-depth: 100 diff --git a/.github/workflows/cffcheck.yml b/.github/workflows/cffcheck.yml index b3f5369cfe9..6826803b73c 100644 --- a/.github/workflows/cffcheck.yml +++ b/.github/workflows/cffcheck.yml @@ -17,9 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out a copy of the repository - uses: actions/checkout@v3 - with: - fetch-depth: 1 + uses: actions/checkout@v4 - name: Check validity of CITATION.cff uses: citation-file-format/cffconvert-github-action@2.0.0 diff --git a/.github/workflows/code-analysis.yml b/.github/workflows/code-analysis.yml index e970a3e1c50..43d963a1f0f 100644 --- a/.github/workflows/code-analysis.yml +++ b/.github/workflows/code-analysis.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Initialize CodeQL uses: github/codeql-action/init@v2 diff --git a/.github/workflows/docs-conda.yml b/.github/workflows/docs-conda.yml index 4b78d316adb..2cfe2dff471 100644 --- a/.github/workflows/docs-conda.yml +++ b/.github/workflows/docs-conda.yml @@ -30,14 +30,11 @@ jobs: os: macOS steps: - # We check out only a limited depth and then pull tags to save time - name: Checkout source - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: - fetch-depth: 100 - - - name: Get tags - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* + fetch-depth: 150 + fetch-tags: true - name: Install from Conda uses: ./.github/actions/install-conda diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index df3fdc7bc4d..a9a95b0ccfe 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -41,19 +41,15 @@ jobs: doc-version: ${{ steps.build-docs.outputs.doc-version }} steps: - # We check out only a limited depth and then pull tags to save time - name: Checkout source - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: - fetch-depth: 100 - - - name: Get tags - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* + fetch-depth: 150 + fetch-tags: true - name: Install using PyPI uses: ./.github/actions/install-pypi with: - need-cartopy: true type: 'doc' python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 8aa818a9d52..bf8e1b8eebd 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -14,7 +14,7 @@ jobs: name: Flake8 runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3 uses: actions/setup-python@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8a00c2f73b..d625fc06c71 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,12 +10,10 @@ jobs: name: Build Release Packages runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 10 - - - name: Get tags - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* + fetch-tags: true - name: Set up Python id: setup @@ -58,4 +56,4 @@ jobs: cp packages-*/* dist/ - name: Publish Package - uses: pypa/gh-action-pypi-publish@v1.8.7 + uses: pypa/gh-action-pypi-publish@v1.8.10 diff --git a/.github/workflows/tests-conda.yml b/.github/workflows/tests-conda.yml index 4d0de3e9d4f..781521a0a15 100644 --- a/.github/workflows/tests-conda.yml +++ b/.github/workflows/tests-conda.yml @@ -32,14 +32,11 @@ jobs: os: [macOS, Windows] steps: - # We check out only a limited depth and then pull tags to save time - name: Checkout source - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: - fetch-depth: 100 - - - name: Get tags - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* + fetch-depth: 150 + fetch-tags: true - name: Install from Conda uses: ./.github/actions/install-conda @@ -52,3 +49,20 @@ jobs: with: run-doctests: ${{ runner.os != 'Windows' }} key: conda-${{ matrix.python-version }}-${{ runner.os }} + + codecov: + name: CodeCov Upload + needs: CondaTests + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Download coverage artifacts + uses: actions/download-artifact@v3 + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + name: ${{ github.workflow }} diff --git a/.github/workflows/tests-pypi.yml b/.github/workflows/tests-pypi.yml index cca74b7590c..fb9ca741eff 100644 --- a/.github/workflows/tests-pypi.yml +++ b/.github/workflows/tests-pypi.yml @@ -26,25 +26,23 @@ jobs: fail-fast: false matrix: python-version: [3.9, '3.10', 3.11] - dep-versions: [requirements.txt] + dep-versions: [Latest] no-extras: [''] include: + - python-version: 3.9 + dep-versions: Minimum - python-version: 3.9 dep-versions: Minimum no-extras: 'No Extras' - python-version: 3.11 - dep-versions: requirements.txt no-extras: 'No Extras' steps: - # We check out only a limited depth and then pull tags to save time - name: Checkout source - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: - fetch-depth: 100 - - - name: Get tags - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* + fetch-depth: 150 + fetch-tags: true - name: Generate minimum dependencies if: ${{ matrix.dep-versions == 'Minimum' }} @@ -55,12 +53,14 @@ jobs: from pathlib import Path # Read our pyproject.toml - config = tomllib.load(open('pyproject.toml', 'rb')) + config = tomllib.load(open('pyproject.toml', 'rb'))['project'] + opt_deps = config['optional-dependencies'] # Generate a Minimum dependency file - with (Path('ci') / 'Minimum').open('wt') as out: - for dep in config['project']['dependencies']: - if dep: + for src, fname in [(config['dependencies'], 'requirements.txt'), + (opt_deps['test'], 'test_requirements.txt'), (opt_deps['extras'], 'extra_requirements.txt')]: + with (Path('ci') / fname).open('wt') as out: + for dep in src: dep = dep.split(';')[0] out.write(dep.replace('>=', '==') + '\n') EOF @@ -68,13 +68,29 @@ jobs: - name: Install from PyPI uses: ./.github/actions/install-pypi with: - need-cartopy: ${{ matrix.no-extras != 'No Extras' }} + need-extras: ${{ matrix.no-extras != 'No Extras' }} type: 'test' - version-file: ${{ matrix.dep-versions }} python-version: ${{ matrix.python-version }} - name: Run tests uses: ./.github/actions/run-tests with: - run-doctests: ${{ matrix.dep-versions == 'requirements.txt' && matrix.no-extras != 'No Extras' }} + run-doctests: ${{ matrix.dep-versions == 'Latest' && matrix.no-extras != 'No Extras' }} key: pypi-${{ matrix.python-version }}-${{ matrix.dep-versions }}-${{ matrix.no-extras }}-${{ runner.os }} + + codecov: + needs: PyPITests + name: CodeCov Upload + runs-on: ubuntu-latest + timeout-minutes: 2 + steps: + - name: Checkout source + uses: actions/checkout@v4 + + - name: Download coverage artifacts + uses: actions/download-artifact@v3 + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + name: ${{ github.workflow }} diff --git a/.github/workflows/unstable-builds.yml b/.github/workflows/unstable-builds.yml index 978500d8d1b..c10e65da733 100644 --- a/.github/workflows/unstable-builds.yml +++ b/.github/workflows/unstable-builds.yml @@ -16,14 +16,11 @@ jobs: outputs: result: ${{ steps.tests.outcome }} steps: - # We check out only a limited depth and then pull tags to save time - name: Checkout source - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: - fetch-depth: 100 - - - name: Get tags - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* + fetch-depth: 150 + fetch-tags: true - name: Assemble test requirements run: | @@ -33,7 +30,7 @@ jobs: - name: Install using PyPI uses: ./.github/actions/install-pypi with: - need-cartopy: true + need-extras: true type: test version-file: Prerelease python-version: 3.11 @@ -59,14 +56,11 @@ jobs: outputs: result: ${{ steps.build.outcome }} steps: - # We check out only a limited depth and then pull tags to save time - name: Checkout source - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: - fetch-depth: 100 - - - name: Get tags - run: git fetch --depth=1 origin +refs/tags/*:refs/tags/* + fetch-depth: 150 + fetch-tags: true - name: Assemble doc requirements run: | @@ -76,7 +70,6 @@ jobs: - name: Install using PyPI uses: ./.github/actions/install-pypi with: - need-cartopy: true type: doc version-file: Prerelease python-version: 3.11 diff --git a/LICENSE b/LICENSE index 8b6c8e0ac63..a34c4df4d2e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2008-2020, MetPy Developers +Copyright (c) 2008-2023, MetPy Developers All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/README.md b/README.md index 29c8d3289ac..9db569a52f4 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ take the average GEMPAK script for a weather map, you need to: One of the benefits hoped to achieve over GEMPAK is to make it easier to use these routines for any meteorological Python application; this means making it easy to pull out the LCL -calculation and just use that, or re-use the Skew-T with your own data code. MetPy also prides +calculation and just use that, or reuse the Skew-T with your own data code. MetPy also prides itself on being well-documented and well-tested, so that on-going maintenance is easily manageable. diff --git a/ci/doc_requirements.txt b/ci/doc_requirements.txt index 6693b6c3e26..805c550a34f 100644 --- a/ci/doc_requirements.txt +++ b/ci/doc_requirements.txt @@ -1,8 +1,8 @@ -sphinx==6.2.1 -pydata-sphinx-theme==0.13.3 -sphinx-design==0.4.1 -sphinx-gallery==0.13.0 +sphinx==7.2.6 +pydata-sphinx-theme==0.14.3 +sphinx-design==0.5.0 +sphinx-gallery==0.14.0 myst-parser==2.0.0 -netCDF4==1.6.4 -geopandas==0.13.2 -rtree==1.0.1 +netCDF4==1.6.5 +geopandas==0.14.0 +rtree==1.1.0 diff --git a/ci/extra_requirements.txt b/ci/extra_requirements.txt index 69fa15bea40..1e3366ee16c 100644 --- a/ci/extra_requirements.txt +++ b/ci/extra_requirements.txt @@ -1,2 +1,3 @@ -cartopy==0.21.1 -shapely==2.0.1 +cartopy==0.22.0 +dask==2023.10.1 +shapely==2.0.2 diff --git a/ci/linting_requirements.txt b/ci/linting_requirements.txt index 820f3e9458e..9c5b6dd2784 100644 --- a/ci/linting_requirements.txt +++ b/ci/linting_requirements.txt @@ -1,12 +1,12 @@ -ruff==0.0.277 +ruff==0.1.3 -flake8==6.0.0 -pycodestyle==2.10.0 -pyflakes==3.0.1 +flake8==6.1.0 +pycodestyle==2.11.1 +pyflakes==3.1.0 flake8-continuation==1.0.5 flake8-copyright==0.2.4 -flake8-isort==6.0.0 +flake8-isort==6.1.0 isort==5.12.0 flake8-requirements==1.7.8 @@ -15,4 +15,4 @@ flake8-rst-docstrings==0.3.0 doc8==1.1.1 restructuredtext_lint==1.4.0 -codespell==2.2.5 +codespell==2.2.6 diff --git a/ci/requirements.txt b/ci/requirements.txt index c57c89b2afb..b18fbab7e4b 100644 --- a/ci/requirements.txt +++ b/ci/requirements.txt @@ -1,9 +1,9 @@ -matplotlib==3.7.2 -numpy==1.24.4 -pandas==2.0.3 -pooch==1.7.0 +matplotlib==3.8.0 +numpy==1.26.0 +pandas==2.1.2 +pooch==1.8.0 pint==0.22 -pyproj==3.6.0 -scipy==1.10.1 -traitlets==5.9.0 -xarray==2023.6.0 +pyproj==3.6.1 +scipy==1.11.3 +traitlets==5.13.0 +xarray==2023.10.1 diff --git a/ci/test_requirements.txt b/ci/test_requirements.txt index 80c3f1c0166..11b3ab94ac1 100644 --- a/ci/test_requirements.txt +++ b/ci/test_requirements.txt @@ -1,6 +1,5 @@ -packaging==23.1 -pytest==7.4.0 +packaging==23.2 +pytest==7.4.3 pytest-mpl==0.16.1 -netCDF4==1.6.4 -coverage==7.2.7 -dask==2023.7.0 +netCDF4==1.6.5 +coverage==7.3.2 diff --git a/docs/conf.py b/docs/conf.py index ea2fc0978d4..e8de320fa84 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -91,7 +91,7 @@ ('py:class', 'M'), ('py:class', 'N'), ('py:class', 'P'), ('py:class', '2'), ('py:class', 'optional'), ('py:class', 'array-like'), ('py:class', 'file-like object'), # For traitlets docstrings - ('py:class', 'All'), ('py:class', 'callable'), + ('py:class', 'All'), ('py:class', 't.Any'), ('py:class', 't.Iterable'), # Next two are from Python dict docstring that we inherit ('py:class', 'a shallow copy of D'), ('py:class', 'v, remove specified key and return the corresponding value.') @@ -99,6 +99,11 @@ nitpick_ignore_regex = [ ('py:class', r'default:.*'), # For some traitlets docstrings + ('py:class', r'.*Sentinel'), + ('py:class', r'.*Bunch'), + ('py:class', r'.*[cC]allable'), + ('py:class', r'.*EventHandler'), + ('py:class', r'.*TraitType'), ('py:class', r'.*object providing a view on.*'), # Python dict docstring ('py:class', r'None. .*'), # Python dict docstring ('py:class', r'.*D\[k\].*'), # Python dict docstring @@ -229,6 +234,7 @@ 'json_url': 'https://unidata.github.io/MetPy/pst-versions.json', 'version_match': 'dev' if 'dev' in version else f'v{version}', }, + 'navigation_with_keys': False } # Theme options are theme-specific and customize the look and feel of a theme diff --git a/examples/Advanced_Sounding_With_Complex_Layout.py b/examples/Advanced_Sounding_With_Complex_Layout.py new file mode 100644 index 00000000000..f007b2b8148 --- /dev/null +++ b/examples/Advanced_Sounding_With_Complex_Layout.py @@ -0,0 +1,335 @@ +# Copyright (c) 2015,2016,2017 MetPy Developers. +# Distributed under the terms of the BSD 3-Clause License. +# SPDX-License-Identifier: BSD-3-Clause + + +""" +========================================== +Advanced Sounding Plot with Complex Layout +========================================== + +This example combines simple MetPy plotting functionality, `metpy.calc` +computation functionality, and a few basic Matplotlib tricks to create +an advanced sounding plot with a complex layout & high readability. +""" + +# First let's start with some simple imports +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd + +import metpy.calc as mpcalc +from metpy.cbook import get_test_data +from metpy.plots import add_metpy_logo, Hodograph, SkewT +from metpy.units import units + +########################################### +# Upper air data can easily be obtained using the siphon package, +# but for this example we will use some of MetPy's sample data. +col_names = ['pressure', 'height', 'temperature', 'dewpoint', 'direction', 'speed'] +df = pd.read_fwf(get_test_data('may4_sounding.txt', as_file_obj=False), + skiprows=5, usecols=[0, 1, 2, 3, 6, 7], names=col_names) + +# Drop any rows with all NaN values for T, Td, winds +df = df.dropna(subset=('temperature', 'dewpoint', 'direction', 'speed'), + how='all').reset_index(drop=True) +########################################### +# We will pull the data out of the example dataset into +# individual variables and assign units. +p = df['pressure'].values * units.hPa +z = df['height'].values * units.m +T = df['temperature'].values * units.degC +Td = df['dewpoint'].values * units.degC +wind_speed = df['speed'].values * units.knots +wind_dir = df['direction'].values * units.degrees +u, v = mpcalc.wind_components(wind_speed, wind_dir) +########################################### +# Now let's make a Skew-T Log-P diagram using some simply +# MetPy functionality +# Create a new figure. The dimensions here give a good aspect ratio +fig = plt.figure(figsize=(9, 9)) +add_metpy_logo(fig, 90, 80, size='small') +skew = SkewT(fig, rotation=45, rect=(0.1, 0.1, 0.55, 0.85)) + +# Plot the data using normal plotting functions, in this case using +# log scaling in Y, as dictated by the typical meteorological plot +skew.plot(p, T, 'r') +skew.plot(p, Td, 'g') +skew.plot_barbs(p, u, v) + +# Change to adjust data limits and give it a semblance of what we want +skew.ax.set_adjustable('datalim') +skew.ax.set_ylim(1000, 100) +skew.ax.set_xlim(-20, 30) + +# Add the relevant special lines +skew.plot_dry_adiabats() +skew.plot_moist_adiabats() +skew.plot_mixing_lines() + +# Create a hodograph +ax = plt.axes((0.7, 0.75, 0.2, 0.2)) +h = Hodograph(ax, component_range=60.) +h.add_grid(increment=20) +h.plot(u, v) +########################################### +# This layout isn't bad, especially for how little code it requires, +# but we could add a few simple tricks to greatly increase the +# readability and complexity of our Skew-T/Hodograph layout. Let's +# try another Skew-T with a few more advanced features: + +# STEP 1: CREATE THE SKEW-T OBJECT AND MODIFY IT TO CREATE A +# NICE, CLEAN PLOT +# Create a new figure. The dimensions here give a good aspect ratio +fig = plt.figure(figsize=(18, 12)) +skew = SkewT(fig, rotation=45, rect=(0.05, 0.05, 0.50, 0.90)) + +# add the Metpy logo +add_metpy_logo(fig, 105, 85, size='small') + +# Change to adjust data limits and give it a semblance of what we want +skew.ax.set_adjustable('datalim') +skew.ax.set_ylim(1000, 100) +skew.ax.set_xlim(-20, 30) + +# Set some better labels than the default to increase readability +skew.ax.set_xlabel(str.upper(f'Temperature ({T.units:~P})'), weight='bold') +skew.ax.set_ylabel(str.upper(f'Pressure ({p.units:~P})'), weight='bold') + +# Set the facecolor of the skew-t object and the figure to white +fig.set_facecolor('#ffffff') +skew.ax.set_facecolor('#ffffff') + +# Here we can use some basic math and Python functionality to make a cool +# shaded isotherm pattern. +x1 = np.linspace(-100, 40, 8) +x2 = np.linspace(-90, 50, 8) +y = [1100, 50] +for i in range(0, 8): + skew.shade_area(y=y, x1=x1[i], x2=x2[i], color='gray', alpha=0.02, zorder=1) + +# STEP 2: PLOT DATA ON THE SKEW-T. TAKE A COUPLE EXTRA STEPS TO +# INCREASE READABILITY +# Plot the data using normal plotting functions, in this case using +# log scaling in Y, as dictated by the typical meteorological plot +# Set the linewidth to 4 for increased readability. +# We will also add the 'label' keyword argument for our legend. +skew.plot(p, T, 'r', lw=4, label='TEMPERATURE') +skew.plot(p, Td, 'g', lw=4, label='DEWPOINT') + +# Again we can use some simple Python math functionality to 'resample' +# the wind barbs for a cleaner output with increased readability. +# Something like this would work. +interval = np.logspace(2, 3, 40) * units.hPa +idx = mpcalc.resample_nn_1d(p, interval) +skew.plot_barbs(pressure=p[idx], u=u[idx], v=v[idx]) + +# Add the relevant special lines native to the Skew-T Log-P diagram & +# provide basic adjustments to linewidth and alpha to increase readability +# first, we add a matplotlib axvline to highlight the 0-degree isotherm +skew.ax.axvline(0 * units.degC, linestyle='--', color='blue', alpha=0.3) +skew.plot_dry_adiabats(lw=1, alpha=0.3) +skew.plot_moist_adiabats(lw=1, alpha=0.3) +skew.plot_mixing_lines(lw=1, alpha=0.3) + +# Calculate LCL height and plot as a black dot. Because `p`'s first value is +# ~1000 mb and its last value is ~250 mb, the `0` index is selected for +# `p`, `T`, and `Td` to lift the parcel from the surface. If `p` was inverted, +# i.e. start from a low value, 250 mb, to a high value, 1000 mb, the `-1` index +# should be selected. +lcl_pressure, lcl_temperature = mpcalc.lcl(p[0], T[0], Td[0]) +skew.plot(lcl_pressure, lcl_temperature, 'ko', markerfacecolor='black') +# Calculate full parcel profile and add to plot as black line +prof = mpcalc.parcel_profile(p, T[0], Td[0]).to('degC') +skew.plot(p, prof, 'k', linewidth=2, label='SB PARCEL PATH') + +# Shade areas of CAPE and CIN +skew.shade_cin(p, T, prof, Td, alpha=0.2, label='SBCIN') +skew.shade_cape(p, T, prof, alpha=0.2, label='SBCAPE') + +# STEP 3: CREATE THE HODOGRAPH INSET. TAKE A FEW EXTRA STEPS TO +# INCREASE READABILITY +# Create a hodograph object: first we need to add an axis +# then we can create the Metpy Hodograph +hodo_ax = plt.axes((0.48, 0.45, 0.5, 0.5)) +h = Hodograph(hodo_ax, component_range=80.) + +# Add two separate grid increments for a cooler look. This also +# helps to increase readability +h.add_grid(increment=20, ls='-', lw=1.5, alpha=0.5) +h.add_grid(increment=10, ls='--', lw=1, alpha=0.2) + +# The next few steps makes for a clean hodograph inset, removing the +# tick marks, tick labels, and axis labels +h.ax.set_box_aspect(1) +h.ax.set_yticklabels([]) +h.ax.set_xticklabels([]) +h.ax.set_xticks([]) +h.ax.set_yticks([]) +h.ax.set_xlabel(' ') +h.ax.set_ylabel(' ') + +# Here we can add a simple Python for loop that adds tick marks +# to the inside of the hodograph plot to increase readability! +plt.xticks(np.arange(0, 0, 1)) +plt.yticks(np.arange(0, 0, 1)) +for i in range(10, 120, 10): + h.ax.annotate(str(i), (i, 0), xytext=(0, 2), textcoords='offset pixels', + clip_on=True, fontsize=10, weight='bold', alpha=0.3, zorder=0) +for i in range(10, 120, 10): + h.ax.annotate(str(i), (0, i), xytext=(0, 2), textcoords='offset pixels', + clip_on=True, fontsize=10, weight='bold', alpha=0.3, zorder=0) + +# plot the hodograph itself, using plot_colormapped, colored +# by height +h.plot_colormapped(u, v, c=z, linewidth=6, label='0-12km WIND') +# compute Bunkers storm motion so we can plot it on the hodograph! +RM, LM, MW = mpcalc.bunkers_storm_motion(p, u, v, z) +h.ax.text((RM[0].m + 0.5), (RM[1].m - 0.5), 'RM', weight='bold', ha='left', + fontsize=13, alpha=0.6) +h.ax.text((LM[0].m + 0.5), (LM[1].m - 0.5), 'LM', weight='bold', ha='left', + fontsize=13, alpha=0.6) +h.ax.text((MW[0].m + 0.5), (MW[1].m - 0.5), 'MW', weight='bold', ha='left', + fontsize=13, alpha=0.6) +h.ax.arrow(0, 0, RM[0].m - 0.3, RM[1].m - 0.3, linewidth=2, color='black', + alpha=0.2, label='Bunkers RM Vector', + length_includes_head=True, head_width=2) + +# STEP 4: ADD A FEW EXTRA ELEMENTS TO REALLY MAKE A NEAT PLOT +# First we want to actually add values of data to the plot for easy viewing +# To do this, let's first add a simple rectangle using Matplotlib's 'patches' +# functionality to add some simple layout for plotting calculated parameters +# xloc yloc xsize ysize +fig.patches.extend([plt.Rectangle((0.563, 0.05), 0.334, 0.37, + edgecolor='black', facecolor='white', + linewidth=1, alpha=1, transform=fig.transFigure, + figure=fig)]) + +# Now let's take a moment to calculate some simple severe-weather parameters using +# metpy's calculations +# Here are some classic severe parameters! +kindex = mpcalc.k_index(p, T, Td) +total_totals = mpcalc.total_totals_index(p, T, Td) + +# mixed layer parcel properties! +ml_t, ml_td = mpcalc.mixed_layer(p, T, Td, depth=50 * units.hPa) +ml_p, _, _ = mpcalc.mixed_parcel(p, T, Td, depth=50 * units.hPa) +mlcape, mlcin = mpcalc.mixed_layer_cape_cin(p, T, prof, depth=50 * units.hPa) + +# most unstable parcel properties! +mu_p, mu_t, mu_td, _ = mpcalc.most_unstable_parcel(p, T, Td, depth=50 * units.hPa) +mucape, mucin = mpcalc.most_unstable_cape_cin(p, T, Td, depth=50 * units.hPa) + +# Estimate height of LCL in meters from hydrostatic thickness (for sig_tor) +new_p = np.append(p[p > lcl_pressure], lcl_pressure) +new_t = np.append(T[p > lcl_pressure], lcl_temperature) +lcl_height = mpcalc.thickness_hydrostatic(new_p, new_t) + +# Compute Surface-based CAPE +sbcape, sbcin = mpcalc.surface_based_cape_cin(p, T, Td) +# Compute SRH +(u_storm, v_storm), *_ = mpcalc.bunkers_storm_motion(p, u, v, z) +*_, total_helicity1 = mpcalc.storm_relative_helicity(z, u, v, depth=1 * units.km, + storm_u=u_storm, storm_v=v_storm) +*_, total_helicity3 = mpcalc.storm_relative_helicity(z, u, v, depth=3 * units.km, + storm_u=u_storm, storm_v=v_storm) +*_, total_helicity6 = mpcalc.storm_relative_helicity(z, u, v, depth=6 * units.km, + storm_u=u_storm, storm_v=v_storm) + +# Copmute Bulk Shear components and then magnitude +ubshr1, vbshr1 = mpcalc.bulk_shear(p, u, v, height=z, depth=1 * units.km) +bshear1 = mpcalc.wind_speed(ubshr1, vbshr1) +ubshr3, vbshr3 = mpcalc.bulk_shear(p, u, v, height=z, depth=3 * units.km) +bshear3 = mpcalc.wind_speed(ubshr3, vbshr3) +ubshr6, vbshr6 = mpcalc.bulk_shear(p, u, v, height=z, depth=6 * units.km) +bshear6 = mpcalc.wind_speed(ubshr6, vbshr6) + +# Use all computed pieces to calculate the Significant Tornado parameter +sig_tor = mpcalc.significant_tornado(sbcape, lcl_height, + total_helicity3, bshear3).to_base_units() + +# Perform the calculation of supercell composite if an effective layer exists +super_comp = mpcalc.supercell_composite(mucape, total_helicity3, bshear3) + +# There is a lot we can do with this data operationally, so let's plot some of +# these values right on the plot, in the box we made +# First lets plot some thermodynamic parameters +plt.figtext(0.58, 0.37, 'SBCAPE: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.71, 0.37, f'{sbcape:.0f~P}', weight='bold', + fontsize=15, color='orangered', ha='right') +plt.figtext(0.58, 0.34, 'SBCIN: ', weight='bold', + fontsize=15, color='black', ha='left') +plt.figtext(0.71, 0.34, f'{sbcin:.0f~P}', weight='bold', + fontsize=15, color='lightblue', ha='right') +plt.figtext(0.58, 0.29, 'MLCAPE: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.71, 0.29, f'{mlcape:.0f~P}', weight='bold', + fontsize=15, color='orangered', ha='right') +plt.figtext(0.58, 0.26, 'MLCIN: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.71, 0.26, f'{mlcin:.0f~P}', weight='bold', + fontsize=15, color='lightblue', ha='right') +plt.figtext(0.58, 0.21, 'MUCAPE: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.71, 0.21, f'{mucape:.0f~P}', weight='bold', + fontsize=15, color='orangered', ha='right') +plt.figtext(0.58, 0.18, 'MUCIN: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.71, 0.18, f'{mucin:.0f~P}', weight='bold', + fontsize=15, color='lightblue', ha='right') +plt.figtext(0.58, 0.13, 'TT-INDEX: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.71, 0.13, f'{total_totals:.0f~P}', weight='bold', + fontsize=15, color='orangered', ha='right') +plt.figtext(0.58, 0.10, 'K-INDEX: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.71, 0.10, f'{kindex:.0f~P}', weight='bold', + fontsize=15, color='orangered', ha='right') + +# now some kinematic parameters +plt.figtext(0.73, 0.37, '0-1km SRH: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.88, 0.37, f'{total_helicity1:.0f~P}', + weight='bold', fontsize=15, color='navy', ha='right') +plt.figtext(0.73, 0.34, '0-1km SHEAR: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.88, 0.34, f'{bshear1:.0f~P}', weight='bold', + fontsize=15, color='blue', ha='right') +plt.figtext(0.73, 0.29, '0-3km SRH: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.88, 0.29, f'{total_helicity3:.0f~P}', + weight='bold', fontsize=15, color='navy', ha='right') +plt.figtext(0.73, 0.26, '0-3km SHEAR: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.88, 0.26, f'{bshear3:.0f~P}', weight='bold', + fontsize=15, color='blue', ha='right') +plt.figtext(0.73, 0.21, '0-6km SRH: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.88, 0.21, f'{total_helicity6:.0f~P}', + weight='bold', fontsize=15, color='navy', ha='right') +plt.figtext(0.73, 0.18, '0-6km SHEAR: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.88, 0.18, f'{bshear6:.0f~P}', weight='bold', + fontsize=15, color='blue', ha='right') +plt.figtext(0.73, 0.13, 'SIG TORNADO: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.88, 0.13, f'{sig_tor[0]:.0f~P}', weight='bold', fontsize=15, + color='orangered', ha='right') +plt.figtext(0.73, 0.10, 'SUPERCELL COMP: ', weight='bold', fontsize=15, + color='black', ha='left') +plt.figtext(0.88, 0.10, f'{super_comp[0]:.0f~P}', weight='bold', fontsize=15, + color='orangered', ha='right') + +# Add legends to the skew and hodo +skewleg = skew.ax.legend(loc='upper left') +hodoleg = h.ax.legend(loc='upper left') + +# add a quick plot title, this could be automated by +# declaring a station and datetime variable when using +# realtime observation data from Siphon. +plt.figtext(0.45, 0.97, 'OUN | MAY 4TH 1999 - 00Z VERTICAL PROFILE', + weight='bold', fontsize=20, ha='center') + +# Show the plot +plt.show() diff --git a/examples/calculations/Vorticity.py b/examples/calculations/Vorticity.py index 1cd256a34e8..d99025d2cc4 100644 --- a/examples/calculations/Vorticity.py +++ b/examples/calculations/Vorticity.py @@ -20,13 +20,13 @@ ds = example_data() # Calculate the vertical vorticity of the flow -vor = mpcalc.vorticity(ds.uwind, ds.vwind) +vort = mpcalc.vorticity(ds.uwind, ds.vwind) # start figure and set axis fig, ax = plt.subplots(figsize=(5, 5)) # scale vorticity by 1e5 for plotting purposes -cf = ax.contourf(ds.lon, ds.lat, vor * 1e5, range(-80, 81, 1), cmap=plt.cm.PuOr_r) +cf = ax.contourf(ds.lon, ds.lat, vort * 1e5, range(-80, 81, 1), cmap=plt.cm.PuOr_r) plt.colorbar(cf, pad=0, aspect=50) ax.barbs(ds.lon.values, ds.lat.values, ds.uwind, ds.vwind, color='black', length=5, alpha=0.5) ax.set(xlim=(260, 270), ylim=(30, 40)) diff --git a/examples/gridding/Natural_Neighbor_Verification.py b/examples/gridding/Natural_Neighbor_Verification.py index 24d8fff332b..ae314665708 100644 --- a/examples/gridding/Natural_Neighbor_Verification.py +++ b/examples/gridding/Natural_Neighbor_Verification.py @@ -162,11 +162,11 @@ def draw_circle(ax, x, y, r, m, label): # spatial data structure that we use here simply to show areal ratios. # Notice that the two natural neighbor triangle circumcenters are also vertices # in the Voronoi plot (green dots), and the observations are in the polygons (blue dots). -vor = Voronoi(list(zip(xp, yp))) +vort = Voronoi(list(zip(xp, yp))) fig, ax = plt.subplots(1, 1, figsize=(15, 10)) ax.ishold = lambda: True # Work-around for Matplotlib 3.0.0 incompatibility -voronoi_plot_2d(vor, ax=ax) +voronoi_plot_2d(vort, ax=ax) nn_ind = np.array([0, 5, 7, 8]) z_0 = zp[nn_ind] diff --git a/examples/plots/Station_Plot_with_Layout.py b/examples/plots/Station_Plot_with_Layout.py index e98fd701727..af884491703 100644 --- a/examples/plots/Station_Plot_with_Layout.py +++ b/examples/plots/Station_Plot_with_Layout.py @@ -15,7 +15,7 @@ The `StationPlotLayout` class is used to standardize the plotting various parameters (i.e. temperature), keeping track of the location, formatting, and even the units for use in -the station plot. This makes it easy (if using standardized names) to re-use a given layout +the station plot. This makes it easy (if using standardized names) to reuse a given layout of a station plot. """ import cartopy.crs as ccrs diff --git a/pyproject.toml b/pyproject.toml index f0447960187..f6b032aaee1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ classifiers = [ ] requires-python = ">=3.9" dependencies = [ - "matplotlib>=3.3.0", + "matplotlib>=3.5.0", "numpy>=1.20.0", "pandas>=1.2.0", "pint>=0.15", @@ -41,24 +41,28 @@ gini = "metpy.io.gini:GiniXarrayBackend" [project.optional-dependencies] doc = [ + "metpy[examples]", + "myst-parser", + "netCDF4", "sphinx", "sphinx-gallery>=0.4", - "myst-parser", - "netCDF4" + "sphinx-design", + "pydata-sphinx-theme" ] examples = [ - "cartopy>=0.17.0", "geopandas>=0.6.0", - "matplotlib>=3.3.0", - "shapely>=1.6.0" + "metpy[extras]" ] test = [ - "packaging>=21.0", - "pytest>=2.4", - "pytest-mpl", - "cartopy>=0.17.0", "netCDF4", - "shapely>=1.6.0" + "packaging>=21.0", + "pytest>=6.2", + "pytest-mpl" +] +extras = [ + "cartopy>=0.21.0", + "dask>=2020.12.0", + "shapely>=1.6.4" ] [project.urls] @@ -71,7 +75,7 @@ test = [ "MetPy Mondays" = "https://www.youtube.com/playlist?list=PLQut5OXpV-0ir4IdllSt1iEZKTwFBa7kO" [tool.codespell] -skip = "*.tbl,*.ipynb,AUTHORS.txt,gempak.rst,.git,./staticdata,./docs/build,*.pdf" +skip = "*.tbl,*.ipynb,AUTHORS.txt,gempak.rst,.git,./staticdata,./docs/build,*.pdf,./talks" exclude-file = ".codespellignore" [tool.doc8] @@ -101,13 +105,15 @@ mpl-results-path = "test_output" [tool.ruff] line-length = 95 exclude = ["docs", "build", "src/metpy/io/_metar_parser/metar_parser.py"] -select = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "R", "S", "T", "U", "W"] +select = ["A", "B", "C", "D", "E", "E226", "F", "G", "I", "N", "Q", "R", "S", "T", "U", "W"] ignore = ["F405", "I001", "RET504", "RET505", "RET506", "RET507", "RUF100"] [tool.ruff.per-file-ignores] "ci/filter_links.py" = ["E731", "T201", "S603", "S607"] "docs/doc-server.py" = ["T201"] "examples/*.py" = ["D", "T201", "B018"] +"src/metpy/_vendor/xarray.py" = ["UP032"] +"src/metpy/deprecation.py" = ["UP032"] "src/metpy/testing.py" = ["S101"] "src/metpy/io/nexrad.py" = ["S101"] "tests/*/*.py" = ["S101"] @@ -135,12 +141,5 @@ max-complexity = 61 [tool.ruff.pydocstyle] convention = "numpy" -[tools.setuptools] -zip-safe = true -platforms = ["any"] - -[tools.setuptools.packages.find] -where = ["src"] - [tool.setuptools_scm] version_scheme = "post-release" diff --git a/setup.cfg b/setup.cfg index 247952cc277..1c0956cbc1c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,7 +10,7 @@ rst-roles = class, data, doc, func, meth, mod rst-directives = plot, versionchanged known-modules = matplotlib:[matplotlib,mpl_toolkits],netcdf4:[netCDF4] exclude = docs build src/metpy/io/_metar_parser/metar_parser.py -select = C I R +select = C E301 E302 E303 E304 E305 E306 I R ignore = F405 W503 RST902 SIM106 per-file-ignores = examples/*.py: D MPY001 tutorials/*.py: D MPY001 diff --git a/src/metpy/calc/indices.py b/src/metpy/calc/indices.py index ef258242e8f..e7560015ca2 100644 --- a/src/metpy/calc/indices.py +++ b/src/metpy/calc/indices.py @@ -298,6 +298,9 @@ def bunkers_storm_motion(pressure, u, v, height): Renamed ``heights`` parameter to ``height`` """ + # remove nans from input data + pressure, u, v, height = _remove_nans(pressure, u, v, height) + # mean wind from sfc-6km wind_mean = weighted_continuous_average(pressure, u, v, height=height, depth=units.Quantity(6000, 'meter')) diff --git a/src/metpy/calc/kinematics.py b/src/metpy/calc/kinematics.py index 853e0ceeb86..c3d8ac85c69 100644 --- a/src/metpy/calc/kinematics.py +++ b/src/metpy/calc/kinematics.py @@ -434,8 +434,8 @@ def advection( ----- This implements the advection of a scalar quantity by wind: - .. math:: \mathbf{u} \cdot \nabla = u \frac{\partial}{\partial x} - + v \frac{\partial}{\partial y} + w \frac{\partial}{\partial z} + .. math:: -\mathbf{u} \cdot \nabla = -(u \frac{\partial}{\partial x} + + v \frac{\partial}{\partial y} + w \frac{\partial}{\partial z}) .. versionchanged:: 1.0 Changed signature from ``(scalar, wind, deltas)`` @@ -1044,7 +1044,7 @@ def potential_vorticity_baroclinic( or np.shape(potential_temperature)[vertical_dim] != np.shape(pressure)[vertical_dim] ): raise ValueError('Length of potential temperature along the vertical axis ' - '{} must be at least 3.'.format(vertical_dim)) + f'{vertical_dim} must be at least 3.') avor = absolute_vorticity(u, v, dx, dy, latitude, x_dim=x_dim, y_dim=y_dim, parallel_scale=parallel_scale, meridional_scale=meridional_scale) diff --git a/src/metpy/calc/thermo.py b/src/metpy/calc/thermo.py index 6f1867c8520..17a7da5a790 100644 --- a/src/metpy/calc/thermo.py +++ b/src/metpy/calc/thermo.py @@ -493,7 +493,7 @@ def ccl(pressure, temperature, dewpoint, height=None, mixed_layer_depth=None, wh Parameters ---------- pressure : `pint.Quantity` - Atmospheric pressure profile + Atmospheric pressure profile. This array must be from high to low pressure. temperature : `pint.Quantity` Temperature at the levels given by `pressure` @@ -548,6 +548,7 @@ def ccl(pressure, temperature, dewpoint, height=None, mixed_layer_depth=None, wh (, ) """ pressure, temperature, dewpoint = _remove_nans(pressure, temperature, dewpoint) + _check_pressure_error(pressure) # If the mixed layer is not defined, take the starting dewpoint to be the # first element of the dewpoint array and calculate the corresponding mixing ratio. @@ -598,7 +599,7 @@ def lfc(pressure, temperature, dewpoint, parcel_temperature_profile=None, dewpoi Parameters ---------- pressure : `pint.Quantity` - Atmospheric pressure + Atmospheric pressure profile. This array must be from high to low pressure. temperature : `pint.Quantity` Temperature at the levels given by `pressure` @@ -814,7 +815,7 @@ def el(pressure, temperature, dewpoint, parcel_temperature_profile=None, which=' Parameters ---------- pressure : `pint.Quantity` - Atmospheric pressure profile + Atmospheric pressure profile. This array must be from high to low pressure. temperature : `pint.Quantity` Temperature at the levels given by `pressure` @@ -827,7 +828,7 @@ def el(pressure, temperature, dewpoint, parcel_temperature_profile=None, which=' surface parcel profile. which: str, optional - Pick which LFC to return. Options are 'top', 'bottom', 'wide', 'most_cape', and 'all'. + Pick which EL to return. Options are 'top', 'bottom', 'wide', 'most_cape', and 'all'. 'top' returns the lowest-pressure EL, default. 'bottom' returns the highest-pressure EL. 'wide' returns the EL whose corresponding LFC is farthest away. @@ -1049,15 +1050,9 @@ def parcel_profile_with_lcl(pressure, temperature, dewpoint): ... .56, .88, .39, .67, .15, .04, .94, .35] * units.dimensionless >>> # calculate dewpoint >>> Td = dewpoint_from_relative_humidity(T, rh) - >>> # computer parcel temperature + >>> # compute parcel temperature >>> Td = dewpoint_from_relative_humidity(T, rh) >>> p_wLCL, T_wLCL, Td_wLCL, prof_wLCL = parcel_profile_with_lcl(p, T, Td) - >>> print(f'Shape of original pressure array: {p.shape}') - Shape of original pressure array: (30,) - >>> print(f'Shape of pressure array from function: {p_wLCL.shape}') - Shape of pressure array from function: (31,) - >>> print(p == p_wLCL) - False See Also -------- @@ -1166,6 +1161,13 @@ def _check_pressure(pressure): return np.all(pressure[:-1] >= pressure[1:]) +def _check_pressure_error(pressure): + """Raise an `InvalidSoundingError` if _check_pressure returns False.""" + if not _check_pressure(pressure): + raise InvalidSoundingError('Pressure increases between at least two points in ' + 'your sounding. Using scipy.signal.medfilt may fix this.') + + def _parcel_profile_helper(pressure, temperature, dewpoint): """Help calculate parcel profiles. @@ -1174,11 +1176,7 @@ def _parcel_profile_helper(pressure, temperature, dewpoint): """ # Check that pressure does not increase. - if not _check_pressure(pressure): - msg = """ - Pressure increases between at least two points in your sounding. - Using scipy.signal.medfilt may fix this.""" - raise InvalidSoundingError(msg) + _check_pressure_error(pressure) # Find the LCL press_lcl, temp_lcl = lcl(pressure[0], temperature, dewpoint) diff --git a/src/metpy/constants/default.py b/src/metpy/constants/default.py index 1a3f698ac7f..28c9558b01f 100644 --- a/src/metpy/constants/default.py +++ b/src/metpy/constants/default.py @@ -56,7 +56,7 @@ # General meteorology constants P0 = pot_temp_ref_press = units.Quantity(1000., 'mbar') kappa = poisson_exponent = (Rd / Cp_d).to('dimensionless') - gamma_d = dry_adiabatic_lapse_rate = g / Cp_d + gamma_d = dry_adiabatic_lapse_rate = (g / Cp_d).to('K / km') epsilon = molecular_weight_ratio = (Mw / Md).to('dimensionless') del Exporter diff --git a/src/metpy/io/gempak.py b/src/metpy/io/gempak.py index 8f0d8106a61..67f298c8f1f 100644 --- a/src/metpy/io/gempak.py +++ b/src/metpy/io/gempak.py @@ -1189,8 +1189,9 @@ def _unpack_grid(self, packing_type, part): # 'GridMetaReal')) # grid_start = self._buffer.set_mark() else: - raise NotImplementedError('No method for unknown grid packing {}' - .format(packing_type.name)) + raise NotImplementedError( + f'No method for unknown grid packing {packing_type.name}' + ) def gdxarray(self, parameter=None, date_time=None, coordinate=None, level=None, date_time2=None, level2=None): @@ -1477,8 +1478,7 @@ def _unpack_merged(self, sndno): }.get(part.data_type) if fmt_code is None: - raise NotImplementedError('No methods for data type {}' - .format(part.data_type)) + raise NotImplementedError(f'No methods for data type {part.data_type}') if fmt_code == 's': lendat *= BYTES_PER_WORD @@ -1544,8 +1544,7 @@ def _unpack_unmerged(self, sndno): }.get(part.data_type) if fmt_code is None: - raise NotImplementedError('No methods for data type {}' - .format(part.data_type)) + raise NotImplementedError(f'No methods for data type {part.data_type}') if fmt_code == 's': lendat *= BYTES_PER_WORD @@ -2453,8 +2452,7 @@ def _unpack_climate(self, sfcno): }.get(part.data_type) if fmt_code is None: - raise NotImplementedError('No methods for data type {}' - .format(part.data_type)) + raise NotImplementedError(f'No methods for data type {part.data_type}') if fmt_code == 's': lendat *= BYTES_PER_WORD @@ -2524,8 +2522,7 @@ def _unpack_ship(self, sfcno): }.get(part.data_type) if fmt_code is None: - raise NotImplementedError('No methods for data type {}' - .format(part.data_type)) + raise NotImplementedError(f'No methods for data type {part.data_type}') if fmt_code == 's': lendat *= BYTES_PER_WORD @@ -2595,8 +2592,7 @@ def _unpack_standard(self, sfcno): }.get(part.data_type) if fmt_code is None: - raise NotImplementedError('No methods for data type {}' - .format(part.data_type)) + raise NotImplementedError(f'No methods for data type {part.data_type}') if fmt_code == 's': lendat *= BYTES_PER_WORD diff --git a/src/metpy/io/nexrad.py b/src/metpy/io/nexrad.py index 1a2931643de..ff76a1dae9b 100644 --- a/src/metpy/io/nexrad.py +++ b/src/metpy/io/nexrad.py @@ -11,7 +11,6 @@ import pathlib import re import struct -from xdrlib import Unpacker import numpy as np from scipy.constants import day, milli @@ -2301,11 +2300,37 @@ def _read_trends(self): 0xba07: _unpack_packet_raster_data} -class Level3XDRParser(Unpacker): +class Level3XDRParser: """Handle XDR-formatted Level 3 NEXRAD products.""" + def __init__(self, data): + """Initialize the parser with a buffer for the bytes.""" + self._buf = IOBuffer(data) + + def unpack_uint(self): + """Unpack a 4-byte unsigned integer.""" + return self._buf.read_int(4, 'big', False) + + def unpack_int(self): + """Unpack a 4-byte signed integer.""" + return self._buf.read_int(4, 'big', True) + + def unpack_float(self): + """Unpack a 4-byte floating-point value.""" + return self._buf.read_binary(1, '>f')[0] + + def unpack_string(self): + """Unpack a string.""" + n = self.unpack_uint() + return self._buf.read_ascii((n + 3) // 4 * 4)[:n] + + def unpack_int_array(self): + """Unpack an int array.""" + n = self.unpack_uint() + return self._buf.read_binary(n, '>l') + def __call__(self, code): - """Perform the actual unpacking.""" + """Perform the actual product unpacking.""" xdr = OrderedDict() if code == 28: @@ -2314,12 +2339,10 @@ def __call__(self, code): log.warning('XDR: code %d not implemented', code) # Check that we got it all - self.done() - return xdr + if not self._buf.at_end(): + log.warning('Data remains in XDR buffer.') - def unpack_string(self): - """Unpack the internal data as a string.""" - return Unpacker.unpack_string(self).decode('ascii') + return xdr def _unpack_prod_desc(self): xdr = OrderedDict() @@ -2415,7 +2438,7 @@ def _unpack_radial(self): width=self.unpack_float(), num_bins=self.unpack_int(), attributes=self.unpack_string(), - data=self.unpack_array(self.unpack_int))) + data=self.unpack_int_array())) return ret._replace(radials=rads) text_fmt = namedtuple('TextComponent', ['parameters', 'text']) diff --git a/src/metpy/io/station_data.py b/src/metpy/io/station_data.py index 4db0f2ec5e1..df7e153b5bf 100644 --- a/src/metpy/io/station_data.py +++ b/src/metpy/io/station_data.py @@ -191,8 +191,9 @@ def key_finder(df): raise KeyError('Second argument not provided to add_station_lat_lon, but none of ' f'{names_to_try} were found.') - df['latitude'] = None - df['longitude'] = None + df['latitude'] = np.nan + df['longitude'] = np.nan + if stn_var is None: stn_var = key_finder(df) for stn in df[stn_var].unique(): diff --git a/src/metpy/plots/declarative.py b/src/metpy/plots/declarative.py index 1afd88044e3..4f2d56f15f0 100644 --- a/src/metpy/plots/declarative.py +++ b/src/metpy/plots/declarative.py @@ -14,8 +14,8 @@ import matplotlib.pyplot as plt import numpy as np import pandas as pd -from traitlets import (Any, Bool, Float, HasTraits, Instance, Int, List, observe, TraitError, - Tuple, Unicode, Union, validate) +from traitlets import (Any, Bool, Dict, Float, HasTraits, Instance, Int, List, observe, + TraitError, Tuple, Unicode, Union, validate) from . import ctables, wx_symbols from ._mpl import TextCollection @@ -56,13 +56,14 @@ def lookup_map_feature(feature_name): return feat.with_scale(scaler) -def plot_kwargs(data): +def plot_kwargs(data, args): """Set the keyword arguments for MapPanel plotting.""" if hasattr(data.metpy, 'cartopy_crs'): # Conditionally add cartopy transform if we are on a map. kwargs = {'transform': data.metpy.cartopy_crs} else: kwargs = {} + kwargs.update(args) return kwargs @@ -103,6 +104,19 @@ def __dir__(self): dir(type(self)) ) + mpl_args = Dict(allow_none=True) + mpl_args.__doc__ = """Supply a dictionary of valid Matplotlib keyword arguments to modify + how the plot variable is drawn. + + Using this attribute you must choose the appropriate keyword arguments (kwargs) based on + what you are plotting (e.g., contours, color-filled contours, image plot, etc.). This is + available for all plot types (ContourPlot, FilledContourPlot, RasterPlot, ImagePlot, + BarbPlot, ArrowPlot, PlotGeometry, and PlotObs). For PlotObs, the kwargs are those to + specify the StationPlot object. NOTE: Setting the mpl_args trait will override + any other trait that corresponds to a specific kwarg for the particular plot type + (e.g., linecolor, linewidth). + """ + class Panel(MetPyHasTraits): """Draw one or more plots.""" @@ -825,8 +839,12 @@ def draw(self): if getattr(self, 'handle', None) is None: self._build() if getattr(self, 'colorbar', None) is not None: - cbar = self.parent.ax.figure.colorbar( - self.handle, orientation=self.colorbar, pad=0, aspect=50) + if isinstance(self.colorbar, dict): + cbar = self.parent.ax.figure.colorbar( + self.handle, **self.colorbar) + else: + cbar = self.parent.ax.figure.colorbar( + self.handle, orientation=self.colorbar, pad=0, aspect=50) cbar.ax.tick_params(labelsize=self.colorbar_fontsize) self._need_redraw = False @@ -878,13 +896,15 @@ class ColorfillTraits(MetPyHasTraits): `matplotlib.colors.Normalize` instance for plotting. """ - colorbar = Unicode(default_value=None, allow_none=True) + colorbar = Union([Unicode(default_value=None, allow_none=True), Dict()]) colorbar.__doc__ = """A string (horizontal/vertical) on whether to add a colorbar to the plot. - To add a colorbar associated with the plot, set the trait to ``horizontal`` or - ``vertical``,specifying the orientation of the produced colorbar. The default value is - ``None``. + To add a colorbar associated with the plot, you can either set the trait with a string of + ``horizontal`` or ``vertical``, which specifies the orientation of the produced colorbar + and uses pre-defined defaults for aspect and pad. Alternatively, you can set a dictionary + of keyword argument values valid for a Matplotlib colorbar to specify how the colorbar will + be plotted. The default value is ``None``. """ colorbar_fontsize = Union([Int(), Float(), Unicode()], allow_none=True, default_value=None) @@ -936,20 +956,17 @@ def _build(self): """Build the plot by calling any plotting methods as necessary.""" x_like, y_like, imdata = self.plotdata - kwargs = plot_kwargs(imdata) + kwargs = plot_kwargs(imdata, self.mpl_args) # If we're on a map, we use min/max for y and manually figure out origin to try to # avoid upside down images created by images where y[0] > y[-1], as well as # specifying the transform kwargs['extent'] = (x_like[0], x_like[-1], y_like.min(), y_like.max()) kwargs['origin'] = 'upper' if y_like[0] > y_like[-1] else 'lower' + kwargs.setdefault('cmap', self._cmap_obj) + kwargs.setdefault('norm', self._norm_obj) - self.handle = self.parent.ax.imshow( - imdata, - cmap=self._cmap_obj, - norm=self._norm_obj, - **kwargs - ) + self.handle = self.parent.ax.imshow(imdata, **kwargs) @exporter.export @@ -993,11 +1010,12 @@ def _build(self): """Build the plot by calling any plotting methods as necessary.""" x_like, y_like, imdata = self.plotdata - kwargs = plot_kwargs(imdata) + kwargs = plot_kwargs(imdata, self.mpl_args) + kwargs.setdefault('linewidths', self.linewidth) + kwargs.setdefault('colors', self.linecolor) + kwargs.setdefault('linestyles', self.linestyle) - self.handle = self.parent.ax.contour(x_like, y_like, imdata, self.contours, - colors=self.linecolor, linewidths=self.linewidth, - linestyles=self.linestyle, **kwargs) + self.handle = self.parent.ax.contour(x_like, y_like, imdata, self.contours, **kwargs) if self.clabels: self.handle.clabel(inline=1, fmt='%.0f', inline_spacing=8, use_clabeltext=True, fontsize=self.label_fontsize) @@ -1018,11 +1036,11 @@ def _build(self): """Build the plot by calling any plotting methods as necessary.""" x_like, y_like, imdata = self.plotdata - kwargs = plot_kwargs(imdata) + kwargs = plot_kwargs(imdata, self.mpl_args) + kwargs.setdefault('cmap', self._cmap_obj) + kwargs.setdefault('norm', self._norm_obj) - self.handle = self.parent.ax.contourf(x_like, y_like, imdata, self.contours, - cmap=self._cmap_obj, norm=self._norm_obj, - **kwargs) + self.handle = self.parent.ax.contourf(x_like, y_like, imdata, self.contours, **kwargs) @exporter.export @@ -1040,11 +1058,11 @@ def _build(self): """Build the raster plot by calling any plotting methods as necessary.""" x_like, y_like, imdata = self.plotdata - kwargs = plot_kwargs(imdata) + kwargs = plot_kwargs(imdata, self.mpl_args) + kwargs.setdefault('cmap', self._cmap_obj) + kwargs.setdefault('norm', self._norm_obj) - self.handle = self.parent.ax.pcolormesh(x_like, y_like, imdata, - cmap=self._cmap_obj, norm=self._norm_obj, - **kwargs) + self.handle = self.parent.ax.pcolormesh(x_like, y_like, imdata, **kwargs) @exporter.export @@ -1219,7 +1237,11 @@ def _build(self): """Build the plot by calling needed plotting methods as necessary.""" x_like, y_like, u, v = self.plotdata - kwargs = plot_kwargs(u) + kwargs = plot_kwargs(u, self.mpl_args) + kwargs.setdefault('color', self.color) + kwargs.setdefault('pivot', self.pivot) + kwargs.setdefault('length', self.barblength) + kwargs.setdefault('zorder', 2) # Conditionally apply the proper transform if 'transform' in kwargs and self.earth_relative: @@ -1230,7 +1252,7 @@ def _build(self): self.handle = self.parent.ax.barbs( x_like[wind_slice], y_like[wind_slice], u.values[wind_slice], v.values[wind_slice], - color=self.color, pivot=self.pivot, length=self.barblength, zorder=2, **kwargs) + **kwargs) @exporter.export @@ -1281,7 +1303,10 @@ def _build(self): """Build the plot by calling needed plotting methods as necessary.""" x_like, y_like, u, v = self.plotdata - kwargs = plot_kwargs(u) + kwargs = plot_kwargs(u, self.mpl_args) + kwargs.setdefault('color', self.color) + kwargs.setdefault('pivot', self.pivot) + kwargs.setdefault('scale', self.arrowscale) # Conditionally apply the proper transform if 'transform' in kwargs and self.earth_relative: @@ -1292,7 +1317,7 @@ def _build(self): self.handle = self.parent.ax.quiver( x_like[wind_slice], y_like[wind_slice], u.values[wind_slice], v.values[wind_slice], - color=self.color, pivot=self.pivot, scale=self.arrowscale, **kwargs) + **kwargs) # The order here needs to match the order of the tuple if self.arrowkey is not None: @@ -1567,9 +1592,12 @@ def _build(self): scale = 1. if self.parent._proj_obj == ccrs.PlateCarree() else 100000. point_locs = self.parent._proj_obj.transform_points(ccrs.PlateCarree(), lon, lat) subset = reduce_point_density(point_locs, self.reduce_points * scale) + kwargs = self.mpl_args + kwargs.setdefault('clip_on', True) + kwargs.setdefault('transform', ccrs.PlateCarree()) + kwargs.setdefault('fontsize', self.fontsize) - self.handle = StationPlot(self.parent.ax, lon[subset], lat[subset], clip_on=True, - transform=ccrs.PlateCarree(), fontsize=self.fontsize) + self.handle = StationPlot(self.parent.ax, lon[subset], lat[subset], **kwargs) for i, ob_type in enumerate(self.fields): field_kwargs = {} @@ -1667,6 +1695,17 @@ class PlotGeometry(MetPyHasTraits): the sequence of colors as needed. Default value is black. """ + stroke_width = Union([Instance(collections.abc.Iterable), Float()], default_value=[1], + allow_none=True) + stroke_width.__doc__ = """Stroke width(s) for polygons and lines. + + A single integer or floating point value or collection of values representing the size of + the stroke width. If a collection, the first value corresponds to the first Shapely + object in `geometry`, the second value corresponds to the second Shapely object, and so on. + If `stroke_width` is shorter than `geometry`, `stroke_width` cycles back to the beginning, + repeating the sequence of values as needed. Default value is 1. + """ + marker = Unicode(default_value='.', allow_none=False) marker.__doc__ = """Symbol used to denote points. @@ -1845,27 +1884,38 @@ def _build(self): else self.label_edgecolor) self.label_facecolor = (['none'] if self.label_facecolor is None else self.label_facecolor) + kwargs = self.mpl_args # Each Shapely object is plotted separately with its corresponding colors and label - for geo_obj, stroke, fill, label, fontcolor, fontoutline in zip( - self.geometry, cycle(self.stroke), cycle(self.fill), cycle(self.labels), - cycle(self.label_facecolor), cycle(self.label_edgecolor)): + for geo_obj, stroke, strokewidth, fill, label, fontcolor, fontoutline in zip( + self.geometry, cycle(self.stroke), cycle(self.stroke_width), cycle(self.fill), + cycle(self.labels), cycle(self.label_facecolor), cycle(self.label_edgecolor)): # Plot the Shapely object with the appropriate method and colors if isinstance(geo_obj, (MultiPolygon, Polygon)): - self.parent.ax.add_geometries([geo_obj], edgecolor=stroke, - facecolor=fill, crs=ccrs.PlateCarree()) + kwargs.setdefault('edgecolor', stroke) + kwargs.setdefault('linewidths', strokewidth) + kwargs.setdefault('facecolor', fill) + kwargs.setdefault('crs', ccrs.PlateCarree()) + self.parent.ax.add_geometries([geo_obj], **kwargs) elif isinstance(geo_obj, (MultiLineString, LineString)): - self.parent.ax.add_geometries([geo_obj], edgecolor=stroke, - facecolor='none', crs=ccrs.PlateCarree()) + kwargs.setdefault('edgecolor', stroke) + kwargs.setdefault('linewidths', strokewidth) + kwargs.setdefault('facecolor', 'none') + kwargs.setdefault('crs', ccrs.PlateCarree()) + self.parent.ax.add_geometries([geo_obj], **kwargs) elif isinstance(geo_obj, MultiPoint): + kwargs.setdefault('color', fill) + kwargs.setdefault('marker', self.marker) + kwargs.setdefault('transform', ccrs.PlateCarree()) for point in geo_obj.geoms: lon, lat = point.coords[0] - self.parent.ax.plot(lon, lat, color=fill, marker=self.marker, - transform=ccrs.PlateCarree()) + self.parent.ax.plot(lon, lat, **kwargs) elif isinstance(geo_obj, Point): + kwargs.setdefault('color', fill) + kwargs.setdefault('marker', self.marker) + kwargs.setdefault('transform', ccrs.PlateCarree()) lon, lat = geo_obj.coords[0] - self.parent.ax.plot(lon, lat, color=fill, marker=self.marker, - transform=ccrs.PlateCarree()) + self.parent.ax.plot(lon, lat, **kwargs) # Plot labels if provided if label: diff --git a/src/metpy/plots/mapping.py b/src/metpy/plots/mapping.py index 0ee2f875388..4f55682e277 100644 --- a/src/metpy/plots/mapping.py +++ b/src/metpy/plots/mapping.py @@ -206,3 +206,14 @@ def make_polar_stereo(attrs_dict, globe): kwargs = CFProjection.build_projection_kwargs(attrs_dict, attr_mapping) return ccrs.Stereographic(globe=globe, **kwargs) + + +@CFProjection.register('rotated_latitude_longitude') +def make_rotated_latlon(attrs_dict, globe): + """Handle rotated latitude/longitude projection.""" + attr_mapping = [('pole_longitude', 'grid_north_pole_longitude'), + ('pole_latitude', 'grid_north_pole_latitude'), + ('central_rotated_longitude', 'north_pole_grid_longitude')] + kwargs = CFProjection.build_projection_kwargs(attrs_dict, attr_mapping) + + return ccrs.RotatedPole(globe=globe, **kwargs) diff --git a/src/metpy/plots/station_plot.py b/src/metpy/plots/station_plot.py index 0601fc981ae..cb71fb267ef 100644 --- a/src/metpy/plots/station_plot.py +++ b/src/metpy/plots/station_plot.py @@ -620,7 +620,7 @@ def coerce_data(dat, u): def __repr__(self): """Return string representation of layout.""" return ('{' - + ', '.join('{0}: ({1[0].name}, {1[1]}, ...)'.format(loc, info) + + ', '.join(f'{loc}: ({info[0].name}, {info[1]}, ...)' for loc, info in sorted(self.items())) + '}') diff --git a/src/metpy/testing.py b/src/metpy/testing.py index 2cca9f731a7..da9416ad216 100644 --- a/src/metpy/testing.py +++ b/src/metpy/testing.py @@ -10,8 +10,10 @@ import contextlib import functools +import matplotlib import numpy as np import numpy.testing +from packaging.version import Version from pint import DimensionalityError import pytest import xarray as xr @@ -21,6 +23,38 @@ from .deprecation import MetpyDeprecationWarning from .units import units +MPL_VERSION = Version(matplotlib.__version__) + + +def mpl_version_before(ver): + """Return whether the active matplotlib is before a certain version. + + Parameters + ---------- + ver : str + The version string for a certain release + + Returns + ------- + bool : whether the current version was released before the passed in one + """ + return MPL_VERSION < Version(ver) + + +def mpl_version_equal(ver): + """Return whether the active matplotlib is equal to a certain version. + + Parameters + ---------- + ver : str + The version string for a certain release + + Returns + ------- + bool : whether the current version is equal to the passed in one + """ + return MPL_VERSION == Version(ver) + def needs_module(module): """Decorate a test function or fixture as requiring a module. diff --git a/tests/calc/test_indices.py b/tests/calc/test_indices.py index b2283c3e7db..77623cbc3ae 100644 --- a/tests/calc/test_indices.py +++ b/tests/calc/test_indices.py @@ -176,6 +176,21 @@ def test_bunkers_motion(): assert_almost_equal(motion.flatten(), truth, 8) +def test_bunkers_motion_with_nans(): + """Test Bunkers storm motion with observed sounding.""" + data = get_upper_air_data(datetime(2016, 5, 22, 0), 'DDC') + u_with_nan = data['u_wind'] + u_with_nan[24:26] = np.nan + v_with_nan = data['v_wind'] + v_with_nan[24:26] = np.nan + motion = concatenate(bunkers_storm_motion(data['pressure'], + u_with_nan, v_with_nan, + data['height'])) + truth = [2.09232447, 0.97612357, 11.25513401, 12.85227283, 6.67372924, + 6.9141982] * units('m/s') + assert_almost_equal(motion.flatten(), truth, 8) + + def test_bulk_shear(): """Test bulk shear with observed sounding.""" data = get_upper_air_data(datetime(2016, 5, 22, 0), 'DDC') diff --git a/tests/calc/test_thermo.py b/tests/calc/test_thermo.py index 1c6b869f327..fb83f7b4b99 100644 --- a/tests/calc/test_thermo.py +++ b/tests/calc/test_thermo.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: BSD-3-Clause """Test the `thermo` module.""" +import platform import sys import warnings @@ -141,9 +142,9 @@ def test_moist_lapse_ref_pressure(): def test_moist_lapse_multiple_temps(): """Test moist_lapse with multiple starting temperatures.""" temp = moist_lapse(np.array([1050., 800., 600., 500., 400.]) * units.mbar, - np.array([19.85, np.nan, 19.85]) * units.degC, 1000. * units.mbar) + np.array([19.85, 25.6, 19.85]) * units.degC, 1000. * units.mbar) true_temp = np.array([[294.76, 284.64, 272.81, 264.42, 252.91], - [np.nan, np.nan, np.nan, np.nan, np.nan], + [300.35, 291.27, 281.05, 274.05, 264.64], [294.76, 284.64, 272.81, 264.42, 252.91]]) * units.kelvin assert_array_almost_equal(temp, true_temp, 2) @@ -198,6 +199,8 @@ def test_moist_lapse_starting_points(start, direction): assert_almost_equal(temp, truth, 4) +@pytest.mark.xfail(platform.machine() == 'aarch64', + reason='ValueError is not raised on aarch64') @pytest.mark.xfail(sys.platform == 'win32', reason='solve_ivp() does not error on Windows') @pytest.mark.xfail(packaging.version.parse(scipy.__version__) < packaging.version.parse('1.7'), reason='solve_ivp() does not error on Scipy < 1.7') diff --git a/tests/io/test_station_data.py b/tests/io/test_station_data.py index 7e0e9b0d703..a281ad830d2 100644 --- a/tests/io/test_station_data.py +++ b/tests/io/test_station_data.py @@ -23,6 +23,7 @@ def test_add_lat_lon_station_data(): assert_almost_equal(df.loc[df.station == 'KDEN'].longitude.values[0], -104.65) assert_almost_equal(df.loc[df.station == 'PAAA'].latitude.values[0], np.nan) assert_almost_equal(df.loc[df.station == 'PAAA'].longitude.values[0], np.nan) + assert df['longitude'].dtype == np.float64 def test_add_lat_lon_station_data_optional(): diff --git a/tests/plots/baseline/test_colorbar_kwargs.png b/tests/plots/baseline/test_colorbar_kwargs.png new file mode 100644 index 00000000000..800453d73f1 Binary files /dev/null and b/tests/plots/baseline/test_colorbar_kwargs.png differ diff --git a/tests/plots/baseline/test_colorfill_args.png b/tests/plots/baseline/test_colorfill_args.png new file mode 100644 index 00000000000..44c18a47db7 Binary files /dev/null and b/tests/plots/baseline/test_colorfill_args.png differ diff --git a/tests/plots/baseline/test_declarative_contour_convert_units.png b/tests/plots/baseline/test_declarative_contour_convert_units.png index 1f1112a3adb..9674f0bc63d 100644 Binary files a/tests/plots/baseline/test_declarative_contour_convert_units.png and b/tests/plots/baseline/test_declarative_contour_convert_units.png differ diff --git a/tests/plots/baseline/test_declarative_contour_options.png b/tests/plots/baseline/test_declarative_contour_options.png index d98911f174e..9c819ce0f67 100644 Binary files a/tests/plots/baseline/test_declarative_contour_options.png and b/tests/plots/baseline/test_declarative_contour_options.png differ diff --git a/tests/plots/baseline/test_declarative_raster_options.png b/tests/plots/baseline/test_declarative_raster_options.png new file mode 100644 index 00000000000..a441f92a20d Binary files /dev/null and b/tests/plots/baseline/test_declarative_raster_options.png differ diff --git a/tests/plots/baseline/test_declarative_sfc_obs_args.png b/tests/plots/baseline/test_declarative_sfc_obs_args.png new file mode 100644 index 00000000000..0237f1addbd Binary files /dev/null and b/tests/plots/baseline/test_declarative_sfc_obs_args.png differ diff --git a/tests/plots/test_declarative.py b/tests/plots/test_declarative.py index 8a790b3ceff..aa4e871cd45 100644 --- a/tests/plots/test_declarative.py +++ b/tests/plots/test_declarative.py @@ -20,11 +20,11 @@ from metpy.io.metar import parse_metar_file from metpy.plots import (ArrowPlot, BarbPlot, ContourPlot, FilledContourPlot, ImagePlot, MapPanel, PanelContainer, PlotGeometry, PlotObs, RasterPlot) -from metpy.testing import needs_cartopy +from metpy.testing import mpl_version_before, needs_cartopy from metpy.units import units -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.005) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.02) @needs_cartopy def test_declarative_image(): """Test making an image plot.""" @@ -32,7 +32,7 @@ def test_declarative_image(): img = ImagePlot() img.data = data.metpy.parse_cf('IR') - img.colormap = 'Greys_r' + img.mpl_args = {'cmap': 'Greys_r'} panel = MapPanel() panel.title = 'Test' @@ -91,7 +91,7 @@ def test_declarative_four_dims_error(): pc.draw() -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.09) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.092) @needs_cartopy def test_declarative_contour(): """Test making a contour plot.""" @@ -119,7 +119,7 @@ def test_declarative_contour(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=False, tolerance=0.091) +@pytest.mark.mpl_image_compare(remove_text=False, tolerance=0.093) @needs_cartopy def test_declarative_titles(): """Test making a contour plot with multiple titles.""" @@ -150,7 +150,7 @@ def test_declarative_titles(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.066) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.072) @needs_cartopy def test_declarative_smooth_contour(): """Test making a contour plot using smooth_contour.""" @@ -179,7 +179,7 @@ def test_declarative_smooth_contour(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.09) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.12) @needs_cartopy def test_declarative_smooth_contour_calculation(): """Test making a contour plot using smooth_contour.""" @@ -221,7 +221,7 @@ def test_declarative_smooth_contour_calculation(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.01) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.021) @needs_cartopy def test_declarative_smooth_contour_order(): """Test making a contour plot using smooth_contour with tuple.""" @@ -250,7 +250,7 @@ def test_declarative_smooth_contour_order(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.058) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.059) @needs_cartopy def test_declarative_figsize(): """Test having an all float figsize.""" @@ -278,7 +278,7 @@ def test_declarative_figsize(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.029) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.032) @needs_cartopy def test_declarative_smooth_field(): """Test the smoothing of the field with smooth_field trait.""" @@ -334,7 +334,8 @@ def test_declarative_contour_cam(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.03) +@pytest.mark.mpl_image_compare(remove_text=True, + tolerance=3.71 if mpl_version_before('3.8') else 0.026) @needs_cartopy def test_declarative_contour_options(): """Test making a contour plot.""" @@ -364,7 +365,7 @@ def test_declarative_contour_options(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.08) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.082) @needs_cartopy def test_declarative_layers_plot_options(): """Test making a contour plot.""" @@ -376,7 +377,7 @@ def test_declarative_layers_plot_options(): contour.level = 700 * units.hPa contour.contours = 5 contour.linewidth = 1 - contour.linecolor = 'grey' + contour.mpl_args = {'colors': 'grey'} panel = MapPanel() panel.area = 'us' @@ -394,7 +395,8 @@ def test_declarative_layers_plot_options(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.0152) +@pytest.mark.mpl_image_compare(remove_text=True, + tolerance=2.74 if mpl_version_before('3.8') else 0.014) @needs_cartopy def test_declarative_contour_convert_units(): """Test making a contour plot.""" @@ -424,7 +426,7 @@ def test_declarative_contour_convert_units(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.246) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.247) @needs_cartopy def test_declarative_events(): """Test that resetting traitlets properly propagates.""" @@ -468,7 +470,7 @@ def test_declarative_events(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.009) @needs_cartopy def test_declarative_raster_events(): """Test that resetting traitlets properly propagates in RasterPlot().""" @@ -566,7 +568,7 @@ def test_no_field_error_barbs(): barbs.draw() -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.377) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.378) def test_projection_object(ccrs, cfeature): """Test that we can pass a custom map projection.""" data = xr.open_dataset(get_test_data('narr_example.nc', as_file_obj=False)) @@ -589,8 +591,9 @@ def test_projection_object(ccrs, cfeature): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0) -def test_colorfill(cfeature): +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.009) +@needs_cartopy +def test_colorfill(): """Test that we can use ContourFillPlot.""" data = xr.open_dataset(get_test_data('narr_example.nc', as_file_obj=False)) @@ -614,7 +617,7 @@ def test_colorfill(cfeature): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.0062) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.02) def test_colorfill_with_image_range(cfeature): """Test that we can use ContourFillPlot with image_range bounds.""" data = xr.open_dataset(get_test_data('narr_example.nc', as_file_obj=False)) @@ -641,7 +644,7 @@ def test_colorfill_with_image_range(cfeature): @pytest.mark.mpl_image_compare( - remove_text=True, tolerance=0.0062, filename='test_colorfill_with_image_range.png' + remove_text=True, tolerance=0.02, filename='test_colorfill_with_image_range.png' ) def test_colorfill_with_normalize_instance_image_range(cfeature): """Test that we can use ContourFillPlot with image_range bounds.""" @@ -668,8 +671,9 @@ def test_colorfill_with_normalize_instance_image_range(cfeature): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.) -def test_colorfill_horiz_colorbar(cfeature): +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.02) +@needs_cartopy +def test_colorfill_horiz_colorbar(): """Test that we can use ContourFillPlot with a horizontal colorbar.""" data = xr.open_dataset(get_test_data('narr_example.nc', as_file_obj=False)) @@ -693,7 +697,32 @@ def test_colorfill_horiz_colorbar(cfeature): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.0062) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.02) +def test_colorbar_kwargs(cfeature): + """Test that we can use ContourFillPlot with specifying colorbar kwargs.""" + data = xr.open_dataset(get_test_data('narr_example.nc', as_file_obj=False)) + + contour = FilledContourPlot() + contour.data = data + contour.level = 700 * units.hPa + contour.field = 'Temperature' + contour.colormap = 'coolwarm' + contour.colorbar = {'orientation': 'horizontal', 'aspect': 60, 'pad': 0.05} + + panel = MapPanel() + panel.area = (-110, -60, 25, 55) + panel.layers = [] + panel.plots = [contour] + + pc = PanelContainer() + pc.panel = panel + pc.size = (8, 8) + pc.draw() + + return pc.figure + + +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.02) def test_colorfill_no_colorbar(cfeature): """Test that we can use ContourFillPlot with no colorbar.""" data = xr.open_dataset(get_test_data('narr_example.nc', as_file_obj=False)) @@ -771,7 +800,7 @@ def test_latlon(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.292) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.342) @needs_cartopy def test_declarative_barb_options(): """Test making a contour plot.""" @@ -783,7 +812,8 @@ def test_declarative_barb_options(): barb.field = ['u_wind', 'v_wind'] barb.skip = (10, 10) barb.color = 'blue' - barb.pivot = 'tip' + barb.pivot = 'middle' + barb.mpl_args = {'pivot': 'tip'} barb.barblength = 6.5 panel = MapPanel() @@ -812,7 +842,8 @@ def test_declarative_arrowplot(): arrows.field = ['u_wind', 'v_wind'] arrows.skip = (10, 10) arrows.color = 'blue' - arrows.pivot = 'mid' + arrows.pivot = 'tip' + arrows.mpl_args = {'pivot': 'mid'} arrows.arrowscale = 1000 panel = MapPanel() @@ -891,7 +922,7 @@ def test_declarative_arrow_changes(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.491) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.86) @needs_cartopy def test_declarative_barb_earth_relative(): """Test making a contour plot.""" @@ -969,7 +1000,7 @@ def test_declarative_overlay_projections(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.021) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.026) @needs_cartopy def test_declarative_gridded_scale(): """Test making a contour plot.""" @@ -1086,7 +1117,7 @@ def test_declarative_barb_scale(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.667) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.668) @needs_cartopy def test_declarative_barb_gfs_knots(): """Test making a contour plot.""" @@ -1217,7 +1248,7 @@ def test_plotobs_subset_time_window_level(sample_obs): pd.testing.assert_frame_equal(obs.obsdata, truth) -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.016) def test_plotobs_units_with_formatter(ccrs): """Test using PlotObs with a field that both has units and a custom formatter.""" df = pd.read_csv(get_test_data('SFC_obs.csv', as_file_obj=False), @@ -1285,7 +1316,40 @@ def test_declarative_sfc_obs(ccrs): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.025) +def test_declarative_sfc_obs_args(ccrs): + """Test making a surface observation plot with mpl arguments.""" + data = pd.read_csv(get_test_data('SFC_obs.csv', as_file_obj=False), + infer_datetime_format=True, parse_dates=['valid']) + + obs = PlotObs() + obs.data = data + obs.time = datetime(1993, 3, 12, 12) + obs.time_window = timedelta(minutes=15) + obs.level = None + obs.fields = ['tmpf'] + obs.colors = ['black'] + obs.mpl_args = {'fontsize': 12} + + # Panel for plot with Map features + panel = MapPanel() + panel.layout = (1, 1, 1) + panel.projection = ccrs.PlateCarree() + panel.area = 'in' + panel.layers = ['states'] + panel.plots = [obs] + + # Bringing it all together + pc = PanelContainer() + pc.size = (10, 10) + pc.panels = [panel] + + pc.draw() + + return pc.figure + + +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.016) @needs_cartopy def test_declarative_sfc_text(): """Test making a surface observation plot with text.""" @@ -1760,7 +1824,7 @@ def test_declarative_contour_label_fontsize(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.02) @needs_cartopy def test_declarative_raster(): """Test making a raster plot.""" @@ -1786,6 +1850,33 @@ def test_declarative_raster(): return pc.figure +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.02) +@needs_cartopy +def test_declarative_raster_options(): + """Test making a raster plot.""" + data = xr.open_dataset(get_test_data('narr_example.nc', as_file_obj=False)) + + raster = RasterPlot() + raster.data = data + raster.colormap = 'viridis' + raster.field = 'Temperature' + raster.level = 700 * units.hPa + raster.mpl_args = {'alpha': 1, 'cmap': 'coolwarm'} + + panel = MapPanel() + panel.area = 'us' + panel.projection = 'lcc' + panel.layers = ['coastline'] + panel.plots = [raster] + + pc = PanelContainer() + pc.size = (8.0, 8) + pc.panels = [panel] + pc.draw() + + return pc.figure + + @pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.607) @needs_cartopy def test_declarative_region_modifier_zoom_in(): @@ -1946,6 +2037,7 @@ def test_declarative_plot_geometry_polygons(): geo = PlotGeometry() geo.geometry = [slgt_risk_polygon, enh_risk_polygon] geo.stroke = ['#DDAA00', '#FF6600'] + geo.stroke_width = [1] geo.fill = None geo.labels = ['SLGT', 'ENH'] geo.label_facecolor = ['#FFE066', '#FFA366'] @@ -1990,6 +2082,7 @@ def test_declarative_plot_geometry_lines(ccrs): geo.stroke = 'green' geo.labels = ['Irma', '+/- 0.25 deg latitude'] geo.label_facecolor = None + geo.mpl_args = {'linewidth': 1} # Place plot in a panel and container panel = MapPanel() diff --git a/tests/plots/test_mapping.py b/tests/plots/test_mapping.py index 1f476c4291b..8992112b790 100644 --- a/tests/plots/test_mapping.py +++ b/tests/plots/test_mapping.py @@ -241,6 +241,22 @@ def test_polar_stereographic_std_parallel(): assert crs.proj4_params['lat_ts'] == 60 +def test_rotated_latitude_longitude(): + """Test handling a rotated latitude longitude projection.""" + attrs = { + 'grid_mapping_name': 'rotated_latitude_longitude', + 'grid_north_pole_latitude': 36, + 'grid_north_pole_longitude': 65, + 'north_pole_grid_longitude': 0.0, + } + crs = CFProjection(attrs).to_cartopy() + + assert isinstance(crs, ccrs.RotatedPole) + assert crs.proj4_params['o_lon_p'] == 0 + assert crs.proj4_params['o_lat_p'] == 36 + assert crs.proj4_params['lon_0'] == 180 + 65 + + def test_lat_lon(): """Test handling basic lat/lon projection.""" attrs = {'grid_mapping_name': 'latitude_longitude'} diff --git a/tests/plots/test_plot_areas.py b/tests/plots/test_plot_areas.py index 662831c3d6a..6328bcb88a3 100644 --- a/tests/plots/test_plot_areas.py +++ b/tests/plots/test_plot_areas.py @@ -7,7 +7,7 @@ import pytest -@pytest.mark.mpl_image_compare(tolerance=0.005) +@pytest.mark.mpl_image_compare(tolerance=0.023) def test_uslcc_plotting(ccrs, cfeature): """Test plotting the uslcc area with projection.""" from metpy.plots import named_areas @@ -27,7 +27,7 @@ def test_uslcc_plotting(ccrs, cfeature): return fig -@pytest.mark.mpl_image_compare(tolerance=0.005) +@pytest.mark.mpl_image_compare(tolerance=0.016) def test_au_plotting(ccrs, cfeature): """Test plotting the au area with projection.""" from metpy.plots import named_areas @@ -47,7 +47,7 @@ def test_au_plotting(ccrs, cfeature): return fig -@pytest.mark.mpl_image_compare(tolerance=0.008) +@pytest.mark.mpl_image_compare(tolerance=0.017) def test_cn_plotting(ccrs, cfeature): """Test plotting the cn area with projection.""" from metpy.plots import named_areas @@ -67,7 +67,7 @@ def test_cn_plotting(ccrs, cfeature): return fig -@pytest.mark.mpl_image_compare(tolerance=0.005) +@pytest.mark.mpl_image_compare(tolerance=0.016) def test_hi_plotting(ccrs, cfeature): """Test plotting the hi area with projection.""" from metpy.plots import named_areas @@ -87,7 +87,7 @@ def test_hi_plotting(ccrs, cfeature): return fig -@pytest.mark.mpl_image_compare(tolerance=0.005) +@pytest.mark.mpl_image_compare(tolerance=0.016) def test_wpac_plotting(ccrs, cfeature): """Test plotting the wpac area with projection.""" from metpy.plots import named_areas diff --git a/tests/plots/test_skewt.py b/tests/plots/test_skewt.py index 1fa5cad4bd6..ab5d5c14933 100644 --- a/tests/plots/test_skewt.py +++ b/tests/plots/test_skewt.py @@ -11,10 +11,9 @@ import pytest from metpy.plots import Hodograph, SkewT +from metpy.testing import mpl_version_before, mpl_version_equal from metpy.units import units -MPL_VERSION = matplotlib.__version__[:5] - @pytest.mark.mpl_image_compare(remove_text=True, style='default', tolerance=0.069) def test_skewt_api(): @@ -86,7 +85,7 @@ def test_skewt_default_aspect_empty(): @pytest.mark.mpl_image_compare(tolerance=0., remove_text=True, style='default') def test_skewt_mixing_line_args(): """Test plot_mixing_lines accepting kwargs for mixing ratio and pressure levels.""" - # Explicitly pass default values as kwargs the, should recreate NWS SkewT PDF as above + # Explicitly pass default values as kwargs, should recreate NWS SkewT PDF as above fig = plt.figure(figsize=(12, 9)) skew = SkewT(fig, rotation=43) mlines = np.array([0.0004, 0.001, 0.002, 0.004, 0.007, 0.01, 0.016, 0.024, 0.032]) @@ -156,8 +155,8 @@ def test_skewt_units(): skew.ax.axvline(-10, color='orange') # On Matplotlib <= 3.6, ax[hv]line() doesn't trigger unit labels - assert skew.ax.get_xlabel() == ('degree_Celsius' if MPL_VERSION == '3.7.0' else '') - assert skew.ax.get_ylabel() == ('hectopascal' if MPL_VERSION == '3.7.0' else '') + assert skew.ax.get_xlabel() == ('degree_Celsius' if mpl_version_equal('3.7.0') else '') + assert skew.ax.get_ylabel() == ('hectopascal' if mpl_version_equal('3.7.0') else '') # Clear them for the image test skew.ax.set_xlabel('') @@ -320,7 +319,7 @@ def test_hodograph_api(): @pytest.mark.mpl_image_compare(remove_text=True, - tolerance=0.6 if MPL_VERSION.startswith('3.3') else 0.) + tolerance=0.6 if mpl_version_before('3.5') else 0.) def test_hodograph_units(): """Test passing quantities to Hodograph.""" fig = plt.figure(figsize=(9, 9)) diff --git a/tests/plots/test_station_plot.py b/tests/plots/test_station_plot.py index 30c16f074e6..0a784207aa4 100644 --- a/tests/plots/test_station_plot.py +++ b/tests/plots/test_station_plot.py @@ -291,7 +291,7 @@ def test_barb_projection(wind_plot, ccrs): return fig -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.01) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.023) def test_arrow_projection(wind_plot, ccrs): """Test that arrows are properly projected.""" u, v, x, y = wind_plot diff --git a/tests/plots/test_util.py b/tests/plots/test_util.py index 3dab3dcd868..2f973c4f0d1 100644 --- a/tests/plots/test_util.py +++ b/tests/plots/test_util.py @@ -5,16 +5,13 @@ from datetime import datetime -import matplotlib import matplotlib.pyplot as plt import numpy as np import pytest import xarray as xr from metpy.plots import add_metpy_logo, add_timestamp, add_unidata_logo, convert_gempak_color -from metpy.testing import get_test_data - -MPL_VERSION = matplotlib.__version__[:3] +from metpy.testing import get_test_data, mpl_version_before @pytest.mark.mpl_image_compare(tolerance=2.638, remove_text=True) @@ -94,7 +91,7 @@ def test_add_logo_invalid_size(): add_metpy_logo(fig, size='jumbo') -@pytest.mark.mpl_image_compare(tolerance={'3.3': 1.072}.get(MPL_VERSION, 0), +@pytest.mark.mpl_image_compare(tolerance=1.072 if mpl_version_before('3.5') else 0, remove_text=True) def test_gempak_color_image_compare(): """Test creating a plot with all the GEMPAK colors.""" @@ -114,7 +111,7 @@ def test_gempak_color_image_compare(): return fig -@pytest.mark.mpl_image_compare(tolerance={'3.3': 1.215}.get(MPL_VERSION, 0), +@pytest.mark.mpl_image_compare(tolerance=1.215 if mpl_version_before('3.5') else 0, remove_text=True) def test_gempak_color_xw_image_compare(): """Test creating a plot with all the GEMPAK colors using xw style.""" diff --git a/tools/nexrad_msgs/parse_spec.py b/tools/nexrad_msgs/parse_spec.py index ed9d5219f3c..87034f998b1 100644 --- a/tools/nexrad_msgs/parse_spec.py +++ b/tools/nexrad_msgs/parse_spec.py @@ -103,8 +103,7 @@ def fix_type(typ, size, additional=None): matches = t(typ) if callable(t) else t == typ if matches: fmt_str, true_size = info(size) if callable(info) else info - assert size == true_size, ('{}: Got size {} instead of {}'.format(typ, size, - true_size)) + assert size == true_size, (f'{typ}: Got size {size} instead of {true_size}') return fmt_str.format(size=size) raise ValueError(f'No type match! ({typ})') diff --git a/tutorials/unit_tutorial.py b/tutorials/unit_tutorial.py index 898db572234..2fd4ed8f56f 100644 --- a/tutorials/unit_tutorial.py +++ b/tutorials/unit_tutorial.py @@ -81,6 +81,26 @@ # operation can be performed: print(3 * units.inch + 5 * units.cm) +######################################################################### +# ``pint`` by default will print full unit names for :class:`~pint.Quantity`. +print(f'{20 * units.meter ** 2}') + +######################################################################### +# This can be reduced to symbolic by specifying a compact (~) formatter: +print(f'{20 * units.meter ** 2:~}') + +######################################################################### +# A compact (~), pretty (P) formatter: +print(f'{20 * units.meter ** 2:~P}') + +######################################################################### +# Place formatters following other print specifications: +print(f'{20 * units.meter ** 2:0.3f~P}') + +######################################################################### +# Other string formatting options are available, see the `Pint string formatting specification +# `_. + ######################################################################### # Converting Units # ----------------