Coverage for src/flexfrac1d/lib/physics.py: 97%

228 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-08-30 14:00 +0200

1from __future__ import annotations 

2 

3import functools 

4import typing 

5 

6import attrs 

7import numpy as np 

8 

9from . import numerical 

10from ._ph_utils import _wavefield 

11from .constants import PI_D4, SQR2 

12 

13if typing.TYPE_CHECKING: 

14 from ..model import model 

15 

16 

17def _package_wuf(wuf: model.WavesUnderFloe, growth_params): 

18 floe_params = wuf.wui.ice._red_elastic_number, wuf.length 

19 wave_params = wuf.edge_amplitudes, wuf.wui._c_wavenumbers 

20 if growth_params is not None: 20 ↛ 21line 20 didn't jump to line 21, because the condition on line 20 was never true

21 growth_params = growth_params[0] - wuf.left_edge, growth_params[1] 

22 return floe_params, wave_params, growth_params 

23 

24 

25def _dis_par_amps(red_num: float, wave_params: tuple[np.ndarray]): 

26 """Complex amplitudes of individual particular solutions""" 

27 c_amplitudes, c_wavenumbers = wave_params 

28 return c_amplitudes / (1 + 0.25 * (c_wavenumbers / red_num) ** 4) 

29 

30 

31def _dis_hom_coefs( 

32 floe_params: tuple[float], wave_params: tuple[np.ndarray] 

33) -> np.ndarray: 

34 """Coefficients of the four orthogonal homogeneous solutions""" 

35 return _dis_hom_mat(*floe_params) @ _dis_hom_rhs(floe_params, wave_params) 

36 

37 

38def _dis_hom_mat(red_num: float, length: float): 

39 """Linear application to determine, from the BCs, the coefficients of 

40 the four independent solutions to the homo ODE""" 

41 adim = red_num * length 

42 adim2 = 2 * adim 

43 denom = ( 

44 -2 

45 * red_num**2 

46 * (np.expm1(-adim2) ** 2 + 2 * np.exp(-adim2) * (np.cos(adim2) - 1)) 

47 ) 

48 mat = np.array( 

49 [ 

50 [ 

51 SQR2 * np.exp(-adim) * np.sin(adim2 + PI_D4) - np.exp(-3 * adim), 

52 -SQR2 * np.cos(adim + PI_D4) 

53 - 3 * np.exp(-adim2) * np.sin(adim) 

54 + np.exp(-adim2) * np.cos(adim), 

55 np.exp(-adim) * np.sin(adim2) - np.exp(-adim) + np.exp(-3 * adim), 

56 np.cos(adim) 

57 - 2 * np.exp(-adim2) * np.sin(adim) 

58 - np.exp(-adim2) * np.cos(adim), 

59 ], 

60 [ 

61 -SQR2 * np.exp(-adim) * np.cos(adim2 + PI_D4) 

62 + 2 * np.exp(-adim) 

63 - np.exp(-3 * adim), 

64 -SQR2 * np.sin(adim + PI_D4) 

65 + SQR2 * np.exp(-adim2) * np.cos(adim + PI_D4), 

66 -np.exp(-adim) * np.cos(adim2) + np.exp(-adim), 

67 np.sin(adim) - np.exp(-adim2) * np.sin(adim), 

68 ], 

69 [ 

70 -1 + SQR2 * np.exp(-adim2) * np.cos(adim2 + PI_D4), 

71 (3 * np.sin(adim) + np.cos(adim)) * np.exp(-adim) 

72 - SQR2 * np.exp(-3 * adim) * np.sin(adim + PI_D4), 

73 (np.sin(adim2) + 1) * np.exp(-adim2) - 1, 

74 (-2 * np.sin(adim) + np.cos(adim)) * np.exp(-adim) 

75 - np.exp(-3 * adim) * np.cos(adim), 

76 ], 

77 [ 

78 (SQR2 * np.sin(adim2 + PI_D4) - 2) * np.exp(-adim2) + 1, 

79 -SQR2 * np.exp(-adim) * np.sin(adim + PI_D4) 

80 + SQR2 * np.exp(-3 * adim) * np.cos(adim + PI_D4), 

81 (-np.cos(adim2) + 1) * np.exp(-adim2), 

82 np.exp(-adim) * np.sin(adim) - np.exp(-3 * adim) * np.sin(adim), 

83 ], 

84 ] 

85 ) 

86 mat[:, 2:] /= red_num 

87 

88 return mat / denom 

89 

90 

