Last updated on Feb 20, 2026.

Source code for netsse.analys.emep

# -*- coding: utf-8 -*-
"""
Python implementation of the **EMEP** algorithm, used for analyzing
directional wave spectra from field data.

.. 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
import itertools


[docs] def norm_resp(Rxy_ReIm, TRF, S1D, f, mn=None, ReIm=None, Na=None, axy=None): """Normalizes the response spectra and transfer functions for application of the EMEP method. .. note:: The normalization factors are found in Bendat & Piersol (2010). Parameters ---------- Rxy_ReIm : array_like of shape (Nfreq,Nxy) Response spectra as a function of frequency ``f``, considering `Nxy` pairs of responses. TRF : Multidimensional array of shape (Nresp,Ntheta,Nfreq) Transfer functions for `Nresp` different responses, as functions of wave direction and frequency ``f``. S1D : array_like of shape (Nfreq,) 1-D wave spectrum, as a function of frequency ``f``. f : array_like of shape (Nfreq,) Vector of wave frequencies. mn : array_like of shape (Nxy,2), optional Definition of the considered responses in ``Rxy_ReIm``. Elements in ``mn`` correspond to the indices of the pairs of responses along the first dimension of ``TRF``, i.e., from 0 to `Nresp`-1. Default is ``None``; in that case, the response pairs are ordered in the following order (illustrated for `Nresp = 3`): ``Rxy_ReIm = [Re(R00),Re(R11),Re(R22),Re(R01),Re(R02),Re(R12),Im(R01),Im(R02),Im(R12)]`` ``mn = [[0, 0], [1, 1], [2, 2], [0, 1], [0, 2], [1, 2], [0, 1], [0, 2], [1, 2]]`` ReIm : array_like of shape (Nxy,) or (Nxy,1), optional Vector indicating whether the real part (``'R'``) or imaginary part (``'I'``) of the response cross-spectrum is considered in ``Rxy_ReIm``. Default is ``None``; in that case, the real and imaginary parts of the response pairs are ordered in the order shown above, thus: ``ReIm = ['R', 'R', 'R', 'R', 'R', 'R', 'I', 'I', 'I']`` Na : str, or array_like of shape (Nxy,), optional Number of the ensembled average for computation of the response spectra variance. Default is ``None``, for which case ``Na = 1``. axy : array_like of shape (Nxy,), optional Additional weights (must be positive). Any weight greater than zero increases the importance given to a corresponding pair of responses in the EMEP method. Default is ``None``, for which case the weights are set to zero. Returns ------- Rxy_ReIm_n : array_like of shape (Nfreq,Nxy) Normalized version of the response spectra. Hn : array_like of shape (Ntheta,Nfreq,Nxy) Normalized version of the real and imaginary parts of the product of the transfer functions for pairs of responses. The real and imaginary parts for the pairs of responses are output in the same order as ``Rxy_ReIm``. See Also -------- emep : Extended Maximum Entropy Principle (EMEP) method for reconstructing the directional spreading function based on the cross-power spectra of measured wave-induced responses. References ---------- Bendat, J. S., & Piersol, A. G. (2010). Random Data: Analysis and Measurement Procedures. In Measurement Science and Technology (Vol. 11, Issue 12). Wiley. https://doi.org/10.1002/9781118032428 Example ------- >>> Rxy_ReIm_n, Hn = norm_resp(Rxy_ReIm,TRF,S1D,f,mn=None,ReIm=None,Na=None,axy=None) """ Nxy = np.shape(Rxy_ReIm)[1] # Number of considered response pairs Nfreq = np.shape(f)[0] # Number of wave frequencies Ntheta = np.shape(TRF)[1] # Number of wave directions Wmn = np.ones((Nfreq, Nxy)) # Weighting factor to normalise responses Swave = np.reshape(S1D, (Nfreq, 1)) # Point wave spectrum Hn = np.zeros((Ntheta, Nfreq, Nxy)) # Normalised transfer function products ## Optional arguments: Nresp = int(np.sqrt(Nxy)) # Number of considered responses if mn is None: # Indicate the indices of considered response pairs mn = np.array( [[ix, ix] for ix in range(Nresp)] + [list(x) for x in itertools.combinations(range(Nresp), 2)] * 2 ) if ReIm is None: # Indicates whether the real or the imaginary part is considered ReIm = np.array(["R"] * 2 * Nresp + ["I"] * Nresp).reshape((-1, 1)) else: ReIm = np.reshape(ReIm, (-1, 1)) if Na is None: # Number of the ensembled average Na = np.ones((Nxy,)) elif np.size(Na) == 1: Na = Na * np.ones((Nxy,)) if axy is None: # Additional weights axy = np.zeros((Nxy,)) else: axy = np.abs(axy) ## Compute the weighting factors Wmn: for ixy in range(Nxy): mn_ixy = mn[ixy] index_xx_Re = np.where( np.all((mn == [mn_ixy[0], mn_ixy[0]]) * (ReIm == "R"), axis=1) )[0][0] index_yy_Re = np.where( np.all((mn == [mn_ixy[1], mn_ixy[1]]) * (ReIm == "R"), axis=1) )[0][0] if ReIm[ixy] == "R": # Case 1: real part # Find the response index for the imaginary part for the corresponding pair: index_xy_Im = np.where(np.all((mn == mn_ixy) * (ReIm == "I"), axis=1))[0] if np.any(index_xy_Im): Rxy_Im = Rxy_ReIm[:, index_xy_Im[0]] else: Rxy_Im = np.zeros((Nfreq,)) index_xy_Re = ixy # Weigthing factor value: Wmn[:, ixy] = np.sqrt( ( Rxy_ReIm[:, index_xx_Re] * Rxy_ReIm[:, index_yy_Re] + np.square(Rxy_ReIm[:, index_xy_Re]) - np.square(Rxy_Im) ) / (2 * (Na[ixy] + axy[ixy])) ) elif ReIm[ixy] == "I": # Case 2: imaginary part # Find the response index for the real part for the corresponding pair: index_xy_Re = np.where(np.all((mn == mn_ixy) * (ReIm == "R"), axis=1))[0] if np.any(index_xy_Re): Rxy_Re = Rxy_ReIm[:, index_xy_Re[0]] else: Rxy_Re = np.zeros((Nfreq,)) index_xy_Im = ixy # Weigthing factor value: Wmn[:, ixy] = np.sqrt( ( Rxy_ReIm[:, index_xx_Re] * Rxy_ReIm[:, index_yy_Re] - np.square(Rxy_Re) + np.square(Rxy_ReIm[:, index_xy_Im]) ) / (2 * (Na[ixy] + axy[ixy])) ) ## Normalise the response spectra: Rxy_ReIm_n = Rxy_ReIm / (Swave * Wmn) ## Normalise the transfer functions: for ixy in range(Nxy): mn_ixy = mn[ixy] if ReIm[ixy] == "R": Hn[:, :, ixy] = np.real( TRF[mn_ixy[0], :, :] * np.conj(TRF[mn_ixy[1], :, :]) ) / np.expand_dims(Wmn[:, ixy], 0) if ReIm[ixy] == "I": Hn[:, :, ixy] = np.imag( TRF[mn_ixy[0], :, :] * np.conj(TRF[mn_ixy[1], :, :]) ) / np.expand_dims(Wmn[:, ixy], 0) return Rxy_ReIm_n, Hn
[docs] def norm_DSF(D, theta): """Normalizes the directional spreading function. The function ensures that the output has a unit integral over wave directions. Parameters ---------- D : array_like of shape (nt,nf) Directional spreading function. theta : array_like of shape (nt,) Vector of wave directions. Returns ------- Dn : array_like of shape (nt,nf) Normalized spreading function. Example ------- >>> Dn = norm_DSF(D, theta) """ Dn = D # Repair corrupted values: ind = np.where((Dn < 0) + np.isnan(Dn)) if np.any(ind): Dn[ind] = 0 _, iy = np.where(Dn == np.inf) if np.any(iy): for iz in iy: ind0 = Dn[:, iz] < np.inf Dn[ind0, iz] = 0 Dn[~ind0, iz] = 1 # Normalize so that int D(theta, f) dtheta = 1 for each f Sf2 = np.trapezoid(Dn, theta, axis=0) k = np.where((Sf2 > np.sqrt(np.finfo(float).eps)) * (Sf2 < np.inf))[0] if np.any(k): Dn[:, k] = Dn[:, k] / Sf2[k] return Dn
[docs] def emep(Sxyn, Hn, theta, fi, k, opt=None): r"""Applies the Extended Maximum Entropy Principle (EMEP) method to reconstruct the directional spreading function based on the cross-power spectra of measured wave-induced responses. .. note:: This implementation is based on the functions EMEP in the DIWASP toolbox and EMEM in the WAFO toolbox. Parameters ---------- Sxyn : ndarray of shape (nf,m*m), where nf is the number of frequencies and m is the number of sensors Real and imaginary parts of the normalised cross-power spectral density: ``Sxyn(f,i) = Re(Smn(f)/(Szeta(f)*Wmn(f)))`` or the imaginary (``Im(.)``) counterpart. Hn : ndarray of shape (nt,nf,m*m), where nt is the number of theta values Matrix of the real and imaginary parts of the normalised RAO products, in the same order of response pairs as for ``Sxyn``: ``Hn(theta,f,i) = Re(Phi_m(theta,f)*conj(Phi_n(theta,f))/Wmn(f))`` or the imaginary (``Im(.)``) counterpart. theta : ndarray of shape (nt,) Vector of wave headings. .. warning:: The wave heading *must be* in a wrapped format, corresponding to [0,360] deg. For ship responses, those headings must be the *relative* wave headings. fi : ndarray of shape (nf,) Frequency vector. k : array_like List of indices corresponding to frequencies where the wave power spectral density is substantially greater than zero. opt : dict, optional Optional parameters controlling the EMEM calculation. Available options are: - 'errortol' : float, default 0.0005 Error tolerance for convergence. - 'maxiter' : int, default 25 Maximum number of iterations for the Newton-Raphson method. - 'relax' : float, default 1 Relaxation parameter for controlling step shape in optimization. - 'maxcoef' : float, default 10000 Maximum value for coefficients to prevent divergence. - 'coefabstol' : float, default 0.01 Coefficient absolute tolerance for convergence. - 'minmodelorder' : int, default 1 Minimum model order for AIC evaluation. - 'maxmodelorder' : int, default M/2 + 1 Maximum model order for AIC evaluation. - 'diradjust' : float, default 0 Deviation term to adjust the wave directions to other direction conventions. .. warning:: For ship responses, a value of :math:`-\pi/2` was necessary to use: ``opt = {'diradjust': numpy.pi/2}``. - 'message' : {0,1} Display messages during the calculation. Returns ------- D : ndarray Estimated directional spreading distribution matrix of shape (nt, nf). See Also -------- norm_resp : Normalizes the response spectra and transfer functions. norm_DSF : Normalizes the directional spreading function. netsse.analys.buoy.Shannon_MEMII_Newton : Reconstructs the directional spreading function based on the first four Fourier coefficients of a directional wave spectrum. References ---------- 1. Hashimoto, N. (1997). "Analysis of the directional wave spectra from field data." Advances in Coastal and Ocean Engineering, Vol.3., pp.103-143. 2. DIWASP, a directional wave spectra toolbox for MATLAB: User Manual. Research Report WP-1601-DJ (V1.1), Centre for Water Research, University of Western Australia. 3. Brodtkorb, P.A., Johannesson, P., Lindgren, G., Rychlik, I., Rydén, J. and Sö, E. (2000). "WAFO - a Matlab toolbox for analysis of random waves and loads", Proc. 10th Int. Offshore and Polar Eng. Conf., Seattle, USA, Vol III, pp. 343-350. Example ------- >>> D = emep(Sxyn, Hn, theta, fi, k, opt=None) """ nt, nf, mm = Hn.shape H = np.zeros((mm, nt, nf)) phi = np.zeros((mm, nf)) # Eliminate meaningless equations such as those determined from the zero co-spectrum # and zero quadrature-spectrum. M = 0 # M is the number of independent equations dtheta = theta[1,] - theta[0,] tol = np.sqrt(np.finfo(float).eps) # threshold defining zero for transfer functions for ii in range(mm): Htemp = Hn[:, :, ii] if np.any(np.any(np.abs(np.diff(Htemp, axis=0)) > tol * dtheta)): M += 1 phi[M - 1, :] = Sxyn[:, ii] H[M - 1, :, :] = Htemp # for iy in range(ix, m): # Htemp = Gwt[ix, :, :] * np.conj(Gwt[iy, :, :]) # if np.any(np.any(np.abs(np.diff(np.real(Htemp), axis=0)) > tol*dtheta)): # M += 1 # phi[M-1, :] = np.real(Sxyn[ix, iy, :]) # H[M-1, :, :] = np.real(Htemp) # if np.any(np.any(np.abs(np.diff(np.imag(Htemp), axis=0)) > tol*dtheta)): # M += 1 # phi[M-1, :] = np.imag(Sxyn[ix, iy, :]) # H[M-1, :, :] = np.imag(Htemp) # Note: H and Phi here are normalized as described in Hashimoto, N. (1997) H = H[:M, :, :] phi = phi[:M, :] if opt is None: opt = {} # Default values of the parameters controlling the calculations: errorTol = 0.0005 # Error tolerance for convergence maxIter = 25 # Maximum number of iterations Li = 1 # Relaxation parameter maxCoef = 10000 # Maximum value for coefficients coefAbsTol = 0.01 # Coefficient absolute tolerance coefAbsTol2 = 500 # See below* minModelOrder = 1 # Minimum model order for AIC evaluation maxModelOrder = M // 2 + 1 # Maximum model order for AIC evaluation AICTol = 0.1 # AIC tolerance dirAdjust = 0 # Directional deviation for adjustment display = 0 # * 'coefAbsTol2' is a threshold value used to check whether the change in coefficients # during the Newton-Raphson iteration is within acceptable bounds. if "errortol" in opt: errorTol = opt["errortol"] if "maxiter" in opt: maxIter = opt["maxiter"] if "relax" in opt: Li = opt["relax"] if "maxcoef" in opt: maxCoef = opt["maxcoef"] if "coefabstol" in opt: coefAbsTol = opt["coefabstol"] if "minmodelorder" in opt: minModelOrder = opt["minmodelorder"] if "maxmodelorder" in opt: maxModelOrder = opt["maxmodelorder"] if "diradjust" in opt: dirAdjust = opt["diradjust"] if "message" in opt: display = opt["message"] if display > 0: print("Number of independent equations: ", M) cosn = np.cos(np.outer(np.arange(1, maxModelOrder + 1), theta + dirAdjust)) sinn = np.sin(np.outer(np.arange(1, maxModelOrder + 1), theta + dirAdjust)) cosnt = np.transpose(np.tile(cosn, (M, 1, 1)), (2, 0, 1)) sinnt = np.transpose(np.tile(sinn, (M, 1, 1)), (2, 0, 1)) AIC = np.zeros((maxModelOrder,)) XY = np.zeros( (2 * maxModelOrder, M) ) # Matrix to store the negative Jacobian in the iteration coef = np.zeros((2 * maxModelOrder,)) deltaCoef = np.zeros((2 * maxModelOrder,)) Nbest = 0 # Best model order D = np.ones((nt, nf)) / (2 * np.pi) # initialize DS for ff in k: Hj = H[:, :, ff].T # H has shape (1:M,1:nt,1:nf) Phij = np.tile(phi[:, ff], (nt, 1)) stop = 0 localMinModelOrder = max(minModelOrder, int(np.ceil(Nbest / 2))) N = localMinModelOrder - 1 coefOld = np.zeros((2 * N,)) while not stop: N += 1 coef[: 2 * N] = np.zeros((2 * N,)) deltaCoef[: 2 * N] = np.zeros((2 * N,)) count = 0 lambda_ = Li modelFound = 0 exponent = np.dot(coef[0:N], cosn[0:N, :]) + np.dot( coef[N : 2 * N], sinn[0:N, :] ) a0 = -np.max(exponent) Fn = np.exp(a0 + exponent) Dn = np.tile( Fn.reshape((-1, 1)) / (trapezoid(Fn) * dtheta), (1, M) ) # Fn(theta|f)/norm(Fn)=Dn(theta|f) PhiHD = (Phij - Hj) * Dn Z = trapezoid(PhiHD, axis=0) * dtheta # Z has shape (M,) error1 = np.max(np.abs(Z)) while not (stop or modelFound): count += 1 for ix in range(N): XY[ix, :] = ( Z * trapezoid(Dn * cosnt[:, :, ix], axis=0) - trapezoid(PhiHD * cosnt[:, :, ix], axis=0) ) * dtheta XY[N + ix, :] = ( Z * trapezoid(Dn * sinnt[:, :, ix], axis=0) - trapezoid(PhiHD * sinnt[:, :, ix], axis=0) ) * dtheta deltaCoefOld = deltaCoef[0 : 2 * N] # Perform the least-square method for sum((Z - deltacoef.T @ XY)**2): deltaCoef[0 : 2 * N] = np.linalg.lstsq( XY[0 : 2 * N, :].T, Z, rcond=None )[0] coef[0 : 2 * N] = coef[0 : 2 * N] + lambda_ * deltaCoef[0 : 2 * N] if maxCoef < np.inf: k0 = np.where(np.abs(coef[0 : 2 * N]) > maxCoef)[0] if len(k0) > 0: deltaCoef[k0] = ( np.sign(deltaCoef[k0]) * maxCoef - (coef[k0] - lambda_ * deltaCoef[k0]) ) / lambda_ coef[k0] = np.sign(coef[k0]) * maxCoef exponent = np.dot(coef[0:N], cosn[0:N, :]) + np.dot( coef[N : 2 * N], sinn[0:N, :] ) a0 = -np.max(exponent) # Trick in order to avoid infinities Fn = np.exp(a0 + exponent) Dn = np.tile(Fn.reshape((-1, 1)) / (trapezoid(Fn) * dtheta), (1, M)) PhiHD = (Phij - Hj) * Dn Z = trapezoid(PhiHD, axis=0) * dtheta error2 = error1 error1 = np.max(np.abs(Z)) if np.all(np.abs(deltaCoef[0 : 2 * N]) < coefAbsTol) or ( error1 <= errorTol ): modelFound = 1 if display > 0: print("Model found.") elif count > maxIter or ( (error2 < error1) and ( np.any(np.abs(deltaCoef[: 2 * N] - deltaCoefOld) > coefAbsTol2) ) ): if lambda_ > Li * 2 ** (-4): lambda_ = lambda_ * 0.5 if display > 0: print( "Coefficient divergence detected. Relaxing to lamdba_= %.3f" % lambda_ ) count = 0 deltaCoef[0 : 2 * N] = 0 coef[: 2 * N] = np.zeros( (2 * N,) ) # np.concatenate((coefOld[:(N-1)],np.array([0]),coefOld[(N-1):2*(N-1)],np.array([0]))) Dn = np.tile(1 / (2 * np.pi), (nt, M)) PhiHD = (Phij - Hj) * Dn Z = trapezoid(PhiHD, axis=0) * dtheta error2 = np.inf error1 = np.max(np.abs(Z)) else: stop = 1 if display > 0: print("Model not found.") if 0: # modelFound Np = 4 # subplot(Np,1,1), semilogy(abs(Z)+eps,'*'),hline(errorTol), title('error') # subplot(Np,1,2),plot(deltaCoef(1:2*N),'g*'), title('deltaCoef') # subplot(Np,1,3), plot(coef(1:2*N),'r*'), title('coef') # subplot(Np,1,4), plot(theta,Dn) # drawnow, # disp('Hit any key'),pause AIC[N - 1] = M * (np.log(2 * np.pi * np.var(Z)) + 1) + 4 * N + 2 if N > localMinModelOrder: stop = stop or (AIC[N - 1] + AICTol > AIC[N - 2]) if np.isnan(AIC[N - 1]): stop = 1 if stop: if N > localMinModelOrder: Nbest = N - 1 coef[0 : 2 * Nbest] = coefOld else: Nbest = 1 coef[0 : 2 * Nbest] = np.zeros((2 * Nbest,)) else: Nbest = N stop = N >= maxModelOrder coefOld = coef[0 : 2 * N] if display > 0: print( f"f = {fi[ff]} \t \t Model order = {Nbest} \t \t error = {np.max(np.abs(Z))}" ) exponent = np.dot(coef[0:Nbest], cosn[0:Nbest, :]) + np.dot( coef[Nbest : 2 * Nbest], sinn[0:Nbest, :] ) a0 = -np.max(exponent) # Trick in order to avoid infinities D[:, ff] = np.exp(a0 + exponent) D = norm_DSF(D, theta) if np.all(np.abs(D.flatten() - D[0, 0]) < np.sqrt(np.finfo(float).eps)): print("No main direction found. Check the estimated spectrum!") return D