• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

sandialabs / WecOptTool / 9120563447

16 May 2024 11:34PM UTC coverage: 94.538% (-0.09%) from 94.631%
9120563447

Pull #342

github

web-flow
Merge c8912d06b into c21878006
Pull Request #342: ENHANCEMENT: Implemented cyipopt minimize_ipopt

408 of 423 new or added lines in 7 files covered. (96.45%)

11 existing lines in 1 file now uncovered.

2752 of 2911 relevant lines covered (94.54%)

5.67 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.55
/wecopttool/core.py
1
"""Core functionality for solving the pseudo-spectral problem for WEC.
2

3
Contains:
4

5
* The *WEC* class
6
* Functions for basic functionality
7

8
.. note:: All contents of this module are imported into *WecOpTool* and
9
          can be called directly as :python:`wecopttool.<function>`
10
          instead of :python:`wecopttool.core.<function>`.
11
"""
12

13

14
from __future__ import annotations
6✔
15

16

17
__all__ = [
6✔
18
    "WEC",
19
    "ncomponents",
20
    "frequency",
21
    "time",
22
    "time_mat",
23
    "derivative_mat",
24
    "derivative2_mat",
25
    "mimo_transfer_mat",
26
    "vec_to_dofmat",
27
    "dofmat_to_vec",
28
    "real_to_complex",
29
    "complex_to_real",
30
    "fd_to_td",
31
    "td_to_fd",
32
    "read_netcdf",
33
    "write_netcdf",
34
    "check_radiation_damping",
35
    "check_impedance",
36
    "force_from_rao_transfer_function",
37
    "force_from_impedance",
38
    "force_from_waves",
39
    "inertia",
40
    "standard_forces",
41
    "run_bem",
42
    "change_bem_convention",
43
    "add_linear_friction",
44
    "wave_excitation",
45
    "hydrodynamic_impedance",
46
    "atleast_2d",
47
    "degrees_to_radians",
48
    "subset_close",
49
    "scale_dofs",
50
    "decompose_state",
51
    "frequency_parameters",
52
    "time_results",
53
    "set_fb_centers",
54
]
55

56

57
import logging
6✔
58
from typing import Iterable, Callable, Any, Optional, Mapping, TypeVar, Union
6✔
59
from pathlib import Path
6✔
60
import warnings
6✔
61
from datetime import datetime
6✔
62

63
from numpy.typing import ArrayLike
6✔
64
import autograd.numpy as np
6✔
65
from autograd.numpy import ndarray
6✔
66
from autograd.builtins import isinstance, tuple, list, dict
6✔
67
from autograd import grad, jacobian
6✔
68
import xarray as xr
6✔
69
from xarray import DataArray, Dataset
6✔
70
import capytaine as cpy
6✔
71
from scipy.optimize import minimize, OptimizeResult, Bounds
6✔
72
from scipy.linalg import block_diag, dft
6✔
73
from cyipopt import minimize_ipopt
6✔
74

75
# logger
76
_log = logging.getLogger(__name__)
6✔
77

78
# autograd warnings
79
filter_msg = "Casting complex values to real discards the imaginary part"
6✔
80
warnings.filterwarnings("ignore", message=filter_msg)
6✔
81

82
# default values
83
_default_parameters = {'rho': 1025.0, 'g': 9.81, 'depth': np.infty}
6✔
84
_default_min_damping = 1e-6
6✔
85

86
# type aliases
87
TWEC = TypeVar("TWEC", bound="WEC")
6✔
88
TStateFunction = Callable[
6✔
89
    [TWEC, ndarray, ndarray, Dataset], ndarray]
90
TForceDict = dict[str, TStateFunction]
6✔
91
TIForceDict = Mapping[str, TStateFunction]
6✔
92
FloatOrArray = Union[float, ArrayLike]
6✔
93

94

95
class WEC:
6✔
96
    """A wave energy converter (WEC) object for performing simulations
97
    using the pseudo-spectral solution method.
98

99
    To create the WEC use one of the initialization methods:
100

101
    * :py:meth:`wecopttool.WEC.__init__`
102
    * :py:meth:`wecopttool.WEC.from_bem`
103
    * :py:meth:`wecopttool.WEC.from_floating_body`
104
    * :py:meth:`wecopttool.WEC.from_impedance`.
105

106
    .. note:: Direct initialization of a :py:class:`wecopttool.WEC`
107
        object as :python:`WEC(f1, nfreq, forces, ...)` using
108
        :py:meth:`wecopttool.WEC.__init__` is discouraged. Instead
109
        use one of the other initialization methods listed in the
110
        *See Also* section.
111

112
    To solve the pseudo-spectral problem use
113
    :py:meth:`wecopttool.WEC.solve`.
114
    """
115
    def __init__(
6✔
116
        self,
117
        f1: float,
118
        nfreq: int,
119
        forces: TIForceDict,
120
        constraints: Optional[Iterable[Mapping]] = None,
121
        inertia_matrix: Optional[ndarray] = None,
122
        ndof: Optional[int] = None,
123
        inertia_in_forces: Optional[bool] = False,
124
        dof_names: Optional[Iterable[str]] = None,
125
        ) -> None:
126
        """Create a WEC object directly from its inertia matrix and
127
        list of forces.
128

129
        The :py:class:`wecopttool.WEC` class describes a WEC's
130
        equation of motion as :math:`ma=Σf` where the
131
        :python:`inertia_matrix` matrix specifies the inertia :math:`m`,
132
        and the :python:`forces` dictionary specifies the different
133
        forces to be summed. The forces can be linear or nonlinear.
134
        If :python:`inertia_in_forces is True` the equation of motion is
135
        :math:`Σf=0`, which is included to allow for initialization
136
        using an intrinsic impedance through the
137
        :python:`WEC.from_impedance` initialization function.
138

139
        .. note:: Direct initialization of a
140
            :py:class:`wecopttool.WEC` object as
141
            :python:`WEC(f1, nfreq, forces, ...)` is discouraged.
142
            Instead use one of the other initialization methods listed
143
            in the *See Also* section.
144

145
        Parameters
146
        ----------
147
        f1
148
            Fundamental frequency :python:`f1` [:math:`Hz`].
149
        nfreq
150
            Number of frequencies (not including zero frequency),
151
            i.e., :python:`freqs = [0, f1, 2*f1, ..., nfreq*f1]`.
152
        forces
153
            Dictionary with entries :python:`{'force_name': fun}`,
154
            where :python:`fun` has a  signature
155
            :python:`def fun(wec, x_wec, x_opt, waves):`, and returns
156
            forces in the time-domain of size
157
            :python:`(2*nfreq+1, ndof)`.
158
        constraints
159
            List of constraints, see documentation for
160
            :py:func:`scipy.optimize.minimize` for description and
161
            options of constraints dictionaries.
162
            If :python:`None`: empty list :python:`[]`.
163
        inertia_matrix
164
           Inertia matrix of size :python:`(ndof, ndof)`.
165
           Not used if :python:`inertia_in_forces` is :python:`True`.
166
        ndof
167
            Number of degrees of freedom.
168
            Must be specified if :python:`inertia_in_forces is True`,
169
            else not used.
170
        inertia_in_forces
171
            Set to True if inertial "forces" are included in the
172
            :python:`forces` argument.
173
            This scenario is rare.
174
            If using an intrinsic impedance, consider initializing with
175
            :py:meth:`wecoptool.core.WEC.from_impedance` instead.
176
        dof_names
177
            Names of the different degrees of freedom (e.g.
178
            :python:`'Heave'`).
179
            If :python:`None` the names
180
            :python:`['DOF_0', ..., 'DOF_N']` are used.
181

182
        Raises
183
        ------
184
        ValueError
185
            If :python:`inertia_in_forces is True` but :python:`ndof` is
186
            not specified.
187
        ValueError
188
            If :python:`inertia_in_forces is False` but
189
            :python:`inertia_matrix` is not specified.
190
        ValueError
191
            If :python:`inertia_matrix` does not have the correct size
192
            (:python:`(ndof, ndof)`).
193
        ValueError
194
            If :python:`dof_names` does not have the correct size
195
            (:python:`ndof`).
196

197
        See Also
198
        --------
199
        from_bem:
200
            Initialize a :py:class:`wecopttool.WEC` object from BEM
201
            results.
202
        from_floating_body:
203
            Initialize a :py:class:`wecopttool.WEC` object from a
204
            :py:class:`capytaine.bodies.bodies.FloatingBody` object.
205
        from_impedance:
206
            Initialize a :py:class:`wecopttool.WEC` object from an
207
            intrinsic impedance array and excitation coefficients.
208
        """
209
        self._freq = frequency(f1, nfreq)
6✔
210
        self._time = time(f1, nfreq)
6✔
211
        self._time_mat = time_mat(f1, nfreq)
6✔
212
        self._derivative_mat = derivative_mat(f1, nfreq)
6✔
213
        self._derivative2_mat = derivative2_mat(f1, nfreq)
6✔
214
        self._forces = forces
6✔
215
        constraints = list(constraints) if (constraints is not None) else []
6✔
216
        self._constraints = constraints
6✔
217

218
        # inertia options
219
        self._inertia_in_forces = inertia_in_forces
6✔
220

221
        def _missing(var_name, condition):
6✔
222
            msg = (f"'{var_name}' must be provided if 'inertia_in_forces' is" +
×
223
                    f"'{condition}'.")
224
            return msg
×
225

226
        def _ignored(var_name, condition):
6✔
227
            msg = (f"'{var_name}' is not used when 'inertia_in_forces' is " +
×
228
                    f"'{condition}' and should not be provided")
229
            return msg
×
230

231
        if inertia_in_forces:
6✔
232
            condition = "True"
6✔
233
            if inertia_matrix is not None:
6✔
234
                _log.warning(_ignored("inertia_matrix", condition))
×
235
                inertia_matrix = None
×
236
            if ndof is None:
6✔
237
                raise ValueError(_missing("ndof", condition))
×
238
        elif not inertia_in_forces:
6✔
239
            condition = "False"
6✔
240
            if inertia_matrix is None:
6✔
241
                raise ValueError(_missing("inertia_matrix", condition))
×
242
            inertia_matrix = np.atleast_2d(np.squeeze(inertia_matrix))
6✔
243
            if ndof is not None:
6✔
244
                _log.warning(_ignored("ndof", condition))
×
245
                if ndof != inertia_matrix.shape[0]:
×
246
                    _log.warning(
×
247
                        "Provided value of 'ndof' does not match size of " +
248
                        "'inertia_matrix'. Setting " +
249
                        f"'ndof={inertia_matrix.shape[0]}'.")
250
            ndof = inertia_matrix.shape[0]
6✔
251

252
            if inertia_matrix.shape != (ndof, ndof):
6✔
253
                raise ValueError(
×
254
                    "'inertia_matrix' must be a square matrix of size equal " +
255
                    "to the number of degrees of freedom.")
256
        self._inertia_matrix = inertia_matrix
6✔
257
        self._ndof = ndof
6✔
258
        if inertia_in_forces:
6✔
259
            _inertia = None
6✔
260
        else:
261
            _inertia = inertia(f1, nfreq, inertia_matrix)
6✔
262
        self._inertia = _inertia
6✔
263

264
        # names
265
        if dof_names is None:
6✔
266
            dof_names = [f'DOF_{i}' for i in range(ndof)]
6✔
267
        elif len(dof_names) != ndof:
×
268
            raise ValueError("'dof_names' must have length 'ndof'.")
×
269
        self._dof_names = list(dof_names)
6✔
270

271
    def __str__(self) -> str:
6✔
272
        str = (f'{self.__class__.__name__}: ' +
×
273
               f'DOFs ({self.ndof})={self.dof_names}, ' +
274
               f'f=[0, {self.f1}, ..., {self.nfreq}({self.f1})] Hz.')
275
        return str
×
276

277
    def __repr__(self) -> str:
6✔
278
        type_ = type(self)
×
279
        module = type_.__module__
×
280
        qualname = type_.__qualname__
×
281
        repr_org = f"<{module}.{qualname} object at {hex(id(self))}>"
×
282
        return repr_org + " :: " + self.__str__()
×
283

284
    # other initialization methods
285
    @staticmethod
6✔
286
    def from_bem(
6✔
287
        bem_data: Union[Dataset, Union[str, Path]],
288
        friction: Optional[ndarray] = None,
289
        f_add: Optional[TIForceDict] = None,
290
        constraints: Optional[Iterable[Mapping]] = None,
291
        min_damping: Optional[float] = _default_min_damping,
292
        uniform_shift: Optional[bool] = False,
293
        dof_names: Optional[Iterable[str]] = None,
294
        ) -> TWEC:
