Source code for windkit.wind_climate.stability

# (c) 2026 DTU Wind Energy
"""Synthetic mesoscale surface fields.

Stability-aware wind climate code that starts from raw mesoscale output needs
the surface fields the surface-layer temperature scale is derived from -
pressure, temperature, friction velocity, the heat fluxes, the boundary layer
height and the surface roughness. This module creates a synthetic version of
those, so that such code can be exercised without a mesoscale model run.

The derived stability variables themselves, ``temp_scale`` and
``ustar_over_pblh``, belong to a time series wind climate and are generated by
:func:`windkit.create_tswc` through its ``fields`` argument.
:func:`create_surface_fields` takes the same argument, for the case where the
two groups have to be correlated with each other. Both share the engine in
:mod:`windkit.wind_climate._correlated_fields`, so the values follow bounded
multi-harmonic sinusoids whose amplitude and phase differ per point, and
within a point they carry the cross-correlations of the reference run.
"""

__all__ = [
    "create_surface_fields",
]

import numpy as np
import pandas as pd
import xarray as xr

from windkit.wind_climate._correlated_fields import (
    _column_index,
    _column_of,
    _correlated_fields,
    _seconds_since_start,
    _stability_field_ranges,
)
from windkit.wind_climate.time_series_wind_climate import _create_tswc_core
from windkit.xarray_structures.empty import (
    _native_batch,
    _native_batch_horizontal,
    _native_flat_coords,
    _native_store,
)
from windkit.xarray_structures.metadata import (
    _ALL_VARS_META,
    _TS_ATTRS,
    _update_history,
    _update_var_attrs,
)

# One month at hourly resolution. Small enough to stay a cheap fixture, and
# long enough to clear the two-day span the correlated-field engine needs.
_DEFAULT_DATE_RANGE = pd.date_range(
    "2001-01-01", "2001-02-01", freq="h", inclusive="left"
)

# Ranges for the mesoscale surface fields, used when the caller does not
# override them. Each entry is ``(low, high)`` in the variable's own units,
# taken as the 0.1st and 99.9th percentile of the same Cabauw reference and
# rounded. ZNT is absent from the reference, so its entry is a plausible
# range of land cover roughness instead.
_SURFACE_FIELD_RANGES = {
    "PSFC": (97500.0, 104100.0),
    "T2": (261.0, 304.0),
    "UST": (0.01, 1.15),
    "HFX": (-100.0, 265.0),
    "LH": (-25.0, 440.0),
    "PBLH": (19.0, 2100.0),
    "Q2": (0.0011, 0.0152),
    "ZNT": (5e-3, 0.1),
}


