Skip to content

Commit 4412bcd

Browse files
committed
feat(wind): Bias correct wind speeds based on scaling to a known average
Adds a new function :py:func:`atlite.datasets.era5.retrieve_average_windspeed` to retrieve average windspeeds. This dataset function is called by :py:func:`atlite.wind.calculate_windspeed_bias_correction` to derive a bias correction which can be passed to the default wind generation. Example usage: ```python windspeed_bias_correction = atlite.wind.calculate_windspeed_bias_correction( cutout, real_average="gwa3_250_windspeed_100m.tif", height=100, ) cutout.wind( atlite.windturbines.Enercon_E101_3000kW, windspeed_bias_correction=windspeed_bias_correction, windspeed_height=100, ) ```
1 parent 5d974d1 commit 4412bcd

3 files changed

Lines changed: 187 additions & 38 deletions

File tree

atlite/convert.py

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -478,19 +478,28 @@ def solar_thermal(
478478

479479

480480
# wind
481-
def convert_wind(ds, turbine):
481+
def convert_wind(
482+
ds: xr.Dataset, turbine, windspeed_bias_correction=None, from_height=None
483+
):
482484
"""
483485
Convert wind speeds for turbine to wind energy generation.
484486
"""
485487
V, POW, hub_height, P = itemgetter("V", "POW", "hub_height", "P")(turbine)
486488

487-
wnd_hub = windm.extrapolate_wind_speed(ds, to_height=hub_height)
489+
if windspeed_bias_correction is not None:
490+
ds = ds.assign(
491+
{f"wnd{from_height}m": ds[f"wnd{from_height}m"] * windspeed_bias_correction}
492+
)
493+
494+
wnd_hub = windm.extrapolate_wind_speed(
495+
ds, to_height=hub_height, from_height=from_height
496+
)
488497

489-
def _interpolate(da):
498+
def apply_power_curve(da):
490499
return np.interp(da, V, POW / P)
491500

492501
da = xr.apply_ufunc(
493-
_interpolate,
502+
apply_power_curve,
494503
wnd_hub,
495504
input_core_dims=[[]],
496505
output_core_dims=[[]],
@@ -503,7 +512,15 @@ def _interpolate(da):
503512
return da
504513

505514

506-
def wind(cutout, turbine, smooth=False, add_cutout_windspeed=False, **params):
515+
def wind(
516+
cutout,
517+
turbine: str | dict,
518+
smooth: bool | dict = False,
519+
add_cutout_windspeed: bool = False,
520+
windspeed_bias_correction: xr.DataArray | None = None,
521+
windspeed_height: int | None = None,
522+
**params,
523+
):
507524
"""
508525
Generate wind generation time-series.
509526
@@ -513,22 +530,32 @@ def wind(cutout, turbine, smooth=False, add_cutout_windspeed=False, **params):
513530
Parameters
514531
----------
515532
turbine : str or dict
516-
A turbineconfig dictionary with the keys 'hub_height' for the
517-
hub height and 'V', 'POW' defining the power curve.
518-
Alternatively a str refering to a local or remote turbine configuration
519-
as accepted by atlite.resource.get_windturbineconfig(). Locally stored turbine
520-
configurations can also be modified with this function. E.g. to setup a different hub
521-
height from the one used in the yaml file,one would write
522-
"turbine=get_windturbineconfig(“NREL_ReferenceTurbine_5MW_offshore”)|{“hub_height”:120}"
533+
A turbineconfig dictionary with the keys 'hub_height' for the hub height
534+
and 'V', 'POW' defining the power curve. Alternatively a str refering to
535+
a local or remote turbine configuration as accepted by
536+
atlite.resource.get_windturbineconfig(). Locally stored turbine
537+
configurations can also be modified with this function. E.g. to setup a
538+
different hub height from the one used in the yaml file, one would write
539+
>>> turbine = (
540+
>>> get_windturbineconfig("NREL_ReferenceTurbine_5MW_offshore")
541+
>>> | {"hub_height":120}
542+
>>> )
523543
smooth : bool or dict
524-
If True smooth power curve with a gaussian kernel as
525-
determined for the Danish wind fleet to Delta_v = 1.27 and
526-
sigma = 2.29. A dict allows to tune these values.
544+
If True smooth power curve with a gaussian kernel as determined for the
545+
Danish wind fleet to Delta_v = 1.27 and sigma = 2.29. A dict allows to
546+
tune these values.
527547
add_cutout_windspeed : bool
528-
If True and in case the power curve does not end with a zero, will add zero power
529-
output at the highest wind speed in the power curve. If False, a warning will be
530-
raised if the power curve does not have a cut-out wind speed. The default is
531-
False.
548+
If True and in case the power curve does not end with a zero, will add
549+
zero power output at the highest wind speed in the power curve. If
550+
False, a warning will be raised if the power curve does not have a
551+
cut-out wind speed. The default is False.
552+
windspeed_bias_correction : DataArray, optional
553+
Correction factor that is applied to the windspeed at
554+
`windspeed_height`. Such a correction factor can be calculated using
555+
:py:func:`atlite.wind.calculate_windspeed_bias_correction` with a raster
556+
dataset of mean wind speeds.
557+
windspeed_height : int, optional
558+
Height in meters of windspeed data from which to extrapolate
532559
533560
Note
534561
----
@@ -541,7 +568,7 @@ def wind(cutout, turbine, smooth=False, add_cutout_windspeed=False, **params):
541568
1074 – 1088. doi:10.1016/j.energy.2015.09.071
542569
543570
"""
544-
if isinstance(turbine, (str, Path, dict)):
571+
if isinstance(turbine, str | Path | dict):
545572
turbine = get_windturbineconfig(
546573
turbine, add_cutout_windspeed=add_cutout_windspeed
547574
)
@@ -550,7 +577,11 @@ def wind(cutout, turbine, smooth=False, add_cutout_windspeed=False, **params):
550577
turbine = windturbine_smooth(turbine, params=smooth)
551578

552579
return cutout.convert_and_aggregate(
553-
convert_func=convert_wind, turbine=turbine, **params
580+
convert_func=convert_wind,
581+
turbine=turbine,
582+
windspeed_bias_correction=windspeed_bias_correction,
583+
from_height=windspeed_height,
584+
**params,
554585
)
555586

556587

atlite/datasets/era5.py

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
https://confluence.ecmwf.int/display/CKB/ERA5%3A+data+documentation
99
"""
1010

11+
import datetime
1112
import logging
1213
import os
1314
import warnings
@@ -323,7 +324,7 @@ def noisy_unlink(path):
323324
logger.error(f"Unable to delete file {path}, as it is still in use.")
324325

325326

326-
def retrieve_data(product, chunks=None, tmpdir=None, lock=None, **updates):
327+
def retrieve_data(dataset, chunks=None, tmpdir=None, lock=None, **updates):
327328
"""
328329
Download data like ERA5 from the Climate Data Store (CDS).
329330
@@ -340,7 +341,7 @@ def retrieve_data(product, chunks=None, tmpdir=None, lock=None, **updates):
340341
client = cdsapi.Client(
341342
info_callback=logger.debug, debug=logging.DEBUG >= logging.root.level
342343
)
343-
result = client.retrieve(product, request)
344+
result = client.retrieve(dataset, request)
344345

345346
if lock is None:
346347
lock = nullcontext()
@@ -359,7 +360,7 @@ def retrieve_data(product, chunks=None, tmpdir=None, lock=None, **updates):
359360
ds = xr.open_dataset(target, chunks=chunks or {})
360361
if tmpdir is None:
361362
logger.debug(f"Adding finalizer for {target}")
362-
weakref.finalize(ds._file_obj._manager, noisy_unlink, target)
363+
weakref.finalize(ds._close.__self__.ds, noisy_unlink, target)
363364

364365
# Remove default encoding we get from CDSAPI, which can lead to NaN values after loading with subsequent
365366
# saving due to how xarray handles netcdf compression (only float encoded as short int seem affected)
@@ -373,6 +374,55 @@ def retrieve_data(product, chunks=None, tmpdir=None, lock=None, **updates):
373374
return ds
374375

375376

377+
def retrieve_windspeed_average(cutout, height, first_year=1980, last_year=None):
378+
"""
379+
Retrieve average windspeed from `first_year` to `last_year`
380+
381+
Parameters
382+
----------
383+
cutout : atlite.Cutout
384+
Cutout for which to retrieve windspeeds from CDS
385+
height : int
386+
Height of windspeeds (ERA5 typically knows about 10m, 100m, 150m?)
387+
first_year : int
388+
First year to take into account
389+
last_year : int, optional
390+
Last year to take into account (if omitted takes the previous year)
391+
392+
Returns
393+
-------
394+
DataArray
395+
Mean windspeed at cutout dimension
396+
"""
397+
if last_year is None:
398+
last_year = datetime.date.today().year - 1
399+
400+
ds = retrieve_data(
401+
"reanalysis-era5-single-levels-monthly-means",
402+
chunks=cutout.chunks,
403+
product_type="monthly_averaged_reanalysis",
404+
variable=[
405+
f"{height}m_u_component_of_wind",
406+
f"{height}m_v_component_of_wind",
407+
],
408+
area=_area(cutout.coords),
409+
grid=[cutout.dx, cutout.dy],
410+
year=[str(y) for y in range(first_year, last_year + 1)],
411+
month=[f"{m:02}" for m in range(1, 12 + 1)],
412+
time=["00:00"],
413+
)
414+
ds = _rename_and_clean_coords(ds)
415+
416+
return (
417+
sqrt(ds[f"u{height}"] ** 2 + ds[f"v{height}"] ** 2)
418+
.mean("date")
419+
.assign_attrs(
420+
units=ds[f"u{height}"].attrs["units"],
421+
long_name=f"{height} metre wind speed as long run average",
422+
)
423+
)
424+
425+
376426
def get_data(
377427
cutout,
378428
feature,
@@ -418,7 +468,8 @@ def get_data(
418468
sanitize = creation_parameters.get("sanitize", True)
419469

420470
retrieval_params = {
421-
"product": "reanalysis-era5-single-levels",
471+
"dataset": "reanalysis-era5-single-levels",
472+
"product_type": "reanalysis",
422473
"area": _area(coords),
423474
"chunks": cutout.chunks,
424475
"grid": [cutout.dx, cutout.dy],
@@ -431,7 +482,7 @@ def get_data(
431482

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

434-
def retrieve_once(time):
485+
def retrieve_once(time, longrunaverage=False):
435486
ds = func({**retrieval_params, **time})
436487
if sanitize and sanitize_func is not None:
437488
ds = sanitize_func(ds)

atlite/wind.py

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,20 @@
77

88
import logging
99
import re
10+
from pathlib import Path
1011

1112
import numpy as np
13+
import rasterio as rio
14+
import xarray as xr
15+
16+
from . import datasets
1217

1318
logger = logging.getLogger(__name__)
1419

1520

16-
def extrapolate_wind_speed(ds, to_height, from_height=None):
21+
def extrapolate_wind_speed(
22+
ds: xr.Dataset, to_height: int | float, from_height: int | None = None
23+
):
1724
"""
1825
Extrapolate the wind speed from a given height above ground to another.
1926
@@ -28,8 +35,7 @@ def extrapolate_wind_speed(ds, to_height, from_height=None):
2835
Dataset containing the wind speed time-series at 'from_height' with key
2936
'wnd{height:d}m' and the surface orography with key 'roughness' at the
3037
geographic locations of the wind speeds.
31-
from_height : int
32-
(Optional)
38+
from_height : int, optional
3339
Height (m) from which the wind speeds are interpolated to 'to_height'.
3440
If not provided, the closest height to 'to_height' is selected.
3541
to_height : int|float
@@ -51,14 +57,11 @@ def extrapolate_wind_speed(ds, to_height, from_height=None):
5157
Retrieved 2019-02-15.
5258
5359
"""
54-
# Fast lane
55-
to_name = f"wnd{int(to_height):0d}m"
56-
if to_name in ds:
57-
return ds[to_name]
58-
5960
if from_height is None:
6061
# Determine closest height to to_name
61-
heights = np.asarray([int(s[3:-1]) for s in ds if re.match(r"wnd\d+m", s)])
62+
heights = np.asarray(
63+
[int(m.group(1)) for s in ds if (m := re.match(r"wnd(\d+)m", s))]
64+
)
6265

6366
if len(heights) == 0:
6467
raise AssertionError("Wind speed is not in dataset")
@@ -67,17 +70,81 @@ def extrapolate_wind_speed(ds, to_height, from_height=None):
6770

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

73+
# Fast lane
74+
if from_height == to_height:
75+
return ds[from_name]
76+
7077
# Wind speed extrapolation
7178
wnd_spd = ds[from_name] * (
7279
np.log(to_height / ds["roughness"]) / np.log(from_height / ds["roughness"])
7380
)
7481

75-
wnd_spd.attrs.update(
82+
return wnd_spd.assign_attrs(
7683
{
7784
"long name": f"extrapolated {to_height} m wind speed using logarithmic "
7885
f"method with roughness and {from_height} m wind speed",
7986
"units": "m s**-1",
8087
}
81-
)
88+
).rename(f"wnd{to_height}m")
89+
90+
91+
def calculate_windspeed_bias_correction(
92+
cutout, real_average: str | rio.DatasetReader, height: int = 100
93+
):
94+
"""
95+
Derive a bias correction factor for windspeed at lra_height
96+
97+
Regrids the raster dataset in real_average to cutout grid, retrieves the average
98+
windspeed from the first dataset that offers
99+
:py:func:`retrieve_longrunaverage_windspeed` (only ERA5, currently).
100+
101+
Parameters
102+
----------
103+
cutout : Cutout
104+
Atlite cutout
105+
real_average : Path or rasterio.Dataset
106+
Raster dataset with wind speeds to bias correct average wind speeds
107+
height : int
108+
Height in meters at which average windspeeds are provided
82109
83-
return wnd_spd.rename(to_name)
110+
Returns
111+
-------
112+
DataArray
113+
Ratio between windspeeds in `real_average` to those of average windspeeds in
114+
dataset.
115+
"""
116+
if isinstance(real_average, str | Path):
117+
real_average = rio.open(real_average)
118+
119+
if isinstance(real_average, rio.DatasetReader):
120+
real_average = rio.band(real_average, 1)
121+
122+
if isinstance(real_average, rio.Band):
123+
real_average, transform = rio.warp.reproject(
124+
real_average,
125+
np.empty(cutout.shape),
126+
dst_crs=cutout.crs,
127+
dst_transform=cutout.transform,
128+
dst_nodata=np.nan,
129+
resampling=rio.enums.Resampling.average,
130+
)
131+
132+
real_average = xr.DataArray(
133+
real_average, [cutout.coords["y"], cutout.coords["x"]]
134+
)
135+
136+
for module in np.atleast_1d(cutout.module):
137+
retrieve_windspeed_average = getattr(
138+
getattr(datasets, module), "retrieve_windspeed_average"
139+
)
140+
if retrieve_windspeed_average is not None:
141+
break
142+
else:
143+
raise AssertionError(
144+
"None of the datasets modules define retrieve_windspeed_average"
145+
)
146+
147+
logger.info("Retrieving average windspeeds at %d from module %s", height, module)
148+
data_average = retrieve_windspeed_average(cutout, height)
149+
150+
return real_average / data_average

0 commit comments

Comments
 (0)