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

sandialabs / WecOptTool / 11295368056

11 Oct 2024 03:48PM UTC coverage: 94.514% (-0.07%) from 94.583%
11295368056

Pull #372

github

web-flow
Merge 94ba997b1 into 0ed470487
Pull Request #372: Adding Pioneer journal paper to reference list

2774 of 2935 relevant lines covered (94.51%)

5.67 hits per line

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

92.68
/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

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
            try:
6✔
762
                wave = wave.squeeze(dim='realization')
6✔
763
            except KeyError:
×
764
                pass
×
765
                      
766
            # objective function
767
            sign = -1.0 if maximize else 1.0
6✔
768

769
            def obj_fun_scaled(x):
6✔
770
                x_wec, x_opt = self.decompose_state(x/scale)
6✔
771
                return obj_fun(self, x_wec, x_opt, wave)*scale_obj*sign
6✔
772

773
            # constraints
774
            constraints = self.constraints.copy()
6✔
775

776
            for i, icons in enumerate(self.constraints):
6✔
777
                icons_new = {"type": icons["type"]}
6✔
778

779
                def make_new_fun(icons):
6✔
780
                    def new_fun(x):
6✔
781
                        x_wec, x_opt = self.decompose_state(x/scale)
6✔
782
                        return icons["fun"](self, x_wec, x_opt, wave)
6✔
783
                    return new_fun
6✔
784

785
                icons_new["fun"] = make_new_fun(icons)
6✔
786
                if use_grad:
6✔
787
                    icons_new['jac'] = jacobian(icons_new['fun'])
6✔
788
                constraints[i] = icons_new
6✔
789

790
            # system dynamics through equality constraint, ma - Σf = 0
791
            def scaled_resid_fun(x):
6✔
792
                x_s = x/scale
6✔
793
                x_wec, x_opt = self.decompose_state(x_s)
6✔
794
                return self.residual(x_wec, x_opt, wave)
6✔
795

796
            eq_cons = {'type': 'eq', 'fun': scaled_resid_fun}
6✔
797
            if use_grad:
6✔
798
                eq_cons['jac'] = jacobian(scaled_resid_fun)
6✔
799
            constraints.append(eq_cons)
6✔
800

801
            # callback
802
            if callback is None:
6✔
803
                def callback_scipy(x):
6✔
804
                    x_wec, x_opt = self.decompose_state(x)
6✔
805
                    max_x_opt = np.nan if np.size(x_opt)==0 else np.max(np.abs(x_opt))
6✔
806
                    _log.info("Scaled [max(x_wec), max(x_opt), obj_fun(x)]: "
6✔
807
                              + f"[{np.max(np.abs(x_wec)):.2e}, "
808
                              + f"{max_x_opt:.2e}, "
809
                              + f"{obj_fun_scaled(x):.2e}]")
810
            else:
811
                def callback_scipy(x):
6✔
812
                    x_s = x/scale
6✔
813
                    x_wec, x_opt = self.decompose_state(x_s)
6✔
814
                    return callback(self, x_wec, x_opt, wave)
6✔
815

816
            # optimization problem
817
            optim_options['disp'] = optim_options.get('disp', True)
6✔
818
            problem = {'fun': obj_fun_scaled,
6✔
819
                        'x0': x0,
820
                        'method': 'SLSQP',
821
                        'constraints': constraints,
822
                        'options': optim_options,
823
                        'bounds': bounds,
824
                        'callback': callback_scipy,
825
                        }
826
            if use_grad:
6✔
827
                problem['jac'] = grad(obj_fun_scaled)
6✔
828

829
            # minimize
830
            optim_res = minimize(**problem)
6✔
831

832
            msg = f'{optim_res.message}    (Exit mode {optim_res.status})'
6✔
833
            if optim_res.status == 0:
6✔
834
                _log.info(msg)
6✔
835
            elif optim_res.status == 9:
6✔
836
                _log.warning(msg)
6✔
837
            else:
838
                raise Exception(msg)
×
839

840
            # unscale
841
            optim_res.x = optim_res.x / scale
6✔
842
            optim_res.fun = optim_res.fun / scale_obj
6✔
843
            optim_res.jac = optim_res.jac / scale_obj * scale
6✔
844

845
            results.append(optim_res)
6✔
846

847
        return results
6✔
848

849
    def post_process(self,
6✔
850
        wec: TWEC,
851
        res: Union[OptimizeResult, Iterable],
852
        waves: Dataset,
853
        nsubsteps: Optional[int] = 1,
854
    ) -> tuple[list[Dataset], list[Dataset]]:
855
        """Post-process the results from :py:meth:`wecopttool.WEC.solve`.
856

857
        Parameters
858
        ----------
859
        wec
860
            :py:class:`wecopttool.WEC` object.
861
        res
862
            Results produced by :py:meth:`wecopttool.WEC.solve`.
863
        waves
864
            :py:class:`xarray.Dataset` with the structure and elements
865
            shown by :py:mod:`wecopttool.waves`.
866
        nsubsteps
867
            Number of steps between the default (implied) time steps.
868
            A value of :python:`1` corresponds to the default step
869
            length.
870

871
        Returns
872
        -------
873
        results_fd
874
            Dynamic responses in the frequency-domain.
875
        results_td
876
            Dynamic responses in the time-domain.
877

878
        Examples
879
        --------
880
        The :py:meth:`wecopttool.WEC.solve` method only returns the
881
        raw results dictionary produced by :py:func:`scipy.optimize.minimize`.
882

883
        >>> res_opt = wec.solve(waves=wave,
884
                                obj_fun=pto.average_power,
885
                                nstate_opt=2*nfreq+1)
886

887
        To get the post-processed results we may call
888

889
        >>> res_wec_fd, res_wec_td = wec.post_process(wec, res_opt,wave)
890

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

897
        def _postproc(res, waves, nsubsteps):
6✔
898
            create_time = f"{datetime.utcnow()}"
6✔
899

900
            omega_vals = np.concatenate([[0], waves.omega.values])
6✔
901
            freq_vals = np.concatenate([[0], waves.freq.values])
6✔
902
            period_vals = np.concatenate([[np.inf], 1/waves.freq.values])
6✔
903
            pos_attr = {'long_name': 'Position', 'units': 'm or rad'}
6✔
904
            vel_attr = {'long_name': 'Velocity', 'units': 'm/s or rad/s'}
6✔
905
            acc_attr = {'long_name': 'Acceleration', 'units': 'm/s^2 or rad/s^2'}
6✔
906
            omega_attr = {'long_name': 'Radial frequency', 'units': 'rad/s'}
6✔
907
            freq_attr = {'long_name': 'Frequency', 'units': 'Hz'}
6✔
908
            period_attr = {'long_name': 'Period', 'units': 's'}
6✔
909
            time_attr = {'long_name': 'Time', 'units': 's'}
6✔
910
            dof_attr = {'long_name': 'Degree of freedom'}
6✔
911
            force_attr = {'long_name': 'Force or moment', 'units': 'N or Nm'}
6✔
912
            wave_elev_attr = {'long_name': 'Wave elevation', 'units': 'm'}
6✔
913
            x_wec, x_opt = self.decompose_state(res.x)
6✔
914
            omega_coord = ("omega", omega_vals, omega_attr)
6✔
915
            freq_coord = ("omega", freq_vals, freq_attr)
6✔
916
            period_coord = ("omega", period_vals, period_attr)
6✔
917
            dof_coord = ("influenced_dof", self.dof_names, dof_attr)
6✔
918

919
            # frequency domain
920
            force_da_list = []
6✔
921
            for name, force in self.forces.items():
6✔
922
                force_td_tmp = force(self, x_wec, x_opt, waves)
6✔
923
                force_fd = self.td_to_fd(force_td_tmp)
6✔
924
                force_da = DataArray(data=force_fd,
6✔
925
                                    dims=["omega", "influenced_dof"],
926
                                    coords={
927
                                        'omega': omega_coord,
928
                                        'freq': freq_coord,
929
                                        'period': period_coord,
930
                                        'influenced_dof': dof_coord},
931
                                    attrs=force_attr
932
                                    ).expand_dims({'type': [name]})
933
                force_da_list.append(force_da)
6✔
934

935
            fd_forces = xr.concat(force_da_list, dim='type')
6✔
936
            fd_forces.type.attrs['long_name'] = 'Type'
6✔
937
            fd_forces.name = 'force'
6✔
938
            fd_forces.attrs['long_name'] = 'Force'
6✔
939

940
            pos = self.vec_to_dofmat(x_wec)
6✔
941
            pos_fd = real_to_complex(pos)
6✔
942

943
            vel = self.derivative_mat @ pos
6✔
944
            vel_fd = real_to_complex(vel)
6✔
945

946
            acc = self.derivative2_mat @ pos
6✔
947
            acc_fd = real_to_complex(acc)
6✔
948

949
            fd_state = Dataset(
6✔
950
                data_vars={
951
                    'pos': (['omega', 'influenced_dof'], pos_fd, pos_attr),
952
                    'vel': (['omega', 'influenced_dof'], vel_fd, vel_attr),
953
                    'acc': (['omega', 'influenced_dof'], acc_fd, acc_attr)},
954
                coords={
955
                    'omega': omega_coord,
956
                    'freq': freq_coord,
957
                    'period': period_coord,
958
                    'influenced_dof': dof_coord},
959
                attrs={"time_created_utc": create_time}
960
            )
961

962
            results_fd = xr.merge([fd_state, fd_forces, waves])
6✔
963
            results_fd = results_fd.transpose('omega', 'influenced_dof', 'type',
6✔
964
                                            'wave_direction')
965
            results_fd = results_fd.fillna(0)
6✔
966

967
            # time domain
968
            t_dat = self.time_nsubsteps(nsubsteps)
6✔
969
            time = DataArray(
6✔
970
                data=t_dat, name='time', dims='time', coords=[t_dat])
971
            results_td = results_fd.map(lambda x: time_results(x, time))
6✔
972

973
            results_td['pos'].attrs = pos_attr
6✔
974
            results_td['vel'].attrs = vel_attr
6✔
975
            results_td['acc'].attrs = acc_attr
6✔
976
            results_td['wave_elev'].attrs = wave_elev_attr
6✔
977
            results_td['force'].attrs = force_attr
6✔
978
            results_td['time'].attrs = time_attr
6✔
979
            results_td.attrs['time_created_utc'] = create_time
6✔
980
            return results_fd, results_td
6✔
981

982
        results_fd = []
6✔
983
        results_td = []
6✔
984
        for idx, ires in enumerate(res):
6✔
985
            ifd, itd = _postproc(ires, waves.sel(realization=idx), nsubsteps)
6✔
986
            results_fd.append(ifd)
6✔
987
            results_td.append(itd)
6✔
988
        return results_fd, results_td
6✔
989

990
    # properties
991
    @property
6✔
992
    def forces(self) -> TForceDict:
6✔
993
        """Dictionary of forces."""
994
        return self._forces
6✔
995

996
    @forces.setter
6✔
997
    def forces(self, val):
6✔
998
        self._forces = dict(val)
×
999

1000
    @property
6✔
1001
    def constraints(self) -> list[dict]:
6✔
1002
        """List of constraints."""
1003
        return self._constraints
6✔
1004

1005
    @constraints.setter
6✔
1006
    def constraints(self, val):
6✔
1007
        self._constraints = list(val)
×
1008

1009
    @property
6✔
1010
    def inertia_in_forces(self) -> bool:
6✔
1011
        """Whether inertial "forces" are included in the
1012
        :python:`forces` dictionary.
1013
        """
1014
        return self._inertia_in_forces
6✔
1015

1016
    @property
6✔
1017
    def inertia_matrix(self) -> ndarray:
6✔
1018
        """Inertia (mass) matrix.
1019
        :python:`None` if  :python:`inertia_in_forces is True`.
1020
        """
1021
        return self._inertia_matrix
×
1022

1023
    @property
6✔
1024
    def inertia(self) -> TStateFunction:
6✔
1025
        """Function representing the inertial term :math:`ma` in the
1026
        WEC's dynamics equation.
1027
        """
1028
        return self._inertia
6✔
1029

1030
    @property
6✔
1031
    def dof_names(self) -> list[str]:
6✔
1032
        """Names of the different degrees of freedom."""
1033
        return self._dof_names
6✔
1034

1035
    @property
6✔
1036
    def ndof(self) -> int:
6✔
1037
        """Number of degrees of freedom."""
1038
        return self._ndof
6✔
1039

1040
    @property
6✔
1041
    def frequency(self) -> ndarray:
6✔
1042
        """Frequency vector [:math:`Hz`]."""
1043
        return self._freq
6✔
1044

1045
    @property
6✔
1046
    def f1(self) -> float:
6✔
1047
        """Fundamental frequency :python:`f1` [:math:`Hz`]."""
1048
        return self._freq[1]
6✔
1049

1050
    @property
6✔
1051
    def nfreq(self) -> int:
6✔
1052
        """Number of frequencies, not including the zero-frequency."""
1053
        return len(self._freq)-1
6✔
1054

1055
    @property
6✔
1056
    def omega(self) -> ndarray:
6✔
1057
        """Radial frequency vector [rad/s]."""
1058
        return self._freq * (2*np.pi)
6✔
1059

1060
    @property
6✔
1061
    def period(self) -> ndarray:
6✔
1062
        """Period vector [s]."""
1063
        return np.concatenate([[np.Infinity], 1/self._freq[1:]])
6✔
1064

1065
    @property
6✔
1066
    def w1(self) -> float:
6✔
1067
        """Fundamental radial frequency [rad/s]."""
1068
        return self.omega[1]
×
1069

1070
    @property
6✔
1071
    def time(self) -> ndarray:
6✔
1072
        """Time vector [s], size '(2*nfreq, ndof)', not containing the
1073
        end time 'tf'."""
1074
        return self._time
6✔
1075

1076
    @property
6✔
1077
    def time_mat(self) -> ndarray:
6✔
1078
        """Matrix to create time-series from Fourier coefficients.
1079

1080
        For some array of Fourier coefficients :python:`x`
1081
        (excluding the sine component of the highest frequency), size
1082
        :python:`(2*nfreq, ndof)`, the time series is obtained via
1083
        :python:`time_mat @ x`, also size
1084
        :python:`(2*nfreq, ndof)`.
1085
        """
1086
        return self._time_mat
6✔
1087

1088
    @property
6✔
1089
    def derivative_mat(self) -> ndarray:
6✔
1090
        """Matrix to create Fourier coefficients of the derivative of
1091
        some quantity.
1092

1093
        For some array of Fourier coefficients :python:`x`
1094
        (excluding the sine component of the highest frequency), size
1095
        :python:`(2*nfreq, ndof)`, the Fourier coefficients of the
1096
        derivative of :python:`x` are obtained via
1097
        :python:`derivative_mat @ x`.
1098
        """
1099
        return self._derivative_mat
6✔
1100

1101
    @property
6✔
1102
    def derivative2_mat(self) -> ndarray:
6✔
1103
        """Matrix to create Fourier coefficients of the second derivative of
1104
        some quantity.
1105

1106
        For some array of Fourier coefficients :python:`x`
1107
        (excluding the sine component of the highest frequency), size
1108
        :python:`(2*nfreq, ndof)`, the Fourier coefficients of the
1109
        second derivative of :python:`x` are obtained via
1110
        :python:`derivative2_mat @ x`.
1111
        """
1112
        return self._derivative2_mat
6✔
1113

1114
    @property
6✔
1115
    def dt(self) -> float:
6✔
1116
        """Time spacing [s]."""
1117
        return self._time[1]
6✔
1118

1119
    @property
6✔
1120
    def tf(self) -> float:
6✔
1121
        """Final time (repeat period) [s]. Not included in
1122
        :python:`time` vector.
1123
        """
1124
        return 1/self.f1
6✔
1125

1126
    @property
6✔
1127
    def nt(self) -> int:
6✔
1128
        """Number of timesteps."""
1129
        return self.ncomponents
6✔
1130

1131
    @property
6✔
1132
    def ncomponents(self) -> int:
6✔
1133
        """Number of Fourier components (:python:`2*nfreq`) for each
1134
        degree of freedom. Note that the sine component of the highest
1135
        frequency (the 2-point wave) is excluded as this will always
1136
        evaluate to zero.
1137
        """
1138
        return ncomponents(self.nfreq)
6✔
1139

1140
    @property
6✔
1141
    def nstate_wec(self) -> int:
6✔
1142
        """Length of the WEC dynamics state vector consisting of the
1143
        Fourier coefficient of the position of each degree of freedom.
1144
        """
1145
        return self.ndof * self.ncomponents
6✔
1146

1147
    # other methods
1148
    def decompose_state(self,
6✔
1149
        state: ndarray
1150
    ) -> tuple[ndarray, ndarray]:
1151
        """Split the state vector into the WEC dynamics state and the
1152
        optimization (control) state.
1153

1154
        Calls :py:func:`wecopttool.decompose_state` with the
1155
        appropriate inputs for the WEC object.
1156

1157
        Examples
1158
        --------
1159
        >>> x_wec, x_opt = wec.decompose_state(x)
1160

1161
        Parameters
1162
        ----------
1163
        state
1164
            Combined WEC and optimization states.
1165

1166
        Returns
1167
        -------
1168
        state_wec
1169
            WEC state vector.
1170
        state_opt
1171
            Optimization (control) state.
1172

1173
        See Also
1174
        --------
1175
        decompose_state
1176
        """
1177
        return decompose_state(state, self.ndof, self.nfreq)
6✔
1178

1179
    def time_nsubsteps(self, nsubsteps: int) -> ndarray:
6✔
1180
        """Create a time vector with finer discretization.
1181

1182
        Calls :py:func:`wecopttool.time` with the appropriate
1183
        inputs for the WEC object.
1184

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

1190
        See Also
1191
        --------
1192
        time, WEC.time
1193
        """
1194
        return time(self.f1, self.nfreq, nsubsteps)
6✔
1195

1196
    def time_mat_nsubsteps(self, nsubsteps: int) -> ndarray:
6✔
1197
        """Create a time matrix similar to
1198
        :py:meth:`wecopttool.WEC.time_mat` but with finer
1199
        time-domain discretization.
1200

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

1204
        Parameters
1205
        ----------
1206
        nsubsteps
1207
            Number of substeps between implied/default time steps.
1208

1209
        See Also
1210
        --------
1211
        time_mat, WEC.time_mat, WEC.time_nsubsteps
1212
        """
1213
        return time_mat(self.f1, self.nfreq, nsubsteps)
6✔
1214

1215
    def vec_to_dofmat(self, vec: ndarray) -> ndarray:
6✔
1216
        """Convert a vector to a matrix with one column per degree of
1217
        freedom.
1218

1219
        Opposite of :py:meth:`wecopttool.WEC.dofmat_to_vec`.
1220

1221
        Calls :py:func:`wecopttool.vec_to_dofmat` with the
1222
        appropriate inputs for the WEC object.
1223

1224
        Examples
1225
        --------
1226
        >>> x_wec, x_opt = wec.decompose_state(x)
1227
        >>> x_wec_mat = wec.vec_to_dofmat(x_wec)
1228

1229
        Parameters
1230
        ----------
1231
        vec
1232
            One-dimensional vector.
1233

1234
        See Also
1235
        --------
1236
        vec_to_dofmat, WEC.dofmat_to_vec
1237
        """
1238
        return vec_to_dofmat(vec, self.ndof)
6✔
1239

1240
    def dofmat_to_vec(self, mat: ndarray) -> ndarray:
6✔
1241
        """Flatten a matrix to a vector.
1242

1243
        Opposite of :py:meth:`wecopttool.WEC.vec_to_dofmat`.
1244

1245
        Calls :py:func:`wecopttool.dofmat_to_vec` with the
1246
        appropriate inputs for the WEC object.
1247

1248
        Parameters
1249
        ----------
1250
        mat
1251
            Matrix with one column per degree of freedom.
1252

1253
        See Also
1254
        --------
1255
        dofmat_to_vec, WEC.vec_to_dofmat
1256
        """
1257
        return dofmat_to_vec(mat)
6✔
1258

1259
    def fd_to_td(self, fd: ndarray) -> ndarray:
6✔
1260
        """Convert a frequency-domain array to time-domain.
1261

1262
        Opposite of :py:meth:`wecopttool.WEC.td_to_fd`.
1263

1264
        Calls :py:func:`wecopttool.fd_to_td` with the appropriate inputs
1265
        for the WEC object.
1266

1267
        Parameters
1268
        ----------
1269
        fd
1270
            Frequency-domain complex array with shape
1271
            :python:`(WEC.nfreq+1, N)` for any :python:`N`.
1272

1273
        See Also
1274
        --------
1275
        fd_to_td, WEC.td_to_fd
1276
        """
1277
        return fd_to_td(fd, self.f1, self.nfreq, True)
×
1278

1279
    def td_to_fd(
6✔
1280
        self,
1281
        td: ndarray,
1282
        fft: Optional[bool] = True,
1283
        ) -> ndarray:
1284
        """Convert a time-domain array to frequency-domain.
1285

1286
        Opposite of :py:meth:`wecopttool.WEC.fd_to_td`.
1287

1288
        Calls :py:func:`wecopttool.fd_to_td` with the appropriate
1289
        inputs for the WEC object.
1290

1291
        Parameters
1292
        ----------
1293
        td
1294
            Time-domain real array with shape
1295
            :python:`(2*WEC.nfreq, N)` for any :python:`N`.
1296
        fft
1297
            Whether to use the real FFT.
1298

1299
        See Also
1300
        --------
1301
        td_to_fd, WEC.fd_to_td
1302
        """
1303
        return td_to_fd(td, fft, True)
6✔
1304

1305

1306
def ncomponents(
6✔
1307
    nfreq : int,
1308
    zero_freq: Optional[bool] = True,
1309
) -> int:
1310
    """Number of Fourier components (:python:`2*nfreq`) for each
1311
    DOF. The sine component of the highest frequency (the 2-point wave)
1312
    is excluded as it will always evaluate to zero.
1313

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

1317
    Parameters
1318
    ----------
1319
    nfreq
1320
        Number of frequencies.
1321
    zero_freq
1322
        Whether to include the zero-frequency.
1323
    """
1324
    ncomp = 2*nfreq - 1
6✔
1325
    if zero_freq:
6✔
1326
        ncomp = ncomp + 1
6✔
1327
    return ncomp
6✔
1328

1329

1330
def frequency(
6✔
1331
    f1: float,
1332
    nfreq: int,
1333
    zero_freq: Optional[bool] = True,
1334
) -> ndarray:
1335
    """Construct equally spaced frequency array.
1336

1337
    The array includes :python:`0` and has length of :python:`nfreq+1`.
1338
    :python:`f1` is fundamental frequency (1st harmonic).
1339

1340
    Returns the frequency array, e.g.,
1341
    :python:`freqs = [0, f1, 2*f1, ..., nfreq*f1]`.
1342

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

1346
    Parameters
1347
    ----------
1348
    f1
1349
        Fundamental frequency :python:`f1` [:math:`Hz`].
1350
    nfreq
1351
        Number of frequencies.
1352
    zero_freq
1353
        Whether to include the zero-frequency.
1354
    """
1355
    freq = np.arange(0, nfreq+1)*f1
6✔
1356
    freq = freq[1:] if not zero_freq else freq
6✔
1357
    return freq
6✔
1358

1359

1360
def time(
6✔
1361
    f1: float,
1362
    nfreq: int,
1363
    nsubsteps: Optional[int] = 1,
1364
) -> ndarray:
1365
    """Assemble the time vector with :python:`nsubsteps` subdivisions.
1366

1367
    Returns the 1D time vector, in seconds, starting at time
1368
    :python:`0`, and not containing the end time :python:`tf=1/f1`.
1369
    The time vector has length :python:`(2*nfreq)*nsubsteps`.
1370
    The timestep length is :python:`dt = dt_default * 1/nsubsteps`,
1371
    where :python:`dt_default=tf/(2*nfreq)`.
1372

1373
    Parameters
1374
    ----------
1375
    f1
1376
        Fundamental frequency :python:`f1` [:math:`Hz`].
1377
    nfreq
1378
        Number of frequencies.
1379
    nsubsteps
1380
        Number of steps between the default (implied) time steps.
1381
        A value of :python:`1` corresponds to the default step length.
1382
    """
1383
    if nsubsteps < 1:
6✔
1384
        raise ValueError("'nsubsteps' must be 1 or greater")
×
1385
    nsteps = nsubsteps * ncomponents(nfreq)
6✔
1386
    return np.linspace(0, 1/f1, nsteps, endpoint=False)
6✔
1387

1388

1389
def time_mat(
6✔
1390
    f1: float,
1391
    nfreq: int,
1392
    nsubsteps: Optional[int] = 1,
1393
    zero_freq: Optional[bool] = True,
1394
) -> ndarray:
1395
    """Assemble the time matrix that converts the state to a
1396
    time-series.
1397

1398
    For a state :math:`x` consisting of the mean (DC) component
1399
    followed by the real and imaginary components of the Fourier
1400
    coefficients (excluding the imaginary component of the 2-point wave) as
1401
    :math:`x=[X0, Re(X1), Im(X1), ..., Re(Xn)]`,
1402
    the response vector in the time-domain (:math:`x(t)`) is given as
1403
    :math:`Mx`, where :math:`M` is the time matrix.
1404

1405
    The time matrix has size :python:`(nfreq*2, nfreq*2)`.
1406

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

1410
    Parameters
1411
    ---------
1412
    f1
1413
        Fundamental frequency :python:`f1` [:math:`Hz`].
1414
    nfreq
1415
        Number of frequencies.
1416
    nsubsteps
1417
        Number of steps between the default (implied) time steps.
1418
        A value of :python:`1` corresponds to the default step length.
1419
    zero_freq
1420
        Whether the first frequency should be zero.
1421
    """
1422
    t = time(f1, nfreq, nsubsteps)
6✔
1423
    omega = frequency(f1, nfreq) * 2*np.pi
6✔
1424
    wt = np.outer(t, omega[1:])
6✔
1425
    ncomp = ncomponents(nfreq)
6✔
1426
    time_mat = np.empty((nsubsteps*ncomp, ncomp))
6✔
1427
    time_mat[:, 0] = 1.0
6✔
1428
    time_mat[:, 1::2] = np.cos(wt)
6✔
1429
    time_mat[:, 2::2] = -np.sin(wt[:, :-1]) # remove 2pt wave sine component
6✔
1430
    if not zero_freq:
6✔
1431
        time_mat = time_mat[:, 1:]
6✔
1432
    return time_mat
6✔
1433

1434

1435
def derivative_mat(
6✔
1436
    f1: float,
1437
    nfreq: int,
1438
    zero_freq: Optional[bool] = True,
1439
) -> ndarray:
1440
    """Assemble the derivative matrix that converts the state vector of
1441
    a response to the state vector of its derivative.
1442

1443
    For a state :math:`x` consisting of the mean (DC) component
1444
    followed by the real and imaginary components of the Fourier
1445
    coefficients (excluding the imaginary component of the 2-point wave) as
1446
    :math:`x=[X0, Re(X1), Im(X1), ..., Re(Xn)]`,
1447
    the state of its derivative is given as :math:`Dx`, where
1448
    :math:`D` is the derivative matrix.
1449

1450
    The time matrix has size :python:`(nfreq*2, nfreq*2)`.
1451

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

1455
    Parameters
1456
    ---------
1457
    f1
1458
        Fundamental frequency :python:`f1` [:math:`Hz`].
1459
    nfreq
1460
        Number of frequencies.
1461
    zero_freq
1462
        Whether the first frequency should be zero.
1463
    """
1464
    def block(n): return np.array([[0, -1], [1, 0]]) * n*f1 * 2*np.pi
6✔
1465
    blocks = [block(n+1) for n in range(nfreq)]
6✔
1466
    if zero_freq:
6✔
1467
        blocks = [0.0] + blocks
6✔
1468
    deriv_mat = block_diag(*blocks)
6✔
1469
    return deriv_mat[:-1, :-1] # remove 2pt wave sine component
6✔
1470

1471

1472
def derivative2_mat(
6✔
1473
    f1: float,
1474
    nfreq: int,
1475
    zero_freq: Optional[bool] = True,
1476
) -> ndarray:
1477
    """Assemble the second derivative matrix that converts the state vector of
1478
    a response to the state vector of its second derivative.
1479

1480
    For a state :math:`x` consisting of the mean (DC) component
1481
    followed by the real and imaginary components of the Fourier
1482
    coefficients (excluding the imaginary component of the 2-point wave) as
1483
    :math:`x=[X0, Re(X1), Im(X1), ..., Re(Xn)]`,
1484
    the state of its second derivative is given as :math:`(DD)x`, where
1485
    :math:`DD` is the second derivative matrix.
1486

1487
    The time matrix has size :python:`(nfreq*2, nfreq*2)`.
1488

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

1492
    Parameters
1493
    ---------
1494
    f1
1495
        Fundamental frequency :python:`f1` [:math:`Hz`].
1496
    nfreq
1497
        Number of frequencies.
1498
    zero_freq
1499
        Whether the first frequency should be zero.
1500
    """
1501
    vals = [((n+1)*f1 * 2*np.pi)**2 for n in range(nfreq)]
6✔
1502
    diagonal = np.repeat(-np.ones(nfreq) * vals, 2)[:-1] # remove 2pt wave sine
6✔
1503
    if zero_freq:
6✔
1504
        diagonal = np.concatenate(([0.0], diagonal))
6✔
1505
    return np.diag(diagonal)
6✔
1506

1507

1508
def mimo_transfer_mat(
6✔
1509
    transfer_mat: DataArray,
1510
    zero_freq: Optional[bool] = True,
1511
) -> ndarray:
1512
    """Create a block matrix of the MIMO transfer function.
1513

1514
    The input is a complex transfer matrix that relates the complex
1515
    Fourier representation of two variables.
1516
    For example, it can be an impedance matrix or an RAO transfer
1517
    matrix.
1518
    The input complex impedance matrix has shape
1519
    :python`(nfreq, ndof, ndof)`.
1520

1521
    Returns the 2D real matrix that transform the state representation
1522
    of the input variable variable to the state representation of the
1523
    output variable.
1524
    Here, a state representation :python:`x` consists of the mean (DC)
1525
    component followed by the real and imaginary components of the
1526
    Fourier coefficients (excluding the imaginary component of the
1527
    2-point wave) as
1528
    :python:`x=[X0, Re(X1), Im(X1), ..., Re(Xn)]`.
1529

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

1533
    Parameters
1534
    ----------
1535
    transfer_mat
1536
        Complex transfer matrix.
1537
    zero_freq
1538
        Whether the first frequency should be zero.
1539
    """
1540
    ndof = transfer_mat.shape[1]
6✔
1541
    assert transfer_mat.shape[2] == ndof
6✔
1542
    elem = [[None]*ndof for _ in range(ndof)]
6✔
1543
    def block(re, im): return np.array([[re, -im], [im, re]])
6✔
1544
    for idof in range(ndof):
6✔
1545
        for jdof in range(ndof):
6✔
1546
            if zero_freq:
6✔
1547
                Zp0 = transfer_mat[0, idof, jdof]
6✔
1548
                assert np.all(np.isreal(Zp0))
6✔
1549
                Zp0 = np.real(Zp0)
6✔
1550
                Zp = transfer_mat[1:, idof, jdof]
6✔
1551
            else:
1552
                Zp0 = [0.0]
6✔
1553
                Zp = transfer_mat[:, idof, jdof]
6✔
1554
            re = np.real(Zp)
6✔
1555
            im = np.imag(Zp)
6✔
1556
            blocks = [block(ire, iim) for (ire, iim) in zip(re[:-1], im[:-1])]
6✔
1557
            blocks = [Zp0] + blocks + [re[-1]]
6✔
1558
            elem[idof][jdof] = block_diag(*blocks)
6✔
1559
    return np.block(elem)
6✔
1560

1561

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

1565
    Returns a matrix with :python:`ndof` columns.
1566
    The number of rows is inferred from the size of the input vector.
1567

1568
    Opposite of :py:func:`wecopttool.dofmat_to_vec`.
1569

1570
    Parameters
1571
    ----------
1572
    vec
1573
        1D array consisting of concatenated arrays of several DOFs, as
1574
        :python:`vec = [vec_1, vec_2, ..., vec_ndof]`.
1575
    ndof
1576
        Number of degrees of freedom.
1577

1578
    See Also
1579
    --------
1580
    dofmat_to_vec,
1581
    """
1582
    return np.reshape(vec, (-1, ndof), order='F')
6✔
1583

1584

1585
def dofmat_to_vec(mat: ArrayLike) -> ndarray:
6✔
1586
    """Flatten a matrix that has one column per DOF.
1587

1588
    Returns a 1D vector.
1589

1590
    Opposite of :py:func:`wecopttool.vec_to_dofmat`.
1591

1592
    Parameters
1593
    ----------
1594
    mat
1595
        Matrix to be flattened.
1596

1597
    See Also
1598
    --------
1599
    vec_to_dofmat,
1600
    """
1601
    return np.reshape(mat, -1, order='F')
6✔
1602

1603

1604
def real_to_complex(
6✔
1605
    fd: ArrayLike,
1606
    zero_freq: Optional[bool] = True,
1607
) -> ndarray:
1608
    """Convert from two real amplitudes to one complex amplitude per
1609
    frequency.
1610

1611
    The input is a real 2D array with each column containing the real
1612
    and imaginary components of the Fourier coefficients for some
1613
    response, excluding the imaginary component of the highest frequency
1614
    (2-point wave).
1615
    The column length is :python:`2*nfreq`.
1616
    The entries of a column representing a response :python:`x` are
1617
    :python:`x=[X0, Re(X1), Im(X1), ..., Re(Xn)]`.
1618

1619
    Returns a complex 2D array with each column containing the complex
1620
    Fourier coefficients.
1621
    Columns are length :python:`nfreq+1`, and the first row corresponds
1622
    to the real-valued zero-frequency (mean, DC) components.
1623
    The entries of a column representing a response :python:`x` are
1624
    :python:`x=[X0, X1, ..., Xn]`.
1625

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

1629
    Parameters
1630
    ----------
1631
    fd
1632
        Array containing the real and imaginary components of the
1633
        Fourier coefficients.
1634
    zero_freq
1635
        Whether the mean (DC) component is included.
1636

1637
    See Also
1638
    --------
1639
    complex_to_real,
1640
    """
1641
    fd= atleast_2d(fd)
6✔
1642
    if zero_freq:
6✔
1643
        assert fd.shape[0]%2==0
6✔
1644
        mean = fd[0:1, :]
6✔
1645
        fd = fd[1:, :]
6✔
1646
    fdc = np.append(fd[0:-1:2, :] + 1j*fd[1::2, :],
6✔
1647
                    [fd[-1, :]], axis=0)
1648
    if zero_freq:
6✔
1649
        fdc = np.concatenate((mean, fdc), axis=0)
6✔
1650
    return fdc
6✔
1651

1652

1653
def complex_to_real(
6✔
1654
    fd: ArrayLike,
1655
    zero_freq: Optional[bool] = True,
1656
) -> ndarray:
1657
    """Convert from one complex amplitude to two real amplitudes per
1658
    frequency.
1659

1660
    The input is a complex 2D array with each column containing the
1661
    Fourier coefficients for some response.
1662
    Columns are length :python:`nfreq+1`, and the first row corresponds
1663
    to the real-valued zero-frequency (mean, DC) components.
1664
    The entries of a column representing a response :python:`x` are
1665
    :python:`x=[X0, X1, ..., Xn]`.
1666

1667
    Returns a real 2D array with each column containing the real and
1668
    imaginary components of the Fourier coefficients. The imaginary component
1669
    of the highest frequency (the 2-point wave) is excluded, as it will
1670
    always evaluate to zero.
1671
    The column length is :python:`2*nfreq`.
1672
    The entries of a column representing a response :python:`x` are
1673
    :python:`x=[X0, Re(X1), Im(X1), ..., Re(Xn)]`.
1674

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

1678
    Parameters
1679
    ----------
1680
    fd
1681
        Array containing the complex Fourier coefficients.
1682
    zero_freq
1683
        Whether the mean (DC) component is included.
1684

1685
    See Also
1686
    --------
1687
    real_to_complex,
1688
    """
1689
    fd = atleast_2d(fd)
6✔
1690
    nfreq = fd.shape[0] - 1 if zero_freq else fd.shape[0]
6✔
1691
    ndof = fd.shape[1]
6✔
1692
    if zero_freq:
6✔
1693
        assert np.all(np.isreal(fd[0, :]))
6✔
1694
        a = np.real(fd[0:1, :])
6✔
1695
        b = np.real(fd[1:-1, :])
6✔
1696
        c = np.imag(fd[1:-1, :])
6✔
1697
        d = np.real(fd[-1:, :])
6✔
1698
    else:
1699
        b = np.real(fd[:-1, :])
6✔
1700
        c = np.imag(fd[:-1, :])
6✔
1701
        d = np.real(fd[-1:, :])
6✔
1702
    out = np.concatenate([np.transpose(b), np.transpose(c)])
6✔
1703
    out = np.reshape(np.reshape(out, [-1], order='F'), [-1, ndof])
6✔
1704
    if zero_freq:
6✔
1705
        out = np.concatenate([a, out, d])
6✔
1706
        assert out.shape == (2*nfreq, ndof)
6✔
1707
    else:
1708
        out = np.concatenate([out, d])
6✔
1709
        assert out.shape == (2*nfreq-1, ndof)
6✔
1710
    return out
6✔
1711

1712

1713
def fd_to_td(
6✔
1714
    fd: ArrayLike,
1715
    f1: Optional[float] = None,
1716
    nfreq: Optional[int] = None,
1717
    zero_freq: Optional[bool] = True,
1718
) -> ndarray:
1719
    """Convert a complex array of Fourier coefficients to a real array
1720
    of time-domain responses.
1721

1722
    The input is a complex 2D array with each column containing the
1723
    Fourier coefficients for some response.
1724
    Columns are length :python:`nfreq+1`, and the first row corresponds
1725
    to the real-valued zero-frequency (mean, DC) components.
1726
    The entries of a column representing a response :python:`x` are
1727
    :python:`x=[X0, X1, ..., Xn]`.
1728

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

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

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

1742
    Opposite of :py:meth:`wecopttool.td_to_fd`.
1743

1744
    Parameters
1745
    ----------
1746
    fd
1747
        Array containing the complex Fourier coefficients.
1748
    f1
1749
        Fundamental frequency :python:`f1` [:math:`Hz`].
1750
    nfreq
1751
        Number of frequencies.
1752
    zero_freq
1753
        Whether the mean (DC) component is included.
1754

1755
    Raises
1756
    ------
1757
    ValueError
1758
        If only one of :python:`f1` or :python:`nfreq` is provided.
1759
        Must provide both or neither.
1760

1761
    See Also
1762
    --------
1763
    td_to_fd, time, time_mat
1764
    """
1765
    fd = atleast_2d(fd)
6✔
1766

1767
    if zero_freq:
6✔
1768
        msg = "The first row must be real when `zero_freq=True`."
6✔
1769
        assert np.allclose(np.imag(fd[0, :]), 0), msg
6✔
1770

1771
    if (f1 is not None) and (nfreq is not None):
6✔
1772
        tmat = time_mat(f1, nfreq, zero_freq=zero_freq)
6✔
1773
        td = tmat @ complex_to_real(fd, zero_freq)
6✔
1774
    elif (f1 is None) and (nfreq is None):
6✔
1775
        n = 2*(fd.shape[0]-1)
6✔
1776
        fd = np.concatenate((fd[:1, :], fd[1:-1, :]/2, fd[-1:, :]))
6✔
1777
        td = np.fft.irfft(fd, n=n, axis=0, norm='forward')
6✔
1778
    else:
1779
        raise ValueError(
×
1780
            "Provide either both 'f1' and 'nfreq' or neither.")
1781
    return td
6✔
1782

1783

1784
def td_to_fd(
6✔
1785
    td: ArrayLike,
1786
    fft: Optional[bool] = True,
1787
    zero_freq: Optional[bool] = True,
1788
) -> ndarray:
1789
    """Convert a real array of time-domain responses to a complex array
1790
    of Fourier coefficients.
1791

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

1795
    Opposite of :py:func:`wecopttool.fd_to_td`
1796

1797
    Parameters
1798
    ----------
1799
    td
1800
        Real array of time-domains responses.
1801
    fft
1802
        Whether to use the real FFT.
1803
    zero_freq
1804
        Whether the mean (DC) component is returned.
1805

1806
    See Also
1807
    --------
1808
    fd_to_td
1809
    """
1810
    td= atleast_2d(td)
6✔
1811
    n = td.shape[0]
6✔
1812
    if fft:
6✔
1813
        fd = np.fft.rfft(td, n=n, axis=0, norm='forward')
6✔
1814
    else:
