Repository: Unidata/MetPy

Issue:
Relative Humidity Inconsistency
### What should we add?

MetPy is inconsistent in its definition of relative humidity. For review, there are two definitions:
1) The WMO favors $w/w_s$
2) Most thermo textbooks (e.g., Curry and Webster p. 113) and the [AMS](https://glossary.ametsoc.org/wiki/Relative_humidity) prefer $e/e_s$. 

Although the differences between these definitions are small, it would be good to be consistent, and perhaps give the user the power to decide which definition to use (perhaps with an rcparams-like option or by giving each existing RH-related function a twin?).

As examples, `relative_humidity_from_dewpoint` and `dewpoint_from_relative_humidity` use the AMS definition, but `relative_humidity_from_mixing_ratio` and `relative_humidity_from_specific_humidity` use the WMO definition. In general, it appears the current code picks the definition by expediency (i.e.,  functions easier to write with the WMO definition use that definition, and functions easier to write with the AMS definition use that definition).

### Reference

_No response_

Discussion:

Patch:
diff --git a/pyproject.toml b/pyproject.toml
index f6b032aaee..cfae651fb5 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 75fadfe55f..25357c2fa3 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')
-    <Quantity(15.9828362, 'gram / kilogram')>
+    <Quantity(15.7646969, 'gram / kilogram')>
 
     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')
-    <Quantity(66.1763544, 'percent')>
+    <Quantity(67.1277085, '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')
-    <Quantity(67.3893629, 'percent')>
+    <Quantity(68.3229304, '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])
-    <Quantity(5781.35394, 'meter')>
+    <Quantity(5781.16001, 'meter')>
 
     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'))
-    <Quantity(3.73203192, 'degree_Celsius')>
+    >>> dewpoint_from_specific_humidity(1000 * units.hPa, 5 * units('g/kg'))
+    <Quantity(3.79314079, 'degree_Celsius')>
+
+    .. 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 7121d61277..8e1e35b990 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)


Test patch:
diff --git a/tests/calc/test_thermo.py b/tests/calc/test_thermo.py
index 6a7017eed1..42f6dbb1c2 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