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

1 from __future__ import annotations

2

3 from collections.abc import Sequence

4 import functools

5 import itertools

6 from numbers import Real

7 import operator

8 import typing

9

10 import attrs

11 import numpy as np

12 from sortedcontainers import SortedList

13

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

15 from ..lib.constants import PI_2, SQR2

16 from ..lib.graphics import plot_displacement

17

18 if typing.TYPE_CHECKING:

19 # Guard against circular imports

20 from . import frac_handlers as fh

21

22

23@attrs.define(frozen=True)

24 class 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)

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

115 class _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)

179 class 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)

240 class 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)

335 class 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)

416 class 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)

561 class 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)

601 class 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)

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

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

758 class 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 )