Source code for windkit.spatial.spatial

# (c) 2026 DTU Wind Energy
"""
Public interface to all of the spatial tools in WindKit
"""

__all__ = [
    "are_spatially_equal",
    "clip",
    "clip_with_margin",
    "create_dataset",
    "create_point",
    "create_stacked_point",
    "create_cuboid",
    "create_raster",
    "count_spatial_points",
    "equal_spatial_shape",
    "mask",
    "grid_convergence",
    "reproject",
    "warp",
    "nearest_points",
    "covers",
    "gdf_to_ds",
    "ds_to_gdf",
    "add_projected_wrf_coordinates",
]

import logging
import warnings
from dataclasses import dataclass
from enum import Enum, auto

import geopandas as gpd
import numpy as np
import pyproj
import xarray as xr
from scipy.spatial import KDTree

from ..xarray_structures.metadata import (
    _GLOBAL_CONVENTIONS,
    _ALL_VARS_META,
    _update_var_attrs,
    _update_history,
)
from ._crs import set_crs, get_crs
from ._bbox import BBox
from ._dimensions import _point_dim, _stacked_point_dim, _vertical_dim, _xy_dims
from ._point import (
    _mask_point,
    _stack_to_point,
    is_point,
    is_stacked_point,
    to_point,
    to_stacked_point,
)
from ._struct import _apply_height_contract, _drop_pwio_attrs
from ._raster import _bounds as _raster_bounds
from ._raster import (
    _can_be_raster,
    _clip_to_bbox_raster,
    _has_raster_dims,
    _mask_raster,
)
from ._raster import _resolution as _raster_resolution
from ._raster import (
    _warp_raster,
    to_raster,
)
from ._struct import (
    _from_scalar,
    _save_stacked_index,
    get_spatial_struct,
    is_cuboid,
    is_raster,
    _is_vertical,
)
from ._vector import _clip_vector
from ._vertical import has_height_coord, has_height_dim

logger = logging.getLogger(__name__)

_STACK_ATTRS = ("_pwio_was_stacked_point", "_pwio_was_cuboid", "_pwio_orig_srs_wkt")


