Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(wind): Bias correct wind speeds based on scaling to a known average (alternative) #405

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 52 additions & 21 deletions atlite/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -478,19 +478,28 @@


# wind
def convert_wind(ds, turbine):
def convert_wind(
ds: xr.Dataset, turbine, windspeed_bias_correction=None, from_height=None
):
"""
Convert wind speeds for turbine to wind energy generation.
"""
V, POW, hub_height, P = itemgetter("V", "POW", "hub_height", "P")(turbine)

wnd_hub = windm.extrapolate_wind_speed(ds, to_height=hub_height)
if windspeed_bias_correction is not None:
ds = ds.assign(

Check warning on line 490 in atlite/convert.py

View check run for this annotation

Codecov / codecov/patch

atlite/convert.py#L490

Added line #L490 was not covered by tests
{f"wnd{from_height}m": ds[f"wnd{from_height}m"] * windspeed_bias_correction}
)

wnd_hub = windm.extrapolate_wind_speed(
ds, to_height=hub_height, from_height=from_height
)

def _interpolate(da):
def apply_power_curve(da):
return np.interp(da, V, POW / P)

da = xr.apply_ufunc(
_interpolate,
apply_power_curve,
wnd_hub,
input_core_dims=[[]],
output_core_dims=[[]],
Expand All @@ -503,7 +512,15 @@
return da


def wind(cutout, turbine, smooth=False, add_cutout_windspeed=False, **params):
def wind(
cutout,
turbine: str | dict,
smooth: bool | dict = False,
add_cutout_windspeed: bool = False,
windspeed_bias_correction: xr.DataArray | None = None,
windspeed_height: int | None = None,
**params,
):
"""
Generate wind generation time-series.

Expand All @@ -513,22 +530,32 @@
Parameters
----------
turbine : str or dict
A turbineconfig dictionary with the keys 'hub_height' for the
hub height and 'V', 'POW' defining the power curve.
Alternatively a str refering to a local or remote turbine configuration
as accepted by atlite.resource.get_windturbineconfig(). Locally stored turbine
configurations can also be modified with this function. E.g. to setup a different hub
height from the one used in the yaml file,one would write
"turbine=get_windturbineconfig(“NREL_ReferenceTurbine_5MW_offshore”)|{“hub_height”:120}"
A turbineconfig dictionary with the keys 'hub_height' for the hub height
and 'V', 'POW' defining the power curve. Alternatively a str refering to
a local or remote turbine configuration as accepted by
atlite.resource.get_windturbineconfig(). Locally stored turbine
configurations can also be modified with this function. E.g. to setup a
different hub height from the one used in the yaml file, one would write
>>> turbine = (
>>> get_windturbineconfig("NREL_ReferenceTurbine_5MW_offshore")
>>> | {"hub_height":120}
>>> )
smooth : bool or dict
If True smooth power curve with a gaussian kernel as
determined for the Danish wind fleet to Delta_v = 1.27 and
sigma = 2.29. A dict allows to tune these values.
If True smooth power curve with a gaussian kernel as determined for the
Danish wind fleet to Delta_v = 1.27 and sigma = 2.29. A dict allows to
tune these values.
add_cutout_windspeed : bool
If True and in case the power curve does not end with a zero, will add zero power
output at the highest wind speed in the power curve. If False, a warning will be
raised if the power curve does not have a cut-out wind speed. The default is
False.
If True and in case the power curve does not end with a zero, will add
zero power output at the highest wind speed in the power curve. If
False, a warning will be raised if the power curve does not have a
cut-out wind speed. The default is False.
windspeed_bias_correction : DataArray, optional
Correction factor that is applied to the windspeed at
`windspeed_height`. Such a correction factor can be calculated using
:py:func:`atlite.wind.calculate_windspeed_bias_correction` with a raster
dataset of mean wind speeds.
windspeed_height : int, optional
Height in meters of windspeed data from which to extrapolate

Note
----
Expand All @@ -541,7 +568,7 @@
1074 – 1088. doi:10.1016/j.energy.2015.09.071

"""
if isinstance(turbine, (str, Path, dict)):
if isinstance(turbine, str | Path | dict):
turbine = get_windturbineconfig(
turbine, add_cutout_windspeed=add_cutout_windspeed
)
Expand All @@ -550,7 +577,11 @@
turbine = windturbine_smooth(turbine, params=smooth)

return cutout.convert_and_aggregate(
convert_func=convert_wind, turbine=turbine, **params
convert_func=convert_wind,
turbine=turbine,
windspeed_bias_correction=windspeed_bias_correction,
from_height=windspeed_height,
**params,
)


Expand Down
61 changes: 56 additions & 5 deletions atlite/datasets/era5.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
https://confluence.ecmwf.int/display/CKB/ERA5%3A+data+documentation
"""

import datetime
import logging
import os
import warnings
Expand Down Expand Up @@ -323,7 +324,7 @@
logger.error(f"Unable to delete file {path}, as it is still in use.")


def retrieve_data(product, chunks=None, tmpdir=None, lock=None, **updates):
def retrieve_data(dataset, chunks=None, tmpdir=None, lock=None, **updates):
"""
Download data like ERA5 from the Climate Data Store (CDS).

Expand All @@ -340,7 +341,7 @@
client = cdsapi.Client(
info_callback=logger.debug, debug=logging.DEBUG >= logging.root.level
)
result = client.retrieve(product, request)
result = client.retrieve(dataset, request)

Check warning on line 344 in atlite/datasets/era5.py

View check run for this annotation

Codecov / codecov/patch

atlite/datasets/era5.py#L344

Added line #L344 was not covered by tests

if lock is None:
lock = nullcontext()
Expand All @@ -359,7 +360,7 @@
ds = xr.open_dataset(target, chunks=chunks or {})
if tmpdir is None:
logger.debug(f"Adding finalizer for {target}")
weakref.finalize(ds._file_obj._manager, noisy_unlink, target)
weakref.finalize(ds._close.__self__.ds, noisy_unlink, target)

Check warning on line 363 in atlite/datasets/era5.py

View check run for this annotation

Codecov / codecov/patch

atlite/datasets/era5.py#L363

Added line #L363 was not covered by tests

# Remove default encoding we get from CDSAPI, which can lead to NaN values after loading with subsequent
# saving due to how xarray handles netcdf compression (only float encoded as short int seem affected)
Expand All @@ -373,6 +374,55 @@
return ds


def retrieve_windspeed_average(cutout, height, first_year=1980, last_year=None):
"""
Retrieve average windspeed from `first_year` to `last_year`

Parameters
----------
cutout : atlite.Cutout
Cutout for which to retrieve windspeeds from CDS
height : int
Height of windspeeds (ERA5 typically knows about 10m, 100m, 150m?)
first_year : int
First year to take into account
last_year : int, optional
Last year to take into account (if omitted takes the previous year)

Returns
-------
DataArray
Mean windspeed at cutout dimension
"""
if last_year is None:
last_year = datetime.date.today().year - 1

Check warning on line 398 in atlite/datasets/era5.py

View check run for this annotation

Codecov / codecov/patch

atlite/datasets/era5.py#L398

Added line #L398 was not covered by tests

ds = retrieve_data(

Check warning on line 400 in atlite/datasets/era5.py

View check run for this annotation

Codecov / codecov/patch

atlite/datasets/era5.py#L400

Added line #L400 was not covered by tests
"reanalysis-era5-single-levels-monthly-means",
chunks=cutout.chunks,
product_type="monthly_averaged_reanalysis",
variable=[
f"{height}m_u_component_of_wind",
f"{height}m_v_component_of_wind",
],
area=_area(cutout.coords),
grid=[cutout.dx, cutout.dy],
year=[str(y) for y in range(first_year, last_year + 1)],
month=[f"{m:02}" for m in range(1, 12 + 1)],
time=["00:00"],
)
ds = _rename_and_clean_coords(ds)

Check warning on line 414 in atlite/datasets/era5.py

View check run for this annotation

Codecov / codecov/patch

atlite/datasets/era5.py#L414

Added line #L414 was not covered by tests

return (

Check warning on line 416 in atlite/datasets/era5.py

View check run for this annotation

Codecov / codecov/patch

atlite/datasets/era5.py#L416

Added line #L416 was not covered by tests
sqrt(ds[f"u{height}"] ** 2 + ds[f"v{height}"] ** 2)
.mean("date")
.assign_attrs(
units=ds[f"u{height}"].attrs["units"],
long_name=f"{height} metre wind speed as long run average",
)
)


def get_data(
cutout,
feature,
Expand Down Expand Up @@ -418,7 +468,8 @@
sanitize = creation_parameters.get("sanitize", True)

retrieval_params = {
"product": "reanalysis-era5-single-levels",
"dataset": "reanalysis-era5-single-levels",
"product_type": "reanalysis",
"area": _area(coords),
"chunks": cutout.chunks,
"grid": [cutout.dx, cutout.dy],
Expand All @@ -431,7 +482,7 @@

logger.info(f"Requesting data for feature {feature}...")

def retrieve_once(time):
def retrieve_once(time, longrunaverage=False):

Check warning on line 485 in atlite/datasets/era5.py

View check run for this annotation

Codecov / codecov/patch

atlite/datasets/era5.py#L485

Added line #L485 was not covered by tests
ds = func({**retrieval_params, **time})
if sanitize and sanitize_func is not None:
ds = sanitize_func(ds)
Expand Down
91 changes: 79 additions & 12 deletions atlite/wind.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@

import logging
import re
from pathlib import Path

import numpy as np
import rasterio as rio
import xarray as xr

from . import datasets

logger = logging.getLogger(__name__)


def extrapolate_wind_speed(ds, to_height, from_height=None):
def extrapolate_wind_speed(
ds: xr.Dataset, to_height: int | float, from_height: int | None = None
):
"""
Extrapolate the wind speed from a given height above ground to another.

Expand All @@ -28,8 +35,7 @@
Dataset containing the wind speed time-series at 'from_height' with key
'wnd{height:d}m' and the surface orography with key 'roughness' at the
geographic locations of the wind speeds.
from_height : int
(Optional)
from_height : int, optional
Height (m) from which the wind speeds are interpolated to 'to_height'.
If not provided, the closest height to 'to_height' is selected.
to_height : int|float
Expand All @@ -51,14 +57,11 @@
Retrieved 2019-02-15.

"""
# Fast lane
to_name = f"wnd{int(to_height):0d}m"
if to_name in ds:
return ds[to_name]

if from_height is None:
# Determine closest height to to_name
heights = np.asarray([int(s[3:-1]) for s in ds if re.match(r"wnd\d+m", s)])
heights = np.asarray(
[int(m.group(1)) for s in ds if (m := re.match(r"wnd(\d+)m", s))]
)

if len(heights) == 0:
raise AssertionError("Wind speed is not in dataset")
Expand All @@ -67,17 +70,81 @@

from_name = f"wnd{int(from_height):0d}m"

# Fast lane
if from_height == to_height:
return ds[from_name]

Check warning on line 75 in atlite/wind.py

View check run for this annotation

Codecov / codecov/patch

atlite/wind.py#L75

Added line #L75 was not covered by tests

# Wind speed extrapolation
wnd_spd = ds[from_name] * (
np.log(to_height / ds["roughness"]) / np.log(from_height / ds["roughness"])
)

wnd_spd.attrs.update(
return wnd_spd.assign_attrs(
{
"long name": f"extrapolated {to_height} m wind speed using logarithmic "
f"method with roughness and {from_height} m wind speed",
"units": "m s**-1",
}
)
).rename(f"wnd{to_height}m")


def calculate_windspeed_bias_correction(
cutout, real_average: str | rio.DatasetReader, height: int = 100
):
"""
Derive a bias correction factor for windspeed at lra_height

Regrids the raster dataset in real_average to cutout grid, retrieves the average
windspeed from the first dataset that offers
:py:func:`retrieve_longrunaverage_windspeed` (only ERA5, currently).

Parameters
----------
cutout : Cutout
Atlite cutout
real_average : Path or rasterio.Dataset
Raster dataset with wind speeds to bias correct average wind speeds
height : int
Height in meters at which average windspeeds are provided

return wnd_spd.rename(to_name)
Returns
-------
DataArray
Ratio between windspeeds in `real_average` to those of average windspeeds in
dataset.
"""
if isinstance(real_average, str | Path):
real_average = rio.open(real_average)

Check warning on line 117 in atlite/wind.py

View check run for this annotation

Codecov / codecov/patch

atlite/wind.py#L117

Added line #L117 was not covered by tests

if isinstance(real_average, rio.DatasetReader):
real_average = rio.band(real_average, 1)

Check warning on line 120 in atlite/wind.py

View check run for this annotation

Codecov / codecov/patch

atlite/wind.py#L120

Added line #L120 was not covered by tests

if isinstance(real_average, rio.Band):
real_average, transform = rio.warp.reproject(

Check warning on line 123 in atlite/wind.py

View check run for this annotation

Codecov / codecov/patch

atlite/wind.py#L123

Added line #L123 was not covered by tests
real_average,
np.empty(cutout.shape),
dst_crs=cutout.crs,
dst_transform=cutout.transform,
dst_nodata=np.nan,
resampling=rio.enums.Resampling.average,
)

real_average = xr.DataArray(

Check warning on line 132 in atlite/wind.py

View check run for this annotation

Codecov / codecov/patch

atlite/wind.py#L132

Added line #L132 was not covered by tests
real_average, [cutout.coords["y"], cutout.coords["x"]]
)

for module in np.atleast_1d(cutout.module):
retrieve_windspeed_average = getattr(

Check warning on line 137 in atlite/wind.py

View check run for this annotation

Codecov / codecov/patch

atlite/wind.py#L137

Added line #L137 was not covered by tests
getattr(datasets, module), "retrieve_windspeed_average"
)
if retrieve_windspeed_average is not None:
break

Check warning on line 141 in atlite/wind.py

View check run for this annotation

Codecov / codecov/patch

atlite/wind.py#L141

Added line #L141 was not covered by tests
else:
raise AssertionError(

Check warning on line 143 in atlite/wind.py

View check run for this annotation

Codecov / codecov/patch

atlite/wind.py#L143

Added line #L143 was not covered by tests
"None of the datasets modules define retrieve_windspeed_average"
)

logger.info("Retrieving average windspeeds at %d from module %s", height, module)
data_average = retrieve_windspeed_average(cutout, height)

Check warning on line 148 in atlite/wind.py

View check run for this annotation

Codecov / codecov/patch

atlite/wind.py#L147-L148

Added lines #L147 - L148 were not covered by tests

return real_average / data_average

Check warning on line 150 in atlite/wind.py

View check run for this annotation

Codecov / codecov/patch

atlite/wind.py#L150

Added line #L150 was not covered by tests
Loading