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

1 from __future__ import annotations

2

3 import functools

4 import typing

5

6 import attrs

7 import numpy as np

8

9 from . import numerical

10 from ._ph_utils import _wavefield

11 from .constants import PI_D4, SQR2

12

13 if typing.TYPE_CHECKING:

14 from ..model import model

15

16

17 def _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

25 def _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

31 def _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

38 def _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

91 def _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.

112 def _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

126 class 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

150 class 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

194 class 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

250 class 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

263 class 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