"""Utility functions."""
import copy
import itertools
import numpy as np
import xarray as xr
from importlib import import_module
from inspect import getmembers, isfunction
from scipy.interpolate import griddata
from wavespectra.core.attributes import attrs, set_spec_attributes
D2R = np.pi / 180.0
R2D = 180.0 / np.pi
[docs]
def create_frequencies(f0: float, nf: int, df: float = 1.1) -> np.ndarray:
"""Create an array of logarithmically spaced frequencies.
Args:
- f0 (float): First frequency value in Hz (e.g., 0.03453)
- nf (int): Number of frequencies to generate
- df (float, optional): Multiplicative increment between frequencies. Defaults to 1.1.
Returns:
- np.ndarray: Array of frequencies in Hz
"""
return f0 * (df ** np.arange(nf))
def angle(dir1, dir2):
"""Relative angle between two directions.
Args:
- dir1 (array): First direction (degree).
- dir2 (array): Second direction (degree).
Returns:
- angle (array): Angle difference between dir1 and dir2 (degree).
"""
dif = np.absolute(dir1 % 360 - dir2 % 360)
return np.minimum(dif, 360 - dif)
[docs]
def waveage(freq, dir, wspd, wdir, dpt, agefac):
"""Wave age criterion for partitioning wind-sea.
Args:
- freq (xr.DataArray): Spectral frequencies.
- dir (xr.DataArray): Spectral directions.
- wspd (xr.DataArray): Wind speed.
- wdir (xr.DataArray): Wind direction.
- dpt (xr.DataArray): Water depth.
- agefac (float): Age factor.
"""
wind_speed_component = agefac * wspd * np.cos(D2R * (dir - wdir))
wave_celerity = celerity(freq, dpt)
return wave_celerity <= wind_speed_component
[docs]
def wavelen(freq, depth=None):
"""Wavelength L.
Args:
- freq (ndarray): Frequencies (Hz) for calculating L.
- depth (float): Water depth, use deep water approximation by default.
Returns;
- L: ndarray of same shape as freq with wavelength for each frequency.
"""
if depth is not None:
return 2 * np.pi / wavenuma(freq, depth)
else:
return 1.56 / freq**2
[docs]
def wavenuma(freq, water_depth):
"""Chen and Thomson wavenumber approximation.
Args:
freq (DataArray, 1darray, float): Frequencies (Hz).
water_depth (DataArray, float): Water depth (m).
Returns:
k (DataArray, 1darray, float): Wavenumber 2pi / L.
"""
ang_freq = 2 * np.pi * freq
k0h = 0.10194 * ang_freq * ang_freq * water_depth
D = [0, 0.6522, 0.4622, 0, 0.0864, 0.0675]
a = 1.0
for i in range(1, 6):
a += D[i] * k0h**i
return (k0h * (1 + 1.0 / (k0h * a)) ** 0.5) / water_depth
[docs]
def celerity(freq, depth=None):
"""Wave celerity C.
Args:
- freq (ndarray): Frequencies (Hz) for calculating C.
- depth (float): Water depth, use deep water approximation by default.
Returns;
- C: ndarray of same shape as freq with wave celerity (m/s) for each frequency.
"""
if depth is not None:
ang_freq = 2 * np.pi * freq
return ang_freq / wavenuma(freq, depth)
else:
return 1.56 / freq
[docs]
def to_nautical(ang):
"""Convert from cartesian to nautical angle."""
return np.mod(270 - ang, 360)
[docs]
def unique_indices(ds, dim="time"):
"""Remove duplicate indices from dataset.
Args:
- ds (Dataset, DataArray): Dataset to remove duplicate indices from.
- dim (str): Dimension to remove duplicate indices from.
Returns:
dsout (Dataset, DataArray): Dataset with duplicate indices along dim removed.
"""
_, index = np.unique(ds[dim], return_index=True)
return ds.isel(**{dim: index})
[docs]
def unique_times(ds):
"""Remove duplicate times from dataset."""
return unique_indices(ds, "time")
[docs]
def spddir_to_uv(spd, direc, coming_from=False):
"""Converts (spd, dir) to (u, v).
Args:
spd (array): magnitudes to convert.
direc (array): directions to convert (degree).
coming_from (bool): True if directions in coming-from convention,
False if in going-to.
Returns:
u (array): eastward wind component.
v (array): northward wind component.
"""
ang_rot = 180 if coming_from else 0
direcR = np.deg2rad(direc + ang_rot)
u = spd * np.sin(direcR)
v = spd * np.cos(direcR)
return u, v
[docs]
def uv_to_spddir(u, v, coming_from=False):
"""Converts (u, v) to (spd, dir).
Args:
u (array): eastward wind component.
v (array): northward wind component.
coming_from (bool): True for output directions in coming-from convention,
False for going-to.
Returns:
mag (array): magnitudes.
direc (array): directions (degree).
"""
to_nautical = 270 if coming_from else 90
mag = np.sqrt(u**2 + v**2)
direc = np.rad2deg(np.arctan2(v, u))
direc = (to_nautical - direc) % 360
return mag, direc
[docs]
def interp_spec(inspec, infreq, indir, outfreq=None, outdir=None, method="linear"):
"""Interpolate onto new spectral basis.
Args:
inspec (2D ndarray): input spectrum E(infreq,indir) to be interpolated.
infreq (1D ndarray): frequencies of input spectrum.
indir (1D ndarray): directions of input spectrum.
outfreq (1D ndarray): frequencies of output interpolated spectrum, same as
infreq by default.
outdir (1D ndarray): directions of output interpolated spectrum, same as
infreq by default.
method: {'linear', 'nearest', 'cubic'}, method of interpolation to use with
griddata.
Returns:
outspec (2D ndarray): interpolated ouput spectrum E(outfreq,outdir).
Note:
If either outfreq or outdir is None or False this coordinate is not interpolated
Choose indir=None if spectrum is 1D.
TODO: Deprecate in favour of new regrid_spec function.
"""
ndim = inspec.ndim
if ndim > 2:
raise ValueError(f"interp_spec requires 2d spectra but inspec has {ndim} dims")
if outfreq is None:
outfreq = infreq
if outdir is None:
outdir = indir
if (np.array_equal(infreq, outfreq)) & (np.array_equal(indir, outdir)):
outspec = copy.deepcopy(inspec)
elif np.array_equal(indir, outdir):
if indir is not None:
outspec = np.zeros((len(outfreq), len(outdir)))
for idir in range(len(indir)):
outspec[:, idir] = np.interp(
outfreq, infreq, inspec[:, idir], left=0.0, right=0.0
)
else:
outspec = np.interp(
outfreq, infreq, np.array(inspec).ravel(), left=0.0, right=0.0
)
else:
outdir = D2R * (270 - np.expand_dims(outdir, 0))
outcos = np.dot(np.expand_dims(outfreq, -1), np.cos(outdir))
outsin = np.dot(np.expand_dims(outfreq, -1), np.sin(outdir))
indir = D2R * (270 - np.expand_dims(indir, 0))
incos = np.dot(np.expand_dims(infreq, -1), np.cos(indir)).flat
insin = np.dot(np.expand_dims(infreq, -1), np.sin(indir)).flat
outspec = griddata((incos, insin), inspec.flat, (outcos, outsin), method, 0.0)
return outspec
[docs]
def flatten_list(list_to_flat, list_to_append_into):
"""Flatten list of lists"""
for i in list_to_flat:
if isinstance(i, list):
flatten_list(i, list_to_append_into)
else:
list_to_append_into.append(i)
return list_to_append_into
[docs]
def scaled(spec, hs):
"""Scale spectra.
The energy density in each spectrum is scaled by a single factor so that
significant wave height calculated from the scaled spectrum corresponds to hs.
Args:
- spec (SpecArray, SpecDataset): Wavespectra object to be scaled.
- hs (DataArray, float): Hs values to use for scaling, if float it will scale
each spectrum in the dataset, if a DataArray it must have all non-spectral
coordinates as the spectra dataset.
Returns:
- spec (SpecArray, SpecDataset): Scaled wavespectra object.
"""
fac = (hs / spec.spec.hs()) ** 2
return fac * spec
[docs]
def check_same_coordinates(*args):
"""Check if DataArrays have same coordinates."""
for darr1, darr2 in itertools.combinations(args, 2):
if isinstance(darr1, xr.DataArray) and isinstance(darr2, xr.DataArray):
if not darr1.coords.to_dataset().equals(darr2.coords.to_dataset()):
raise ValueError(f"{darr1.name} and {darr2.name} must have same coords")
elif isinstance(darr1, xr.Dataset) or isinstance(darr2, xr.Dataset):
raise TypeError(
f"Only DataArrays should be compared, got {type(darr1)}, {type(darr2)}"
)
def dataset_from_transform(transformed, dset):
"""Merge transformed spectral variable back into its source dataset.
Args:
- transformed (DataArray, Dataset): Transformed spectral variable(s)
calculated from the spectral variable in dset.
- dset (Dataset): Source dataset the transform was calculated from.
Returns:
- dsout (Dataset): Dataset with the transformed spectral variable(s)
and the non-spectral variables from dset.
Note:
- Variables in dset that depend on the spectral dims (freq, dir) are
dropped since their coordinates may no longer be consistent with
those in the transformed spectra.
"""
if isinstance(transformed, xr.DataArray):
transformed = transformed.to_dataset(name=transformed.name or attrs.SPECNAME)
drop_dims = [d for d in (attrs.FREQNAME, attrs.DIRNAME) if d in dset.dims]
others = dset.drop_dims(drop_dims)
others = others.drop_vars(
[v for v in others.data_vars if v in transformed.data_vars]
)
dsout = xr.merge([transformed, others])
dsout.attrs = dset.attrs
return dsout
[docs]
def load_function(module_name, func_name, prefix=None):
"""Returns a function object from string.
Args:
- module_name (str): Name of module to import function from.
- func_name (str): Name of function to import.
- prefix (str): Used to filter available functions in exception.
"""
module = import_module(module_name)
try:
return getattr(module, func_name)
except AttributeError as exc:
members = getmembers(module, isfunction)
if prefix is not None:
# Check for functions starting with prefix
funcs = [mem[0] for mem in members if mem[0].startswith(prefix)]
else:
# Check for functions defined in module (exclude those imported in module)
funcs = [mem[0] for mem in members if mem[1].__module__ == module.__name__]
raise AttributeError(
f"'{func_name}' not available in {module.__name__}, available are: {funcs}"
) from exc
[docs]
def to_coords(array, name):
"""Create coordinates DataArray.
Args:
- array (list, 1darray): Coordinate values.
- name (str): Coordinate name.
Returns:
coords (DataArray): Coordinates DataArray.
"""
coords = xr.DataArray(array, coords={name: array}, dims=(name,))
set_spec_attributes(coords)
return coords
[docs]
def regrid_spec(dset, freq=None, dir=None, maintain_m0=True):
"""Regrid spectra onto new spectral basis.
Args:
- dset (Dataset, DataArray): Spectra to interpolate.
- freq (DataArray, 1darray): Frequencies of interpolated spectra (Hz).
- dir (DataArray, 1darray): Directions of interpolated spectra (deg).
- maintain_m0 (bool): Ensure variance is conserved in interpolated spectra.
Returns:
- dsi (Dataset, DataArray): Regridded spectra.
Note:
- All freq below lowest freq are interpolated assuming :math:`E_d(f=0)=0`.
- :math:`Ed(f)` is set to zero for new freq above the highest freq in dset.
- Only the 'linear' method is currently supported.
- Duplicate wrapped directions (e.g., 0 and 360) are removed when regridding
directions because indices must be unique to intepolate.
"""
dsout = dset.copy()
if isinstance(freq, (list, tuple)):
freq = np.array(freq)
if isinstance(dir, (list, tuple)):
dir = np.array(dir)
if dir is not None:
dsout = dsout.assign_coords({attrs.DIRNAME: dsout[attrs.DIRNAME] % 360})
# Remove any duplicate direction index
dsout = unique_indices(dsout, attrs.DIRNAME)
# Interpolate heading
dsout = dsout.sortby("dir")
to_concat = [dsout]
# Repeat the first and last direction with 360 deg offset when required
if dir.min() < dsout.dir.min():
highest = dsout.isel(dir=-1)
highest["dir"] = highest.dir - 360
to_concat = [highest, dsout]
if dir.max() > dsout.dir.max():
lowest = dsout.isel(dir=0)
lowest["dir"] = lowest.dir + 360
to_concat.append(lowest)
if len(to_concat) > 1:
dsout = xr.concat(to_concat, dim="dir", data_vars="all")
# Interpolate directions
dsout = dsout.interp(dir=dir, assume_sorted=True)
if freq is not None:
# If needed, add a new frequency at f=0 with zero energy
if freq.min() < dsout.freq.min():
fzero = 0 * dsout.isel(freq=0)
fzero["freq"] = 0
dsout = xr.concat([fzero, dsout], dim="freq", data_vars="all")
# Interpolate frequencies
dsout = dsout.interp(freq=freq, assume_sorted=False, kwargs={"fill_value": 0})
if maintain_m0:
scale = dset.spec.hs() ** 2 / dsout.spec.hs() ** 2
dsout = dsout * scale
if isinstance(dsout, xr.DataArray):
dsout.name = "efth"
set_spec_attributes(dsout)
return dsout
[docs]
def smooth_spec(dset, freq_window=3, dir_window=3):
"""Smooth spectra with a running average.
Args:
- dset (Dataset, DataArray): Spectra to smooth.
- freq_window (int): Rolling window size along `freq` dim.
- dir_window (int): Rolling window size along `dir` dim.
Returns:
- efth (DataArray): Smoothed spectra.
Note:
- The window size should be an odd value to ensure symmetry.
"""
for window in [freq_window, dir_window]:
if (window % 2) == 0:
raise ValueError(
f"Window size must be an odd value to ensure symmetry, got {window}"
)
dsout = dset.sortby(attrs.DIRNAME)
# Avoid problems when extending dirs with wrong data type
dsout[attrs.DIRNAME] = dset[attrs.DIRNAME].astype("float32")
# Extend circular directions to take care of edge effects
dirs = dsout[attrs.DIRNAME].values
dd = list(set(np.diff(dirs)))
if len(dd) == 1:
dd = float(dd[0])
is_circular = (abs(dirs.max() - dirs.min() + dd - 360)) < (0.1 * dd)
else:
is_circular = False
if is_circular:
# Extend directions on both sides
left = dsout.isel(**{attrs.DIRNAME: slice(-window, None)})
left = left.assign_coords({attrs.DIRNAME: left[attrs.DIRNAME] - 360})
right = dsout.isel(**{attrs.DIRNAME: slice(0, window)})
right = right.assign_coords({attrs.DIRNAME: right[attrs.DIRNAME] + 360})
dsout = xr.concat([left, dsout, right], dim=attrs.DIRNAME, data_vars="all")
# Smooth
dim = {attrs.FREQNAME: freq_window, attrs.DIRNAME: dir_window}
dsout = dsout.rolling(dim=dim, center=True).mean()
# Clip to original shape
if not dsout[attrs.DIRNAME].equals(dset[attrs.DIRNAME]):
dsout = dsout.sel(**{attrs.DIRNAME: dset[attrs.DIRNAME]})
dsout = dsout.chunk(**{attrs.DIRNAME: -1})
# Assign coords from original dataset
dsout = dsout.assign_coords(dset.coords)
# Fill missing values at boundaries using original spectra
dsout = xr.where(dsout.notnull(), dsout, dset)
set_spec_attributes(dsout)
return dsout
[docs]
def is_overlap(rect1, rect2):
"""Check if rectangles overlap.
Args:
- rect1 (list): Bounding box of the 1st rectangle [l1, b1, r1, t1].
- rect2 (list): Bounding box of the 2nd rectangle [l2, b2, r2, t2].
Returns:
- True if the two rectangles overlap, False otherwise.
"""
l1, b1, r1, t1 = rect1
l2, b2, r2, t2 = rect2
if (r1 <= l2) or (r2 <= l1):
return False
if (t1 <= b2) or (t2 <= b1):
return False
return True