295
        """Create a WEC object from linear hydrodynamic coefficients
296
        obtained using the boundary element method (BEM) code Capytaine.
297

298
        The :python:`bem_data` can be a dataset or the name of a
299
        *NetCDF* file containing the dataset.
300

301
        The returned :py:class:`wecopttool.WEC` object contains the
302
        inertia and the default linear forces: radiation, diffraction,
303
        and Froude-Krylov. Additional forces can be specified through
304
        :python:`f_add`.
305

306
        Note that because Capytaine uses a different sign convention,
307
        the direct results from capytaine must be modified using
308
        :py:func:`wecopttool.change_bem_convention` before calling
309
        this initialization function.
310
        Instead, the recommended approach is to use
311
        :py:func:`wecopttool.run_bem`,
312
        rather than running Capytaine directly, which outputs the
313
        results in the correct convention. The results can be saved
314
        using :py:func:`wecopttool.write_netcdf`.
315
        :py:func:`wecopttool.run_bem` also computes the inertia and
316
        hydrostatic stiffness which should be included in bem_data.
317

318
        Parameters
319
        ----------
320
        bem_data
321
            Linear hydrodynamic coefficients obtained using the boundary
322
            element method (BEM) code Capytaine, with sign convention
323
            corrected. Also includes inertia and hydrostatic stiffness.
324
        friction
325
            Linear friction, in addition to radiation damping, of size
326
            :python:`(ndof, ndof)`.
327
            :python:`None` if included in :python:`bem_data` or to set
328
            to zero.
329
        f_add
330
            Dictionary with entries :python:`{'force_name': fun}`, where
331
            :python:`fun` has a  signature
332
            :python:`def fun(wec, x_wec, x_opt, waves):`, and returns
333
            forces in the time-domain of size
334
            :python:`(2*nfreq+1, ndof)`.
335
        constraints
336
            List of constraints, see documentation for
337
            :py:func:`scipy.optimize.minimize` for description and
338
            options of constraints dictionaries.
339
            If :python:`None`: empty list :python:`[]`.
340
        min_damping
341
            Minimum damping level to ensure a stable system.
342
            See :py:func:`wecopttool.check_radiation_damping` for more details.
343
        uniform_shift
344
            Boolean determining whether damping corrections shifts the damping
345
            values uniformly for all frequencies or only for frequencies below
346
            :python:`min_damping`.
347
            See :py:func:`wecopttool.check_radiation_damping` for more details.
348
        dof_names
349
            Names of the different degrees of freedom (e.g.
350
            :python:`'Heave'`).
351
            If :python:`None` the names
352
            :python:`['DOF_0', ..., 'DOF_N']` are used.
353

354
        See Also
355
        --------
356
        run_bem, add_linear_friction, change_bem_convention,
357
        write_netcdf, check_radiation_damping
358
        """
359
        if isinstance(bem_data, (str, Path)):
6✔
360
            bem_data = read_netcdf(bem_data)
×
361
        # add friction
362
        hydro_data = add_linear_friction(bem_data, friction)
6✔
363
        inertia_matrix = hydro_data['inertia_matrix'].values
6✔
364

365
        # frequency array
366
        f1, nfreq = frequency_parameters(
6✔
367
            hydro_data.omega.values/(2*np.pi), False)
368

369
        # check real part of damping diagonal > 0
370
        if min_damping is not None:
6✔
371
            hydro_data = check_radiation_damping(
6✔
372
                hydro_data, min_damping, uniform_shift)
373

374
        # forces in the dynamics equations
375
        linear_force_functions = standard_forces(hydro_data)
6✔
376
        f_add = f_add if (f_add is not None) else {}
6✔
377
        forces = linear_force_functions | f_add
6✔
378
        # constraints
379
        constraints = constraints if (constraints is not None) else []
6✔
380
        return WEC(f1, nfreq, forces, constraints, inertia_matrix, dof_names=dof_names)
6✔
381

382
    @staticmethod
6✔
383
    def from_floating_body(
6✔
384
        fb: cpy.FloatingBody,
385
        f1: float,
386
        nfreq: int,
387
        friction: Optional[ndarray] = None,
388
        f_add: Optional[TIForceDict] = None,
389
        constraints: Optional[Iterable[Mapping]] = None,
390
        min_damping: Optional[float] = _default_min_damping,
391
        wave_directions: Optional[ArrayLike] = np.array([0.0,]),
392
        rho: Optional[float] = _default_parameters['rho'],
393
        g: Optional[float] = _default_parameters['g'],
394
        depth: Optional[float] = _default_parameters['depth'],
395
    ) -> TWEC:
396
        """Create a WEC object from a Capytaine :python:`FloatingBody`
397
        (:py:class:capytaine.bodies.bodies.FloatingBody).
398

399
        A :py:class:capytaine.bodies.bodies.FloatingBody object contains
400
        information on the mesh and degrees of freedom.
401

402
        This initialization method calls :py:func:`wecopttool.run_bem`
403
        followed by :py:meth:`wecopttool.WEC.from_bem`.
404

405
        This will run Capytaine to obtain the linear hydrodynamic
406
        coefficients, which can take from a few minutes to several
407
        hours.
408
        Instead, if the hydrodynamic coefficients can be reused, it is
409
        recommended to run Capytaine first and save the results using
410
        :py:func:`wecopttool.run_bem` and
411
        :py:func:`wecopttool.write_netcdf`,
412
        and then initialize the :py:class:`wecopttool.WEC` object
413
        using :py:meth:`wecopttool.WEC.from_bem`.
414
        This initialization method should be
415
        reserved for the cases where the hydrodynamic coefficients
416
        constantly change and are not reused, as for example for
417
        geometry optimization.
418

419
        Parameters
420
        ----------
421
        fb
422
            Capytaine FloatingBody.
423
        f1
424
            Fundamental frequency :python:`f1` [:math:`Hz`].
425
        nfreq
426
            Number of frequencies (not including zero frequency),
427
            i.e., :python:`freqs = [0, f1, 2*f1, ..., nfreq*f1]`.
428
        friction
429
            Linear friction, in addition to radiation damping, of size
430
            :python:`(ndof, ndof)`.
431
            :python:`None` to set to zero.
432
        f_add
433
            Dictionary with entries :python:`{'force_name': fun}`, where
434
            :python:`fun` has a  signature
435
            :python:`def fun(wec, x_wec, x_opt, waves):`, and returns
436
            forces in the time-domain of size
437
            :python:`(2*nfreq, ndof)`.
438
        constraints
439
            List of constraints, see documentation for
440
            :py:func:`scipy.optimize.minimize` for description and
441
            options of constraints dictionaries.
442
            If :python:`None`: empty list :python:`[]`.
443
        min_damping
444
            Minimum damping level to ensure a stable system.
445
            See :py:func:`wecopttool.check_radiation_damping` for
446
            more details.
447
        wave_directions
448
            List of wave directions [degrees] to evaluate BEM at.
449
        rho
450
            Water density in :math:`kg/m^3`.
451
        g
452
            Gravitational acceleration in :math:`m/s^2`.
453
        depth
454
            Water depth in :math:`m`.
455

456
        Returns
457
        -------
458
        WEC
459
            An instance of the :py:class:`wecopttool.WEC` class.
460

461
        See Also
462
        --------
463
        run_bem, write_netcdf, WEC.from_bem
464
        """
465

466
        # RUN BEM
467
        _log.info(f"Running Capytaine (BEM): {nfreq+1} frequencies x " +
6✔
468
                 f"{len(wave_directions)} wave directions.")
469
        freq = frequency(f1, nfreq)[1:]
6✔
470
        bem_data = run_bem(
6✔
471
            fb, freq, wave_directions, rho=rho, g=g, depth=depth)
472
        wec = WEC.from_bem(
6✔
473
            bem_data, friction, f_add,
474
            constraints, min_damping=min_damping)
475
        return wec
6✔
476

477
    @staticmethod
6✔
478
    def from_impedance(
6✔
479
        freqs: ArrayLike,
480
        impedance: DataArray,
481
        exc_coeff: DataArray,
482
        hydrostatic_stiffness: ndarray,
483
        f_add: Optional[TIForceDict] = None,
484
        constraints: Optional[Iterable[Mapping]] = None,
485
        min_damping: Optional[float] = _default_min_damping,
486
        uniform_shift: Optional[bool] = False,
487
    ) -> TWEC:
488
        """Create a WEC object from the intrinsic impedance and
489
        excitation coefficients.
490

491
        The intrinsic (mechanical) impedance :math:`Z(ω)` linearly
492
        relates excitation forces :math:`F(ω)` to WEC velocity
493
        :math:`U(ω)` as :math:`ZU=F`.
494
        Using linear hydrodynamic coefficients, e.g. from a BEM code
495
        like Capytaine, the impedance is given as
496
        :math:`Z(ω) = (m+A(ω))*iω + B(ω) + B_f + K/(iω)`.
497
        The impedance can also be obtained experimentally.
498
        Note that the impedance is not defined at :math:`ω=0`.
499

500

501
        Parameters
502
        ----------
503
        freqs
504
            Frequency vector [:math:`Hz`] not including the zero frequency,
505
            :python:`freqs = [f1, 2*f1, ..., nfreq*f1]`.
506
        impedance
507
            Complex impedance of size :python:`(nfreq, ndof, ndof)`.
508
        exc_coeff
509
            Complex excitation transfer function of size
510
            :python:`(ndof, nfreq)`.
511
        hydrostatic_stiffness
512
            Linear hydrostatic restoring coefficient of size
513
            :python:`(ndof, ndof)`.
514
        f_add
515
            Dictionary with entries :python:`{'force_name': fun}`, where
516
            :python:`fun` has a  signature
517
            :python:`def fun(wec, x_wec, x_opt, waves):`, and returns
518
            forces in the time-domain of size
519
            :python:`(2*nfreq, ndof)`.
520
        constraints
521
            List of constraints, see documentation for
522
            :py:func:`scipy.optimize.minimize` for description and
523
            options of constraints dictionaries.
524
            If :python:`None`: empty list :python:`[]`.
525
        min_damping
526
            Minimum damping level to ensure a stable system.
527
            See :py:func:`wecopttool.check_impedance` for
528
            more details.
529
        uniform_shift
530
            Boolean determining whether damping corrections shifts the damping
531
            values uniformly for all frequencies or only for frequencies below
532
            :python:`min_damping`.
533
            See :py:func:`wecopttool.check_radiation_damping` for more details.
534

535
        Raises
536
        ------
537
        ValueError
538
            If :python:`impedance` does not have the correct size:
539
            :python:`(ndof, ndof, nfreq)`.
540
        """
541
        f1, nfreq = frequency_parameters(freqs, False)
6✔
542

543
        # impedance matrix shape
544
        shape = impedance.shape
6✔
545
        ndim = impedance.ndim
6✔
546
        if (ndim!=3) or (shape[1]!=shape[2]) or (shape[0]!=nfreq):
6✔
547
            raise ValueError(
×
548
                "'impedance' must have shape '(nfreq, ndof, ndof)'.")
549

550
        impedance = check_impedance(impedance, min_damping)
6✔
551

552
        # impedance force
553
        omega = freqs * 2*np.pi
6✔
554
        omega0 = np.expand_dims(omega, [1,2])
6✔
555
        transfer_func = impedance * (1j*omega0)
6✔
556
        transfer_func0 = np.expand_dims(hydrostatic_stiffness, 2)
6✔
557
        transfer_func = np.concatenate([transfer_func0, transfer_func], axis=0)
6✔
558
        transfer_func = -1 * transfer_func  # RHS of equation: ma = Σf
6✔
559
        force_impedance = force_from_rao_transfer_function(transfer_func)
6✔
560

561
        # excitation force
562
        force_excitation = force_from_waves(exc_coeff)
6✔
563

564
        # all forces
565
        f_add = {} if (f_add is None) else f_add
6✔
566
        forces =  {
6✔
567
            'intrinsic_impedance': force_impedance,
568
            'excitation': force_excitation
569
        }
570
        forces = forces | f_add
6✔
571

572
        # wec
573
        wec = WEC(f1, nfreq, forces, constraints,
6✔
574
                  inertia_in_forces=True, ndof=shape[1])
575
        return wec
6✔
576

577
    def residual(self, x_wec: ndarray, x_opt: ndarray, waves: Dataset,
6✔
578
        ) -> float:
579
        """
580
        Return the residual of the dynamic equation (r = m⋅a-Σf).
581

582
        Parameters
583
        ----------
584
        x_wec
585
            WEC state vector.
586
        x_opt
587
            Optimization (control) state.
588
        waves
589
            :py:class:`xarray.Dataset` with the structure and elements
590
            shown by :py:mod:`wecopttool.waves`.
591
        """
592
        if not self.inertia_in_forces:
6✔
593
            ri = self.inertia(self, x_wec, x_opt, waves)
6✔
594
        else:
595
            ri = np.zeros([self.ncomponents, self.ndof])
6✔
596
        # forces, -Σf
597
        for f in self.forces.values():
6✔
598
            ri = ri - f(self, x_wec, x_opt, waves)
6✔
599
        return self.dofmat_to_vec(ri)
6✔
600

601
    # solve
602
    def solve(self,
6✔
603
        waves: Dataset,
604
        obj_fun: TStateFunction,
605
        nstate_opt: int,
606
        x_wec_0: Optional[ndarray] = None,
607
        x_opt_0: Optional[ndarray] = None,
608
        scale_x_wec: Optional[list] = None,
609
        scale_x_opt: Optional[FloatOrArray] = 1.0,
610
        scale_obj: Optional[float] = 1.0,
611
        optim_options: Optional[Mapping[str, Any]] = {},
612
        use_grad: Optional[bool] = True,
613
        maximize: Optional[bool] = False,
614
        bounds_wec: Optional[Bounds] = None,
615
        bounds_opt: Optional[Bounds] = None,
616
        callback: Optional[TStateFunction] = None,
617
        ) -> list[OptimizeResult]:
