Coverage for src/flexfrac1d/model/model.py: 92%

321 statements  

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

1from __future__ import annotations 

2 

3from collections.abc import Sequence 

4import functools 

5import itertools 

6from numbers import Real 

7import operator 

8import typing 

9 

10import attrs 

11import numpy as np 

12from sortedcontainers import SortedList 

13 

14from ..lib import att, dr, physics as ph 

15from ..lib.constants import PI_2, SQR2 

16from ..lib.graphics import plot_displacement 

17 

18if typing.TYPE_CHECKING: 

19 # Guard against circular imports 

20 from . import frac_handlers as fh 

21 

22 

23@attrs.define(frozen=True) 

24class Wave: 

25 """A monochromatic wave. 

26 

27 Parameters 

28 ---------- 

29 amplitude : float 

30 Wave amplitude in m 

31 period : float 

32 Wave period in s 

33 phase : 

34 Wave phase in rad 

35 

36 Attributes 

37 ---------- 

38 frequency : float 

39 Wave frequency in Hz 

40 angular_frequency: float 

41 Wave angular frequency in rad s^-1 

42 

43 """ 

44 

45 amplitude: float 

46 period: float 

47 phase: float = attrs.field(default=0, converter=lambda raw: raw % PI_2) 

48 

49 @classmethod 

50 def from_frequency(cls, amplitude, frequency, phase=0): 

51 """Build a wave from frequency instead of period. 

52 

53 Parameters 

54 ---------- 

55 amplitude : float 

56 The wave amplitude in m 

57 frequency : float 

58 The wave frequency in Hz 

59 phase : 

60 The wave phase in rad 

61 

62 Returns 

63 ------- 

64 Wave 

65 

66 """ 

67 return cls(amplitude, 1 / frequency, phase) 

68 

69 @functools.cached_property 

70 def frequency(self) -> float: 

71 return 1 / self.period 

72 

73 @functools.cached_property 

74 def angular_frequency(self) -> float: 

75 return PI_2 / self.period 

76 

77 @functools.cached_property 

78 def _angular_frequency_pow2(self) -> float: 

79 """Return the squared angular frequency. 

80 

81 This is a convenience method, this quantity appearing frequently in 

82 computations. 

83 

84 Returns 

85 ------- 

86 float 

87 

88 

89 """ 

90 return self.angular_frequency**2 

91 

92 

93@attrs.define(frozen=True) 

94class Ocean: 

95 """The fluid bearing ice floes. 

96 

97 This class encapsulates the properties of an incompressible ocean of 

98 constant depth and given density. 

99 

100 Parameters 

101 ---------- 

102 depth : float 

103 Ocean depth in m 

104 density : float 

105 Ocean density in kg m^-3 

106 

107 """ 

108 

109 depth: float = np.inf 

110 density: float = 1025 

111 

112 

113@attrs.define(frozen=True, eq=False) 

114@functools.total_ordering 

115class _Subdomain: 

116 """A segment localised in space. 

117 

118 Parameters 

119 ---------- 

120 left_edge : float 

121 Coordinate of the left edge of the domain in m 

122 length : float 

123 Length of the domain in m 

124 

125 Attributes 

126 ---------- 

127 right_edge : float 

128 Coordinate of the right edge of the domain in m 

129 

130 Notes 

131 ----- 

132 To be used within sorted collections, instances of `_Subdomain` and its 

133 subclasses need to be sortable. The order is defined with respect to the 

134 left edge. Therefore, an equality test between two instances of 

135 `_Subdomain` with the same `left_edge` attribute, and differing `length` 

136 attributes, would hold. This unfortunately differs from the behaviour of 

137 all other `attrs`-defined class, and can be surprising. 

138 

139 """ 

140 

141 # TODO: total_ordering and SortedList are nice and all, but the surprise 

142 # element of `==` not behaving as expected is not. Maybe consider a 

143 # rewrite/perf comparison, doing without, and reinstatiating a list in 

144 # order after breakup events, à la swisib. 

145 

146 left_edge: float 

147 length: float 

148 

149 def __eq__(self, other: _Subdomain | Real) -> bool: 

150 match other: 

151 case _Subdomain(): 

152 return self.left_edge == other.left_edge 

153 case Real(): 153 ↛ 155line 153 didn't jump to line 155, because the pattern on line 153 always matched

154 return self.left_edge == other 

155 case _: 

156 raise TypeError( 

157 "Comparison not supported between instance of " 

158 f"{type(self)} and {type(other)}" 

159 ) 

160 

161 def __lt__(self, other: _Subdomain | Real) -> bool: 

162 match other: 

163 case _Subdomain(): 

164 return self.left_edge < other.left_edge 

165 case Real(): 

166 return self.left_edge < other 

167 case _: 

168 raise TypeError( 

169 "Comparison not supported between instance of " 

170 f"{type(self)} and {type(other)}" 

171 ) 

172 

173 @functools.cached_property 

174 def right_edge(self): 

175 return self.left_edge + self.length 

176 

177 

178@attrs.define(frozen=True) 

179class Ice: 

180 """A container for ice mechanical properties. 

181 

182 Ice is modelled as an elastic thin plate, with prescribed density, 

183 thickness, Poisson's ratio and Young's modulus. Its fracture under bending 

184 is considered either through the lens of Griffith's fracture mechanics, or 

185 through the framework of strain failure commonly used in the sea ice 

186 modelling community. The fracture toughness is relevant to the former, 

187 while the strain threshold is relevant to the latter. Ice is considered 

188 translationally invariant in one horizontal direction, so that its 

189 quadratic moment of area is given per unit length in that direction. 

190 

191 Parameters 

192 ---------- 

193 density : float 

194 Density in kg m^-3 

195 frac_toughness : float 

196 Fracture toughness in Pa m^-1/2 

197 poissons_ratio : float 

198 Poisson's ratio 

199 strain_threshold : float 

200 Critical flexural strain in m m^-1 

201 thickness : float 

202 Ice thickness in m 

203 youngs_modulus : float 

204 Scalar Young's modulus in Pa 

205 

206 Attributes 

207 ---------- 

208 quad_moment : float 

209 Quadratic moment of area in m^3 

210 flex_rigidity : float 

211 Flexural rigidity in 

212 frac_energy_rate : float 

213 Fracture energy release rate in J m^-2 

214 

215 """ 

216 

217 density: float = 922.5 

218 frac_toughness: float = 1e5 

219 poissons_ratio: float = 0.3 

220 strain_threshold: float = 3e-5 

221 thickness: float = 1.0 

222 youngs_modulus: float = 6e9 

223 

224 @functools.cached_property 

225 def quad_moment(self) -> float: 

226 return self.thickness**3 / (12 * (1 - self.poissons_ratio**2)) 

227 

228 @functools.cached_property 

229 def flex_rigidity(self) -> float: 

230 return self.quad_moment * self.youngs_modulus 

231 

232 @functools.cached_property 

233 def frac_energy_rate(self) -> float: 

234 return ( 

235 (1 - self.poissons_ratio**2) * self.frac_toughness**2 / self.youngs_modulus 

236 ) 

237 

238 

239@attrs.define(kw_only=True, frozen=True) 

240class FloatingIce(Ice): 

241 """An extension of `Ice` to represent properties due to buyoancy. 

242 

243 Parameters 

244 ---------- 

245 draft : float 

246 Immersed ice thickness at rest in m 

247 dud : float 

248 Height of the water column underneath the ice at rest in m 

249 elastic_length_pow4 : float 

250 Characteristic elastic length scale, raised to the 4th power, in m^4 

251 

252 Attributes 

253 ---------- 

254 elastic_length : float 

255 Characteristic elastic length scale in m 

256 freeboard : float 

257 Emerged ice thickness at rest in m 

258 

259 """ 

260 

261 draft: float 

262 dud: float 

263 elastic_length_pow4: float 

264 

265 @classmethod 

266 def from_ice_ocean(cls, ice: Ice, ocean: Ocean, gravity: float) -> FloatingIce: 

267 """Build an instance by combining properties of existing objects. 

268 

269 Parameters 

270 ---------- 

271 ice : Ice 

272 ocean : Ocean 

273 gravity : float 

274 Strengh of the local gravitational field in m s^-2 

275 

276 Returns 

277 ------- 

278 FloatingIce 

279 

280 """ 

281 draft = ice.density / ocean.density * ice.thickness 

282 dud = ocean.depth - draft 

283 # NOTE: as the 4th power of the elastic length scale arises naturally, 

284 # we prefer using it to instantiate the class and computing the length 

285 # scale when needed, over using the length scale for instantiation and 

286 # recomputing the fourth power from it, as the latter approach can lead 

287 # to substantial numerical imprecision. 

288 el_lgth_pow4 = ice.flex_rigidity / (ocean.density * gravity) 

289 return cls( 

290 density=ice.density, 

291 frac_toughness=ice.frac_toughness, 

292 poissons_ratio=ice.poissons_ratio, 

293 strain_threshold=ice.strain_threshold, 

294 thickness=ice.thickness, 

295 youngs_modulus=ice.youngs_modulus, 

296 draft=draft, 

297 dud=dud, 

298 elastic_length_pow4=el_lgth_pow4, 

299 ) 

300 

301 @functools.cached_property 

302 def elastic_length(self): 

303 return self.elastic_length_pow4**0.25 

304 

305 @functools.cached_property 

306 def freeboard(self): 

307 return self.thickness - self.draft 

308 

309 @functools.cached_property 

310 def _elastic_number(self) -> float: 

311 """Reciprocal of the Characteristic elastic length scale. 

312 

313 Returns 

314 ------- 

315 float 

316 Elastic number in m^-1 

317 

318 """ 

319 return 1 / self.elastic_length 

320 

321 @functools.cached_property 

322 def _red_elastic_number(self) -> float: 

323 """Characteristic elastic number scaled by 1/sqrt(2). 

324 

325 Returns 

326 ------- 

327 float 

328 Reduced elastic number in m^-1 

329 

330 """ 

331 return 1 / (SQR2 * self.elastic_length) 

332 

333 

334@attrs.define(frozen=True) 

335class WavesUnderElasticPlate: 

336 """A non-localised zone characterised by wave action. 

337 

338 The spatial behaviour of waves (wavelength) is linked to their temporal 

339 behaviour (period) through a dispersion relation. In the case of waves 

340 propagating underneath floating ice, considered as an elastic plate, this 

341 dispersion relation depends on the properties of the ice as encapsulated by 

342 the `FloatingIce` class. 

343 

344 Parameters 

345 ---------- 

346 ice : FloatingIce 

347 wavenumbers : 1d array_like of float 

348 Propagating wavenumbers, in rad m^-1 

349 

350 """ 

351 

352 ice: FloatingIce 

353 wavenumbers: np.ndarray = attrs.field(repr=False) 

354 

355 @classmethod 

356 def from_floating( 

357 cls, 

358 ice: FloatingIce, 

359 spectrum: DiscreteSpectrum, 

360 gravity: float, 

361 ) -> typing.Self: 

362 """Build an instance by combining properties of existing objects. 

363 

364 Parameters 

365 ---------- 

366 ice : FloatingIce 

367 spectrum : DiscreteSpectrum 

368 gravity : float 

369 Strengh of the local gravitational field in m s^-2 

370 

371 Returns 

372 ------- 

373 WavesUnderElasticPlate 

374 

375 """ 

376 alphas = spectrum._ang_freqs_pow2 / gravity 

377 deg1 = 1 - alphas * ice.draft 

378 deg0 = -alphas * ice.elastic_length 

379 scaled_ratio = ice.dud / ice.elastic_length 

380 

381 solver = dr.ElasticMassLoadingSolver(alphas, deg1, deg0, scaled_ratio) 

382 # NOTE: `solver` returns non-dimensional wavenumbers 

383 wavenumbers = solver.compute_wavenumbers() / ice.elastic_length 

384 

385 return cls(ice, wavenumbers) 

386 

387 @classmethod 

388 def from_ocean( 

389 cls, 

390 ice: Ice, 

391 ocean: Ocean, 

392 spectrum: DiscreteSpectrum, 

393 gravity: float, 

394 ) -> typing.Self: 

395 """Build an instance by combining properties of existing objects. 

396 

397 Parameters 

398 ---------- 

399 ice : Ice 

400 ocean : Ocean 

401 spectrum : DiscreteSpectrum 

402 gravity : float 

403 Strengh of the local gravitational field in m s^-2 

404 

405 Returns 

406 ------- 

407 WavesUnderElasticPlate 

408 

409 """ 

410 floating_ice = FloatingIce.from_ice_ocean(ice, ocean, gravity) 

411 return cls.from_floating(floating_ice, spectrum, gravity) 

412 

413 

414# TODO: check docstring 

415@attrs.define(frozen=True) 

416class WavesUnderIce: 

417 """A non-localised zone characetrised by wave action under floating ice. 

418 

419 This class extends the behaviour of `WavesUnderElasticPlate` by adding an 

420 `attenuations` attribute, that parametrises the observed exponential decay 

421 of waves underneath floating ice. 

422 

423 Parameters 

424 ---------- 

425 ice : FloatingIce 

426 wavenumbers : 1d array_like of float 

427 Propagating wavenumbers, in rad m^-1 

428 attenuations : 1d array_like of float 

429 Parametrised wave amplitude attenuation rate, in m^-1 

430 

431 """ 

432 

433 ice: FloatingIce 

434 wavenumbers: np.ndarray = attrs.field(repr=False) 

435 attenuations: np.ndarray | Real = attrs.field(repr=False) 

436 

437 @classmethod 

438 def without_attenuation(cls, waves_under_ep: WavesUnderElasticPlate) -> typing.Self: 

439 """Build an instance by combining properties of existing objects. 

440 

441 Parameters 

442 ---------- 

443 waves_under_ep : WavesUnderElasticPlate 

444 An object instance 

445 

446 Returns 

447 ------- 

448 WavesUnderIce 

449 

450 See Also 

451 ----- 

452 lib.att.no_attenuation 

453 

454 """ 

455 return cls( 

456 waves_under_ep.ice, 

457 waves_under_ep.wavenumbers, 

458 att.no_attenuation(), 

459 ) 

460 

461 @classmethod 

462 def with_attenuation_01(cls, waves_under_ep: WavesUnderElasticPlate) -> typing.Self: 

463 """Build an instance by combining properties of existing objects. 

464 

465 Parameters 

466 ---------- 

467 waves_under_ep : WavesUnderElasticPlate 

468 An object instance 

469 

470 Returns 

471 ------- 

472 WavesUnderIce 

473 

474 See Also 

475 ----- 

476 lib.att.parameterisation_01 

477 

478 """ 

479 return cls( 

480 waves_under_ep.ice, 

481 waves_under_ep.wavenumbers, 

482 att.parameterisation_01( 

483 waves_under_ep.ice.thickness, waves_under_ep.wavenumbers 

484 ), 

485 ) 

486 

487 @classmethod 

488 def with_generic_attenuation( 

489 cls, 

490 waves_under_ep: WavesUnderElasticPlate, 

491 parameterisation: typing.Callable, 

492 args: str | None = None, 

493 **kwargs, 

494 ) -> typing.Self: 

495 """Instantiate a `WavesUnderFloe` with custom attenuation. 

496 

497 Parameters 

498 ---------- 

499 waves_under_ep : WavesUnderElasticPlate 

500 An object instance. 

501 parameterisation : typing.Callable 

502 Function defining attenuation. 

503 Must return a type broadcastable to `waves_under_ep.wavenumbers`. 

504 args : str | None 

505 A string of attributes of `waves_under_ep`, separated by 

506 whitespace, to be passed as parameters to `parameterisation`. 

507 All parameters will be passed as a mapping between the stem of the 

508 attribute and its value. 

509 **kwargs : dict 

510 Additional parameters to pass to `parameterisation`. 

511 

512 Returns 

513 ------- 

514 WavesUnderFloe 

515 

516 Examples 

517 -------- 

518 Assuming an existing `wue` instance of `WavesUnderElasticPlate`, the 

519 three following objects are identical, setting the attenuation egal to 

520 the ice density for all wave modes. 

521 

522 >>> WavesUnderIce.with_generic_attenuation_param( 

523 wue, 

524 lambda density: density, 

525 "ice.density" 

526 ) 

527 >>> WavesUnderIce.with_generic_attenuation_param( 

528 wue, 

529 lambda density: density, 

530 {"density": wue.ice.density}, 

531 ) 

532 >>> WavesUnderIce(wue.ice, wue.wavenumbers, wue.ice.density) 

533 

534 """ 

535 if args is not None: 

536 kwargs |= { 

537 arg.split(".")[-1]: operator.attrgetter(arg)(waves_under_ep) 

538 for arg in args.split() 

539 } 

540 return cls( 

541 waves_under_ep.ice, waves_under_ep.wavenumbers, parameterisation(**kwargs) 

542 ) 

543 

544 @functools.cached_property 

545 def _c_wavenumbers(self) -> np.ndarray: 

546 """Complex wavenumbers. 

547 

548 Their real part correspond to the propagating wavenumber, while their 

549 imaginary part correspond to the attenuation rate. 

550 

551 Returns 

552 ------- 

553 1d np.ndarray of complex 

554 The complex wavenumbers in m^-1 

555 

556 """ 

557 return self.wavenumbers + 1j * self.attenuations 

558 

559 

560@attrs.define(frozen=True) 

561class FreeSurfaceWaves: 

562 """The wave state in the absence of ice. 

563 

564 The spatial behaviour of waves (wavelength) is linked to their temporal 

565 behaviour (period) through a dispersion relation. In the case of free 

566 surface waves, propagating underneath floating ice, this dispersion 

567 relation depends on the properties of the ocean as encapsulated in the 

568 `Ocean` class. 

569 

570 Parameters 

571 ---------- 

572 ocean : Ocean 

573 wavenumbers : array_like 

574 Propagating wavenumbers in rad m^-1 

575 

576 Attributes 

577 ---------- 

578 wavelengths : 1d np.ndarray of float 

579 Propagating wavelengths in m 

580 

581 """ 

582 

583 ocean: Ocean 

584 wavenumbers: np.ndarray 

585 

586 @classmethod 

587 def from_ocean(cls, ocean: Ocean, spectrum: DiscreteSpectrum, gravity: float): 

588 """Build an instance by combining properties of existing objects.""" 

589 alphas = spectrum._ang_freqs_pow2 / gravity 

590 solver = dr.FreeSurfaceSolver(alphas, ocean.depth) 

591 wavenumbers = solver.compute_wavenumbers() 

592 return cls(ocean, wavenumbers) 

593 

594 @functools.cached_property 

595 def wavelengths(self) -> np.ndarray: 

596 return PI_2 / self.wavenumbers 

597 

598 

599# TODO: docstring inheritance 

600@attrs.define(kw_only=True, eq=False) 

601class Floe(_Subdomain): 

602 """An ice floe localised in space. 

603 

604 Parameters 

605 ---------- 

606 ice : Ice 

607 The mechanical properties of the floe 

608 

609 """ 

610 

611 ice: Ice 

612 

613 

614@attrs.define(kw_only=True, eq=False) 

615class WavesUnderFloe(_Subdomain): 

616 """A localised zone characetrised by wave action under floating ice. 

617 

618 Parameters 

619 ---------- 

620 wui : WavesUnderIce 

621 edge_amplitudes : 1d np.ndarray of complex 

622 The wave complex amplitude at the left edge of the floe in m 

623 generation : int 

624 The number of fractures that led to the existence of this floe 

625 

626 """ 

627 

628 wui: WavesUnderIce 

629 edge_amplitudes: np.ndarray 

630 generation: int = 0 

631 

632 @functools.cached_property 

633 def _adim(self) -> float: 

634 """A non-dimentional number characetrising the floe. 

635 

636 Returns 

637 ------- 

638 float 

639 

640 """ 

641 return self.length * self.wui.ice._red_elastic_number 

642 

643 # TODO: typing.Self? 

644 def make_copy(self) -> WavesUnderFloe: 

645 return WavesUnderFloe( 

646 left_edge=self.left_edge, 

647 length=self.length, 

648 wui=self.wui, 

649 edge_amplitudes=self.edge_amplitudes.copy(), 

650 generation=self.generation, 

651 ) 

652 

653 @typing.overload 

654 def shift_waves(self, phase_shifts: np.ndarray, inplace: typing.Literal[True]): ... 654 ↛ exitline 654 didn't return from function 'shift_waves'

655 

656 # TODO: typing.Self 

657 @typing.overload 

658 def shift_waves( 658 ↛ exitline 658 didn't jump to the function exit

659 self, phase_shifts: np.ndarray, inplace: typing.Literal[False] 

660 ) -> WavesUnderFloe: ... 

661 

662 # TODO: docstring 

663 def shift_waves(self, phase_shifts: np.ndarray, inplace: bool = True): 

664 shifted_amplitudes = self.edge_amplitudes * np.exp(-1j * phase_shifts) 

665 if not inplace: 665 ↛ 666line 665 didn't jump to line 666, because the condition on line 665 was never true

666 return WavesUnderFloe( 

667 left_edge=self.left_edge, 

668 length=self.length, 

669 wui=self.wui, 

670 edge_amplitudes=shifted_amplitudes, 

671 generation=self.generation, 

672 ) 

673 # HACK: instantiating an new object is cheap, resorting a whole list of 

674 # subdomains is not, so we mutate the amplitude/phase instead 

675 object.__setattr__(self, "edge_amplitudes", shifted_amplitudes) 

676 

677 def displacement( 

678 self, 

679 x: np.ndarray, 

680 growth_params=None, 

681 an_sol: bool | None = None, 

682 num_params=None, 

683 ): 

684 return ph.DisplacementHandler.from_wuf(self, growth_params).compute( 

685 x, an_sol, num_params 

686 ) 

687 

688 def energy(self, growth_params=None, an_sol: bool | None = None, num_params=None): 

689 return ph.EnergyHandler.from_wuf(self, growth_params).compute( 

690 an_sol, num_params 

691 ) 

692 

693 

694class DiscreteSpectrum: 

695 def __init__( 

696 self, 

697 amplitudes, 

698 frequencies, 

699 phases=0, 

700 ): 

701 

702 # np.ravel to force precisely 1D-arrays 

703 # Promote the map to list so the iterator can be used several times 

704 args = list(map(np.ravel, (amplitudes, frequencies, phases))) 

705 (size,) = np.broadcast_shapes(*(arr.shape for arr in args)) 

706 

707 # TODO: sort waves by frequencies or something 

708 # TODO: sanity checks on nan, etc. that could be returned 

709 # by the Spectrum objects 

710 

711 # If size is one, all the arguments are scalar and the "spectrum" is 

712 # monochromatic. Otherwise, there is at least one argument with 

713 # different components. The eventual other arguments with a single 

714 # component are repeated for instantiating as many Wave objects as 

715 # needed. 

716 if size != 1: 

717 for i, arr in enumerate(args): 

718 if arr.size == 0: 718 ↛ 719line 718 didn't jump to line 719, because the condition on line 718 was never true

719 raise ValueError 

720 if arr.size == 1: 

721 args[i] = itertools.repeat(arr[0], size) 

722 

723 self.__waves = [ 

724 Wave.from_frequency(_a, _f, phase=_ph) for _a, _f, _ph in zip(*args) 

725 ] 

726 

727 @property 

728 def waves(self): 

729 return self.__waves 

730 

731 @functools.cached_property 

732 def _ang_freqs_pow2(self): 

733 return self._ang_freqs**2 

734 

735 @functools.cached_property 

736 def _amps(self): 

737 return np.asarray([wave.amplitude for wave in self.waves]) 

738 

739 @functools.cached_property 

740 def _freqs(self): 

741 return np.asarray([wave.frequency for wave in self.waves]) 

742 

743 @functools.cached_property 

744 def _ang_freqs(self): 

745 return np.asarray([wave.angular_frequency for wave in self.waves]) 

746 

747 @functools.cached_property 

748 def _phases(self): 

749 return np.asarray([wave.phase for wave in self.waves]) 

750 

751 @functools.cached_property 

752 def nf(self): 

753 return len(self.waves) 

754 

755 

756# TODO: docstrings 

757@attrs.define 

758class Domain: 

759 """A spatial domain forced by waves. 

760 

761 This represents the state of a MIZ at a given time. 

762 

763 

764 Attributes 

765 ---------- 

766 gravity : float 

767 spectrum : DiscreteSpectrum 

768 fsw : FreeSurfaceWaves 

769 attenuation: flexrac1d.lib.att.Attenuation 

770 growth_params : list 

771 subdomains : SortedList of WavesUnderFloe 

772 cached_wuis : 

773 cached_phases : 

774 

775 """ 

776 

777 gravity: float 

778 spectrum: DiscreteSpectrum 

779 fsw: FreeSurfaceWaves 

780 attenuation: att.Attenuation = attrs.field(repr=False) 

781 growth_params: list[np.array, float] | None = None 

782 subdomains: SortedList = attrs.field(repr=False, init=False, factory=SortedList) 

783 cached_wuis: dict[Ice, WavesUnderIce] = attrs.field( 

784 repr=False, init=False, factory=dict 

785 ) 

786 cached_phases: dict[float, np.ndarray] = attrs.field( 

787 repr=False, init=False, factory=dict 

788 ) 

789 

790 @classmethod 

791 def from_discrete( 

792 cls, 

793 gravity, 

794 spectrum, 

795 ocean, 

796 attenuation: att.Attenuation | None = None, 

797 growth_params: tuple | None = None, 

798 ): 

799 fsw = FreeSurfaceWaves.from_ocean(ocean, spectrum, gravity) 

800 if attenuation is None: 

801 attenuation = att.AttenuationParameterisation(1) 

802 return cls(gravity, spectrum, fsw, attenuation, growth_params) 

803 

804 @classmethod 

805 def with_growth_means( 

806 cls, 

807 gravity: float, 

808 spectrum: DiscreteSpectrum, 

809 ocean: Ocean, 

810 growth_means: np.ndarray | Sequence[Real] | Real, 

811 attenuation: att.Attenuation | None = None, 

812 ) -> typing.Self: 

813 return cls.from_discrete( 

814 gravity, spectrum, ocean, attenuation, (growth_means, None) 

815 ) 

816 

817 @classmethod 

818 def with_growth_std( 

819 cls, 

820 gravity: float, 

821 spectrum: DiscreteSpectrum, 

822 ocean: Ocean, 

823 growth_std: Real, 

824 attenuation: att.Attenuation | None = None, 

825 ) -> typing.Self: 

826 return cls.from_discrete(gravity, spectrum, ocean, attenuation, (0, growth_std)) 

827 

828 def __attrs_post_init__(self): 

829 if self.growth_params is not None: 

830 if len(self.growth_params) != 2: 

831 raise ValueError 

832 growth_means, growth_std = ( 

833 np.asarray(self.growth_params[0]), 

834 self.growth_params[1], 

835 ) 

836 # TODO: simplify all this. Ideally, do not test for anything or 

837 # babysit the user. Why was upping growth_mean to a column 

838 # necessary in case its of size 1? 

839 if growth_means.size == 1: 

840 # As `broadcast_to` returns a view, 

841 # copying is necessary to obtain a mutable array. It is easier 

842 # than dealing with 0-length and 1-length arrays seperately. 

843 growth_means = np.broadcast_to( 

844 growth_means, (self.spectrum.nf, 1) 

845 ).copy() 

846 else: 

847 if growth_means.size != self.spectrum.nf: 847 ↛ 853line 847 didn't jump to line 853, because the condition on line 847 was never false

848 raise ValueError( 

849 f"Means (size {growth_means.size}) could not be" 

850 "broadcast with the shape of the spectrum" 

851 f"({self.spectrum.nf} components)" 

852 ) 

853 if growth_std is None: 

854 growth_std = self.fsw.wavelengths[self.spectrum._amps.argmax()] 

855 self.growth_params = [growth_means, growth_std] 

856 

857 def _compute_phase_shifts(self, delta_time: float): 

858 if delta_time not in self.cached_phases: 

859 self.cached_phases[delta_time] = delta_time * self.spectrum._ang_freqs 

860 return self.cached_phases[delta_time] 

861 

862 def _compute_wui(self, ice: Ice): 

863 if ice not in self.cached_wuis: 

864 wup = WavesUnderElasticPlate.from_ocean( 

865 ice, self.fsw.ocean, self.spectrum, self.gravity 

866 ) 

867 if isinstance(self.attenuation, att.AttenuationParameterisation): 867 ↛ 873line 867 didn't jump to line 873, because the condition on line 867 was never false

868 if self.attenuation == att.AttenuationParameterisation.NO: 

869 wui = WavesUnderIce.without_attenuation(wup) 

870 elif self.attenuation == att.AttenuationParameterisation.PARAM_01: 870 ↛ 879line 870 didn't jump to line 879, because the condition on line 870 was never false

871 wui = WavesUnderIce.with_attenuation_01(wup) 

872 else: 

873 wui = WavesUnderIce.with_generic_attenuation( 

874 wup, 

875 self.attenuation.function, 

876 self.attenuation.args, 

877 **self.attenuation.kwargs, 

878 ) 

879 self.cached_wuis[ice] = wui 

880 return self.cached_wuis[ice] 

881 

882 def _shift_phases(self, phases: np.ndarray): 

883 for i in range(len(self.floes)): 

884 self.floes[i].phases -= phases 

885 

886 def _shift_growth_means(self, phases: np.ndarray): 

887 # TODO: refine to take into account subdomain transitions 

888 # and floes with variying properties 

889 mask = self.growth_params[0] < self.subdomains[0].left_edge 

890 if mask.any(): 

891 self.growth_params[0][mask] += ( 

892 phases[mask[:, 0]] / self.fsw.wavenumbers[mask[:, 0]] 

893 ) 

894 if not mask.all(): 

895 self.growth_params[0][~mask] += ( 

896 phases[~mask[:, 0]] / self.subdomains[0].wui.wavenumbers[~mask[:, 0]] 

897 ) 

898 

899 def add_floes(self, floes: Floe | Sequence[Floe]): 

900 subdomains = self._init_subdomains(floes) 

901 self._add_c_floes(subdomains) 

902 

903 @staticmethod 

904 def _promote_floe(floes: Floe | Sequence[Floe]) -> Sequence[Floe]: 

905 match floes: 

906 case Floe(): 

907 return (floes,) 

908 case Sequence(): 

909 return floes 

910 case _: 

911 raise ValueError( 

912 "`floes` should be a `Floe` object or a sequence of such objects" 

913 ) 

