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)
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()
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()
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()