618
        """Simulate WEC dynamics using a pseudo-spectral solution
619
        method and returns the raw results dictionary produced by
620
        :py:func:`scipy.optimize.minimize`.
621

622
        Parameters
623
        ----------
624
        waves
625
            :py:class:`xarray.Dataset` with the structure and elements
626
            shown by :py:mod:`wecopttool.waves`.
627
        obj_fun
628
            Objective function to minimize for pseudo-spectral solution,
629
            must have signature :python:`fun(wec, x_wec, x_opt, waves)`
630
            and return a scalar.
631
        nstate_opt
632
            Length of the optimization (controls) state vector.
633
        x_wec_0
634
            Initial guess for the WEC dynamics state.
635
            If :python:`None` it is randomly initiated.
636
        x_opt_0
637
            Initial guess for the optimization (control) state.
638
            If :python:`None` it is randomly initiated.
639
        scale_x_wec
640
            Factor(s) to scale each DOF in :python:`x_wec` by, to
641
            improve convergence.
642
            A single float or an array of size :python:`ndof`.
643
        scale_x_opt
644
            Factor(s) to scale :python:`x_opt` by, to improve
645
            convergence.
646
            A single float or an array of size :python:`nstate_opt`.
647
        scale_obj
648
            Factor to scale :python:`obj_fun` by, to improve
649
            convergence.
650
        optim_options
651
            Optimization options passed to the optimizer.
652
            See :py:func:`scipy.optimize.minimize`.
653
        use_grad
654
             If :python:`True`, optimization will utilize
655
             `autograd <https://github.com/HIPS/autograd>`_
656
             for gradients.
657
        maximize
658
            Whether to maximize the objective function.
659
            The default is to minimize the objective function.
660
        bounds_wec
661
            Bounds on the WEC components of the decision variable.
662
            See :py:func:`scipy.optimize.minimize`.
663
        bounds_opt
664
            Bounds on the optimization (control) components of the
665
            decision variable.
666
            See :py:func:`scipy.optimize.minimize`.
667
        callback
668
            Function called after each iteration, must have signature
669
            :python:`fun(wec, x_wec, x_opt, waves)`. The default
670
            provides status reports at each iteration via logging at the
671
            INFO level.
672

673
        Raises
674
        ------
675
        ValueError
676
            If :python:`scale_x_opt` is a scalar and
677
            :python:`nstate_opt` is not provided.
678
        Exception
679
            If the optimizer fails for any reason other than maximum
680
            number of states, i.e. for exit modes other than 0 or 9.
681
            See :py:mod:`scipy.optimize` for exit mode details.
682

683
        Examples
684
        --------
685
        The :py:meth:`wecopttool.WEC.solve` method only returns the
686
        raw results dictionary produced by :py:func:`scipy.optimize.minimize`.
687

688
        >>> res_opt = wec.solve(waves=wave,
689
                                obj_fun=pto.average_power,
690
                                nstate_opt=2*nfreq+1)
691

692
        To get the post-processed results for the :py:class:`wecopttool.WEC`
693
        and :py:class:`wecopttool.pto.PTO` for a single realization, you
694
        may call
695

696
        >>> realization = 0 # realization index
697
        >>> res_wec_fd, res_wec_td = wec.post_process(wec,res_opt,wave,nsubsteps)
698
        >>> res_pto_fd, res_pto_td = pto.post_process(wec,res_opt,wave,nsubsteps)
699

700
        See Also
701
        --------
702
        wecopttool.waves,
703
        wecopttool.core.wec.post_process,
704
        wecopttool.core.pto.post_process,
705
        """
706

707
        results = []
6✔
708

709
        # x_wec scaling vector
710
        if scale_x_wec is None:
6✔
711
            scale_x_wec = [1.0] * self.ndof
6✔
712
        elif isinstance(scale_x_wec, float) or isinstance(scale_x_wec, int):
6✔
713
            scale_x_wec = [scale_x_wec] * self.ndof
6✔
714
        scale_x_wec = scale_dofs(scale_x_wec, self.ncomponents)
6✔
715

716
        # x_opt scaling vector
717
        if isinstance(scale_x_opt, float) or isinstance(scale_x_opt, int):
6✔
718
            if nstate_opt is None:
6✔
719
                raise ValueError("If 'scale_x_opt' is a scalar, " +
×
720
                                    "'nstate_opt' must be provided")
721
            scale_x_opt = scale_dofs([scale_x_opt], nstate_opt)
6✔
722

723
        # composite scaling vector
724
        scale = np.concatenate([scale_x_wec, scale_x_opt])
6✔
725

726
        # decision variable initial guess
727
        if x_wec_0 is None:
6✔
728
            x_wec_0 = np.random.randn(self.nstate_wec)
6✔
729
        if x_opt_0 is None:
6✔
730
            x_opt_0 = np.random.randn(nstate_opt)
6✔
731
        x0 = np.concatenate([x_wec_0, x_opt_0])*scale
6✔
732

733
        # bounds
734
        if (bounds_wec is None) and (bounds_opt is None):
6✔
735
            bounds = None
6✔
736
        else:
737
            bounds_in = [bounds_wec, bounds_opt]
6✔
738
            for idx, bii in enumerate(bounds_in):
6✔
739
                if isinstance(bii, tuple):
6✔
740
                    bounds_in[idx] = Bounds(lb=[xibs[0] for xibs in bii],
6✔
741
                                            ub=[xibs[1] for xibs in bii])
742
            inf_wec = np.ones(self.nstate_wec)*np.inf
6✔
743
            inf_opt = np.ones(nstate_opt)*np.inf
6✔
744
            bounds_dflt = [Bounds(lb=-inf_wec, ub=inf_wec),
6✔
745
                            Bounds(lb=-inf_opt, ub=inf_opt)]
746
            bounds_list = []
6✔
747
            for bi, bd in zip(bounds_in, bounds_dflt):
6✔
748
                if bi is not None:
6✔
749
                    bo = bi
6✔
750
                else:
751
                    bo = bd
6✔
752
                bounds_list.append(bo)
6✔
753
            bounds = Bounds(lb=np.hstack([le.lb for le in bounds_list])*scale,
6✔
754
                            ub=np.hstack([le.ub for le in bounds_list])*scale)
755

756
        for realization, wave in waves.groupby('realization'):
6✔
757

758
            _log.info("Solving pseudo-spectral control problem "
6✔
759
                      + f"for realization number {realization}.")
760

761
            # objective function
762
            sign = -1.0 if maximize else 1.0
6✔
763

764
            def obj_fun_scaled(x):
6✔
765
                x_wec, x_opt = self.decompose_state(x/scale)
6✔
766
                return obj_fun(self, x_wec, x_opt, wave)*scale_obj*sign
6✔
767

768
            # constraints
769
            constraints = self.constraints.copy()
6✔
770

771
            for i, icons in enumerate(self.constraints):
6✔
772
                icons_new = {"type": icons["type"]}
6✔
773

774
                def make_new_fun(icons):
6✔
775
                    def new_fun(x):
6✔
776
                        x_wec, x_opt = self.decompose_state(x/scale)
6✔
777
                        return icons["fun"](self, x_wec, x_opt, wave)
6✔
778
                    return new_fun
6✔
779

780
                icons_new["fun"] = make_new_fun(icons)
6✔
781
                if use_grad:
6✔
782
                    icons_new['jac'] = jacobian(icons_new['fun'])
6✔
783
                constraints[i] = icons_new
6✔
784

785
            # system dynamics through equality constraint, ma - Σf = 0
786
            def scaled_resid_fun(x):
6✔
787
                x_s = x/scale
6✔
788
                x_wec, x_opt = self.decompose_state(x_s)
6✔
789
                return self.residual(x_wec, x_opt, wave)
6✔
790

791
            eq_cons = {'type': 'eq', 'fun': scaled_resid_fun}
6✔
792
            if use_grad:
6✔
793
                eq_cons['jac'] = jacobian(scaled_resid_fun)
6✔
794
            constraints.append(eq_cons)
6✔
795

796
            # optimization problem
797
            optim_options['disp'] = optim_options.get('disp', True)
6✔
798
            problem = {'fun': obj_fun_scaled,
6✔
799
                        'x0': x0,
800
                        'method': 'SLSQP',
801
                        'constraints': constraints,
802
                        'options': optim_options,
803
                        'bounds': bounds,
804
                        }
805
            if use_grad:
6✔
806
                problem['jac'] = grad(obj_fun_scaled)
6✔
807

808
            # minimize
809
            optim_res = minimize_ipopt(**problem)
6✔
810

811
            msg = f'{optim_res.message}    (Exit mode {optim_res.status})'
6✔
812
            if optim_res.status == 0:
6✔
813
                _log.info(msg)
6✔
UNCOV
814
            elif optim_res.status == 9:
×
UNCOV
815
                _log.warning(msg)
×
816
            else:
817
                raise Exception(msg)
×
818

819
            # unscale
820
            optim_res.x = optim_res.x / scale
6✔
821
            optim_res.fun = optim_res.fun / scale_obj
6✔
822
            optim_res.jac = optim_res.jac / scale_obj * scale
6✔
823

824
            results.append(optim_res)
6✔
825

826
        return results
6✔
827

828
    def post_process(self,
6✔
829
        wec: TWEC,
830
        res: Union[OptimizeResult, Iterable],
831
        waves: Dataset,
832
        nsubsteps: Optional[int] = 1,
833
    ) -> tuple[list[Dataset], list[Dataset]]:
834
        """Post-process the results from :py:meth:`wecopttool.WEC.solve`.
835

836
        Parameters
837
        ----------
838
        wec
839
            :py:class:`wecopttool.WEC` object.
840
        res
841
            Results produced by :py:meth:`wecopttool.WEC.solve`.
842
        waves
843
            :py:class:`xarray.Dataset` with the structure and elements
844
            shown by :py:mod:`wecopttool.waves`.
845
        nsubsteps
846
            Number of steps between the default (implied) time steps.
847
            A value of :python:`1` corresponds to the default step
848
            length.
849

850
        Returns
851
        -------
852
        results_fd
853
            Dynamic responses in the frequency-domain.
854
        results_td
855
            Dynamic responses in the time-domain.
856

857
        Examples
858
        --------
859
        The :py:meth:`wecopttool.WEC.solve` method only returns the
860
        raw results dictionary produced by :py:func:`scipy.optimize.minimize`.
861

862
        >>> res_opt = wec.solve(waves=wave,
863
                                obj_fun=pto.average_power,
864
                                nstate_opt=2*nfreq+1)
865

866
        To get the post-processed results we may call
867

868
        >>> res_wec_fd, res_wec_td = wec.post_process(wec, res_opt,wave)
869

870
        Note that :py:meth:`wecopttool.WEC.solve` method produces a list of
871
        results objects (one for each phase realization).
872
        """
873
        assert self == wec , ("The same wec object should be used to call " +
6✔
874
                                "post-process and be passed as an input.")
875

876
        def _postproc(res, waves, nsubsteps):
6✔
877
            create_time = f"{datetime.utcnow()}"
6✔
878

879
            omega_vals = np.concatenate([[0], waves.omega.values])
6✔
880
            freq_vals = np.concatenate([[0], waves.freq.values])
6✔
881
            period_vals = np.concatenate([[np.inf], 1/waves.freq.values])
6✔
882
            pos_attr = {'long_name': 'Position', 'units': 'm or rad'}
6✔
883
            vel_attr = {'long_name': 'Velocity', 'units': 'm/s or rad/s'}
6✔
884
            acc_attr = {'long_name': 'Acceleration', 'units': 'm/s^2 or rad/s^2'}
6✔
885
            omega_attr = {'long_name': 'Radial frequency', 'units': 'rad/s'}
6✔
886
            freq_attr = {'long_name': 'Frequency', 'units': 'Hz'}
6✔
887
            period_attr = {'long_name': 'Period', 'units': 's'}
6✔
888
            time_attr = {'long_name': 'Time', 'units': 's'}
6✔
889
            dof_attr = {'long_name': 'Degree of freedom'}
6✔
890
            force_attr = {'long_name': 'Force or moment', 'units': 'N or Nm'}
6✔
891
            wave_elev_attr = {'long_name': 'Wave elevation', 'units': 'm'}
6✔
892
            x_wec, x_opt = self.decompose_state(res.x)
6✔
893
            omega_coord = ("omega", omega_vals, omega_attr)
6✔
894
            freq_coord = ("omega", freq_vals, freq_attr)
6✔
895
            period_coord = ("omega", period_vals, period_attr)
6✔
896
            dof_coord = ("influenced_dof", self.dof_names, dof_attr)
6✔
897

898
            # frequency domain
899
            force_da_list = []
6✔
900
            for name, force in self.forces.items():
6✔
901
                force_td_tmp = force(self, x_wec, x_opt, waves)
6✔
902
                force_fd = self.td_to_fd(force_td_tmp)
6✔
903
                force_da = DataArray(data=force_fd,
6✔
904
                                    dims=["omega", "influenced_dof"],
905
                                    coords={
906
                                        'omega': omega_coord,
907
                                        'freq': freq_coord,
908
                                        'period': period_coord,
909
                                        'influenced_dof': dof_coord},
910
                                    attrs=force_attr
911
                                    ).expand_dims({'type': [name]})
912
                force_da_list.append(force_da)
6✔
913

914
            fd_forces = xr.concat(force_da_list, dim='type')
6✔
915
            fd_forces.type.attrs['long_name'] = 'Type'
6✔
916
            fd_forces.name = 'force'
6✔
917
            fd_forces.attrs['long_name'] = 'Force'
6✔
918

919
            pos = self.vec_to_dofmat(x_wec)
6✔
920
            pos_fd = real_to_complex(pos)
6✔
921

922
            vel = self.derivative_mat @ pos
6✔
923
            vel_fd = real_to_complex(vel)
6✔
924

925
            acc = self.derivative2_mat @ pos
6✔
926
            acc_fd = real_to_complex(acc)
6✔
927

928
            fd_state = Dataset(
6✔
929
                data_vars={
930
                    'pos': (['omega', 'influenced_dof'], pos_fd, pos_attr),
931
                    'vel': (['omega', 'influenced_dof'], vel_fd, vel_attr),
932
                    'acc': (['omega', 'influenced_dof'], acc_fd, acc_attr)},
933
                coords={
934
                    'omega': omega_coord,
935
                    'freq': freq_coord,
936
                    'period': period_coord,
937
                    'influenced_dof': dof_coord},
938
                attrs={"time_created_utc": create_time}
939
            )
