diff --git a/pyproject.toml b/pyproject.toml index f6b032aaee1..cfae651fb52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,6 +107,8 @@ 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..25357c2fa38 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 @@ -3544,7 +3560,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 +3873,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 +3896,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/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_thermo.py b/tests/calc/test_thermo.py index 6a7017eed13..42f6dbb1c20 100644 --- a/tests/calc/test_thermo.py +++ b/tests/calc/test_thermo.py @@ -1080,7 +1080,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(): @@ -1117,7 +1117,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(): @@ -1618,7 +1618,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(): @@ -1804,8 +1804,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(): @@ -1813,8 +1814,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(): @@ -2041,6 +2098,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