1815
        fd = np.dot(dft(n, 'n')[:n//2+1, :], td)
×
1816
    fd = np.concatenate((fd[:1, :], fd[1:-1, :]*2, fd[-1:, :]))
6✔
1817
    if not zero_freq:
6✔
1818
        fd = fd[1:, :]
×
1819
    return fd
6✔
1820

1821

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

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

1829
    Parameters
1830
    ----------
1831
    fpath
1832
        Path to the *NetCDF* file.
1833

1834
    See Also
1835
    --------
1836
    write_netcdf,
1837
    """
1838
    with xr.open_dataset(fpath) as ds:
6✔
1839
        ds.load()
6✔
1840
    return cpy.io.xarray.merge_complex_values(ds)
6✔
1841

1842

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

1847
    Can handle complex entries in the *NetCDF* by using
1848
    :py:func:`capytaine.io.xarray.separate_complex_values`
1849

1850
    Parameters
1851
    ----------
1852
    fpath
1853
        Name of file to save.
1854
    data
1855
        Dataset to save.
1856

1857
    See Also
1858
    --------
1859
    read_netcdf,
1860
    """
1861
    cpy.io.xarray.separate_complex_values(data).to_netcdf(fpath)
6✔
1862

1863

1864
def check_radiation_damping(
6✔
1865
    hydro_data: Dataset,
1866
    min_damping: Optional[float] = 1e-6,
1867
    uniform_shift: Optional[bool] = False,
1868
) -> Dataset:
1869
    """Ensure that the linear hydrodynamics (friction + radiation
1870
    damping) have positive damping.
1871

1872
    Shifts the :python:`friction` or :python:`radiation_damping` up
1873
    if necessary. Returns the (possibly) updated Dataset with
1874
    :python:`damping` :math:`>=` :python:`min_damping`.
1875

1876
    Parameters
1877
    ----------
1878
    hydro_data
1879
        Linear hydrodynamic data.
1880
    min_damping
1881
        Minimum threshold for damping. Default is 1e-6.
1882
    uniform_shift
1883
        Boolean that determines whether the damping correction for each
1884
        degree of freedom is frequency dependent or not. If :python:`True`,
1885
        the damping correction is applied to :python:`friction` and shifts the
1886
        damping for all frequencies. If :python:`False`, the damping correction
1887
        is applied to :python:`radiation_damping` and only shifts the
1888
        damping for frequencies with negative damping values. Default is
1889
        :python:`False`.
1890
    """
1891
    hydro_data_new = hydro_data.copy(deep=True)
6✔
1892
    radiation = hydro_data_new['radiation_damping']
6✔
1893
    friction = hydro_data_new['friction']
6✔
1894
    ndof = len(hydro_data_new.influenced_dof)
6✔
1895
    assert ndof == len(hydro_data.radiating_dof)
6✔
1896
    for idof in range(ndof):
6✔
1897
        iradiation = radiation.isel(radiating_dof=idof, influenced_dof=idof)
6✔
1898
        ifriction = friction.isel(radiating_dof=idof, influenced_dof=idof)
6✔
1899
        if uniform_shift:
6✔
1900
            dmin = (iradiation+ifriction).min()
6✔
1901
            if dmin <= 0.0 + min_damping:
6✔
1902
                dof = hydro_data_new.influenced_dof.values[idof]
6✔
1903
                delta = min_damping-dmin
6✔
1904
                _log.warning(
6✔
1905
                    f'Linear damping for DOF "{dof}" has negative or close ' +
1906
                    'to zero terms. Shifting up radiation damping by ' +
1907
                    f'{delta.values} N/(m/s).')
1908
                hydro_data_new['radiation_damping'][:, idof, idof] = (iradiation + delta)
6✔
1909
        else:
1910
            new_damping = iradiation.where(
6✔
1911
                iradiation+ifriction>min_damping, other=min_damping)
1912
            dof = hydro_data_new.influenced_dof.values[idof]
6✔
1913
            if (new_damping==min_damping).any():
6✔
1914
                _log.warning(
6✔
1915
                    f'Linear damping for DOF "{dof}" has negative or close to ' +
1916
                    'zero terms. Shifting up damping terms ' +
1917
                    f'{np.where(new_damping==min_damping)[0]} to a minimum of ' +
1918
                    f'{min_damping} N/(m/s)')
1919
            hydro_data_new['radiation_damping'][:, idof, idof] = new_damping
6✔
1920
    return hydro_data_new
6✔
1921

1922

1923
def check_impedance(
6✔
1924
    Zi: DataArray,
1925
    min_damping: Optional[float] = 1e-6,
1926
    uniform_shift: Optional[bool] = False,
1927
) -> DataArray:
1928
    """Ensure that the real part of the impedance (resistive) is positive.
1929

1930
    Adds to real part of the impedance.
1931
    Returns the (possibly) updated impedance with
1932
    :math:`Re(Zi)>=` :python:`min_damping`.
1933

1934
    Parameters
1935
    ----------
1936
    Zi
1937
        Linear hydrodynamic impedance.
1938
    min_damping
1939
        Minimum threshold for damping. Default is 1e-6.
1940
    """
1941
    Zi_diag = np.diagonal(Zi,axis1=1,axis2=2)
6✔
1942
    Zi_shifted = Zi.copy()
6✔
1943
    for dof in range(Zi_diag.shape[1]):
6✔
1944
        if uniform_shift:
6✔
1945
            dmin = np.min(np.real(Zi_diag[:, dof]))
×
1946
            if dmin < min_damping:
×
1947
                delta = min_damping - dmin
×
1948
                Zi_shifted[:, dof, dof] = Zi_diag[:, dof] \
×
1949
                    + np.abs(delta)
1950
                _log.warning(
×
1951
                    f'Real part of impedance for {dof} has negative or close to ' +
1952
                    f'zero terms. Shifting up by {delta:.2f}')
1953
        else:
1954
            points = np.where(np.real(Zi_diag[:, dof])<min_damping)
6✔
1955
            Zi_dof_real = Zi_diag[:,dof].real.copy()
6✔
1956
            Zi_dof_imag = Zi_diag[:,dof].imag.copy()
6✔
1957
            Zi_dof_real[Zi_dof_real < min_damping] = min_damping
6✔
1958
            Zi_shifted[:, dof, dof] = Zi_dof_real + Zi_dof_imag*1j
6✔
1959
            if (Zi_dof_real==min_damping).any():
6✔
1960
                _log.warning(
6✔
1961
                    f'Real part of impedance for {dof} has negative or close to ' +
1962
                    f'zero terms. Shifting up elements '
1963
                    f'{np.where(Zi_dof_real==min_damping)[0]} to a minimum of ' +
1964
                    f' {min_damping} N/(m/s)')
1965
    return Zi_shifted
6✔
1966

1967

1968
def force_from_rao_transfer_function(
6✔
1969
    rao_transfer_mat: DataArray,
1970
    zero_freq: Optional[bool] = True,
1971
) -> TStateFunction:
1972
    """Create a force function from its position transfer matrix.
1973

1974
    This is the position equivalent to the velocity-based
1975
    :py:func:`wecopttool.force_from_impedance`.
1976

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

1980
    Parameters
1981
    ----------
1982
    rao_transfer_mat
1983
        Complex position transfer matrix.
1984
    zero_freq
1985
        Whether the first frequency should be zero. Default is
1986
        :python:`True`.
1987

1988
    See Also
1989
    --------
1990
    force_from_impedance,
1991
    """
1992
    def force(wec, x_wec, x_opt, waves):
6✔
1993
        transfer_mat = mimo_transfer_mat(rao_transfer_mat, zero_freq)
6✔
1994
        force_fd = wec.vec_to_dofmat(np.dot(transfer_mat, x_wec))
6✔
1995
        return np.dot(wec.time_mat, force_fd)
6✔
1996
    return force
6✔
1997

1998

1999
def force_from_impedance(
6✔
2000
    omega: ArrayLike,
2001
    impedance: DataArray,
2002
) -> TStateFunction:
2003
    """Create a force function from its impedance.
2004

2005
    Parameters
2006
    ----------
2007
    omega
2008
        Radial frequency vector.
2009
    impedance
2010
        Complex impedance matrix.
2011

2012
    See Also
2013
    --------
2014
    force_from_rao_transfer_function,
2015
    """
2016
    return force_from_rao_transfer_function(impedance*(1j*omega), False)
6✔
2017

2018

2019
def force_from_waves(force_coeff: DataArray,
6✔
2020
                     ) -> TStateFunction:
2021
    """Create a force function from waves excitation coefficients.
2022

2023
    Parameters
2024
    ----------
2025
    force_coeff
2026
        Complex excitation coefficients indexed by frequency and
2027
        direction angle.
2028
    """
2029
    def force(wec, x_wec, x_opt, waves):
6✔
2030
        force_fd = complex_to_real(wave_excitation(force_coeff, waves), False)
6✔
2031
        return np.dot(wec.time_mat[:, 1:], force_fd)
6✔
2032
    return force
6✔
2033

2034

2035
def inertia(
6✔
2036
    f1: float,
2037
    nfreq: int,
2038
    inertia_matrix: ArrayLike,
2039
) -> TStateFunction:
2040
    """Create the inertia "force" from the inertia matrix.
2041

2042
    Parameters
2043
    ----------
2044
    f1
2045
        Fundamental frequency :python:`f1` [:math:`Hz`].
2046
    nfreq
2047
        Number of frequencies.
2048
    inertia_matrix
2049
        Inertia matrix.
2050
    """
2051
    omega = np.expand_dims(frequency(f1, nfreq, False)*2*np.pi, [1,2])
6✔
2052
    inertia_matrix = np.expand_dims(inertia_matrix, 0)
6✔
2053
    rao_transfer_function = -1*omega**2*inertia_matrix + 0j
6✔
2054
    inertia_fun = force_from_rao_transfer_function(
6✔
2055
        rao_transfer_function, False)
2056
    return inertia_fun
6✔
2057

2058

2059
def standard_forces(hydro_data: Dataset) -> TForceDict:
6✔
2060
    """Create functions for linear hydrodynamic forces.
2061

2062
    Returns a dictionary with the standard linear forces:
2063
    radiation, hydrostatic, friction, Froude—Krylov, and diffraction.
2064
    The functions are type :python:`StateFunction` (see Type Aliases in
2065
    API Documentation).
2066

2067
    Parameters
2068
    ----------
2069
    hydro_data
2070
        Linear hydrodynamic data.
2071
    """
2072

2073
    # intrinsic impedance
2074
    w = hydro_data['omega']
6✔
2075
    A = hydro_data['added_mass']
6✔
2076
    B = hydro_data['radiation_damping']
6✔
2077
    K = hydro_data['hydrostatic_stiffness']
6✔
2078
    Bf = hydro_data['friction']
6✔
2079

2080
    rao_transfer_functions = dict()
6✔
2081
    rao_transfer_functions['radiation'] = (1j*w*B + -1*w**2*A, False)
6✔
2082
    rao_transfer_functions['friction'] = (1j*w*Bf, False)
6✔
2083

2084
    # include zero_freq in hydrostatics
2085
    hs = ((K + 0j).expand_dims({"omega": B.omega}, 0))
6✔
2086
    tmp = hs.isel(omega=0).copy(deep=True)
6✔
2087
    tmp['omega'] = tmp['omega'] * 0
6✔
2088
    hs = xr.concat([tmp, hs], dim='omega') #, data_vars='minimal')
6✔
2089
    rao_transfer_functions['hydrostatics'] = (hs, True)
6✔
2090

2091
    linear_force_functions = dict()
6✔
2092
    for name, (value, zero_freq) in rao_transfer_functions.items():
6✔
2093
        value = value.transpose("omega", "radiating_dof", "influenced_dof")
6✔
2094
        value = -1*value  # RHS of equation: ma = Σf
6✔
2095
        linear_force_functions[name] = (
6✔
2096
            force_from_rao_transfer_function(value, zero_freq))
2097

2098
    # wave excitation
2099
    excitation_coefficients = {
6✔
2100
        'Froude_Krylov': hydro_data['Froude_Krylov_force'],
2101
        'diffraction': hydro_data['diffraction_force']
2102
    }
2103

2104
    for name, value in excitation_coefficients.items():
6✔
2105
        linear_force_functions[name] = force_from_waves(value)
6✔
2106

2107
    return linear_force_functions
6✔
2108

2109

2110
def run_bem(
6✔
2111
    fb: cpy.FloatingBody,
2112
    freq: Iterable[float] = [np.infty],
2113
    wave_dirs: Iterable[float] = [0],
2114
    rho: float = _default_parameters['rho'],
2115
    g: float = _default_parameters['g'],
2116
    depth: float = _default_parameters['depth'],
2117
    write_info: Optional[Mapping[str, bool]] = None,
2118
    njobs: int = 1,
2119
) -> Dataset:
2120
    """Run Capytaine for a range of frequencies and wave directions.
2121

2122
    This simplifies running *Capytaine* and ensures the output are in
2123
    the correct convention (see
2124
    :py:func:`wecopttool.change_bem_convention`).
2125

2126
    It creates the *test matrix*, calls
2127
    :py:meth:`capytaine.bodies.bodies.FloatingBody.keep_immersed_part`,
2128
    calls :py:meth:`capytaine.bem.solver.BEMSolver.fill_dataset`,
2129
    and changes the sign convention using
2130
    :py:func:`wecopttool.change_bem_convention`.
2131

2132
    Parameters
2133
    ----------
2134
    fb
2135
        The WEC as a Capytaine floating body (mesh + DOFs).
2136
    freq
2137
        List of frequencies [:math:`Hz`] to evaluate BEM at.
2138
    wave_dirs
2139
        List of wave directions [degrees] to evaluate BEM at.
2140
    rho
2141
        Water density in :math:`kg/m^3`.
2142
    g
2143
        Gravitational acceleration in :math:`m/s^2`.
2144
    depth
2145
        Water depth in :math:`m`.
2146
    write_info
2147
        Which additional information to write.
2148
        Options are:
2149
        :python:`['hydrostatics', 'mesh', 'wavelength', 'wavenumber']`.
2150
        See :py:func:`capytaine.io.xarray.assemble_dataset` for more
2151
        details.
2152
    njobs
2153
        Number of jobs to run in parallel.
2154
        See :py:meth:`capytaine.bem.solver.BEMSolver.fill_dataset`
2155

2156
    See Also
2157
    --------
2158
    change_bem_convention,
2159
    """
2160
    if wave_dirs is not None:
6✔
2161
        wave_dirs = np.atleast_1d(degrees_to_radians(wave_dirs))
6✔
2162
    solver = cpy.BEMSolver()
6✔
2163
    test_matrix = Dataset(coords={
6✔
2164
        'rho': [rho],
2165
        'water_depth': [depth],
2166
        'omega': [ifreq*2*np.pi for ifreq in freq],
2167
        'wave_direction': wave_dirs,
2168
        'radiating_dof': list(fb.dofs.keys()),
2169
        'g': [g],
2170
    })
2171
    if wave_dirs is None:
6✔
2172
        # radiation only problem, no diffraction or excitation
2173
        test_matrix = test_matrix.drop_vars('wave_direction')
×
2174
    if write_info is None:
6✔
2175
        write_info = {'hydrostatics': True,
6✔
2176
                      'mesh': False,
2177
                      'wavelength': False,
2178
                      'wavenumber': False,
2179
                     }
2180
    wec_im = fb.copy(name=f"{fb.name}_immersed").keep_immersed_part()
6✔
2181
    wec_im = set_fb_centers(wec_im, rho=rho)
6✔
2182
    if not hasattr(wec_im, 'inertia_matrix'):
6✔
2183
        _log.warning('FloatingBody has no inertia_matrix field. ' + 
6✔
2184
                     'If the FloatingBody mass is defined, it will be ' + 
2185
                     'used for calculating the inertia matrix here. ' + 
2186
                     'Otherwise, the neutral buoyancy assumption will ' + 
2187
                     'be used to auto-populate.')
2188
        wec_im.inertia_matrix = wec_im.compute_rigid_body_inertia(rho=rho)
6✔
2189
    if not hasattr(wec_im, 'hydrostatic_stiffness'):
6✔
2190
        _log.warning('FloatingBody has no hydrostatic_stiffness field. ' +
6✔
2191
                     'Capytaine will auto-populate the hydrostatic ' +
2192
                     'stiffness based on the provided mesh.')
2193
        wec_im.hydrostatic_stiffness = wec_im.compute_hydrostatic_stiffness(rho=rho, g=g)
6✔
2194
    bem_data = solver.fill_dataset(
6✔
2195
        test_matrix, wec_im, n_jobs=njobs, **write_info)
2196
    return change_bem_convention(bem_data)
6✔
2197

2198

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

2202
    Change the linear hydrodynamic coefficients from the Capytaine
2203
    convention (:math:`x(t)=Xe^{-iωt}`), where :math:`X` is the
2204
    frequency-domain response, to the more standard convention
2205
    used in WecOptTool (:math:`x(t)=Xe^{+iωt}`).
2206

2207
    NOTE: This might change in Capytaine in the future.
2208

2209
    Parameters
2210
    ----------
2211
    bem_data
2212
        Linear hydrodynamic coefficients for the WEC.
2213
    """
2214
    bem_data['Froude_Krylov_force'] = np.conjugate(
6✔
2215
        bem_data['Froude_Krylov_force'])
2216
    bem_data['diffraction_force'] = np.conjugate(bem_data['diffraction_force'])
6✔
2217
    return bem_data
6✔
2218

2219

2220
def add_linear_friction(
6✔
2221
    bem_data: Dataset,
2222
    friction: Optional[ArrayLike] = None
2223
) -> Dataset:
2224
    """Add linear friction to BEM data.
2225

2226
    Returns the Dataset with the additional information added.
2227

2228
    Parameters
2229
    ----------
2230
    bem_data
2231
        Linear hydrodynamic coefficients obtained using the boundary
2232
        element method (BEM) code Capytaine, with sign convention
2233
        corrected. Also includes inertia and hydrostatic stiffness.
2234
    friction
2235
        Linear friction, in addition to radiation damping, of size
2236
        :python:`(ndof, ndof)`.
2237
        :python:`None` if included in :python:`bem_data` or to set to zero.
2238
    """
2239
    dims = ['radiating_dof', 'influenced_dof']
6✔
2240
    hydro_data = bem_data.copy(deep=True)
6✔
2241

2242
    if friction is not None:
6✔
2243
        if 'friction' in hydro_data.variables.keys():
6✔
2244
            if not np.allclose(data, hydro_data.variables[name]):
×
2245
                raise ValueError(
×
2246
                        f'Variable "friction" is already in BEM data ' +
2247
                        f'with different values.')
2248
            else:
2249
                _log.warning(
×
2250
                    f'Variable "{name}" is already in BEM data ' +
2251
                    'with same value.')
2252
        else:
2253
            data = atleast_2d(friction)
6✔
2254
            hydro_data['friction'] = (dims, friction)
6✔
2255
    elif friction is None:
6✔
2256
        ndof = len(hydro_data["influenced_dof"])
6✔
2257
        hydro_data['friction'] = (dims, np.zeros([ndof, ndof]))
6✔
2258

2259
    return hydro_data
6✔
2260

2261

2262
def wave_excitation(exc_coeff: DataArray, waves: Dataset) -> ndarray:
6✔
2263
    """Calculate the complex, frequency-domain, excitation force due to
2264
    waves.
2265

2266
    The resulting force is indexed only by frequency and not direction
2267
    angle.
2268
    The input :python:`waves` frequencies must be same as
2269
    :python:`exc_coeff`, but the directions can be a subset.
2270

2271
    Parameters
2272
    ----------
2273
    exc_coeff
2274
        Complex excitation coefficients indexed by frequency and
2275
        direction angle.
2276
    waves
2277
        Complex frequency-domain wave elevation.
2278

2279
    Raises
2280
    ------
2281
    ValueError
2282
        If the frequency vectors of :python:`exc_coeff` and
2283
        :python:`waves` are different.
2284
    ValueError
2285
        If any of the directions in :python:`waves` is not in
2286
        :python:`exc_coeff`.
2287
    """
2288
    omega_w = waves['omega'].values
6✔
2289
    omega_e = exc_coeff['omega'].values
6✔
2290
    dir_w = waves['wave_direction'].values
6✔
2291
    dir_e = exc_coeff['wave_direction'].values
6✔
2292
    exc_coeff = exc_coeff.values
6✔
2293

2294
    wave_elev_fd = np.expand_dims(waves.values, -1)
6✔
2295

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

2299
    subset, sub_ind = subset_close(dir_w, dir_e)
6✔
2300

2301
    if not subset:
6✔
2302
        raise ValueError(
6✔
2303
            "Some wave directions are not in excitation coefficients " +
2304
            f"\n Wave direction(s): {(np.rad2deg(dir_w))} (deg)" +
2305
            f"\n BEM direction(s): {np.rad2deg(dir_e)} (deg).")
2306

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

2309

2310
def hydrodynamic_impedance(hydro_data: Dataset) -> Dataset:
6✔
2311
    """Calculate hydrodynamic intrinsic impedance.
2312

2313
    Parameters
2314
    ----------
2315
    hydro_data
2316
        Dataset with linear hydrodynamic coefficients produced by
2317
        :py:func:`wecopttool.add_linear_friction`.
2318
    """
2319

2320
    hydro_impedance = (hydro_data['inertia_matrix'] \
6✔
2321
        + hydro_data['added_mass'])*1j*hydro_data['omega'] \
2322
            + hydro_data['radiation_damping'] + hydro_data['friction'] \
2323
                + hydro_data['hydrostatic_stiffness']/1j/hydro_data['omega']
2324
    return hydro_impedance.transpose('omega', 'radiating_dof', 'influenced_dof')
6✔
2325

2326

2327
def atleast_2d(array: ArrayLike) -> ndarray:
6✔
2328
    """Ensure an array is at least 2D, otherwise add trailing dimensions
2329
    to make it 2D.
2330

2331
    This differs from :py:func:`numpy.atleast_2d` in that the additional
2332
    dimensions are appended at the end rather than at the begining.
2333
    This might be an option in :py:func:`numpy.atleast_2d` in the
2334
    future, see
2335
    `NumPy #12336 <https://github.com/numpy/numpy/issues/12336>`_.
2336

2337
    Parameters
2338
    ----------
2339
    array
2340
        Input array.
2341
    """
2342
    array = np.atleast_1d(array)
6✔
2343
    return np.expand_dims(array, -1) if len(array.shape)==1 else array
6✔
2344

2345

2346
def degrees_to_radians(
6✔
2347
    degrees: FloatOrArray,
2348
    sort: Optional[bool] = True,
2349
) -> Union[float, ndarray]:
2350
    """Convert a 1D array of angles in degrees to radians in the range
2351
    :math:`[-π, π)` and optionally sort them.
2352

2353
    Parameters
2354
    ----------
2355
    degrees
2356
        1D array of angles in degrees.
2357
    sort
2358
        Whether to sort the angles from smallest to largest in
2359
        :math:`[-π, π)`.
2360
    """
2361
    radians = np.asarray(np.remainder(np.deg2rad(degrees), 2*np.pi))
6✔
2362
    radians[radians > np.pi] -= 2*np.pi
6✔
2363
    if radians.size > 1 and sort:
6✔
2364
        radians = np.sort(radians)
6✔
2365
    return radians
6✔
2366

2367

2368
def subset_close(
6✔
2369
    set_a: FloatOrArray,
2370
    set_b: FloatOrArray,
2371
    rtol: float = 1.e-5,
2372
    atol: float = 1.e-8,
2373
    equal_nan: bool = False,
2374
) -> tuple[bool, list]:
2375
    """Check if the first set :python:`set_a` is contained, to some
2376
    tolerance, in the second set :python:`set_b`.
2377

2378
    Parameters
2379
    ----------
2380
    set_a
2381
        First array which is tested for being subset.
2382
    set_b
2383
        Second array which is tested for containing :python:`set_a`.
2384
    rtol
2385
        The relative tolerance parameter. Passed to
2386
        :py:func:`numpy.isclose`.
2387
    atol
2388
        The absolute tolerance parameter. Passed to
2389
        :py:func:`numpy.isclose`.
2390
    equal_nan
2391
        Whether to compare NaNs as equal. Passed to
2392
        :py:func:`numpy.isclose`.
2393

2394
    Returns
2395
    -------
2396
    subset
2397
        Whether the first array is a subset of the second array.
2398
    ind
2399
        List with integer indices where the first array's elements are
2400
        located inside the second array.
2401
        Only contains values if :python:`subset==True`.
2402

2403
    Raises
2404
    ------
2405
    ValueError
2406
        If either of the two arrays contains repeated elements.
2407
    """
2408
    set_a = np.atleast_1d(set_a)
6✔
2409
    set_b = np.atleast_1d(set_b)
6✔
2410

2411
    if len(np.unique(set_a.round(decimals = 6))) != len(set_a):
6✔
2412
        raise ValueError("Elements in set_a not unique")
×
2413
    if len(np.unique(set_b.round(decimals = 6))) != len(set_b):
6✔
2414
        raise ValueError("Elements in set_b not unique")
×
2415

2416
    ind = []
6✔
2417
    for el in set_a:
6✔
2418
        a_in_b = np.isclose(set_b, el,
6✔
2419
                            rtol=rtol, atol=atol, equal_nan=equal_nan)
2420
        if np.sum(a_in_b) == 1:
6✔
2421
            ind.append(np.flatnonzero(a_in_b)[0])
6✔
2422
        if np.sum(a_in_b) > 1:
6✔
2423
            _log.warning('Multiple matching elements in subset, ' +
×
2424
                         'selecting closest match.')
2425
            ind.append(np.argmin(np.abs(a_in_b - el)))
×
2426
    subset = len(set_a) == len(ind)
6✔
2427
    ind = ind if subset else []
6✔
2428
    return subset, ind
6✔
2429

2430

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

2434
    Returns a 1D array of length :python:`NDOF*ncomponents` where the
2435
    number of DOFs (:python:`NDOF`) is the length of
2436
    :python:`scale_list`.
2437
    The first :python:`ncomponents` entries have the value of the first
2438
    scale :python:`scale_list[0]`, the next :python:`ncomponents`
2439
    entries have the value of the second scale :python:`scale_list[1]`,
2440
    and so on.
2441

2442

2443
    Parameters
2444
    ----------
2445
    scale_list
2446
        Scale for each DOF.
2447
    ncomponents
2448
        Number of elements in the state vector for each DOF.
2449
    """
2450
    ndof = len(scale_list)
6✔
2451
    scale = []
6✔
2452
    for dof in range(ndof):
6✔
2453
        scale += [scale_list[dof]] * ncomponents
6✔
2454
    return np.array(scale)
6✔
2455

2456

2457
def decompose_state(
6✔
2458
    state: ndarray,
2459
    ndof: int,
2460
    nfreq: int,
2461
) -> tuple[ndarray, ndarray]:
2462
    """Split the state vector into the WEC dynamics state and the
2463
    optimization (control) state.
2464

2465
    The WEC dynamics state consists of the Fourier coefficients of
2466
    the position of each degree of freedom.
2467
    The optimization state depends on the chosen control states for
2468
    the problem.
2469

2470
    Parameters
2471
    ----------
2472
    state
2473
        Combined WEC and optimization states.
2474
    ndof
2475
        Number of degrees of freedom for the WEC dynamics.
2476
    nfreq
2477
        Number of frequencies.
2478

2479
    Returns
2480
    -------
2481
    state_wec
2482
        WEC state vector.
2483
    state_opt
2484
        Optimization (control) state.
2485
    """
2486
    nstate_wec = ndof * ncomponents(nfreq)
6✔
2487
    return state[:nstate_wec], state[nstate_wec:]
6✔
2488

2489

2490
def frequency_parameters(
6✔
2491
    freqs: ArrayLike,
2492
    zero_freq: bool = True,
2493
) -> tuple[float, int]:
2494
    """Return the fundamental frequency and the number of frequencies
2495
    in a frequency array.
2496

2497
    This function can be used as a check for inputs to other functions
2498
    since it raises an error if the frequency vector does not have
2499
    the correct format :python:`freqs = [0, f1, 2*f1, ..., nfreq*f1]`
2500
    (or :python:`freqs = [f1, 2*f1, ..., nfreq*f1]` if
2501
    :python:`zero_freq = False`).
2502

2503
    Parameters
2504
    ----------
2505
    freqs
2506
        The frequency array, starting at zero and having equal spacing.
2507
    zero_freq
2508
        Whether the first frequency should be zero.
2509

2510
    Returns
2511
    -------
2512
    f1
2513
        Fundamental frequency :python:`f1` [:math:`Hz`]
2514
    nfreq
2515
        Number of frequencies (not including zero frequency),
2516
        i.e., :python:`freqs = [0, f1, 2*f1, ..., nfreq*f1]`.
2517

2518
    Raises
2519
    ------
2520
    ValueError
2521
        If the frequency vector is not evenly spaced.
2522
    ValueError
2523
        If the zero-frequency was expected but not included or not
2524
        expected but included.
2525
    """
2526
    if np.isclose(freqs[0], 0.0):
6✔
2527
        if zero_freq:
6✔
2528
            freqs0 = freqs[:]
6✔
2529
        else:
2530
            raise ValueError('Zero frequency was included.')
6✔
2531
    else:
2532
        if zero_freq:
6✔
2533
            raise ValueError(
6✔
2534
                'Frequency array must start with the zero frequency.')
2535
        else:
2536
            freqs0 = np.concatenate([[0.0,], freqs])
6✔
2537

2538
    f1 = freqs0[1]
6✔
2539
    nfreq = len(freqs0) - 1
6✔
2540
    f_check = np.arange(0, f1*(nfreq+0.5), f1)
6✔
2541
    if not np.allclose(f_check, freqs0):
6✔
2542
        raise ValueError("Frequency array 'omega' must be evenly spaced by" +
6✔
2543
                         "the fundamental frequency " +
2544
                         "(i.e., 'omega = [0, f1, 2*f1, ..., nfreq*f1]')")
2545
    return f1, nfreq
6✔
2546

2547

2548
def time_results(fd: DataArray, time: DataArray) -> ndarray:
6✔
2549
    """Create a :py:class:`xarray.DataArray` of time-domain results from
2550
    :py:class:`xarray.DataArray` of frequency-domain results.
2551

2552
    Parameters
2553
    ----------
2554
    fd
2555
        Frequency domain response.
2556
    time
2557
        Time array.
2558
    """
2559
    out = np.zeros((*fd.isel(omega=0).shape, len(time)))
6✔
2560
    for w, mag in zip(fd.omega, fd):
6✔
2561
        out = out + \
6✔
2562
            np.real(mag)*np.cos(w*time) - np.imag(mag)*np.sin(w*time)
2563
    return out
6✔
2564

2565

2566
def set_fb_centers(
6✔
2567
    fb: FloatingBody,
2568
    rho: float = _default_parameters["rho"],
2569
) -> FloatingBody:
2570
    """Sets default properties if not provided by the user:
2571
        - `center_of_mass` is set to the geometric centroid
2572
        - `rotation_center` is set to the center of mass
2573
    """
2574
    valid_properties = ['center_of_mass', 'rotation_center']
6✔
2575

2576
    for property in valid_properties:
6✔
2577
        if not hasattr(fb, property):
6✔
2578
            setattr(fb, property, None)
6✔
2579
        if getattr(fb, property) is None:
6✔
2580
            if property == 'center_of_mass':
6✔
2581
                def_val = fb.center_of_buoyancy
6✔
2582
                log_str = (
6✔
2583
                    "Using the geometric centroid as the center of gravity (COG).")
2584
            elif property == 'rotation_center':
6✔
2585
                def_val = fb.center_of_mass
6✔
2586
                log_str = (
6✔
2587
                    "Using the center of gravity (COG) as the rotation center " +
2588
                    "for hydrostatics.")
2589
            setattr(fb, property, def_val)
6✔
2590
            _log.warning(log_str)
6✔
2591
        elif getattr(fb, property) is not None:
×
2592
            _log.warning(
×
2593
                f'{property} already defined as {getattr(fb, property)}.')
2594

2595
    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