940

941
            results_fd = xr.merge([fd_state, fd_forces, waves])
6✔
942
            results_fd = results_fd.transpose('omega', 'influenced_dof', 'type',
6✔
943
                                            'wave_direction')
944
            results_fd = results_fd.fillna(0)
6✔
945

946
            # time domain
947
            t_dat = self.time_nsubsteps(nsubsteps)
6✔
948
            time = DataArray(
6✔
949
                data=t_dat, name='time', dims='time', coords=[t_dat])
950
            results_td = results_fd.map(lambda x: time_results(x, time))
6✔
951

952
            results_td['pos'].attrs = pos_attr
6✔
953
            results_td['vel'].attrs = vel_attr
6✔
954
            results_td['acc'].attrs = acc_attr
6✔
955
            results_td['wave_elev'].attrs = wave_elev_attr
6✔
956
            results_td['force'].attrs = force_attr
6✔
957
            results_td['time'].attrs = time_attr
6✔
958
            results_td.attrs['time_created_utc'] = create_time
6✔
959
            return results_fd, results_td
6✔
960

961
        results_fd = []
6✔
962
        results_td = []
6✔
963
        for idx, ires in enumerate(res):
6✔
964
            ifd, itd = _postproc(ires, waves.sel(realization=idx), nsubsteps)
6✔
965
            results_fd.append(ifd)
6✔
966
            results_td.append(itd)
6✔
967
        return results_fd, results_td
6✔
968

969
    # properties
970
    @property
6✔
971
    def forces(self) -> TForceDict:
6✔
972
        """Dictionary of forces."""
973
        return self._forces
6✔
974

975
    @forces.setter
6✔
976
    def forces(self, val):
6✔
977
        self._forces = dict(val)
×
978

979
    @property
6✔
980
    def constraints(self) -> list[dict]:
6✔
981
        """List of constraints."""
982
        return self._constraints
6✔
983

984
    @constraints.setter
6✔
985
    def constraints(self, val):
6✔
986
        self._constraints = list(val)
×
987

988
    @property
6✔
989
    def inertia_in_forces(self) -> bool:
6✔
990
        """Whether inertial "forces" are included in the
991
        :python:`forces` dictionary.
992
        """
993
        return self._inertia_in_forces
6✔
994

995
    @property
6✔
996
    def inertia_matrix(self) -> ndarray:
6✔
997
        """Inertia (mass) matrix.
998
        :python:`None` if  :python:`inertia_in_forces is True`.
999
        """
1000
        return self._inertia_matrix
×
1001

1002
    @property
6✔
1003
    def inertia(self) -> TStateFunction:
6✔
1004
        """Function representing the inertial term :math:`ma` in the
1005
        WEC's dynamics equation.
1006
        """
1007
        return self._inertia
6✔
1008

1009
    @property
6✔
1010
    def dof_names(self) -> list[str]:
6✔
1011
        """Names of the different degrees of freedom."""
1012
        return self._dof_names
6✔
1013

1014
    @property
6✔
1015
    def ndof(self) -> int:
6✔
1016
        """Number of degrees of freedom."""
1017
        return self._ndof
6✔
1018

1019
    @property
6✔
1020
    def frequency(self) -> ndarray:
6✔
1021
        """Frequency vector [:math:`Hz`]."""
1022
        return self._freq
6✔
1023

1024
    @property
6✔
1025
    def f1(self) -> float:
6✔
1026
        """Fundamental frequency :python:`f1` [:math:`Hz`]."""
1027
        return self._freq[1]
6✔
1028

1029
    @property
6✔
1030
    def nfreq(self) -> int:
6✔
1031
        """Number of frequencies, not including the zero-frequency."""
1032
        return len(self._freq)-1
6✔
1033

1034
    @property
6✔
1035
    def omega(self) -> ndarray:
6✔
1036
        """Radial frequency vector [rad/s]."""
1037
        return self._freq * (2*np.pi)
6✔
1038

1039
    @property
6✔
1040
    def period(self) -> ndarray:
6✔
1041
        """Period vector [s]."""
1042
        return np.concatenate([[np.Infinity], 1/self._freq[1:]])
6✔
1043

1044
    @property
6✔
1045
    def w1(self) -> float:
6✔
1046
        """Fundamental radial frequency [rad/s]."""
1047
        return self.omega[1]
×
1048

1049
    @property
6✔
1050
    def time(self) -> ndarray:
6✔
1051
        """Time vector [s], size '(2*nfreq, ndof)', not containing the
1052
        end time 'tf'."""
1053
        return self._time
6✔
1054

1055
    @property
6✔
1056
    def time_mat(self) -> ndarray:
6✔
1057
        """Matrix to create time-series from Fourier coefficients.
1058

1059
        For some array of Fourier coefficients :python:`x`
1060
        (excluding the sine component of the highest frequency), size
1061
        :python:`(2*nfreq, ndof)`, the time series is obtained via
1062
        :python:`time_mat @ x`, also size
1063
        :python:`(2*nfreq, ndof)`.
1064
        """
1065
        return self._time_mat
6✔
1066

1067
    @property
6✔
1068
    def derivative_mat(self) -> ndarray:
6✔
1069
        """Matrix to create Fourier coefficients of the derivative of
1070
        some quantity.
1071

1072
        For some array of Fourier coefficients :python:`x`
1073
        (excluding the sine component of the highest frequency), size
1074
        :python:`(2*nfreq, ndof)`, the Fourier coefficients of the
1075
        derivative of :python:`x` are obtained via
1076
        :python:`derivative_mat @ x`.
1077
        """
1078
        return self._derivative_mat
6✔
1079

1080
    @property
6✔
1081
    def derivative2_mat(self) -> ndarray:
6✔
1082
        """Matrix to create Fourier coefficients of the second derivative of
1083
        some quantity.
1084

1085
        For some array of Fourier coefficients :python:`x`
1086
        (excluding the sine component of the highest frequency), size
1087
        :python:`(2*nfreq, ndof)`, the Fourier coefficients of the
1088
        second derivative of :python:`x` are obtained via
1089
        :python:`derivative2_mat @ x`.
1090
        """
1091
        return self._derivative2_mat
6✔
1092

1093
    @property
6✔
1094
    def dt(self) -> float:
6✔
1095
        """Time spacing [s]."""
1096
        return self._time[1]
6✔
1097

1098
    @property
6✔
1099
    def tf(self) -> float:
6✔
1100
        """Final time (repeat period) [s]. Not included in
1101
        :python:`time` vector.
1102
        """
1103
        return 1/self.f1
6✔
1104

1105
    @property
6✔
1106
    def nt(self) -> int:
6✔
1107
        """Number of timesteps."""
1108
        return self.ncomponents
6✔
1109

1110
    @property
6✔
1111
    def ncomponents(self) -> int:
6✔
1112
        """Number of Fourier components (:python:`2*nfreq`) for each
1113
        degree of freedom. Note that the sine component of the highest
1114
        frequency (the 2-point wave) is excluded as this will always
1115
        evaluate to zero.
1116
        """
1117
        return ncomponents(self.nfreq)
6✔
1118

1119
    @property
6✔
1120
    def nstate_wec(self) -> int:
6✔
1121
        """Length of the WEC dynamics state vector consisting of the
1122
        Fourier coefficient of the position of each degree of freedom.
1123
        """
1124
        return self.ndof * self.ncomponents
6✔
1125

1126
    # other methods
1127
    def decompose_state(self,
6✔
1128
        state: ndarray
1129
    ) -> tuple[ndarray, ndarray]:
1130
        """Split the state vector into the WEC dynamics state and the
1131
        optimization (control) state.
1132

1133
        Calls :py:func:`wecopttool.decompose_state` with the
1134
        appropriate inputs for the WEC object.
1135

1136
        Examples
1137
        --------
1138
        >>> x_wec, x_opt = wec.decompose_state(x)
1139

1140
        Parameters
1141
        ----------
1142
        state
1143
            Combined WEC and optimization states.
1144

1145
        Returns
1146
        -------
1147
        state_wec
1148
            WEC state vector.
1149
        state_opt
1150
            Optimization (control) state.
1151

1152
        See Also
1153
        --------
1154
        decompose_state
1155
        """
1156
        return decompose_state(state, self.ndof, self.nfreq)
6✔
1157

1158
    def time_nsubsteps(self, nsubsteps: int) -> ndarray:
6✔
1159
        """Create a time vector with finer discretization.
1160

1161
        Calls :py:func:`wecopttool.time` with the appropriate
1162
        inputs for the WEC object.
1163

1164
        Parameters
1165
        ----------
1166
        nsubsteps
1167
            Number of substeps between implied/default time steps.
1168

1169
        See Also
1170
        --------
1171
        time, WEC.time
1172
        """
1173
        return time(self.f1, self.nfreq, nsubsteps)
6✔
1174

1175
    def time_mat_nsubsteps(self, nsubsteps: int) -> ndarray:
6✔
1176
        """Create a time matrix similar to
1177
        :py:meth:`wecopttool.WEC.time_mat` but with finer
1178
        time-domain discretization.
1179

1180
        Calls :py:func:`wecopttool.time_mat` with the appropriate
1181
        inputs for the WEC object.
1182

1183
        Parameters
1184
        ----------
1185
        nsubsteps
1186
            Number of substeps between implied/default time steps.
1187

1188
        See Also
1189
        --------
1190
        time_mat, WEC.time_mat, WEC.time_nsubsteps
1191
        """
1192
        return time_mat(self.f1, self.nfreq, nsubsteps)
6✔
1193

1194
    def vec_to_dofmat(self, vec: ndarray) -> ndarray:
6✔
1195
        """Convert a vector to a matrix with one column per degree of
1196
        freedom.
1197

1198
        Opposite of :py:meth:`wecopttool.WEC.dofmat_to_vec`.
1199

1200
        Calls :py:func:`wecopttool.vec_to_dofmat` with the
1201
        appropriate inputs for the WEC object.
1202

1203
        Examples
1204
        --------
1205
        >>> x_wec, x_opt = wec.decompose_state(x)
1206
        >>> x_wec_mat = wec.vec_to_dofmat(x_wec)
1207

1208
        Parameters
1209
        ----------
1210
        vec
1211
            One-dimensional vector.
1212

1213
        See Also
1214
        --------
1215
        vec_to_dofmat, WEC.dofmat_to_vec
1216
        """
1217
        return vec_to_dofmat(vec, self.ndof)
6✔
1218

1219
    def dofmat_to_vec(self, mat: ndarray) -> ndarray:
6✔
1220
        """Flatten a matrix to a vector.
1221

1222
        Opposite of :py:meth:`wecopttool.WEC.vec_to_dofmat`.
1223

1224
        Calls :py:func:`wecopttool.dofmat_to_vec` with the
1225
        appropriate inputs for the WEC object.
1226

1227
        Parameters
1228
        ----------
1229
        mat
1230
            Matrix with one column per degree of freedom.
1231

1232
        See Also
1233
        --------
1234
        dofmat_to_vec, WEC.vec_to_dofmat
1235
        """
1236
        return dofmat_to_vec(mat)
6✔
1237

1238
    def fd_to_td(self, fd: ndarray) -> ndarray:
6✔
1239
        """Convert a frequency-domain array to time-domain.
1240

1241
        Opposite of :py:meth:`wecopttool.WEC.td_to_fd`.
1242

1243
        Calls :py:func:`wecopttool.fd_to_td` with the appropriate inputs
1244
        for the WEC object.
1245

1246
        Parameters
1247
        ----------
1248
        fd
1249
            Frequency-domain complex array with shape
1250
            :python:`(WEC.nfreq+1, N)` for any :python:`N`.
1251

1252
        See Also
1253
        --------
1254
        fd_to_td, WEC.td_to_fd
1255
        """
1256
        return fd_to_td(fd, self.f1, self.nfreq, True)
×
1257

1258
    def td_to_fd(
6✔
1259
        self,
1260
        td: ndarray,
1261
        fft: Optional[bool] = True,
1262
        ) -> ndarray:
1263
        """Convert a time-domain array to frequency-domain.
1264

1265
        Opposite of :py:meth:`wecopttool.WEC.fd_to_td`.
1266

1267
        Calls :py:func:`wecopttool.fd_to_td` with the appropriate
1268
        inputs for the WEC object.
1269

1270
        Parameters
1271
        ----------
1272
        td
1273
            Time-domain real array with shape
1274
            :python:`(2*WEC.nfreq, N)` for any :python:`N`.
1275
        fft
1276
            Whether to use the real FFT.
1277

1278
        See Also
1279
        --------
1280
        td_to_fd, WEC.fd_to_td
1281
        """
1282
        return td_to_fd(td, fft, True)
6✔
1283

1284

1285
def ncomponents(
6✔
1286
    nfreq : int,
1287
    zero_freq: Optional[bool] = True,
1288
) -> int:
1289
    """Number of Fourier components (:python:`2*nfreq`) for each
1290
    DOF. The sine component of the highest frequency (the 2-point wave)
1291
    is excluded as it will always evaluate to zero.
1292

1293
    If :python:`zero_freq = False` (not default), the mean (DC) component
1294
    :python:`X0` is excluded, and the number of components is reduced by 1.
1295

1296
    Parameters
1297
    ----------
1298
    nfreq
1299
        Number of frequencies.
1300
    zero_freq
1301
        Whether to include the zero-frequency.
1302
    """
1303
    ncomp = 2*nfreq - 1
6✔
1304
    if zero_freq:
6✔
1305
        ncomp = ncomp + 1
6✔
1306
    return ncomp
6✔
1307

1308

