Last updated on Feb 20, 2026.

Source code for netsse.analys.dsa

# -*- coding: utf-8 -*-
"""
Functions for **transforming spectra** observed from advancing floating platforms.

.. 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 netsse.tools.misc_func import re_range
from scipy.sparse import bsr_array, dia_array


[docs] def polynom_DSA(tau, om0m, weighting="uniform", nu23=None, full_output=False): r"""Computes the polynomial approximation of the Doppler Shift. .. note:: This implementation uses the derivations presented in Mounet et al. (2025, 2026). Parameters ---------- tau : array_like of shape (Ntau,) Array of Doppler shift intensities. om0m : float Cut-off frequency of the DSA. weighting : {'uniform','tripvalpb','lowfreq'}, optional Weighting method to use: - 'uniform' : Equal weight is given to all frequencies in the range ``[0,om0m]`` in the cost function. This is the default option. It is introduced in Mounet et al. (2025). - 'tripvalpb' : Same as ``'uniform'`` weighting, but the ``om0m`` is set to a value of :math:`(1+\sqrt{2})/(2\tau)` when :math:`\nu>\nu_{23}`. This ensures the DSA features an optimal accuracy in the range of frequencies where the triple-value problem occurs. This idea is introduced in Mounet et al. (2026). - 'lowfreq' : A decreasing (affine) weighting function is applied in the range ``[0,om0m]``, such that low frequencies are given more importance in the cost function. This option is not recommended for general use. nu23 : float, optional Threshold value of ``nu`` for switching between the concave and convex forms of the approximation. Default is ``None``, which means that the convex form is not used. full_output : bool, optional If True, the function returns ``nu``and ``kappa`` as additional outputs. Default is False. Returns ------- rho_p : array_like of shape (Ntau,) Computed ``rho_p`` values. nu_p : array_like of shape (Ntau,) Computed ``nu_p`` values. nu : array_like of shape (Ntau,) Computed ``nu`` values. kappa : array_like of shape (Ntau,) Computed ``kappa`` values. See Also -------- trans_2Dmat_DSA : Computes the matrices for applying the Doppler shift approximation (DSA). References ---------- 1. Mounet, R.E.G., Nielsen, U.D., and Takami, T. (2025). "Doppler Shift Approximation in Seakeeping Problems: A New Formulation for Ships Advancing at Any Forward Speed." In: Proceedings of the 16th International Symposium on Practical Design of Ships and Other Floating Structures (PRADS 2025), Ann Arbor, MI, USA. (Accepted). 2. Mounet, R.E.G., Nielsen, U.D., and Takami, T. (2026). "Approximating the Doppler Shift in Sea-Wave Spectra Observed from an Advancing Floating Platform." Applied Mathematical Modelling. (Submitted). Example ------- >>> rho_p, nu_p = polynom_DSA(tau, om0m, weighting='tripvalpb', full_output=False) """ Ntau = np.shape(tau)[0] nu12 = 1 / 2 nu = tau * om0m kappa = np.nanmin([np.ones((Ntau,)), 1 / nu], axis=0) match weighting: case "uniform": # Used in the PRADS'25 paper (Mounet et al., 2026) f1 = ( 1 / 8 * ( 3 * nu * (-4 * kappa**5 + 10 * kappa**4 - 3) + 15 * kappa**4 - 40 * kappa**3 + 41 / 2 ) ) f2 = nu * (2 * kappa**5 - 1) - 5 / 2 * kappa**4 + 5 / 4 case "tripvalpb": # Used in the journal paper (Mounet et al., 2026) Inu = np.nanmin( [np.ones(np.shape(tau)), (1 + np.sqrt(2)) / (2 * nu)], axis=0 ) f1 = 1 + ( 6 * nu * (Inu**4 - 2 * kappa**4) + 8 * (2 * kappa**3 - Inu**3) ) / (Inu**3 * (3 * Inu - 8)) f2 = (4 * nu * (2 * kappa**5 - Inu**5) + 5 * (Inu**4 - 2 * kappa**4)) / ( 4 * Inu**5 ) case "lowfreq": # Not recommended for general use f1 = ( 2 / 5 * ( nu * (10 * kappa**6 - 36 * kappa**5 + 30 * kappa**4 - 2) + (-12 * kappa**5 + 45 * kappa**4 - 40 * kappa**3 + 6) ) ) f2 = nu * (-10 * kappa**6 + 12 * kappa**5 - 1) + ( 12 * kappa**5 - 15 * kappa**4 + 3 / 2 ) # Compute rho_p as a function of nu and kappa: if nu23 is None: nu23 = np.inf rho_p = 0 * (nu <= nu12) + f1 * (nu > nu12) * (nu < nu23) + 1 * (nu >= nu23) # Compute nu_p as a function of nu and kappa: nu_p = ( nu * (nu <= nu12) + (1 - f1) / 2 * (nu > nu12) * (nu < nu23) + f2 * (nu >= nu23) ) if full_output: return rho_p, nu_p, nu, kappa return rho_p, nu_p
[docs] def trans_2Dmat_DSA( freq_in, beta, U, freq_out=None, unit_freq="rad/s", unit_beta="deg", conv="from", max_freq=None, nu23=None, transform="abs2enc", weighting="uniform", highfreq_matching=True, ): r"""Computes the transformation matrices for applying the Doppler shift approximation (DSA) at the given forward speed and for the given frequency and direction discretizations. .. note:: This implementation uses the derivations presented in Mounet et al. (2025, 2026). Parameters ---------- freq_in : array_like of shape (NfreqIN,) Input frequencies. beta : array_like of shape (Nbeta,) Relative wave heading angles. U : float Forward speed of the observer [m/s]. freq_out : array_like of shape (NfreqOUT), optional Output frequencies. If None, it is set to freq_in. unit_freq : {'rad/s','Hz'}, optional Unit of the frequencies: - 'rad/s' : The variables ``freq_in`` and ``freq_out`` denote the angular frequencies in radians per second. The default is 'rad/s'. - 'Hz' : Frequencies are in Hertz. unit_beta : {'deg','rad'}, optional Unit of the relative wave heading angles ``beta`` ('deg' or 'rad'). Default is 'deg'. conv : {'from','to'}, optional The convention that is used to express the direction ``beta`` of wave spectrum: - 'from' : The naval architecture convention, indicating where the waves are COMING FROM. - 'to' : The oceanographic convention, indicating where the waves are GOING TO. The default is "from" direction. max_freq : float, optional Cut-off frequency. If None, it is set to the maximum of ``freq_in``. nu23 : float, optional Threshold value of ``nu`` for switching between the concave and convex forms of the approximation. Default is ``None``, which means that the convex form is not used. transform : str, optional Type of transformation ('abs2enc' or 'enc2abs'). Default is 'abs2enc'. weighting : {'uniform','tripvalpb','lowfreq'}, optional Weighting method to use: - 'uniform' : Equal weight is given to all frequencies in the range [0,om0m] in the cost function. This is the default option. It is introduced in Mounet et al. (2025). - 'tripvalpb' : Same as ``'uniform'`` weighting, but the ``om0m`` is set to a value of :math:`(1+\sqrt{2})/(2\tau)` when :math:`\nu>\nu_{23}`. This ensures the DSA features an optimal accuracy in the range of frequencies where the triple-value problem occurs. This idea is introduced in Mounet et al. (2026). - 'lowfreq' : A decreasing (affine) weighting function is applied to frequencies in the range [0,om0m], such that low frequencies are given more importance in the cost function. This option is not recommended for general use. highfreq_matching : bool, optional If ``True``, the convex form of the DSA is overriden to become exact beyond the point where the DSA and the exact Doppler shift intersect each other in Region III of the mapping. If ``False``, the DSA is kept unchanged otherwise. Default is True. Returns ------- Omega_in_fromout : ndarray of shape (NfreqOUT,Nbeta) Approximated input frequencies as a function of the target frequencies and wave directions. C : ndarray of shape (NfreqOUT*Nbeta,NfreqIN*Nbeta) Linear interpolation matrix. D_wave : ndarray of shape (NfreqOUT*Nbeta,NfreqOUT*Nbeta) 2D-to-2D transformation matrix for wave spectra. S : ndarray of shape (NfreqOUT,NfreqOUT*Nbeta) 2D-to-1D summation matrix. See Also -------- polynom_DSA : Computes the polynomial approximation of the Doppler Shift. References ---------- 1. Mounet, R.E.G., Nielsen, U.D., and Takami, T. (2025). "Doppler Shift Approximation in Seakeeping Problems: A New Formulation for Ships Advancing at Any Forward Speed." In: Proceedings of the 16th International Symposium on Practical Design of Ships and Other Floating Structures (PRADS 2025), Ann Arbor, MI, USA. (Accepted). 2. Mounet, R.E.G., Nielsen, U.D., and Takami, T. (2026). "Approximating the Doppler Shift in Sea-Wave Spectra Observed from an Advancing Floating Platform." Applied Mathematical Modelling. (Submitted). Example ------- >>> _, C, D, S = trans_2Dmat_DSA( ... freq_in, beta, U, freq_out, unit_freq='Hz', unit_beta='deg', conv='from', max_freq=0.3, ... transform='abs2enc', weighting="uniform", highfreq_matching=True) """ g = 9.818 # Gravitational acceleration [m/s^2] # Define the absolute and encountered wave frequencies: if freq_out is None: freq_out = freq_in match unit_freq: case "rad/s": omega_in = freq_in omega_out = freq_out # Define the cut-off wave frequency: if max_freq is not None: omega_m = max_freq case "Hz": omega_in = 2 * np.pi * freq_in omega_out = 2 * np.pi * freq_out if max_freq is not None: omega_m = 2 * np.pi * max_freq if max_freq is None: omega_m = np.amax(omega_in) # Define the normalized frequencies: Omega_in = omega_in / omega_m Omega_out = omega_out / omega_m # Define the wave heading angles: match unit_beta: case "deg": beta_rad = np.radians(beta) case "rad": beta_rad = beta # Account for the direction convention: if conv == "from": beta_rad = re_range(beta_rad, unit="rad") elif conv == "to": beta_rad = re_range(beta_rad - np.pi, unit="rad") else: raise NameError('Invalid direction convention. Only accepts "from" or "to".') # Reshape the vector of absolute frequencies and relative wave headings: Omega_in = np.reshape(Omega_in, (-1, 1)) Omega_out = np.reshape(Omega_out, (-1, 1)) beta_rad = np.reshape(beta_rad, (1, -1)) # Avoid counting twice the same direction: if beta_rad[0, 0] % (2 * np.pi) == beta_rad[0, -1] % (2 * np.pi): beta_rad = beta_rad[:, :-1] print("Warning: The direction vector has duplicates!") NOmega_in = np.shape(Omega_in)[0] NOmega_out = np.shape(Omega_out)[0] Nbeta = np.shape(beta_rad)[1] dOmega_in = Omega_in[1, 0] - Omega_in[0, 0] # even spacing required in omega_in! dbeta = beta[1,] - beta[0,] # even spacing required in beta! # Variables of the Doppler shift approximation: tau = U / g * np.cos(beta_rad) nu = tau * omega_m kappa = np.nanmin([np.ones(np.shape(tau)), 1 / nu], axis=0) match weighting: case "uniform": # Used in the PRADS'25 paper (Mounet et al., 2025). A1 = ( 1 / 8 * ( 3 * nu * (-4 * kappa**5 + 10 * kappa**4 - 3) + 15 * kappa**4 - 40 * kappa**3 + 41 / 2 ) ) A2 = nu * (2 * kappa**5 - 1) - 5 / 2 * kappa**4 + 5 / 4 case "tripvalpb": # Used in the journal paper (Mounet et al., 2026). Inu = np.nanmin( [np.ones(np.shape(tau)), (1 + np.sqrt(2)) / (2 * nu)], axis=0 ) A1 = 1 + ( 6 * nu * (Inu**4 - 2 * kappa**4) + 8 * (2 * kappa**3 - Inu**3) ) / (Inu**3 * (3 * Inu - 8)) A2 = (4 * nu * (2 * kappa**5 - Inu**5) + 5 * (Inu**4 - 2 * kappa**4)) / ( 4 * Inu**5 ) case "lowfreq": # Not recommended for general use. A1 = ( 2 / 5 * ( 2 * nu * (5 * kappa**6 - 18 * kappa**5 + 15 * kappa**4 - 1) + (-12 * kappa**5 + 45 * kappa**4 - 40 * kappa**3 + 6) ) ) A2 = nu * (-10 * kappa**6 + 12 * kappa**5 - 1) + ( 12 * kappa**5 - 15 * kappa**4 + 3 / 2 ) # Compute rho_p and nu_p as a function of nu and kappa: nu12 = 1 / 2 if nu23 is None: nu23 = np.inf rho_p = 0 * (nu <= nu12) + A1 * (nu > nu12) * (nu < nu23) + 1 * (nu >= nu23) nu_p = ( nu * (nu <= nu12) + (1 - A1) / 2 * (nu > nu12) * (nu < nu23) + A2 * (nu >= nu23) ) nuTVP = np.nanmin( [(1 + np.sqrt(2)) / 2, nu23] ) # Upper limit of the triple-value problem # Compute the Doppler shift approximation: match transform: case "abs2enc": Omega_in_fromout = np.abs( 1 / (2 * nu_p) * ((1 - rho_p) - ((1 - rho_p) ** 2 - 4 * Omega_out * nu_p) ** (1 / 2)) ) # Avoid nan values: Omega_in_fromout[np.isnan(Omega_in_fromout)] = 0 # Avoid triple-value problem (TVP) issues: Omega_in_fromout[(Omega_in_fromout > 1) * (nu > nu12) * (nu < nuTVP)] = 0 # Compute matrix E: E = 1 / ((1 - rho_p) - 2 * nu_p * Omega_in_fromout) # Special handling for the cases beta=90° and beta=270°: i_beta_90 = np.argmin(np.abs(beta_rad - np.pi / 2)) i_beta_270 = np.argmin(np.abs(beta_rad - 3 * np.pi / 2)) if np.abs(beta_rad[0, i_beta_90] - np.pi / 2) < 1e-3: Omega_in_fromout[:, i_beta_90] = Omega_out[:, 0] E[:, i_beta_90] = 1 if np.abs(beta_rad[0, i_beta_270] - 3 * np.pi / 2) < 1e-3: Omega_in_fromout[:, i_beta_270] = Omega_out[:, 0] E[:, i_beta_270] = 1 # Special handling for the cases outside the range of the triple-value problem (TVP): if highfreq_matching: Omega0_lims = np.ones(np.shape(nu)) * np.inf index_above_nuTVP = np.where(nu > nuTVP)[1] Omega0_lims[:, index_above_nuTVP] = ( 2 - rho_p[0, index_above_nuTVP] ) / (nu[0, index_above_nuTVP] + nu_p[0, index_above_nuTVP]) index_outside_TVP = np.where(Omega_in_fromout > Omega0_lims) Omega_in_fromout_regIII = (1 + np.sqrt(1 + 4 * nu * Omega_out)) / ( 2 * nu ) Omega_in_fromout[index_outside_TVP] = Omega_in_fromout_regIII[ index_outside_TVP ] E[index_outside_TVP] = np.abs( 1 / ( -1 + 2 * nu[0, index_outside_TVP[1]] * Omega_in_fromout[index_outside_TVP] ) ) case "enc2abs": # Compute matrix E: E = (1 - rho_p) - 2 * nu_p * Omega_out # Compute the encounter frequency by the DSA: Omega_in_fromout = (1 - rho_p) * Omega_out - nu_p * Omega_out**2 Omega_in_fromout[np.isnan(Omega_in_fromout)] = 0 Omega_in_fromout[E < 0] = 0 E[E < 0] = 0 # Special handling for the cases beta=90° and beta=270°: i_beta_90 = np.argmin(np.abs(beta_rad - np.pi / 2)) i_beta_270 = np.argmin(np.abs(beta_rad - 3 * np.pi / 2)) if np.abs(beta_rad[0, i_beta_90] - np.pi / 2) < 1e-3: Omega_in_fromout[:, i_beta_90] = Omega_out[:, 0] E[:, i_beta_90] = 1 if np.abs(beta_rad[0, i_beta_270] - 3 * np.pi / 2) < 1e-3: Omega_in_fromout[:, i_beta_270] = Omega_out[:, 0] E[:, i_beta_270] = 1 # Special handling for the cases outside the range of the triple-value problem (TVP): if highfreq_matching: Omegae_lims = np.ones(np.shape(nu)) * np.inf index_above_nuTVP = np.where(nu > nuTVP)[1] Omegae_lims[:, index_above_nuTVP] = ( 1 / (4 * nu[0, index_above_nuTVP]) # (rho_p[0, index_above_nuTVP] - 2) # * ( # nu_p[0, index_above_nuTVP] # + nu[0, index_above_nuTVP] * (rho_p[0, index_above_nuTVP] - 1) # ) # / (nu[0, index_above_nuTVP] + nu_p[0, index_above_nuTVP]) ** 2 ) index_outside_TVP = np.where(Omega_in_fromout - Omegae_lims > 0) Omega_in_fromout_regIII = -Omega_out + nu * Omega_out**2 Omega_in_fromout[index_outside_TVP] = Omega_in_fromout_regIII[ index_outside_TVP ] E[index_outside_TVP] = ( -1 + 2 * nu[0, index_outside_TVP[1]] * Omega_out[index_outside_TVP[0], 0] ) # Build matrix C: N1 = np.int64(np.floor(Omega_in_fromout / dOmega_in)) N2 = N1 + 1 A = np.zeros((NOmega_out, Nbeta), dtype=np.float32) B = np.zeros((NOmega_out, Nbeta), dtype=np.float32) A[N2 < NOmega_in] = ( 1 / dOmega_in * (Omega_in[N2[N2 < NOmega_in], 0] - Omega_in_fromout[N2 < NOmega_in]) ) B[N2 < NOmega_in] = ( 1 / dOmega_in * (Omega_in_fromout[N2 < NOmega_in] - Omega_in[N1[N2 < NOmega_in], 0]) ) N1[N2 >= NOmega_in] = 0 N2[N2 >= NOmega_in] = 0 rows, cols, data = [], [], [] for k in range(NOmega_out): for p in range(Nbeta): rows.append(k * Nbeta + p) cols.append(N1[k, p] * Nbeta + p) data.append(B[k, p]) rows.append(k * Nbeta + p) cols.append(N2[k, p] * Nbeta + p) data.append(A[k, p]) C = bsr_array((data, (rows, cols)), shape=(NOmega_out * Nbeta, NOmega_in * Nbeta)) # Build matrices D_wave: D_wave = dia_array( (np.reshape(E, (-1,)), np.array([0])), shape=(NOmega_out * Nbeta, NOmega_out * Nbeta), ) # Build sparse block diagonal array S data = np.tile(dbeta, NOmega_out * Nbeta) rows = np.repeat(np.arange(NOmega_out), Nbeta) cols = np.arange(NOmega_out * Nbeta) S = bsr_array((data, (rows, cols)), shape=(NOmega_out, NOmega_out * Nbeta)) return Omega_in_fromout, C, D_wave, S