Experiment

Setup

1import matplotlib.pyplot as plt
2import numpy as np
3import seaborn as sns
4from tqdm.auto import tqdm
5
6from swiift import Experiment, Ocean, Ice, Floe, DiscreteSpectrum
7from swiift.api.api import Step
8from swiift.lib.physics import FluidSurfaceHandler

Introduction

The most straightforward way to use SWIIFT, is through the API exposed by Experiment objects.

In this example, we present the steps to initiate and run a wave fracture simulation.

Ocean and gravity waves

Experiment instances are preferentially initialised through the factory method create().

This method takes gravity, ocean and spectrum (respectively of type float, Ocean, and DiscreteSpectrum) as mandatory parameters. It correctly initialises the attribute domain, an object that handles the coupling between ocean and spectrum by determining the wavenumbers associated to the forcing frequencies. It also initialise the fracture handler, and the attribute history, that will be populated when the experiment is run.

 1# All the physical quantities are given in the appropriate SI unit, or
 2# derived unit.
 3# Therefore, times are expressed in seconds, lengths in metres,
 4# accelerations in metre per square second, energies in joules, etc.
 5
 6# Set the value of gravity.
 7gravity = 9.8
 8
 9# Set the characteristics of an `Ocean` object and instantiate it.
10depth = 300
11ocean = Ocean(depth=depth)
12
13# Set the period and amplitude of the wave forcing,
14# and use them to instantiate a `DiscreteSpectrum` object.
15# Because we use scalar as parameters here, the model will be forced
16# by a simple monochromatic wave.
17period = 6
18amplitude = .25
19spectrum = DiscreteSpectrum(amplitude, 1 / period)
20
21# Gravity, spectrum and ocean are combined
22# to instantiate an `Experiment` object.
23experiment = Experiment.create(
24    gravity,
25    spectrum,
26    ocean,
27)
28
29# The attributes of an ``Experiment`` instance.
30# The current time of the experiment; increases when the experiment is run.
31# Initialised at 0 s.
32print(f"Time: {experiment.time} s\n")
33# The array of existing timesteps, that can index the history.
34# Empty for now, has the experiment was not run.
35print(f"Timesteps: {experiment.timesteps}\n")
36# The history, a dictionary of ``Step`` objects associated to individual timesteps.
37# Empty for now, has the experiment was not run.
38print(f"History: {experiment.history}\n")
39# The experiment's domain, the container that holds information on
40# the fluid, the forcing, and the floes.
41# It does not have any floes for now. We'll add them at the next step.
42print(f"Domain: {experiment.domain}\n")
43# Note the attribute ``fsw``, that combines the properties of ``ocean``
44# and ``spectrum``, as well as ``gravity``, to compute wavenumbers.
45print(f"`fsw` property of the domain: {experiment.domain.fsw}\n")
46# The attribute ``subdomains``, for now an empty list, shows there is
47# no floes in the domain.
48print(f"Subdomains: {experiment.domain.subdomains}\n")
49# The experiment's fracture handler.
50# It determines the fracture parametrisation that will be used.
51print(f"Experiment's fracture handler: {experiment.fracture_handler}")
Time: 0 s

Timesteps: []

History: {}

Domain: Domain(gravity=9.8, spectrum=DiscreteSpectrum(amplitudes=array([0.25]), frequencies=array([0.16666667]), phases=array([0.])), fsw=FreeSurfaceWaves(ocean=Ocean(depth=300, density=1025), wavenumbers=array([0.11190028])), growth_params=None)

`fsw` property of the domain: FreeSurfaceWaves(ocean=Ocean(depth=300, density=1025), wavenumbers=array([0.11190028]))

Subdomains: []

Experiment's fracture handler: BinaryFracture(coef_nd=4, scattering_handler=ContinuousScatteringHandler())

Ice and floe

A Floe can then be added to the Experiment. Floes bring together three properties: location (left_edge), extent (length), and ice characteristics (ice). The latter are grouped in an object Ice.

