"""Binned Wind Climate Module
Extends windkit Binned Wind Climate module to add a function for creating a weibull
distribution out of the binned wind climate using the WAsP method. It contains a much
faster fortran based binning routine that can be used for example in combination with
mesoscale model output, where speed is crucial.
It also contains functions for calculating stability information efficiently using
a binning algorithm which computes the conditional mean stability based on the wind
histogram in a single pass without storing the whole time series of wind speed.
"""
__all__ = [
"bwc_from_tswc",
"calc_temp_scale",
"stability_histogram",
"create_histogram_z0",
"mean_z0",
"baroclinicity_histogram",
"bwc_resample_like",
"bwc_resample_sectors",
"bwc_resample_wsbins_like",
]
import logging
import numpy as np
import pyproj
import xarray as xr
from pywasp._batch import batch_layout
from pywasp._errors import PywaspError
from pywasp.wasp.config import Config
from pywasp._windkit_internal import (
_ALL_VARS_META,
_ANY_3D_SPATIAL,
_BWC_ATTRS,
_BWC_SCHEMA,
_HIS_ATTRS,
_HIS_BARO_ATTRS,
_HIS_STAB_ATTRS,
_MEAN_BARO_ATTRS,
_MEAN_STAB_ATTRS,
_TSWC_SCHEMA,
_TS_STAB_ATTRS,
_bwc_from_counts,
_create_time_attributes,
_update_history,
_update_var_attrs,
validate_input,
)
from windkit.wind_climate.time_series_wind_climate import WS, WD, DIM_TIME
from windkit import create_sector_coords
from windkit import create_wsbin_coords
from windkit import spatial as wksp
from windkit.spatial import (
to_point,
get_crs,
set_crs,
interp_unstructured_like,
are_spatially_equal,
)
from pywasp.core import Rvea0288, Rvea0326 # pylint:disable=no-name-in-module
from pywasp.wasp.time_series_wind_climate import tswc_rotate
logger = logging.getLogger(__name__)
# fortran calls
_CREATE_HIST = Rvea0288.histograms.add_histogram_points
_CALC_TSCALE = Rvea0326.calc_points.tstar_points
_COND_HIST = Rvea0288.histograms.conditional_hist
_MEAN_HIST = Rvea0288.histograms.mean_from_hist
_STD_HIST = Rvea0288.histograms.std_from_hist
_RESAMPLE_HIST = Rvea0288.histograms.histogram_resample_sectors
_RESAMPLE_HIST_WSBINS = Rvea0288.histograms.histogram_resample_wsbins
_RESAMPLE_HIST_WSBINS_AK = Rvea0288.histograms.histogram_resample_wsbins_ak
_DGDZ = Rvea0288.baroclin.compute_dgdz
_DGDZ_TIME = Rvea0288.baroclin.compute_dgdz_t
# constants
RTD = 180 / np.pi
# Amortize Dask graph overhead while capping coalesced input blocks at 64 MiB.
_HISTOGRAM_TARGET_SAMPLES = 24 * 31
_HISTOGRAM_MAX_INPUT_BYTES = 64 * 1024**2
def _block_layout(array, core_dims):
"""Wrap one eager gufunc block in a batch layout."""
batch_dims = tuple(f"batch_{index}" for index in range(array.ndim - len(core_dims)))
data = xr.DataArray(array, dims=(*batch_dims, *core_dims))
return batch_dims, data, batch_layout(data, core_dims=core_dims, required_coords=())
def _histogram_layout(ds, hist, revert_to_original):
"""Return inputs on either their native or point layout."""
if revert_to_original:
return ds, hist
return to_point(ds), None if hist is None else to_point(hist)
def _broadcast_block(array, array_dims, target_dims, target_shape):
"""Broadcast a NumPy gufunc block without expanding the xarray input."""
for axis, dim in enumerate(target_dims):
if dim not in array_dims:
array = np.expand_dims(array, axis)
return np.broadcast_to(array, target_shape)
def _run_histograms(wspd, wdir, values, bin_ws, bin_wd, n_points):
"""Run the compiled histogram once for each value array."""
outputs = []
count_bins_exceeded = None
for value in values:
# hist and ierr are intent(inout), so never pass caller-owned views.
histogram = np.zeros(
(bin_ws.size, bin_wd.size, n_points), dtype=np.float32, order="F"
)
errors = np.zeros(n_points, dtype=np.int32, order="F")
_CREATE_HIST(wspd, wdir, 0, bin_ws, bin_wd, value, histogram, errors)
outputs.append(histogram)
if count_bins_exceeded is None:
count_bins_exceeded = errors
return (*outputs, count_bins_exceeded)
def _histogram_kernel(wspd, wdir, *values, bin_ws, bin_wd, input_batch_dims):
"""Bin one NumPy block supplied by the lazy gufunc path."""
arrays = tuple(
_broadcast_block(array, dims, input_batch_dims[0], wspd.shape)
for array, dims in zip((wspd, wdir, *values), input_batch_dims, strict=True)
)
wspd, wdir, *values = arrays
batch_dims, speed, ctx = _block_layout(wspd, (DIM_TIME,))
dims = speed.dims
wspd = ctx.flatten(speed, dtype=np.float32)
wdir = ctx.flatten(xr.DataArray(wdir, dims=dims), dtype=np.float32)
values = (
tuple(
ctx.flatten(xr.DataArray(value, dims=dims), dtype=np.float32)
for value in values
)
if values
else (np.ones_like(wspd),)
)
output = _run_histograms(wspd, wdir, values, bin_ws, bin_wd, ctx.n_points)
restored = [
ctx.restore(array, core_dims=("wsbin", "sector"))
.transpose(*batch_dims, "wsbin", "sector")
.data
for array in output[:-1]
]
return (*restored, ctx.restore(output[-1], core_dims=()).data)
def _apply_histogram_kernel(inputs, names, bin_ws, bin_wd):
"""Apply the histogram kernel to one complete eager or Dask time chunk."""
return xr.apply_ufunc(
_histogram_kernel,
*inputs,
input_core_dims=[[DIM_TIME]] * len(inputs),
output_core_dims=[["wsbin", "sector"]] * len(names) + [[]],
kwargs={
"bin_ws": bin_ws,
"bin_wd": bin_wd,
"input_batch_dims": [
tuple(dim for dim in array.dims if dim != DIM_TIME) for array in inputs
],
},
dask="parallelized",
vectorize=False,
output_dtypes=[np.float32] * len(names) + [np.int32],
dask_gufunc_kwargs={
"output_sizes": {"wsbin": bin_ws.size, "sector": bin_wd.size}
},
)
def _histogram_time_slices(inputs, time_chunks):
"""Coalesce small time chunks without creating oversized input blocks."""
speed = inputs[0]
batch_size = np.prod(
[
max(speed.chunksizes.get(dim, (speed.sizes[dim],)))
for dim in speed.dims
if dim != DIM_TIME
],
dtype=np.int64,
)
bytes_per_sample = batch_size * sum(
np.dtype(array.dtype).itemsize for array in inputs
)
target = min(
_HISTOGRAM_TARGET_SAMPLES,
max(1, _HISTOGRAM_MAX_INPUT_BYTES // max(1, bytes_per_sample)),
)
slices = []
start = stop = 0
for chunk_size in time_chunks:
stop += chunk_size
if stop - start >= target:
slices.append(slice(start, stop))
start = stop
if start < stop:
slices.append(slice(start, stop))
return slices
def _slice_time_block(inputs, indexer):
"""Return one block with a single Dask chunk along its time core dimension."""
block = tuple(array.isel({DIM_TIME: indexer}) for array in inputs)
return tuple(
array.chunk({DIM_TIME: -1})
if array.chunks is not None and len(array.chunksizes[DIM_TIME]) > 1
else array
for array in block
)
def _histogram_sums(
ds, values=None, hist=None, wsbin_width=1, n_wsbins=30, n_sectors=12
):
"""Create eager or lazy weighted histograms without a zero template.
Parameters
----------
ds : xr.Dataset
Dataset containing wind speed and wind direction.
values : dict of str to xr.DataArray, optional
Values to sum by wind bin. If omitted, wv_count is produced.
hist : xr.Dataset, optional
Existing sums to add to the newly created histograms.
wsbin_width : float
Width of the wind-speed bins.
n_wsbins : int
Number of wind-speed bins.
n_sectors : int
Number of direction sectors.
Returns
-------
xr.Dataset
Histogram sums and count_bins_exceeded. NumPy input produces NumPy
output; Dask input remains lazy and spatially chunked.
"""
values = {} if values is None else values
names = tuple(values) if values else ("wv_count",)
# Compatibility for xarray 2024.9.0; remove when that version is unsupported.
crs_attrs = ds["crs"].attrs.copy() if "crs" in ds.coords else {}
if hist is not None:
# are_spatially_equal only reads the west_east/south_north coordinates,
# never a data variable, and Dataset.chunk() never chunks dimension
# coordinates -- so this stays cheap and adds no Dask tasks even when
# ds/hist's own data variables are lazy. Run it unconditionally.
if not are_spatially_equal(ds, hist):
raise PywaspError(
"Existing histogram and time series must have the same spatial structure"
)
if hist is None:
ws_bins = create_wsbin_coords(n_wsbins, width=wsbin_width)
wd_bins = create_sector_coords(n_sectors)
else:
ws_bins = hist["wsbin"]
wd_bins = hist["sector"]
bin_ws = np.asarray(ws_bins, dtype=np.float32, order="F")
bin_wd = np.asarray(wd_bins, dtype=np.float32, order="F")
# Let apply_ufunc align named dimensions; the kernel expands missing axes
# inside each NumPy block to avoid expensive dataset-wide broadcasting.
inputs = (ds[WS], ds[WD], *(values[name] for name in names if values))
if any(array.chunks is not None for array in inputs):
inputs = xr.unify_chunks(*inputs)
time_chunks = next(
(array.chunksizes[DIM_TIME] for array in inputs if array.chunks is not None), ()
)
if len(time_chunks) <= 1:
output = _apply_histogram_kernel(inputs, names, bin_ws, bin_wd)
else:
time_slices = _histogram_time_slices(inputs, time_chunks)
partials = [
_apply_histogram_kernel(
_slice_time_block(inputs, indexer),
names,
bin_ws,
bin_wd,
)
for indexer in time_slices
]
if len(partials) == 1:
output = partials[0]
else:
output = tuple(
xr.concat(arrays, dim="_histogram_time_chunk").sum(
"_histogram_time_chunk", dtype=arrays[0].dtype
)
for arrays in zip(*partials, strict=True)
)
result = xr.Dataset(attrs=hist.attrs if hist is not None else ds.attrs)
for name, array in zip(names, output[:-1], strict=True):
result[name] = array.transpose("wsbin", "sector", ...)
result["count_bins_exceeded"] = output[-1]
result = result.assign_coords({**ws_bins.coords, **wd_bins.coords})
# Compatibility for xarray 2024.9.0; remove when that version is unsupported.
if crs_attrs and "crs" in result.coords:
result["crs"].attrs = crs_attrs
if hist is not None:
required = {*names, "count_bins_exceeded"}
missing = required - set(hist.data_vars)
if missing:
raise PywaspError(
"Existing histogram is missing variables: " + ", ".join(sorted(missing))
)
for name in (*names, "count_bins_exceeded"):
try:
current, previous = xr.align(
result[name], hist[name], join="exact", copy=False
)
except ValueError as error:
raise PywaspError(
"Existing histogram coordinates do not match the time series"
) from error
result[name] = current + previous
return result
def _update_time_attributes_without_computing(result, ds, hist=None):
"""Update time metadata without executing a Dask-backed time series.
WindKit's metadata helper counts complete records with a dropna over the
data, which is exact for eager data but executes every Dask block.
count, count_missing and recovery_percentage need those real data values
to know which samples are missing; start_time, end_time and
count_expected only need the time index. Once any chunk in an
accumulation has been processed lazily, the true count for that chunk is
permanently unknown, so count/count_missing/recovery_percentage are
omitted from that point on -- for this call and every later one
accumulating on top of it -- rather than silently reporting a number
that is wrong, such as 100% recovery for data that in fact recovered 0%.
"""
all_eager = all(array.chunks is None for array in ds.data_vars.values())
hist_omitted_count = hist is not None and "count" not in hist.attrs
if all_eager and not hist_omitted_count:
attrs = _create_time_attributes(ds, hist=hist)
else:
dummy = np.broadcast_to(np.float32(0.0), (ds.sizes[DIM_TIME],))
proxy = xr.Dataset(
{WS: (DIM_TIME, dummy), WD: (DIM_TIME, dummy)},
coords={DIM_TIME: ds[DIM_TIME]},
)
if hist_omitted_count:
hist = xr.Dataset(attrs={**hist.attrs, "count": 0})
attrs = _create_time_attributes(proxy, hist=hist)
for key in ("count", "count_missing", "recovery_percentage"):
attrs.pop(key, None)
for key in ("count", "count_missing", "recovery_percentage"):
if key not in attrs:
result.attrs.pop(key, None)
result.attrs.update(attrs)
return result
def _conditional_histogram_kernel(hist, freq, percentile):
"""Apply the conditional-histogram core routine to one spatial block."""
batch_dims, histogram_da, ctx = _block_layout(hist, ("wsbin", "sector"))
dims = histogram_da.dims
histogram = ctx.flatten(histogram_da, dtype=np.float32)
frequency = ctx.flatten(
xr.DataArray(freq, dims=dims),
dtype=np.float32,
)
conditional, sector_frequency = _COND_HIST(histogram, frequency, percentile)
conditional = ctx.restore(conditional, core_dims=("sector",)).transpose(
*batch_dims, "sector"
)
sector_frequency = ctx.restore(sector_frequency, core_dims=("sector",)).transpose(
*batch_dims, "sector"
)
return conditional.data, sector_frequency.data
def _conditional_histogram(hist, freq, percentile):
"""Return conditional sums and frequencies eagerly or lazily."""
return xr.apply_ufunc(
_conditional_histogram_kernel,
hist,
freq,
input_core_dims=[["wsbin", "sector"], ["wsbin", "sector"]],
output_core_dims=[["sector"], ["sector"]],
kwargs={"percentile": percentile},
dask="parallelized",
vectorize=False,
output_dtypes=[np.float32, np.float32],
dask_gufunc_kwargs={"allow_rechunk": True},
)
def _sector_statistic_kernel(*arrays, statistic_name):
"""Apply a sector-wise WAsP statistic to one spatial block."""
batch_dims, first, ctx = _block_layout(arrays[0], ("sector",))
dims = first.dims
input_arrays = [xr.DataArray(array, dims=dims) for array in arrays]
fortran_arrays = [ctx.flatten(array, dtype=np.float32) for array in input_arrays]
if statistic_name == "mean":
result = _MEAN_HIST(*fortran_arrays)
elif statistic_name == "std":
result = _STD_HIST(*fortran_arrays)
else:
raise PywaspError(f"Unknown sector statistic: {statistic_name}")
return (
ctx.restore(result, core_dims=("sector",)).transpose(*batch_dims, "sector").data
)
def _sector_statistic(statistic_name, *arrays):
"""Return a sector statistic eagerly or lazily."""
return xr.apply_ufunc(
_sector_statistic_kernel,
*arrays,
input_core_dims=[["sector"]] * len(arrays),
output_core_dims=[["sector"]],
kwargs={"statistic_name": statistic_name},
dask="parallelized",
vectorize=False,
output_dtypes=[np.float32],
dask_gufunc_kwargs={"allow_rechunk": True},
)
[docs]
@validate_input(_TSWC_SCHEMA, run_extra_checks=False)
def bwc_from_tswc(
ds,
hist=None,
normalize=True,
revert_to_original=True,
wsbin_width=1,
n_wsbins=30,
n_sectors=12,
wind_dir_crs=None,
):
"""Add timeseries to histogram
Converts a time series with wind speed & direction to derive
summed histogram of the wind vector.
.. warning::
This function is experimental and its signature may change.
Parameters
----------
ds: xr.Dataset
PyWAsP Timeseries dataset. Can have any of the pywasp
spatial data structures
hist: xr.Dataset, optional
Histogram with dimensions point, wsbin, sector containing counts to
add to this time series. The input is not modified. Default is None,
creating a histogram using wsbin_width, n_wsbins, and n_sectors.
normalize: boolean
Normalize histogram for each sector, storing the frequency per sector in "wdfreq"
variable, Default True
revert_to_original: boolean
If True (default), operate on and return ds's own native spatial
structure directly -- there is no promotion/reversion round trip to
pay for any more, unlike before. If False, promote to point
structure and return that; still useful when composing with other
point-structured accumulators, e.g. across chunked calls.
wsbin_width: float
width of wind speed bins
n_wsbins: int
Number of wind speed bins
n_sectors: int
Number of sectors (wind direction bins)
wind_dir_crs : CRS-like, optional
CRS that defines the output wind-direction frame. If ``ds`` has a
``wind_dir_crs`` attribute, input directions are treated as already
expressed in that CRS frame. Otherwise, input directions are assumed
true-north-relative. Directions are rotated into a projected target
CRS's grid-north frame before binning; a geographic target CRS retains
them in the true-north frame.
Returns
-------
hist: xr.Dataset
Histogram with dimensions point, wsbin, sector containing the values
from the time series. The per-location count_bins_exceeded diagnostic
is retained for both normalized and unnormalized output.
Notes
-----
NumPy-backed inputs are evaluated eagerly. Dask-backed inputs remain lazy
and histogram each time chunk independently before a tree reduction,
preserving the input spatial chunks. Dask is therefore optional.
For lazy output, inspect count_bins_exceeded after computation instead of
relying on the eager bin-overflow warning.
The result's ``count``, ``count_missing`` and ``recovery_percentage``
attributes need the actual data values (a missing-sample count) and are
therefore only set for eager input; ``start_time``, ``end_time`` and
``count_expected`` are always set. For lazy input, or once any chunk in
an accumulation has been lazy, those three attributes are omitted rather
than reported as a number that could be wrong.
"""
existing_wind_dir_crs = ds.attrs.get("wind_dir_crs")
if existing_wind_dir_crs is not None:
existing_wind_dir_crs = pyproj.CRS.from_user_input(existing_wind_dir_crs)
target_wind_dir_crs = existing_wind_dir_crs
if wind_dir_crs is not None:
target_wind_dir_crs = pyproj.CRS.from_user_input(wind_dir_crs)
ds = wksp.reproject(ds, target_wind_dir_crs)
rotation = None
if wind_dir_crs is not None and existing_wind_dir_crs is None:
rotation = wksp.grid_convergence(ds)
elif (
wind_dir_crs is not None
and existing_wind_dir_crs is not None
and not existing_wind_dir_crs.equals(target_wind_dir_crs)
):
rotation = wksp.grid_convergence(ds, source=existing_wind_dir_crs)
if rotation is not None:
ds = tswc_rotate(ds, -rotation)
logger.debug(
f"Convert time series ({ds[WS].sizes[DIM_TIME]} samples) into histogram"
)
previous_hist = hist
anchor_ds, hist = _histogram_layout(ds, hist, revert_to_original)
hist = _histogram_sums(
anchor_ds,
hist=hist,
wsbin_width=wsbin_width,
n_wsbins=n_wsbins,
n_sectors=n_sectors,
)
hist.attrs.update(ds.attrs)
hist = _update_time_attributes_without_computing(hist, ds, previous_hist)
errors = hist["count_bins_exceeded"]
if errors.chunks is None:
n_failed_points = np.count_nonzero(errors.data > 0)
if n_failed_points > 0:
logger.warning(
"The wind speeds exceeded the binning range "
f"({float(hist.wsbin.max())}) in {n_failed_points} points"
)
if normalize:
hist = _normalize_hist(hist)
attrs = (
{
**_BWC_ATTRS,
"count_bins_exceeded": _HIS_ATTRS["count_bins_exceeded"],
}
if normalize
else _HIS_ATTRS
)
hist = _update_var_attrs(hist, attrs)
if target_wind_dir_crs is not None:
hist.attrs["wind_dir_crs"] = target_wind_dir_crs.to_wkt()
return _update_history(hist)
def _accumulate_wv_count(
ds,
hist,
wv_count,
hist_was_supplied,
histogram_name,
wsbin_width,
validate_bins=False,
):
"""Add the current time series to counts used for finalization."""
if wv_count is not None:
return bwc_from_tswc(
ds, hist=wv_count, normalize=False, revert_to_original=True
)
if hist_was_supplied:
raise PywaspError(
f"Finalizing a supplied {histogram_name} histogram requires the "
"matching accumulated wind-vector counts. Build them alongside "
"the histogram with bwc_from_tswc(..., normalize=False) for every "
"chunk already added, on the same spatial structure as hist, and "
"pass them as wv_count. Counts from only the final chunk would "
"silently inflate the means."
)
wsbin = hist["wsbin"].values
if validate_bins:
if wsbin.size < 2:
raise PywaspError(
f"{histogram_name.capitalize()} histograms require at least "
"two wind-speed bins"
)
wsbin_width = float(wsbin[1] - wsbin[0])
if wsbin_width <= 0.0 or not np.allclose(np.diff(wsbin), wsbin_width):
raise PywaspError(
f"{histogram_name.capitalize()} histogram wind-speed bins "
"must be uniformly spaced"
)
return bwc_from_tswc(
ds,
normalize=False,
revert_to_original=True,
wsbin_width=wsbin_width,
n_wsbins=hist.sizes["wsbin"],
n_sectors=hist.sizes["sector"],
)
[docs]
def calc_temp_scale(ds, revert_to_original=True, enhmix=1.0, use_humidity=False):
"""Calculate temperature scale
This calculates a surface layer temperature scale that can
be used to model the wind profile. Using the enhmix can be used
to control mixing over land, because ERA5 has a large bias
in $u_*$ over land. If you supply enhmix you will have to
add a landmask because it is only applied over land. If use_humidity
is true the humidity is used for calculating the air density.
.. warning::
This function is experimental and its signature may change.
Parameters
----------
ds : xarray.Dataset
Dataset containing variables
["PSFC", "T2", "UST", "HFX", "LH", "PBLH"]
Can have any pywasp spatial structure
revert_to_original: boolean
Return the histogram input in original format from the
input :class:`xarray.Dataset`? If false, keeps the data in stacked point
format which is more efficient when doing large
calculations for numerical wind atlases.
enhmix : float
Decrease mixing over land with this factor. If different from
1.0, requires a land mask variable "LANDMASK" since it is only
applied over land.
use_humidity: bool
If True, use the humdity for calculating the air density. If
true, requires variable "elevation" (for the terrain elevation)
and "Q2" to be present on the dataset. By default False
Returns
-------
hist : xr.DataArray
Height-independent ``temp_scale`` DataArray with the input's remaining
spatial and time dimensions.
"""
source = ds if revert_to_original else to_point(ds)
if "UST" not in source.data_vars:
raise KeyError("Required variable 'UST' not found in dataset!")
ctx = batch_layout(source, anchor="UST", core_dims=(DIM_TIME,), required_coords=())
def _over_time(name):
return ctx.flatten(source[name], dtype=np.float32)
def _per_location(name):
return ctx.flatten(source[name], core_dims=(), dtype=np.float32)
def _zeros(*shape):
return np.zeros(shape, dtype=np.float32, order="F")
try:
logger.debug("Calculating temperature scale")
ust = _over_time("UST")
# The land mask only matters when mixing is reduced over land, and the
# humidity fields only when use_humidity is set. Both stay zero-filled
# otherwise, even when the dataset happens to carry them.
land = _per_location("LANDMASK") if enhmix != 1.0 else _zeros(ctx.n_points)
if use_humidity:
elevation = _per_location("elevation")
q2 = _over_time("Q2")
else:
logger.debug("Using dummy variables Q2 and elevation")
elevation = _zeros(ctx.n_points)
q2 = _zeros(*ust.shape)
tstar = _CALC_TSCALE(
_over_time("PSFC"),
_over_time("T2"),
ust,
_over_time("HFX"),
_over_time("LH"),
_over_time("PBLH"),
elevation,
q2,
land,
enhmix,
int(use_humidity),
)
except KeyError as e:
raise KeyError(f"Required variable {e} not found in dataset!")
# The surface fields are height-independent by construction, so a height
# batch dimension is reduced away rather than carried into the result. The
# reduction is checked: restore proves the values really are constant.
drop_height = ("height",) if "height" in ctx.batch_dims else ()
res = ctx.restore(
tstar,
core_dims=(DIM_TIME,),
drop_batch_dims=drop_height,
name="temp_scale",
).to_dataset()
res = _update_var_attrs(res, _TS_STAB_ATTRS)
return _update_history(res["temp_scale"])
[docs]
def stability_histogram(
ds,
hist=None,
finalize=True,
revert_to_original=True,
wsbin_width=1,
n_wsbins=40,
n_sectors=12,
percentile=0.5,
wv_count=None,
landmask=None,
):
"""Add timeseries to existing histogram
Uses the stability parameters "temp_scale" and "ustar_over_pblh".
See the paper "Using observed and modelled heat fluxes for
improved extrapolation of wind distributions" for the definitions
of these variables. The temp_scale variable is defined as $T_*$
in that paper, whereas the "ustar_over_pblh" should be defined as
$u_*/pblh$ as input for this function. This is done to give priority
to the smallest pblh that are most important for the wind profile.
The output is reversed ($**-1$) to give the input format as required
by WAsP ($pblh/u_*$).
The time dimension is copied so that one can see what was the
period that was used to generate the histogram.
This method is optimized for large datasets, where storing
all values in memory is not possible. It uses a binning so that
the stability parameters are calculated as a conditional mean (on the
wind speed and direction) in a single pass through the data.
The histogram is created if not provided, but it can also be
provided to add more data to an existing histogram. The idea
is than that you sum to the existing histogram and then
finalize at the end using finalize=True. When using finalize=True,
the histogram is converted into a mean and standard deviation
of the stability parameters per wind speed and direction bin. To
calculate both sea and land stability, you will have to provide
a landmask so that the finalize step can separate between sea
and land points.
.. warning::
This function is experimental and its signature may change.
Parameters
----------
ds : xarray.Dataset
Dataset containing variables ['wind_speed', 'wind_direction',
'temp_scale', 'ustar_over_pblh']
Can have any pywasp spatial structure
hist : xarray.Dataset
Histogram with dimensions point, wsbin, sector
finalize : boolean
Convert the stabilility histogram into a mean and
standard deviation?
revert_to_original : boolean
If True (default), operate on and return ds's own native spatial
structure directly; there is no promotion/reversion round trip. If
False, promote to point structure and return that; still useful when
composing with other point-structured accumulators, e.g. across
chunked calls.
wsbin_width : float
width of wind speed bins
n_wsbins : int
Number of wind speed bins
n_sectors : int
Number of sectors (wind direction bins)
percentile : float
Percentile of the wind speed distribution to use for
calculating the stability parameters. Default is 0.5,
which is the median.
wv_count : xarray.Dataset, optional
Histogram with dimensions point, wsbin, sector
containg a count. If provided, this histogram is used
for the finalization step. If None, a new histogram
is created from the input ``ds``. Default is None.
landmask: xr.DataArray
Landmask with values 0 (sea) and 1 (land)
Returns
-------
hist : xarray.Dataset
Histogram with dimensions point, wsbin, sector
with the values from ds-timeseries added.
Raises
------
ValueError
If landmask is provided, but does not have the same
spatial structure as ds.
PywaspError
If hist is provided, but does not have the same
spatial structure as ds; or if a supplied hist is finalized
without the matching accumulated wv_count.
ValueError
If landmask is provided, finalize=True and
there are only land or sea points in ds.
"""
if landmask is not None:
if not are_spatially_equal(ds, landmask):
raise ValueError(
"Input ds and landmask must have the same spatial structure"
)
# A supplied histogram may already hold accumulated sums from earlier chunks,
# so counts covering only the current chunk must never be used to finalize it.
hist_is_accumulated = hist is not None
ds_sub = ds[[WS, WD, "temp_scale", "ustar_over_pblh"]]
dss, previous_hist = _histogram_layout(ds_sub, hist, revert_to_original)
hist = _histogram_sums(
dss,
values={
"sum_temp_scale": dss["temp_scale"],
"sum_squared_temp_scale": dss["temp_scale"] ** 2,
"sum_pblh": dss["ustar_over_pblh"],
},
hist=previous_hist,
wsbin_width=wsbin_width,
n_wsbins=n_wsbins,
n_sectors=n_sectors,
)
hist = _update_var_attrs(hist, _HIS_STAB_ATTRS)
hist = _update_time_attributes_without_computing(hist, dss, previous_hist)
if finalize:
wv_count = _accumulate_wv_count(
dss,
hist,
wv_count,
hist_is_accumulated,
"stability",
wsbin_width,
)
res = _finalize_meso(
hist, wv_count["wv_count"], landmask, percentile=percentile
)
return _update_history(res)
return _update_history(hist)
def _stab_mean_std(hist, wv_count, percentile=0.5):
"""Finalize stability sums as sector means and standard deviations."""
counts = wv_count
cond_hist, freq = _conditional_histogram(hist["sum_pblh"], counts, percentile)
pblhmean = _sector_statistic("mean", cond_hist, freq)
cond_hist, freq = _conditional_histogram(hist["sum_temp_scale"], counts, percentile)
cond_hist2, freq = _conditional_histogram(
hist["sum_squared_temp_scale"], counts, percentile
)
tstarstd = _sector_statistic("std", cond_hist, cond_hist2, freq)
tstarmean = _sector_statistic("mean", cond_hist, freq)
result = xr.Dataset(
{
"mean_pblh_scale": 1 / pblhmean,
"rms_temp_scale": tstarstd,
"mean_temp_scale": tstarmean,
},
attrs=hist.attrs,
)
# The conditional-histogram gufunc does not always carry the crs coordinate
# through (older xarray/pyproj drop its attributes); re-set it from the input
# so the result's crs coordinate is identical across dependency versions.
if "crs" in hist.coords:
result = set_crs(result, get_crs(hist))
return result
def create_histogram_z0(
ds,
hist=None,
finalize=True,
revert_to_original=True,
wsbin_width=1,
n_wsbins=40,
n_sectors=12,
percentile=0.5,
wv_count=None,
landmask=None,
):
"""Add timeseries to existing histogram
Uses the stability parameters to derive
summed histogram of the surface layer temperature scale,
squared surface layer temperature scale, boundary
layer height and logarithm of roughness length. The time
dimension is copied so that one can see what was the
period that was used to generate the histogram.
.. warning::
This function is experimental and its signature may change.
Parameters
----------
ds: xr.Dataset
Dataset containing variables
['wind_speed', 'wind_direction', 'LANDMASK', 'ZNT']
Can have any pywasp spatial structure
hist: xr.Dataset
Histogram with dimensions point, wsbin, sector
finalize: boolean
Convert the stabilility histogram into a mean and
standard deviation?
revert_to_original: boolean
If True (default), operate on and return ds's own native spatial
structure directly; there is no promotion/reversion round trip. If
False, promote to point structure and return that; still useful when
composing with other point-structured accumulators, e.g. across
chunked calls.
wsbin_width: float
width of wind speed bins
n_wsbins: int
Number of wind speed bins
n_sectors: int
Number of sectors (wind direction bins)
kwargs:
Other keyword arguments passed on to _finalize_meso
Returns
-------
hist: xr.Dataset
Histogram with dimensions point, wsbin, sector
with the values from ds-timeseries added.
Raises
------
PywaspError
If a supplied hist is finalized without the matching accumulated
wv_count.
"""
# A supplied histogram may already hold accumulated sums from earlier chunks,
# so counts covering only the current chunk must never be used to finalize it.
hist_is_accumulated = hist is not None
ds_sub = ds[[WS, WD, "ZNT"]]
dss, previous_hist = _histogram_layout(ds_sub, hist, revert_to_original)
hist = _histogram_sums(
dss,
values={"z0sum": np.log(dss["ZNT"])},
hist=previous_hist,
wsbin_width=wsbin_width,
n_wsbins=n_wsbins,
n_sectors=n_sectors,
)
hist = _update_var_attrs(
hist,
{
"z0sum": _ALL_VARS_META["z0sum"],
"count_bins_exceeded": _ALL_VARS_META["count_bins_exceeded"],
},
)
hist = _update_time_attributes_without_computing(hist, dss, previous_hist)
hist = hist.drop_vars("point", errors="ignore")
if finalize:
wv_count = _accumulate_wv_count(
dss, hist, wv_count, hist_is_accumulated, "z0", wsbin_width
)
res = _z0_mean(hist, wv_count["wv_count"], landmask, percentile=percentile)
return _update_history(res)
return _update_history(hist)
def _z0_mean(hist, wv_count, landmask, percentile=0.5):
"""Finalize roughness sums as sector means."""
hist["wv_count"] = wv_count
cond_hist, wd_count = _conditional_histogram(
hist["z0sum"], hist["wv_count"], percentile
)
hist["logz0sec"] = _sector_statistic("mean", cond_hist, wd_count)
hist["wd_count"] = wd_count
if landmask is not None:
hist["landmask"] = landmask
# As in _finalize_baro, DataArray.where keeps mean_z0's own axis order
# rather than adopting the landmask's.
hist["mean_z0"] = mean_z0(hist).where(hist["landmask"] != 0, 0.0)
else:
hist["mean_z0"] = mean_z0(hist)
return hist
def mean_z0(hist):
"""Compute mean roughness
Compute mean roughness from log(z0) per wsbin and
sector
.. warning::
This function is experimental and its signature may change.
Parameters
----------
hist: xr.Dataset
Dataset with variables `logz0sec` and `wd_count`
and dimensions `wsbin`, `sector`
Returns
-------
ds: xr.DataArray
Dataarray with mean z0 per sector
"""
mean_z0 = hist["logz0sec"] * hist["wd_count"] / hist["wd_count"].sum(dim=["sector"])
return np.exp(mean_z0.sum(dim=["sector"]))
def _normalize_hist(hist):
"""Convert frequency to probability-density histogram
Convert frequency to probability density histogram in the
WAsP core structure, i.e. with an additional variables
containing wind speed sector frequencies.
Parameters
----------
hist: xr.Dataset
Dataset with the frequency of occurence in each bin. Can have any
pywasp spatial structure.
Returns
-------
hist: xr.Dataset
Binned wind climate with probability density in each bin, on the
same spatial structure as ``hist``.
"""
counts = hist["wv_count"]
# A sector with no samples divides by one rather than by zero, so it comes
# out as a zero density -- what the WAsP core's normalize_histogram_points
# produces, whether the sector alone or the whole location is empty.
ws_count = counts.sum("wsbin", skipna=False)
wd_count = ws_count
total_count = wd_count.sum("sector", skipna=False)
wsfreq = counts / ws_count.where(ws_count != 0, 1)
# A NaN anywhere in a location's wv_count -- e.g. a masked-out grid cell in
# a caller-supplied hist -- poisons the whole location in the core: every
# sector's wsfreq comes back 0, not just the sector containing the NaN.
# total_count (skipna=False over both wsbin and sector) is NaN exactly
# when that happens, so use it to zero the whole location the same way.
wsfreq = wsfreq.where(total_count.notnull(), 0)
wdfreq = (wd_count / total_count.where(total_count != 0, 1)).where(total_count != 0)
result = hist.drop_vars("wv_count")
result["wsfreq"] = wsfreq
result["wdfreq"] = wdfreq
return result
def _finalize_meso(total_stab_histogram, wv_count, landmask=None, percentile=0.5):
"""Finalize cumulative stability histograms, optionally by surface type."""
hist = _stab_mean_std(total_stab_histogram, wv_count, percentile=percentile)
if landmask is not None:
hist = _nearest_landsea(hist, landmask)
_update_var_attrs(hist, _MEAN_STAB_ATTRS)
return hist
def _nearest_landsea(ds, landmask):
"""Interpolate stability fields separately over land and sea.
Parameters
----------
ds : xr.Dataset
Dataset containing the stability variables.
landmask : xr.DataArray
Landmask with values 0 (sea) and 1 (land).
Returns
-------
xr.Dataset
Land and sea variants of each stability variable.
"""
source_names = ("mean_temp_scale", "rms_temp_scale", "mean_pblh_scale")
surfaces = (("land", 1, "land"), ("sea", 0, "water"))
result_names = [
f"{name}_{suffix}" for suffix, _, _ in surfaces for name in source_names
]
# Compatibility for xarray 2024.9.0; remove when that version is unsupported.
crs_attrs = ds["crs"].attrs.copy() or landmask["crs"].attrs.copy()
rounded_landmask = np.round(landmask)
for suffix, landflag, surface_type in surfaces:
targets = {
name: ds[name].where(rounded_landmask == landflag) for name in source_names
}
valid = {
name: to_point(target).dropna("point") for name, target in targets.items()
}
for source_name in source_names:
targets[source_name]["crs"].attrs = crs_attrs
valid[source_name]["crs"].attrs = crs_attrs
if valid[source_name].sizes["point"] == 0:
raise ValueError(
"The landmask you passed in does not contain any pixels "
f"with {landflag} ({surface_type}). Please include such "
"points to obtain a valid estimation of the stability "
f"conditions over {surface_type}"
)
reference = valid[source_names[0]]
# Reuse one spatial search when fields share valid points; retain
# per-field interpolation for variable-specific missing values.
shared_points = all(
reference["point"].equals(valid[name]["point"]) for name in source_names[1:]
)
if shared_points:
point_coords = {
name: coord
for name, coord in reference.coords.items()
if set(coord.dims).issubset({"point"})
}
source_index = xr.DataArray(
np.arange(reference.sizes["point"]),
dims="point",
coords=point_coords,
)
nearest = interp_unstructured_like(
source_index, targets[source_names[0]], method="nearest"
)
for source_name in source_names:
if shared_points:
indexer = xr.DataArray(nearest.data, dims=nearest.dims)
selected = valid[source_name].isel(point=indexer)
target_coords = {
name: coord
for name, coord in targets[source_name].coords.items()
if set(coord.dims).issubset(selected.dims)
}
target = xr.DataArray(
selected.data,
dims=selected.dims,
coords=target_coords,
attrs=selected.attrs,
)
else:
target = interp_unstructured_like(
valid[source_name], targets[source_name], method="nearest"
)
target["crs"].attrs = crs_attrs
if "sector" in target.dims:
target = target.transpose("sector", ...)
ds[f"{source_name}_{suffix}"] = target
result = ds[result_names]
result["crs"].attrs = crs_attrs
return result
def _finalize_baro(hist, wv_count, percentile=0.5):
"""Finalize shear sums as sector magnitudes and directions."""
cond_hist, freq = _conditional_histogram(hist["sum_dugdz"], wv_count, percentile)
mean_dugdz = _sector_statistic("mean", cond_hist, freq)
cond_hist, freq = _conditional_histogram(hist["sum_dvgdz"], wv_count, percentile)
mean_dvgdz = _sector_statistic("mean", cond_hist, freq)
north = np.arctan2(-mean_dvgdz, -mean_dugdz) * RTD
south = np.arctan2(mean_dvgdz, mean_dugdz) * RTD
# DataArray.where keeps the direction's axis order, unlike xr.where.
direction = south.where(hist["south_north"] < 0, north)
clim = xr.Dataset(
{
"mean_dgdz_dir": direction,
"mean_dgdz": np.hypot(mean_dugdz, mean_dvgdz),
},
attrs=hist.attrs,
)
clim = _update_var_attrs(clim, _MEAN_BARO_ATTRS)
# Older xarray/pyproj versions can drop the scalar CRS coordinate here.
if "crs" in hist.coords:
clim = set_crs(clim, get_crs(hist))
return clim
def _validate_baro_wv_count(hist, wv_count):
"""Validate that a wind-count histogram matches a baroclinicity histogram."""
if not np.array_equal(wv_count["wsbin"], hist["wsbin"]) or not np.array_equal(
wv_count["sector"], hist["sector"]
):
raise PywaspError(
"Baroclinicity histogram counts do not align with the supplied bins"
)
def baroclinicity_histogram(
ds,
hist=None,
finalize=True,
revert_to_original=True,
wsbin_width=1,
n_wsbins=40,
n_sectors=12,
percentile=0.5,
wv_count=None,
**kw,
):
"""Add timeseries to existing histogram
Uses the baroclinicity parameters to derive
summed histogram of the geostrophic shear components.
.. warning::
This function is experimental and its signature may change.
Parameters
----------
ds: xr.Dataset
Dataset containing the variables
['wind_speed', 'wind_direction', 'dugdz', 'dvgdz'].
Can have any pywasp spatial structure
hist: xr.Dataset
Histogram with dimensions point, wsbin, sector. When supplied together
with ``finalize=True``, the matching accumulated counts must be given as
``wv_count``; otherwise a `PywaspError` is raised, because counts
covering only the final chunk would silently inflate the means.
finalize: boolean
Convert the stabilility histogram into a mean and
standard deviation?
revert_to_original: boolean
If True (default), operate on and return ds's own native spatial
structure directly; there is no promotion/reversion round trip. If
False, promote to point structure and return that; still useful when
composing with other point-structured accumulators, e.g. across
chunked calls.
wsbin_width : float
Width of wind speed bins. Only used to initialize a new histogram; it is
ignored when ``hist`` is supplied, where the bins come from ``hist``.
n_wsbins : int
Number of wind speed bins. Init-only, like ``wsbin_width``.
n_sectors : int
Number of wind direction bins. Init-only, like ``wsbin_width``.
percentile : float
Percentile of the wind speed distribution used for finalization.
wv_count : xarray.Dataset, optional
Accumulating wind-vector count histogram covering every chunk already
added to ``hist``. When supplied during finalization, the current
dataset is added to this histogram before computing the conditional
means. This follows the same workflow as stability_histogram and avoids
rebuilding counts for every chunk.
**kwargs
Backward-compatible aliases nws and nwd for the bin counts.
Returns
-------
hist: xr.Dataset
Histogram with dimensions point, wsbin, sector
with the values from ds-timeseries added.
Raises
------
PywaspError
If an unknown keyword argument is given; if a supplied ``hist`` is
finalized without the matching accumulated ``wv_count``; if the
histogram has fewer than two wind-speed bins or bins that are not
uniformly spaced; or if the supplied counts are not on the same bin
grid as the histogram.
"""
n_wsbins = kw.pop("nws", n_wsbins)
n_sectors = kw.pop("nwd", n_sectors)
if kw:
unexpected = next(iter(kw))
raise PywaspError(f"Unexpected keyword argument: {unexpected}")
# A supplied histogram may already hold accumulated sums from earlier chunks,
# so counts covering only the current chunk must never be used to finalize it.
hist_is_accumulated = hist is not None
ds_sub = ds[[WS, WD, "dugdz", "dvgdz"]]
dss, previous_hist = _histogram_layout(ds_sub, hist, revert_to_original)
hist = _histogram_sums(
dss,
values={
"sum_dugdz": dss["dugdz"],
"sum_dvgdz": dss["dvgdz"],
},
hist=previous_hist,
wsbin_width=wsbin_width,
n_wsbins=n_wsbins,
n_sectors=n_sectors,
)
hist = _update_var_attrs(hist, _HIS_BARO_ATTRS)
hist = _update_time_attributes_without_computing(hist, dss, previous_hist)
if finalize:
wv_count = _accumulate_wv_count(
dss,
hist,
wv_count,
hist_is_accumulated,
"baroclinicity",
wsbin_width,
validate_bins=True,
)
_validate_baro_wv_count(hist, wv_count)
return _finalize_baro(hist, wv_count["wv_count"], percentile)
return _update_history(hist)
[docs]
@validate_input(_BWC_SCHEMA, _ANY_3D_SPATIAL, run_extra_checks=False)
def bwc_resample_like(source, target, conf=None):
"""
Resamples a histogram to different sector or wind speed bin structure.
Parameters
----------
source: xarray.Dataset
Binned wind climate xr.Dataset object that we want to resample
target: xarray.Dataset
Binned wind climate xr.Dataset object structure that we want to resample to
Returns
-------
xarray.Dataset
Binned Wind Climate
"""
if conf is None:
conf = Config()
result = bwc_resample_sectors(source, target.sizes["sector"])
result = bwc_resample_wsbins_like(result, target, conf=conf)
return result
[docs]
@validate_input(_BWC_SCHEMA, _ANY_3D_SPATIAL, run_extra_checks=False)
def bwc_resample_sectors(source, n_sectors=12, offset=None):
"""
Resamples a histogram to different sector structure.
Parameters
----------
source: xarray.Dataset
Binned wind climate xr.Dataset object that we want to resample
target: xarray.Dataset
Binned wind climate xr.Dataset object structure that we want to resample to
offset: xarray.DataArray or float
Offset in degrees with which the histogram are rotated (positive
for clockwise rotation). If it is a DataArray, needs to have the same spatial shape as
the binned wind climate.
Returns
-------
xarray.Dataset
Binned Wind Climate
"""
if source.sizes["sector"] == n_sectors and offset is None:
return source
if isinstance(offset, xr.DataArray):
if not are_spatially_equal(source, offset):
raise ValueError(
"Input source and offset must have the same spatial structure."
)
offset = offset.astype("float32")
elif offset is None:
offset = 0.0
elif np.isscalar(offset):
offset = np.float32(offset)
else:
raise PywaspError("offset must be a scalar or xarray.DataArray")
def _resample(wv_freq, doff, n_sectors):
return _RESAMPLE_HIST(wv_freq, doff, n_sectors)
wv_freq = xr.apply_ufunc(
_resample,
(source.wsfreq * source.wdfreq).transpose(..., "wsbin", "sector"),
offset,
input_core_dims=[["wsbin", "sector"], []],
output_core_dims=[["wsbin", "sector"]],
exclude_dims={"sector"},
kwargs={"n_sectors": n_sectors},
dask="allowed",
vectorize=True,
keep_attrs=True,
)
wdcenters = create_sector_coords(n_sectors)
wv_freq = wv_freq.assign_coords({**wdcenters.coords})
spatial_dims = [d for d in wv_freq.dims if d not in ("wsbin", "sector")]
wv_freq = wv_freq.transpose("wsbin", "sector", *spatial_dims)
result = _bwc_from_counts(wv_freq)
# Carry the input's global metadata (e.g. wind_dir_crs), then set our title.
result.attrs = {k: v for k, v in source.attrs.items() if not k.startswith("_pwio")}
result.attrs["title"] = "Binned wind climate"
return _update_history(result)
[docs]
@validate_input(_BWC_SCHEMA, _ANY_3D_SPATIAL, run_extra_checks=False)
def bwc_resample_wsbins_like(
source, target, fit_weibull=True, errors="raise", conf=None
):
"""
Resamples a histogram to different wind speed bin structure.
Parameters
----------
source: xarray.Dataset
Binned wind climate xr.Dataset object that we want to resample
target: xarray.Dataset
Binned wind climate xr.Dataset object structure that we want to resample to
fit_weibull: boolean, optional
Default True, when resampling the histogram find the sectorwise A and k and
use these to resample the histogram. If false, use the A and k provided in
the dataset instead.
errors: {'raise', 'ignore'}, optional
If 'raise' (default), raises a ValueError error if probability mass
is transfered beyond the highest wind speed bin.
If "ignore", all points including those that lose probability mass
are returned.
Returns
-------
xarray.Dataset
Binned Wind Climate
"""
if conf is None:
conf = Config()
# we reset coordinates with drop=True to make sure there is no user added
# coordinates that cause the dataarrays to be different
if (
not source["sector"]
.reset_coords(drop=True)
.equals(target["sector"].reset_coords(drop=True))
):
raise ValueError(
"Input and target histogram must have the same sector structure!"
)
# source and target wsbin structure are the same, so no resampling needed
same_wsbins = (
source["wsbin"]
.reset_coords(drop=True)
.equals(target["wsbin"].reset_coords(drop=True))
)
if same_wsbins and fit_weibull:
return source
if not fit_weibull:
if not all(v in target.data_vars for v in ["A", "k"]):
raise ValueError(
"Variables A and k are needed for resampling when fit_weibull is set to False"
)
if wksp.get_spatial_struct(source) != wksp.get_spatial_struct(target):
raise PywaspError(
"When fit_weibull=False, source and target must have the same "
"spatial structure so their per-point A/k values line up."
)
source = source.transpose(..., "wsbin", "sector")
target = target.transpose(..., "wsbin", "sector")
def _resample_wsbins(wsfreq, wsceil, target_wsceil, target_ws_floor, conf):
return _RESAMPLE_HIST_WSBINS(
wsfreq, wsceil, target_wsceil, target_ws_floor, conf.climate.param
)
def _resample_wsbins_ak(wsfreq, wsceil, A, k, target_wsceil, target_ws_floor, conf):
return _RESAMPLE_HIST_WSBINS_AK(
wsfreq, wsceil, A, k, target_wsceil, target_ws_floor, conf.climate.param
)
if fit_weibull:
wsfreq, ierror = xr.apply_ufunc(
_resample_wsbins,
source.wsfreq,
source.wsceil,
target.wsceil,
target.wsfloor,
input_core_dims=[["wsbin", "sector"], *[["wsbin"]] * 3],
output_core_dims=[["wsbin", "sector"], ["sector"]],
exclude_dims={"wsbin"},
kwargs={"conf": conf},
dask="allowed",
vectorize=True,
keep_attrs=True,
)
else:
wsfreq, ierror = xr.apply_ufunc(
_resample_wsbins_ak,
source.wsfreq,
source.wsceil,
target.A,
target.k,
target.wsceil,
target.wsfloor,
input_core_dims=[
["wsbin", "sector"],
["wsbin"],
*[["sector"]] * 2,
*[["wsbin"]] * 2,
],
output_core_dims=[["wsbin", "sector"], ["sector"]],
exclude_dims={"wsbin"},
kwargs={"conf": conf},
dask="allowed",
vectorize=True,
keep_attrs=True,
)
if ierror.sum() > 0 and errors == "raise":
bad = ierror.any(dim="sector")
i_points = np.flatnonzero(np.asarray(bad.values).ravel())
raise ValueError(
f"Resampling of the wsbins in {len(i_points)} out of {bad.size} points "
f"(i={','.join(map(str, i_points))}) exceeded the number of target bins!"
)
spatial_dims = [d for d in wsfreq.dims if d not in ("wsbin", "sector")]
wsfreq = wsfreq.transpose("wsbin", "sector", *spatial_dims).assign_coords(
wsbin=target["wsbin"],
wsceil=target["wsceil"].reset_coords(drop=True),
wsfloor=target["wsfloor"].reset_coords(drop=True),
)
wdfreq = source["wdfreq"].transpose("sector", *spatial_dims)
result = xr.Dataset({"wsfreq": wsfreq, "wdfreq": wdfreq})
result = result.assign_coords(sector=source["sector"])
result.attrs = {k: v for k, v in source.attrs.items() if not k.startswith("_pwio")}
return _update_history(result)
def _get_dgdz(z, sp, levs, lats):
"""
Calculate the gradients of the zonal and meridional geostrophic wind components with respect to height.
Parameters
----------
z : array_like
Geopotential height array.
sp : array_like
Surface pressure array.
levs : array_like
Pressure levels array.
lats : array_like
Latitudes array.
Returns
-------
tuple of array_like
Gradients of the zonal (dugdz) and meridional (dvgdz) geostrophic wind components with respect to height.
"""
dugdz, dvgdz = _DGDZ(z, sp, levs, lats)
return (dugdz, dvgdz)
def _get_dgdz_t(z, sp, levs, lats):
"""
Calculate the time-dependent gradients of the zonal and meridional geostrophic wind components with respect to height.
Parameters
----------
z : array_like
Geopotential height array.
sp : array_like
Surface pressure array.
levs : array_like
Pressure levels array.
lats : array_like
Latitudes array.
Returns
-------
tuple of array_like
Time-dependent gradients of the zonal (dugdz) and meridional (dvgdz) geostrophic wind components with respect to height.
"""
dugdz, dvgdz = _DGDZ_TIME(z, sp, levs, lats)
return (dugdz, dvgdz)
def _calc_dgdz(sfc):
"""
Calculate the vertical gradients of the zonal and meridional geostrophic wind components with respect to height for a given surface dataset.
Parameters
----------
sfc : xarray.Dataset
Surface dataset containing geopotential height, surface pressure, wind speed, and wind direction.
Returns
-------
xarray.Dataset
Dataset with added gradients of the zonal (dugdz) and meridional (dvgdz) geostrophic wind components with respect to height.
"""
dugdz, dvgdz = xr.apply_ufunc(
_get_dgdz,
sfc["z"]
.transpose("west_east", "south_north", "isobaricInhPa", "time")
.astype("float32"),
(sfc["sp"].transpose("west_east", "south_north", "time") / 100).astype(
"float32"
),
sfc["isobaricInhPa"],
sfc["south_north"],
input_core_dims=[
["west_east", "south_north", "isobaricInhPa"],
["west_east", "south_north"],
["isobaricInhPa"],
["south_north"],
],
output_core_dims=[["west_east", "south_north"], ["west_east", "south_north"]],
dask="allowed",
vectorize=True,
keep_attrs=True,
)
out = xr.Dataset(
{"dugdz": dugdz, "dvgdz": dvgdz},
)
out = set_crs(out, 4326)
return out.transpose("time", "south_north", "west_east")
def _calc_dgdz_t(sfc):
"""
Calculate vertical gradients of the zonal and meridional geostrophic wind components with respect to height for a given dataset.
Parameters
----------
sfc : xarray.Dataset
Dataset containing geopotential height, surface pressure, wind speed, and wind direction.
Returns
-------
xarray.Dataset
Dataset with added time-dependent gradients of the zonal (dugdz) and meridional (dvgdz) wind components with respect to height.
"""
dugdz, dvgdz = _get_dgdz_t(
sfc["z"].transpose("west_east", "south_north", "isobaricInhPa", "time"),
sfc["sp"].transpose("west_east", "south_north", "time") / 100,
sfc["isobaricInhPa"],
sfc["south_north"],
)
out = xr.Dataset(
{"dugdz": dugdz, "dvgdz": dvgdz},
)
out = set_crs(out, 4326)
return out.transpose("time", "south_north", "west_east")