1309
def frequency(
6✔
1310
    f1: float,
1311
    nfreq: int,
1312
    zero_freq: Optional[bool] = True,
1313
) -> ndarray:
1314
    """Construct equally spaced frequency array.
1315

1316
    The array includes :python:`0` and has length of :python:`nfreq+1`.
1317
    :python:`f1` is fundamental frequency (1st harmonic).
1318

1319
    Returns the frequency array, e.g.,
1320
    :python:`freqs = [0, f1, 2*f1, ..., nfreq*f1]`.
1321

1322
    If :python:`zero_freq = False` (not default), the mean (DC) component
1323
    :python:`0` is excluded, and the vector length is reduced by 1.
1324

1325
    Parameters
1326
    ----------
1327
    f1
1328
        Fundamental frequency :python:`f1` [:math:`Hz`].
1329
    nfreq
1330
        Number of frequencies.
1331
    zero_freq
1332
        Whether to include the zero-frequency.
1333
    """
1334
    freq = np.arange(0, nfreq+1)*f1
6✔
1335
    freq = freq[1:] if not zero_freq else freq
6✔
1336
    return freq
6✔
1337

1338

1339
def time(
6✔
1340
    f1: float,
1341
    nfreq: int,
1342
    nsubsteps: Optional[int] = 1,
1343
) -> ndarray:
1344
    """Assemble the time vector with :python:`nsubsteps` subdivisions.
1345

1346
    Returns the 1D time vector, in seconds, starting at time
1347
    :python:`0`, and not containing the end time :python:`tf=1/f1`.
1348
    The time vector has length :python:`(2*nfreq)*nsubsteps`.
1349
    The timestep length is :python:`dt = dt_default * 1/nsubsteps`,
1350
    where :python:`dt_default=tf/(2*nfreq)`.
1351

1352
    Parameters
1353
    ----------
1354
    f1
1355
        Fundamental frequency :python:`f1` [:math:`Hz`].
1356
    nfreq
1357
        Number of frequencies.
1358
    nsubsteps
1359
        Number of steps between the default (implied) time steps.
1360
        A value of :python:`1` corresponds to the default step length.
1361
    """
1362
    if nsubsteps < 1:
6✔
1363
        raise ValueError("'nsubsteps' must be 1 or greater")
×
1364
    nsteps = nsubsteps * ncomponents(nfreq)
6✔
1365
    return np.linspace(0, 1/f1, nsteps, endpoint=False)
6✔
1366

1367

1368
def time_mat(
6✔
1369
    f1: float,
1370
    nfreq: int,
1371
    nsubsteps: Optional[int] = 1,
1372
    zero_freq: Optional[bool] = True,
1373
) -> ndarray:
1374
    """Assemble the time matrix that converts the state to a
1375
    time-series.
1376

1377
    For a state :math:`x` consisting of the mean (DC) component
1378
    followed by the real and imaginary components of the Fourier
1379
    coefficients (excluding the imaginary component of the 2-point wave) as
1380
    :math:`x=[X0, Re(X1), Im(X1), ..., Re(Xn)]`,
1381
    the response vector in the time-domain (:math:`x(t)`) is given as
1382
    :math:`Mx`, where :math:`M` is the time matrix.
1383

1384
    The time matrix has size :python:`(nfreq*2, nfreq*2)`.
1385

1386
    If :python:`zero_freq = False` (not default), the mean (DC) component
1387
    :python:`X0` is excluded, and the matrix/vector length is reduced by 1.
1388

1389
    Parameters
1390
    ---------
1391
    f1
1392
        Fundamental frequency :python:`f1` [:math:`Hz`].
1393
    nfreq
1394
        Number of frequencies.
1395
    nsubsteps
1396
        Number of steps between the default (implied) time steps.
1397
        A value of :python:`1` corresponds to the default step length.
1398
    zero_freq
1399
        Whether the first frequency should be zero.
1400
    """
1401
    t = time(f1, nfreq, nsubsteps)
6✔
1402
    omega = frequency(f1, nfreq) * 2*np.pi
6✔
1403
    wt = np.outer(t, omega[1:])
6✔
1404
    ncomp = ncomponents(nfreq)
6✔
1405
    time_mat = np.empty((nsubsteps*ncomp, ncomp))
6✔
1406
    time_mat[:, 0] = 1.0
6✔
1407
    time_mat[:, 1::2] = np.cos(wt)
6✔
1408
    time_mat[:, 2::2] = -np.sin(wt[:, :-1]) # remove 2pt wave sine component
6✔
1409
    if not zero_freq:
6✔
1410
        time_mat = time_mat[:, 1:]
6✔
1411
    return time_mat
6✔
1412

1413

1414
def derivative_mat(
6✔
1415
    f1: float,
1416
    nfreq: int,
1417
    zero_freq: Optional[bool] = True,
1418
) -> ndarray:
1419
    """Assemble the derivative matrix that converts the state vector of
1420
    a response to the state vector of its derivative.
1421

1422
    For a state :math:`x` consisting of the mean (DC) component
1423
    followed by the real and imaginary components of the Fourier
1424
    coefficients (excluding the imaginary component of the 2-point wave) as
1425
    :math:`x=[X0, Re(X1), Im(X1), ..., Re(Xn)]`,
1426
    the state of its derivative is given as :math:`Dx`, where
1427
    :math:`D` is the derivative matrix.
1428

1429
    The time matrix has size :python:`(nfreq*2, nfreq*2)`.
1430

1431
    If :python:`zero_freq = False` (not default), the mean (DC) component
1432
    :python:`X0` is excluded, and the matrix/vector length is reduced by 1.
1433

1434
    Parameters
1435
    ---------
1436
    f1
1437
        Fundamental frequency :python:`f1` [:math:`Hz`].
1438
    nfreq
1439
        Number of frequencies.
1440
    zero_freq
1441
        Whether the first frequency should be zero.
1442
    """
1443
    def block(n): return np.array([[0, -1], [1, 0]]) * n*f1 * 2*np.pi
6✔
1444
    blocks = [block(n+1) for n in range(nfreq)]
6✔
1445
    if zero_freq:
6✔
1446
        blocks = [0.0] + blocks
6✔
1447
    deriv_mat = block_diag(*blocks)
6✔
1448
    return deriv_mat[:-1, :-1] # remove 2pt wave sine component
6✔
1449

1450

1451
def derivative2_mat(
6✔
1452
    f1: float,
1453
    nfreq: int,
1454
    zero_freq: Optional[bool] = True,
1455
) -> ndarray:
1456
    """Assemble the second derivative matrix that converts the state vector of
1457
    a response to the state vector of its second derivative.
1458

1459
    For a state :math:`x` consisting of the mean (DC) component
1460
    followed by the real and imaginary components of the Fourier
1461
    coefficients (excluding the imaginary component of the 2-point wave) as
1462
    :math:`x=[X0, Re(X1), Im(X1), ..., Re(Xn)]`,
1463
    the state of its second derivative is given as :math:`(DD)x`, where
1464
    :math:`DD` is the second derivative matrix.
1465

1466
    The time matrix has size :python:`(nfreq*2, nfreq*2)`.
1467

1468
    If :python:`zero_freq = False` (not default), the mean (DC) component
1469
    :python:`X0` is excluded, and the matrix/vector length is reduced by 1.
1470

1471
    Parameters
1472
    ---------
1473
    f1
1474
        Fundamental frequency :python:`f1` [:math:`Hz`].
1475
    nfreq
1476
        Number of frequencies.
1477
    zero_freq
1478
        Whether the first frequency should be zero.
1479
    """
1480
    vals = [((n+1)*f1 * 2*np.pi)**2 for n in range(nfreq)]
6✔
1481
    diagonal = np.repeat(-np.ones(nfreq) * vals, 2)[:-1] # remove 2pt wave sine
6✔
1482
    if zero_freq:
6✔
1483
        diagonal = np.concatenate(([0.0], diagonal))
6✔
1484
    return np.diag(diagonal)
6✔
1485

1486

1487
def mimo_transfer_mat(
6✔
1488
    transfer_mat: DataArray,
1489
    zero_freq: Optional[bool] = True,
1490
) -> ndarray:
1491
    """Create a block matrix of the MIMO transfer function.
1492

1493
    The input is a complex transfer matrix that relates the complex
1494
    Fourier representation of two variables.
1495
    For example, it can be an impedance matrix or an RAO transfer
1496
    matrix.
1497
    The input complex impedance matrix has shape
1498
    :python`(nfreq, ndof, ndof)`.
1499

1500
    Returns the 2D real matrix that transform the state representation
1501
    of the input variable variable to the state representation of the
1502
    output variable.
1503
    Here, a state representation :python:`x` consists of the mean (DC)
1504
    component followed by the real and imaginary components of the
1505
    Fourier coefficients (excluding the imaginary component of the
1506
    2-point wave) as
1507
    :python:`x=[X0, Re(X1), Im(X1), ..., Re(Xn)]`.
1508

1509
    If :python:`zero_freq = False` (not default), the mean (DC) component
1510
    :python:`X0` is excluded, and the matrix/vector length is reduced by 1.
1511

1512
    Parameters
1513
    ----------
1514
    transfer_mat
1515
        Complex transfer matrix.
1516
    zero_freq
1517
        Whether the first frequency should be zero.
1518
    """
1519
    ndof = transfer_mat.shape[1]
6✔
1520
    assert transfer_mat.shape[2] == ndof
6✔
1521
    elem = [[None]*ndof for _ in range(ndof)]
6✔
1522
    def block(re, im): return np.array([[re, -im], [im, re]])
6✔
1523
    for idof in range(ndof):
6✔
1524
        for jdof in range(ndof):
6✔
1525
            if zero_freq:
6✔
1526
                Zp0 = transfer_mat[0, idof, jdof]
6✔
1527
                assert np.all(np.isreal(Zp0))
6✔
1528
                Zp0 = np.real(Zp0)
6✔
1529
                Zp = transfer_mat[1:, idof, jdof]
6✔
1530
            else:
1531
                Zp0 = [0.0]
6✔
1532
                Zp = transfer_mat[:, idof, jdof]
6✔
1533
            re = np.real(Zp)
6✔
1534
            im = np.imag(Zp)
6✔
1535
            blocks = [block(ire, iim) for (ire, iim) in zip(re[:-1], im[:-1])]
6✔
1536
            blocks = [Zp0] + blocks + [re[-1]]
6✔
1537
            elem[idof][jdof] = block_diag(*blocks)
6✔
1538
    return np.block(elem)
6✔
1539

1540

1541
def vec_to_dofmat(vec: ArrayLike, ndof: int) -> ndarray:
6✔
1542
    """Convert a vector back to a matrix with one column per DOF.
1543

1544
    Returns a matrix with :python:`ndof` columns.
1545
    The number of rows is inferred from the size of the input vector.
1546

1547
    Opposite of :py:func:`wecopttool.dofmat_to_vec`.
1548

1549
    Parameters
1550
    ----------
1551
    vec
1552
        1D array consisting of concatenated arrays of several DOFs, as
1553
        :python:`vec = [vec_1, vec_2, ..., vec_ndof]`.
1554
    ndof
1555
        Number of degrees of freedom.
1556

1557
    See Also
1558
    --------
1559
    dofmat_to_vec,
1560
    """
1561
    return np.reshape(vec, (-1, ndof), order='F')
6✔
1562

1563

1564
def dofmat_to_vec(mat: ArrayLike) -> ndarray:
6✔
1565
    """Flatten a matrix that has one column per DOF.
1566

1567
    Returns a 1D vector.
1568

1569
    Opposite of :py:func:`wecopttool.vec_to_dofmat`.
1570

1571
    Parameters
1572
    ----------
1573
    mat
1574
        Matrix to be flattened.
1575

1576
    See Also
1577
    --------
1578
    vec_to_dofmat,
1579
    """
1580
    return np.reshape(mat, -1, order='F')
6✔
1581

1582

1583
def real_to_complex(
6✔
1584
    fd: ArrayLike,
1585
    zero_freq: Optional[bool] = True,
1586
) -> ndarray:
1587
    """Convert from two real amplitudes to one complex amplitude per
1588
    frequency.
1589

1590
    The input is a real 2D array with each column containing the real
1591
    and imaginary components of the Fourier coefficients for some
1592
    response, excluding the imaginary component of the highest frequency
1593
    (2-point wave).
1594
    The column length is :python:`2*nfreq`.
1595
    The entries of a column representing a response :python:`x` are
1596
    :python:`x=[X0, Re(X1), Im(X1), ..., Re(Xn)]`.
1597

1598
    Returns a complex 2D array with each column containing the complex
1599
    Fourier coefficients.
1600
    Columns are length :python:`nfreq+1`, and the first row corresponds
1601
    to the real-valued zero-frequency (mean, DC) components.
1602
    The entries of a column representing a response :python:`x` are
1603
    :python:`x=[X0, X1, ..., Xn]`.
1604

1605
    If :python:`zero_freq = False`, the mean (DC) component :python:`X0`
1606
    is excluded, and the column length is reduced by 1.
1607

1608
    Parameters
1609
    ----------
1610
    fd
1611
        Array containing the real and imaginary components of the
1612
        Fourier coefficients.
1613
    zero_freq
1614
        Whether the mean (DC) component is included.
1615

1616
    See Also
1617
    --------
1618
    complex_to_real,
1619
    """
1620
    fd= atleast_2d(fd)
6✔
1621
    if zero_freq:
6✔
1622
        assert fd.shape[0]%2==0
6✔
1623
        mean = fd[0:1, :]
6✔
1624
        fd = fd[1:, :]
6✔
1625
    fdc = np.append(fd[0:-1:2, :] + 1j*fd[1::2, :],
6✔
1626
                    [fd[-1, :]], axis=0)
1627
    if zero_freq:
6✔
1628
        fdc = np.concatenate((mean, fdc), axis=0)