Once constructed, the floe (or a collection of floes) can be added to an existing Experiment with a call to the method add_floes().

 1# Set mechanical characteristics and instantiate an `Ice` object.
 2thickness = 0.5
 3youngs_modulus = 4e9
 4poissons_ratio = 0.3
 5ice = Ice(
 6    thickness=thickness,
 7    poissons_ratio=poissons_ratio,
 8    youngs_modulus=youngs_modulus,
 9)
10# Properties like the flexural rigidity, that can be inferred from
11# the initialisation parameters, are automatically computed.
12print(f"Flexural rigidity: {ice.flex_rigidity:e} Pa m^3.\n")
13
14# Set position and extent and instantiate a `Floe` object.
15left_edge = 12.3
16length = 236.1
17floe = Floe(left_edge=left_edge, length=length, ice=ice)
18# Floes are analogous to segments, bounded by two points in space.
19print(f"Floe goes from x={floe.left_edge} m to x={floe.right_edge:.1f} m.\n")
20
21# Add the floe to the experiment's domain.
22experiment.add_floes(floe)
23
24# Let us inspect attributes again.
25# The time has not changed.
26print(f"Time: {experiment.time} s.\n")
27# However, adding a floe to the experiment added this timestep as
28# a first key to the history.
29print(f"Timesteps: {experiment.timesteps}.\n")
30# The associated value is a ``Step`` object, that hold
31# its own ``subdomains`` attribute.
32print(f"History: {experiment.history}\n")
33# The ``subdomains`` property of ``experiment.domain`` is identical to
34# that of the last step in the history.
35print(f"Subdomains: {experiment.domain.subdomains}\n")
36# Subdomains are a list, that can be indexed to access
37# the ``WavesUnderFloe`` objects it contains.
38print(f"First subdomain: {experiment.domain.subdomains[0]}\n")
39# In particular, these have an attribute ``wui``.
40# Like ``domain.fsw``, these combine the forcing information with other
41# quantities to determine the wavenumbers propagating under the ice.
42print(
43    "`wui` property of the first subdomain: "
44    f"{experiment.domain.subdomains[0].wui}\n"
45)
46# These ``WavesUnderIce`` objects compose wavenumbers, attenuation, and
47# ice, which extends the class ``Ice`` to include properties that need
48# ocean and gravity to be defined, like draught and freeboard of the ice.
49print(
50    "`ice` property of the `wui` property of the first subdomain: "
51    f"{experiment.domain.subdomains[0].wui.ice}"
52)
Flexural rigidity: 4.578755e+07 Pa m^3.

Floe goes from x=12.3 m to x=248.4 m.

Time: 0 s.

Timesteps: [0].

History: {0: Step(subdomains=(WavesUnderFloe(left_edge=12.3, length=236.1, wui=WavesUnderIce(ice=FloatingIce(density=922.5, frac_toughness=100000.0, poissons_ratio=0.3, strain_threshold=3e-05, thickness=0.5, youngs_modulus=4000000000.0, draft=0.45, dud=299.55, elastic_length_pow4=4558.242487560556)), edge_amplitudes=array([0.04830009+0.24528983j]), generation=0),), growth_params=None)}

Subdomains: [WavesUnderFloe(left_edge=12.3, length=236.1, wui=WavesUnderIce(ice=FloatingIce(density=922.5, frac_toughness=100000.0, poissons_ratio=0.3, strain_threshold=3e-05, thickness=0.5, youngs_modulus=4000000000.0, draft=0.45, dud=299.55, elastic_length_pow4=4558.242487560556)), edge_amplitudes=array([0.04830009+0.24528983j]), generation=0)]

First subdomain: WavesUnderFloe(left_edge=12.3, length=236.1, wui=WavesUnderIce(ice=FloatingIce(density=922.5, frac_toughness=100000.0, poissons_ratio=0.3, strain_threshold=3e-05, thickness=0.5, youngs_modulus=4000000000.0, draft=0.45, dud=299.55, elastic_length_pow4=4558.242487560556)), edge_amplitudes=array([0.04830009+0.24528983j]), generation=0)

`wui` property of the first subdomain: WavesUnderIce(ice=FloatingIce(density=922.5, frac_toughness=100000.0, poissons_ratio=0.3, strain_threshold=3e-05, thickness=0.5, youngs_modulus=4000000000.0, draft=0.45, dud=299.55, elastic_length_pow4=4558.242487560556))

`ice` property of the `wui` property of the first subdomain: FloatingIce(density=922.5, frac_toughness=100000.0, poissons_ratio=0.3, strain_threshold=3e-05, thickness=0.5, youngs_modulus=4000000000.0, draft=0.45, dud=299.55, elastic_length_pow4=4558.242487560556)

Run the experiment

Single step

An experiment is moved forward in time by calling the method step(). It takes a time increment as mandatory parameter.

Warning

This method modifies the instance in-place. The wave is advected across the domain, the time attribute of the instance is incremented, and its history updated.

 1# We extract the first (and for now, only) subdomain to access its
 2# attributes more easily.
 3wuf = experiment.domain.subdomains[0]
 4# We establish in Mokus et al. (2026) a lower bound and an upper bound
 5# on the timestep.
 6# The former comes from fracture theory and represents the time
 7# necessary for a fracture to propagate entirely through the thickness;
 8# the second is empirical and was selected to ensure convergence in a
 9# repeated fracture experiment.
10shear_wave_speed = np.sqrt(ice.youngs_modulus / (2 * ice.density * (1 + ice.poissons_ratio)))
11print(f"{shear_wave_speed} m s^-1")
12min_time = ice.thickness / shear_wave_speed
13print(f"{min_time} s")
14phase_speed = (spectrum.angular_frequencies / wuf.wui.wavenumbers).squeeze()
15print(f"{phase_speed} m s^-1")
16security_factor = 1 / 5
17max_time = security_factor * spectrum.amplitudes.squeeze() / phase_speed
18print(f"{max_time} s")
19print(f"Time ratio: {max_time / min_time}")  # Greater than 1, good news!
20
21# We can now select the larger time as our simulation timestep.
22timestep = max_time
23experiment.step(timestep)
1291.3980737100994 m s^-1
0.0003871772849742092 s
11.661232222403259 m s^-1
0.004287711542519605 s
Time ratio: 11.074284853268754
 1# Let us inspect attributes again.
 2# This time, the time has changed, increased by ``timestep``.
 3print(f"Time: {experiment.time} s.\n")
 4# This new time was added to the history.
 5print(f"Timesteps: {experiment.timesteps} s.\n")
 6print(f"Length of history: {len(experiment.history)}")
 7# The final state of the experiment (corresponding to the current value
 8# of its attribute ``time`` is accessed with the convenience method
 9# ``get_final_state``.
10print(f"Number of floes: {len(experiment.get_final_state().subdomains)}")
11print(
12    "Length of floes: "
13    f"{np.asarray([_w.length for _w in experiment.get_final_state().subdomains])} m."
14)
15# Fractures conserve ice area (that is, in our 1D case, ice length).
16is_length_the_same = np.isclose(
17    sum([_w.length for _w in experiment.get_final_state().subdomains]),
18    floe.length,
19)
20print(
21    "Total ice length after fracture is the same as before: "
22    f"{is_length_the_same}\n"
23)
24# The wave has been advected.
25# We can confirm it by looking at the wave phase at the edge of the first (leftmost) floe.
26phase_0 = np.angle(experiment.history[0].subdomains[0].edge_amplitudes).squeeze()
27phase_t = np.angle(experiment.get_final_state().subdomains[0].edge_amplitudes).squeeze()
28print(
29    f"Wave phase at x={floe.left_edge} m, t=0 s: "
30    f"{phase_0} rad"
31)
32print(
33    f"Wave phase at x={floe.left_edge} m, t={experiment.time} s: "
34    f"{phase_t} rad"
35)
36print(
37    "Difference in phases: "
38    f"{phase_t - phase_0} rad."
39)
40print(
41    r"Timestep × angular frequency: "
42    f"{timestep * spectrum.angular_frequencies.squeeze()} rad."
43)
Time: 0.004287711542519605 s.

Timesteps: [0.         0.00428771] s.

Length of history: 2
Number of floes: 2
Length of floes: [ 34.35425515 201.74574485] m.
Total ice length after fracture is the same as before: True

Wave phase at x=12.3 m, t=0 s: 1.3763734028730055 rad
Wave phase at x=12.3 m, t=0.004287711542519605 s: 1.3718833218454416 rad
Difference in phases: -0.004490081027563875 rad.
Timestep × angular frequency: 0.004490081027563917 rad.

Multiple steps

The experiment can also be run for a specified number of steps by calling the method run() method. The total time, as well as the duration of a timestep, must be specified.

Warning

This method also modifies the instance in-place, by repeatedly calling step().

 1# Let's run the experiment for one extra second.
 2total_time = 3
 3# We instantiate a progress bar for convenient tracking.
 4n_steps = int(np.ceil(total_time / timestep).astype(int))
 5print(f"Number of timesteps to run: {n_steps}")
 6pbar = tqdm(total=n_steps)
 7experiment.run(
 8    total_time,
 9    timestep,
10    verbose=2,  # Tell the program to write to stdout on fractures
11    pbar=pbar,
12    dump_final=False,  # To keep the history in memory
13    
14)
Number of timesteps to run: 700
t = 0.009 s; N_f = 4
t = 0.013 s; N_f = 6
t = 0.017 s; N_f = 10
t = 0.021 s; N_f = 14
t = 0.026 s; N_f = 16
t = 0.030 s; N_f = 18
t = 0.141 s; N_f = 19
t = 0.172 s; N_f = 20
t = 0.596 s; N_f = 21
t = 1.972 s; N_f = 22
 1# Let us inspect attributes again.
 2print(f"Time: {experiment.time} s.")
 3print(f"Length of history: {len(experiment.history)}.")
 4# The final state of the experiment (corresponding to the current value
 5# of its attribute ``time`` is accessed with the convenience method
 6# ``get_final_state``.
 7print(f"Number of floes: {len(experiment.get_final_state().subdomains)}")
 8print(
 9    "Length of floes: "
10    f"{np.asarray([_w.length for _w in experiment.get_final_state().subdomains])} m."
11)
12# Fractures conserve ice area (that is, in our 1D case, ice length).
13is_length_the_same = np.isclose(
14    sum([_w.length for _w in experiment.get_final_state().subdomains]),
15    floe.length,
16)
17print(
18    "Total ice length after fracture is the same as before: "
19    f"{is_length_the_same}\n"
20)
Time: 3.0056857913062482 s.
Length of history: 702.
Number of floes: 22
Length of floes: [ 5.64177269 13.52769477 15.18478769  8.61617131  9.74803598 11.13716787
  8.87792195  8.4394007   8.80910952 16.92499919 14.97156451  8.61539743
  8.73203258  6.33835087 13.55364126 15.51786234  8.67925219  9.92593494
 10.94690929  8.88487497  8.63859646 14.3885215 ] m.
Total ice length after fracture is the same as before: True

Plotting some results

1import matplotlib as mpl
  1def floe_state_gen(
  2    xx: np.ndarray, tt: np.ndarray, _exp: Experiment, post_f_times: np.ndarray
  3) -> np.ndarray:
  4    """Generate mesh data to plot from floe generation.
  5
  6    Paramters
  7    ---------
  8    xx : np.ndarray
  9        1D array of boundaries between floes.
 10    tt : np.ndarray
 11        1D array of timesteps.
 12    _exp : Experiment
 13        Experiment object to build a mesh for.
 14    post_f_times : np.ndarray
 15        1D array of times following fractures.
 16
 17    Returns
 18    -------
 19    np.ndarray
 20        2D array of type int containing the generation mesh associated
 21        with the parameters.
 22        
 23    """
 24    # Initialise a mesh of integers with -1 as sentinel value.
 25    # Rows are time and columns space.
 26    floe_state = np.full((len(tt) - 1, len(xx) - 1), -1, dtype=int)
 27    # The first timestep has only a single floe of generation 1.
 28    floe_state[0, :] = 0
 29
 30    # Iterating on the timesteps following fracture, gradually build
 31    # the mesh by recovering spatial (left edge, length) and generation
 32    # information for each floe (inner loop) at each step (outer loop).
 33    for i, _t in enumerate(post_f_times, start=1):
 34        le, lgt, gen = get_edges_lengths_gen(_exp.history[_t])
 35        for _le, _l, _gen in zip(le, lgt, gen):
 36            # A floe goes from left edge (included) to right edge (excluded).
 37            mask = (xx >= _le) & (xx < _le + _l)
 38            floe_state[i, mask[:-1]] = _gen
 39    # Check that every cell in the mesh has had a value attributed.
 40    assert np.all(floe_state > -1)
 41    return floe_state
 42
 43
 44def get_edges_lengths_gen(step: Step):
 45    """Get edge, length, and generation of each floe in a step.
 46
 47    Paramters
 48    ---------
 49    step : Step
 50        The step to work on.
 51        
 52    """
 53    # `Step` objects are simple namedtuple objects representing
 54    # the geometry in the model at a given timestep.
 55    # The important field now is `subdomains`, the list of floe
 56    # objects in the domain.
 57    subdomains = step.subdomains
 58    # Sequentially recover, then return, the left edge,
 59    # length, and generation, of each floe in the domain.
 60    edges, lengths, generations = zip(*(
 61        (_w.left_edge, _w.length, _w.generation) for _w in subdomains
 62    ))
 63    return np.vstack((edges, lengths, generations))
 64
 65
 66def make_gen_pmesh(_exp: Experiment):
 67    """Generate and display a space-time plot of generations.
 68
 69    Parameters
 70    ----------
 71    _exp : Experiment
 72        The experiment to generate the figure of.
 73        
 74    """
 75    # Build an array of times.
 76    # The first time is the first key in `history`, the last time
 77    # is the Experiment's current `time` attribute.
 78    first_time, final_time = next(iter(_exp.history)), _exp.time
 79    # This convenience method returns all the times (keys in `history`)
 80    # immediately following breakups.
 81    post_f_times = _exp.get_post_fracture_times()
 82    # Extend the post-breakup times with our initial and final times.
 83    extended_post_times = np.hstack((first_time, post_f_times, final_time))
 84
 85    # Build an array of x coordinates.
 86    # Recover the final state, identify the left edge of each floe,
 87    # and add the right edge of the rightmost floe to close the domain.
 88    final_state = _exp.get_final_state()
 89    xx, tt = (
 90        np.hstack((
 91            get_edges_lengths_gen(final_state)[0],
 92            final_state.subdomains[-1].right_edge,
 93        )),
 94        extended_post_times,
 95    )
 96
 97    # Get the mesh values0
 98    floe_state = floe_state_gen(xx, tt, _exp, post_f_times)
 99
100    # Prep a discrete colourbar.
101    generations = np.unique(floe_state)
102    # If N categories, we need N+1 bounds.
103    bounds = np.arange(len(generations) + 1)
104    # Artifical 0.5 offset for label centring.
105    _bounds = bounds - .5
106    norm = mpl.colors.BoundaryNorm(_bounds, len(generations))
107    fmt = mpl.ticker.FuncFormatter(lambda x, pos: generations[norm(x)])
108    cmap = mpl.colors.LinearSegmentedColormap.from_list(
109        "name",
110        mpl.colormaps.get_cmap("grey")(generations / generations.max()), len(generations)
111    )
112    # Plot, add label decoration, show and close.
113    plt.pcolormesh(xx, tt, floe_state, cmap=cmap, norm=norm)    
114    plt.colorbar(ticks=generations, format=fmt, label="Floe generation")
115    plt.xlabel("$x$ (m)")
116    plt.ylabel("$t$ (s)")
117    plt.show()
118    plt.close()

Space-time floe generations

1from swiift.model.model import WavesUnderFloe

Fractured floes have an attribute generation that keeps track of the number of fracture events having led to them. Floes manually added to the domain have their generation set to 0. When a fracture occurs, the right fragment keeps the generation of the parent floe, and the left fragment has its generation incremented by 1.

The next cell shows the space-time evolution of the floes generation during the experiment. Feel free to inspect the code of the function that builds the figure, to get some insight on swiift innards.

1make_gen_pmesh(experiment)
../_images/3a9b6f16187f2449f91b6dd968a1f20a583ba4cb7612c59e6ebc7621a5f69538.png

Final FSD

