Repository: pvlib/pvlib-python

Issue:
Expression for calculating wind speed at different heights
Most times, the wind speed provided in weather files is measured at a height of 10 m. However, in many cases it is required to estimate the wind speed at panel height. Which equation should be used in order to do this?

I found this issue (#1814), which mentions an equation from a Sandia report (I couldn't find the report, though... some help from Sandia people? @cwhanse @kandersolar). This is what is mentioned:
`Estimate wind speed at module height - use technique developed by menicucci and hall (sand84-2530) */`
`windmd=ws2*pow(height/9.144,0.2) + 0.0001`

And then there is the expression used in wind engineering, described [here](https://en.wikipedia.org/wiki/Wind_gradient) (for a more official reference, it is also mentioned in the book by Siegfried Heier (2014), Grid Integration of Wind Energy Conversion Systems (publisher: Wiley))

I am willing to take up the task of making the PR, etc., but first, I would like to know your thoughts on which expression should be added (maybe both?) or if there is some other equation you think is more relevant.


Discussion:
Here is a public copy of SAND 84-2530: https://web.archive.org/web/20230418202422/https://www2.jpl.nasa.gov/adv_tech/photovol/2016CTR/SNL%20-%20Est%20Wind%20Speed%20vs%20Height_1985.pdf
A word of caution here: some temperature model coefficients (e.g. those for the SAPM) implicitly account for the difference between anemometer and PV module height above ground. They do so by regressing measured module temperature to wind speed at 10m.

The proposed function is worth adding but I would be cautious about suggesting it needs to be in a PV modeling pipeline.

> A word of caution here: some temperature model coefficients (e.g. those for the SAPM) implicitly account for the difference between anemometer and PV module height above ground. They do so by regressing measured module temperature to wind speed at 10m.

Great insight @cwhanse. Let's add this info in a warning admonition for the proposed wind speed function.

Probably worth checking out if there is similar functionality in metpy.


Good point, @cwhanse! From a quick search, I can see that only the SAPM models and the generic_linear require the wind speed at 10 m as an input. The rest of the temperature models either require wind speed at module height or don't specify (e.g., Fuentes), but I would still imagine it requires at module height...
I agree with @cwhanse we we need to tread cautiously because in my experience there is already a lot of misunderstanding about correlation coefficients and their relation to wind speed height measurements.

1. I emphasize what Cliff says: correlation coefficients like `Uc = 25 W/m2/K` and `Uv = 1.2 W/m2/K/(m/s)` require and depend on wind speed measured at 10-m. So if wind speeds were measured at array height, eg 2-m, a function such as the one proposed could be helpful in estimating wind speed at 10-m
2. Ideally I would prefer to see a discussion added to the documentation explaining why free stream velocity measurements are preferred and used in temperature correlations, before adding new functions
3. I have most frequently seen a power law but there is often debate over what exponent to use. Eg: `Uw(10m) = Uw(2m)*(10m/2m)^n` where `n` is the exponent. Other methods may be more accurate but I’m not familiar with them. I also don’t have a reference for this or know the history why it’s so prevalent

This is my view on the subject:

> B. Wind speed
>
>Each model has a coefficient that multiplies wind speed. In practice, wind speed increases with height above the ground in a non-linear manner, therefore the model coefficients are valid for a specific combination of module installation height and wind speed measurement height. The Faiman model coefficients and NOCT values are typically determined using wind speed measured near module height, but the modules are not necessarily near ground level. SAPM and PVsyst coefficients are specified for use with wind speed data at the standard 10 m height, but that information is only useful if the module height corresponding to those coefficients is also clearly specified.
>
>To accommodate a different installation or measurement height, either the wind speed data or the wind speed coefficient can be adjusted. The basis for such an adjustment is often either the log law or the power law which describe the wind speed profile as a function of height using an empirical parameter related to the surface roughness; **however, these profiles are not applicable close to the ground or close to the level of the objects that contribute to the roughness.** [12] For the adjustment from 10 m to 2 m, a nominal height for a ground-mounted array, some reduction ratios found in the literature are: 0.51 [7], 0.56 [13], 0.67 [14] and 0.725 [15]. This range gives an indication of the level of uncertainty associated with such adjustments.
>
>The SAM NOCT model uses a predetermined factor of 0.51 to reduce 10 m wind speed data to ground-mounted, module-level wind speed, but unfortunately the origin of this value is undocumented. The need for adjustments with other models to or from different heights should be evaluated by the user of the models on a case by case basis taking into consideration the both the source of the empirical parameters and the source of the wind speed data.
Thanks Anton! Can we get those references?
Here is a link to my temperature model equivalence paper that contains the above excerpt: 
https://www.osti.gov/biblio/2003640
Thanks Anton. So I think there is a case for pvlib to include an expression to account for wind speed height measurement, but IMO. I still think the documentation takes precedence so that folks don’t misuse it. In particular I’d like to see a table of weather resources that shows the wind speed height. Eg ground campaigns most often measure at about 3-m while satellite is usually at 10-m. Also a corresponding table of temperature models. Finally some gallery examples that show when and how an adjustment should be made. My personal preference is to adjust the wind coefficient rather than the wind speed measurements which is associative (ie: `Uv’ * Uw = Uv * adj * Uw = Uv * Uw’` in which prime indicates adjustment. Also a discussion of uncertainty would be useful.
I think this is another area where the PV modellers are just muddling along.  Someone needs to access a large amount of data and do some thorough analysis in order to develop some well-founded correlations that are useful for PV systems!



Patch:
diff --git a/docs/sphinx/source/reference/airmass_atmospheric.rst b/docs/sphinx/source/reference/airmass_atmospheric.rst
index fbd33a5f28..384c345aec 100644
--- a/docs/sphinx/source/reference/airmass_atmospheric.rst
+++ b/docs/sphinx/source/reference/airmass_atmospheric.rst
@@ -17,3 +17,4 @@ Airmass and atmospheric models
    atmosphere.kasten96_lt
    atmosphere.angstrom_aod_at_lambda
    atmosphere.angstrom_alpha
+   atmosphere.windspeed_powerlaw
diff --git a/docs/sphinx/source/whatsnew/v0.11.1.rst b/docs/sphinx/source/whatsnew/v0.11.1.rst
index 4ccc9687c3..3197882d93 100644
--- a/docs/sphinx/source/whatsnew/v0.11.1.rst
+++ b/docs/sphinx/source/whatsnew/v0.11.1.rst
@@ -17,6 +17,9 @@ Enhancements
   :py:func:`pvlib.spectrum.spectral_factor_firstsolar`.
   (:issue:`2086`, :pull:`2100`)
 
+* Added function for calculating wind speed at different heights,
+  :py:func:`pvlib.atmosphere.windspeed_powerlaw`.
+  (:issue:`2118`, :pull:`2124`)
 
 Bug fixes
 ~~~~~~~~~
@@ -45,4 +48,3 @@ Contributors
 * Leonardo Micheli (:ghuser:`lmicheli`)
 * Echedey Luis (:ghuser:`echedey-ls`)
 * Rajiv Daxini (:ghuser:`RDaxini`)
-
diff --git a/pvlib/atmosphere.py b/pvlib/atmosphere.py
index 08e40b9fc2..5b7ffff4ee 100644
--- a/pvlib/atmosphere.py
+++ b/pvlib/atmosphere.py
@@ -1,6 +1,7 @@
 """
 The ``atmosphere`` module contains methods to calculate relative and
-absolute airmass and to determine pressure from altitude or vice versa.
+absolute airmass, determine pressure from altitude or vice versa, and wind
+speed at different heights.
 """
 
 import numpy as np
@@ -533,3 +534,158 @@ def angstrom_alpha(aod1, lambda1, aod2, lambda2):
     pvlib.atmosphere.angstrom_aod_at_lambda
     """
     return - np.log(aod1 / aod2) / np.log(lambda1 / lambda2)
+
+
+# Values of the Hellmann exponent
+HELLMANN_SURFACE_EXPONENTS = {
+    'unstable_air_above_open_water_surface': 0.06,
+    'neutral_air_above_open_water_surface': 0.10,
+    'stable_air_above_open_water_surface': 0.27,
+    'unstable_air_above_flat_open_coast': 0.11,
+    'neutral_air_above_flat_open_coast': 0.16,
+    'stable_air_above_flat_open_coast': 0.40,
+    'unstable_air_above_human_inhabited_areas': 0.27,
+    'neutral_air_above_human_inhabited_areas': 0.34,
+    'stable_air_above_human_inhabited_areas': 0.60,
+}
+
+
+def windspeed_powerlaw(wind_speed_reference, height_reference,
+                       height_desired, exponent=None,
+                       surface_type=None):
+    r"""
+    Estimate wind speed for different heights.
+
+    The model is based on the power law equation by Hellmann [1]_ [2]_.
+
+    Parameters
+    ----------
+    wind_speed_reference : numeric
+        Measured wind speed. [m/s]
+
+    height_reference : float
+        The height above ground at which the wind speed is measured. [m]
+
+    height_desired : float
+        The height above ground at which the wind speed will be estimated. [m]
+
+    exponent : float, optional
+        Exponent based on the surface type. [unitless]
+
+    surface_type : string, optional
+        If supplied, overrides ``exponent``. Can be one of the following
+        (see [1]_):
+
+        * ``'unstable_air_above_open_water_surface'``
+        * ``'neutral_air_above_open_water_surface'``
+        * ``'stable_air_above_open_water_surface'``
+        * ``'unstable_air_above_flat_open_coast'``
+        * ``'neutral_air_above_flat_open_coast'``
+        * ``'stable_air_above_flat_open_coast'``
+        * ``'unstable_air_above_human_inhabited_areas'``
+        * ``'neutral_air_above_human_inhabited_areas'``
+        * ``'stable_air_above_human_inhabited_areas'``
+
+    Returns
+    -------
+    wind_speed : numeric
+        Adjusted wind speed for the desired height. [m/s]
+
+    Raises
+    ------
+    ValueError
+        If neither of ``exponent`` nor a ``surface_type`` is given.
+        If both ``exponent`` and a ``surface_type`` is given. These parameters
+        are mutually exclusive.
+
+    KeyError
+        If the specified ``surface_type`` is invalid.
+
+    Notes
+    -----
+    Module temperature functions often require wind speeds at a height of 10 m
+    and not the wind speed at the module height.
+
+    For example, the following temperature functions require the input wind
+    speed to be 10 m: :py:func:`~pvlib.temperature.sapm_cell`, and
+    :py:func:`~pvlib.temperature.sapm_module` whereas the
+    :py:func:`~pvlib.temperature.fuentes` model requires wind speed at 9.144 m.
+
+    Additionally, the heat loss coefficients of some models have been developed
+    for wind speed measurements at 10 m (e.g.,
+    :py:func:`~pvlib.temperature.pvsyst_cell`,
+    :py:func:`~pvlib.temperature.faiman`, and
+    :py:func:`~pvlib.temperature.faiman_rad`).
+
+    The equation for calculating the wind speed at a height of :math:`h` is
+    given by the following power law equation [1]_ [2]_:
+
+    .. math::
+       :label: wind speed
+
+        WS_{h} = WS_{ref} \cdot \left( \frac{h}{h_{ref}} \right)^a
+
+    where :math:`h` [m] is the height at which we would like to calculate the
+    wind speed, :math:`h_{ref}` [m] is the reference height at which the wind
+    speed is known, and :math:`WS_{h}` [m/s] and :math:`WS_{ref}`
+    [m/s] are the corresponding wind speeds at these heights. The exponent
+    :math:`a` [unitless] depends on the surface type. Some values found in the
+    literature [1]_ for :math:`a` are:
+
+    .. table:: Values for the Hellmann-exponent
+
+       +-----------+--------------------+------------------+------------------+
+       | Stability | Open water surface | Flat, open coast | Cities, villages |
+       +===========+====================+==================+==================+
+       | Unstable  | 0.06               | 0.10             | 0.27             |
+       +-----------+--------------------+------------------+------------------+
+       | Neutral   | 0.11               | 0.16             | 0.40             |
+       +-----------+--------------------+------------------+------------------+
+       | Stable    | 0.27               | 0.34             | 0.60             |
+       +-----------+--------------------+------------------+------------------+
+
+    In a report by Sandia [3]_, the equation was experimentally tested for a
+    height of 30 ft (:math:`h_{ref} = 9.144` [m]) at their test site in
+    Albuquerque for a period of six weeks where a coefficient of
+    :math:`a = 0.219` was calculated.
+
+    It should be noted that the equation returns a value of NaN if the
+    reference heights or wind speed are negative.
+
+    References
+    ----------
+    .. [1] Kaltschmitt M., Streicher W., Wiese A. (2007). "Renewable Energy:
+       Technology, Economics and Environment." Springer,
+       :doi:`10.1007/3-540-70949-5`.
+
+    .. [2] Hellmann G. (1915). "Über die Bewegung der Luft in den untersten
+       Schichten der Atmosphäre." Meteorologische Zeitschrift, 32
+
+    .. [3] Menicucci D.F., Hall I.J. (1985). "Estimating wind speed as a
+       function of height above ground: An analysis of data obtained at the
+       southwest residential experiment station, Las Cruses, New Mexico."
+       SAND84-2530, Sandia National Laboratories.
+       Accessed at:
+       https://web.archive.org/web/20230418202422/https://www2.jpl.nasa.gov/adv_tech/photovol/2016CTR/SNL%20-%20Est%20Wind%20Speed%20vs%20Height_1985.pdf
+    """  # noqa:E501
+    if surface_type is not None and exponent is None:
+        # use the Hellmann exponent from dictionary
+        exponent = HELLMANN_SURFACE_EXPONENTS[surface_type]
+    elif surface_type is None and exponent is not None:
+        # use the provided exponent
+        pass
+    else:
+        raise ValueError(
+            "Either a 'surface_type' or an 'exponent' parameter must be given")
+
+    wind_speed = wind_speed_reference * (
+        (height_desired / height_reference) ** exponent)
+
+    # if wind speed is negative or complex return NaN
+    wind_speed = np.where(np.iscomplex(wind_speed) | (wind_speed < 0),
+                          np.nan, wind_speed)
+
+    if isinstance(wind_speed_reference, pd.Series):
+        wind_speed = pd.Series(wind_speed, index=wind_speed_reference.index)
+
+    return wind_speed


Test patch:
diff --git a/pvlib/tests/test_atmosphere.py b/pvlib/tests/test_atmosphere.py
index 46db622ee5..e12a41dc6d 100644
--- a/pvlib/tests/test_atmosphere.py
+++ b/pvlib/tests/test_atmosphere.py
@@ -131,3 +131,74 @@ def test_bird_hulstrom80_aod_bb():
     aod380, aod500 = 0.22072480948195175, 0.1614279181106312
     bird_hulstrom = atmosphere.bird_hulstrom80_aod_bb(aod380, aod500)
     assert np.isclose(0.11738229553812768, bird_hulstrom)
+
+
+@pytest.fixture
+def windspeeds_data_powerlaw():
+    data = pd.DataFrame(
+        index=pd.date_range(start="2015-01-01 00:00", end="2015-01-01 05:00",
+                            freq="1h"),
+        columns=["wind_ref", "height_ref", "height_desired", "wind_calc"],
+        data=[
+            (10, -2, 5, np.nan),
+            (-10, 2, 5, np.nan),
+            (5, 4, 5, 5.067393209486324),
+            (7, 6, 10, 7.2178684911195905),
+            (10, 8, 20, 10.565167835216586),
+            (12, 10, 30, 12.817653329393977)
+        ]
+    )
+    return data
+
+
+def test_windspeed_powerlaw_ndarray(windspeeds_data_powerlaw):
+    # test wind speed estimation by passing in surface_type
+    result_surface = atmosphere.windspeed_powerlaw(
+        windspeeds_data_powerlaw["wind_ref"].to_numpy(),
+        windspeeds_data_powerlaw["height_ref"],
+        windspeeds_data_powerlaw["height_desired"],
+        surface_type='unstable_air_above_open_water_surface')
+    assert_allclose(windspeeds_data_powerlaw["wind_calc"].to_numpy(),
+                    result_surface)
+    # test wind speed estimation by passing in the exponent corresponding
+    # to the surface_type above
+    result_exponent = atmosphere.windspeed_powerlaw(
+        windspeeds_data_powerlaw["wind_ref"].to_numpy(),
+        windspeeds_data_powerlaw["height_ref"],
+        windspeeds_data_powerlaw["height_desired"],
+        exponent=0.06)
+    assert_allclose(windspeeds_data_powerlaw["wind_calc"].to_numpy(),
+                    result_exponent)
+
+
+def test_windspeed_powerlaw_series(windspeeds_data_powerlaw):
+    result = atmosphere.windspeed_powerlaw(
+        windspeeds_data_powerlaw["wind_ref"],
+        windspeeds_data_powerlaw["height_ref"],
+        windspeeds_data_powerlaw["height_desired"],
+        surface_type='unstable_air_above_open_water_surface')
+    assert_series_equal(windspeeds_data_powerlaw["wind_calc"],
+                        result, check_names=False)
+
+
+def test_windspeed_powerlaw_invalid():
+    with pytest.raises(ValueError, match="Either a 'surface_type' or an "
+                       "'exponent' parameter must be given"):
+        # no exponent or surface_type given
+        atmosphere.windspeed_powerlaw(wind_speed_reference=10,
+                                      height_reference=5,
+                                      height_desired=10)
+    with pytest.raises(ValueError, match="Either a 'surface_type' or an "
+                       "'exponent' parameter must be given"):
+        # no exponent or surface_type given
+        atmosphere.windspeed_powerlaw(wind_speed_reference=10,
+                                      height_reference=5,
+                                      height_desired=10,
+                                      exponent=1.2,
+                                      surface_type="surf")
+    with pytest.raises(KeyError, match='not_an_exponent'):
+        # invalid surface_type
+        atmosphere.windspeed_powerlaw(wind_speed_reference=10,
+                                      height_reference=5,
+                                      height_desired=10,
+                                      surface_type='not_an_exponent')