Coverage for src/swiift/api/spectra.py: 100%

87 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-09-11 16:30 +0200

1"""Consistant implementations of usual spectral formulations. 

2 

3Spectra are defined as function of the frequency. Definitions are taken 

4from [1]_. 

5 

6References 

7---------- 

8.. [1] Stansberg, Carl Trygve & Contento, G. & Hong, S.W. & Irani, M. & 

9 Ishida, S. & Mercier, R.. (2002). The specialist committee on waves 

10 final report and recommendations to the 23rd ITTC. Proceedings of the 

11 23rd ITTC. 505-736. 

12 

13""" 

14 

15import abc 

16import functools 

17import typing 

18 

19import attrs 

20import numpy as np 

21import scipy.integrate as integrate 

22 

23from ..lib.constants import PI_2 

24from ..lib.physics import _demote_to_scalar 

25 

26"""Constant appearing in the definition of the PM family spectra.""" 

27_ALPHA = 8.1e-3 

28"""JONSWAP peak width, below and beyond peak frequency.""" 

29_PEAK_WIDTH_LEQ = 0.07 

30_PEAK_WIDTH_GT = 0.09 

31 

32T = typing.TypeVar("T", float, np.ndarray[tuple[int], np.dtype[np.floating]]) 

33 

34 

35class _ParametrisedSpectrum(abc.ABC): 

36 @abc.abstractmethod 

37 def density(self, frequencies: T) -> T: 

38 """Spectral density as a function of frequency. 

39 

40 Parameters 

41 ---------- 

42 frequencies : array_like 

43 Frequencies at which to evaluate the density. 

44 

45 Returns 

46 ------- 

47 density: float or np.ndarray 

48 The density in m^2 s. 

49 

50 """ 

51 

52 def discrete_energy(self, frequencies: np.ndarray) -> float: 

53 r"""Spectral density integrated over the input frequencies. 

54 

55 The full spectral energy is obtained by integrating the 

56 density over the positive half-line: this method computes 

57 energy for the frequency band spanned by the input array. 

58 

59 The spectral density is computed for the input frequencies, 

60 and integrated over these same frequencies using the 

61 trapezoid rule, so that for an input of size :math:`N+1`: 

62 

63 .. math:: 

64 

65 E(f_0, f_{N-1}) \approx \frac{1}{2} \sum_j=0^{N-1} { 

66 (S(f_j) + S(f_{j+1})) (f_{j+1} - f_j) 

67 }. 

68 

69 

70 It is assumed the input array is sorted. 

71 

72 Parameters 

73 ---------- 

74 frequencies : np.ndarray 

75 Frequencies to use for computing the energy. 

76 

77 Returns 

78 ------- 

79 float 

80 Wave spectral energy in m^2. 

81 

82 """ 

83 return np.trapezoid(self.density(frequencies), frequencies) 1b

84 

85 def _density_ang(self, angular_frequency: T) -> T: 

86 # Helper method to use angular frequency as input to the density. 

87 return self.density(angular_frequency / PI_2) / PI_2 1b

88 

89 def __call__(self, frequencies: T) -> T: 

90 """Wrapper for `density`. 

91 

92 Parameters 

93 ---------- 

94 frequencies : T 

95 Frequencies in Hz. 

96 

97 Returns 

98 ------- 

99 T 

100 [TODO:description] 

101 

102 """ 

103 return self.density(frequencies) 1bd

104 

105 

106@attrs.define 

107class _UnimodalSpectrum(_ParametrisedSpectrum): 

108 @functools.cached_property 

109 def peak_period(self) -> float: 

110 """Spectral peak (modal) period. 

111 

112 Returns 

113 ------- 

114 float 

115 Peak period, in s. 

116 

117 """ 

118 return 1 / self.peak_frequency 1e

119 

120 @functools.cached_property 

121 def peak_ang_frequency(self) -> float: 

122 """Spectral peak angular frequency. 

123 

124 Returns 

125 ------- 

126 float 

127 Peak angular frequency, in rad s^-1. 

128 

129 """ 

130 return PI_2 * self.peak_frequency 1e

131 

132 @property 

133 @abc.abstractmethod 

134 def peak_frequency(self) -> float: 

135 """Peak frequency. 

136 

137 Returns 

138 ------- 

139 float 

140 Peak frequency, in Hz. 

141 

142 """ 

143 

144 

145@attrs.define 

146class _PMFamily(_UnimodalSpectrum): 

147 r"""Base class for spectra of the Bretschneider family. 

148 

149 These spectra are of the form 

150 

151 .. math:: S(f) = \frac{A}{f^5}\exp{\Bigl(-\frac{B}{f^4}\Bigr)} 

152 

153 with the definitions of the parameters :math:`A` and :math:`B` 

154 identifying particular forms. For clarity, we call them 

155 respectively `scale` and `exp_scale`. 

156 

157 In practice, spectra are parameterised by physical variables 

158 which can be linked back to these parameters. In particular, 

159 the peak frequency is given by 

160 

161 .. math:: f_p = {\Bigl(\frac{4B}{5}\Bigr)}^{1/4} 

162 

163 and the significant wave height by 

164 

165 .. math:: H_s = 2\sqrt{\frac{A}{B}}. 

166 

167 Attributes 

168 ---------- 

169 scale : float 

170 exp_scale : float 

171 

172 Methods 

173 ------- 

174 __call__ 

175 

176 """ 

177 

178 scale: float 

179 exp_scale: float 

180 

181 @functools.cached_property 

182 def swh(self) -> float: 

183 r"""Significant wave height. 

184 

185 We use the spectral definition, 

186 

187 .. math:: H_s = 4\sqrt{\int_0^{+\infty} S(f) df}. 

188 

189 Returns 

190 ------- 

191 float 

192 Significant wave height, in m. 

193 

194 """ 

195 return 2 * (self.scale / self.exp_scale) ** 0.5 1cbd

196 

197 @functools.cached_property 

198 def peak_frequency(self) -> float: 

199 return (4 * self.exp_scale / 5) ** 0.25 1acbe

200 

201 def density(self, frequencies): 

202 return self.scale / frequencies**5 * np.exp(-self.exp_scale / frequencies**4) 1abd

203 

204 

205@attrs.define 

206class PiersonMoskowitz(_PMFamily): 

207 """Class encapsulating the Pierson--Moskowitz spectrum.""" 

208 

209 @staticmethod 

210 def _make_scale(gravity): 

211 return _ALPHA * (gravity / PI_2**2) ** 2 1ac

212 

213 @classmethod 

214 def from_swh(cls, swh: float, gravity: float) -> typing.Self: 

215 """Build a spectrum parameterised by the SWH. 

216 

217 Parameters 

218 ---------- 

219 swh : float 

220 Significant wave height, in m. 

221 gravity : float 

222 Acceleration of gravity, in m s**-2. 

223 

224 Returns 

225 ------- 

226 PiersonMoskowitz 

227 Initialised spectrum object. 

228 

229 """ 

230 scale = cls._make_scale(gravity) 1c

231 exp_scale = 4 * scale / swh**2 1c

232 return cls(scale, exp_scale) 1c

233 

234 @classmethod 

235 def from_peak_frequency(cls, peak_frequency: float, gravity: float) -> typing.Self: 

236 """Build a spectrum parameterised by the peak frequency. 

237 

238 Parameters 

239 ---------- 

240 peak_frequency : float 

241 Peak frequency, in Hz. 

242 gravity : float 

243 Acceleration of gravity, in m s^-2. 

244 

245 Returns 

246 ------- 

247 PiersonMoskowitz 

248 Initialised spectrum object. 

249 

250 """ 

251 scale = cls._make_scale(gravity) 1ac

252 exp_scale = 5 / 4 * peak_frequency**4 1ac

253 return cls(scale, exp_scale) 1ac

254 

255 

256class Bretschneider(_PMFamily): 

257 """Class encapsulating the Bretschneider spectrum.""" 

258 

259 @classmethod 

260 def from_peak_frequency_swh(cls, peak_frequency: float, swh: float) -> typing.Self: 

261 """Build a spectrum parameterised by peak frequency and SWH. 

262 

263 Parameters 

264 ---------- 

265 peak_frequency : float 

266 Peak frequency, in Hz. 

267 swh : float 

268 Significant wave height, in m. 

269 

270 Returns 

271 ------- 

272 Bretschneider 

273 Initialised spectrum. 

274 

275 """ 

276 scale = 5 * swh**2 * peak_frequency**4 / 16 1ac

277 exp_scale = 5 / 4 * peak_frequency**4 1ac

278 return cls(scale, exp_scale) 1ac

279 

280 

281@attrs.define 

282class JONSWAP(_UnimodalSpectrum): 

283 """Class encapsulating the JONSWAP spectrum. 

284 

285 The JONSWAP spectrum modifies the PM spectrum by introducing a peak 

286 enhancement factor. 

287 

288 Attributes 

289 ---------- 

290 peakedness : float 

291 _base_spectrum : PiersonMoskowitz 

292 

293 """ 

294 

295 peakedness: float 

296 _base_spectrum: PiersonMoskowitz 

297 

298 @property 

299 def peak_frequency(self): 

300 """Peak frequency. 

301 

302 Returns 

303 ------- 

304 float 

305 Peak frequency, in m. 

306 

307 """ 

308 return self._base_spectrum.peak_frequency 1bed

309 

310 @functools.cached_property 

311 def swh(self): 

312 r"""Significant wave height. 

313 

314 The SWH is computed with a cubic approximation. 

315 

316 Returns 

317 ------- 

318 float 

319 Significant wave height, in m. 

320 

321 """ 

322 return ( 1d

323 self._base_spectrum.scale**0.5 

324 * ( 

325 1.555 

326 + 0.2596 * self.peakedness 

327 - 0.02231 * self.peakedness**2 

328 + 0.001142 * self.peakedness**3 

329 ) 

330 / self.peak_frequency**2 

331 ) 

332 

333 @classmethod 

334 def from_parameters( 

335 cls, 

336 peak_frequency: float, 

337 peakedness: float, 

338 gravity: float, 

339 ) -> typing.Self: 

340 """Build a spectrum. 

341 

342 Peak frequency and gravity are used to instantiate the 

343 underlying Pierson--Moskowitz spectrum, while peakedness is 

344 specific to the JONSWAP formulation. 

345 

346 Parameters 

347 ---------- 

348 peak_frequency : float 

349 Peak frequency, in Hz. 

350 peakedness : float 

351 Peakedness (base of the peak enhancement factor). 

352 gravity : float 

353 Acceleration of gravity, in m s^-2. 

354 

355 Returns 

356 ------- 

357 JONSWAP 

358 Initialised spectrum. 

359 

360 """ 

361 pm_spectrum = PiersonMoskowitz.from_peak_frequency(peak_frequency, gravity) 

362 return cls(peakedness, pm_spectrum) 

363 

364 # Decoratar actually mandatory for integrate.quad not to error out 

365 # on this method. 

366 @_demote_to_scalar 1abd

367 def density(self, frequencies): 1abd

368 # The dimension of `frequencies` cannot be 0, 

369 # to allow for masking/assignement 

370 peak_width = np.ones_like(np.atleast_1d(frequencies)) * _PEAK_WIDTH_LEQ 1bd

371 peak_width[frequencies > self.peak_frequency] = _PEAK_WIDTH_GT 1bd

372 peak_enhancement = self.peakedness ** np.exp( 1bd

373 -((frequencies - self.peak_frequency) ** 2) 

374 / (2 * peak_width**2 * self.peak_frequency**2) 

375 ) 

376 return self._base_spectrum(frequencies) * peak_enhancement 1bd