An important quantity is the floe size distribution after repeated breakup. It can easily be constructed and visualise as demonstrated below.

The method get_final_state() conveniently gives access to the last Step of the simulation: calling experiment.get_final_state() is equivalent to calling experiment.history[experiment.time]. This Step object is simply a namedtuple; for our purpose, the important field is subdomains. A subdomain is simply a Floe-derived object, that contains length information.

 1# Get the final subdomains.
 2final_subdomains = experiment.get_final_state().subdomains
 3# For every subdomain, recover its length.
 4floe_lengths = np.asarray([_w.length for _w in final_subdomains])
 5
 6fig, axes = plt.subplots(2, sharex=True)
 7
 8# Plot the density as a histogram.
 9sns.histplot(floe_lengths, stat="density", ax=axes[0])
10# Alternatively, plot the density as a stripplot, more legible when
11# observations are few.
12# Each dot represents an observation, the y-axis is only here to
13# prevent the dots from overlapping.
14sns.stripplot(x=floe_lengths, ax=axes[1])
15
16plt.xlabel("Floe length (x)")
17plt.title("Distribution of floe lengths")
18plt.show()
19plt.close()
../_images/b49984368404c8b493d87c81bbe03ef1504b89df1515f722637683629bada07e.png

Number of floes in time

One might also be interested in the time-dependent evolution of the number of floes.

 1# Get the time following fracture events.
 2post_f_times = experiment.get_post_fracture_times()
 3# For each of these times, recover the length of the domain.
 4n_floes = [len(experiment.history[_pf_time].subdomains) for _pf_time in post_f_times]
 5
 6plt.plot(post_f_times, n_floes, ".-")
 7# Number of floes grows fast for a few timesteps then slows down.
 8plt.xscale("log")
 9# We're plotting integers, so avoid floating point ticks.
10# Add a nice grid.
11plt.gca().yaxis.set_major_locator(plt.MultipleLocator(5))
12plt.gca().yaxis.set_minor_locator(plt.MultipleLocator(1))
13plt.grid(axis="y")
14plt.show()
15plt.close()
../_images/2786de1f224793fc42809ea156dc6fe368e735651380c68143cfdd7435658062.png

Spatial evolution

The domain is iteratively populated with new floes. The way these are spatially aranged, that is, which floe breaks and when, can be visualised as illustrated below.

 1# Instantiate a perceptually uniform colour palette with enough colours.
 2palette = sns.color_palette("flare_r", n_colors=len(post_f_times))
 3
 4with palette:
 5    for _it, _t in enumerate(post_f_times):
 6        _s = experiment.history[_t]
 7        # Construct a step function to illustrate the time evolution
 8        # of the spatial arrangement of the domain.
 9        # At each (post-fracture) timestep, we get the boundaries of
10        # each floe.
11        # We use these to draw a segment.
12        # The next segment is then drawn a little higher, in a step pattern.
13        # The y-axis is thus the floe rank number within the array,
14        # normalised by the size of the array.
15        # The luminance increases at each timestep: "more recent" times
16        # are lighter in colour.
17        for i, _w in enumerate(_s.subdomains):
18            plt.plot(
19                [_w.left_edge, _w.right_edge],
20                [i / len(_s.subdomains)]*2,
21                f"C{_it}",
22                lw=2,
23            )
24    plt.xlabel("$x$ (m)")
25    plt.ylabel("Relative order")
26    plt.show()
27    plt.close()
28
29# Same thing using maplotlib builtin `plt.stairs`, which adds vertical
30# connecting lines between horizontal segments.
31# with palette:
32#     for _it, _t in enumerate(experiment.get_post_fracture_times()):
33#         _s = experiment.history[_t].subdomains
34#         edges = np.asarray([_w.left_edge for _w in _s] + [_s[-1].right_edge])
35#         values = np.arange(len(edges) - 1) / (len(edges) - 2)
36#         plt.stairs(
37#             values,
38#             edges,
39#             edgecolor=f"C{_it}",
40#             lw=2,
41#         )
42#     plt.show()
43#     plt.close()
../_images/fd733ec4fe1a2cd17479278a5672bbc2ada9966b38bfc40f92ca010509220315.png