6✔
1629
    return fdc
6✔
1630

1631

1632
def complex_to_real(
6✔
1633
    fd: ArrayLike,
1634
    zero_freq: Optional[bool] = True,
1635
) -> ndarray:
1636
    """Convert from one complex amplitude to two real amplitudes per
1637
    frequency.
1638

1639
    The input is a complex 2D array with each column containing the
1640
    Fourier coefficients for some response.
1641
    Columns are length :python:`nfreq+1`, and the first row corresponds
1642
    to the real-valued zero-frequency (mean, DC) components.
1643
    The entries of a column representing a response :python:`x` are
1644
    :python:`x=[X0, X1, ..., Xn]`.
1645

1646
    Returns a real 2D array with each column containing the real and
1647
    imaginary components of the Fourier coefficients. The imaginary component
1648
    of the highest frequency (the 2-point wave) is excluded, as it will
1649
    always evaluate to zero.
1650
    The column length is :python:`2*nfreq`.
1651
    The entries of a column representing a response :python:`x` are
1652
    :python:`x=[X0, Re(X1), Im(X1), ..., Re(Xn)]`.
1653

1654
    If :python:`zero_freq = False` (not default), the mean (DC) component
1655
    :python:`X0` is excluded, and the vector length is reduced by 1.
1656

1657
    Parameters
1658
    ----------
1659
    fd
1660
        Array containing the complex Fourier coefficients.
1661
    zero_freq
1662
        Whether the mean (DC) component is included.
1663

1664
    See Also
1665
    --------
1666
    real_to_complex,
1667
    """
1668
    fd = atleast_2d(fd)
6✔
1669
    nfreq = fd.shape[0] - 1 if zero_freq else fd.shape[0]
6✔
1670
    ndof = fd.shape[1]
6✔
1671
    if zero_freq:
6✔
1672
        assert np.all(np.isreal(fd[0, :]))
6✔
1673
        a = np.real(fd[0:1, :])
6✔
1674
        b = np.real(fd[1:-1, :])
6✔
1675
        c = np.imag(fd[1:-1, :])
6✔
1676
        d = np.real(fd[-1:, :])
6✔
1677
    else:
1678
        b = np.real(fd[:-1, :])
6✔
1679
        c = np.imag(fd[:-1, :])
6✔
1680
        d = np.real(fd[-1:, :])
6✔
1681
    out = np.concatenate([np.transpose(b), np.transpose(c)])
6✔
1682
    out = np.reshape(np.reshape(out, [-1], order='F'), [-1, ndof])
6✔
1683
    if zero_freq:
6✔
1684
        out = np.concatenate([a, out, d])
6✔
1685
        assert out.shape == (2*nfreq, ndof)
6✔
1686
    else:
1687
        out = np.concatenate([out, d])
6✔
1688
        assert out.shape == (2*nfreq-1, ndof)
6✔
1689
    return out
6✔
1690

1691

1692
def fd_to_td(
6✔
1693
    fd: ArrayLike,
1694
    f1: Optional[float] = None,
1695
    nfreq: Optional[int] = None,
1696
    zero_freq: Optional[bool] = True,
1697
) -> ndarray:
1698
    """Convert a complex array of Fourier coefficients to a real array
1699
    of time-domain responses.
1700

1701
    The input is a complex 2D array with each column containing the
1702
    Fourier coefficients for some response.
1703
    Columns are length :python:`nfreq+1`, and the first row corresponds
1704
    to the real-valued zero-frequency (mean, DC) components.
1705
    The entries of a column representing a response :python:`x` are
1706
    :python:`x=[X0, X1, ..., Xn]`.
1707

1708
    Returns a real array with same number of columns and
1709
    :python:`2*nfreq` rows, containing the time-domain response at
1710
    times :python:`wecopttool.time(f1, nfreq, nsubsteps=1)`.
1711
    The imaginary component of the highest frequency (the 2-point wave) is
1712
    excluded, as it will always evaluate to zero.
1713

1714
    If both :python:`f1` and :python:`nfreq` are provided, it uses the
1715
    time matrix :python:`wecopttool.time_mat(f1, nfreq, nsubsteps=1)`,
1716
    else it uses the inverse real FFT (:py:func:`numpy.fft.irfft`).
1717

1718
    If :python:`zero_freq = False` (not default), the mean (DC) component
1719
    :python:`X0` is excluded, and the matrix/vector length is reduced by 1.
1720

1721
    Opposite of :py:meth:`wecopttool.td_to_fd`.
1722

1723
    Parameters
1724
    ----------
1725
    fd
1726
        Array containing the complex Fourier coefficients.
1727
    f1
1728
        Fundamental frequency :python:`f1` [:math:`Hz`].
1729
    nfreq
1730
        Number of frequencies.
1731
    zero_freq
1732
        Whether the mean (DC) component is included.
1733

1734
    Raises
1735
    ------
1736
    ValueError
1737
        If only one of :python:`f1` or :python:`nfreq` is provided.
1738
        Must provide both or neither.
1739

1740
    See Also
1741
    --------
1742
    td_to_fd, time, time_mat
1743
    """
1744
    fd = atleast_2d(fd)
6✔
1745

1746
    if zero_freq:
6✔
1747
        msg = "The first row must be real when `zero_freq=True`."
6✔
1748
        assert np.allclose(np.imag(fd[0, :]), 0), msg
6✔
1749

1750
    if (f1 is not None) and (nfreq is not None):
6✔
1751
        tmat = time_mat(f1, nfreq, zero_freq=zero_freq)
6✔
1752
        td = tmat @ complex_to_real(fd, zero_freq)
6✔
1753
    elif (f1 is None) and (nfreq is None):
6✔
1754
        n = 2*(fd.shape[0]-1)
6✔
1755
        fd = np.concatenate((fd[:1, :], fd[1:-1, :]/2, fd[-1:, :]))
6✔
1756
        td = np.fft.irfft(fd, n=n, axis=0, norm='forward')
6✔
1757
    else:
1758
        raise ValueError(
×
1759
            "Provide either both 'f1' and 'nfreq' or neither.")
1760
    return td
6✔
1761

1762

1763
def td_to_fd(
6✔
1764
    td: ArrayLike,
1765
    fft: Optional[bool] = True,
1766
    zero_freq: Optional[bool] = True,
1767
) -> ndarray:
1768
    """Convert a real array of time-domain responses to a complex array
1769
    of Fourier coefficients.
1770

1771
    If :python:`zero_freq = False` (not default), the mean (DC) component
1772
    :python:`X0` is excluded, and the matrix/vector length is reduced by 1.
1773

1774
    Opposite of :py:func:`wecopttool.fd_to_td`
1775

1776
    Parameters
1777
    ----------
1778
    td
1779
        Real array of time-domains responses.
1780
    fft
1781
        Whether to use the real FFT.
1782
    zero_freq
1783
        Whether the mean (DC) component is returned.
1784

1785
    See Also
1786
    --------
1787
    fd_to_td
1788
    """
1789
    td= atleast_2d(td)
6✔
1790
    n = td.shape[0]
6✔
1791
    if fft:
6✔
1792
        fd = np.fft.rfft(td, n=n, axis=0, norm='forward')
6✔
1793
    else:
NEW
1794
        fd = np.dot(dft(n, 'n')[:n//2+1, :], td)
×
1795
    fd = np.concatenate((fd[:1, :], fd[1:-1, :]*2, fd[-1:, :]))
6✔
1796
    if not zero_freq:
6✔
1797
        fd = fd[1:, :]
×
1798
    return fd
6✔
1799

1800

1801
def read_netcdf(fpath: Union[str, Path]) -> Dataset:
6✔
1802
    """Read a *NetCDF* file with possibly complex entries as a
1803
    :py:class:`xarray.Dataset`.
1804

1805
    Can handle complex entries in the *NetCDF* by using
1806
    :py:func:`capytaine.io.xarray.merge_complex_values`.
1807

1808
    Parameters
1809
    ----------
1810
    fpath
1811
        Path to the *NetCDF* file.
1812

1813
    See Also
1814
    --------
1815
    write_netcdf,
1816
    """
1817
    with xr.open_dataset(fpath) as ds:
6✔
1818
        ds.load()
6✔
1819
    return cpy.io.xarray.merge_complex_values(ds)
6✔
1820

1821

1822
def write_netcdf(fpath: Union[str, Path], data: Dataset) -> None:
6✔
1823
    """Save an :py:class:`xarray.Dataset` with possibly complex entries as a
1824
    *NetCDF* file.
1825

1826
    Can handle complex entries in the *NetCDF* by using
1827
    :py:func:`capytaine.io.xarray.separate_complex_values`
1828

1829
    Parameters
1830
    ----------
1831
    fpath
1832
        Name of file to save.
1833
    data
1834
        Dataset to save.
1835

1836
    See Also
1837
    --------
1838
    read_netcdf,
1839
    """
1840
    cpy.io.xarray.separate_complex_values(data).to_netcdf(fpath)
6✔
1841

1842

1843
def check_radiation_damping(
6✔
1844
    hydro_data: Dataset,
1845
    min_damping: Optional[float] = 1e-6,
1846
    uniform_shift: Optional[bool] = False,
1847
) -> Dataset:
1848
    """Ensure that the linear hydrodynamics (friction + radiation
1849
    damping) have positive damping.
1850

1851
    Shifts the :python:`friction` or :python:`radiation_damping` up
1852
    if necessary. Returns the (possibly) updated Dataset with
1853
    :python:`damping` :math:`>=` :python:`min_damping`.
1854

1855
    Parameters
1856
    ----------
1857
    hydro_data
1858
        Linear hydrodynamic data.
1859
    min_damping
1860
        Minimum threshold for damping. Default is 1e-6.
1861
    uniform_shift
1862
        Boolean that determines whether the damping correction for each
1863
        degree of freedom is frequency dependent or not. If :python:`True`,
1864
        the damping correction is applied to :python:`friction` and shifts the
1865
        damping for all frequencies. If :python:`False`, the damping correction
1866
        is applied to :python:`radiation_damping` and only shifts the
1867
        damping for frequencies with negative damping values. Default is
1868
        :python:`False`.
1869
    """
1870
    hydro_data_new = hydro_data.copy(deep=True)
6✔
1871
    radiation = hydro_data_new['radiation_damping']
6✔
1872
    friction = hydro_data_new['friction']
6✔
1873
    ndof = len(hydro_data_new.influenced_dof)
6✔
1874
    assert ndof == len(hydro_data.radiating_dof)
6✔
1875
    for idof in range(ndof):
6✔
1876
        iradiation = radiation.isel(radiating_dof=idof, influenced_dof=idof)
6✔
1877
        ifriction = friction.isel(radiating_dof=idof, influenced_dof=idof)
6✔
1878
        if uniform_shift:
6✔
1879
            dmin = (iradiation+ifriction).min()
6✔
1880
            if dmin <= 0.0 + min_damping:
6✔
1881
                dof = hydro_data_new.influenced_dof.values[idof]
6✔
1882
                delta = min_damping-dmin
6✔
1883
                _log.warning(
6✔
1884
                    f'Linear damping for DOF "{dof}" has negative or close ' +
1885
                    'to zero terms. Shifting up radiation damping by ' +
1886
                    f'{delta.values} N/(m/s).')
1887
                hydro_data_new['radiation_damping'][:, idof, idof] = (iradiation + delta)
6✔
1888
        else:
1889
            new_damping = iradiation.where(
6✔
1890
                iradiation+ifriction>min_damping, other=min_damping)
1891
            dof = hydro_data_new.influenced_dof.values[idof]
6✔
1892
            if (new_damping==min_damping).any():
6✔
1893
                _log.warning(
6✔
1894
                    f'Linear damping for DOF "{dof}" has negative or close to ' +
1895
                    'zero terms. Shifting up damping terms ' +
1896
                    f'{np.where(new_damping==min_damping)[0]} to a minimum of ' +
1897
                    f'{min_damping} N/(m/s)')
1898
            hydro_data_new['radiation_damping'][:, idof, idof] = new_damping
6✔
1899
    return hydro_data_new
6✔
1900

1901

1902
def check_impedance(
6✔
1903
    Zi: DataArray,
1904
    min_damping: Optional[float] = 1e-6,
1905
    uniform_shift: Optional[bool] = False,
1906
) -> DataArray:
1907
    """Ensure that the real part of the impedance (resistive) is positive.
1908

1909
    Adds to real part of the impedance.
1910
    Returns the (possibly) updated impedance with
1911
    :math:`Re(Zi)>=` :python:`min_damping`.
1912

1913
    Parameters
1914
    ----------
1915
    Zi
1916
        Linear hydrodynamic impedance.
1917
    min_damping
1918
        Minimum threshold for damping. Default is 1e-6.
1919
    """
1920
    Zi_diag = np.diagonal(Zi,axis1=1,axis2=2)
6✔
1921
    Zi_shifted = Zi.copy()
6✔
1922
    for dof in range(Zi_diag.shape[1]):
6✔
1923
        if uniform_shift:
6✔
NEW
1924
            dmin = np.min(np.real(Zi_diag[:, dof]))
×
NEW
1925
            if dmin < min_damping:
×
NEW
1926
                delta = min_damping - dmin
×
NEW
1927
                Zi_shifted[:, dof, dof] = Zi_diag[:, dof] \
×
1928
                    + np.abs(delta)
NEW
1929
                _log.warning(
×
1930
                    f'Real part of impedance for {dof} has negative or close to ' +
1931
                    f'zero terms. Shifting up by {delta:.2f}')
1932
        else:
1933
            points = np.where(np.real(Zi_diag[:, dof])<min_damping)
6✔
1934
            Zi_dof_real = Zi_diag[:,dof].real.copy()
6✔
1935
            Zi_dof_imag = Zi_diag[:,dof].imag.copy()
6✔
1936
            Zi_dof_real[Zi_dof_real < min_damping] = min_damping
6✔
1937
            Zi_shifted[:, dof, dof] = Zi_dof_real + Zi_dof_imag*1j
6✔
1938
            if (Zi_dof_real==min_damping).any():
6✔
1939
                _log.warning(
6✔
1940
                    f'Real part of impedance for {dof} has negative or close to ' +
1941
                    f'zero terms. Shifting up elements '
1942
                    f'{np.where(Zi_dof_real==min_damping)[0]} to a minimum of ' +
1943
                    f' {min_damping} N/(m/s)')
1944
    return Zi_shifted
6✔
1945

1946

1947
def force_from_rao_transfer_function(
6✔
1948
    rao_transfer_mat: DataArray,
1949
    zero_freq: Optional[bool] = True,
1950
) -> TStateFunction:
1951
    """Create a force function from its position transfer matrix.
1952

1953
    This is the position equivalent to the velocity-based
1954
    :py:func:`wecopttool.force_from_impedance`.
1955

1956
    If :python:`zero_freq = False` (not default), the mean (DC) component
1957
    of the transfer matrix (first row) is excluded.
1958

1959
    Parameters
1960
    ----------
1961
    rao_transfer_mat
1962
        Complex position transfer matrix.
1963
    zero_freq
1964
        Whether the first frequency should be zero. Default is
1965
        :python:`True`.
1966

1967
    See Also
1968
    --------
1969
    force_from_impedance,
1970
    """
1971
    def force(wec, x_wec, x_opt, waves):
6✔
1972
        transfer_mat = mimo_transfer_mat(rao_transfer_mat, zero_freq)
6✔
1973
        force_fd = wec.vec_to_dofmat(np.dot(transfer_mat, x_wec))
6✔
1974
        return np.dot(wec.time_mat, force_fd)
6✔
1975
    return force
6✔
1976

1977

1978
def force_from_impedance(
6✔
1979
    omega: ArrayLike,
1980
    impedance: DataArray,
1981
) -> TStateFunction:
1982
    """Create a force function from its impedance.
1983

1984
    Parameters
1985
    ----------
1986
    omega
1987
        Radial frequency vector.
1988
    impedance
1989
        Complex impedance matrix.
1990

1991
    See Also
1992
    --------
1993
    force_from_rao_transfer_function,
1994
    """
1995
    return force_from_rao_transfer_function(impedance*(1j*omega), False)
6✔
1996

1997

1998
def force_from_waves(force_coeff: DataArray,
6✔
1999
                     ) -> TStateFunction:
2000
    """Create a force function from waves excitation coefficients.
2001

2002
    Parameters
2003
    ----------
2004
    force_coeff
2005
        Complex excitation coefficients indexed by frequency and
2006
        direction angle.
2007
    """
2008
    def force(wec, x_wec, x_opt, waves):
6✔
2009
        force_fd = complex_to_real(wave_excitation(force_coeff, waves), False)
6✔
2010
        return np.dot(wec.time_mat[:, 1:], force_fd)
6✔
2011
    return force
6✔
2012

2013

2014
def inertia(
6✔
2015
    f1: float,
2016
    nfreq: int,
2017
    inertia_matrix: ArrayLike,
2018
) -> TStateFunction:
2019
    """Create the inertia "force" from the inertia matrix.
2020

2021
    Parameters
2022
    ----------
2023
    f1
2024
        Fundamental frequency :python:`f1` [:math:`Hz`].
2025
    nfreq
2026
        Number of frequencies.
2027
    inertia_matrix
2028
        Inertia matrix.
2029
    """
2030
    omega = np.expand_dims(frequency(f1, nfreq, False)*2*np.pi, [1,2])
6✔
2031
    inertia_matrix = np.expand_dims(inertia_matrix, 0)
6✔
2032
    rao_transfer_function = -1*omega**2*inertia_matrix + 0j
6✔
2033
    inertia_fun = force_from_rao_transfer_function(
6✔
2034
        rao_transfer_function, False)
2035
    return inertia_fun
6✔
2036

2037

2038
def standard_forces(hydro_data: Dataset) -> TForceDict:
6✔
2039
    """Create functions for linear hydrodynamic forces.
2040

2041
    Returns a dictionary with the standard linear forces:
2042
    radiation, hydrostatic, friction, Froude—Krylov, and diffraction.
2043
    The functions are type :python:`StateFunction` (see Type Aliases in
2044
    API Documentation).
2045

2046
    Parameters
2047
    ----------
2048
    hydro_data
2049
        Linear hydrodynamic data.
2050
    """
2051

2052
    # intrinsic impedance
2053
    w = hydro_data['omega']
6✔
2054
    A = hydro_data['added_mass']
6✔
2055
    B = hydro_data['radiation_damping']
6✔
2056
    K = hydro_data['hydrostatic_stiffness']
6✔
2057
    Bf = hydro_data['friction']
6✔
2058

2059
    rao_transfer_functions = dict()
6✔
2060
    rao_transfer_functions['radiation'] = (1j*w*B + -1*w**2*A, False)
6✔
2061
    rao_transfer_functions['friction'] = (1j*w*Bf, False)
6✔
2062

2063
    # include zero_freq in hydrostatics
2064
    hs = ((K + 0j).expand_dims({"omega": B.omega}, 0))
6✔
2065
    tmp = hs.isel(omega=0).copy(deep=True)
6✔
2066
    tmp['omega'] = tmp['omega'] * 0
6✔
2067
    hs = xr.concat([tmp, hs], dim='omega') #, data_vars='minimal')
6✔
2068
    rao_transfer_functions['hydrostatics'] = (hs, True)
6✔
2069

2070
    linear_force_functions = dict()
6✔
2071
    for name, (value, zero_freq) in rao_transfer_functions.items():
6✔
2072
        value = value.transpose("omega", "radiating_dof", "influenced_dof")
6✔
2073
        value = -1*value  # RHS of equation: ma = Σf
6✔
2074
        linear_force_functions[name] = (
6✔
2075
            force_from_rao_transfer_function(value, zero_freq))
2076

2077
    # wave excitation
2078
    excitation_coefficients = {
6✔
2079
        'Froude_Krylov': hydro_data['Froude_Krylov_force'],
2080
        'diffraction': hydro_data['diffraction_force']
2081
    }
2082

2083
    for name, value in excitation_coefficients.items():
6✔
2084
        linear_force_functions[name] = force_from_waves(value)
6✔
2085

2086
    return linear_force_functions
6✔
2087

2088

2089
def run_bem(
6✔
2090
    fb: cpy.FloatingBody,
2091
    freq: Iterable[float] = [np.infty],
2092
    wave_dirs: Iterable[float] = [0],
2093
    rho: float = _default_parameters['rho'],
2094
    g: float = _default_parameters['g'],
2095
    depth: float = _default_parameters['depth'],
2096
    write_info: Optional[Mapping[str, bool]] = None,
2097
    njobs: int = 1,
2098
) -> Dataset:
2099
    """Run Capytaine for a range of frequencies and wave directions.
2100

2101
    This simplifies running *Capytaine* and ensures the output are in
2102
    the correct convention (see
2103
    :py:func:`wecopttool.change_bem_convention`).
2104

2105
    It creates the *test matrix*, calls
2106
    :py:meth:`capytaine.bodies.bodies.FloatingBody.keep_immersed_part`,
2107
    calls :py:meth:`capytaine.bem.solver.BEMSolver.fill_dataset`,
2108
    and changes the sign convention using
2109
    :py:func:`wecopttool.change_bem_convention`.
2110

2111
    Parameters
2112
    ----------
2113
    fb
2114
        The WEC as a Capytaine floating body (mesh + DOFs).
2115
    freq
2116
        List of frequencies [:math:`Hz`] to evaluate BEM at.
2117
    wave_dirs
2118
        List of wave directions [degrees] to evaluate BEM at.
2119
    rho
2120
        Water density in :math:`kg/m^3`.
2121
    g
2122
        Gravitational acceleration in :math:`m/s^2`.
2123
    depth
2124
        Water depth in :math:`m`.
2125
    write_info
2126
        Which additional information to write.
2127
        Options are:
2128
        :python:`['hydrostatics', 'mesh', 'wavelength', 'wavenumber']`.
2129
        See :py:func:`capytaine.io.xarray.assemble_dataset` for more
2130
        details.
2131
    njobs
2132
        Number of jobs to run in parallel.
2133
        See :py:meth:`capytaine.bem.solver.BEMSolver.fill_dataset`
2134

2135
    See Also
2136
    --------
2137
    change_bem_convention,
2138
    """
2139
    if wave_dirs is not None:
6✔
2140
        wave_dirs = np.atleast_1d(degrees_to_radians(wave_dirs))
6✔
2141
    solver = cpy.BEMSolver()
6✔
2142
    test_matrix = Dataset(coords={
6✔
2143
        'rho': [rho],
2144
        'water_depth': [depth],
2145
        'omega': [ifreq*2*np.pi for ifreq in freq],
2146
        'wave_direction': wave_dirs,
2147
        'radiating_dof': list(fb.dofs.keys()),
2148
        'g': [g],
2149
    })
2150
    if wave_dirs is None:
6✔
2151
        # radiation only problem, no diffraction or excitation
2152
        test_matrix = test_matrix.drop_vars('wave_direction')
×
2153
    if write_info is None:
6✔
2154
        write_info = {'hydrostatics': True,
6✔
2155
                      'mesh': False,
2156
                      'wavelength': False,
2157
                      'wavenumber': False,
2158
                     }
2159
    wec_im = fb.copy(name=f"{fb.name}_immersed").keep_immersed_part()
6✔
2160
    wec_im = set_fb_centers(wec_im, rho=rho)
6✔
2161
    if not hasattr(wec_im, 'inertia_matrix'):
6✔
2162
        _log.warning('FloatingBody has no inertia_matrix field. ' + 
6✔
2163
                     'If the FloatingBody mass is defined, it will be ' + 
2164
                     'used for calculating the inertia matrix here. ' + 
2165
                     'Otherwise, the neutral buoyancy assumption will ' + 
2166
                     'be used to auto-populate.')
2167
        wec_im.inertia_matrix = wec_im.compute_rigid_body_inertia(rho=rho)
6✔
2168
    if not hasattr(wec_im, 'hydrostatic_stiffness'):
6✔
2169
        _log.warning('FloatingBody has no hydrostatic_stiffness field. ' +
6✔
2170
                     'Capytaine will auto-populate the hydrostatic ' +
2171
                     'stiffness based on the provided mesh.')
2172
        wec_im.hydrostatic_stiffness = wec_im.compute_hydrostatic_stiffness(rho=rho, g=g)
6✔
2173
    bem_data = solver.fill_dataset(
6✔
2174
        test_matrix, wec_im, n_jobs=njobs, **write_info)
2175
    return change_bem_convention(bem_data)
6✔
2176

2177

2178
def change_bem_convention(bem_data: Dataset) -> Dataset:
6✔
2179
    """Change the convention from :math:`-iωt` to :math:`+iωt`.
2180

2181
    Change the linear hydrodynamic coefficients from the Capytaine
2182
    convention (:math:`x(t)=Xe^{-iωt}`), where :math:`X` is the
2183
    frequency-domain response, to the more standard convention
2184
    used in WecOptTool (:math:`x(t)=Xe^{+iωt}`).
2185

2186
    NOTE: This might change in Capytaine in the future.
2187

2188
    Parameters
2189
    ----------
2190
    bem_data
2191
        Linear hydrodynamic coefficients for the WEC.
2192
    """
2193
    bem_data['Froude_Krylov_force'] = np.conjugate(
6✔
2194
        bem_data['Froude_Krylov_force'])
2195
    bem_data['diffraction_force'] = np.conjugate(bem_data['diffraction_force'])
6✔
2196
    return bem_data
6✔
2197

2198

2199
def add_linear_friction(
6✔
2200
    bem_data: Dataset,
2201
    friction: Optional[ArrayLike] = None
2202
) -> Dataset:
2203
    """Add linear friction to BEM data.
2204

2205
    Returns the Dataset with the additional information added.
2206

2207
    Parameters
2208
    ----------
2209
    bem_data
2210
        Linear hydrodynamic coefficients obtained using the boundary
2211
        element method (BEM) code Capytaine, with sign convention
2212
        corrected. Also includes inertia and hydrostatic stiffness.
2213
    friction
2214
        Linear friction, in addition to radiation damping, of size
2215
        :python:`(ndof, ndof)`.
2216
        :python:`None` if included in :python:`bem_data` or to set to zero.
2217
    """
2218
    dims = ['radiating_dof', 'influenced_dof']
6✔
2219
    hydro_data = bem_data.copy(deep=True)
6✔
2220

2221
    if friction is not None:
6✔
2222
        if 'friction' in hydro_data.variables.keys():
6✔
UNCOV
2223
            if not np.allclose(data, hydro_data.variables[name]):
×
UNCOV
2224
                raise ValueError(
×
2225
                        f'Variable "friction" is already in BEM data ' +
2226
                        f'with different values.')
2227
            else:
UNCOV
2228
                _log.warning(
×
2229
                    f'Variable "{name}" is already in BEM data ' +
2230
                    'with same value.')
2231
        else:
2232
            data = atleast_2d(friction)
6✔
2233
            hydro_data['friction'] = (dims, friction)
6✔
2234
    elif friction is None:
6✔
2235
        ndof = len(hydro_data["influenced_dof"])
6✔
2236
        hydro_data['friction'] = (dims, np.zeros([ndof, ndof]))
6✔
2237

2238
    return hydro_data
6✔
2239

2240

2241
def wave_excitation(exc_coeff: DataArray, waves: Dataset) -> ndarray:
6✔
2242
    """Calculate the complex, frequency-domain, excitation force due to
2243
    waves.
2244

2245
    The resulting force is indexed only by frequency and not direction
2246
    angle.
2247
    The input :python:`waves` frequencies must be same as
2248
    :python:`exc_coeff`, but the directions can be a subset.
2249

2250
    Parameters
2251
    ----------
2252
    exc_coeff
2253
        Complex excitation coefficients indexed by frequency and
2254
        direction angle.
2255
    waves
2256
        Complex frequency-domain wave elevation.
2257

2258
    Raises
2259
    ------
2260
    ValueError
2261
        If the frequency vectors of :python:`exc_coeff` and
2262
        :python:`waves` are different.
2263
    ValueError
2264
        If any of the directions in :python:`waves` is not in
2265
        :python:`exc_coeff`.
2266
    """
2267
    omega_w = waves['omega'].values
6✔
2268
    omega_e = exc_coeff['omega'].values
6✔
2269
    dir_w = waves['wave_direction'].values
6✔
2270
    dir_e = exc_coeff['wave_direction'].values
6✔
2271
    exc_coeff = exc_coeff.values
6✔
2272

2273
    wave_elev_fd = np.expand_dims(waves.values, -1)
6✔
2274

2275
    if not np.allclose(omega_w, omega_e):
6✔
2276
        raise ValueError(f"Wave and excitation frequencies do not match. WW: {omega_w}, EE: {omega_e}")
6✔
2277

2278
    subset, sub_ind = subset_close(dir_w, dir_e)
6✔
2279

2280
    if not subset:
6✔
2281
        raise ValueError(
6✔
2282
            "Some wave directions are not in excitation coefficients " +
2283
            f"\n Wave direction(s): {(np.rad2deg(dir_w))} (deg)" +
2284
            f"\n BEM direction(s): {np.rad2deg(dir_e)} (deg).")
2285

2286
    return np.sum(wave_elev_fd*exc_coeff[:, sub_ind, :], axis=1)
6✔
2287

2288

2289
def hydrodynamic_impedance(hydro_data: Dataset) -> Dataset:
6✔
2290
    """Calculate hydrodynamic intrinsic impedance.
2291

2292
    Parameters
2293
    ----------
2294
    hydro_data
2295
        Dataset with linear hydrodynamic coefficients produced by
2296
        :py:func:`wecopttool.add_linear_friction`.
2297
    """
2298

2299
    hydro_impedance = (hydro_data['inertia_matrix'] \
6✔
2300
        + hydro_data['added_mass'])*1j*hydro_data['omega'] \
2301
            + hydro_data['radiation_damping'] + hydro_data['friction'] \
2302
                + hydro_data['hydrostatic_stiffness']/1j/hydro_data['omega']
2303
    return hydro_impedance.transpose('omega', 'radiating_dof', 'influenced_dof')
6✔
2304

2305

2306
def atleast_2d(array: ArrayLike) -> ndarray:
6✔
2307
    """Ensure an array is at least 2D, otherwise add trailing dimensions
2308
    to make it 2D.
2309

2310
    This differs from :py:func:`numpy.atleast_2d` in that the additional
2311
    dimensions are appended at the end rather than at the begining.
2312
    This might be an option in :py:func:`numpy.atleast_2d` in the
2313
    future, see
2314
    `NumPy #12336 <https://github.com/numpy/numpy/issues/12336>`_.
2315

2316
    Parameters
2317
    ----------
2318
    array
2319
        Input array.
2320
    """
2321
    array = np.atleast_1d(array)
6✔
2322
    return np.expand_dims(array, -1) if len(array.shape)==1 else array
6✔
2323

2324

2325
def degrees_to_radians(
6✔
2326
    degrees: FloatOrArray,
2327
    sort: Optional[bool] = True,
2328
) -> Union[float, ndarray]:
2329
    """Convert a 1D array of angles in degrees to radians in the range
2330
    :math:`[-π, π)` and optionally sort them.
2331

2332
    Parameters
2333
    ----------
2334
    degrees
2335
        1D array of angles in degrees.
2336
    sort
2337
        Whether to sort the angles from smallest to largest in
2338
        :math:`[-π, π)`.
2339
    """
2340
    radians = np.asarray(np.remainder(np.deg2rad(degrees), 2*np.pi))
6✔
2341
    radians[radians > np.pi] -= 2*np.pi
6✔
2342
    if radians.size > 1 and sort:
6✔
2343
        radians = np.sort(radians)
6✔
2344
    return radians
6✔
2345

2346

2347
def subset_close(
6✔
2348
    set_a: FloatOrArray,
2349
    set_b: FloatOrArray,
2350
    rtol: float = 1.e-5,
2351
    atol: float = 1.e-8,
2352
    equal_nan: bool = False,
2353
) -> tuple[bool, list]:
2354
    """Check if the first set :python:`set_a` is contained, to some
2355
    tolerance, in the second set :python:`set_b`.
2356

2357
    Parameters
2358
    ----------
2359
    set_a
2360
        First array which is tested for being subset.
2361
    set_b
2362
        Second array which is tested for containing :python:`set_a`.
2363
    rtol
2364
        The relative tolerance parameter. Passed to
2365
        :py:func:`numpy.isclose`.
2366
    atol
2367
        The absolute tolerance parameter. Passed to
2368
        :py:func:`numpy.isclose`.
2369
    equal_nan
2370
        Whether to compare NaNs as equal. Passed to
2371
        :py:func:`numpy.isclose`.
2372

2373
    Returns
2374
    -------
2375
    subset
2376
        Whether the first array is a subset of the second array.
2377
    ind
2378
        List with integer indices where the first array's elements are
2379
        located inside the second array.
2380
        Only contains values if :python:`subset==True`.
2381

2382
    Raises
2383
    ------
2384
    ValueError
2385
        If either of the two arrays contains repeated elements.
2386
    """
2387
    set_a = np.atleast_1d(set_a)
6✔
2388
    set_b = np.atleast_1d(set_b)
6✔
2389

2390
    if len(np.unique(set_a.round(decimals = 6))) != len(set_a):
6✔
UNCOV
2391
        raise ValueError("Elements in set_a not unique")
×
2392
    if len(np.unique(set_b.round(decimals = 6))) != len(set_b):
6✔
UNCOV
2393
        raise ValueError("Elements in set_b not unique")
×
2394

2395
    ind = []
6✔
2396
    for el in set_a:
6✔
2397
        a_in_b = np.isclose(set_b, el,
6✔
2398
                            rtol=rtol, atol=atol, equal_nan=equal_nan)
2399
        if np.sum(a_in_b) == 1:
6✔
2400
            ind.append(np.flatnonzero(a_in_b)[0])
6✔
2401
        if np.sum(a_in_b) > 1:
6✔
UNCOV
2402
            _log.warning('Multiple matching elements in subset, ' +
×
2403
                         'selecting closest match.')
UNCOV
2404
            ind.append(np.argmin(np.abs(a_in_b - el)))
×
2405
    subset = len(set_a) == len(ind)
6✔
2406
    ind = ind if subset else []
6✔
2407
    return subset, ind
6✔
2408

2409

2410
def scale_dofs(scale_list: Iterable[float], ncomponents: int) -> ndarray:
6✔
2411
    """Create a scaling vector based on a different scale for each DOF.
2412

2413
    Returns a 1D array of length :python:`NDOF*ncomponents` where the
2414
    number of DOFs (:python:`NDOF`) is the length of
2415
    :python:`scale_list`.
2416
    The first :python:`ncomponents` entries have the value of the first
2417
    scale :python:`scale_list[0]`, the next :python:`ncomponents`
2418
    entries have the value of the second scale :python:`scale_list[1]`,
2419
    and so on.
2420

2421

2422
    Parameters
2423
    ----------
2424
    scale_list
2425
        Scale for each DOF.
2426
    ncomponents
2427
        Number of elements in the state vector for each DOF.
2428
    """
2429
    ndof = len(scale_list)
6✔
2430
    scale = []
6✔
2431
    for dof in range(ndof):
6✔
2432
        scale += [scale_list[dof]] * ncomponents
6✔
2433
    return np.array(scale)
6✔
2434

2435

2436
def decompose_state(
6✔
2437
    state: ndarray,
2438
    ndof: int,
2439
    nfreq: int,
2440
) -> tuple[ndarray, ndarray]:
2441
    """Split the state vector into the WEC dynamics state and the
2442
    optimization (control) state.
2443

2444
    The WEC dynamics state consists of the Fourier coefficients of
2445
    the position of each degree of freedom.
2446
    The optimization state depends on the chosen control states for
2447
    the problem.
2448

2449
    Parameters
2450
    ----------
2451
    state
2452
        Combined WEC and optimization states.
2453
    ndof
2454
        Number of degrees of freedom for the WEC dynamics.
2455
    nfreq
2456
        Number of frequencies.
2457

2458
    Returns
2459
    -------
2460
    state_wec
2461
        WEC state vector.
2462
    state_opt
2463
        Optimization (control) state.
2464
    """
2465
    nstate_wec = ndof * ncomponents(nfreq)
6✔
2466
    return state[:nstate_wec], state[nstate_wec:]
6✔
2467

2468

2469
def frequency_parameters(
6✔
2470
    freqs: ArrayLike,
2471
    zero_freq: bool = True,
2472
) -> tuple[float, int]:
2473
    """Return the fundamental frequency and the number of frequencies
2474
    in a frequency array.
2475

2476
    This function can be used as a check for inputs to other functions
2477
    since it raises an error if the frequency vector does not have
2478
    the correct format :python:`freqs = [0, f1, 2*f1, ..., nfreq*f1]`
2479
    (or :python:`freqs = [f1, 2*f1, ..., nfreq*f1]` if
2480
    :python:`zero_freq = False`).
2481

2482
    Parameters
2483
    ----------
2484
    freqs
2485
        The frequency array, starting at zero and having equal spacing.
2486
    zero_freq
2487
        Whether the first frequency should be zero.
2488

2489
    Returns
2490
    -------
2491
    f1
2492
        Fundamental frequency :python:`f1` [:math:`Hz`]
2493
    nfreq
2494
        Number of frequencies (not including zero frequency),
2495
        i.e., :python:`freqs = [0, f1, 2*f1, ..., nfreq*f1]`.
2496

2497
    Raises
2498
    ------
2499
    ValueError
2500
        If the frequency vector is not evenly spaced.
2501
    ValueError
2502
        If the zero-frequency was expected but not included or not
2503
        expected but included.
2504
    """
2505
    if np.isclose(freqs[0], 0.0):
6✔
2506
        if zero_freq:
6✔
2507
            freqs0 = freqs[:]
6✔
2508
        else:
2509
            raise ValueError('Zero frequency was included.')
6✔
2510
    else:
2511
        if zero_freq:
6✔
2512
            raise ValueError(
6✔
2513
                'Frequency array must start with the zero frequency.')
2514
        else:
2515
            freqs0 = np.concatenate([[0.0,], freqs])
6✔
2516

2517
    f1 = freqs0[1]
6✔
2518
    nfreq = len(freqs0) - 1
6✔
2519
    f_check = np.arange(0, f1*(nfreq+0.5), f1)
6✔
2520
    if not np.allclose(f_check, freqs0):
6✔
2521
        raise ValueError("Frequency array 'omega' must be evenly spaced by" +
6✔
2522
                         "the fundamental frequency " +
2523
                         "(i.e., 'omega = [0, f1, 2*f1, ..., nfreq*f1]')")
2524
    return f1, nfreq
6✔
2525

2526

2527
def time_results(fd: DataArray, time: DataArray) -> ndarray:
6✔
2528
    """Create a :py:class:`xarray.DataArray` of time-domain results from
2529
    :py:class:`xarray.DataArray` of frequency-domain results.
2530

2531
    Parameters
2532
    ----------
2533
    fd
2534
        Frequency domain response.
2535
    time
2536
        Time array.
2537
    """
2538
    out = np.zeros((*fd.isel(omega=0).shape, len(time)))
6✔
2539
    for w, mag in zip(fd.omega, fd):
6✔
2540
        out = out + \
6✔
2541
            np.real(mag)*np.cos(w*time) - np.imag(mag)*np.sin(w*time)
2542
    return out
6✔
2543

2544

2545
def set_fb_centers(
6✔
2546
    fb: FloatingBody,
2547
    rho: float = _default_parameters["rho"],
2548
) -> FloatingBody:
2549
    """Sets default properties if not provided by the user:
2550
        - `center_of_mass` is set to the geometric centroid
2551
        - `rotation_center` is set to the center of mass
2552
    """
2553
    valid_properties = ['center_of_mass', 'rotation_center']
6✔
2554

2555
    for property in valid_properties:
6✔
2556
        if not hasattr(fb, property):
6✔
2557
            setattr(fb, property, None)
6✔
2558
        if getattr(fb, property) is None:
6✔
2559
            if property == 'center_of_mass':
6✔
2560
                def_val = fb.center_of_buoyancy
6✔
2561
                log_str = (
6✔
2562
                    "Using the geometric centroid as the center of gravity (COG).")
2563
            elif property == 'rotation_center':
6✔
2564
                def_val = fb.center_of_mass
6✔
2565
                log_str = (
6✔
2566
                    "Using the center of gravity (COG) as the rotation center " +
2567
                    "for hydrostatics.")
2568
            setattr(fb, property, def_val)
6✔
2569
            _log.warning(log_str)
6✔
UNCOV
2570
        elif getattr(fb, property) is not None:
×
UNCOV
2571
            _log.warning(
×
2572
                f'{property} already defined as {getattr(fb, property)}.')
2573

2574
    return fb
6✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc