Last updated on Feb 20, 2026.

Source code for netsse.analys.spec

# -*- coding: utf-8 -*-
"""
Functions to compute sea state parameters from **wave spectra**.

.. dropdown:: Copyright (C) 2023-2026 Technical University of Denmark, R.E.G. Mounet
    :color: primary
    :icon: law

    *This code is part of the NetSSE software.*

    NetSSE is free software: you can redistribute it and/or modify it under
    the terms of the GNU General Public License as published by the Free
    Software Foundation, either version 3 of the License, or (at your
    option) any later version.

    NetSSE is distributed in the hope that it will be useful, but WITHOUT
    ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
    FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
    for more details.

    You should have received a copy of the GNU General Public License along
    with this program.  If not, see https://www.gnu.org/licenses/.

    To credit the author, users are encouraged to use below reference:

    .. code-block:: text

        Mounet, R. E. G., & Nielsen, U. D. NetSSE: An open-source Python package
        for network-based sea state estimation from ships, buoys, and other
        observation platforms (version 2.2). Technical University of Denmark,
        GitLab. February 2026. https://doi.org/10.11583/DTU.26379811.

*Last updated on 20-02-2026 by R.E.G. Mounet*

"""

import numpy as np
from scipy.integrate import trapezoid
from netsse.tools.misc_func import re_range


[docs] def spec1d_to_params(S, freq, unit_freq="rad/s", smooth_Tp=False): """Computes the main sea state parameters from a given 1-D wave spectrum. Parameters ---------- S : array_like 1-D wave spectrum. If ``S`` has more than one dimension, then one axis must have a length Nfreq. freq : array_like of shape (Nfreq,) Set of discretized frequencies. unit_freq : {'rad/s','Hz'}, optional Unit of the frequencies: - 'rad/s' : The variable ``freq`` denotes the circular frequencies in radians per second. - 'Hz' : The 1-D wave spectrum is expressed as a function of frequency in Hertz. smooth_Tp : bool, default False Specify whether the output ``Tp`` value should be smoothened by fitting a polynomial of order 3 in the vicinity of the spectral peak and estimating the peak period through the fitted polynomial. Returns ------- m : array_like of shape (6,...,) Spectral moments :math:`[m_{-1},m_0,m_1,m_2,m_3,m_4]`. Hm0 : float, or array_like Spectral significant wave height [m]. Tp : float, or array_like Peak wave period [s]. Tm01 : float, or array_like Mean wave period [s]. Tm02 : float, or array_like Zero up-crossing period [s]. Tm24 : float, or array_like Mean crest period [s]. TE : float, or array_like Mean energy period [s]. Sm02 : float, or array_like Significant wave steepness [-]. epsilon : float, or array_like Spectral bandwidth [-]. Qp : float, or array_like Goda's peakedness parameter [-]. See Also -------- spec2d_to_params : Computes a set of overall parameters characterizing the sea state from the 2-D wave spectrum. Example ------- >>> m, Hm0, Tp, Tm01, Tm02, Tm24, TE, Sm02, epsilon, Qp = ... spec1d_to_params(S,freq,unit_freq='rad/s',smooth_Tp=False) """ if unit_freq == "rad/s": f = freq / (2 * np.pi) Sf = S * 2 * np.pi elif unit_freq == "Hz": f = freq Sf = S f = np.reshape(f, (-1,)) Nf = len(f) nDim_Sf = np.ndim(Sf) if nDim_Sf >= 1: for dim in range(1, nDim_Sf): f = np.expand_dims(f, dim) shape_spec = np.shape(S) freq_axis = np.where(np.array(shape_spec) == Nf)[0][0] Sf = np.moveaxis(Sf, freq_axis, 0) shape_moments = np.shape(Sf)[1:] m = np.zeros((6,) + shape_moments) else: m = np.zeros((6,)) #### ## Moments of the variance spectral density [m-1,m0,m1,m2,m3,m4] #### m[0,] = trapezoid(f[1:,] ** (-1) * Sf[1:,], f[1:,], axis=0) for i in range(1, 6): m[i,] = trapezoid(f ** (i - 1) * Sf, f, axis=0) #### ## Sea state parameters, derived from the spectrum #### g = 9.81 # Gravitational acceleration [m/s^2] Hm0 = 4 * np.sqrt(m[1,]) # Spectral significant wave height [m] Tm02 = np.sqrt(m[1,] / m[3,]) # Zero up-crossing period [s] Tm01 = m[1,] / m[2,] # Mean wave period [s] Tm24 = np.sqrt(m[3,] / m[5,]) # Mean crest period [s] TE = m[0] / m[1] # Mean energy period [s] Sm02 = 2 * np.pi / g * Hm0 / Tm02**2 # Significant wave steepness [-] epsilon = np.sqrt(1 - m[3,] ** 2 / (m[1,] * m[5])) # Spectral bandwidth [-] #### ## Goda's peakedness parameter [-] #### Qp = 2 / m[1,] ** 2 * trapezoid(f * Sf**2, f, axis=0) #### ## Peak period [s] #### I = np.argmax(Sf, axis=0, keepdims=True) f = np.reshape(f, (-1,)) if not smooth_Tp: # Basic method: Tp = 1 / np.squeeze(f[I,]) elif smooth_Tp: # More elaborate method: fit a polynomial of order 3 in the vicinity # of the peak (increases the accuracy of Tp): I = np.squeeze(I) Tp = np.where((I < 2) + (I > Nf - 3), float("nan"), 0) shape_Tp = np.shape(Tp) Sf_reshaped = np.squeeze(np.moveaxis(Sf, 0, -1)) for ii, I_val in enumerate(np.nditer(I)): indices_ii = np.unravel_index(ii, shape_Tp) if ~np.isnan(Tp[indices_ii]): x = f[I_val - 2 : I_val + 3,] y = Sf_reshaped[indices_ii][I_val - 2 : I_val + 3,] z = np.polyfit(x, y, 3) x1 = np.linspace(f[I_val - 2,], f[I_val + 2,], 100) p = np.poly1d(z) y1 = p(x1) I1 = np.argmax(y1) Tp[indices_ii] += 1 / x1[I1,] # Plot of the polynomial fit, compared with the true values # at the peak: # plt.figure() # plt.plot(x1,y1) # plt.plot(x,y,marker='+') # plt.show() return m, Hm0, Tp, Tm01, Tm02, Tm24, TE, Sm02, epsilon, Qp
[docs] def spec2d_to_params(S2D, freq, theta, unit_theta="deg", smooth_peak=False): """Computes a set of overall parameters characterizing the sea state from the 2-D wave spectrum. Parameters ---------- S2D : array_like of shape (...,Nf,Ntheta,...) Directional wave spectrum [m^2.s/``unit_theta``], as a function of frequency in Hertz and wave direction in specified unit (``unit_theta``). freq : array_like of shape (Nf,) Vector of (discretized) wave frequencies [Hz] (must include :math:`f =` 0 Hz). theta : array_like of shape (Ntheta,) Vector of wave headings [the unit is specified in ``unit_theta``]. .. attention:: ``theta`` must be in a wrapped format, i.e., corresponding to [0,360] deg. unit_theta : {'deg','rad'}, optional Unit of the wave directions. This applies for the expression of the wave spectrum too. Can be ``'deg'`` (for degrees), or ``'rad'`` (for radians). ``'deg'`` is the default. smooth_peak : bool, optional Specify whether the output ``Tp`` and ``theta_p`` values should be smoothened by fitting a polynomial of order 3 in the vicinity of the spectral peak and estimating the peak period and direction through the fitted polynomial. The default is ``False``. Returns ------- m : array_like of shape (6,) Spectral moments :math:`[m_{-1},m_0,m_1,m_2,m_3,m_4]`. Hm0 : float, or array_like Spectral significant wave height [m]. Tp : float, or array_like Peak wave period [s]. Tm01 : float, or array_like Mean wave period [s]. Tm02 : float, or array_like Zero up-crossing period [s]. Tm24 : float, or array_like Mean crest period [s]. TE : float, or array_like Mean energy period [s]. Sm02 : float, or array_like Significant wave steepness [-]. epsilon : float, or array_like Spectral bandwidth [-]. Qp : float, or array_like Goda's peakedness parameter [-]. theta_p : float, or array_like Peak wave direction [deg]. theta_m : float, or array_like Mean overall wave direction [deg]. sigma_m : float, or array_like Mean directional spreading [deg]. See Also -------- spec1d_to_params : Computes the main sea state parameters from a given 1-D wave spectrum. Example ------- >>> m, Hm0, Tp, Tm01, Tm02, Tm24, TE, Sm02, epsilon, Qp, theta_p, theta_m, sigma_m = ... spec2d_to_params(S2D, freq, theta, unit_theta='deg', smooth_peak=False) """ # Physical constants: g = 9.81 # Gravitational acceleration [m/s^2] # Dimensions: nDim_S2D = np.ndim(S2D) Nf = len(freq) Nmu = len(theta) shape_spec2D = np.array(np.shape(S2D)) f_axis = np.where(shape_spec2D == Nf)[0][0] mu_axis = np.where(shape_spec2D == Nmu)[0][0] if nDim_S2D >= 3: spec2D = np.moveaxis(S2D, [f_axis, mu_axis], [0, 1]) f = np.reshape(freq, (Nf, 1) + tuple([1] * (nDim_S2D - 2))) mu = np.reshape(theta, (1, Nmu) + tuple([1] * (nDim_S2D - 2))) shape_moments = np.shape(spec2D)[2:] m = np.zeros((6,) + shape_moments) else: spec2D = S2D f = np.reshape(freq, (Nf, 1)) mu = np.reshape(theta, (1, Nmu)) shape_moments = [] m = np.zeros((6,)) # Units: if unit_theta == "deg": theta_deg = theta # theta_rad = theta*np.pi/180 mu_rad = mu * np.pi / 180 spec2D *= 180 / np.pi elif unit_theta == "rad": theta_deg = theta * 180 / np.pi # theta_rad = theta mu_rad = mu Sf = np.expand_dims(trapezoid(spec2D, mu_rad, axis=1), 1) # Moments of the variance spectral density [m-1,m0,m1,m2,m3,m4]: m[0,] = np.squeeze(trapezoid(f ** (-1) * Sf, f, axis=0)) for i in range(1, 6): m[i,] = np.squeeze(trapezoid(f ** (i - 1) * Sf, f, axis=0)) # Directional spreading function D = spec2D / Sf D[np.isnan(D)] = 0 # Fourier coefficients (frequency-dependent): a1 = trapezoid(D * np.cos(mu_rad), mu_rad, axis=1) b1 = trapezoid(D * np.sin(mu_rad), mu_rad, axis=1) # print(a1); print(b1) # Sea state parameters, derived from the spectrum: Hm0 = 4 * np.sqrt(m[1,]) # Spectral significant wave height [m] Tm02 = np.sqrt(m[1,] / m[3,]) # Zero up-crossing period [s] Tm01 = m[1,] / m[2,] # Mean wave period [s] Tm24 = np.sqrt(m[3,] / m[5,]) # Mean crest period [s] TE = m[0] / m[1] # Mean energy period [s] Sm02 = 2 * np.pi / g * Hm0 / Tm02**2 # Significant wave steepness [-] epsilon = np.sqrt(1 - m[3,] ** 2 / (m[1,] * m[5])) # Spectral bandwidth [-] # Goda's peakedness parameter [-]: Qp = 2 / m[1,] ** 2 * np.squeeze(trapezoid(f * Sf**2, f, axis=0)) # Peak period [s] and peak direction [deg]: I = np.argmax(Sf, axis=0, keepdims=True) J = np.argmax(spec2D, axis=1, keepdims=True) K = np.take_along_axis(J, I, axis=0) # Important note: The peak direction is defined here as the direction of # maximum PSD in the 2-D wave spectrum at the peak frequency (computed # from the 1-D spectrum) if not smooth_peak: # Basic method: Tp = 1 / np.squeeze(freq[I,]) theta_p = np.squeeze(theta_deg[K,]) elif smooth_peak: # More elaborate method: fit a polynomial of order 3 in the vicinity # of the peak (increases the accuracy of Tp and theta_p): theta_deg_wrap = np.concatenate( (theta_deg[-3:-1,] - 360, theta_deg, theta_deg[1:3,] + 360), axis=0 ) K = np.squeeze(K + 2) shape_thetap = np.shape(K) theta_p = np.zeros(shape_thetap) Stheta = np.take_along_axis(spec2D, I, axis=0) Stheta_wrap = np.squeeze( np.moveaxis( np.concatenate((Stheta[:, -3:-1], Stheta, Stheta[:, 1:3]), axis=1), 1, -1, ) ) I = np.squeeze(I) Tp = np.where((I < 2) + (I > Nf - 3), float("nan"), 0) shape_Tp = np.shape(Tp) Sf_reshaped = np.squeeze(np.moveaxis(Sf, 0, -1)) for ii, I_val in enumerate(np.nditer(I)): indices_ii = np.unravel_index(ii, shape_Tp) if ~np.isnan(Tp[indices_ii]): x = freq[I_val - 2 : I_val + 3,] y = Sf_reshaped[indices_ii][I_val - 2 : I_val + 3,] z = np.polyfit(x, y, 3) x1 = np.linspace(freq[I_val - 2,], freq[I_val + 2,], 100) p = np.poly1d(z) y1 = p(x1) I1 = np.argmax(y1) Tp[indices_ii] += 1 / x1[I1,] # Plot of the polynomial fit, compared with the true values # at the peak: # plt.figure() # plt.plot(x1,y1) # plt.plot(x,y,marker='+') # plt.show() for kk, K_val in enumerate(np.nditer(K)): indices_kk = np.unravel_index(kk, shape_thetap) x = theta_deg_wrap[K_val - 2 : K_val + 3,] y = Stheta_wrap[indices_kk][K_val - 2 : K_val + 3,] z = np.polyfit(x, y, 3) x1 = np.linspace( theta_deg_wrap[K_val - 2,], theta_deg_wrap[K_val + 2,], 100 ) p = np.poly1d(z) y1 = p(x1) K1 = np.argmax(y1) theta_p[indices_kk] += x1[K1,] # Plot of the polynomial fit, compared with the true values # at the peak: # plt.figure() # plt.plot(x1,y1) # plt.plot(x,y,marker='+') # plt.show() theta_p = re_range(theta_p) # Frequency-dependent mean wave direction [deg] and directional spreading # factor [rad]: # theta1 = np.arctan2(b1,a1)*180/np.pi # s = np.sqrt(a1**2+b1**2)/(1-np.sqrt(a1**2+b1**2)) # Mean overall wave direction [deg]: d = trapezoid(trapezoid(spec2D * np.sin(mu_rad), f, axis=0), mu_rad[0,], axis=0) c = trapezoid(trapezoid(spec2D * np.cos(mu_rad), f, axis=0), mu_rad[0,], axis=0) theta_m = np.reshape( re_range(np.reshape(np.arctan2(d, c) * 180 / np.pi, (-1,))), shape_moments ) # First-order spreading coefficient [rad] (as per Saulnier et al. 2012) sigma1 = np.expand_dims(np.sqrt(2 * (1 - np.sqrt(a1**2 + b1**2))), 1) # Mean directional spreading [deg]: d_s = trapezoid(trapezoid(spec2D * np.sin(sigma1), f, axis=0), mu_rad[0,], axis=0) c_s = trapezoid(trapezoid(spec2D * np.cos(sigma1), f, axis=0), mu_rad[0,], axis=0) sigma_m = np.arctan2(d_s, c_s) * 180 / np.pi return ( m, Hm0, Tp, Tm01, Tm02, Tm24, TE, Sm02, epsilon, Qp, theta_p, theta_m, sigma_m, )
[docs] def spread_dist_to_spec2d(Sf, D, theta): """Converts the given 1-D wave spectrum and directional spreading function into a 2-D wave spectrum. Parameters ---------- Sf : array_like of shape (...,Nf,...) One-sided variance spectrum of the waves, as a function of frequency. D : array_like of shape (...,Nf,Ntheta,...) Directional spreading function. theta : array_like of shape (Ntheta,) Vector of wave headings (with arbitrary unit). Returns ------- S2D : array_like of shape (...,Nf,Ntheta,...) Directional wave spectrum, as a function of frequency and wave direction (with arbitrary units). See Also -------- spec2d_to_spread_dist : Converts the given 2-D wave spectrum into a directional spreading function and a 1-D wave spectrum. Example ------- >>> S2D = spread_dist_to_spec2d(Sf,D,theta) """ # Dimensions: Nmu = len(theta) shape_spec2D = np.array(np.shape(D)) mu_axis = np.where(shape_spec2D == Nmu)[0][0] # Directional wave spectrum: D[np.isnan(D)] = 0 S2D = np.expand_dims(Sf, mu_axis) * D return S2D
[docs] def spec2d_to_spread_dist(S2D, theta): """Converts the given 2-D wave spectrum into a directional spreading function and a 1-D wave spectrum. Parameters ---------- S2D : array_like of shape (...,Nf,Ntheta,...) Directional wave spectrum, as a function of frequency and wave direction (with arbitrary units). theta : array_like of shape (Ntheta,) Vector of wave headings (with arbitrary unit). Returns ------- Sf : array_like of shape (...,Nf,...) One-sided variance spectrum of the waves, as a function of frequency. D : array_like of shape (...,Nf,Ntheta,...) Directional spreading function. See Also -------- spread_dist_to_spec2d : Converts the given 1-D wave spectrum and directional spreading function into a 2-D wave spectrum. Example ------- >>> Sf, D = spec2d_to_spread_dist(S2D,theta) """ # Dimensions: Ntheta = len(theta) shape_spec2D = np.array(np.shape(S2D)) theta_axis = np.where(shape_spec2D == Ntheta)[0][0] # 1-D wave spectrum: Sf = np.expand_dims(trapezoid(S2D, theta, axis=theta_axis), theta_axis) # Directional spreading function D = S2D / Sf D[np.isnan(D)] = 0 Sf = np.squeeze(Sf) return Sf, D