Source code for wavespectra.partition.partition

"""Partitioning interface."""

import warnings
from itertools import combinations

import numpy as np
import xarray as xr

from wavespectra.partition import specpart
from wavespectra.core.utils import (
    set_spec_attributes,
    regrid_spec,
    smooth_spec,
    check_same_coordinates,
    dataset_from_transform,
    D2R,
    celerity,
    is_overlap,
    waveage,
)
from wavespectra.core.attributes import attrs
from wavespectra.core.options import OPTIONS, DATASET_TRANSFORMS
from wavespectra.core import npstats
from wavespectra.partition.hanson_and_phillips_2001 import combine_partitions_hp01
from wavespectra.partition.tracking import track_partitions, wave_systems


DEFAULTS = {
    "ihmax": 100,
    "angle_max": 30,
    "hs_min": 0.2,
    "kappa": 0.4,
    "zeta": 0.65,
    "noise_a": None,
    "noise_b": 0.0,
    "swells": 3,
    "smooth": False,
    "window": 3,
    "wscut": 0.3333,
    "agefac": 1.7,
}


[docs] class Partition: """Spectra partition methods. Note: - When defined from a Dataset and the `dataset_transforms` option is set with `wavespectra.set_options(dataset_transforms=True)`, the partitioning methods return a Dataset carrying the non-spectral variables from the source dataset, and the `wspd`, `wdir` and `dpt` arguments of the `hp01` and `track` methods default to the dataset variables with those names. This will become the default behaviour in wavespectra 5.0. When defined from a DataArray, the partitioned spectra are returned as a DataArray (except for `track` which always returns a Dataset). Methods: - ptm1: In PTM1, topographic partitions for which the percentage of wind-sea energy exceeds a defined fraction are aggregated and assigned to the wind-sea component (e.g., the first partition). The remaining partitions are assigned as swell components in order of decreasing wave height. - ptm2: PTM2 works in a similar way to PTM1 by identifying a primary wind sea (assigned as partition 0) and one or more swell components. In this method however all the swell partitions are checked for the influence of wind-sea with energy within spectral bins within the wind-sea range (as defined by a wave age criterion) removed and combined into a secondary wind-sea partition (assigned as partition 1). The remaining swell partitions are then assigned in order of decreasing wave height from partition 2 onwards. This implies PTM2 has an extra partition compared to PTM1. - ptm3: PTM3 does not classify the topographic partitions into wind-sea or swell - it simply orders them by wave height. This approach is useful for producing data for spectral reconstruction applications using a limited number of partitions, where the classification of the partition as wind-sea or swell is less important than the proportion of overall spectral energy each partition represents. - ptm4: PTM4 uses the wave age criterion derived from the local wind speed to split the spectrum in to a wind-sea and single swell partition. In this case waves with a celerity greater than the directional component of the local wind speed are considered to be freely propogating swell (i.e. unforced by the wind). This is similar to the method commonly used to generate wind-sea and swell from the WAM model. - ptm5: PTM5 splits spectra into wind sea and swell based on a user defined static cutoff. - hp01: HP01 partitions the spectra and merges wind-sea components as in the PTM1 method, then it combines adjacent swells belonging to the same wave system following the criteria outlined in Hanson and Phillips (2001) and Hanson et al. (2009). Useful for noisy measured spectra which the watershed algorithm tends to over-segment, and to prescribe an exact number of output partitions. - bbox: BBOX partitions the spectra based on user-defined bounding boxes in frequency-direction space. - track: Partition spectra using any of the ptm1, ptm2, ptm3 or hp01 methods and track the partitions using the evolution of peak frequency and peak direction in time. References: - Hanson and Phillips (2001), Automated Analysis of Ocean Surface Directional Wave Spectra, Journal of Atmospheric and Oceanic Technology, 18, 277-293. - Hanson et al. (2009), Pacific hindcast performance of three numerical wave models, JTECH 26.8, 1614-1633. - Portilla et al. (2009), Spectral Partitioning and Identification of Wind Sea and Swell, Journal of Atmospheric and Oceanic Technology, 107-122. - Tracy et al. (2007), Wind Sea and Swell Delineation for Numerical Wave Modeling, JCOMM Tech. Rep. 41, WMO/TDNo, 1442, Paper P12. - Vincent et al. (1991) Watersheds in digital spaces: an efficient algorithm based on immersion simulations, IEEE Transactions on Pattern Analysis and Machine Intelligence, Vol. 13, No. 6, June 1991, p. 583-598. - WW3 wave model documentation, https://github.com/NOAA-EMC/WW3. """
[docs] def __init__(self, dset): if isinstance(dset, xr.DataArray): self.dset = dset self._dset = None elif isinstance(dset, xr.Dataset): self.dset = dset[attrs.SPECNAME] self._dset = dset else: raise ValueError("dset needs to be either SpecArray or SpecDataset")
def _resolve(self, value, name): """Fall back on dataset variable `name` when `value` is not prescribed.""" if value is None and self._dset is not None: value = self._dset.get(name) return value def _wind_and_depth(self, wspd, wdir, dpt, required=False): """Resolve wind and depth args, falling back on dataset variables.""" wspd = self._resolve(wspd, attrs.WSPDNAME) wdir = self._resolve(wdir, attrs.WDIRNAME) dpt = self._resolve(dpt, attrs.DEPNAME) if required and (wspd is None or wdir is None or dpt is None): raise ValueError( "wspd, wdir and dpt are required, either as arguments or as " "variables in the underlying dataset when partitioning from a " "Dataset" ) return wspd, wdir, dpt def _wrap_output(self, dsout): """Return Dataset with non-spectral variables if defined from a Dataset. The Dataset output only takes place if the `dataset_transforms` option is set, otherwise a FutureWarning is emitted and the bare partitioned spectra are returned. """ if self._dset is None: return dsout if OPTIONS[DATASET_TRANSFORMS]: return dataset_from_transform(dsout, self._dset) warnings.warn( "Partitioning from the Dataset accessor will return a Dataset " "preserving the non-spectral variables in wavespectra 5.0. Opt " "in to the future behaviour with " "`wavespectra.set_options(dataset_transforms=True)`, or " "partition from the DataArray accessor, e.g. " "`dset.efth.spec.partition`, to retain the current behaviour " "and silence this warning.", FutureWarning, stacklevel=3, ) return dsout def _set_metadata(self, dsout): """Define metadata attributes in output.""" dsout.name = "efth" dsout["part"] = np.arange(dsout.part.size) set_spec_attributes(dsout) dsout.attrs = attrs.ATTRS[attrs.SPECNAME] return dsout def _detect_nparts(self, func, *args, input_core_dims, nparts_fixed): """Number of partition slots required to hold all detected partitions. The size of the output `part` dimension must be known before the partitioning runs. When the number of partitions is not prescribed, this method runs the partitioning function over all spectra without a prescribed number and returns the largest number of partitions detected, excluding the `nparts_fixed` leading partitions (e.g. the wind sea) the function always outputs. Args: - func (callable): Partitioning function to run, e.g. `np_ptm1`. - args: Positional arguments of `func` with the number of partitions argument set to None. - input_core_dims (list): Core dimensions of each argument as passed to xr.apply_ufunc. - nparts_fixed (int): Number of fixed leading partitions in the output of `func` to exclude from the count. Returns: - nparts (int): Largest number of non-fixed partitions detected from all spectra, at least 1. """ def nparts(*args): return np.int64(func(*args).shape[0] - nparts_fixed) counts = xr.apply_ufunc( nparts, *args, input_core_dims=input_core_dims, vectorize=True, dask="parallelized", output_dtypes=["int64"], dask_gufunc_kwargs={"allow_rechunk": True}, ) return max(int(counts.max()), 1)
[docs] def ptm1( self, wspd=None, wdir=None, dpt=None, agefac=DEFAULTS["agefac"], wscut=DEFAULTS["wscut"], swells=DEFAULTS["swells"], smooth=DEFAULTS["smooth"], freq_window=DEFAULTS["window"], dir_window=DEFAULTS["window"], ihmax=DEFAULTS["ihmax"], ): """PTM1 watershed partitioning. In PTM1, topographic partitions for which the percentage of wind-sea energy exceeds a defined fraction are aggregated and assigned to the wind-sea component (e.g., the first partition). The remaining partitions are assigned as swell components in order of decreasing wave height. Args: - wspd (xr.DataArray): Wind speed DataArray, taken from the `wspd` variable in the underlying dataset if not provided. - wdir (xr.DataArray): Wind direction DataArray, taken from the `wdir` variable in the underlying dataset if not provided. - dpt (xr.DataArray): Depth DataArray, taken from the `dpt` variable in the underlying dataset if not provided. - swells (int): Number of swell partitions to compute. If None, the number required to hold all swells detected from all spectra is used, which doubles the compute time and triggers an eager computation on dask datasets. - agefac (float): Age factor. - wscut (float): Wind sea fraction cutoff. - smooth (bool): Compute watershed boundaries from smoothed spectra as described in Portilla et al., 2009. - freq_window (int): Size of running window for smoothing spectra. - dir_window (int): Size of running window for smoothing spectra. - ihmax (int): Number of discrete spectral levels in WW3 Watershed code. Returns: - dspart (xr.DataArray, xr.Dataset): Partitioned spectra with extra `part` dimension where the 0th index are the wind sea and remaining indices are the swells sorted by descending order of Hs. References: - Hanson and Phillips (2001). - Hanson et al. (2009). - Portilla et al. (2009). - Tracy et al. (2007). - Vincent et al. (1991). - WW3 documentation (https://github.com/NOAA-EMC/WW3). """ # Sort out inputs wspd, wdir, dpt = self._wind_and_depth(wspd, wdir, dpt, required=True) check_same_coordinates(wspd, wdir, dpt) if smooth: dset_smooth = smooth_spec(self.dset, freq_window, dir_window) else: dset_smooth = self.dset args = [ self.dset, dset_smooth, self.dset.freq, self.dset.dir, wspd, wdir, dpt, agefac, wscut, ] input_core_dims = [ ["freq", "dir"], ["freq", "dir"], ["freq"], ["dir"], [], [], [], [], [], [], [], ] # Detect the number of swells required if not prescribed if swells is None: swells = self._detect_nparts( np_ptm1, *args, None, ihmax, input_core_dims=input_core_dims, nparts_fixed=1, ) # Partitioning full spectra dsout = xr.apply_ufunc( np_ptm1, *args, swells, ihmax, input_core_dims=input_core_dims, output_core_dims=[["part", "freq", "dir"]], vectorize=True, dask="parallelized", output_dtypes=["float32"], dask_gufunc_kwargs={ "allow_rechunk": True, "output_sizes": {"part": swells + 1}, }, ) # Finalise output dsout = self._set_metadata(dsout) parts_description = { "part0": "wind sea", "part1-n": "swells in descending order of hs", } dsout.attrs.update(parts_description) return self._wrap_output(dsout.transpose("part", ...))
[docs] def ptm2( self, wspd=None, wdir=None, dpt=None, agefac=DEFAULTS["agefac"], wscut=DEFAULTS["wscut"], swells=DEFAULTS["swells"], smooth=DEFAULTS["smooth"], freq_window=DEFAULTS["window"], dir_window=DEFAULTS["window"], ihmax=DEFAULTS["ihmax"], ): """PTM2 watershed partitioning with secondary wind-sea. PTM2 works in a similar way to PTM1 by identifying a primary wind sea (assigned to partition 0) and one or more swell components. In this method however all the swell partitions are checked for the influence of wind-sea with energy within spectral bins within the wind-sea range (as defined by a wave age criterion) removed and combined into a secondary wind-sea partition (assigned to partition 1). The remaining swell partitions are then assigned in order of decreasing wave height from partition 2 onwards. This implies PTM2 has an extra partition compared to PTM1. Args: - wspd (xr.DataArray): Wind speed DataArray, taken from the `wspd` variable in the underlying dataset if not provided. - wdir (xr.DataArray): Wind direction DataArray, taken from the `wdir` variable in the underlying dataset if not provided. - dpt (xr.DataArray): Depth DataArray, taken from the `dpt` variable in the underlying dataset if not provided. - swells (int): Number of swell partitions to compute. If None, the number required to hold all swells detected from all spectra is used, which doubles the compute time and triggers an eager computation on dask datasets. - agefac (float): Age factor. - wscut (float): Wind sea fraction cutoff. - smooth (bool): Compute watershed boundaries from smoothed spectra as described in Portilla et al., 2009. - freq_window (int): Size of running window along `freq` for smoothing spectra. - dir_window (int): Size of running window along `dir` for smoothing spectra. - ihmax (int): Number of discrete spectral levels in WW3 Watershed code. Returns: - dspart (xr.DataArray, xr.Dataset): Partitioned spectra with extra `part` dimension where the 0th and 1st indices are the primary and secondary wind seas and remaining indices are the swells sorted by descending order of Hs. References: - Hanson and Phillips (2001). - Hanson et al. (2009). - Portilla et al. (2009). - Tracy et al. (2007). - Vincent et al. (1991). - WW3 documentation (https://github.com/NOAA-EMC/WW3). """ # Sort out inputs wspd, wdir, dpt = self._wind_and_depth(wspd, wdir, dpt, required=True) check_same_coordinates(wspd, wdir, dpt) if smooth: dset_smooth = smooth_spec(self.dset, freq_window, dir_window) else: dset_smooth = self.dset args = [ self.dset, dset_smooth, self.dset.freq, self.dset.dir, wspd, wdir, dpt, agefac, wscut, ] input_core_dims = [ ["freq", "dir"], ["freq", "dir"], ["freq"], ["dir"], [], [], [], [], [], [], [], ] # Detect the number of swells required if not prescribed if swells is None: swells = self._detect_nparts( np_ptm2, *args, None, ihmax, input_core_dims=input_core_dims, nparts_fixed=2, ) # Partitioning full spectra dsout = xr.apply_ufunc( np_ptm2, *args, swells, ihmax, input_core_dims=input_core_dims, output_core_dims=[["part", "freq", "dir"]], vectorize=True, dask="parallelized", output_dtypes=["float32"], dask_gufunc_kwargs={ "allow_rechunk": True, "output_sizes": {"part": swells + 2}, }, ) # Finalise output dsout = self._set_metadata(dsout) parts_description = { "part0": "primary wind sea", "part1": "secondary wind sea", "part2-n": "swells in descending order of hs", } dsout.attrs.update(parts_description) return self._wrap_output(dsout.transpose("part", ...))
[docs] def ptm3( self, parts=DEFAULTS["swells"], smooth=DEFAULTS["smooth"], freq_window=DEFAULTS["window"], dir_window=DEFAULTS["window"], ihmax=DEFAULTS["ihmax"], ): """PTM3 watershed partitioning with no wind-sea or swell classification. PTM3 does not classify the topographic partitions into wind-sea or swell - it simply orders them by wave height. This approach is useful for producing data for spectral reconstruction applications using a limited number of partitions, where the classification of the partition as wind-sea or swell is less important than the proportion of overall spectral energy each partition represents. In addition, this method does not require wind and water depth information and can be used with any spectral dataset. Args: - parts (int): Number of partitions to keep. If None, the number required to hold all partitions detected from all spectra is used, which doubles the compute time and triggers an eager computation on dask datasets. - smooth (bool): Compute watershed boundaries from smoothed spectra as described in Portilla et al., 2009. - freq_window (int): Size of running window along `freq` for smoothing spectra. - dir_window (int): Size of running window along `dir` for smoothing spectra. - ihmax (int): Number of discrete spectral levels in WW3 Watershed code. Returns: - dspart (xr.DataArray, xr.Dataset): Partitioned spectra with extra `part` dimension defining watershed partitions sorted by descending order of Hs. References: - Portilla et al. (2009). - Tracy et al. (2007). - Vincent et al. (1991). - WW3 documentation (https://github.com/NOAA-EMC/WW3). """ # Sort out inputs if smooth: dset_smooth = smooth_spec(self.dset, freq_window, dir_window) else: dset_smooth = self.dset args = [self.dset, dset_smooth, self.dset.freq, self.dset.dir] input_core_dims = [ ["freq", "dir"], ["freq", "dir"], ["freq"], ["dir"], [], [], ] # Detect the number of partitions required if not prescribed if parts is None: parts = self._detect_nparts( np_ptm3, *args, None, ihmax, input_core_dims=input_core_dims, nparts_fixed=0, ) # Partitioning full spectra dsout = xr.apply_ufunc( np_ptm3, *args, parts, ihmax, input_core_dims=input_core_dims, output_core_dims=[["part", "freq", "dir"]], vectorize=True, dask="parallelized", output_dtypes=["float32"], dask_gufunc_kwargs={"allow_rechunk": True, "output_sizes": {"part": parts}}, ) # Finalise output dsout = self._set_metadata(dsout) dsout.attrs.update({"part0-n": "partitions in descending order of hs"}) return self._wrap_output(dsout.transpose("part", ...))
[docs] def ptm4(self, wspd=None, wdir=None, dpt=None, agefac=DEFAULTS["agefac"]): """PTM4 WAM partitioning of sea and swell based on wave age criterion.. PTM4 uses the wave age criterion derived from the local wind speed to split the spectrum into a wind-sea and single swell partition. In this case waves with a celerity greater than the directional component of the local wind speed are considered to be freely propogating swell (i.e. unforced by the wind). This is similar to the method commonly used to generate wind-sea and swell from the WAM model. Args: - wspd (xr.DataArray): Wind speed DataArray, taken from the `wspd` variable in the underlying dataset if not provided. - wdir (xr.DataArray): Wind direction DataArray, taken from the `wdir` variable in the underlying dataset if not provided. - dpt (xr.DataArray): Depth DataArray, taken from the `dpt` variable in the underlying dataset if not provided. - agefac (float): Age factor. Returns: - dspart (xr.DataArray, xr.Dataset): Partitioned spectra with extra `part` dimension where the 0th index is the wind sea and the 1st index is the swell. References: - WW3 documentation (https://github.com/NOAA-EMC/WW3). """ wspd, wdir, dpt = self._wind_and_depth(wspd, wdir, dpt, required=True) dsout = self.dset.sortby("dir").sortby("freq") # Masking wind sea and swell regions windseamask = waveage(dsout.freq, dsout.dir, wspd, wdir, dpt, agefac) sea = dsout.where(windseamask) swell = dsout.where(~windseamask) # Combining into part index dsout = xr.concat([sea, swell], dim="part") # Finalise output dsout = self._set_metadata(dsout) dsout.attrs.update({"part0": "wind sea", "part1": "swell"}) return self._wrap_output(dsout.fillna(0.0))
[docs] def ptm5(self, fcut, interpolate=True): """PTM5 SWAN partitioning of sea and swell based on user-defined threshold. PTM5 splits spectra into wind sea and swell based on a user defined static cutoff. This method differs from :meth:`~wavespectra.specarray.SpecArray.split` in that here the output partitioned spectra dataset has an extra `part` dimension and the sea and swell partitions have zero-values outside the defined frequency ranges. Conversely, :meth:`~wavespectra.specarray.SpecArray.split` returns a single partition with frequencies truncated to the defined ranges. Notice there could be slight differences when integrating the partitions generated by these two methods since in PTM5 there will be an "area" at one of the frequency adges adjacent to the zero-values. Args: - fcut (float): Frequency cutoff (Hz). - interpolate (bool): Interpolate spectra at fcut if it is not an exact frequency in the dset. Returns: - dspart (xr.DataArray, xr.Dataset): Partitioned spectra with extra `part` dimension where the 0th index is the wind sea and the 1st index is the swell. Note: - Spectra are interpolated at `fcut` if this frequency is not in `dset` and `interpolate` is set to True which implies the frequency coordinates will be different between the input and the output. References: - WW3 documentation (https://github.com/NOAA-EMC/WW3). """ dsout = self.dset.sortby("dir").sortby("freq") # Include cuttof if not in coordinates if interpolate: freqs = sorted(set(self.dset.freq.values).union([fcut])) if len(freqs) > self.dset.freq.size: dsout = regrid_spec(self.dset, freq=freqs) # Zero data outside the domain of each partition hf = dsout.where((dsout.freq >= fcut)) lf = dsout.where((dsout.freq <= fcut)) # Combining into part index dsout = xr.concat([hf, lf], dim="part") # Finalise output dsout = self._set_metadata(dsout) dsout.attrs.update({"part0": "sea", "part1": "swell"}) return self._wrap_output(dsout.fillna(0.0))
[docs] def hp01( self, wspd=None, wdir=None, dpt=None, agefac=DEFAULTS["agefac"], wscut=DEFAULTS["wscut"], swells=DEFAULTS["swells"], smooth=DEFAULTS["smooth"], freq_window=DEFAULTS["window"], dir_window=DEFAULTS["window"], kappa=DEFAULTS["kappa"], zeta=DEFAULTS["zeta"], angle_max=DEFAULTS["angle_max"], hs_min=DEFAULTS["hs_min"], noise_a=DEFAULTS["noise_a"], noise_b=DEFAULTS["noise_b"], ihmax=DEFAULTS["ihmax"], combine_extra_swells=True, ): """Hanson and Phillips 2001 spectra partitioning and swell merging. HP01 partitions the spectra and merges wind-sea components as in the PTM1 method, then it combines adjacent swells following the criteria outlined in Hanson and Phillips (2001) and Hanson et al. (2009). This method can be useful when partitioning measured wave spectra which are typically noisy and may contain small, non-physical partitions. Args: - wspd (xr.DataArray): Wind speed DataArray, taken from the `wspd` variable in the underlying dataset if not provided. - wdir (xr.DataArray): Wind direction DataArray, taken from the `wdir` variable in the underlying dataset if not provided. - dpt (xr.DataArray): Depth DataArray, taken from the `dpt` variable in the underlying dataset if not provided. - swells (int): Number of swell partitions to compute. If None, the number required to hold all combined swells from all spectra is detected in a first pass, which doubles the compute time and triggers an eager computation on dask datasets. - agefac (float): Age factor. - wscut (float): Wind sea fraction cutoff. - smooth (bool): Compute watershed boundaries from smoothed spectra as described in Portilla et al., 2009. - freq_window (int): Size of running window along `freq` for smoothing spectra. - dir_window (int): Size of running window along `dir` for smoothing spectra. - kappa (float): Spread factor in the peak separation criterion of Hanson and Phillips (2001)'s eq 9, larger values combine more partitions. - zeta (float): Peak minimum factor, the fraction of the smaller peak density that the saddle point between two partitions must exceed for the partitions to be combined, smaller values combine more partitions. - angle_max (float): Optional maximum angle (deg) between partition mean directions for combining partitions, disabled if None. - hs_min (float): Minimum Hs of swell partitions, smaller ones are always combined with their most connected neighbours regardless of other criteria being satisfied. The default of 0.2 m follows Hanson et al. (2009) and is appropriate for open-ocean data, consider reducing it in low-energy environments such as sheltered sites so genuine small wave systems are not combined. - noise_a (float): Factor `A` in Hanson and Phillips (2001)'s noise threshold eq 10, e <= A / (fp^4 + B), partitions with total energy below this threshold are treated as noise and merged onto their most connected neighbours. Disabled if None. - noise_b (float): Factor `B` in Hanson and Phillips (2001)'s eq 10. - ihmax (int): Number of discrete spectral levels in WW3 Watershed code. - combine_extra_swells (bool): If True and more swell partitions remain after combining than the number requested in the `swells` argument, merge each extra swell with its closest neighbour until the requested number is achieved. If False, the smallest extra swells are excluded from the output. Returns: - dspart (xr.DataArray, xr.Dataset): Partitioned spectra with extra `part` dimension where the 0th index are the wind sea and remaining indices are the swells sorted by descending order of Hs. Note: - If wspd, wdir or dpt are not provided no wind sea classification is done and partition 0 is null. - Spectral variance is conserved unless `combine_extra_swells` is False and there are more combined swells than requested. References: - Hanson and Phillips (2001). - Hanson et al. (2009). - Portilla et al. (2009). - Tracy et al. (2007). - Vincent et al. (1991). - WW3 documentation (https://github.com/NOAA-EMC/WW3). """ # Falling back on dataset wind and depth would change the wind sea # classification of existing code hence it is opt-in until v5.0 if OPTIONS[DATASET_TRANSFORMS]: wspd, wdir, dpt = self._wind_and_depth(wspd, wdir, dpt) check_same_coordinates(wspd, wdir, dpt) # Smooth spectra for defining watershed boundaries if smooth: dset_smooth = smooth_spec(self.dset, freq_window, dir_window) else: dset_smooth = self.dset # Wind sea mask if wspd is None or wdir is None or dpt is None: warnings.warn( "wspd, wdir and dpt were not all provided, no wind sea " "classification will be done and partition 0 will be null" ) windseamask = xr.zeros_like(self.dset).astype(bool) else: windseamask = waveage( self.dset.freq, self.dset.dir, wspd, wdir, dpt, agefac, ) args = [self.dset, dset_smooth, windseamask, self.dset.freq, self.dset.dir] args_after_swells = [ kappa, zeta, angle_max, hs_min, noise_a, noise_b, ihmax, combine_extra_swells, ] input_core_dims = [ ["freq", "dir"], ["freq", "dir"], ["freq", "dir"], ["freq"], ["dir"], [], [], [], [], [], [], [], [], [], [], ] # Detect the number of swells required if not prescribed if swells is None: swells = self._detect_nparts( np_hp01, *args, wscut, None, *args_after_swells, input_core_dims=input_core_dims, nparts_fixed=1, ) # Partitioning full spectra dsout = xr.apply_ufunc( np_hp01, *args, wscut, swells, *args_after_swells, input_core_dims=input_core_dims, output_core_dims=[["part", "freq", "dir"]], vectorize=True, dask="parallelized", output_dtypes=["float32"], dask_gufunc_kwargs={ "allow_rechunk": True, "output_sizes": {"part": swells + 1}, }, ) # Finalise output dsout = self._set_metadata(dsout) parts_description = { "part0": "wind sea", "part1-n": "merged swells in descending order of hs", } dsout.attrs.update(parts_description) return self._wrap_output(dsout.transpose("part", ...))
[docs] def bbox(self, bboxes): """Partition based on user-defined bounding boxes in frequency-direction space. BBOX partitions the spectra based on user-defined bounding boxes in frequency-direction space. Args: - bboxes (list(dict)): List of dictionaries with keys `fmin`, `fmax`, `dmin` and `dmax` specifying the boundaries of each bounding box. Returns: - dspart (xr.DataArray, xr.Dataset): Partitioned spectra with extra `part` dimension with indices ordered as in the list of dictionaries. Note: - Non-specified bounds in each bbox dict are defined from the bounds of the freq / dir bounds in the spectrum, e.g., `fmin=min(freq)`. - Bounding boxes must not overlap. - Bounding boxes cannot wrap across the 360-degree discontinuity, i.e., `dmin` must be smaller than `dmax`. - Last part index is defined by spectral bins not covered by any bboxes. """ ds = self.dset.sortby("dir").sortby("freq") # Chec inputs and define rectangles rectangles = [] for bbox in bboxes: fmin = bbox.get("fmin", float(ds.freq.min())) or float(ds.freq.min()) fmax = bbox.get("fmax", float(ds.freq.max())) or float(ds.freq.max()) dmin = bbox.get("dmin", float(ds.dir.min())) or float(ds.dir.min()) dmax = bbox.get("dmax", float(ds.dir.max())) or float(ds.dir.max()) if fmin >= fmax: raise ValueError(f"fmin {fmin} Hz >= fmax {fmax} Hz") rectangles.append([fmin, dmin, fmax, dmax]) # Ensure there is no overlapping among bboxes for rect1, rect2 in combinations(rectangles, 2): if is_overlap(rect1, rect2): l1, b1, r1, t1 = rect1 l2, b2, r2, t2 = rect2 raise ValueError( f"bboxes [fmin={l1:g}, dmin={b1:g}, fmax={r1:g}, dmax={t1:g}] and " f"[fmin={l2:g}, dmin={b2:g}, fmax={r2:g}, dmax={t2:g}] overlap" ) # Define partitions partitions = [] masks = False for rect in rectangles: fmin, dmin, fmax, dmax = rect mask = ( (ds.freq >= fmin) & (ds.freq <= fmax) & (ds.dir >= dmin) & (ds.dir <= dmax) ) partitions.append(ds.where(mask)) masks = masks | mask # Last partition partitions.append(ds.where(~masks)) # Combining into part index dsout = xr.concat(partitions, dim="part") # Finalise output dsout = self._set_metadata(dsout) for ind, rect in enumerate(rectangles): fmin, dmin, fmax, dmax = rect dsout.attrs.update( {f"part{ind}": f"fmin={fmin}, fmax={fmax}, dmin={dmin}, dmax={dmax}"} ) dsout.attrs.update({f"part{ind + 1}": "complement"}) return self._wrap_output(dsout.fillna(0.0))
[docs] def track( self, wspd=None, wdir=None, dpt=None, method="ptm1", ddpm_sea_max=30, ddpm_swell_max=20, dfp_sea_scaling=1, dfp_swell_source_distance=1e6, systems=False, min_duration=1, **kwargs, ): """Partition the spectra and track the wave systems over time. Partition spectra using any of the watershed partitioning methods and track the partitions using the evolution of peak frequency and peak direction. Partitions are matched with the closest partition in the frequency-direction space of the previous time step for which the difference in direction is less than ddpm_max and the difference in peak frequency is less than dfp_max. ddpm_max differs for sea and swell partitions and is set manually. dfp_max also differs for sea and swell partitions. In the case of sea partitions it is a function of wind speed and is set to the rate of change of the wind-sea peak wave frequency estimated from fetch-limited relationships (Ewans & Kibblewhite, 1986). In the case of swell partitions it is set to the rate of change of the swell peak wave frequency based on the swell dispersion relationship derived by Snodgrass et al (1966) assuming the distance to the source is 1e6 m. Args: - wspd (xr.DataArray): Wind speed DataArray, required by the ptm1 and ptm2 methods and optional for hp01. Taken from the `wspd` variable in the underlying dataset if not provided. - wdir (xr.DataArray): Wind direction DataArray, as above. - dpt (xr.DataArray): Depth DataArray, as above. - method (str): Partitioning method to track partitions from, one of "ptm1", "ptm2", "ptm3" or "hp01". The ptm4, ptm5 and bbox methods define partitions as fixed spectral regions whose identity is already continuous in time, so there is nothing to track. - ddpm_sea_max (float): Maximum peak direction difference for wind sea partitions. Default is 30 degrees. - ddpm_swell_max (float): Maximum peak direction difference for swell partitions. Default is 20 degrees. - dfp_sea_scaling (float): Scaling factor for maximum peak frequency difference for wind sea partitions. Default is 1. - dfp_swell_source_distance (float): Distance to source for swell peak frequency difference. Default is 1e6 m. - systems (bool): If True, remap the output onto a `wave_system` dimension in place of `part` so that each tracked wave system occupies its own index and carries values along the entire time axis, null where the system does not exist. If False (default), return the partitioned spectra with the `track_id` variable identifying the wave system of each partition at each time step. - min_duration (int): Minimum number of time steps a wave system must span to be included in the `systems=True` output. The default of 1 keeps all tracked systems. - kwargs: Further arguments passed to the partitioning method, e.g. `swells`, `agefac`, `wscut`, `smooth` or the hp01 combining parameters. Returns: - dspart (xr.DataArray, xr.Dataset): Partitioned spectra with extra `part` dimension ordered according to the partitioning method, plus the variable `track_id` identifying the wave system each partition belongs to at each time step and the variable `ntracks` with the number of wave systems tracked. If `systems` is True the spectra are instead organised along a `wave_system` dimension replacing `part`, with the `track_id` variable mapping each wave system back to its id in the `systems=False` output. Note: - Wind sea partitions (partition 0 in ptm1 and hp01, partitions 0 and 1 in ptm2) are matched with wind-sea thresholds and the remaining partitions with swell thresholds. The ptm3 partitions are not classified and are all matched with swell thresholds, which makes wind inputs optional for that method. - The time step is evaluated for each pair of consecutive spectra so records with gaps or irregular sampling use matching thresholds consistent with the actual time elapsed. - Wave systems are tracked independently at each site so with `systems=True` the same `wave_system` index at different sites corresponds to different, physically unrelated systems, and the `wave_system` dimension is sized by the site with the most systems with null padding entries at the other sites. - The spectra remapping with `systems=True` is lazy on dask datasets but the track ids must be computed upfront to define the size of the output. """ # Falling back on dataset wind and depth would change the wind sea # classification of existing hp01 code hence it is opt-in until v5.0 if OPTIONS[DATASET_TRANSFORMS]: wspd, wdir, dpt = self._wind_and_depth(wspd, wdir, dpt) wind_kwargs = {"wspd": wspd, "wdir": wdir, "dpt": dpt} if method in ("ptm1", "ptm2"): check_same_coordinates(wspd, wdir, dpt) nsea = 1 if method == "ptm1" else 2 elif method == "hp01": # hp01 skips the wind sea classification without wind inputs if wspd is None or wdir is None or dpt is None: wind_kwargs = {} nsea = 0 else: nsea = 1 elif method == "ptm3": # ptm3 partitions are not classified, tracking does not need wind wind_kwargs = {} nsea = 0 else: raise ValueError( f"Cannot track partitions from method '{method}', " "available methods are 'ptm1', 'ptm2', 'ptm3' and 'hp01'" ) # Do the partitioning, on the spectral variable only so the tracking # is not affected by other variables in the underlying dataset dspart = getattr(Partition(self.dset), method)(**wind_kwargs, **kwargs) # Calculate peak frequency and peak direction stats = dspart.spec.stats(["fp", "dpm"]) # Track partitions tracks = track_partitions( stats, wspd=wspd if nsea > 0 else None, ddpm_sea_max=ddpm_sea_max, ddpm_swell_max=ddpm_swell_max, dfp_sea_scaling=dfp_sea_scaling, dfp_swell_source_distance=dfp_swell_source_distance, nsea=nsea, ) # Add track ids to partition data dsout = xr.merge([dspart, tracks]) # Remap onto the wave_system dimension if systems: dsout = wave_systems(dsout, min_duration=min_duration) return self._wrap_output(dsout)
[docs] def np_ptm1( spectrum, spectrum_smooth, freq, dir, wspd, wdir, dpt, agefac=DEFAULTS["agefac"], wscut=DEFAULTS["wscut"], swells=DEFAULTS["swells"], ihmax=DEFAULTS["ihmax"], ): """PTM1 spectra partitioning on numpy arrays. Args: - spectrum (2darray): Wave spectrum array with shape (nf, nd). - spectrum_smooth (2darray): Smoothed wave spectrum array with shape (nf, nd). - freq (1darray): Wave frequency array with shape (nf). - dir (1darray): Wave direction array with shape (nd). - wspd (float): Wind speed. - wdir (float): Wind direction. - dpt (float): Water depth. - agefac (float): Age factor. - wscut (float): Wind sea fraction cutoff. - swells (int): Number of swell partitions to compute, all detected if None. - ihmax (int): Number of discrete spectral levels in WW3 Watershed code. Returns: - specpart (3darray): Wave spectrum partitions sorted in decreasing order of Hs with shape (np, nf, nd). Note: - The smooth spectrum `spectrum_smooth` is used to define the watershed boundaries which are applied to the original spectrum. """ # Use smooth spectrum to define morphological boundaries watershed_map = specpart.partition(spectrum_smooth, ihmax) nparts = watershed_map.max() # Wind sea mask up = np.tile(agefac * wspd * np.cos(D2R * (dir - wdir)), (freq.size, 1)) windseamask = up > np.tile(celerity(freq, dpt)[:, np.newaxis], (1, dir.size)) # Assign partitioned arrays from raw spectrum and morphological boundaries wsea_partition = np.zeros_like(spectrum) swell_partitions = [np.zeros_like(spectrum) for n in range(nparts)] for ipart in range(nparts): part = np.where(watershed_map == ipart + 1, spectrum, 0.0) # start at 1 wsfrac = part[windseamask].sum() / part.sum() if wsfrac > wscut: wsea_partition += part else: swell_partitions[ipart] += part # Sort swells by Hs isort = np.argsort([-npstats.hs(swell, freq, dir) for swell in swell_partitions]) swell_partitions = list(np.array(swell_partitions)[isort]) # Dealing with the number of swells if swells is None: # Exclude null swell partitions if the number of output swells is undefined swell_partitions = [swell for swell in swell_partitions if swell.sum() > 0] else: if nparts > swells: # Discard extra partitions swell_partitions = swell_partitions[:swells] elif nparts < swells: # Extend partitions list with null spectra n = swells - len(swell_partitions) for i in range(n): swell_partitions.append(np.zeros_like(spectrum)) return np.array([wsea_partition] + swell_partitions)
[docs] def np_ptm2( spectrum, spectrum_smooth, freq, dir, wspd, wdir, dpt, agefac=DEFAULTS["agefac"], wscut=DEFAULTS["wscut"], swells=DEFAULTS["swells"], ihmax=DEFAULTS["ihmax"], ): """PTM2 spectra partitioning on numpy arrays. Args: - spectrum (2darray): Wave spectrum array with shape (nf, nd). - spectrum_smooth (2darray): Smoothed wave spectrum array with shape (nf, nd). - freq (1darray): Wave frequency array with shape (nf). - dir (1darray): Wave direction array with shape (nd). - wspd (float): Wind speed. - wdir (float): Wind direction. - dpt (float): Water depth. - agefac (float): Age factor. - wscut (float): Wind sea fraction cutoff. - swells (int): Number of swell partitions to compute, all detected if None. - ihmax (int): Number of discrete spectral levels in WW3 Watershed code. Returns: - specpart (3darray): Wave spectrum partitions sorted in decreasing order of Hs with shape (np, nf, nd) with np indices 0 and 1 reserved for primary and secondary wind sea partitions and remaining ones for ordered swells. Note: - The smooth spectrum `spectrum_smooth` is used to define the watershed boundaries which are applied to the original spectrum. - The option in WW3 to leave secondary wind seas as separate partitions is not available as it makes it harder to distinguish them from swells in the output. """ # Use smooth spectrum to define morphological boundaries watershed_map = specpart.partition(spectrum_smooth, ihmax) nparts = watershed_map.max() # Wind sea mask up = np.tile(agefac * wspd * np.cos(D2R * (dir - wdir)), (freq.size, 1)) windseamask = up > np.tile(celerity(freq, dpt)[:, np.newaxis], (1, dir.size)) # Assign partitioned arrays from raw spectrum and morphological boundaries wsea_primary_partition = np.zeros_like(spectrum) wsea_secondary_partition = np.zeros_like(spectrum) swell_partitions = [np.zeros_like(spectrum) for n in range(nparts)] for ipart in range(nparts): part = np.where(watershed_map == ipart + 1, spectrum, 0.0) # start at 1 wsfrac = part[windseamask].sum() / part.sum() if wsfrac > wscut: wsea_primary_partition += part else: wsea_secondary_partition += np.where(windseamask, part, 0.0) swell_partitions[ipart] += np.where(windseamask, 0.0, part) # Sort swells by Hs isort = np.argsort([-npstats.hs(swell, freq, dir) for swell in swell_partitions]) swell_partitions = list(np.array(swell_partitions)[isort]) # Dealing with the number of swells if swells is None: # Exclude null swell partitions if the number of output swells is undefined swell_partitions = [swell for swell in swell_partitions if swell.sum() > 0] else: if nparts > swells: # Discard extra partitions swell_partitions = swell_partitions[:swells] elif nparts < swells: # Extend partitions list with null spectra n = swells - len(swell_partitions) for i in range(n): swell_partitions.append(np.zeros_like(spectrum)) wsea_partitions = [wsea_primary_partition, wsea_secondary_partition] return np.array(wsea_partitions + swell_partitions)
[docs] def np_ptm3( spectrum, spectrum_smooth, freq, dir, parts=DEFAULTS["swells"], ihmax=DEFAULTS["ihmax"], ): """PTM3 spectra partitioning on numpy arrays. Args: - spectrum (2darray): Wave spectrum array with shape (nf, nd). - spectrum_smooth (2darray): Smoothed wave spectrum array with shape (nf, nd). - freq (1darray): Wave frequency array with shape (nf). - dir (1darray): Wave direction array with shape (nd). - parts (int): Number of partitions to compute, all detected by default. - ihmax (int): Number of discrete spectral levels in WW3 Watershed code. Returns: - specpart (3darray): Wave spectrum partitions sorted in decreasing order of Hs with shape (np, nf, nd). Note: - The smooth spectrum `spectrum_smooth` is used to define the watershed boundaries which are applied to the original spectrum. """ # Use smooth spectrum to define morphological boundaries watershed_map = specpart.partition(spectrum_smooth, ihmax) nparts = watershed_map.max() # Assign partitioned arrays from raw spectrum and morphological boundaries partitions = [] for npart in range(1, nparts + 1): partitions.append(np.where(watershed_map == npart, spectrum, 0.0)) # Sort partitions by Hs isort = np.argsort([-npstats.hs(swell, freq, dir) for swell in partitions]) partitions = list(np.array(partitions)[isort]) if parts is not None: if nparts > parts: # Discard extra partitions partitions = partitions[:parts] elif nparts < parts: # Extend partitions list with zero arrays template = np.zeros_like(spectrum) n = parts - len(partitions) for i in range(n): partitions.append(template) return np.array(partitions)
[docs] def np_hp01( spectrum, spectrum_smooth, windseamask, freq, dir, wscut=DEFAULTS["wscut"], swells=DEFAULTS["swells"], kappa=DEFAULTS["kappa"], zeta=DEFAULTS["zeta"], angle_max=DEFAULTS["angle_max"], hs_min=DEFAULTS["hs_min"], noise_a=DEFAULTS["noise_a"], noise_b=DEFAULTS["noise_b"], ihmax=DEFAULTS["ihmax"], combine_extra_swells=True, ): """Hanson and Phillips 2001 spectra partitioning on numpy arrays. The spectrum is partitioned with the watershed algorithm, wind sea partitions are identified by the wind sea fraction criterion and combined into partition 0 as in the PTM1 method, and adjacent swell partitions belonging to the same wave system are combined following the criteria in Hanson and Phillips (2001) and Hanson et al. (2009). Args: - spectrum (2darray): Wave spectrum array with shape (nf, nd). - spectrum_smooth (2darray): Smoothed wave spectrum array with shape (nf, nd). - windseamask (2darray): Wind-sea mask array with shape (nf, nd). - freq (1darray): Wave frequency array with shape (nf). - dir (1darray): Wave direction array with shape (nd). - wscut (float): Wind sea fraction cutoff. - swells (int): Number of swell partitions to compute, all detected by default. - kappa (float): Spread factor in the peak separation criterion of Hanson and Phillips (2001)'s eq 9, larger values combine more partitions. - zeta (float): Peak minimum factor, the fraction of the smaller peak density that the saddle point between two partitions must exceed for the partitions to be combined, smaller values combine more partitions. - angle_max (float): Maximum angle (deg) between partition mean directions for combining partitions as per Hanson et al. (2009), disabled if None. - hs_min (float): Minimum Hs of swell partitions, smaller ones are always combined with their most connected neighbours regardless of other criteria being satisfied. The default of 0.2 m follows Hanson et al. (2009) and is appropriate for open-ocean data, consider reducing it in low-energy environments such as sheltered sites so genuine small wave systems are not combined. - noise_a (float): Factor `A` in Hanson and Phillips (2001)'s noise threshold eq 10, e <= A / (fp^4 + B), partitions with total energy below this threshold are treated as noise and merged onto their most connected neighbours. Disabled if None. - noise_b (float): Factor `B` in Hanson and Phillips (2001)'s eq 10. - ihmax (int): Number of discrete spectral levels in WW3 Watershed code. - combine_extra_swells (bool): If True and more swell partitions remain after combining than the number requested in the `swells` argument, merge each extra swell with its closest neighbour until the requested number is achieved. If False, the smallest extra swells are excluded from the output. Returns: - specpart (3darray): Wave spectrum partitions with shape (np, nf, nd) where the 0th index is the wind sea and the remaining indices are the swells sorted in decreasing order of Hs. Note: - The smooth spectrum `spectrum_smooth` is used to define the watershed boundaries which are applied to the original spectrum. - Spectral variance is conserved unless `combine_extra_swells` is False and there are more combined swells than requested. """ # Use smooth spectrum to define morphological boundaries watershed_map = specpart.partition(spectrum_smooth, ihmax) nparts = watershed_map.max() # Assign partitioned arrays from raw spectrum and morphological boundaries wsea_partition = np.zeros_like(spectrum) swell_partitions = [] for ipart in range(nparts): part = np.where(watershed_map == ipart + 1, spectrum, 0.0) # start at 1 wsfrac = part[windseamask].sum() / part.sum() if wsfrac > wscut: wsea_partition += part else: swell_partitions.append(part) # Convert to numpy array for easy sorting swell_partitions = np.array(swell_partitions) # Sort swells by Hs hs_swells_neg = [-npstats.hs(swell, freq, dir) for swell in swell_partitions] isort = np.argsort(hs_swells_neg) swell_partitions = list(swell_partitions[isort]) # Combine extra swell partitions if len(swell_partitions) > 1: swell_partitions = combine_partitions_hp01( partitions=swell_partitions, freq=freq, dir=dir, swells=swells, kappa=kappa, zeta=zeta, angle_max=angle_max, hs_min=hs_min, noise_a=noise_a, noise_b=noise_b, combine_extra_swells=combine_extra_swells, ) # Extend list to ensure the correct number of partitions is returned nswells = len(swell_partitions) if swells is not None and nswells < swells: nullspec = np.zeros_like(spectrum) n = swells - nswells for i in range(n): swell_partitions.append(nullspec) return np.array([wsea_partition] + swell_partitions)