Skip to content

Commit 3491dbb

Browse files
mbruggsakeeste
andauthored
Compute surface elevation using IFFT (#250)
* Prevent nan values for zero-frequency component in spectrum Previously providing a zero frequency when creating a spectrum (JONSWAP or Pierson Moskowitz) produced a NaN value. This is due the f^-5 and f^-4 terms in the calculation. The zero frequency is a valid input and is important when calculating the surface elevation from the spectrum. We know, however, that the zero frequency should __always__ have zero amplitude as the surface elevation has a mean of zero. This change ensures that if a zero frequency is provided, the amplitude is set to zero. * Compute surface elevation using IFFT Previously the surface elevation was only computed using the 'sum of sines' method. This has been found to be prohibitively slow when computing long duration surface elevation traces. This commit introduces the capability to compute the surface elevation using the more computationally efficient IFFT. The IFFT routine is used by default if no frequency bins are provided and the input frequency vector is equally spaced. For example a 1hr sea state realisation at 20Hz took 11s to compute using the 'sum of sines' and 0.007s using the IFFT. Fixes #229 * add docstrings to surface_elevation * update surface_elevation calls in examples --------- Co-authored-by: akeeste <akeeste@sandia.gov>
1 parent 008cba8 commit 3491dbb

File tree

6 files changed

+376
-296
lines changed

6 files changed

+376
-296
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,6 @@ $ cat .gitignore
3232
# Exemptions
3333
!**/examples/data/wave/*.mat
3434
!**/tests/data/wave/*.mat
35+
36+
# Files created during tests
37+
mhkit/tests/wave/plots/

examples/extreme_response_contour_example.ipynb

Lines changed: 10 additions & 17 deletions
Large diffs are not rendered by default.

examples/extreme_response_full_sea_state_example.ipynb

Lines changed: 224 additions & 218 deletions
Large diffs are not rendered by default.

examples/short_term_extremes_example.ipynb

Lines changed: 42 additions & 43 deletions
Large diffs are not rendered by default.

mhkit/tests/wave/test_resource_spectrum.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,11 @@ class TestResourceSpectrum(unittest.TestCase):
3232

3333
@classmethod
3434
def setUpClass(self):
35-
omega = np.arange(0.1,3.5,0.01)
36-
self.f = omega/(2*np.pi)
35+
Trep = 600
36+
df = 1 / Trep
37+
self.f = np.arange(0, 1, df)
3738
self.Hs = 2.5
3839
self.Tp = 8
39-
df = self.f[1] - self.f[0]
40-
Trep = 1/df
4140
self.t = np.arange(0, Trep, 0.05)
4241

4342
@classmethod
@@ -55,6 +54,17 @@ def test_pierson_moskowitz_spectrum(self):
5554
self.assertLess(errorHm0, 0.01)
5655
self.assertLess(errorTp0, 0.01)
5756

57+
def test_pierson_moskowitz_spectrum_zero_freq(self):
58+
df = 0.1
59+
f_zero = np.arange(0, 1, df)
60+
f_nonzero = np.arange(df, 1, df)
61+
62+
S_zero = wave.resource.pierson_moskowitz_spectrum(f_zero, self.Tp, self.Hs)
63+
S_nonzero = wave.resource.pierson_moskowitz_spectrum(f_nonzero, self.Tp, self.Hs)
64+
65+
self.assertEqual(S_zero.values.squeeze()[0], 0.0)
66+
self.assertGreater(S_nonzero.values.squeeze()[0], 0.0)
67+
5868
def test_jonswap_spectrum(self):
5969
S = wave.resource.jonswap_spectrum(self.f, self.Tp, self.Hs)
6070
Hm0 = wave.resource.significant_wave_height(S).iloc[0,0]
@@ -66,6 +76,17 @@ def test_jonswap_spectrum(self):
6676
self.assertLess(errorHm0, 0.01)
6777
self.assertLess(errorTp0, 0.01)
6878

79+
def test_jonswap_spectrum_zero_freq(self):
80+
df = 0.1
81+
f_zero = np.arange(0, 1, df)
82+
f_nonzero = np.arange(df, 1, df)
83+
84+
S_zero = wave.resource.jonswap_spectrum(f_zero, self.Tp, self.Hs)
85+
S_nonzero = wave.resource.jonswap_spectrum(f_nonzero, self.Tp, self.Hs)
86+
87+
self.assertEqual(S_zero.values.squeeze()[0], 0.0)
88+
self.assertGreater(S_nonzero.values.squeeze()[0], 0.0)
89+
6990
def test_surface_elevation_phases_np_and_pd(self):
7091
S0 = wave.resource.jonswap_spectrum(self.f,self.Tp,self.Hs)
7192
S1 = wave.resource.jonswap_spectrum(self.f,self.Tp,self.Hs*1.1)
@@ -129,6 +150,14 @@ def test_surface_elevation_rmse(self):
129150

130151
self.assertLess(rmse_sum, 0.02)
131152

153+
def test_ifft_sum_of_sines(self):
154+
S = wave.resource.jonswap_spectrum(self.f, self.Tp, self.Hs)
155+
156+
eta_ifft = wave.resource.surface_elevation(S, self.t, seed=1, method='ifft')
157+
eta_sos = wave.resource.surface_elevation(S, self.t, seed=1, method='sum_of_sines')
158+
159+
assert_allclose(eta_ifft, eta_sos)
160+
132161
def test_plot_spectrum(self):
133162
filename = abspath(join(plotdir, 'wave_plot_spectrum.png'))
134163
if isfile(filename):

mhkit/wave/resource.py

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,17 @@ def pierson_moskowitz_spectrum(f, Tp, Hs):
9090
f.sort()
9191
B_PM = (5/4)*(1/Tp)**4
9292
A_PM = B_PM*(Hs/2)**2
93-
Sf = A_PM*f**(-5)*np.exp(-B_PM*f**(-4))
93+
94+
# Avoid a divide by zero if the 0 frequency is provided
95+
# The zero frequency should always have 0 amplitude, otherwise
96+
# we end up with a mean offset when computing the surface elevation.
97+
Sf = np.zeros(f.size)
98+
if f[0] == 0.0:
99+
inds = range(1, f.size)
100+
else:
101+
inds = range(0, f.size)
102+
103+
Sf[inds] = A_PM*f[inds]**(-5)*np.exp(-B_PM*f[inds]**(-4))
94104

95105
col_name = 'Pierson-Moskowitz ('+str(Tp)+'s)'
96106
S = pd.DataFrame(Sf, index=f, columns=[col_name])
@@ -132,7 +142,17 @@ def jonswap_spectrum(f, Tp, Hs, gamma=None):
132142
f.sort()
133143
B_PM = (5/4)*(1/Tp)**4
134144
A_PM = B_PM*(Hs/2)**2
135-
S_f = A_PM*f**(-5)*np.exp(-B_PM*f**(-4))
145+
146+
# Avoid a divide by zero if the 0 frequency is provided
147+
# The zero frequency should always have 0 amplitude, otherwise
148+
# we end up with a mean offset when computing the surface elevation.
149+
S_f = np.zeros(f.size)
150+
if f[0] == 0.0:
151+
inds = range(1, f.size)
152+
else:
153+
inds = range(0, f.size)
154+
155+
S_f[inds] = A_PM*f[inds]**(-5)*np.exp(-B_PM*f[inds]**(-4))
136156

137157
if not gamma:
138158
TpsqrtHs = Tp/np.sqrt(Hs);
@@ -162,7 +182,7 @@ def jonswap_spectrum(f, Tp, Hs, gamma=None):
162182
return S
163183

164184
### Metrics
165-
def surface_elevation(S, time_index, seed=None, frequency_bins=None, phases=None):
185+
def surface_elevation(S, time_index, seed=None, frequency_bins=None, phases=None, method='ifft'):
166186
"""
167187
Calculates wave elevation time-series from spectrum
168188
@@ -175,9 +195,18 @@ def surface_elevation(S, time_index, seed=None, frequency_bins=None, phases=None
175195
for example, time = np.arange(0,100,0.01)
176196
seed: int (optional)
177197
Random seed
198+
frequency_bins: numpy array or pandas DataFrame (optional)
199+
Bin widths for frequency of S. Required for unevenly sized bins
178200
phases: numpy array or pandas DataFrame (optional)
179201
Explicit phases for frequency components (overrides seed)
180202
for example, phases = np.random.rand(len(S)) * 2 * np.pi
203+
method: str (optional)
204+
Method used to calculate the surface elevation. 'ifft'
205+
(Inverse Fast Fourier Transform) used by default if the
206+
given frequency_bins==None.
207+
'sum_of_sines' explicitly sums each frequency component
208+
and used by default if frequency_bins are provided.
209+
The 'ifft' method is significantly faster.
181210
182211
Returns
183212
---------
@@ -194,13 +223,21 @@ def surface_elevation(S, time_index, seed=None, frequency_bins=None, phases=None
194223
"frequency_bins must be of type None, np.ndarray, or pd,DataFrame")
195224
assert isinstance(phases, (type(None), np.ndarray, pd.DataFrame)), (
196225
'phases must be of type None, np.ndarray, or pd,DataFrame')
226+
assert isinstance(method, str)
197227

198228
if frequency_bins is not None:
199229
assert frequency_bins.squeeze().shape == (S.squeeze().shape[0],),(
200230
'shape of frequency_bins must match shape of S')
201231
if phases is not None:
202232
assert phases.squeeze().shape == S.squeeze().shape,(
203233
'shape of phases must match shape of S')
234+
235+
if method is not None:
236+
assert method == 'ifft' or method == 'sum_of_sines',(
237+
f"unknown method {method}, options are 'ifft' or 'sum_of_sines'")
238+
239+
if method == 'ifft':
240+
assert S.index.values[0] == 0, ('ifft method must have zero frequency defined')
204241

205242
f = pd.Series(S.index)
206243
f.index = f
@@ -209,10 +246,12 @@ def surface_elevation(S, time_index, seed=None, frequency_bins=None, phases=None
209246
assert np.allclose(f.diff()[1:], delta_f)
210247
elif isinstance(frequency_bins, np.ndarray):
211248
delta_f = pd.Series(frequency_bins, index=S.index)
249+
method = 'sum_of_sines'
212250
elif isinstance(frequency_bins, pd.DataFrame):
213251
assert len(frequency_bins.columns) == 1, ('frequency_bins must only'
214252
'contain 1 column')
215253
delta_f = frequency_bins.squeeze()
254+
method = 'sum_of_sines'
216255

217256
if phases is None:
218257
np.random.seed(seed)
@@ -231,17 +270,28 @@ def surface_elevation(S, time_index, seed=None, frequency_bins=None, phases=None
231270
A = A.multiply(delta_f, axis=0)
232271
A = np.sqrt(A)
233272

234-
# Product of omega and time
235-
B = np.outer(time_index, omega)
236-
B = B.reshape((len(time_index), len(omega)))
237-
B = pd.DataFrame(B, index=time_index, columns=omega.index)
238-
239-
# wave elevation
240-
eta = pd.DataFrame(columns=S.columns, index=time_index)
241-
for mcol in eta.columns:
242-
C = np.cos(B+phase[mcol])
243-
C = pd.DataFrame(C, index=time_index, columns=omega.index)
244-
eta[mcol] = (C*A[mcol]).sum(axis=1)
273+
if method == 'ifft':
274+
A_cmplx = A * (np.cos(phase) + 1j*np.sin(phase))
275+
276+
def func(v):
277+
eta = np.fft.irfft(0.5 * v.values.squeeze() * time_index.size, time_index.size)
278+
return pd.Series(data=eta, index=time_index)
279+
280+
eta = A_cmplx.apply(func)
281+
282+
elif method == 'sum_of_sines':
283+
# Product of omega and time
284+
B = np.outer(time_index, omega)
285+
B = B.reshape((len(time_index), len(omega)))
286+
B = pd.DataFrame(B, index=time_index, columns=omega.index)
287+
288+
# wave elevation
289+
eta = pd.DataFrame(columns=S.columns, index=time_index)
290+
for mcol in eta.columns:
291+
C = np.cos(B+phase[mcol])
292+
C = pd.DataFrame(C, index=time_index, columns=omega.index)
293+
eta[mcol] = (C*A[mcol]).sum(axis=1)
294+
245295
return eta
246296

247297

0 commit comments

Comments
 (0)