914 

915 def _check_overlap(self, floes: Sequence[Floe]): 

916 l_edges, r_edges = map( 

917 np.array, zip(*((floe.left_edge, floe.right_edge) for floe in floes)) 

918 ) 

919 if not (r_edges[:-1] <= l_edges[1:]).all(): 

920 raise ValueError("Floe overlap") # TODO: dedicated exception 

921 

922 def _init_phases(self, floes: Sequence[Floe]) -> np.ndarray: 

923 phases = np.full((len(floes), self.spectrum.nf), np.nan) 

924 phases[0] = self.spectrum._phases + floes[0].left_edge * self.fsw.wavenumbers 

925 for i, floe in enumerate(floes[1:], 1): 

926 wui = self._compute_wui(floe.ice) 

927 prev = floes[i - 1] 

928 phases[i:,] = ( 

929 phases[i - 1] 

930 + floe.length * wui.wavenumbers 

931 + (prev.right_edge - floe.left_edge) * self.fsw.wavenumbers 

932 ) 

933 return phases % PI_2 

934 

935 def _init_amplitudes(self, floes: Sequence[Floe]) -> np.ndarray: 

936 amplitudes = np.full((len(floes), self.spectrum.nf), np.nan) 

937 amplitudes[0, :] = self.spectrum._amps 

938 for i, floe in enumerate(floes[1:], 1): 

939 amplitudes[i, :] = amplitudes[i - 1, :] * np.exp( 

940 -self._compute_wui(floe.ice).attenuations * floe.length 

941 ) 

942 return amplitudes 

943 

944 def _init_subdomains(self, floes: Sequence[Floe]) -> list[WavesUnderFloe]: 

945 # TODO: look for already existing floes. In the present state, only 

946 # valid for starting from scratch, not for adding floes to a domain 

947 # that already has some. 

948 floes = self.__class__._promote_floe(floes) 

949 self._check_overlap(floes) 

950 complex_amplitudes = self._init_amplitudes(floes) * np.exp( 

951 1j * self._init_phases(floes) 

952 ) 

953 

954 return [ 

955 WavesUnderFloe( 

956 left_edge=floe.left_edge, 

957 length=floe.length, 

958 wui=self._compute_wui(floe.ice), 

959 edge_amplitudes=edge_amplitudes, 

960 ) 

961 for floe, edge_amplitudes in zip(floes, complex_amplitudes) 

962 ] 

963 

964 def iterate(self, delta_time: float): 

965 phase_shifts = self._compute_phase_shifts(delta_time) 

966 # TODO: can be optimised by iterating a first time to extract the 

967 # edges, coerce them to a np.array, apply the product with 

968 # complex_shifts, and then iterate a second time to build the objects. 

969 # See Propagation_tests.ipynb/DNE06-26 

970 for i in range(len(self.subdomains)): 

971 self.subdomains[i].shift_waves(phase_shifts) 

972 if self.growth_params is not None: 972 ↛ 974line 972 didn't jump to line 974, because the condition on line 972 was never true

973 # Phases are only modulo'd in the setter 

974 self._shift_growth_means(phase_shifts) 

975 

976 def _pop_c_floe(self, wuf: WavesUnderFloe): 

977 self.subdomains.remove(wuf) 

978 

979 def _add_c_floes(self, wuf: Sequence[WavesUnderFloe]): 

980 # It is assume no overlap will occur, and phases have been properly 

981 # set, as these method should only be called after a fracture event 

982 self.subdomains.update(wuf) 

983 

984 def breakup( 

985 self, fracture_handler: fh._FractureHandler, an_sol=None, num_params=None 

986 ): 

987 dct = {} 

988 for i, wuf in enumerate(self.subdomains): 

989 xf = fracture_handler.search(wuf, self.growth_params, an_sol, num_params) 

990 if xf is not None: 

991 old = wuf 

992 new = fracture_handler.split(wuf, xf) 

993 dct[i] = old, new 

994 for old, new in dct.values(): 

995 self._pop_c_floe(old) 

996 self._add_c_floes(new) 

997 

998 def plot( 

999 self, 

1000 resolution: float, 

1001 left_bound: float, 

1002 ax=None, 

1003 an_sol=None, 

1004 add_surface=True, 

1005 base=0, 

1006 kw_dis=None, 

1007 kw_sur=None, 

1008 ): 

1009 plot_displacement( 

1010 resolution, self, left_bound, ax, an_sol, add_surface, base, kw_dis, kw_sur 

1011 )