diff --git a/.github/actions/install-pypi/action.yml b/.github/actions/install-pypi/action.yml index 54cd9fe4b05..cc687e8dd39 100644 --- a/.github/actions/install-pypi/action.yml +++ b/.github/actions/install-pypi/action.yml @@ -15,6 +15,10 @@ inputs: python-version: description: 'What version of Python to use' required: true + old-build: + description: 'Whether to enable old builds for shapely and cartopy' + required: false + default: 'false' runs: using: composite @@ -30,13 +34,13 @@ runs: 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-extras == 'true' }} + - name: Install CartoPy/Shapely build dependencies + if: ${{ inputs.old-build == 'true' }} shell: bash run: sudo apt-get install libgeos-dev - name: Disable Shapely Wheels - if: ${{ inputs.need-extras == 'true' }} + if: ${{ inputs.old-build == 'true' }} shell: bash run: echo "PIP_NO_BINARY=shapely" >> $GITHUB_ENV @@ -57,6 +61,7 @@ runs: -c ci/${{ inputs.version-file }} -c ci/${{ inputs.type }}_requirements.txt -c ci/extra_requirements.txt - name: Install additional test tools + if: ${{ inputs.type == 'test' }} shell: bash run: > python -m pip install coverage diff --git a/.github/workflows/assign-milestone.yml b/.github/workflows/assign-milestone.yml index cb00d636c17..4dba85d1af9 100644 --- a/.github/workflows/assign-milestone.yml +++ b/.github/workflows/assign-milestone.yml @@ -15,7 +15,7 @@ jobs: name: Assign Latest Milestone runs-on: ubuntu-latest steps: - - uses: actions/github-script@v6 + - uses: actions/github-script@v7 name: Run script with: script: | diff --git a/.github/workflows/backport-prs.yml b/.github/workflows/backport-prs.yml index f93bfbc9b70..e93b71bea97 100644 --- a/.github/workflows/backport-prs.yml +++ b/.github/workflows/backport-prs.yml @@ -59,7 +59,7 @@ jobs: labels: backport - name: Comment on completion - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: script: | github.rest.issues.createComment({ @@ -71,7 +71,7 @@ jobs: - name: Comment on error if: failure() - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: script: | const workflow_url = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; diff --git a/.github/workflows/docs-conda.yml b/.github/workflows/docs-conda.yml index 2cfe2dff471..469245dc9e4 100644 --- a/.github/workflows/docs-conda.yml +++ b/.github/workflows/docs-conda.yml @@ -28,6 +28,8 @@ jobs: os: Windows - python-version: 3.11 os: macOS + - python-version: 3.12 + os: macOS steps: - name: Checkout source diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a9a95b0ccfe..946e8925f3c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -22,21 +22,16 @@ jobs: # Build our docs on Linux against multiple Pythons # Docs: - name: ${{ matrix.python-version }} ${{ matrix.dep-versions }} + name: "Linux ${{ matrix.python-version }}" runs-on: ubuntu-latest strategy: fail-fast: false matrix: + python-version: [3.9, '3.10', 3.11] + check-links: [false] include: - - python-version: 3.9 - check-links: false - dep-versions: requirements.txt - - python-version: '3.10' - check-links: false - dep-versions: requirements.txt - - python-version: 3.11 + - python-version: 3.12 check-links: true - dep-versions: requirements.txt outputs: doc-version: ${{ steps.build-docs.outputs.doc-version }} @@ -52,6 +47,7 @@ jobs: with: type: 'doc' python-version: ${{ matrix.python-version }} + need-extras: true - name: Build docs id: build-docs diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index bf8e1b8eebd..1d66af844fa 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -39,7 +39,7 @@ jobs: REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -o pipefail - ruff check . | reviewdog -f=pep8 -name=flake8 -reporter=github-check -filter-mode=nofilter + ruff check . | reviewdog -f=pep8 -name=ruff -reporter=github-check -filter-mode=nofilter - name: Run flake8 env: diff --git a/.github/workflows/nightly-builds.yml b/.github/workflows/nightly-builds.yml index ec0fa569a2d..6b361e6e26c 100644 --- a/.github/workflows/nightly-builds.yml +++ b/.github/workflows/nightly-builds.yml @@ -14,9 +14,11 @@ on: - main paths: - .github/workflows/nightly-builds.yml + - .github/workflows/unstable-builds.yml pull_request: paths: - .github/workflows/nightly-builds.yml + - .github/workflows/unstable-builds.yml jobs: Builds: @@ -42,7 +44,7 @@ jobs: touch tests-nightly.log build.log linkchecker.log - name: Report failures - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: script: | const fs = require('fs'); diff --git a/.github/workflows/tests-conda.yml b/.github/workflows/tests-conda.yml index 781521a0a15..bf6d1e50098 100644 --- a/.github/workflows/tests-conda.yml +++ b/.github/workflows/tests-conda.yml @@ -28,8 +28,13 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.9, '3.10', 3.11] + python-version: [3.9, 3.12] os: [macOS, Windows] + include: + - python-version: '3.10' + os: macOS + - python-version: 3.11 + os: Windows steps: - name: Checkout source @@ -66,3 +71,4 @@ jobs: uses: codecov/codecov-action@v3 with: name: ${{ github.workflow }} + fail_ci_if_error: true diff --git a/.github/workflows/tests-pypi.yml b/.github/workflows/tests-pypi.yml index fb9ca741eff..d9c5dab440d 100644 --- a/.github/workflows/tests-pypi.yml +++ b/.github/workflows/tests-pypi.yml @@ -25,7 +25,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [3.9, '3.10', 3.11] + python-version: [3.9, '3.10', 3.11, 3.12] dep-versions: [Latest] no-extras: [''] include: @@ -34,7 +34,8 @@ jobs: - python-version: 3.9 dep-versions: Minimum no-extras: 'No Extras' - - python-version: 3.11 + - python-version: 3.12 + dep-versions: Latest no-extras: 'No Extras' steps: @@ -71,6 +72,7 @@ jobs: need-extras: ${{ matrix.no-extras != 'No Extras' }} type: 'test' python-version: ${{ matrix.python-version }} + old-build: ${{ matrix.no-extras != 'No Extras' && matrix.dep-versions == 'Minimum' }} - name: Run tests uses: ./.github/actions/run-tests @@ -94,3 +96,4 @@ jobs: uses: codecov/codecov-action@v3 with: name: ${{ github.workflow }} + fail_ci_if_error: true diff --git a/.github/workflows/unstable-builds.yml b/.github/workflows/unstable-builds.yml index c10e65da733..ef67567e6c5 100644 --- a/.github/workflows/unstable-builds.yml +++ b/.github/workflows/unstable-builds.yml @@ -33,7 +33,7 @@ jobs: need-extras: true type: test version-file: Prerelease - python-version: 3.11 + python-version: 3.12 - name: Run tests id: tests @@ -72,7 +72,7 @@ jobs: with: type: doc version-file: Prerelease - python-version: 3.11 + python-version: 3.12 - name: Build docs id: build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45bc1d5703c..18af2e44827 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -337,7 +337,7 @@ While the authors are no fans of blind adherence to style and so-called project that go through and correct code style, MetPy has adopted this style from the outset. Therefore, it makes sense to enforce this style as code is added to keep everything clean and uniform. To this end, part of the automated testing for MetPy checks style. To check style -locally within the source directory you can use the [ruff](https://beta.ruff.rs/docs/) and +locally within the source directory you can use the [ruff](https://docs.astral.sh/ruff/) and [flake8](https://flake8.pycqa.org/en/latest/) tools. After setting up your [development environment](#setting-up-your-development-environment) above, from within the ``metpy`` directory with your ``devel`` environment active, diff --git a/ci/doc_requirements.txt b/ci/doc_requirements.txt index 805c550a34f..ff6adcb5e82 100644 --- a/ci/doc_requirements.txt +++ b/ci/doc_requirements.txt @@ -4,5 +4,5 @@ sphinx-design==0.5.0 sphinx-gallery==0.14.0 myst-parser==2.0.0 netCDF4==1.6.5 -geopandas==0.14.0 +geopandas==0.14.1 rtree==1.1.0 diff --git a/ci/extra_requirements.txt b/ci/extra_requirements.txt index 1e3366ee16c..0179a0292ca 100644 --- a/ci/extra_requirements.txt +++ b/ci/extra_requirements.txt @@ -1,3 +1,3 @@ cartopy==0.22.0 -dask==2023.10.1 +dask==2023.11.0 shapely==2.0.2 diff --git a/ci/linting_requirements.txt b/ci/linting_requirements.txt index 9c5b6dd2784..742839a5459 100644 --- a/ci/linting_requirements.txt +++ b/ci/linting_requirements.txt @@ -1,4 +1,4 @@ -ruff==0.1.3 +ruff==0.1.5 flake8==6.1.0 pycodestyle==2.11.1 @@ -6,9 +6,9 @@ pyflakes==3.1.0 flake8-continuation==1.0.5 flake8-copyright==0.2.4 -flake8-isort==6.1.0 +flake8-isort==6.1.1 isort==5.12.0 -flake8-requirements==1.7.8 +flake8-requirements==2.0.1 flake8-rst-docstrings==0.3.0 diff --git a/ci/requirements.txt b/ci/requirements.txt index b18fbab7e4b..3ab6d743b53 100644 --- a/ci/requirements.txt +++ b/ci/requirements.txt @@ -1,6 +1,6 @@ -matplotlib==3.8.0 +matplotlib==3.8.1 numpy==1.26.0 -pandas==2.1.2 +pandas==2.1.3 pooch==1.8.0 pint==0.22 pyproj==3.6.1 diff --git a/docs/_templates/overrides/metpy.calc.rst b/docs/_templates/overrides/metpy.calc.rst index c227f4209be..f7a78e3ba58 100644 --- a/docs/_templates/overrides/metpy.calc.rst +++ b/docs/_templates/overrides/metpy.calc.rst @@ -76,6 +76,7 @@ Soundings ccl critical_angle cross_totals + downdraft_cape el k_index lcl diff --git a/docs/api/references.rst b/docs/api/references.rst index 75d4ed95849..7191be729fa 100644 --- a/docs/api/references.rst +++ b/docs/api/references.rst @@ -64,6 +64,8 @@ References Parameters in Forecasting Severe Storms `_. *Electronic J. Severe Storms Meteor.*, **1** (3), 1-22. +.. [Emanuel1994] Emanuel, K. A., 1994: Atmospheric Convection. Oxford University Press, 592 pp. + .. [Esterheld2008] Esterheld, J. M. and D. J. Giuliano, 2008: `Discriminating between Tornadic and Non-Tornadic Supercells: A New Hodograph Technique `_. *Electronic J. Severe Storms Meteor.*, **3** (2), 1-50. @@ -204,11 +206,10 @@ References .. [WMO8] WMO, 2020: Guide to Meteorological Instruments and Methods of Observation, Volume 1: Measurement of Meteorological Variables. - `WMO No.8 `_. + `WMO No.8 `_. .. [WMO306] WMO, 2011: Manual on Codes - International Codes, Volume I.1, Annex II to the WMO - Technical Regulations: Part A - Alphanumeric Codes. `WMO No.306 - `_. + Technical Regulations: Part A - Alphanumeric Codes. `WMO No.306 `_. -.. [WMO1966] WMO, 1966: International Meteorological Tables, `WMO-No. 188.TP.94 - `_. +.. [WMO1966] WMO, 1966: International Meteorological Tables, + `WMO-No. 188.TP.94 `_. diff --git a/docs/conf.py b/docs/conf.py index e8de320fa84..87493d09c4d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -61,8 +61,9 @@ 'reset_modules': [lambda conf, fname: sys.modules.pop('pint', None)] } -# By default, only generate all the areas when running in CI -metpy_generate_all_areas = 'GITHUB_ACTIONS' in os.environ +# By default, only generate all the areas when running on a release CI job +metpy_generate_all_areas = (not os.environ.get('GITHUB_REF', '').startswith('refs/pull') + and sys.version_info < (3, 12)) # Turn off code and image links for embedded mpl plots plot_html_show_source_link = False @@ -431,7 +432,9 @@ r'https://doi\.org/10\.1289/ehp\.1206273', # Couldn't fix these 403's with user agents r'https://doi\.org/10\.1029/2010GL045777', - r'https://doi\.org/10\.1098/rspa\.2004\.1430' + r'https://doi\.org/10\.1098/rspa\.2004\.1430', + # Currently giving certificate errors on GitHub + r'https://library.wmo.int/.*' ] # Dictionary of URL redirects allowed @@ -440,7 +443,8 @@ r'https://conda.io/docs/': r'https://conda.io/en/latest/', r'https://github.com/Unidata/MetPy/issues/new/choose': r'https://github.com/login.*choose', r'https://doi.org/.*': r'https://.*', - r'https://gitter.im/Unidata/MetPy': r'https://app.gitter.im/.*MetPy.*' + r'https://gitter.im/Unidata/MetPy': r'https://app.gitter.im/.*MetPy.*', + r'https://library.wmo.int/idurl/.*': r'https://library.wmo.int/.*' } # Domain-specific HTTP headers for requests diff --git a/docs/make_areas.py b/docs/make_areas.py index 5d565409b24..3df13db1921 100644 --- a/docs/make_areas.py +++ b/docs/make_areas.py @@ -52,6 +52,7 @@ def generate_area_file(app): if area in states_provinces: code = textwrap.dedent(f""" .. plot:: + :context: reset import matplotlib.pyplot as plt import cartopy.crs as ccrs @@ -75,6 +76,7 @@ def generate_area_file(app): else: code = textwrap.dedent(f""" .. plot:: + :context: reset import matplotlib.pyplot as plt import cartopy.crs as ccrs diff --git a/pyproject.toml b/pyproject.toml index f6b032aaee1..ba816b8c960 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Scientific/Engineering", "Topic :: Scientific/Engineering :: Atmospheric Science", "Intended Audience :: Science/Research", @@ -101,12 +102,15 @@ markers = "xfail_dask: marks tests as expected to fail with Dask arrays" norecursedirs = "build docs .idea" doctest_optionflags = "NORMALIZE_WHITESPACE" mpl-results-path = "test_output" +xfail_strict = true [tool.ruff] line-length = 95 exclude = ["docs", "build", "src/metpy/io/_metar_parser/metar_parser.py"] 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"] +preview = true +explicit-preview-rules = true [tool.ruff.per-file-ignores] "ci/filter_links.py" = ["E731", "T201", "S603", "S607"] diff --git a/src/metpy/calc/thermo.py b/src/metpy/calc/thermo.py index 75fadfe55f5..fa172978020 100644 --- a/src/metpy/calc/thermo.py +++ b/src/metpy/calc/thermo.py @@ -2,6 +2,8 @@ # Distributed under the terms of the BSD 3-Clause License. # SPDX-License-Identifier: BSD-3-Clause """Contains a collection of thermodynamic calculations.""" +from inspect import Parameter, Signature, signature + import numpy as np import scipy.integrate as si import scipy.optimize as so @@ -55,7 +57,7 @@ def relative_humidity_from_dewpoint(temperature, dewpoint): Notes ----- - .. math:: rh = \frac{e(T_d)}{e_s(T)} + .. math:: RH = \frac{e(T_d)}{e_s(T)} .. versionchanged:: 1.0 Renamed ``dewpt`` parameter to ``dewpoint`` @@ -63,7 +65,7 @@ def relative_humidity_from_dewpoint(temperature, dewpoint): """ e = saturation_vapor_pressure(dewpoint) e_s = saturation_vapor_pressure(temperature) - return (e / e_s) + return e / e_s @exporter.export @@ -2026,7 +2028,7 @@ def mixing_ratio_from_relative_humidity(pressure, temperature, relative_humidity >>> T = 28.1 * units.degC >>> rh = .65 >>> mixing_ratio_from_relative_humidity(p, T, rh).to('g/kg') - + See Also -------- @@ -2034,20 +2036,25 @@ def mixing_ratio_from_relative_humidity(pressure, temperature, relative_humidity Notes ----- - Formula adapted from [Hobbs1977]_ pg. 74. + Employs [WMO8]_ eq. 4.A.16 as derived from WMO relative humidity definition based on + vapor partial pressures (eq. 4.A.15). - .. math:: w = (rh)(w_s) + .. math:: RH = \frac{w}{\epsilon + w} \frac{\epsilon + w_s}{w_s} + .. math:: \therefore w = \frac{\epsilon * w_s * RH}{\epsilon + w_s (1 - RH)} * :math:`w` is mixing ratio - * :math:`rh` is relative humidity as a unitless ratio * :math:`w_s` is the saturation mixing ratio + * :math:`\epsilon` is the molecular weight ratio of vapor to dry air + * :math:`RH` is relative humidity as a unitless ratio + .. versionchanged:: 1.0 Changed signature from ``(relative_humidity, temperature, pressure)`` """ - return (relative_humidity - * saturation_mixing_ratio(pressure, temperature)).to('dimensionless') + w_s = saturation_mixing_ratio(pressure, temperature) + return (mpconsts.nounit.epsilon * w_s * relative_humidity + / (mpconsts.nounit.epsilon + w_s * (1 - relative_humidity))).to('dimensionless') @exporter.export @@ -2081,7 +2088,7 @@ def relative_humidity_from_mixing_ratio(pressure, temperature, mixing_ratio): >>> from metpy.units import units >>> relative_humidity_from_mixing_ratio(1013.25 * units.hPa, ... 30 * units.degC, 18/1000).to('percent') - + See Also -------- @@ -2089,19 +2096,23 @@ def relative_humidity_from_mixing_ratio(pressure, temperature, mixing_ratio): Notes ----- - Formula based on that from [Hobbs1977]_ pg. 74. + Employs [WMO8]_ eq. 4.A.16 as derived from WMO relative humidity definition based on + vapor partial pressures (eq. 4.A.15). - .. math:: rh = \frac{w}{w_s} + .. math:: RH = \frac{w}{\epsilon + w} \frac{\epsilon + w_s}{w_s} - * :math:`rh` is relative humidity as a unitless ratio * :math:`w` is mixing ratio * :math:`w_s` is the saturation mixing ratio + * :math:`\epsilon` is the molecular weight ratio of vapor to dry air + * :math:`RH` is relative humidity as a unitless ratio .. versionchanged:: 1.0 Changed signature from ``(mixing_ratio, temperature, pressure)`` """ - return mixing_ratio / saturation_mixing_ratio(pressure, temperature) + w_s = saturation_mixing_ratio(pressure, temperature) + return (mixing_ratio / (mpconsts.nounit.epsilon + mixing_ratio) + * (mpconsts.nounit.epsilon + w_s) / w_s) @exporter.export @@ -2217,7 +2228,7 @@ def relative_humidity_from_specific_humidity(pressure, temperature, specific_hum >>> from metpy.units import units >>> relative_humidity_from_specific_humidity(1013.25 * units.hPa, ... 30 * units.degC, 18/1000).to('percent') - + See Also -------- @@ -2225,20 +2236,25 @@ def relative_humidity_from_specific_humidity(pressure, temperature, specific_hum Notes ----- - Formula based on that from [Hobbs1977]_ pg. 74. and [Salby1996]_ pg. 118. + Employs [WMO8]_ eq. 4.A.16 as derived from WMO relative humidity definition based on + vapor partial pressures (eq. 4.A.15). - .. math:: RH = \frac{q}{(1-q)w_s} + .. math:: RH = \frac{w}{\epsilon + w} \frac{\epsilon + w_s}{w_s} - * :math:`RH` is relative humidity as a unitless ratio - * :math:`q` is specific humidity + given :math: w = \frac{q}{1-q} + + * :math:`w` is mixing ratio * :math:`w_s` is the saturation mixing ratio + * :math:`q` is the specific humidity + * :math:`\epsilon` is the molecular weight ratio of vapor to dry air + * :math:`RH` is relative humidity as a unitless ratio .. versionchanged:: 1.0 Changed signature from ``(specific_humidity, temperature, pressure)`` """ - return (mixing_ratio_from_specific_humidity(specific_humidity) - / saturation_mixing_ratio(pressure, temperature)) + return relative_humidity_from_mixing_ratio( + pressure, temperature, mixing_ratio_from_specific_humidity(specific_humidity)) @exporter.export @@ -3026,6 +3042,133 @@ def mixed_layer_cape_cin(pressure, temperature, dewpoint, **kwargs): return cape_cin(p, t, td, ml_profile) +@exporter.export +@preprocess_and_wrap() +def downdraft_cape(pressure, temperature, dewpoint): + r"""Calculate downward CAPE (DCAPE). + + Calculate the downward convective available potential energy (DCAPE) of a given upper air + profile. Downward CAPE is the maximum negative buoyancy energy available to a descending + parcel. Parcel descent is assumed to begin from the lowest equivalent potential temperature + between 700 and 500 hPa. This parcel is lowered moist adiabatically from the environmental + wet bulb temperature to the surface. This assumes the parcel remains saturated + throughout the descent. + + Parameters + ---------- + pressure : `pint.Quantity` + Pressure profile + + temperature : `pint.Quantity` + Temperature profile + + dewpoint : `pint.Quantity` + Dewpoint profile + + Returns + ------- + dcape: `pint.Quantity` + Downward Convective Available Potential Energy (DCAPE) + down_pressure: `pint.Quantity` + Pressure levels of the descending parcel + down_parcel_trace: `pint.Quantity` + Temperatures of the descending parcel + + Examples + -------- + >>> from metpy.calc import dewpoint_from_relative_humidity, downdraft_cape + >>> from metpy.units import units + >>> # pressure + >>> p = [1008., 1000., 950., 900., 850., 800., 750., 700., 650., 600., + ... 550., 500., 450., 400., 350., 300., 250., 200., + ... 175., 150., 125., 100., 80., 70., 60., 50., + ... 40., 30., 25., 20.] * units.hPa + >>> # temperature + >>> T = [29.3, 28.1, 25.5, 20.9, 18.4, 15.9, 13.1, 10.1, 6.7, 3.1, + ... -0.5, -4.5, -9.0, -14.8, -21.5, -29.7, -40.0, -52.4, + ... -59.2, -66.5, -74.1, -78.5, -76.0, -71.6, -66.7, -61.3, + ... -56.3, -51.7, -50.7, -47.5] * units.degC + >>> # relative humidity + >>> rh = [.85, .75, .56, .39, .82, .72, .75, .86, .65, .22, .52, + ... .66, .64, .20, .05, .75, .76, .45, .25, .48, .76, .88, + ... .56, .88, .39, .67, .15, .04, .94, .35] * units.dimensionless + >>> # calculate dewpoint + >>> Td = dewpoint_from_relative_humidity(T, rh) + >>> downdraft_cape(p, T, Td) + (, , ) + + See Also + -------- + cape_cin, surface_based_cape_cin, most_unstable_cape_cin, mixed_layer_cape_cin + + Notes + ----- + Formula adopted from [Emanuel1994]_. + + .. math:: \text{DCAPE} = -R_d \int_{SFC}^{p_\text{top}} + (T_{{v}_{env}} - T_{{v}_{parcel}}) d\text{ln}(p) + + + * :math:`DCAPE` is downward convective available potential energy + * :math:`SFC` is the level of the surface or beginning of parcel path + * :math:`p_\text{top}` is pressure of the start of descent path + * :math:`R_d` is the gas constant + * :math:`T_{{v}_{env}}` is environment virtual temperature + * :math:`T_{{v}_{parcel}}` is the parcel virtual temperature + * :math:`p` is atmospheric pressure + + Only functions on 1D profiles (not higher-dimension vertical cross sections or grids). + Since this function returns scalar values when given a profile, this will return Pint + Quantities even when given xarray DataArray profiles. + + + + """ + pressure, temperature, dewpoint = _remove_nans(pressure, temperature, dewpoint) + if not len(pressure) == len(temperature) == len(dewpoint): + raise ValueError('Provided pressure, temperature,' + 'and dewpoint must be the same length') + + # Get layer between 500 and 700 hPa + p_layer, t_layer, td_layer = get_layer(pressure, temperature, dewpoint, + bottom=700 * units.hPa, + depth=200 * units.hPa, interpolate=True) + theta_e = equivalent_potential_temperature(p_layer, t_layer, td_layer) + + # Find parcel with minimum thetae in the layer + min_idx = np.argmin(theta_e) + parcel_start_p = p_layer[min_idx] + + parcel_start_td = td_layer[min_idx] + parcel_start_wb = wet_bulb_temperature(parcel_start_p, t_layer[min_idx], parcel_start_td) + + # Descend parcel moist adiabatically to surface + down_pressure = pressure[pressure >= parcel_start_p].to(units.hPa) + down_parcel_trace = moist_lapse(down_pressure, parcel_start_wb, + reference_pressure=parcel_start_p) + + # Find virtual temperature of parcel and environment + parcel_virt_temp = virtual_temperature_from_dewpoint(down_pressure, down_parcel_trace, + down_parcel_trace) + env_virt_temp = virtual_temperature_from_dewpoint(down_pressure, + temperature[pressure >= parcel_start_p], + dewpoint[pressure >= parcel_start_p]) + + # calculate differences (remove units for NumPy) + diff = (env_virt_temp - parcel_virt_temp).to(units.degK).magnitude + lnp = np.log(down_pressure.magnitude) + + # Find DCAPE + dcape = -(mpconsts.Rd + * units.Quantity(np.trapz(diff, lnp), 'K') + ).to(units('J/kg')) + + return dcape, down_pressure, down_parcel_trace + + @exporter.export @preprocess_and_wrap() @check_units('[pressure]', '[temperature]', '[temperature]') @@ -3544,7 +3687,7 @@ def thickness_hydrostatic_from_relative_humidity(pressure, temperature, relative >>> thickness_hydrostatic_from_relative_humidity(p[ip1000_500], ... T[ip1000_500], ... rh[ip1000_500]) - + See Also -------- @@ -3857,21 +4000,16 @@ def static_stability(pressure, temperature, vertical_dim=0): @exporter.export -@preprocess_and_wrap( - wrap_like='temperature', - broadcast=('pressure', 'temperature', 'specific_humidity') -) -@check_units('[pressure]', '[temperature]', '[dimensionless]') -def dewpoint_from_specific_humidity(pressure, temperature, specific_humidity): - r"""Calculate the dewpoint from specific humidity, temperature, and pressure. +def dewpoint_from_specific_humidity(*args, **kwargs): + r"""Calculate the dewpoint from specific humidity and pressure. Parameters ---------- pressure: `pint.Quantity` Total atmospheric pressure - temperature: `pint.Quantity` - Air temperature + temperature: `pint.Quantity`, optional + Air temperature. Unused in calculation, pending deprecation specific_humidity: `pint.Quantity` Specific humidity of air @@ -3885,20 +4023,87 @@ def dewpoint_from_specific_humidity(pressure, temperature, specific_humidity): -------- >>> from metpy.calc import dewpoint_from_specific_humidity >>> from metpy.units import units - >>> dewpoint_from_specific_humidity(1000 * units.hPa, 10 * units.degC, 5 * units('g/kg')) - + >>> dewpoint_from_specific_humidity(1000 * units.hPa, 5 * units('g/kg')) + + + .. versionchanged:: 1.6 + Made `temperature` arg optional, to be deprecated .. versionchanged:: 1.0 Changed signature from ``(specific_humidity, temperature, pressure)`` See Also -------- - relative_humidity_from_mixing_ratio, dewpoint_from_relative_humidity + relative_humidity_from_mixing_ratio, dewpoint_from_relative_humidity, dewpoint + + Notes + ----- + Employs [WMO8]_ eq 4.A.6, + + .. math:: e = \frac{w}{\epsilon + w} p + + with + .. math:: w = \frac{q}{1-q} + + * :math:`q` is specific humidity + * :math:`w` is mixing ratio + * :math:`\epsilon` is the molecular weight ratio of vapor to dry air + + to calculate vapor partial pressure :math:`e` for dewpoint calculation input. See + :func:`~dewpoint` for additional information. """ - return dewpoint_from_relative_humidity(temperature, - relative_humidity_from_specific_humidity( - pressure, temperature, specific_humidity)) + sig = Signature([Parameter('pressure', Parameter.POSITIONAL_OR_KEYWORD), + Parameter('temperature', Parameter.POSITIONAL_OR_KEYWORD), + Parameter('specific_humidity', Parameter.POSITIONAL_OR_KEYWORD)]) + + try: + bound_args = sig.bind(*args, **kwargs) + _warnings.warn( + 'Temperature argument is unused and will be deprecated in a future version.', + PendingDeprecationWarning) + except TypeError: + sig = signature(_dewpoint_from_specific_humidity) + bound_args = sig.bind(*args, **kwargs) + + return _dewpoint_from_specific_humidity(bound_args.arguments['pressure'], + bound_args.arguments['specific_humidity']) + + +@preprocess_and_wrap( + wrap_like='specific_humidity', + broadcast=('specific_humidity', 'pressure') +) +@check_units(pressure='[pressure]', specific_humidity='[dimensionless]') +def _dewpoint_from_specific_humidity(pressure, specific_humidity): + r"""Calculate the dewpoint from specific humidity and pressure. + + See :func:`~dewpoint_from_specific_humidity` for more information. This implementation + is provided internally to preserve backwards compatibility with MetPy<1.6. + + Parameters + ---------- + pressure: `pint.Quantity` + Total atmospheric pressure + + specific_humidity: `pint.Quantity` + Specific humidity of air + + Returns + ------- + `pint.Quantity` + Dew point temperature + + .. versionchanged:: 1.6 + Made `temperature` arg optional, to be deprecated + + .. versionchanged:: 1.0 + Changed signature from ``(specific_humidity, temperature, pressure)`` + """ + w = mixing_ratio_from_specific_humidity(specific_humidity) + e = pressure * w / (mpconsts.nounit.epsilon + w) + + return dewpoint(e) @exporter.export diff --git a/src/metpy/io/_tools.py b/src/metpy/io/_tools.py index ef2e5ee858f..174566cc615 100644 --- a/src/metpy/io/_tools.py +++ b/src/metpy/io/_tools.py @@ -57,7 +57,7 @@ def open_as_needed(filename, mode='rb'): return open(filename, mode, **kwargs) # noqa: SIM115 -class NamedStruct(Struct): +class NamedStruct: """Parse bytes using :class:`Struct` but provide named fields.""" def __init__(self, info, prefmt='', tuple_name=None): @@ -73,7 +73,12 @@ def __init__(self, info, prefmt='', tuple_name=None): elif not i[0]: # Skip items with no name conv_off += 1 self._tuple = namedtuple(tuple_name, ' '.join(n for n in names if n)) - super().__init__(prefmt + ''.join(f for f in fmts if f)) + self._struct = Struct(prefmt + ''.join(f for f in fmts if f)) + + @property + def size(self): + """Return the size of the struct in bytes.""" + return self._struct.size def _create(self, items): if self.converters: @@ -90,11 +95,11 @@ def make_tuple(self, *args, **kwargs): def unpack(self, s): """Parse bytes and return a namedtuple.""" - return self._create(super().unpack(s)) + return self._create(self._struct.unpack(s)) def unpack_from(self, buff, offset=0): """Read bytes from a buffer and return as a namedtuple.""" - return self._create(super().unpack_from(buff, offset)) + return self._create(self._struct.unpack_from(buff, offset)) def unpack_file(self, fobj): """Unpack the next bytes from a file object.""" @@ -103,12 +108,12 @@ def unpack_file(self, fobj): def pack(self, **kwargs): """Pack the arguments into bytes using the structure.""" t = self.make_tuple(**kwargs) - return super().pack(*t) + return self._struct.pack(*t) # This works around times when we have more than 255 items and can't use # NamedStruct. This is a CPython limit for arguments. -class DictStruct(Struct): +class DictStruct: """Parse bytes using :class:`Struct` but provide named fields using dictionary access.""" def __init__(self, info, prefmt=''): @@ -118,18 +123,23 @@ def __init__(self, info, prefmt=''): # Remove empty names self._names = [n for n in names if n] - super().__init__(prefmt + ''.join(f for f in formats if f)) + self._struct = Struct(prefmt + ''.join(f for f in formats if f)) + + @property + def size(self): + """Return the size of the struct in bytes.""" + return self._struct.size def _create(self, items): return dict(zip(self._names, items)) def unpack(self, s): """Parse bytes and return a dict.""" - return self._create(super().unpack(s)) + return self._create(self._struct.unpack(s)) def unpack_from(self, buff, offset=0): """Unpack the next bytes from a file object.""" - return self._create(super().unpack_from(buff, offset)) + return self._create(self._struct.unpack_from(buff, offset)) class Enum: diff --git a/src/metpy/plots/_mpl.py b/src/metpy/plots/_mpl.py index b5075a4e904..fba2ba80663 100644 --- a/src/metpy/plots/_mpl.py +++ b/src/metpy/plots/_mpl.py @@ -186,7 +186,7 @@ def draw(self, renderer): posy = self.convert_yunits(self.y) pts = np.vstack((posx, posy)).T pts = trans.transform(pts) - canvasw, canvash = renderer.get_canvas_width_height() + _, canvash = renderer.get_canvas_width_height() gc = renderer.new_gc() gc.set_foreground(self.get_color()) @@ -203,7 +203,7 @@ def draw(self, renderer): continue self._text = t # hack to allow self._get_layout to work - bbox, info, descent = self._get_layout(renderer) + _, info, _ = self._get_layout(renderer) self._text = '' for line, _, x, y in info: diff --git a/src/metpy/plots/declarative.py b/src/metpy/plots/declarative.py index 4f2d56f15f0..182b7ef252e 100644 --- a/src/metpy/plots/declarative.py +++ b/src/metpy/plots/declarative.py @@ -67,6 +67,12 @@ def plot_kwargs(data, args): return kwargs +def get_cartopy_color(val): + """Provide the special map feature colors from Cartopy.""" + from cartopy.feature import COLORS + return COLORS[val] + + class ValidationMixin: """Provides validation of attribute names when set by user.""" @@ -281,9 +287,38 @@ class MapPanel(Panel, ValidationMixin): layers_linewidth = List(Union([Int(), Float()], allow_none=True), default_value=[1]) layers_linewidth.__doc__ = """A list of values defining the linewidth for a layer. - An option to set a different color for the map layer edge colors. Length of list should - match that of layers if not using default value. Behavior is to repeat colors if not enough - provided by user. Use `None` value for 'ocean', 'lakes', 'rivers', and 'land'. + An option to set a different linewidth for the layer feature. Length of list should + match that of layers if not using default value. Behavior is to repeat linewidth if + not enough provided by user. Use `None` value for 'ocean', 'lakes', 'rivers', and 'land'. + """ + + layers_linestyle = List(Unicode(), default_value=['solid']) + layers_linestyle.__doc__ = """A list of string values defining the linestyle for a layer or + None. + + Default is `solid`, which, will use a solid lines for drawing the layer. Behavior is to + repeat linestyle if not enough provided by user. + + The valid string values are those of Matplotlib which are 'solid', 'dashed', 'dotted', and + 'dashdot', as well as their short codes ('-', '--', '.', '-.'). The object `None`, as + described above, can also be used. Use `None` value for 'ocean', 'lakes', 'rivers', and + 'land'. + """ + + layers_zorder = List(Union([Int(), Float()], allow_none=True), default_value=[None]) + layers_zorder.__doc__ = """A list of values defining the zorder for a layer. + + An option to set a different zorder for the map layer edge colors. Length of list should + match that of layers if not using default value. Behavior is to repeat zorder if not enough + provided by user. + """ + + layers_alpha = List(Union([Int(), Float()], allow_none=True), default_value=[1]) + layers_alpha.__doc__ = """A list of values defining the alpha for a layer. + + An option to set a different alpha for the map layer edge colors. Length of list should + match that of layers if not using default value. Behavior is to repeat alpha if not enough + provided by user. """ title = Unicode() @@ -479,13 +514,25 @@ def draw(self): self.layers_edgecolor *= len(self.layers) if len(self.layers) > len(self.layers_linewidth): self.layers_linewidth *= len(self.layers) + if len(self.layers) > len(self.layers_linestyle): + self.layers_linestyle *= len(self.layers) + if len(self.layers) > len(self.layers_zorder): + self.layers_zorder *= len(self.layers) + if len(self.layers) > len(self.layers_alpha): + self.layers_alpha *= len(self.layers) for i, feat in enumerate(self._layer_features): - if self.layers[i] in ['', 'land', 'lake', 'river']: + color = self.layers_edgecolor[i] + if self.layers[i] in ['', 'land', 'ocean']: color = 'face' - else: - color = self.layers_edgecolor[i] + if self.layers_edgecolor[i] in ['water', 'land', 'land_alt1']: + color = get_cartopy_color(self.layers_edgecolor[i]) width = self.layers_linewidth[i] - self.ax.add_feature(feat, edgecolor=color, linewidth=width) + style = self.layers_linestyle[i] + zorder = self.layers_zorder[i] + alpha = self.layers_alpha[i] + kwargs = {'zorder': zorder} if zorder is not None else {} + self.ax.add_feature(feat, edgecolor=color, linewidth=width, + linestyle=style, alpha=alpha, **kwargs) # Use the set title or generate one. if (self.right_title is None) and (self.left_title is None): diff --git a/src/metpy/testing.py b/src/metpy/testing.py index da9416ad216..529b6609293 100644 --- a/src/metpy/testing.py +++ b/src/metpy/testing.py @@ -9,8 +9,10 @@ """ import contextlib import functools +from importlib.metadata import requires, version +import operator as op +import re -import matplotlib import numpy as np import numpy.testing from packaging.version import Version @@ -23,37 +25,87 @@ from .deprecation import MetpyDeprecationWarning from .units import units -MPL_VERSION = Version(matplotlib.__version__) +def version_check(version_spec): + """Return comparison between the active module and a requested version number. -def mpl_version_before(ver): - """Return whether the active matplotlib is before a certain version. + Will also validate specification against package metadata to alert if spec is irrelevant. Parameters ---------- - ver : str - The version string for a certain release + version_spec : str + Module version specification to validate against installed package. Must take the form + of `f'{module_name}{comparison_operator}{version_number}'` where `comparison_operator` + must be one of `['==', '=', '!=', '<', '<=', '>', '>=']`, eg `'metpy>1.0'`. Returns ------- - bool : whether the current version was released before the passed in one + bool : Whether the installed package validates against the provided specification """ - return MPL_VERSION < Version(ver) + comparison_operators = { + '==': op.eq, '=': op.eq, '!=': op.ne, '<': op.lt, '<=': op.le, '>': op.gt, '>=': op.ge, + } + # Match version_spec for groups of module name, + # comparison operator, and requested module version + module_name, comparison, version_number = _parse_version_spec(version_spec) -def mpl_version_equal(ver): - """Return whether the active matplotlib is equal to a certain version. + # Check MetPy metadata for minimum required version of same package + metadata_spec = _get_metadata_spec(module_name) + _, _, minimum_version_number = _parse_version_spec(metadata_spec) + + installed_version = Version(version(module_name)) + specified_version = Version(version_number) + minimum_version = Version(minimum_version_number) + + if specified_version < minimum_version: + raise ValueError( + f'Specified {version_spec} outdated according to MetPy minimum {metadata_spec}.') + + try: + return comparison_operators[comparison](installed_version, specified_version) + except KeyError: + raise ValueError( + f'Comparison operator {comparison} not one of {list(comparison_operators)}.' + ) from None + + +def _parse_version_spec(version_spec): + """Parse module name, comparison, and version from pip-style package spec string. + + Parameters + ---------- + version_spec : str + Package spec to parse + + Returns + ------- + tuple of str : Parsed specification groups of package name, comparison, and version + + """ + pattern = re.compile(r'(\w+)\s*([<>!=]+)\s*([\d.]+)') + match = pattern.match(version_spec) + + if not match: + raise ValueError(f'Invalid version specification {version_spec}.' + f'See version_check documentation for more information.') + else: + return match.groups() + + +def _get_metadata_spec(module_name): + """Get package spec string for requested module from package metadata. Parameters ---------- - ver : str - The version string for a certain release + module_name : str + Name of MetPy required package to look up Returns ------- - bool : whether the current version is equal to the passed in one + str : Package spec string for request module """ - return MPL_VERSION == Version(ver) + return [entry for entry in requires('metpy') if module_name.lower() in entry.lower()][0] def needs_module(module): diff --git a/src/metpy/units.py b/src/metpy/units.py index 7121d61277e..8e1e35b9902 100644 --- a/src/metpy/units.py +++ b/src/metpy/units.py @@ -249,10 +249,10 @@ def _check_argument_units(args, defaults, dimensionality): yield arg, val, 'none', need -def _get_changed_version(docstring): +def _get_changed_versions(docstring): """Find the most recent version in which the docs say a function changed.""" matches = re.findall(r'.. versionchanged:: ([\d.]+)', docstring) - return max(matches) if matches else None + return matches def _check_units_outer_helper(func, *args, **kwargs): @@ -302,10 +302,10 @@ def _check_units_inner_helper(func, sig, defaults, dims, *args, **kwargs): # If function has changed, mention that fact if func.__doc__: - changed_version = _get_changed_version(func.__doc__) - if changed_version: + changed_versions = _get_changed_versions(func.__doc__) + if changed_versions: msg = ( - f'This function changed in {changed_version}--double check ' + f'This function changed in version(s) {changed_versions}--double check ' 'that the function is being called properly.\n' ) + msg raise ValueError(msg) diff --git a/tests/calc/test_indices.py b/tests/calc/test_indices.py index d793116517e..6fbace61136 100644 --- a/tests/calc/test_indices.py +++ b/tests/calc/test_indices.py @@ -12,7 +12,8 @@ from metpy.calc import (bulk_shear, bunkers_storm_motion, corfidi_storm_motion, critical_angle, mean_pressure_weighted, precipitable_water, significant_tornado, supercell_composite, weighted_continuous_average) -from metpy.testing import assert_almost_equal, assert_array_almost_equal, get_upper_air_data +from metpy.testing import (assert_almost_equal, assert_array_almost_equal, get_upper_air_data, + version_check) from metpy.units import concatenate, units @@ -130,7 +131,7 @@ def test_weighted_continuous_average(): assert_almost_equal(v, 6.900543760612305 * units('m/s'), 7) -@pytest.mark.xfail(reason='hgrecco/pint#1593') +@pytest.mark.xfail(condition=version_check('pint<0.21'), reason='hgrecco/pint#1593') def test_weighted_continuous_average_temperature(): """Test pressure-weighted mean temperature function with vertical interpolation.""" data = get_upper_air_data(datetime(2016, 5, 22, 0), 'DDC') @@ -138,8 +139,7 @@ def test_weighted_continuous_average_temperature(): data['temperature'], height=data['height'], depth=6000 * units('meter')) - # Commenting out since it won't run until the above can run without error - # assert_almost_equal(t, 279.3275828240889 * units('kelvin'), 7) + assert_almost_equal(t, 279.07450928270185 * units('kelvin'), 7) def test_weighted_continuous_average_elevated(): diff --git a/tests/calc/test_thermo.py b/tests/calc/test_thermo.py index 59877bb9851..d53da112bfb 100644 --- a/tests/calc/test_thermo.py +++ b/tests/calc/test_thermo.py @@ -8,16 +8,15 @@ import warnings import numpy as np -import packaging.version import pytest -import scipy import xarray as xr from metpy.calc import (brunt_vaisala_frequency, brunt_vaisala_frequency_squared, brunt_vaisala_period, cape_cin, ccl, cross_totals, density, dewpoint, dewpoint_from_relative_humidity, dewpoint_from_specific_humidity, - dry_lapse, dry_static_energy, el, equivalent_potential_temperature, - exner_function, gradient_richardson_number, InvalidSoundingError, + downdraft_cape, dry_lapse, dry_static_energy, el, + equivalent_potential_temperature, exner_function, + gradient_richardson_number, InvalidSoundingError, isentropic_interpolation, isentropic_interpolation_as_dataset, k_index, lcl, lfc, lifted_index, mixed_layer, mixed_layer_cape_cin, mixed_parcel, mixing_ratio, mixing_ratio_from_relative_humidity, @@ -39,7 +38,8 @@ virtual_temperature, virtual_temperature_from_dewpoint, wet_bulb_temperature) from metpy.calc.thermo import _find_append_zero_crossings -from metpy.testing import assert_almost_equal, assert_array_almost_equal, assert_nan +from metpy.testing import (assert_almost_equal, assert_array_almost_equal, assert_nan, + version_check) from metpy.units import is_quantity, masked_array, units @@ -200,8 +200,10 @@ def test_moist_lapse_starting_points(start, direction): @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'), +@pytest.mark.xfail(platform.machine() == 'arm64', reason='ValueError is not raised on Mac M2') +@pytest.mark.xfail((sys.platform == 'win32') and version_check('scipy<1.11.3'), + reason='solve_ivp() does not error on Windows + SciPy < 1.11.3') +@pytest.mark.xfail(version_check('scipy<1.7'), reason='solve_ivp() does not error on Scipy < 1.7') def test_moist_lapse_failure(): """Test moist_lapse under conditions that cause the ODE solver to fail.""" @@ -1079,7 +1081,7 @@ def test_rh_mixing_ratio(): temperature = 20. * units.degC w = 0.012 * units.dimensionless rh = relative_humidity_from_mixing_ratio(p, temperature, w) - assert_almost_equal(rh, 81.72498 * units.percent, 3) + assert_almost_equal(rh, 82.0709069 * units.percent, 3) def test_mixing_ratio_from_specific_humidity(): @@ -1116,7 +1118,7 @@ def test_rh_specific_humidity(): temperature = 20. * units.degC q = 0.012 * units.dimensionless rh = relative_humidity_from_specific_humidity(p, temperature, q) - assert_almost_equal(rh, 82.71759 * units.percent, 3) + assert_almost_equal(rh, 83.0486264 * units.percent, 3) def test_cape_cin(): @@ -1554,6 +1556,27 @@ def test_mixed_layer_cape_cin(multiple_intersections): assert_almost_equal(mlcin, -13.4809966289 * units('joule / kilogram'), 2) +def test_dcape(): + """Test the calculation of DCAPE.""" + pressure = [1008., 1000., 950., 900., 850., 800., 750., 700., 650., 600., + 550., 500., 450., 400., 350., 300., 250., 200., + 175., 150., 125., 100., 80., 70., 60., 50., + 40., 30., 25., 20.] * units.hPa + temperature = [29.3, 28.1, 25.5, 20.9, 18.4, 15.9, 13.1, 10.1, 6.7, 3.1, + -0.5, -4.5, -9.0, -14.8, -21.5, -29.7, -40.0, -52.4, + -59.2, -66.5, -74.1, -78.5, -76.0, -71.6, -66.7, -61.3, + -56.3, -51.7, -50.7, -47.5] * units.degC + dewpoint = [26.5, 23.3, 16.1, 6.4, 15.3, 10.9, 8.8, 7.9, 0.6, + -16.6, -9.2, -9.9, -14.6, -32.8, -51.2, -32.7, -42.6, -58.9, + -69.5, -71.7, -75.9, -79.3, -79.7, -72.5, -73.3, -64.3, -70.6, + -75.8, -51.2, -56.4] * units.degC + dcape, down_press, down_t = downdraft_cape(pressure, temperature, dewpoint) + assert_almost_equal(dcape, 1222 * units('joule / kilogram'), 0) + assert_array_almost_equal(down_press, pressure[:10], 0) + assert_almost_equal(down_t, [17.5, 17.2, 15.2, 13.1, 10.9, 8.4, + 5.7, 2.7, -0.6, -4.3] * units.degC, 1) + + def test_mixed_layer(): """Test the mixed layer calculation.""" pressure = np.array([959., 779.2, 751.3, 724.3, 700., 269.]) * units.hPa @@ -1617,7 +1640,7 @@ def test_thickness_hydrostatic_from_relative_humidity(): relative_humidity = np.array([81.69, 15.43, 18.95, 23.32, 28.36, 18.55]) * units.percent thickness = thickness_hydrostatic_from_relative_humidity(pressure, temperature, relative_humidity) - assert_almost_equal(thickness, 9891.71 * units.m, 2) + assert_almost_equal(thickness, 9891.56669 * units.m, 2) def test_mixing_ratio_dimensions(): @@ -1803,8 +1826,9 @@ def test_dewpoint_specific_humidity(): p = 1013.25 * units.mbar temperature = 20. * units.degC q = 0.012 * units.dimensionless - td = dewpoint_from_specific_humidity(p, temperature, q) - assert_almost_equal(td, 16.973 * units.degC, 3) + with pytest.deprecated_call(match='Temperature argument'): + td = dewpoint_from_specific_humidity(p, temperature, q) + assert_almost_equal(td, 17.0363429 * units.degC, 3) def test_dewpoint_specific_humidity_old_signature(): @@ -1812,8 +1836,64 @@ def test_dewpoint_specific_humidity_old_signature(): p = 1013.25 * units.mbar temperature = 20. * units.degC q = 0.012 * units.dimensionless - with pytest.raises(ValueError, match='changed in 1.0'): - dewpoint_from_specific_humidity(q, temperature, p) + with pytest.deprecated_call(match='Temperature argument'): + with pytest.raises(ValueError, match='changed in version'): + dewpoint_from_specific_humidity(q, temperature, p) + + +def test_dewpoint_specific_humidity_kwargs(): + """Test kw-specified signature for backwards compatibility MetPy>=1.6.""" + p = 1013.25 * units.mbar + temperature = 20. * units.degC + q = 0.012 * units.dimensionless + with pytest.deprecated_call(match='Temperature argument'): + td = dewpoint_from_specific_humidity( + pressure=p, temperature=temperature, specific_humidity=q) + assert_almost_equal(td, 17.036 * units.degC, 3) + + +def test_dewpoint_specific_humidity_three_mixed_args_kwargs(): + """Test mixed arg, kwarg handling for backwards compatibility MetPy>=1.6.""" + p = 1013.25 * units.mbar + temperature = 20. * units.degC + q = 0.012 * units.dimensionless + with pytest.deprecated_call(match='Temperature argument'): + td = dewpoint_from_specific_humidity( + p, temperature, specific_humidity=q) + assert_almost_equal(td, 17.036 * units.degC, 3) + + +def test_dewpoint_specific_humidity_two_mixed_args_kwargs(): + """Test function's internal arg, kwarg processing handles mixed case.""" + p = 1013.25 * units.mbar + q = 0.012 * units.dimensionless + td = dewpoint_from_specific_humidity( + p, specific_humidity=q) + assert_almost_equal(td, 17.036 * units.degC, 3) + + +def test_dewpoint_specific_humidity_two_args(): + """Test new signature, Temperature unneeded, MetPy>=1.6.""" + p = 1013.25 * units.mbar + q = 0.012 * units.dimensionless + td = dewpoint_from_specific_humidity(p, q) + assert_almost_equal(td, 17.036 * units.degC, 3) + + +def test_dewpoint_specific_humidity_arrays(): + """Test function arg handling can process arrays.""" + p = 1013.25 * units.mbar + q = np.tile(0.012 * units.dimensionless, (3, 2)) + td = dewpoint_from_specific_humidity(p, specific_humidity=q) + assert_almost_equal(td, np.tile(17.036 * units.degC, (3, 2)), 3) + + +def test_dewpoint_specific_humidity_xarray(index_xarray_data): + """Test function arg handling processes xarray inputs.""" + p = index_xarray_data.isobaric + q = specific_humidity_from_dewpoint(p, index_xarray_data.dewpoint) + td = dewpoint_from_specific_humidity(p, specific_humidity=q) + assert_array_almost_equal(td, index_xarray_data.dewpoint) def test_lfc_not_below_lcl(): @@ -2040,6 +2120,14 @@ def test_specific_humidity_from_dewpoint(): assert_almost_equal(q, 0.012 * units.dimensionless, 3) +def test_specific_humidity_from_dewpoint_versionchanged(): + """Test returning singular version changed suggestion in ValueError.""" + pressure = 1013.25 * units.mbar + dewpoint = 16.973 * units.degC + with pytest.raises(ValueError, match='changed in version'): + specific_humidity_from_dewpoint(dewpoint, pressure) + + def test_lcl_convergence_issue(): """Test profile where LCL wouldn't converge (#1187).""" pressure = np.array([990, 973, 931, 925, 905]) * units.hPa diff --git a/tests/interpolate/test_geometry.py b/tests/interpolate/test_geometry.py index 8dd4921107d..0ca0bc4ff87 100644 --- a/tests/interpolate/test_geometry.py +++ b/tests/interpolate/test_geometry.py @@ -178,11 +178,14 @@ def test_find_nn_triangles_point(): tri = Delaunay(pts) tri_match = tri.find_simplex([4.5, 4.5]) - nn = find_nn_triangles_point(tri, tri_match, [4.5, 4.5]) - - # Can't rely on simplex indices, so need to check point indices - truth = {(45, 55, 44), (55, 54, 44)} - assert {tuple(verts) for verts in tri.simplices[nn]} == truth + nn = find_nn_triangles_point(tri, tri_match, (4.5, 4.5)) + + # Can't rely on simplex indices or the order of points that define them, + # so need to be a bit more involved to check + truth = [{45, 55, 44}, {55, 54, 44}] + found = [set(verts) for verts in tri.simplices[nn]] + for s in truth: + assert s in found def test_find_local_boundary(): diff --git a/tests/plots/baseline/test_declarative_additional_layers_plot_options.png b/tests/plots/baseline/test_declarative_additional_layers_plot_options.png new file mode 100644 index 00000000000..4dcafcafb9e Binary files /dev/null and b/tests/plots/baseline/test_declarative_additional_layers_plot_options.png differ diff --git a/tests/plots/baseline/test_declarative_barb_options.png b/tests/plots/baseline/test_declarative_barb_options.png index a4a5ee19d30..18082f7b7c3 100644 Binary files a/tests/plots/baseline/test_declarative_barb_options.png and b/tests/plots/baseline/test_declarative_barb_options.png differ diff --git a/tests/plots/test_declarative.py b/tests/plots/test_declarative.py index aa4e871cd45..bacee611cdf 100644 --- a/tests/plots/test_declarative.py +++ b/tests/plots/test_declarative.py @@ -20,7 +20,7 @@ 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 mpl_version_before, needs_cartopy +from metpy.testing import needs_cartopy, version_check from metpy.units import units @@ -91,7 +91,7 @@ def test_declarative_four_dims_error(): pc.draw() -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.092) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.093) @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.093) +@pytest.mark.mpl_image_compare(remove_text=False, tolerance=0.094) @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.072) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.098) @needs_cartopy def test_declarative_smooth_contour(): """Test making a contour plot using smooth_contour.""" @@ -307,7 +307,7 @@ def test_declarative_smooth_field(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.708) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.828) @needs_cartopy def test_declarative_contour_cam(): """Test making a contour plot with CAM data.""" @@ -334,8 +334,9 @@ def test_declarative_contour_cam(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, - tolerance=3.71 if mpl_version_before('3.8') else 0.026) +@pytest.mark.mpl_image_compare( + remove_text=True, + tolerance=3.71 if version_check('matplotlib<3.8') else 0.74) @needs_cartopy def test_declarative_contour_options(): """Test making a contour plot.""" @@ -368,7 +369,7 @@ def test_declarative_contour_options(): @pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.082) @needs_cartopy def test_declarative_layers_plot_options(): - """Test making a contour plot.""" + """Test declarative layer options of edgecolor and linewidth.""" data = xr.open_dataset(get_test_data('narr_example.nc', as_file_obj=False)) contour = ContourPlot() @@ -395,8 +396,42 @@ def test_declarative_layers_plot_options(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, - tolerance=2.74 if mpl_version_before('3.8') else 0.014) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.021) +@needs_cartopy +def test_declarative_additional_layers_plot_options(): + """Test additional declarative layer options of linestyle, zorder, and alpha.""" + data = xr.open_dataset(get_test_data('narr_example.nc', as_file_obj=False)) + + contour = ContourPlot() + contour.data = data + contour.field = 'Temperature' + contour.level = 700 * units.hPa + contour.contours = 5 + contour.linewidth = 1 + contour.linecolor = 'grey' + + panel = MapPanel() + panel.area = 'us' + panel.projection = 'lcc' + panel.layers = ['coastline', 'usstates', 'borders', 'lakes', 'rivers'] + panel.layers_edgecolor = ['blue', 'red', 'black', None, 'water'] + panel.layers_linewidth = [0.75, 0.75, 1, 1, 1] + panel.layers_linestyle = ['solid', 'dotted', 'dashed', 'dotted'] + panel.layers_alpha = [1, .5, .75, 1] + panel.layers_zorder = [1, 1, 1, -1, -1] + panel.plots = [contour] + + pc = PanelContainer() + pc.size = (8, 8) + pc.panels = [panel] + pc.draw() + + return pc.figure + + +@pytest.mark.mpl_image_compare( + remove_text=True, + tolerance=2.74 if version_check('matplotlib<3.8') else 1.91) @needs_cartopy def test_declarative_contour_convert_units(): """Test making a contour plot.""" @@ -426,7 +461,7 @@ def test_declarative_contour_convert_units(): return pc.figure -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.247) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.276) @needs_cartopy def test_declarative_events(): """Test that resetting traitlets properly propagates.""" @@ -568,7 +603,7 @@ def test_no_field_error_barbs(): barbs.draw() -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.378) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.381) 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)) diff --git a/tests/plots/test_patheffects.py b/tests/plots/test_patheffects.py index 6763473cd24..d1af81a8b35 100644 --- a/tests/plots/test_patheffects.py +++ b/tests/plots/test_patheffects.py @@ -15,7 +15,7 @@ StationaryFrontolysis, WarmFront, WarmFrontogenesis, WarmFrontolysis) -@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.) +@pytest.mark.mpl_image_compare(remove_text=True, tolerance=0.01) def test_fronts(): """Basic test of plotting fronts using path effects.""" x = np.linspace(0, 80, 5) diff --git a/tests/plots/test_skewt.py b/tests/plots/test_skewt.py index ab5d5c14933..433fa095491 100644 --- a/tests/plots/test_skewt.py +++ b/tests/plots/test_skewt.py @@ -11,7 +11,7 @@ import pytest from metpy.plots import Hodograph, SkewT -from metpy.testing import mpl_version_before, mpl_version_equal +from metpy.testing import version_check from metpy.units import units @@ -155,8 +155,10 @@ 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_equal('3.7.0') else '') - assert skew.ax.get_ylabel() == ('hectopascal' if mpl_version_equal('3.7.0') else '') + assert skew.ax.get_xlabel() == ( + 'degree_Celsius' if version_check('matplotlib==3.7.0') else '') + assert skew.ax.get_ylabel() == ( + 'hectopascal' if version_check('matplotlib==3.7.0') else '') # Clear them for the image test skew.ax.set_xlabel('') @@ -318,8 +320,8 @@ def test_hodograph_api(): return fig -@pytest.mark.mpl_image_compare(remove_text=True, - tolerance=0.6 if mpl_version_before('3.5') else 0.) +@pytest.mark.mpl_image_compare( + remove_text=True, tolerance=0.6 if version_check('matplotlib==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_util.py b/tests/plots/test_util.py index 2f973c4f0d1..6d77ae33ef9 100644 --- a/tests/plots/test_util.py +++ b/tests/plots/test_util.py @@ -11,7 +11,7 @@ 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_before +from metpy.testing import get_test_data, version_check @pytest.mark.mpl_image_compare(tolerance=2.638, remove_text=True) @@ -91,8 +91,9 @@ def test_add_logo_invalid_size(): add_metpy_logo(fig, size='jumbo') -@pytest.mark.mpl_image_compare(tolerance=1.072 if mpl_version_before('3.5') else 0, - remove_text=True) +@pytest.mark.mpl_image_compare( + tolerance=1.072 if version_check('matplotlib<3.5') else 0, + remove_text=True) def test_gempak_color_image_compare(): """Test creating a plot with all the GEMPAK colors.""" c = range(32) @@ -111,8 +112,9 @@ def test_gempak_color_image_compare(): return fig -@pytest.mark.mpl_image_compare(tolerance=1.215 if mpl_version_before('3.5') else 0, - remove_text=True) +@pytest.mark.mpl_image_compare( + tolerance=1.215 if version_check('matplotlib<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.""" c = range(32) diff --git a/tests/test_testing.py b/tests/test_testing.py index 8fa0c398a4a..0625fc9e398 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -10,7 +10,7 @@ from metpy.deprecation import MetpyDeprecationWarning from metpy.testing import (assert_array_almost_equal, check_and_drop_units, - check_and_silence_deprecation) + check_and_silence_deprecation, version_check) # Test #1183: numpy.testing.assert_array* ignores any masked value, so work-around @@ -42,3 +42,27 @@ def test_check_and_drop_units_with_dataarray(): assert isinstance(actual, np.ndarray) assert isinstance(desired, np.ndarray) np.testing.assert_array_almost_equal(actual, desired) + + +def test_module_version_check(): + """Test parsing and version comparison of installed package.""" + numpy_version = np.__version__ + assert version_check(f'numpy >={numpy_version}') + + +def test_module_version_check_outdated_spec(): + """Test checking test version specs against package metadata.""" + with pytest.raises(ValueError, match='Specified numpy'): + version_check('numpy>0.0.0') + + +def test_module_version_check_nonsense(): + """Test failed pattern match of package specification.""" + with pytest.raises(ValueError, match='Invalid version '): + version_check('thousands of birds picking packages') + + +def test_module_version_check_invalid_comparison(): + """Test invalid operator in version comparison.""" + with pytest.raises(ValueError, match='Comparison operator << '): + version_check('numpy << 36') diff --git a/tools/nexrad_msgs/parse_spec.py b/tools/nexrad_msgs/parse_spec.py index 87034f998b1..f3417724e29 100644 --- a/tools/nexrad_msgs/parse_spec.py +++ b/tools/nexrad_msgs/parse_spec.py @@ -63,7 +63,8 @@ def process_msg18(fname): if len(parts) == 8: parts = parts[:6] + [parts[6] + parts[7]] - var_name, desc, typ, units, rng, prec, byte_range = parts + # var_name, desc, typ, units, rng, prec, byte_range + var_name, desc, typ, units, _, _, byte_range = parts start, end = map(int, byte_range.split('-')) size = end - start + 1 assert size >= 4