[docs] def count_spatial_points(obj): """Get the number of spatial points for a dataset or DataArray Parameters ---------- obj: xarray.Dataset, xarray.DataArray WindKit xarray dataset or dataarray containing spatial dimensions. Raises ------ ValueError Undetectable spatial structure """ obj = _from_scalar(obj) dims = obj.sizes # Get dimension names xdim, ydim = _xy_dims() pt_dim = _point_dim() vert_dim = _vertical_dim() spt_dim = _stacked_point_dim() if is_cuboid(obj): return dims[vert_dim] * dims[ydim] * dims[xdim] elif is_raster(obj): return dims[ydim] * dims[xdim] elif is_stacked_point(obj): return dims[vert_dim] * dims[spt_dim] elif is_point(obj): return dims[pt_dim] else: raise ValueError("Unknown data structure cannot count points.")
def _crs_from_crs_like(crs_like): """Return a pyproj CRS from a WindKit spatial object or a CRS-like object. Parameters ---------- crs_like : xarray.Dataset, xarray.DataArray, BBox, geopandas.GeoDataFrame, geopandas.GeoSeries, or any CRS-like input Spatial object containing a CRS or an object that can be converted to a pyproj CRS (e.g. EPSG code, dict, string, or CRS object). Returns ------- pyproj.crs.CRS The pyproj CRS object. """ if isinstance( crs_like, (xr.Dataset, xr.DataArray, BBox, gpd.GeoDataFrame, gpd.GeoSeries) ): return get_crs(crs_like) return pyproj.CRS.from_user_input(crs_like)
[docs] def grid_convergence(ds, source=None): """Return grid convergence for a WindKit spatial object. Parameters ---------- ds : xarray.Dataset or xarray.DataArray WindKit xarray object containing spatial coordinates and a CRS. source : xarray.Dataset, xarray.DataArray, geopandas.GeoDataFrame, geopandas.GeoSeries, BBox, int, dict, str or pyproj.crs.CRS, optional Reference CRS to rotate from. When provided, returns the effective grid convergence needed to rotate directions from the ``source`` frame into the frame of ``ds`` at the locations of ``ds``. Returns ------- xarray.DataArray Grid convergence in degrees, in the range (-180, 180]. A positive value means grid north of ``ds`` is rotated clockwise from the reference north: subtract it from a wind direction expressed in the reference frame to express that direction in the grid frame of ``ds``. Without ``source``, the reference is true (geographic) north: the returned angle is the classic geodetic grid convergence of the projection of ``ds``. With ``source``, the reference is the grid-north of ``source``: the returned angle is the rotation needed to convert a direction expressed in the ``source`` CRS frame into the ``ds`` CRS frame. Raises ------ ValueError If no CRS is found on ``ds``. """ ds_crs = get_crs(ds) proj = pyproj.Proj(ds_crs) south_north, west_east = xr.broadcast(ds["south_north"], ds["west_east"]) lon, lat = proj(west_east.values, south_north.values, inverse=True) mc = proj.get_factors(lon, lat, False, True).meridian_convergence if source is not None: source_proj = pyproj.Proj(_crs_from_crs_like(source)) source_mc = source_proj.get_factors(lon, lat, False, True).meridian_convergence mc = (mc - source_mc + 180.0) % 360.0 - 180.0 coords = { name: coord for name, coord in ds.coords.items() if name == "crs" or set(coord.dims).issubset(south_north.dims) } mc = xr.DataArray( mc, dims=south_north.dims, coords=coords, name="grid_convergence", ) return _update_var_attrs( mc, {"grid_convergence": _ALL_VARS_META["grid_convergence"]}, )
[docs] def are_spatially_equal(obj_a, obj_b): """Checks that the spatial points are equivalent for both datasets Parameters ---------- obj_a, obj_b : xarray.Dataset, xarray.DataArray WindKit xarray dataset or dataarray containing spatial dimensions and CRS variable Returns ------- bool True if spatial coords are numpy.allclose """ x_dim, y_dim = _xy_dims() if obj_a.coords[x_dim].size != obj_b.coords[x_dim].size: return False if obj_a.coords[y_dim].size != obj_b.coords[y_dim].size: return False return np.allclose(obj_a[x_dim], obj_b[x_dim]) and np.allclose( obj_a[y_dim], obj_b[y_dim] )
[docs] def create_dataset(west_east, south_north, height, crs, struct="auto", thresh=1e-9): """Create a WindKit dataset given a set of locations. Parameters ---------- west_east : array_like 1D array of west_east locations of interest south_north : array_like 1D array of south_north locations of interest height : array_like Array of heights to create in output WindKit xarray dataset crs : int, dict, str or pyproj.crs.CRS Value to initialize :py:class:`pyproj.crs.CRS` struct : str Used to specify the type of dataset that is desired Valid values are 'auto', 'cuboid', 'point' and 'stacked_point'. The default value of 'auto' tries to create a cuboid, but falls back to point depending on the values specified. thresh : float Threshold value for raster detection Default set to 1e-9 m Returns ------- xarray.Dataset WindKit formated zero-filled Dataset with one variable "output" and with the grid dimensions specified Notes ----- This function will create a WindKit formatted dataset including all of the geospatial information that is desired. If ds_fmt is set to "auto", the function will attempt to create a cuboid, if the deviation between the largest and smallest interval along west_east and south_north are lower than value in thresh (west_east and south north can be different lengths). Otherwise, the function will attempt to make a point dataset. Specifically, it will create a 3D point object, if west_east, south_north and heights have the same lengths. If west_east and south_north have the same lengths but heights length is different the function will create a 2D point object. Alternatively, ds_fmt can be set to desired output format, that is 'cuboid', 'stacked_point' and 'point', which accordingly requires specific structures of west_east, south_north and heights (as described above): * ``cuboid``: west_east, south_north, and heights must contain unique points along their dimension, and west_east and south_north are evenly spaced * ``stacked_point``: west_east and south_north must have the same length, but heights will have unique values representing the dimension values * ``point``: west_east, south_north and heights must have same lengths """ # Ensure that all input arrays are 1d x, y, z = np.atleast_1d(west_east, south_north, height) if struct.lower() == "auto": if _has_raster_dims(x, y): struct = "cuboid" elif (y.size == x.size) and (x.size != z.size): struct = "stacked_point" elif (y.size == x.size) and (y.size == z.size): struct = "point" else: raise ValueError("Cannot identify struct of input data.") # Get names of dimensions x_dim, y_dim = _xy_dims() z_dim = _vertical_dim() pt_dim = _point_dim() stacked_pt_dim = _stacked_point_dim() if struct.lower() == "cuboid": if not _has_raster_dims(x, y): raise ValueError("Data cannot be converted to raster or cuboid dataset.") # Create spatial data arrays shape = z.size, y.size, x.size dims = (z_dim, y_dim, x_dim) z_coord = ((z_dim,), z) y_coord = ((y_dim,), y) x_coord = ((x_dim,), x) elif struct.lower() == "stacked_point": # Raise error if point array cannot be made if y.size != x.size: err_str = "south_north and west_east sizes do not match." raise ValueError(err_str) # make sure the heights in the z array are unique z = np.unique(z) # Create spatial data arrays shape = z.size, x.size dims = (z_dim, stacked_pt_dim) z_coord = ((z_dim,), z) y_coord = ((stacked_pt_dim,), y) x_coord = ((stacked_pt_dim,), x) elif struct.lower() == "point": # Raise error if point array cannot be made if (y.size != x.size) | (y.size != z.size): err_str = ( "point dataset cannot be made with input " + "arrays of differing sizes" ) raise ValueError(err_str) # Create spatial data arrays shape = (x.size,) dims = (pt_dim,) z_coord = ((pt_dim,), z) y_coord = ((pt_dim,), y) x_coord = ((pt_dim,), x) else: raise ValueError("Unknown struct provided.") # Build DataArray data = np.zeros(shape) coords = {z_dim: z_coord, y_dim: y_coord, x_dim: x_coord} out_da = xr.DataArray(data=data, dims=dims, coords=coords) # Build dataset with crs for storing results out_ds = out_da.to_dataset(name="output") out_ds = set_crs(out_ds, crs) out_ds[z_dim].attrs = _ALL_VARS_META[z_dim] out_ds.attrs = _GLOBAL_CONVENTIONS return _update_history(out_ds)
[docs] def create_point(west_east, south_north, height, crs): """Create a WindKit point dataset given a set of locations. Parameters ---------- west_east : array_like 1D array of west_east locations of interest south_north : array_like 1D array of south_north locations of interest height : array_like Array of heights to create in output WindKit xarray dataset crs : int, dict, str or pyproj.crs.CRS Value to initialize :py:class:`pyproj.crs.CRS` Returns ------- xarray.Dataset WindKit formated zero-filled Dataset with one variable "output" and with the grid dimensions specified Notes ----- This function will create a WindKit formatted dataset including all of the geospatial information that is desired. """ return create_dataset(west_east, south_north, height, crs, struct="point")
[docs] def create_stacked_point(west_east, south_north, height, crs): """Create a WindKit stacked point dataset given a set of locations. Parameters ---------- west_east : array_like 1D array of west_east locations of interest south_north : array_like 1D array of south_north locations of interest height : array_like Array of heights to create in output WindKit xarray dataset crs : int, dict, str or pyproj.crs.CRS Value to initialize `pyproj.crs.CRS` Returns ------- xarray.Dataset WindKit formated zero-filled Dataset with one variable "output" and with the grid dimensions specified Notes ----- This function will create a WindKit formatted dataset including all of the geospatial information that is desired. """ return create_dataset(west_east, south_north, height, crs, struct="stacked_point")
[docs] def create_cuboid(west_east, south_north, height, crs): """Create a WindKit cuboid dataset given a set of locations. Parameters ---------- west_east : array_like 1D array of west_east locations of interest south_north : array_like 1D array of south_north locations of interest height : array_like Array of heights to create in output WindKit xarray dataset crs : int, dict, str or pyproj.crs.CRS Value to initialize `pyproj.crs.CRS` Returns ------- xarray.Dataset WindKit formated zero-filled Dataset with one variable "output" and with the grid dimensions specified Notes ----- This function will create a WindKit formatted dataset including all of the geospatial information that is desired. """ return create_dataset(west_east, south_north, height, crs, struct="cuboid")
[docs] def create_raster(west_east, south_north, crs): """Create a WindKit raster dataset given a set of locations. Parameters ---------- west_east : array_like 1D array of west_east locations of interest south_north : array_like 1D array of south_north locations of interest crs : int, dict, str or pyproj.crs.CRS Value to initialize `pyproj.crs.CRS` Returns ------- xarray.Dataset WindKit formated zero-filled Dataset with one variable "output" and with the grid dimensions specified Notes ----- This function will create a WindKit formatted dataset including all of the geospatial information that is desired. """ return create_dataset(west_east, south_north, [0], crs, struct="cuboid")
def _replace_close(arr, thresh=1e-9): # pragma:no cover internal """Replace all values that are close to each other with an identical value Parameters ---------- arr : array_like Array to be examined thresh : float Threshold value for checking spatial separation between coordinates Default set to 1e-9 m Returns ------- array_like Array of the same shape and dtype as arr, but with close values replaced """ unq = np.sort(np.unique(arr)) diff_unq = np.diff(unq) if any(diff_unq <= thresh): for i, diff in enumerate(diff_unq): if diff <= thresh: val = unq[i] arr = np.where(np.isclose(arr, val), val, arr) return arr
[docs] def reproject(obj, to_crs, copy=True): """Reprojects WindKit object a new CRS without changing the data. If the input is a xarray.Dataset or xarray.DataArray with a 'cuboid' spatial structure, the spatial structure will be changed to 'stacked_point', since the coordiates of the new dataset will no longer be regularly spaced. Parameters ---------- obj : geopandas.GeoDataFrame, xarray.DataArray, xarray.Dataset, or BBox WindKit object that will be reprojected. crs : int, dict, str or pyproj.crs.CRS Value to initialize :py:class:`pyproj.crs.CRS` copy : bool If true, the object is copied. Defaults to True. Returns ------- geopandas.GeoDataFrame, xarray.DataArray,xarray.Dataset, or BBox WindKit object with new projection See Also -------- warp Notes ----- This script reprojects the coordinates of the data, and reshapes the data from ``cuboid`` or ``raster`` to ``stacked_point``. This is done so that none of the data is interpolated, but rather the coordinates are just changed to match those of the new projection. If you want to keep your data in ``cuboid`` or ``raster`` format, use the function ``warp`` instead, which however does do interpolation of the data. """ if isinstance(obj, gpd.GeoDataFrame) or isinstance(obj, gpd.GeoSeries): # pyproj triggers a numpy deprecation warning about ndim>0-to-scalar conversion. # See: https://github.com/pyproj4/pyproj/issues/1307 with warnings.catch_warnings(): warnings.filterwarnings( "ignore", message="Conversion of an array with ndim > 0" ) return obj.to_crs(to_crs) elif isinstance(obj, BBox): return obj.reproject(to_crs) # From here we know that the input object is # a xarray.Dataset or xarray.DataArray if copy: obj = obj.copy() # Get input CRS object try: from_crs = get_crs(obj) except ValueError: raise ValueError( "No CRS found on object! Please set with:" + " obj = set_crs(obj, crs)!" ) # Get spatial dim names x_dim, y_dim = _xy_dims() # Get output CRS object to_crs = pyproj.CRS.from_user_input(to_crs) # Reproject dimensions transformer = pyproj.Transformer.from_crs(from_crs, to_crs, always_xy=True) struct = get_spatial_struct(obj) if struct in ["raster", "cuboid"]: obj = to_stacked_point(obj) struct = "stacked_point" elif struct is None: try: obj = _from_scalar(obj) struct = get_spatial_struct(obj) except Exception: raise RuntimeError("Could not determine spatial structure of your data.") # pyproj internally converts ndim>0 arrays to scalars, which numpy >= 1.25 # deprecates. squeeze() makes single-element (1,) arrays 0-d preemptively; # atleast_1d() restores the 1-d shape expected downstream. # See: https://github.com/pyproj4/pyproj/issues/1307 x_new, y_new = transformer.transform( obj[x_dim].values.squeeze(), obj[y_dim].values.squeeze() ) x_new = np.atleast_1d(x_new) y_new = np.atleast_1d(y_new) x_new = _replace_close(x_new) y_new = _replace_close(y_new) obj = obj.drop_vars([x_dim, y_dim]) obj = obj.assign_coords({x_dim: ((struct), x_new), y_dim: ((struct), y_new)}) obj = set_crs(obj, to_crs) return obj
[docs] def warp( obj, to_crs, resolution=None, method="nearest", nodata=None, coerce_to_float=True ): """Warp cuboid WindKit object to another in a new CRS using data interpolation. Parameters ---------- obj : xarray.DataArray or xarray.Dataset WindKit object to warp. to_crs : int, dict, str or CRS Value to create CRS object or an existing CRS object resolution : tuple (x resolution, y resolution) or float, optional Target resolution, in units of target coordinate reference system. Default: None calculates a resolution in the target crs similar to the resolution of the original crs. method : str Interpolation method, passed to rasterio.warp.reproject. Defaults to "nearest". nodata : scalar Initial data to fill output arrays, passed to rasterio.warp.reproject. Defaults to numpy.nan. Returns ------- xarray.DataArray or xarray.Dataset Warped WindKit object. See Also -------- reproject """ kwargs = { "resolution": resolution, "method": method, "nodata": nodata, "coerce_to_float": coerce_to_float, } if isinstance(obj, (xr.Dataset, xr.DataArray)): struct = get_spatial_struct(obj) if struct in ["raster", "cuboid"]: obj = _warp_raster(obj, to_crs, **kwargs) if isinstance(obj, xr.Dataset): return _update_history(obj) else: return obj else: raise ValueError( "Only 'raster' and 'cuboid' objects currently" + " supports warping!" ) else: raise ValueError( "Only xarray Dataset and DataArray objects " + "currently supported for warping" )
[docs] def mask(obj, mask, all_touched=False, invert=False, nodata=None, **kwargs): """Mask WindKit object with geometric mask. Masking an object returns the same object with values outside of the masked region filled with NaN. Parameters ---------- obj : xarray.Dataset,xarray.DataArray WindKit object to mask. mask : geopandas.GeoDataFrame or BBox Mask to mask object by. all_touched : bool *raster* or *cuboid* only: Include all pixels touched by the mask? False includes only those that pass through the center. Passed to rasterio.features.geometry_mask. Defaults to False. invert : bool *raster* or *cuboid* only: If True, values inside the mask are nulled; if False, values outside the mask are nulled. The opposite is passed to rio.features.geometry_mask. Defaults to False. nodata : float *raster* or *cuboid* only: If no data is not None, all masked data will be filled with this value. Default, masked data is set to NaN. Returns ------- same as obj Clipped WindKit object. See Also -------- clip Note ---- This function behaves the opposite of rasterio.features.geometry_mask by default, in that it nulls areas outside of the area of interest rather than inside. For rasters, when the mask edges intersects with the cell centers they are not guaranteed to be included. It is recommend to use a buffer or all_touched=True to be sure. """ kwargs = { **kwargs, "all_touched": all_touched, "invert": invert, "nodata": nodata, } if isinstance(obj, (xr.Dataset, xr.DataArray)): struct = get_spatial_struct(obj) if struct in ["raster", "cuboid"]: obj = _mask_raster(obj, mask, drop=False, **kwargs) if isinstance(obj, xr.Dataset): return _update_history(obj) else: return obj elif struct in ["point", "stacked_point"]: obj = _mask_point(obj, mask, drop=False, **kwargs) if isinstance(obj, xr.Dataset): return _update_history(obj) else: return obj else: raise ValueError("Spatial structure not supported!") else: raise ValueError("Object not supported!")
[docs] def clip(obj, mask, all_touched=False, invert=False, nodata=None, **kwargs): """Clip object to mask. Clipping returns an object that has been reduced to the requested shape. Dropping data that falls outside of the masked region. Parameters ---------- obj : geopandas.GeoDataFrame, xarray.DataArray or xarray.Dataset Object with raster-like dimensions to clip with mask. mask : geopandas.GeoDataFrame or BBox Geometric features to clip out of object. all_touched : bool *raster* or *cuboid* only: Include all pixels touched by the mask? False inclodes only those that pass through the center. Passed to rasterio.features.geometry_mask. Defaults to False. invert : bool *raster* or *cuboid* only: If True, values inside the mask are nulled; if False, values outside the mask are nulled. The opposite is passed to rio.features.geometry_mask. Ignored when ``mask`` is a BBox. Defaults to False. nodata : float *raster* or *cuboid* only: If no data is not None, all masked data will be filled with this value. Default, masked data is set to NaN. kwargs : dict Other keyword-arguments are passed to the underlying function, depending on the type of object. Returns ------- geopandas.GeoDataFrame, xarray.DataArray,xarray.Dataset Object of the same type as obj clipped by geometric features. See Also -------- mask Notes ----- This function behaves the opposite of rasterio.features.geometry_mask by default, in that it nulls areas outside of the area of interest rather than inside. For rasters, when the mask edges intersects with the cell centers they are not guaranteed to be included. It is recommend to use a buffer or all_touched=True to be sure. """ kwargs = {**kwargs, "all_touched": all_touched, "invert": invert, "nodata": nodata} if isinstance(obj, gpd.GeoDataFrame) or isinstance(obj, gpd.GeoSeries): return _clip_vector(obj, mask, **kwargs) elif isinstance(obj, (xr.Dataset, xr.DataArray)): struct = get_spatial_struct(obj) if struct in ["raster", "cuboid"]: if isinstance(mask, BBox): obj = _clip_to_bbox_raster(obj, mask) else: obj = _mask_raster(obj, mask, drop=True, **kwargs) if isinstance(obj, xr.Dataset): return _update_history(obj) else: return obj elif struct in ["point", "stacked_point"]: obj = _mask_point(obj, mask, drop=True, **kwargs) if isinstance(obj, xr.Dataset): return _update_history(obj) else: return obj else: raise ValueError("Spatial structure not supported!") else: raise ValueError("Object not supported!")
def _to_point_with_saved_index(source): """Convert to point structure, saving the stacked index for reversal. Parameters ---------- source : xarray.Dataset or xarray.DataArray WindKit object in any spatial structure. Returns ------- xarray.Dataset or xarray.DataArray Object in point structure with stacked index saved in attributes. """ source = _from_scalar(source) if is_point(source): return source obj_out, mindex = _stack_to_point(source) _save_stacked_index(obj_out, mindex) if isinstance(obj_out, xr.Dataset): obj_out = _update_history(obj_out) return obj_out def _spatial_stack( source, target_crs=None, revertable=True, copy=True, remove_height=False ): """Returns source in a revertable version of the "point" format This routine can be used to ensure a consistent input form to external routines, by always returning in "point" format. It can also do reprojection, and remove the height field to make the result a 2D spatial object. Parameters ---------- source : xarray.DataSet WindKit dataset containing spatial dimensions and CRS variable to convert target_crs : int, dict, str or pyproj.crs.CRS Value to initialize `pyproj.crs.CRS` (Default is to not reproject.) revertable : bool Should we retain information about the original datastructure so we can revert this process? This is typically True, but should be False when interpolating the data to a new projection. copy : bool Should we make a copy of the initial dataset? This is typically true as we don't want to manipulate the original object, but work on a new version. remove_height : bool Is the resulting object always 2D? This is typically false, but can be useful in some instances. Returns ------- stacked : xarray.DataSet WindKit formated xr.DataSet as a point object on the new projection and with additional that allow it to be converted back to its original form using _spatial_unstack. Notes ----- This routine serves two purposes: 1. Convert source to a point object, storing its former structure. 2. Reproject to the target_crs if provided, and not already met. """ if copy: source = source.copy(deep=True) # For some routines, we don't want to pass a height dimesion, so remove it if asked if remove_height: if has_height_dim(source): source = source.isel(height=0, drop=True) if has_height_coord(source): del source["height"] # Remove attributes from previous call if found if not is_point(source): for attr in _STACK_ATTRS: if attr in source.attrs: del source.attrs[attr] # Label original format and get 2D variables if not "point" orig_format = get_spatial_struct(source) if revertable and orig_format in ("stacked_point", "cuboid", "raster"): logger.debug("Converting from original format %s", orig_format) source.attrs[f"_pwio_was_{orig_format}"] = 1 # Identify 2D variables if has_height_dim(source): hgt_dim = _vertical_dim() if isinstance(source, xr.Dataset): for var in source.data_vars: logger.debug("Flagging variable %s as 2d variable", var) source[var].attrs["_pwio_data_is_2d"] = int( hgt_dim not in source[var].dims ) elif isinstance(source, xr.DataArray): logger.debug("Flagging variable as 2d variable") source.attrs["_pwio_data_is_2d"] = int(hgt_dim not in source.dims) # Reproject if requested if target_crs is not None: source_crs = get_crs(source) target_crs = pyproj.CRS.from_user_input(target_crs) if source_crs != target_crs: if revertable: source.attrs["_pwio_orig_srs_wkt"] = source_crs.to_wkt() return reproject(_to_point_with_saved_index(source), target_crs) ds = _to_point_with_saved_index(source) return _update_history(ds) def _spatial_unstack(source): """Unstacks a point object that was created using _spatial_stack Parameters ---------- source : xarray.DataSet WindKit dataset containing spatial dimensions and CRS variable to revert Returns ------- WindKit dataset Source dataset structured as it was originally. Notes ----- This is the companion function to _spatial_stack and does 2 things: 1. Converts 3D-point object to either raster or 2D-point object 2. Reprojects converted object back to its original projection """ # Check that source was previously reprojected if "_pwio_orig_srs_wkt" in source.attrs: source = reproject(source, source.attrs["_pwio_orig_srs_wkt"]) del source.attrs["_pwio_orig_srs_wkt"] # Convert back to original format; the converters may already have # stripped the `_pwio*` attrs on their stale-metadata fallback paths if "_pwio_was_raster" in source.attrs: source = to_raster(source, True) source.attrs.pop("_pwio_was_raster", None) elif "_pwio_was_cuboid" in source.attrs: source = to_raster(source, True) source.attrs.pop("_pwio_was_cuboid", None) elif "_pwio_was_stacked_point" in source.attrs: source = to_stacked_point(source) source.attrs.pop("_pwio_was_stacked_point", None) # Transpose the coordinates to match netCDF best practices hgt_dim = (_vertical_dim(),) if _is_vertical(source) else () if is_stacked_point(source): geo_dims_order = hgt_dim + ("stacked_point",) elif is_raster(source): # also a cuboid is a raster, if it includes a hgt_dim geo_dims_order = hgt_dim + ("south_north", "west_east") else: geo_dims_order = ("point",) # Removing _pwio_data_is_2d attr from dataset or dataarray if isinstance(source, xr.Dataset): for var in source.data_vars.keys(): if "_pwio_data_is_2d" in source[var].attrs: source[var].attrs.pop("_pwio_data_is_2d", None) elif isinstance(source, xr.DataArray): if "_pwio_data_is_2d" in source.attrs: source.attrs.pop("_pwio_data_is_2d", None) # Drop any leftover internal attrs from the fallback path where stale # stacked-index metadata could not be restored safely. source = _drop_pwio_attrs(source) ds = source.transpose(..., *geo_dims_order) return _update_history(ds) def _isel_nearest(ds_ref, source_idx, ref_spatial_dims, ref_shape, out_dims): """Lazily select ``ds_ref`` at flat ``source_idx``, laying results along ``out_dims``. All work is done on the integer index arrays returned by the KDTree query together with a single :meth:`xarray.Dataset.isel`; the data variables of ``ds_ref`` are never loaded into memory. Parameters ---------- ds_ref : xr.Dataset Reference dataset to select from. source_idx : numpy.ndarray Flat indices into the raveled reference spatial space, shaped like ``out_dims``. ref_spatial_dims : list of str Reference spatial dimensions, in ravel order. ref_shape : tuple of int Sizes of ``ref_spatial_dims``. out_dims : tuple of str Output dimension names to lay the selection along. Returns ------- xr.Dataset Lazily selected points. """ source_idx = np.asarray(source_idx) unraveled = tuple( component.reshape(source_idx.shape) for component in np.unravel_index(source_idx.ravel(), ref_shape) ) indexer = { dim: xr.DataArray(unraveled[i], dims=out_dims) for i, dim in enumerate(ref_spatial_dims) } nearest = ds_ref.isel(indexer) for dim in ("point", "stacked_point"): if dim in nearest.coords and dim not in out_dims: nearest = nearest.drop_vars(dim) return nearest def _normalize_point_coords(nearest): """Make scalar spatial coordinates conform to the point layout.""" if "point" not in nearest.dims: return nearest new_coords = {} for name in ("west_east", "south_north", "height"): if name not in nearest.coords: continue coord = nearest[name] if coord.dims == (): values = np.full(nearest.sizes["point"], coord.item()) new_coords[name] = (("point",), values, coord.attrs) if new_coords: nearest = nearest.assign_coords(new_coords) return nearest def _spatial_dims(obj): """Return spatial dimensions in a deterministic internal order.""" return [ dim for dim in ("point", "stacked_point", "south_north", "west_east", "height") if dim in obj.dims ] def _lookup_view(obj, dims): """Slice structured axes not used by lookup coordinates to their first cell.""" spatial_dims = _spatial_dims(obj) used_dims = {dim for name in dims for dim in obj[name].dims if dim in spatial_dims} omitted_dims = [dim for dim in spatial_dims if dim not in used_dims] view = obj.isel({dim: 0 for dim in omitted_dims}) return view, _spatial_dims(view) def _flatten_lookup_coords(obj, dims, spatial_dims): """Broadcast lookup coordinates and return a flat coordinate matrix.""" coords = [obj[name] for name in dims] dummy = xr.DataArray( 0, coords={dim: obj[dim] for dim in spatial_dims}, dims=spatial_dims, ) broadcasted = list(xr.broadcast(dummy, *coords))[1:] broadcasted = [ coord.transpose(*[dim for dim in spatial_dims if dim in coord.dims]) for coord in broadcasted ] shape = tuple(obj.sizes[dim] for dim in spatial_dims) arrays = np.stack([coord.values.ravel() for coord in broadcasted], axis=-1) return arrays, shape def _lookup_is_unique(obj, dims, spatial_dims): """Return whether a structured lookup is provably unique without deduplication.""" if get_spatial_struct(obj) not in ("cuboid", "raster"): return False for spatial_dim in spatial_dims: matching_coords = [ obj[name] for name in dims if obj[name].dims == (spatial_dim,) ] if not matching_coords: return False if not any( np.unique(coord.values).size == coord.size for coord in matching_coords ): return False return True def _broadcast_query_result(values, reduced_dims, reduced_shape, full_dims, full_shape): """Broadcast a reduced structured query result over omitted target axes.""" values = np.asarray(values) trailing_shape = values.shape[1:] values = values.reshape(reduced_shape + trailing_shape) reshape = tuple( size if dim in reduced_dims else 1 for dim, size in zip(full_dims, full_shape) ) return np.broadcast_to( values.reshape(reshape + trailing_shape), full_shape + trailing_shape ) def _collapse_coords(nearest, coord_dims): """Reduce selected source coordinates to a target coordinate dimensionality. For each named coordinate, drop any dimension not present in the requested ``coord_dims`` after verifying the coordinate is invariant along it. Returns ``None`` when a coordinate genuinely varies along a dropped dimension, i.e. the selection cannot be represented with the requested structure. Only coordinate metadata is touched, so laziness of the data variables is kept. Parameters ---------- nearest : xr.Dataset Selected dataset whose spatial coordinates may carry extra dimensions. coord_dims : dict of str to tuple of str Target dimensions for each spatial coordinate. Returns ------- xr.Dataset or None Dataset with collapsed coordinates, or ``None`` if not representable. """ new_coords = {} for name, target_dims in coord_dims.items(): if name not in nearest.coords: continue coord = nearest[name] drop_dims = [d for d in coord.dims if d not in target_dims] if drop_dims: reduced = coord.isel({d: 0 for d in drop_dims}) # NaN-tolerant invariance check: a constant coordinate (including one # holding NaN) must not be judged as varying, since ``NaN != NaN``. matches = (coord == reduced) | (coord.isnull() & reduced.isnull()) if not bool(matches.all()): return None else: # Already at the target dimensionality; keep it (and its attributes). reduced = coord # A coordinate that ends up as its own dimension coordinate (e.g. ``height`` # on a stacked_point output) must stay unique; repeated values -- when # several target levels select the same source level -- give an ambiguous # index, so the selection cannot form a clean structure and we return None # (the caller raises for a structured target). Checked regardless of # whether a dimension was dropped. if reduced.dims == (name,): vals = reduced.values if np.unique(vals).size != vals.size: return None if drop_dims: new_coords[name] = (reduced.dims, reduced.values, reduced.attrs) if new_coords: nearest = nearest.assign_coords(new_coords) return nearest def _target_coord_dims(ds_target): """Return the coordinate layout required by a target spatial structure.""" struct = get_spatial_struct(ds_target) if struct == "point": return { "west_east": ("point",), "south_north": ("point",), "height": ("point",), } if struct == "stacked_point": return { "west_east": ("stacked_point",), "south_north": ("stacked_point",), "height": ("height",), } if struct == "cuboid": return { "west_east": ("west_east",), "south_north": ("south_north",), "height": ("height",), } if struct == "raster": return { "west_east": ("west_east",), "south_north": ("south_north",), "height": (), } if "height" in ds_target.dims: return { "west_east": (), "south_north": (), "height": ("height",), } return {"west_east": (), "south_north": (), "height": ()} def _match_target_structure(nearest, ds_target): """Return ``nearest`` in the target layout when its coordinates are separable.""" coord_dims = _target_coord_dims(ds_target) matched = _collapse_coords(nearest, coord_dims) if matched is None: return None for name, expected_dims in coord_dims.items(): if name in matched.coords and matched[name].dims != expected_dims: return None target_struct = get_spatial_struct(ds_target) if target_struct is not None and get_spatial_struct(matched) != target_struct: return None if target_struct in ("cuboid", "raster") and not _can_be_raster( matched["west_east"].values, matched["south_north"].values, ): return None return _apply_height_contract(matched, _vertical_dim()) def _restore_structure(nearest, ds_target): """Lay unique nearest points back onto the target's structure if they form it. Converts the point-structured ``nearest`` selection to the target's ``stacked_point``/``raster``/``cuboid`` structure and validates it by comparing spatial sizes (metadata only, so no data variables are loaded here). Returns ``None`` when the points do not form that structure, so the caller can keep a point output rather than silently mis-shaping the result. Parameters ---------- nearest : xr.Dataset Point-structured selection of unique nearest points. ds_target : xr.Dataset Target whose structure is being restored. Returns ------- xr.Dataset or None The restored dataset, or ``None`` if the points do not form the structure. """ n_points = nearest.sizes.get("point", 0) try: if is_stacked_point(ds_target): restored = to_stacked_point(nearest) elif is_cuboid(ds_target) or is_raster(ds_target): restored = to_raster(nearest, True) if is_raster(ds_target) and "height" in restored.dims: if restored.sizes["height"] != 1: return None restored = restored.isel(height=0) else: return None except (ValueError, KeyError): return None # Metadata-only completeness check: if the restored structure spans more grid # cells than there were unique points, an incomplete grid was NaN-padded, so # the restore mis-shaped the result and we reject it. spatial_dims = [ d for d in ("stacked_point", "south_north", "west_east", "height") if d in restored.dims ] restored_size = int(np.prod([restored.sizes[d] for d in spatial_dims])) if restored_size != n_points: return None if (is_cuboid(restored) or is_raster(restored)) and not _can_be_raster( restored["west_east"].values, restored["south_north"].values, ): return None return restored @dataclass(frozen=True) class _NearestLookup: """Reference data and index used by a nearest-neighbour query.""" source: xr.Dataset spatial_dims: tuple[str, ...] shape: tuple[int, ...] representative_indices: np.ndarray tree: KDTree @dataclass(frozen=True) class _NearestQuery: """Nearest-neighbour indices and distances in the target layout.""" source_indices: np.ndarray distances: np.ndarray target_dims: tuple[str, ...] target_shape: tuple[int, ...] class _NearestOutput(Enum): """Ways an exact nearest-neighbour selection can be represented.""" POINT = auto() TARGET = auto() RESTORE_UNIQUE = auto() def _validate_nearest_points( ds_ref, ds_target, dims, n_nearest, return_rank, return_distance, keep_duplicates, ): """Validate nearest-neighbour arguments and lookup coordinates.""" ref_crs = get_crs(ds_ref) target_crs = get_crs(ds_target) if ref_crs.is_geographic or target_crs.is_geographic: warnings.warn( "You are doing nearest neighbour lookup in non metric coordinate systems!", UserWarning, stacklevel=3, ) if ref_crs != target_crs: raise ValueError("Datasets must have the same coordinate system!") if not dims: raise ValueError("dims must contain at least one coordinate name") if n_nearest < 1: raise ValueError("n_nearest must be at least 1") if not keep_duplicates and n_nearest > 1: raise ValueError("n_nearest must be 1 when keep_duplicates is False") if return_distance and "distance" in ds_ref.variables: raise ValueError( "Cannot return distance because ds_ref already contains 'distance'." ) if return_rank and n_nearest > 1 and "rank" in ds_ref.variables: raise ValueError("Cannot return rank because ds_ref already contains 'rank'.") for name in dims: if name not in ds_ref.coords: raise ValueError( f"Dimension '{name}' not found in reference dataset coordinates." ) if name not in ds_target.coords: raise ValueError( f"Dimension '{name}' not found in target dataset coordinates." ) for label, obj in (("reference", ds_ref), ("target", ds_target)): for name in dims: try: is_finite = np.isfinite(obj[name].values) except TypeError as exc: raise ValueError( f"Lookup coordinate '{name}' in the {label} dataset must be numeric." ) from exc if not bool(np.all(is_finite)): raise ValueError( f"Lookup coordinate '{name}' in the {label} dataset contains " "non-finite values." ) omitted_coords = [ name for name in ("west_east", "south_north", "height") if name in ds_ref.coords and name not in dims and ds_ref[name].size > 1 ] if omitted_coords: warnings.warn( f"Lookup dims omit source spatial coordinate(s) {omitted_coords}; " "nearest_points will deterministically use the first source " "representative. Include these coordinates in dims for " "multidimensional matching.", UserWarning, stacklevel=3, ) def _prepare_nearest_lookup(ds_ref, dims): """Build a KDTree over distinct reference locations in lookup space.""" source = _drop_pwio_attrs(_from_scalar(ds_ref)) lookup_source, spatial_dims = _lookup_view(source, dims) arrays, shape = _flatten_lookup_coords(lookup_source, dims, spatial_dims) if _lookup_is_unique(source, dims, spatial_dims): representative_indices = np.arange(arrays.shape[0]) else: _, representative_indices = np.unique(arrays, axis=0, return_index=True) representative_indices.sort() tree = KDTree( arrays[representative_indices], balanced_tree=False, compact_nodes=False, ) return _NearestLookup( source=lookup_source, spatial_dims=tuple(spatial_dims), shape=shape, representative_indices=representative_indices, tree=tree, ) def _query_nearest(lookup, ds_target, dims, n_nearest, workers): """Query the lookup and broadcast results to the complete target layout.""" if n_nearest > lookup.tree.n: raise ValueError( f"n_nearest ({n_nearest}) cannot exceed the number of reference " f"locations in lookup space ({lookup.tree.n})." ) target_dims = tuple(_spatial_dims(ds_target)) target_shape = tuple(ds_target.sizes[dim] for dim in target_dims) target_lookup, reduced_dims = _lookup_view(ds_target, dims) arrays, reduced_shape = _flatten_lookup_coords(target_lookup, dims, reduced_dims) distances, source_indices = lookup.tree.query( arrays, k=n_nearest, workers=workers, ) source_indices = lookup.representative_indices[source_indices] distances = _broadcast_query_result( distances, reduced_dims, reduced_shape, target_dims, target_shape, ) source_indices = _broadcast_query_result( source_indices, reduced_dims, reduced_shape, target_dims, target_shape, ) return _NearestQuery(source_indices, distances, target_dims, target_shape) def _structure_error(reason): """Raise a structured-output error with the lossless point remedy.""" reason = reason.rstrip(".") raise ValueError( f"{reason}. Convert ds_target with to_point(ds_target) to request " "lossless point output." ) def _nearest_output_policy(source_indices, target_struct, n_nearest, keep_duplicates): """Choose how exact selections can satisfy the public output contract.""" positions = np.arange(source_indices.size) if n_nearest > 1: if target_struct != "point": _structure_error( "Multiple neighbours per target cannot represent the target " "spatial structure" ) return _NearestOutput.POINT, positions, False flat_indices = source_indices.ravel() duplicates_removed = False if not keep_duplicates: _, positions = np.unique(flat_indices, return_index=True) positions.sort() duplicates_removed = positions.size != flat_indices.size if target_struct == "point": return _NearestOutput.POINT, positions, duplicates_removed if duplicates_removed: return _NearestOutput.RESTORE_UNIQUE, positions, True if flat_indices.size != np.unique(flat_indices).size: _structure_error( "Multiple target cells map to the same source cell, so the target " "structure cannot represent every mapping" ) return _NearestOutput.TARGET, positions, False def _select_nearest(lookup, indices, distances, out_dims, return_distance): """Select exact source cells and optionally attach query distances.""" nearest = _isel_nearest( lookup.source, indices, lookup.spatial_dims, lookup.shape, out_dims, ) if return_distance: distances = np.array(distances, copy=True, order="C") nearest["distance"] = xr.DataArray(distances, dims=out_dims) return nearest def _select_nearest_point(lookup, indices, distances, return_distance): """Select exact source cells into a normalized point structure.""" nearest = _select_nearest( lookup, np.asarray(indices).ravel(), np.asarray(distances).ravel(), ("point",), return_distance, ) return _normalize_point_coords(nearest) def _format_nearest_result( lookup, query, ds_target, policy, positions, duplicates_removed, n_nearest, return_rank, return_distance, ): """Select and format query results according to the chosen output policy.""" if duplicates_removed: warnings.warn( "You have multiple points in your target dataset mapping to the " "same point in your source ds. The resulting duplicates will be " "removed from the output! Use keep_duplicates=True to retain " "every mapping.", UserWarning, stacklevel=3, ) flat_indices = query.source_indices.ravel() flat_distances = query.distances.ravel() if policy is _NearestOutput.POINT: nearest = _select_nearest_point( lookup, flat_indices[positions], flat_distances[positions], return_distance, ) if n_nearest > 1 and return_rank: n_targets = int(np.prod(query.target_shape) or 1) rank = np.tile(np.arange(n_nearest), n_targets) nearest = nearest.assign_coords(rank=("point", rank)) elif policy is _NearestOutput.RESTORE_UNIQUE: nearest = _select_nearest_point( lookup, flat_indices[positions], flat_distances[positions], return_distance, ) nearest = _restore_structure(nearest, ds_target) if nearest is None: _structure_error( "The unique exact source selections cannot form the target " "spatial structure" ) else: nearest = _select_nearest( lookup, query.source_indices, query.distances, query.target_dims, return_distance, ) nearest = _match_target_structure(nearest, ds_target) if nearest is None: _structure_error( "The exact source selections cannot form the target spatial structure " "without changing coordinates or adding cells" ) return _update_history(_drop_pwio_attrs(nearest))
[docs] def nearest_points( ds_ref: xr.Dataset, ds_target: xr.Dataset, dims=["west_east", "south_north", "height"], n_nearest=1, return_rank=False, return_distance=False, keep_duplicates=True, workers=1, ): """Select the nearest source point(s) for each target location. For every location in ``ds_target``, find the nearest location(s) in ``ds_ref`` by Euclidean distance over ``dims`` and return the exact selected source coordinates and data. No source values are interpolated. Parameters ---------- ds_ref : xr.Dataset Source dataset. Any WindKit spatial structure is accepted. ds_target : xr.Dataset Dataset whose locations and spatial structure define the result. dims : list of str, default ["west_east", "south_north", "height"] Coordinate names used to measure distance. When a source spatial coordinate is omitted, its first representative is selected deterministically and a warning is issued. n_nearest : int, default 1 Number of distinct source locations in lookup space to return per target. Values greater than one require a point target. return_rank : bool, default False Add a ``rank`` coordinate when ``n_nearest > 1``. return_distance : bool, default False Add a ``distance`` data variable containing query distances. keep_duplicates : bool, default True Keep repeated source selections. When False, repeated selections are removed and ``n_nearest`` must be one. workers : int, default 1 Number of workers passed to :meth:`scipy.spatial.KDTree.query`. Use ``-1`` to use all available CPU threads. Returns ------- xr.Dataset Exact selected source cells represented with the target spatial structure. Raises ------ ValueError If coordinate systems differ; lookup coordinates are missing or non-finite; arguments are invalid; ``distance`` or ``rank`` would collide with an existing source variable; or the exact source selections cannot represent the target spatial structure. Warns ----- UserWarning For geographic coordinate lookup, omitted source spatial coordinates, or removed duplicate mappings. Notes ----- Dimension order is not part of the return contract. Equal-distance neighbour ordering follows SciPy and is unspecified; selected coordinates, data, distance, and rank always remain paired. Structured output is accepted only when the exact selected coordinates are separable and unique, and grid output additionally satisfies WindKit's regular-raster requirements. Pass ``to_point(ds_target)`` to request lossless point output explicitly. """ _validate_nearest_points( ds_ref, ds_target, dims, n_nearest, return_rank, return_distance, keep_duplicates, ) lookup = _prepare_nearest_lookup(ds_ref, dims) query = _query_nearest(lookup, ds_target, dims, n_nearest, workers) policy, positions, duplicates_removed = _nearest_output_policy( query.source_indices, get_spatial_struct(ds_target), n_nearest, keep_duplicates, ) return _format_nearest_result( lookup, query, ds_target, policy, positions, duplicates_removed, n_nearest, return_rank, return_distance, )
[docs] def covers(obj_a, obj_b): """ Checks if obj_a covers obj_b Parameters ---------- obj_a : xarray.Dataset, xarray.DataArray WindKit xarray dataset or dataarray containing spatial dimensions. Only cuboid structure is currently supported for obj_a. obj_b : xarray.Dataset, xarray.DataArray WindKit xarray dataset or dataarray containing spatial dimensions. Returns ------- bool True if obj_a covers obj_b """ if not is_cuboid(obj_a): raise ValueError( "obj_a must be cuboid structure, got:", get_spatial_struct(obj_a) ) x_dim, y_dim = _xy_dims() z_dim = _vertical_dim() x_min, x_max = obj_a[x_dim].min(), obj_a[x_dim].max() y_min, y_max = obj_a[y_dim].min(), obj_a[y_dim].max() z_min, z_max = obj_a[z_dim].min(), obj_a[z_dim].max() x, y, z = obj_b[x_dim], obj_b[y_dim], obj_b[z_dim] return ( (x_min <= x).all() & (x <= x_max).all() & (y_min <= y).all() & (y <= y_max).all() & (z_min <= z).all() & (z <= z_max).all() )
[docs] def clip_with_margin( obj_to_clip, obj_clipper, margin_dx_factor=5.0, cap_style=1, join_style=1 ): """ Clip a 'raster' or 'cuboid' dataset to the bounding box of another 'raster', 'cuboid', 'stacked_point', or 'point' dataset including a margin around that dataset to ensure several points are avaiable around the clipper dataset from the clipped one. Parameters ---------- obj_to_clip : xarray Dataset or DataArray The dataset to clip obj_clipper : xarray Dataset or DataArray The dataset to clip to margin_dx_factor : float, optional The margin to add to the bounding box of the clipper dataset, by default 5.0, i.e. 5 times the grid spacing of the dataset to clip cap_style : int, optional The cap style to use for buffering the bounding box of the clipper dataset, by default 1 (round buffering) join_style : int, optional The join style to use for buffering the bounding box of the clipper dataset, by default 1 (round buffering) Returns ------- xarray Dataset or DataArray The clipped dataset """ obj_to_clip = obj_to_clip.copy() if not isinstance(obj_to_clip, (xr.Dataset, xr.DataArray)): raise TypeError("obj_to_clip must be an xarray Dataset or DataArray") if not isinstance(obj_clipper, (xr.Dataset, xr.DataArray)): raise TypeError("obj_clipper must be an xarray Dataset or DataArray") if not is_raster(obj_to_clip): raise ValueError("obj_to_clip must be a cuboid") # Get the CRS of the dataset to clip and the grid spacing resolution # to use for buffering of the bounding buffer # (5 times the resolution of the climate dataset) # We will use the bbox to clip the climate dataset before interpolation crs_clip = get_crs(obj_to_clip) spacing_clip, _ = _raster_resolution(obj_to_clip) bbox_buffer = margin_dx_factor * spacing_clip # Create a bounding box around the output locations buffered with enough edge points # for the interpolation to work¨ # cap_style=1, join_style=1 is used (round buffering) to avoid issues with # spatial datasets of points forming a straigth line (default buffer is square # and wont buffer the points in this case) bbox = ( BBox.from_ds(obj_clipper) .reproject(crs_clip, use_bounds=False) .buffer(bbox_buffer, cap_style=cap_style, join_style=join_style) .envelope() ) # check if bbox is outside bounds of the dataset bounds_to_clip = _raster_bounds(obj_to_clip) bounds_bbox = bbox.bounds() # Use min/max to handle datasets where coordinate bounds may be # reversed (e.g., ERA5/CFSR with south_north going from 90 to -90). clip_x_min = min(bounds_to_clip[0], bounds_to_clip[2]) clip_x_max = max(bounds_to_clip[0], bounds_to_clip[2]) clip_y_min = min(bounds_to_clip[1], bounds_to_clip[3]) clip_y_max = max(bounds_to_clip[1], bounds_to_clip[3]) x_size = clip_x_max - clip_x_min if ( bbox.crs.is_geographic and x_size >= 360 and (bounds_bbox[0] < clip_x_min or bounds_bbox[2] > clip_x_max) ): raise ValueError( "Bounding box is crossing the date line, this may result in missing data!" ) elif ( bounds_bbox[0] < clip_x_min or bounds_bbox[1] < clip_y_min or bounds_bbox[2] > clip_x_max or bounds_bbox[3] > clip_y_max ): warnings.warn( "Clipping bbox including margin is outside the bounds of the dataset to clip!", stacklevel=2, ) # Clip the dataset to the bounding box return clip(obj_to_clip, bbox)
[docs] def equal_spatial_shape(obj_a, obj_b): """Check if two spatial objects have the same shape. Parameters ---------- obj_a : xr.Dataset or xr.DataArray Spatial object. obj_b : xr.Dataset or xr.DataArray Spatial object. Returns ------- bool True if the objects have the same shape, False otherwise. """ struct_a = get_spatial_struct(obj_a) struct_b = get_spatial_struct(obj_b) if struct_a != struct_b: return False if struct_a == "point": return obj_a.point.size == obj_b.point.size elif struct_a == "stacked_point": return ( obj_a.stacked_point.size == obj_b.stacked_point.size and obj_a.height.size == obj_b.height.size ) elif struct_a == "cuboid": return ( obj_a.west_east.size == obj_b.west_east.size and obj_a.south_north.size == obj_b.south_north.size and obj_a.height.size == obj_b.height.size ) elif struct_a == "raster": return ( obj_a.west_east.size == obj_b.west_east.size and obj_a.south_north.size == obj_b.south_north.size ) else: raise ValueError(f"Unknown spatial structure '{struct_a}'.")
[docs] def ds_to_gdf(ds, include_height=False): """Convert windkit spatial structure to geopandas dataframe. Parameters ---------- ds : xr.Dataset or xr.DataArray Spatial object. include_height : boolean, optional Default False, do not include the height dimension in the geopandas dataframe Returns ------- gdf : gpd.GeoDataFrame GeoDataFrame with columns ``x``, ``y`` and optionally ``z`` if include_height is True Notes ----- If a coordinate ``name`` is present on the dataset, it will be included in the geodataframe as well. This can be convenient when you have mast locations that have a name/label. Note that if your dataset is in cuboid or stacked_point format, it will be flattened to a point structure. """ point_ds = to_point(ds) in_crs = get_crs(ds) struct = (point_ds.coords["west_east"], point_ds.coords["south_north"]) if include_height: struct = struct + (point_ds.coords["height"],) try: gdf = gpd.GeoDataFrame( {"name": point_ds["name"]}, geometry=gpd.points_from_xy(*struct), crs=in_crs ) except Exception: gdf = gpd.GeoDataFrame(geometry=gpd.points_from_xy(*struct), crs=in_crs) return gdf.drop_duplicates()
def _get_coords_from_gdf(gdf, height=None): """ Extracts the x, y, and z coordinates from a GeoDataFrame. If the GeoDataFrame does not contain z values, a specified height is used for all points. Parameters ---------- gdf : GeoDataFrame The GeoDataFrame from which to extract the coordinates. The GeoDataFrame should contain a 'geometry' column with Point geometries. If the points do not have a z coordinate, the 'height' parameter must be provided. height : float or None The height to use for all points if the GeoDataFrame does not contain z values. If the GeoDataFrame does contain z values, this parameter is ignored. If the GeoDataFrame does not contain z values and this parameter is None, a ValueError is raised. Returns ------- x, y, z : ndarray The x, y, and z coordinates of the points in the GeoDataFrame. The coordinates are returned as separate 1D numpy arrays. Raises ------ ValueError If the GeoDataFrame does not contain z values and 'height' is None. """ if all(gdf.geometry.has_z): df = gdf.geometry.get_coordinates(include_z=True) else: df = gdf.geometry.get_coordinates(include_z=False) if height is None: raise ValueError( "You must provide a height for the dataset, because the geodataframe only contains x and y!" ) df["z"] = height if "name" in gdf.columns: df["name"] = gdf["name"] return df
[docs] def gdf_to_ds(gdf, height=None, struct=None): """Convert geopandas dataframe to windkit spatial structure. Parameters ---------- gdf : gpd.GeoDataFrame Geopandas dataframe with geometry column height : float, optional The value of the height coordinate if it is not included in the gpd.GeoDataFrame. struct : {"point", "stacked_point", "cuboid"} Default None, which means detect the best fitting windkit spatial structure. Returns ------- ds : xr.Dataset dataset with a windkit spatial structure. Notes ----- If a column ``name`` is present on the GeoDataFrame and your data is in point or stacked_point structure, it will be included in the xr.Dataset as well. This can be convenient when you have mast locations that have a name/label. """ df = _get_coords_from_gdf(gdf, height=height) if struct is None: z = df.set_index("z").index.unique() # check if dataset can be a raster if _can_be_raster(df.x, df.y): struct = "cuboid" # in horizontal plane we can have a cuboid, but height dimension is varying, so return point if np.mod(gdf.size, z.size) != 0: struct = "point" elif np.mod(gdf.size, z.size) == 0: struct = "stacked_point" else: struct = "point" if struct == "cuboid": x = sorted(set(df.x)) y = sorted(set(df.y)) z = sorted(set(df.z)) elif struct == "stacked_point": df = df[~df.set_index(["x", "y"]).index.duplicated(keep="first")] x, y = df[["x", "y"]].values.T z = df.set_index(["z"]).index.unique() ds = create_dataset(x, y, z, struct=struct, crs=gdf.crs) if "name" in gdf.columns and (struct == "point" or struct == "stacked_point"): ds.coords["name"] = (struct, df["name"]) return ds
[docs] def add_projected_wrf_coordinates(ds): """Add the west_east, south_north coordinates to a WRF xarray.Dataset output. Parameters ---------- ds: xarray.Dataset Dataset that is the output of WRF, with TRUELAT1, TRUELAT2, MOAD_CEN_LAT, STAND_LON coordinates. Returns ------- ds : xr.Dataset dataset with the windkit west_east, south_north coordinates. """ if ds.MAP_PROJ == 1: wrf_proj = pyproj.Proj( proj="lcc", lat_1=ds.TRUELAT1, lat_2=ds.TRUELAT2, lat_0=ds.MOAD_CEN_LAT, lon_0=ds.STAND_LON, a=6370000, b=6370000, ) # I have not verified if this works, easiest way to check is just write out # the wrfout and check that you can obtain the given lat/lon after reprojection # as described here: # https://fabienmaussion.info/2018/01/06/wrf-projection/ # elif ds.MAP_PROJ == 2: # wrf_proj = pyproj.Proj(proj='stere', # lat_1=ds.TRUELAT1, lat_2=ds.TRUELAT2, # lat_0=ds.MOAD_CEN_LAT, lon_0=ds.STAND_LON, # a=6370000, b=6370000) # elif ds.MAP_PROJ == 0: # wrf_proj = pyproj.Proj(proj='eqc', # lat_1=ds.TRUELAT1, lat_2=ds.TRUELAT2, # lat_0=ds.MOAD_CEN_LAT, lon_0=ds.STAND_LON, # a=6370000, b=6370000) elif ds.MAP_PROJ == 3: # radius earth has to be specified, see here: # https://gis.stackexchange.com/questions/203923/using-pyproj-and-mercator-x-0-0-and-y-0-0 rad_earth = 6370000 * np.cos(np.radians(ds.TRUELAT1)) wrf_proj = pyproj.Proj( proj="merc", lat_1=ds.TRUELAT1, lat_2=ds.TRUELAT2, lat_0=ds.MOAD_CEN_LAT, lon_0=ds.STAND_LON, a=rad_earth, b=rad_earth, ) else: raise NotImplementedError( "Only llc and mercator (MAP_PROJ=1 or 3) supported at the moment!" ) wgs_proj = pyproj.Proj(proj="latlong", datum="WGS84") trans = pyproj.Transformer.from_proj(wgs_proj, wrf_proj) e, n = trans.transform(ds.CEN_LON, ds.CEN_LAT) dx, dy = ds.DX, ds.DY nx, ny = ds.dims["west_east"], ds.dims["south_north"] x0 = -(nx - 1) / 2.0 * dx + e y0 = -(ny - 1) / 2.0 * dy + n x = np.arange(nx) * dx + x0 y = np.arange(ny) * dy + y0 ds = ds.assign_coords( west_east=(("west_east",), x), south_north=(("south_north"), y) ) ds = set_crs(ds, wrf_proj.crs) return ds