[docs] def create_surface_fields( output_locs, date_range=None, not_empty=True, seed=9876538, ranges=None, elevation_range=(0.0, 200.0), fields=None, **tswc_kwargs, ): """Create synthetic mesoscale surface fields as a time series. Produces the raw model output variables from which the surface-layer temperature scale is derived, using the WRF naming convention, alongside the wind time series from :func:`create_tswc`. Time-varying variables: ``PSFC``, ``T2``, ``UST``, ``HFX``, ``LH``, ``PBLH``, ``Q2``, ``ZNT``, ``wind_speed``, ``wind_direction``. Time-constant variables: ``LANDMASK``, ``elevation``. The stability variables these fields feed, ``temp_scale`` and ``ustar_over_pblh``, are not part of the mesoscale set. They can be added with the ``fields`` argument, which is resolved exactly as :func:`create_tswc` resolves its own; asking for them here generates them together with the surface fields, in one correlated draw, so that a point holds the reference correlations between the two groups as well - the ``temp_scale``/``HFX`` pair among them. Every point is over land: the values are drawn from an onshore reference, so ``LANDMASK`` is 1 everywhere and the fields carry no offshore behaviour. Code that has to exercise a land/sea contrast should set ``LANDMASK`` and the surface fields of the sea points itself. These are all surface variables: every one of them declares ``height_dim_policy: "forbidden"``, so none carries a height dimension in the returned dataset, whatever the structure of ``output_locs``. Each is drawn once per ``(west_east, south_north)`` column, so no two columns hold the same values — a fixture that repeated them could not detect code which loses track of which location a value belongs to. A column is identified by exact equality of its two coordinates, which holds for every structure windkit builds. ``wind_speed`` and ``wind_direction`` come from :func:`create_tswc` and keep their height dimension. Within a point the fields carry the cross-correlations of the reference site, including with ``wind_speed``. Parameters ---------- output_locs : xarray.Dataset Output geospatial information. date_range : pandas.DatetimeIndex or None Time range. If None, one month of 2001 at hourly resolution is used, which is small enough to stay a cheap fixture. It must span more than two days, whatever its resolution. not_empty : bool If True, the surface fields are filled with synthetic data. If False, every variable is NaN, as :func:`create_tswc` does. Defaults to True. seed : int Seed for the random data, defaults to 9876538. ranges : dict or None Overrides for the ``(low, high)`` bounds of individual surface fields. Keys not given fall back to the module defaults, and keys outside the surface fields are rejected - the stability variables are bounded by ``fields`` instead. Each entry must have ``low < high``; neither the ordering nor the width of a pair is checked, so a reversed pair flips the field about its mid-range and an equal pair makes it constant. elevation_range : tuple of float Bounds of the terrain elevation [m], with ``low < high``. Defaults to ``(0.0, 200.0)``. fields : str, sequence of str or None Stability variables to generate alongside the surface fields, as in :func:`create_tswc`: ``None`` for none of them, ``"stability"`` or ``"all"`` for every one, or the names to generate. Unlike passing them to :func:`create_tswc` separately, these are drawn together with the surface fields, so the two groups are correlated with each other. **tswc_kwargs Further keyword arguments passed to :func:`create_tswc`. Returns ------- ds : xarray.Dataset Time series of mesoscale surface fields. Raises ------ ValueError If ``ranges`` holds a key that is not a surface field, if ``fields`` names something that is not a stability variable, or if ``date_range`` holds too few time steps or spans two days or less. Notes ----- Every variable added here is a surface variable, so all of them are stored over the horizontal batch, never a separate height axis: float32, C-contiguous, with ``time`` trailing that batch, the reverse of the compiled core's declared argument order (windkit#807). The wind comes from :func:`create_tswc` and is stored as that function documents. Coordinates stay float64. Neither the dtype nor the dimension order is a supported API contract: ``create_surface_fields`` is a test-data utility. Examples -------- >>> import pandas as pd >>> import windkit as wk >>> locs = wk.spatial.create_dataset( ... [10.0, 10.1], [55.0, 55.1], [100.0, 100.0], 4326, struct="point" ... ).drop_vars("output") >>> ds = wk.create_surface_fields(locs) >>> "PBLH" in ds and "LANDMASK" in ds True >>> ds.sizes["time"] 744 """ unknown = sorted(set(ranges or {}) - set(_SURFACE_FIELD_RANGES)) if unknown: raise ValueError( f"Unknown surface field(s) {unknown}, cannot add to result. The keys " f"of 'ranges' must be a subset of {sorted(_SURFACE_FIELD_RANGES)}." ) # The stability variables come last, so that a call without ``fields`` # draws exactly what it drew before they could be asked for here. field_ranges = { **_SURFACE_FIELD_RANGES, **(ranges or {}), **_stability_field_ranges(fields), } if date_range is None: date_range = _DEFAULT_DATE_RANGE # The core rather than the public constructor, for its float64 wind: the # dataset stores it float32 (windkit#807), and driving the fields off the # rounded values would make them depend on the storage dtype. ds, internals = _create_tswc_core( output_locs, date_range=date_range, not_empty=not_empty, seed=seed, _return_internals=True, **tswc_kwargs, ) seconds = _seconds_since_start(pd.DatetimeIndex(ds["time"].values)) # Every variable added here is a surface variable, so all of them are # built over the horizontal batch and never carry a height dimension. # `create_tswc`'s own wind keeps the full batch, and its height. dims, shape = _native_batch(output_locs) horiz_dims, horiz_shape = _native_batch_horizontal(output_locs) n_horiz = int(np.prod(horiz_shape, dtype=int)) if not not_empty: # Mirrors create_tswc and create_wasp_site_effects: an empty dataset # carries the full variable set, all of it NaN. surface = { "LANDMASK": np.full(n_horiz, np.nan), "elevation": np.full(n_horiz, np.nan), } time_varying = { name: np.full((len(seconds), n_horiz), np.nan) for name in field_ranges } return _finalize(ds, surface, time_varying, horiz_dims, horiz_shape) rng = np.random.default_rng(seed + 2) # Drawn once per horizontal column, then spread over the horizontal batch: # a permutation where height is its own dimension, and a repeat over the # heights above each column in a `point` structure, where it is not. we, sn = _native_flat_coords(output_locs, dims, shape) height = _native_flat_coords(output_locs, dims, shape, ("height",))[0] _, base_of_column = _column_index(we, sn, height) column_of_location = _column_of( *_native_flat_coords(output_locs, horiz_dims, horiz_shape) ) n_col = len(base_of_column) surface = { "LANDMASK": np.ones(n_horiz), "elevation": rng.uniform(*elevation_range, n_col)[column_of_location], } # The wind driving them is taken at the lowest height of each column, the # closest thing to the surface wind the fields respond to. generated = _correlated_fields( field_ranges, seconds, n_col, rng, internals["wind_speed"][:, base_of_column], ) time_varying = { name: values[:, column_of_location] for name, values in generated.items() } return _finalize(ds, surface, time_varying, horiz_dims, horiz_shape)
def _finalize(ds, surface, time_varying, horiz_dims, horiz_shape): """Store the surface fields over the horizontal batch and attach metadata. Nothing is unstacked: the height dimension every surface variable declares ``"forbidden"`` is never introduced in the first place, because these are built over a batch that has no height axis to begin with. """ for name, values in surface.items(): out_dims, array = _native_store(values, horiz_dims, horiz_shape, core_dims=()) ds[name] = xr.DataArray(array, dims=out_dims) for name, values in time_varying.items(): out_dims, array = _native_store( values, horiz_dims, horiz_shape, core_dims=("time",) ) ds[name] = xr.DataArray(array, dims=out_dims) meta = { **_TS_ATTRS, **{k: _ALL_VARS_META[k] for k in ds.data_vars if k in _ALL_VARS_META}, } return _update_history(_update_var_attrs(ds, meta))