91def _dis_hom_rhs(floe_params: tuple[float], wave_params: tuple[np.ndarray]): 

92 """Vector onto which apply the matrix, to extract the coefficients""" 

93 red_num, length = floe_params 

94 _, c_wavenumbers = wave_params 

95 exp_arg = 1j * c_wavenumbers * length 

96 

97 r1 = c_wavenumbers**2 * _dis_par_amps(red_num, wave_params) 

98 r2 = np.imag(r1 @ np.exp(exp_arg)) 

99 r3 = 1j * c_wavenumbers * r1 

100 r4 = np.imag(r3 @ np.exp(exp_arg)) 

101 r1 = np.imag(r1).sum() 

102 r3 = np.imag(r3).sum() 

103 

104 return np.array((r1, r2, r3, r4)) 

105 

106 

107# Can be used to decorate methods to make them return a scalar instead of 

108# a 1d-array of length 1, if the argument is a scalar OR a 0d array (that is, a 

109# scalar). If the argument is a tuple or a list or a 1d array of length 1, 

110# a 1d-array of length 1 is returned. 

111# This emulate the behaviour of standard numpy ufuncs. 

112def _demote_to_scalar(f): 

113 @functools.wraps(f) 

114 def wrapper(*args): 

115 instance, x = args 

116 x = np.asarray(x) 

117 res = f(instance, x) 

118 if len(res) == 1 and x.ndim == 0: 

119 return res.item() 

120 return res 

121 

122 return wrapper 

123 

124 

125@attrs.define 

126class FluidSurfaceHandler: 

127 wave_params: tuple[np.ndarray] 

128 growth_params: list[np.ndarray, float] | None = None 

129 

130 @classmethod 

131 def from_wuf(cls, wuf: model.WavesUnderFloe, growth_params=None): 

132 return cls(*(_package_wuf(wuf, growth_params)[1:])) 

133 

134 @classmethod 

135 def from_domain(cls, domain: model.Domain, growth_params: list | None = None): 

136 return cls( 

137 ( 

138 domain.spectrum._amps * np.exp(1j * domain.spectrum._phases), 

139 domain.fsw.wavenumbers, 

140 ), 

141 growth_params, 

142 ) 

143 

144 @_demote_to_scalar 

145 def compute(self, x, /): 

146 return numerical.free_surface(x, self.wave_params, self.growth_params) 

147 

148 

149@attrs.define 

150class DisplacementHandler: 

151 floe_params: tuple[float] 

152 wave_params: tuple[np.ndarray] 

153 growth_params: list[np.ndarray, float] | None = None 

154 

155 @classmethod 

156 def from_wuf(cls, wuf: model.WavesUnderFloe, growth_params=None): 

157 return cls(*_package_wuf(wuf, growth_params)) 

158 

159 def _dis_hom(self, x: np.ndarray): 

160 """Homogeneous solution to the displacement ODE""" 

161 red_num, length = self.floe_params 

162 arr = red_num * x 

163 cosx, sinx = np.cos(arr), np.sin(arr) 

164 expx = np.exp(-red_num * (length - x)) 

165 exmx = np.exp(-arr) 

166 return np.vstack( 

167 ([expx * cosx, expx * sinx, exmx * cosx, exmx * sinx]) 

168 ).T @ _dis_hom_coefs(self.floe_params, self.wave_params) 

169 

170 def _dis_par(self, x: np.ndarray): 

171 """Sum of the particular solutions to the displacement ODE""" 

172 return _wavefield( 

173 x, _dis_par_amps(self.floe_params[0], self.wave_params), self.wave_params[1] 

174 ) 

175 

176 @_demote_to_scalar 

177 def _dis(self, x, /): 

178 return self._dis_hom(x) + self._dis_par(x) 

179 

180 def compute( 

181 self, 

182 x, 

183 an_sol: bool | None = None, 

184 num_params: dict | None = None, 

185 ): 

186 if numerical._use_an_sol(an_sol, self.floe_params[1], self.growth_params): 

187 return self._dis(np.asarray(x)) 

188 return numerical.displacement( 

189 x, self.floe_params, self.wave_params, self.growth_params, num_params 

190 ) 

191 

192 

193@attrs.define 

194class CurvatureHandler: 

195 floe_params: tuple[float] 

196 wave_params: tuple[np.ndarray] 

197 growth_params: list[np.ndarray, float] | None = None 

198 

199 @classmethod 

200 def from_wuf(cls, wuf: model.WavesUnderFloe, growth_params=None): 

201 return cls(*_package_wuf(wuf, growth_params)) 

202 

203 def _cur_wavefield(self, x): 

204 """Second derivative of the interface""" 

205 red_num = self.floe_params[0] 

206 _, c_wavenumbers = self.wave_params 

207 

208 return -np.imag( 

209 (_dis_par_amps(red_num, self.wave_params) * c_wavenumbers**2) 

210 @ np.exp(1j * c_wavenumbers[:, None] * x) 

211 ) 

212 

213 def _cur_hom(self, x): 

214 """Second derivative of the homogeneous part of the displacement""" 

215 red_num, length = self.floe_params 

216 arr = red_num * x 

217 cosx, sinx = np.cos(arr), np.sin(arr) 

218 expx = np.exp(-red_num * (length - x)) 

219 exmx = np.exp(-arr) 

220 return ( 

221 2 

222 * red_num**2 

223 * np.vstack(([-expx * sinx, expx * cosx, exmx * sinx, -exmx * cosx])).T 

224 @ _dis_hom_coefs(self.floe_params, self.wave_params) 

225 ) 

226 

227 def _cur_par(self, x): 

228 """Second derivative of the particular part of the displacement""" 

229 return self._cur_wavefield(x) 

230 

231 @_demote_to_scalar 

232 def _cur(self, x, /): 

233 return self._cur_hom(x) + self._cur_par(x) 

234 

235 def compute( 

236 self, 

237 x, 

238 an_sol: bool | None = None, 

239 num_params: dict | None = None, 

240 ): 

241 """Curvature of the floe, i.e. second derivative of the vertical displacement""" 

242 if numerical._use_an_sol(an_sol, self.floe_params[1], self.growth_params): 

243 return self._cur(x) 

244 return numerical.curvature( 

245 x, self.floe_params, self.wave_params, self.growth_params, num_params 

246 ) 

247 

248 

249@attrs.define 

250class StrainHandler: 

251 curv_handler: CurvatureHandler 

252 thickness: float 

253 

254 @classmethod 

255 def from_wuf(cls, wuf: model.WavesUnderFloe, growth_params=None): 

256 return cls(CurvatureHandler.from_wuf(wuf, growth_params), wuf.wui.ice.thickness) 

257 

258 def compute(self, x, an_sol: bool | None = None, num_params: dict | None = None): 

259 return -self.thickness / 2 * self.curv_handler.compute(x, an_sol, num_params) 

260 

261 

262@attrs.define 

263class EnergyHandler: 

264 floe_params: tuple[float] 

265 wave_params: tuple[np.ndarray] 

266 growth_params: list[np.ndarray, float] | None = None 

267 factor: float = 1 

268 

269 @classmethod 

270 def from_wuf(cls, wuf: model.WavesUnderFloe, growth_params=None): 

271 factor = wuf.wui.ice.flex_rigidity / (2 * wuf.wui.ice.thickness) 

272 return cls(*_package_wuf(wuf, growth_params), factor) 

273 

274 def _egy_hom(self): 

275 """Energy from the homogen term of the displacement ODE""" 

276 red_num, length = self.floe_params 

277 adim = red_num * length 

278 adim2 = 2 * adim 

279 c_1, c_2, c_3, c_4 = _dis_hom_coefs(self.floe_params, self.wave_params) 

280 

281 return red_num**3 * ( 

282 +(c_1**2) * (-SQR2 * np.sin(adim2 + PI_D4) + 2 - np.exp(-adim2)) / 2 

283 + c_2**2 * (SQR2 * np.sin(adim2 + PI_D4) + 2 - 3 * np.exp(-adim2)) / 2 

284 + c_3**2 * ((SQR2 * np.cos(adim2 + PI_D4) - 2) * np.exp(-adim2) + 1) / 2 

285 + c_4**2 * (3 - (SQR2 * np.cos(adim2 + PI_D4) + 2) * np.exp(-adim2)) / 2 

286 + c_1 * c_2 * (SQR2 * np.cos(adim2 + PI_D4) - np.exp(-adim2)) 

287 - 2 

288 * red_num 

289 * c_1 

290 * c_3 

291 * (2 * length - np.sin(adim2) / red_num) 

292 * np.exp(-adim) 

293 + 4 * c_1 * c_4 * np.exp(-adim) * np.sin(adim) ** 2 

294 + 4 * c_2 * c_3 * np.exp(-adim) * np.sin(adim) ** 2 

295 - 2 

296 * red_num 

297 * c_2 

298 * c_4 

299 * (2 * length + np.sin(adim2) / red_num) 

300 * np.exp(-adim) 

301 + c_3 * c_4 * (-1 + SQR2 * np.exp(-adim2) * np.sin(adim2 + PI_D4)) 

302 ) 

303 

304 def _egy_par_vals(self): 

305 red_num = self.floe_params[0] 

306 comp_amps = _dis_par_amps(red_num, self.wave_params) 

307 _, c_wavenumbers = self.wave_params 

308 

309 return comp_amps * (1j * c_wavenumbers) ** 2 

310 

311 def _egy_par_pow2(self): 

312 """Energy contribution from individual forcings""" 

313 red_num, length = self.floe_params 

314 _, c_wavenumbers = self.wave_params 

315 wavenumbers = np.real(c_wavenumbers) 

316 attenuations = np.imag(c_wavenumbers) 

317 

318 comp_curvs = self._egy_par_vals() 

319 wn_moduli, curv_moduli = map(np.abs, (c_wavenumbers, comp_curvs)) 

320 wn_phases, curv_phases = map(np.angle, (c_wavenumbers, comp_curvs)) 

321 

322 # Because of a division by `attenuations`, this term must me handled 

323 # with some extra care 

324 attenuation_mask = attenuations != 0 

325 non_oscillatory_term = np.full(attenuations.shape, np.nan) 

326 non_oscillatory_term[attenuation_mask] = ( 

327 -np.expm1(-2 * attenuations[attenuation_mask] * length) 

328 / attenuations[attenuation_mask] 

329 ) 

330 # NOTE: corresponds to the limit of the previous term, when attenuation -> 0 

331 non_oscillatory_term[~attenuation_mask] = 2 * length 

332 

333 return ( 

334 curv_moduli**2 

335 @ ( 

336 non_oscillatory_term 

337 + ( 

338 np.sin(2 * curv_phases - wn_phases) 

339 - np.sin(2 * (wavenumbers * length + curv_phases) - wn_phases) 

340 * np.exp(-2 * attenuations * length) 

341 ) 

342 / wn_moduli 

343 ) 

344 ) / 4 

345 

346 def _egy_par_m(self): 

347 """Energy contribution from forcing interactions""" 

348 red_num, length = self.floe_params 

349 _, c_wavenumbers = self.wave_params 

350 wavenumbers = np.real(c_wavenumbers) 

351 attenuations = np.imag(c_wavenumbers) 

352 comp_curvs = self._egy_par_vals() 

353 

354 # Binomial coefficients, much quicker than itertools 

355 idx1, idx2 = np.triu_indices(c_wavenumbers.size, 1) 

356 

357 mean_attenuations = attenuations[idx1] + attenuations[idx2] 

358 comp_wns = ( 

359 wavenumbers[idx1] - wavenumbers[idx2], 

360 wavenumbers[idx1] + wavenumbers[idx2], 

361 ) 

362 comp_wns += 1j * mean_attenuations 

363 

364 curv_moduli = np.abs(comp_curvs) 

365 _curv_phases = np.angle(comp_curvs) 

366 curv_phases = ( 

367 _curv_phases[idx1] - _curv_phases[idx2], 

368 _curv_phases[idx1] + _curv_phases[idx2], 

369 ) 

370 

371 def _f(comp_wns, curv_phases): 

372 wn_moduli = np.abs(comp_wns) 

373 wn_phases = np.angle(comp_wns) 

374 return ( 

375 np.sin(curv_phases - wn_phases) 

376 - ( 

377 np.exp(-np.imag(comp_wns) * length) 

378 * np.sin(np.real(comp_wns) * length + curv_phases - wn_phases) 

379 ) 

380 ) / wn_moduli 

381 

382 return ( 

383 curv_moduli[idx1] 

384 * curv_moduli[idx2] 

385 @ (_f(comp_wns[1], curv_phases[1]) - _f(comp_wns[0], curv_phases[0])) 

386 ) 

387 

388 def _egy_par(self): 

389 """Energy from the forcing term of the displacement ODE""" 

390 return self._egy_par_pow2() + self._egy_par_m() 

391 

392 def _egy_m(self): 

393 red_num, length = self.floe_params 

394 _, c_wavenumbers = self.wave_params 

395 adim = red_num * length 

396 wavenumbers = np.real(c_wavenumbers) 

397 attenuations = np.imag(c_wavenumbers) 

398 

399 wn_abs, wn_phases = (_f(c_wavenumbers) for _f in (np.abs, np.angle)) 

400 comp_curvs = self._egy_par_vals() 

401 curv_moduli, curv_phases = np.abs(comp_curvs), np.angle(comp_curvs) 

402 num_add = red_num + wavenumbers 

403 num_sub = red_num - wavenumbers 

404 att_add = red_num + attenuations 

405 att_sub = red_num - attenuations 

406 q_pp = att_add**2 + num_add**2 

407 q_mp = att_sub**2 + num_add**2 

408 q_pm = att_add**2 + num_sub**2 

409 q_mm = att_sub**2 + num_sub**2 

410 

411 phase_deltas = wn_phases - curv_phases 

412 sin_phase_deltas = np.sin(phase_deltas) 

413 cos_phase_deltas = np.cos(phase_deltas) 

414 phase_quads = curv_phases + PI_D4 

415 sin_phase_quads = np.sin(phase_quads) 

416 cos_phase_quads = np.cos(phase_quads) 

417 energ_1_K = np.array( 

418 ( 

419 sin_phase_deltas * (1 / q_mp - 1 / q_mm), 

420 cos_phase_deltas * (1 / q_mp + 1 / q_mm), 

421 -sin_phase_deltas * (1 / q_pp - 1 / q_pm), 

422 -cos_phase_deltas * (1 / q_pp + 1 / q_pm), 

423 ) 

424 ) 

425 energ_1_b = np.array( 

426 ( 

427 -(sin_phase_quads / q_mp - cos_phase_quads / q_mm), 

428 (cos_phase_quads / q_mp - sin_phase_quads / q_mm), 

429 -(cos_phase_quads / q_pp - sin_phase_quads / q_pm), 

430 -(sin_phase_quads / q_pp - cos_phase_quads / q_pm), 

431 ) 

432 ) 

433 energ_1 = wn_abs * energ_1_K + SQR2 * red_num * energ_1_b 

434 energ_1[:2, :] *= np.exp(-adim) 

435 

436 arg_add = num_add * length 

437 arg_add_phase = arg_add - phase_deltas 

438 sin_arg_ap = np.sin(arg_add_phase) 

439 cos_arg_ap = np.cos(arg_add_phase) 

440 arg_sub = num_sub * length 

441 arg_sub_phase = arg_sub + phase_deltas 

442 sin_arg_sp = np.sin(arg_sub_phase) 

443 cos_arg_sp = np.cos(arg_sub_phase) 

444 arg_add_quad = arg_add + phase_quads 

445 sin_arg_aq = np.sin(arg_add_quad) 

446 cos_arg_aq = np.cos(arg_add_quad) 

447 arg_sub_quad = arg_sub - curv_phases + PI_D4 

448 sin_arg_sq = np.sin(arg_sub_quad) 

449 cos_arg_sq = np.cos(arg_sub_quad) 

450 energ_exp_K = np.array( 

451 ( 

452 sin_arg_ap / q_mp + sin_arg_sp / q_mm, 

453 -(cos_arg_ap / q_mp + cos_arg_sp / q_mm), 

454 -(sin_arg_ap / q_pp + sin_arg_sp / q_pm), 

455 cos_arg_ap / q_pp + cos_arg_sp / q_pm, 

456 ) 

457 ) 

458 energ_exp_b = np.array( 

459 ( 

460 sin_arg_aq / q_mp - sin_arg_sq / q_mm, 

461 -cos_arg_aq / q_mp + cos_arg_sq / q_mm, 

462 cos_arg_aq / q_pp - cos_arg_sq / q_pm, 

463 sin_arg_aq / q_pp - sin_arg_sq / q_pm, 

464 ) 

465 ) 

466 energ_exp = wn_abs * energ_exp_K + SQR2 * red_num * energ_exp_b 

467 energ_exp[2:, :] *= np.exp(-adim) 

468 energ_exp *= np.exp(-attenuations * length) 

469 

470 energ = ( 

471 (energ_1 + energ_exp) 

472 @ curv_moduli 

473 @ _dis_hom_coefs(self.floe_params, self.wave_params) 

474 ) 

475 return red_num**2 * energ 

476 

477 def compute( 

478 self, 

479 an_sol: bool | None = None, 

480 num_params: dict | None = None, 

481 ): 

482 if numerical._use_an_sol(an_sol, self.floe_params[1], self.growth_params): 482 ↛ 485line 482 didn't jump to line 485, because the condition on line 482 was never false

483 unit_energy = self._egy_hom() + 2 * self._egy_m() + self._egy_par() 

484 else: 

485 unit_energy = numerical.energy( 

486 self.floe_params, self.wave_params, self.growth_params, num_params 

487 )[0] 

488 

489 return self.factor * unit_energy