floating-dynamics 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ """Two-dimensional dynamics of floating bodies, including the free-surface
2
+ effect of liquid cargo."""
3
+
4
+ __version__ = '0.1.0'
@@ -0,0 +1,5 @@
1
+ """Run the free-surface demonstration with python -m floating_dynamics."""
2
+
3
+ from .free_surface import main
4
+
5
+ main()
@@ -0,0 +1,282 @@
1
+ """The dynamics of a two-dimensional body floating in the sea.
2
+
3
+ The model is a cross-section of a long body, so every area is really a volume
4
+ per unit length and every "area density" is a mass per unit length: with
5
+ lengths in m and densities in t.m-3, masses are in tonnes, moments of inertia
6
+ in t.m2 and forces in kN, per metre of the body's length. The sea surface is
7
+ the line y = 0, and the sea lies below it.
8
+
9
+ The body is rigid, apart from any liquid it carries in its compartments. Its
10
+ outline determines its buoyancy; its mass, M, centre of mass, G, and moment
11
+ of inertia, Iz, are given separately (for a ship, they depend on how it is
12
+ built and loaded), or calculated for a body of uniform density.
13
+
14
+ The body's state is the vector y = (X, Y, theta, U, V, omega):
15
+ (X, Y) is the position of G (excluding any liquid) in world coordinates;
16
+ theta is its angle of rotation, anticlockwise, from its initial shape;
17
+ (U, V) is the velocity of G and omega = dtheta/dt its angular velocity.
18
+
19
+ Gravity acts at G and buoyancy acts at the centre of buoyancy, B: the centroid
20
+ of the submerged part of the outline. The body is assumed to be watertight:
21
+ the whole of its outline displaces water, however deeply it is submerged.
22
+
23
+ The liquid in each compartment is treated quasi-statically: its surface is
24
+ always level, and its weight acts at its centroid, which moves across the
25
+ compartment as the body rolls (the "free-surface effect"). This is a good
26
+ approximation if the liquid sloshes quickly compared with the body's roll
27
+ (see sloshing_period). The liquid moves with the body as it heaves and, for
28
+ its moment of inertia, it is treated as a point mass at its centroid.
29
+
30
+ The surrounding water is modelled crudely, by:
31
+ added mass: the body must accelerate some water along with it, which is
32
+ modelled by increasing its mass for linear motion, and its moment of
33
+ inertia for rotation, by a fraction of themselves;
34
+ damping: the water resists motion (and carries energy away in waves),
35
+ which is modelled by linear damping of the linear and angular
36
+ velocities at fixed rates.
37
+
38
+ """
39
+
40
+ import numpy as np
41
+ from scipy.optimize import brentq
42
+ from .geometry import (area, make_ccw, centroid, second_moment, transform,
43
+ clip_below, fill, cross2)
44
+
45
+ # Acceleration due to gravity, m.s-2.
46
+ g = 9.81
47
+ # Density of sea water, t.m-3.
48
+ RHO_SEA = 1.027
49
+
50
+
51
+ def mass_properties(poly, rho):
52
+ """Return the mass, centre of mass and Iz of polygon poly of density rho.
53
+
54
+ Iz is the moment of inertia about the centre of mass.
55
+
56
+ """
57
+
58
+ xy = make_ccw(np.asarray(poly, dtype=float))
59
+ G = centroid(xy)
60
+ return rho * area(xy), G, rho * second_moment(xy, G)
61
+
62
+
63
+ def combine(*parts):
64
+ """Return the mass, centre of mass and Iz of a body made of parts.
65
+
66
+ Each part is a tuple (M, G, Iz) of its mass, centre of mass and moment of
67
+ inertia about its centre of mass; the combined Iz is about the combined
68
+ centre of mass (by the parallel axis theorem).
69
+
70
+ """
71
+
72
+ M = sum(p[0] for p in parts)
73
+ G = sum(p[0] * np.asarray(p[1]) for p in parts) / M
74
+ Iz = sum(p[2] + p[0] * np.sum((np.asarray(p[1]) - G)**2) for p in parts)
75
+ return M, G, Iz
76
+
77
+
78
+ class Compartment:
79
+ """A tank fixed in the body, holding a liquid whose surface stays level."""
80
+
81
+ def __init__(self, tank, A_liquid, rho):
82
+ """Initialize the compartment.
83
+
84
+ tank is a sequence of (x, y) vertices in the same coordinates as the
85
+ body's outline; it holds a liquid of area A_liquid and density rho.
86
+
87
+ """
88
+
89
+ self.tank = make_ccw(np.asarray(tank, dtype=float))
90
+ if not 0 <= A_liquid <= area(self.tank):
91
+ raise ValueError('The liquid does not fit in the tank.')
92
+ self.A_liquid, self.rho = A_liquid, rho
93
+ self.M = rho * A_liquid
94
+
95
+ def upright_liquid(self):
96
+ """Return the polygon of the liquid when the body is upright."""
97
+ return fill(self.tank, self.A_liquid)
98
+
99
+ def sloshing_period(self):
100
+ """Return the period of the liquid's slowest sloshing mode (s).
101
+
102
+ The liquid is approximated, when upright, as a rectangle as wide as
103
+ its free surface and of the same area, for which the angular
104
+ frequency of the slowest mode is sqrt((pi.g/b).tanh(pi.h/b)).
105
+
106
+ """
107
+
108
+ liquid = self.upright_liquid()
109
+ top = np.isclose(liquid[:, 1], liquid[:, 1].max())
110
+ b = np.ptp(liquid[top, 0])
111
+ h = self.A_liquid / b
112
+ return 2 * np.pi / np.sqrt(np.pi * g / b * np.tanh(np.pi * h / b))
113
+
114
+
115
+ class FloatingBody:
116
+ """A body with a polygonal outline, carrying liquid in compartments."""
117
+
118
+ def __init__(self, outline, M, G, Iz, compartments=(), rho_water=RHO_SEA,
119
+ added_mass=0, added_inertia=0, heave_damping=0,
120
+ roll_damping=0):
121
+ """Initialize the body.
122
+
123
+ outline is a sequence of (x, y) vertices for the body's shape. M is
124
+ its mass, G the position of its centre of mass (in the same
125
+ coordinates as outline) and Iz its moment of inertia about G, all
126
+ excluding the liquid in compartments, a sequence of Compartment
127
+ objects.
128
+
129
+ added_mass and added_inertia are the fractions by which the mass
130
+ (including the liquid) and Iz (excluding it) are increased by the
131
+ water which moves with the body. The damping rates (s-1) apply to the
132
+ linear and angular velocities, respectively.
133
+
134
+ """
135
+
136
+ xy = make_ccw(np.asarray(outline, dtype=float))
137
+ self.A = area(xy)
138
+ self.M, self.Iz = M, Iz
139
+ # The body's outline and tanks in its own frame of reference, with G
140
+ # at the origin.
141
+ self.G0 = np.asarray(G, dtype=float)
142
+ self.xy0 = xy - self.G0
143
+ self.compartments = compartments
144
+ self.tanks0 = [c.tank - self.G0 for c in compartments]
145
+ self.M_total = M + sum(c.M for c in compartments)
146
+ self.rho_water = rho_water
147
+ self.added_mass, self.added_inertia = added_mass, added_inertia
148
+ self.heave_damping, self.roll_damping = heave_damping, roll_damping
149
+
150
+ @classmethod
151
+ def uniform(cls, poly, rho, **kwargs):
152
+ """Return a solid body of uniform density rho with outline poly."""
153
+ return cls(poly, *mass_properties(poly, rho), **kwargs)
154
+
155
+ def to_world(self, xy, X, theta):
156
+ """Return polygon xy, in the outline's coordinates, in the world.
157
+
158
+ The body's G is at X, and it is rotated by theta.
159
+
160
+ """
161
+
162
+ return transform(np.asarray(xy) - self.G0, theta, X)
163
+
164
+ def world(self, X, theta):
165
+ """Return the body's outline with G at X, rotated by theta."""
166
+ return transform(self.xy0, theta, X)
167
+
168
+ def hydrostatics(self, X, theta):
169
+ """Return the submerged polygon, its area and the centre of buoyancy.
170
+
171
+ If the body is out of the water, the centre of buoyancy is None.
172
+
173
+ """
174
+
175
+ submerged = clip_below(self.world(X, theta))
176
+ A_sub = area(submerged)
177
+ B = centroid(submerged) if A_sub > 0 else None
178
+ return submerged, A_sub, B
179
+
180
+ def liquids(self, X, theta):
181
+ """Return the liquid in each compartment with G at X, rotated by theta.
182
+
183
+ The return value is a list of (polygon, mass, centroid) for each
184
+ compartment with any liquid in it.
185
+
186
+ """
187
+
188
+ out = []
189
+ for c, tank0 in zip(self.compartments, self.tanks0):
190
+ if c.M > 0:
191
+ liquid = fill(transform(tank0, theta, X), c.A_liquid)
192
+ out.append((liquid, c.M, centroid(liquid)))
193
+ return out
194
+
195
+ def centre_of_mass(self, X, theta):
196
+ """Return the centre of mass of the body and its liquid."""
197
+ parts = [(self.M, X, 0)] + [(M_l, G_l, 0) for _, M_l, G_l
198
+ in self.liquids(X, theta)]
199
+ return combine(*parts)[1]
200
+
201
+ def equilibrium_height(self, theta):
202
+ """Return the height of G at which the body floats at angle theta.
203
+
204
+ At this height the buoyant force balances the body's weight, but
205
+ there is a torque on it unless B lies directly below (or above) the
206
+ centre of mass of the body and its liquid.
207
+
208
+ """
209
+
210
+ A_float = self.M_total / self.rho_water
211
+ if A_float >= self.A:
212
+ raise ValueError('The body is too heavy to float.')
213
+ y = transform(self.xy0, theta)[:, 1]
214
+ def excess_area(Y):
215
+ return area(clip_below(self.world((0, Y), theta))) - A_float
216
+ return brentq(excess_area, -y.max(), -y.min())
217
+
218
+ def torque(self, X, theta):
219
+ """Return the torque about G and the moment of inertia about G.
220
+
221
+ The torque is due to buoyancy and to the weight of the liquid; the
222
+ moment of inertia excludes the added inertia.
223
+
224
+ """
225
+
226
+ G = np.asarray(X)
227
+ _, A_sub, B = self.hydrostatics(G, theta)
228
+ Fb = np.array((0, self.rho_water * A_sub * g))
229
+ tau = cross2(B - G, Fb) if B is not None else 0
230
+ Iz = self.Iz
231
+ # The weight of each liquid acts at its centroid, G_l.
232
+ for _, M_l, G_l in self.liquids(G, theta):
233
+ r = G_l - G
234
+ tau += cross2(r, (0, -M_l * g))
235
+ Iz += M_l * r @ r
236
+ return tau, Iz
237
+
238
+ def righting_lever(self, theta):
239
+ """Return the righting lever, GZ, of the body floating at angle theta.
240
+
241
+ GZ is the horizontal distance between the lines of action of the
242
+ weight and the buoyancy: it is positive if the resulting couple turns
243
+ the body back towards theta = 0.
244
+
245
+ """
246
+
247
+ X = (0, self.equilibrium_height(theta))
248
+ tau, _ = self.torque(X, theta)
249
+ return -np.sign(theta) * tau / (self.M_total * g)
250
+
251
+ def metacentric_height(self, dtheta=1e-4):
252
+ """Return the metacentric height, GM, for small angles of heel.
253
+
254
+ This is the slope of the righting lever curve at theta = 0, and so
255
+ includes the free-surface effect of any liquid.
256
+
257
+ """
258
+
259
+ return self.righting_lever(dtheta) / dtheta
260
+
261
+ def roll_period(self):
262
+ """Return the period of small, undamped rolls, or None if GM <= 0."""
263
+ GM = self.metacentric_height()
264
+ if GM <= 0:
265
+ return None
266
+ _, Iz = self.torque((0, self.equilibrium_height(0)), 0)
267
+ Iz += self.added_inertia * self.Iz
268
+ return 2 * np.pi * np.sqrt(Iz / (self.M_total * g * GM))
269
+
270
+ def rhs(self, t, y):
271
+ """Return dy/dt for the state vector y, for scipy's solve_ivp."""
272
+ X, Y, theta, U, V, omega = y
273
+ G = np.array((X, Y))
274
+ _, A_sub, _ = self.hydrostatics(G, theta)
275
+ tau, Iz = self.torque(G, theta)
276
+
277
+ F = np.array((0, (self.rho_water * A_sub - self.M_total) * g))
278
+ a = F / (self.M_total * (1 + self.added_mass))
279
+ alpha = tau / (Iz + self.added_inertia * self.Iz)
280
+ a -= self.heave_damping * np.array((U, V))
281
+ alpha -= self.roll_damping * omega
282
+ return np.array((U, V, omega, a[0], a[1], alpha))
@@ -0,0 +1,130 @@
1
+ """Draw and animate ships rolling in the sea."""
2
+
3
+ import numpy as np
4
+ import matplotlib.pyplot as plt
5
+ from matplotlib import animation
6
+
7
+ SEA_COLOUR = '#8888ff'
8
+ HULL_COLOUR = '#666666'
9
+ TANK_COLOUR = 'white'
10
+ CARGO_COLOUR = '#a0d6b4'
11
+ G_COLOUR, B_COLOUR = 'r', 'b'
12
+
13
+
14
+ class ShipView:
15
+ """The artists depicting a Ship on an Axes, updated for each state."""
16
+
17
+ def __init__(self, ax, ship):
18
+ self.ship = ship
19
+ body = ship.body
20
+ self.hull = ax.add_patch(plt.Polygon(body.xy0, fc=HULL_COLOUR,
21
+ ec='k', zorder=1))
22
+ self.tanks = [ax.add_patch(plt.Polygon(tank, fc=TANK_COLOUR,
23
+ ec='k', lw=0.5, zorder=2))
24
+ for tank in ship.tanks]
25
+ self.solids = [ax.add_patch(plt.Polygon(solid, fc=CARGO_COLOUR,
26
+ ec='k', lw=0.5, hatch='///', zorder=2))
27
+ for solid in ship.solid_cargo]
28
+ self.liquids = [ax.add_patch(plt.Polygon(np.zeros((3, 2)),
29
+ fc=CARGO_COLOUR, ec='none', zorder=2))
30
+ for c in body.compartments if c.M > 0]
31
+ # The sea is drawn translucently over the ship, to show the waterline.
32
+ xmin, xmax = ax.get_xlim()
33
+ ymin, _ = ax.get_ylim()
34
+ ax.add_patch(plt.Rectangle((xmin, ymin), xmax - xmin, -ymin,
35
+ fc=SEA_COLOUR, alpha=0.5, zorder=3))
36
+ # The lines of action of the weight, through the centre of mass of
37
+ # the ship and its cargo, and of the buoyancy: the distance between
38
+ # them is the righting lever, GZ.
39
+ self.G_line = ax.axvline(0, c=G_COLOUR, ls='--', lw=1, zorder=4)
40
+ self.B_line = ax.axvline(0, c=B_COLOUR, ls='--', lw=1, zorder=4)
41
+ self.G = ax.add_patch(plt.Circle((0, 0), 0.3, fc=G_COLOUR, zorder=5))
42
+ self.B = ax.add_patch(plt.Circle((0, 0), 0.3, fc=B_COLOUR, zorder=5))
43
+ self.text = ax.text(0.03, 0.95, '', transform=ax.transAxes, va='top')
44
+
45
+ def update(self, state):
46
+ """Update the artists for the state vector state; return them."""
47
+ body = self.ship.body
48
+ X, theta = state[:2], state[2]
49
+ self.hull.set_xy(body.world(X, theta))
50
+ for patch, tank in zip(self.tanks, self.ship.tanks):
51
+ patch.set_xy(body.to_world(tank, X, theta))
52
+ for patch, solid in zip(self.solids, self.ship.solid_cargo):
53
+ patch.set_xy(body.to_world(solid, X, theta))
54
+ for patch, (liquid, _, _) in zip(self.liquids,
55
+ body.liquids(X, theta)):
56
+ patch.set_xy(liquid)
57
+ G = body.centre_of_mass(X, theta)
58
+ _, _, B = body.hydrostatics(X, theta)
59
+ self.G.center, self.B.center = G, B
60
+ self.G_line.set_xdata((G[0], G[0]))
61
+ self.B_line.set_xdata((B[0], B[0]))
62
+ self.text.set_text(f'heel {int(round(np.degrees(theta)))}°')
63
+ return [self.hull, *self.tanks, *self.solids, *self.liquids,
64
+ self.G_line, self.B_line, self.G, self.B, self.text]
65
+
66
+
67
+ def animate(ships, t, states, speed=1):
68
+ """Return a FuncAnimation of the ships side by side, and their heel.
69
+
70
+ states is a list of the arrays of states of each ship at times t; the
71
+ animation plays at speed times real time.
72
+
73
+ """
74
+
75
+ # Fix the plot limits so that every ship fits throughout.
76
+ all_xy = np.vstack([ship.body.world(s[:2], s[2])
77
+ for ship, ship_states in zip(ships, states)
78
+ for s in ship_states])
79
+ (xmin, ymin), (xmax, ymax) = all_xy.min(axis=0) - 2, all_xy.max(axis=0) + 2
80
+
81
+ n = len(ships)
82
+ fig = plt.figure(figsize=(max(4 * n, 6), 7), layout='constrained')
83
+ grid = fig.add_gridspec(2, n, height_ratios=(2, 1))
84
+ views = []
85
+ for i, ship in enumerate(ships):
86
+ ax = fig.add_subplot(grid[0, i])
87
+ ax.set_xlim(xmin, xmax)
88
+ ax.set_ylim(ymin, ymax)
89
+ ax.set_aspect('equal')
90
+ ax.set_title(ship.title)
91
+ ax.set_xlabel('x / m')
92
+ if i == 0:
93
+ ax.set_ylabel('y / m')
94
+ views.append(ShipView(ax, ship))
95
+
96
+ # The angle of heel of each ship against time, on a scale which shows the
97
+ # rolling of the stable ships: any ship which capsizes leaves the plot,
98
+ # unless every ship capsizes.
99
+ ax = fig.add_subplot(grid[1, :])
100
+ heel_max = 10
101
+ any_stable = False
102
+ for ship, ship_states in zip(ships, states):
103
+ heel = np.degrees(ship_states[:, 2])
104
+ label = ship.title
105
+ if np.abs(heel).max() > 90:
106
+ label += ' (capsizes)'
107
+ else:
108
+ any_stable = True
109
+ heel_max = max(heel_max, 1.5 * np.abs(heel).max())
110
+ ax.plot(t, heel, label=label)
111
+ if not any_stable:
112
+ heel_max = 1.1 * max(np.degrees(np.abs(s[:, 2])).max() for s in states)
113
+ ax.axhline(0, c='k', lw=0.5)
114
+ ax.set_xlim(t[0], t[-1])
115
+ ax.set_ylim(-heel_max, heel_max)
116
+ ax.set_xlabel('t / s')
117
+ ax.set_ylabel('heel / °')
118
+ ax.legend(loc='upper right')
119
+ cursor = ax.axvline(0, c='k', lw=1)
120
+
121
+ def update(i):
122
+ cursor.set_xdata((t[i], t[i]))
123
+ artists = [cursor]
124
+ for view, ship_states in zip(views, states):
125
+ artists.extend(view.update(ship_states[i]))
126
+ return artists
127
+
128
+ interval = (t[1] - t[0]) * 1000 / speed
129
+ return animation.FuncAnimation(fig, update, len(t), blit=True,
130
+ interval=interval)
@@ -0,0 +1,97 @@
1
+ """Animate the free-surface effect on a small ship carrying cargo.
2
+
3
+ Three identical coasters carry the same mass of cargo in their cargo spaces:
4
+ as a solid; as a liquid with a free surface across the whole space; and as a
5
+ liquid divided into tanks by longitudinal bulkheads (two, unless --tanks says
6
+ otherwise). Each is heeled slightly (by a gust, say) and released. The
7
+ liquid's free surface reduces the ship's metacentric height, GM: across the
8
+ whole cargo space, it makes GM negative and the ship capsizes. Dividing the
9
+ space into n tanks reduces the effect by a factor of n^2.
10
+
11
+ Run without a filename to show the animation, or pass one (e.g.
12
+ free-surface.mp4) to save it instead. Use --cargo to show only some of the
13
+ ships, e.g. --cargo free, or --cargo solid --cargo divided.
14
+
15
+ The cargo is 2.5 m of water by default. With --density, but not --depth, the
16
+ cargo has the same mass whatever its density, so --density 13.6 compares
17
+ mercury with water: its free-surface effect is 13.6 times as large.
18
+
19
+ """
20
+
21
+ import argparse
22
+ import sys
23
+ from .scenario import Scenario, run
24
+ from .ships import CARGOES, CoasterDesign
25
+
26
+
27
+ def describe(ship_result):
28
+ """Print the ship's small-angle stability and its liquids' sloshing."""
29
+ s = ship_result.summary()
30
+ roll = f"{s['roll_period']:.1f} s" if s['roll_period'] else 'none (unstable)'
31
+ print(f"{ship_result.ship.title}: GM = {s['GM']:.2f} m, "
32
+ f"roll period {roll}")
33
+ for T in s['sloshing_periods']:
34
+ print(f' sloshing period {T:.1f} s')
35
+
36
+
37
+ def main():
38
+ parser = argparse.ArgumentParser(
39
+ prog='free-surface', description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
40
+ parser.add_argument('filename', nargs='?',
41
+ help='save the animation to this file')
42
+ parser.add_argument('--cargo', action='append', choices=CARGOES,
43
+ help='how the cargo is carried: solid, free (liquid) '
44
+ 'or divided (liquid in tanks); repeat it for '
45
+ 'more than one (default: all three)')
46
+ parser.add_argument('--heel', type=float, default=5,
47
+ help='initial angle of heel (degrees, default 5)')
48
+ parser.add_argument('--density', type=float, default=1.0,
49
+ help='density of the cargo (t/m3, default 1.0: '
50
+ 'water; mercury is 13.6)')
51
+ parser.add_argument('--depth', type=float,
52
+ help='depth of cargo in the cargo space (m; default '
53
+ '2.5 m of water, or the same mass of cargo of '
54
+ 'another density)')
55
+ parser.add_argument('--tanks', type=int, default=2,
56
+ help='number of tanks in the divided cargo space '
57
+ '(default 2)')
58
+ parser.add_argument('--duration', type=float, default=60,
59
+ help='duration of the simulation (s, default 60)')
60
+ parser.add_argument('--speed', type=float, default=2,
61
+ help='playback speed relative to real time (default 2)')
62
+ args = parser.parse_args()
63
+
64
+ # Matplotlib is an optional dependency, needed only here.
65
+ try:
66
+ import matplotlib.pyplot as plt
67
+ from .drawing import animate
68
+ except ImportError:
69
+ sys.exit("The animation needs Matplotlib: "
70
+ "pip install 'floating-dynamics[animation]'")
71
+
72
+ # Unless its depth is given, carry the same mass of cargo whatever its
73
+ # density, so that different cargoes can be compared.
74
+ default = CoasterDesign()
75
+ depth = args.depth or (default.cargo_depth * default.cargo_density
76
+ / args.density)
77
+ scenario = Scenario.from_dict({
78
+ 'cargoes': args.cargo or list(CARGOES),
79
+ 'heel': args.heel, 'duration': args.duration,
80
+ 'design': {'cargo_depth': depth, 'cargo_density': args.density,
81
+ 'tanks': args.tanks},
82
+ })
83
+ print(f'Cargo: {args.density:g} t/m3, {depth:.2f} m deep')
84
+ result = run(scenario)
85
+ for ship_result in result.ships:
86
+ describe(ship_result)
87
+
88
+ ani = animate([r.ship for r in result.ships], result.t,
89
+ [r.states for r in result.ships], args.speed)
90
+ if args.filename:
91
+ ani.save(args.filename)
92
+ else:
93
+ plt.show()
94
+
95
+
96
+ if __name__ == '__main__':
97
+ main()
@@ -0,0 +1,119 @@
1
+ """Geometry of two-dimensional polygons.
2
+
3
+ A polygon is an (N,2) NumPy array of its vertex coordinates, (x, y). The
4
+ first vertex is *not* repeated at the end: the edge from the last vertex back
5
+ to the first is implied. Polygons may be concave, but their edges must not
6
+ cross.
7
+
8
+ """
9
+
10
+ import numpy as np
11
+ from scipy.optimize import brentq
12
+
13
+
14
+ def _cross_terms(xy):
15
+ """Return x_i, y_i, x_{i+1}, y_{i+1} and x_i.y_{i+1} - x_{i+1}.y_i."""
16
+ x, y = xy.T
17
+ xn, yn = np.roll(x, -1), np.roll(y, -1)
18
+ return x, y, xn, yn, x * yn - xn * y
19
+
20
+
21
+ def area(xy):
22
+ """Return the signed area of polygon xy (the "shoelace" formula).
23
+
24
+ The area is positive if the vertices are ordered anticlockwise. A
25
+ polygon with fewer than three vertices has zero area.
26
+
27
+ """
28
+
29
+ if len(xy) < 3:
30
+ return 0.0
31
+ *_, c = _cross_terms(xy)
32
+ return np.sum(c) / 2
33
+
34
+
35
+ def make_ccw(xy):
36
+ """Return polygon xy with its vertices ordered anticlockwise."""
37
+ return xy[::-1] if area(xy) < 0 else xy
38
+
39
+
40
+ def centroid(xy):
41
+ """Return the centroid (centre of mass at uniform density) of polygon xy."""
42
+ A = area(xy)
43
+ if A == 0:
44
+ raise ValueError('The centroid of a polygon with zero area is undefined.')
45
+ x, y, xn, yn, c = _cross_terms(xy)
46
+ return np.array((np.sum((x + xn) * c), np.sum((y + yn) * c))) / 6 / A
47
+
48
+
49
+ def second_moment(xy, P=(0, 0)):
50
+ """Return the polar second moment of area of polygon xy about point P.
51
+
52
+ This is the integral of r^2 over the polygon's area, where r is the
53
+ distance from P; multiply by the area density to get the moment of inertia
54
+ about an axis through P perpendicular to the plane. The polygon is assumed
55
+ to be ordered anticlockwise (otherwise the result is negative).
56
+
57
+ """
58
+
59
+ x, y, xn, yn, c = _cross_terms(xy - np.asarray(P))
60
+ Ix = np.sum(c * (y**2 + y * yn + yn**2)) / 12
61
+ Iy = np.sum(c * (x**2 + x * xn + xn**2)) / 12
62
+ # Perpendicular axis theorem.
63
+ return Ix + Iy
64
+
65
+
66
+ def rotation_matrix(theta):
67
+ """Return the matrix for an anticlockwise rotation by theta radians."""
68
+ s, c = np.sin(theta), np.cos(theta)
69
+ return np.array(((c, -s), (s, c)))
70
+
71
+
72
+ def transform(xy, theta, X=(0, 0)):
73
+ """Rotate polygon xy by theta about the origin, then translate it by X."""
74
+ return xy @ rotation_matrix(theta).T + np.asarray(X)
75
+
76
+
77
+ def clip_below(xy, level=0.0):
78
+ """Return the part of polygon xy which lies at or below y = level.
79
+
80
+ This is the Sutherland-Hodgman algorithm for a single half-plane. If xy
81
+ is concave and crosses the line more than twice, the pieces below it are
82
+ returned as one polygon joined by zero-width "bridges" along y = level:
83
+ these do not change its area or centroid. If no part of xy is below the
84
+ line, an empty (0,2) array is returned.
85
+
86
+ """
87
+
88
+ out = []
89
+ for P, Q in zip(xy, np.roll(xy, -1, axis=0)):
90
+ P_below, Q_below = P[1] <= level, Q[1] <= level
91
+ if P_below:
92
+ out.append(P)
93
+ if P_below != Q_below:
94
+ # This edge crosses the line: add the crossing point.
95
+ out.append(P + (level - P[1]) / (Q[1] - P[1]) * (Q - P))
96
+ return np.array(out).reshape(-1, 2)
97
+
98
+
99
+ def fill(xy, A):
100
+ """Return the polygon of a liquid of area A settled in container xy.
101
+
102
+ The liquid's surface is level (y = constant), and the liquid is assumed to
103
+ be connected: if xy is concave, all of its pockets fill to the same level.
104
+ If A is no less than the area of xy, the liquid fills all of it.
105
+
106
+ """
107
+
108
+ if A <= 0:
109
+ return np.empty((0, 2))
110
+ if A >= area(xy):
111
+ return xy
112
+ y = xy[:, 1]
113
+ level = brentq(lambda h: area(clip_below(xy, h)) - A, y.min(), y.max())
114
+ return clip_below(xy, level)
115
+
116
+
117
+ def cross2(r, F):
118
+ """Return the z-component of the cross product of 2D vectors r and F."""
119
+ return r[0] * F[1] - r[1] * F[0]
@@ -0,0 +1,170 @@
1
+ """Set up and run free-surface simulations, returning their results as data.
2
+
3
+ This is the interface for programs (such as a web page running Pyodide) that
4
+ draw the results themselves: Scenario.from_dict takes the parameters as a
5
+ dict, and Result.to_dict returns everything needed to draw the ships, frame
6
+ by frame, as plain lists, dicts and floats (which convert directly to JSON or
7
+ to JavaScript objects).
8
+
9
+ Polygons are lists of [x, y] vertices (m). Each ship's outline, tanks and
10
+ solid cargo are given once, in the ship's own frame with its centre of mass G
11
+ at the origin; to draw frame i, rotate them by theta[i] (rad, anticlockwise)
12
+ and translate them by (X[i], Y[i]). The liquids, centre of mass of the ship
13
+ and cargo and centre of buoyancy are given in world coordinates for each
14
+ frame. The sea surface is y = 0.
15
+
16
+ """
17
+
18
+ from dataclasses import dataclass, field, fields, asdict
19
+ import numpy as np
20
+ from .ships import coaster, CoasterDesign, CARGOES
21
+ from .simulation import simulate
22
+
23
+
24
+ @dataclass
25
+ class Scenario:
26
+ """The parameters of a simulation of one or more loaded coasters."""
27
+
28
+ design: CoasterDesign = field(default_factory=CoasterDesign)
29
+ # Which ways of loading the cargo to simulate (keys of CARGOES).
30
+ cargoes: tuple = tuple(CARGOES)
31
+ # The initial angle of heel (degrees), the duration of the simulation
32
+ # and the time between frames (s).
33
+ heel: float = 5
34
+ duration: float = 60
35
+ dt: float = 0.1
36
+
37
+ @classmethod
38
+ def from_dict(cls, d):
39
+ """Return a Scenario from a dict, e.g. {'heel': 10, 'design': {...}}.
40
+
41
+ Missing parameters take their default values; unknown ones raise
42
+ ValueError.
43
+
44
+ """
45
+
46
+ d = dict(d)
47
+ design = d.pop('design', {})
48
+ _check_keys(cls, d, exclude=('design',))
49
+ _check_keys(CoasterDesign, design)
50
+ if 'cargoes' in d:
51
+ d['cargoes'] = tuple(d['cargoes'])
52
+ return cls(design=CoasterDesign(**design), **d)
53
+
54
+ def to_dict(self):
55
+ """Return the scenario's parameters as a dict."""
56
+ d = asdict(self)
57
+ d['cargoes'] = list(self.cargoes)
58
+ return d
59
+
60
+ def validate(self):
61
+ """Raise ValueError if the parameters are not sensible."""
62
+ self.design.validate()
63
+ for cargo in self.cargoes:
64
+ if cargo not in CARGOES:
65
+ raise ValueError(f'Unknown cargo: {cargo}')
66
+ if not -90 < self.heel < 90:
67
+ raise ValueError('The initial heel must be between -90 and 90 '
68
+ 'degrees.')
69
+ if self.dt <= 0 or self.duration < self.dt:
70
+ raise ValueError('The duration must be at least one time step.')
71
+
72
+
73
+ def _check_keys(cls, d, exclude=()):
74
+ """Raise ValueError if dict d has keys which are not fields of cls."""
75
+ names = {f.name for f in fields(cls)} - set(exclude)
76
+ unknown = set(d) - names
77
+ if unknown:
78
+ raise ValueError(f'Unknown parameters for {cls.__name__}: '
79
+ + ', '.join(sorted(unknown)))
80
+
81
+
82
+ def _polygon(xy, decimals=3):
83
+ return np.round(xy, decimals).tolist()
84
+
85
+
86
+ @dataclass
87
+ class ShipResult:
88
+ """The simulated motion of one loaded ship."""
89
+
90
+ cargo: str
91
+ ship: object
92
+ t: np.ndarray
93
+ states: np.ndarray
94
+
95
+ def summary(self):
96
+ """Return the ship's small-angle stability, as a dict."""
97
+ body = self.ship.body
98
+ roll_period = body.roll_period()
99
+ return {
100
+ 'GM': float(body.metacentric_height()),
101
+ 'roll_period': float(roll_period) if roll_period else None,
102
+ 'sloshing_periods': [float(c.sloshing_period())
103
+ for c in body.compartments],
104
+ 'capsizes': bool(np.abs(self.states[:, 2]).max() > np.pi / 2),
105
+ }
106
+
107
+ def to_dict(self):
108
+ """Return the ship's geometry and motion, frame by frame."""
109
+ body = self.ship.body
110
+ X, Y, theta = self.states[:, :3].T
111
+ G, B, liquids = [], [], []
112
+ for x, y, th in zip(X, Y, theta):
113
+ G.append(body.centre_of_mass((x, y), th))
114
+ B.append(body.hydrostatics((x, y), th)[2])
115
+ liquids.append([_polygon(liquid)
116
+ for liquid, _, _ in body.liquids((x, y), th)])
117
+ return {
118
+ 'cargo': self.cargo,
119
+ 'title': self.ship.title,
120
+ **self.summary(),
121
+ 'outline': _polygon(body.xy0),
122
+ 'tanks': [_polygon(tank - body.G0) for tank in self.ship.tanks],
123
+ 'solids': [_polygon(solid - body.G0)
124
+ for solid in self.ship.solid_cargo],
125
+ 'X': np.round(X, 4).tolist(),
126
+ 'Y': np.round(Y, 4).tolist(),
127
+ 'theta': np.round(theta, 5).tolist(),
128
+ 'G': _polygon(G),
129
+ 'B': _polygon(B),
130
+ 'liquids': liquids,
131
+ }
132
+
133
+
134
+ @dataclass
135
+ class Result:
136
+ """The results of running a Scenario."""
137
+
138
+ scenario: Scenario
139
+ ships: list
140
+
141
+ @property
142
+ def t(self):
143
+ return self.ships[0].t
144
+
145
+ def to_dict(self):
146
+ """Return the scenario and the results for every ship as a dict."""
147
+ return {'scenario': self.scenario.to_dict(),
148
+ 't': np.round(self.t, 4).tolist(),
149
+ 'ships': [ship.to_dict() for ship in self.ships]}
150
+
151
+
152
+ def run(scenario=None, progress=None):
153
+ """Run the simulations for scenario (by default, Scenario()).
154
+
155
+ progress, if given, is called as progress(i, n) after each of the n ships
156
+ has been simulated. Returns a Result.
157
+
158
+ """
159
+
160
+ scenario = scenario or Scenario()
161
+ scenario.validate()
162
+ results = []
163
+ for i, cargo in enumerate(scenario.cargoes):
164
+ ship = coaster(cargo, scenario.design)
165
+ t, states = simulate(ship.body, np.radians(scenario.heel),
166
+ scenario.duration, scenario.dt)
167
+ results.append(ShipResult(cargo, ship, t, states))
168
+ if progress:
169
+ progress(i + 1, len(scenario.cargoes))
170
+ return Result(scenario, results)
@@ -0,0 +1,164 @@
1
+ """Ship cross-sections and the ways they can be loaded.
2
+
3
+ A ship's midship section is described in coordinates with the keel at y = 0
4
+ and the centreline at x = 0. Its lightship (the empty ship: steel,
5
+ machinery, outfit) is described as naval architects describe it, by its mass,
6
+ the height of its centre of mass above the keel (KG) and its radius of
7
+ gyration for roll, rather than by the shapes of its parts.
8
+
9
+ """
10
+
11
+ from dataclasses import dataclass, field
12
+ import numpy as np
13
+ from .body import FloatingBody, Compartment, mass_properties, combine
14
+
15
+
16
+ def midship_section(beam, depth, bilge_radius, n=8):
17
+ """Return the outline of a box-shaped section with rounded bilges.
18
+
19
+ The bilges are approximated by arcs of n straight segments.
20
+
21
+ """
22
+
23
+ phi = np.linspace(-np.pi / 2, 0, n + 1)
24
+ r = bilge_radius
25
+ starboard = np.column_stack((beam / 2 - r + r * np.cos(phi),
26
+ r + r * np.sin(phi)))
27
+ port = starboard[::-1] * (-1, 1)
28
+ deck = [(beam / 2, depth), (-beam / 2, depth)]
29
+ return np.vstack((starboard, deck, port))
30
+
31
+
32
+ def rectangle(x0, y0, x1, y1):
33
+ """Return the rectangle with corners (x0, y0) and (x1, y1)."""
34
+ return np.array(((x0, y0), (x1, y0), (x1, y1), (x0, y1)), dtype=float)
35
+
36
+
37
+ @dataclass
38
+ class Ship:
39
+ """A loaded ship: its FloatingBody and the shapes needed to draw it."""
40
+
41
+ title: str
42
+ body: FloatingBody
43
+ # Polygons (in the section's coordinates) of all the cargo spaces, and
44
+ # of any solid cargo in them.
45
+ tanks: list = field(default_factory=list)
46
+ solid_cargo: list = field(default_factory=list)
47
+
48
+
49
+ @dataclass
50
+ class CoasterDesign:
51
+ """The design and loading of a small coaster.
52
+
53
+ Lengths are in m, densities in t.m-3 and masses in t per metre of the
54
+ ship's length. The defaults give a GM of 1.0 m with solid cargo.
55
+
56
+ """
57
+
58
+ # The midship section: its beam, depth to the main deck and bilge radius.
59
+ beam: float = 16
60
+ depth: float = 9
61
+ bilge_radius: float = 2
62
+ # The cargo space, between the double bottom, wing tanks and deck:
63
+ # its half-width and the heights of its floor and ceiling above the keel.
64
+ hold_half_width: float = 6
65
+ hold_bottom: float = 1.5
66
+ hold_top: float = 8.5
67
+ # The lightship's mass, KG and radius of gyration for roll (if None,
68
+ # 0.4 times the beam, a typical value).
69
+ lightship_mass: float = 66
70
+ lightship_kg: float = 7.0
71
+ roll_gyradius: float | None = None
72
+ # The cargo's depth in the cargo space when upright, and its density.
73
+ cargo_depth: float = 2.5
74
+ cargo_density: float = 1.0
75
+ # The number of tanks, separated by longitudinal bulkheads, into which the
76
+ # cargo space is divided when the cargo is 'divided'.
77
+ tanks: int = 2
78
+ # Hydrodynamic coefficients: the added mass for heave (as a fraction of
79
+ # the ship's mass) and the added moment of inertia for roll (as a fraction
80
+ # of the lightship's), and damping rates (s-1), for damping ratios of
81
+ # about 0.3 in heave and 0.07 in roll.
82
+ added_mass: float = 1.0
83
+ added_inertia: float = 0.2
84
+ heave_damping: float = 0.5
85
+ roll_damping: float = 0.07
86
+
87
+ def validate(self):
88
+ """Raise ValueError if the design is not physically sensible."""
89
+ positive = ('beam', 'depth', 'lightship_mass', 'cargo_density',
90
+ 'hold_half_width')
91
+ for name in positive:
92
+ if getattr(self, name) <= 0:
93
+ raise ValueError(f'{name} must be positive.')
94
+ if not 0 <= self.bilge_radius <= min(self.beam / 2, self.depth):
95
+ raise ValueError('The bilge radius does not fit the section.')
96
+ if self.hold_half_width > self.beam / 2 - self.bilge_radius:
97
+ raise ValueError('The cargo space is wider than the flat of '
98
+ 'the hull.')
99
+ if not 0 <= self.hold_bottom < self.hold_top <= self.depth:
100
+ raise ValueError('The cargo space must lie between the keel '
101
+ 'and the main deck.')
102
+ if not 0 < self.cargo_depth <= self.hold_top - self.hold_bottom:
103
+ raise ValueError('The cargo does not fit in the cargo space.')
104
+ if self.tanks < 1 or self.tanks != int(self.tanks):
105
+ raise ValueError('The number of tanks must be a positive '
106
+ 'integer.')
107
+ if self.roll_gyradius is not None and self.roll_gyradius <= 0:
108
+ raise ValueError('roll_gyradius must be positive.')
109
+
110
+
111
+ CARGOES = {
112
+ 'solid': 'Solid cargo',
113
+ 'free': 'Liquid cargo',
114
+ 'divided': 'Liquid cargo, divided tank',
115
+ }
116
+
117
+
118
+ def coaster(cargo='free', design=None):
119
+ """Return the coaster carrying cargo of the same mass in different ways.
120
+
121
+ design is a CoasterDesign (by default, CoasterDesign()). The cargo fills
122
+ its cargo space to design.cargo_depth, when upright, and is either:
123
+ 'solid': solid (a liquid frozen solid, say), so it cannot move;
124
+ 'free': a liquid with a free surface across the whole cargo space;
125
+ 'divided': a liquid in a cargo space divided into design.tanks
126
+ tanks by longitudinal watertight bulkheads.
127
+
128
+ """
129
+
130
+ d = design or CoasterDesign()
131
+ d.validate()
132
+ outline = midship_section(d.beam, d.depth, d.bilge_radius)
133
+ k = d.roll_gyradius or 0.4 * d.beam
134
+ lightship = (d.lightship_mass, (0, d.lightship_kg),
135
+ d.lightship_mass * k**2)
136
+ hydrodynamics = {'added_mass': d.added_mass,
137
+ 'added_inertia': d.added_inertia,
138
+ 'heave_damping': d.heave_damping,
139
+ 'roll_damping': d.roll_damping}
140
+ x0, x1 = -d.hold_half_width, d.hold_half_width
141
+ y0, y1 = d.hold_bottom, d.hold_top
142
+ space = rectangle(x0, y0, x1, y1)
143
+ A_cargo = (x1 - x0) * d.cargo_depth
144
+
145
+ if cargo == 'solid':
146
+ solid = rectangle(x0, y0, x1, y0 + d.cargo_depth)
147
+ M, G, Iz = combine(lightship, mass_properties(solid, d.cargo_density))
148
+ body = FloatingBody(outline, M, G, Iz, **hydrodynamics)
149
+ return Ship(CARGOES[cargo], body, [space], [solid])
150
+ if cargo == 'free':
151
+ tanks = [space]
152
+ elif cargo == 'divided':
153
+ x = np.linspace(x0, x1, d.tanks + 1)
154
+ tanks = [rectangle(xa, y0, xb, y1) for xa, xb in zip(x[:-1], x[1:])]
155
+ else:
156
+ raise ValueError(f'Unknown cargo: {cargo}')
157
+ compartments = [Compartment(tank, A_cargo / len(tanks), d.cargo_density)
158
+ for tank in tanks]
159
+ body = FloatingBody(outline, *lightship, compartments=compartments,
160
+ **hydrodynamics)
161
+ title = CARGOES[cargo]
162
+ if cargo == 'divided' and d.tanks != 2:
163
+ title = f'Liquid cargo, {d.tanks} tanks'
164
+ return Ship(title, body, tanks)
@@ -0,0 +1,22 @@
1
+ """Integrate the motion of a floating body."""
2
+
3
+ import numpy as np
4
+ from scipy.integrate import solve_ivp
5
+
6
+
7
+ def simulate(body, theta0, t_max, dt):
8
+ """Integrate the motion of body, released from rest at angle theta0.
9
+
10
+ The body starts floating at its equilibrium height for theta0. Its state
11
+ is returned at intervals of dt up to time t_max (s), as the arrays t and
12
+ states, whose rows are the state vectors (X, Y, theta, U, V, omega).
13
+
14
+ """
15
+
16
+ y0 = (0, body.equilibrium_height(theta0), theta0, 0, 0, 0)
17
+ t = np.arange(0, t_max + dt / 2, dt)
18
+ sol = solve_ivp(body.rhs, (0, t_max), y0, t_eval=t, max_step=dt,
19
+ rtol=1e-6, atol=1e-8)
20
+ if not sol.success:
21
+ raise RuntimeError(sol.message)
22
+ return sol.t, sol.y.T
@@ -0,0 +1,167 @@
1
+ Metadata-Version: 2.4
2
+ Name: floating-dynamics
3
+ Version: 0.1.0
4
+ Summary: Two-dimensional dynamics of floating bodies, including the free-surface effect of liquid cargo.
5
+ Author: Christian Hill
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/xnx/floating-dynamics
8
+ Project-URL: Issues, https://github.com/xnx/floating-dynamics/issues
9
+ Keywords: ship stability,free-surface effect,metacentric height,buoyancy,simulation
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Education
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Physics
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: numpy
19
+ Requires-Dist: scipy
20
+ Provides-Extra: animation
21
+ Requires-Dist: matplotlib; extra == "animation"
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Dynamic: license-file
25
+
26
+ # floating-dynamics
27
+
28
+ Two-dimensional dynamics of ships floating in the sea (cross-sections, per
29
+ metre of length), including the *free-surface effect* of liquid cargo.
30
+
31
+ The demonstration compares three identical coasters carrying the same mass of
32
+ cargo: as a solid, as a liquid free to move across the whole hold, and as a
33
+ liquid divided into tanks. Each is heeled slightly and released. The liquid's
34
+ free surface reduces the ship's metacentric height, GM, so the ship with the
35
+ undivided hold capsizes, while dividing the hold into n tanks reduces the
36
+ effect by a factor of n².
37
+
38
+ With the default cargo, 2.5 m of water (30 t per metre of the ship's
39
+ length), heeled by 5°:
40
+
41
+ | Cargo | GM | Roll period | Outcome |
42
+ |--------------------|---------|-------------|--------------------|
43
+ | Solid | 1.00 m | 13.2 s | rolls, and settles |
44
+ | Liquid | −0.50 m | – | capsizes in ~12 s |
45
+ | Liquid, two tanks | 0.63 m | 16.5 s | rolls, and settles |
46
+
47
+ ## Trying it out
48
+
49
+ Install it with the animation extra (which adds Matplotlib), and run the
50
+ demonstration:
51
+
52
+ pip install 'floating-dynamics[animation]'
53
+ free-surface
54
+
55
+ or, with [uv](https://docs.astral.sh/uv/), without installing anything:
56
+
57
+ uvx --from 'floating-dynamics[animation]' free-surface
58
+
59
+ Options choose which ships to show, and set the initial heel, the cargo's
60
+ density and depth and the number of tanks, or save the animation instead of
61
+ showing it:
62
+
63
+ free-surface --cargo free # one ship
64
+ free-surface --cargo solid --cargo divided # two of them
65
+ free-surface --heel 10 --depth 2 --tanks 3
66
+ free-surface --density 13.6 # mercury
67
+ free-surface free-surface.mp4 # save the animation
68
+ free-surface --help
69
+
70
+ `python -m floating_dynamics` does the same as `free-surface`.
71
+
72
+ ### Denser cargoes
73
+
74
+ `--density` sets the cargo's density in t/m³. Unless `--depth` is also
75
+ given, the cargo's mass stays the same (2.5 m of water would weigh 13.6 times
76
+ as much as mercury, and sink the ship), so its depth is reduced instead. The
77
+ free-surface effect depends on the width of the liquid's surface but not its
78
+ depth, so for the same mass of cargo it grows in proportion to the density:
79
+
80
+ | Cargo (30 t/m) | Water: GM | Outcome | Mercury: GM | Outcome |
81
+ |--------------------|-----------|-------------------|-------------|-------------------|
82
+ | Solid | 1.00 m | stable | 1.37 m | stable |
83
+ | Liquid | −0.50 m | capsizes in ~12 s | −19.0 m | capsizes in ~4.5 s |
84
+ | Liquid, two tanks | 0.63 m | stable | −3.7 m | lolls at about 24° |
85
+
86
+ The mercury in two tanks makes GM negative, but the ship does not capsize: it
87
+ heels until its wider waterline makes up the difference, and settles at an
88
+ *angle of loll*. A layer of mercury only 0.18 m deep sloshes slowly, though,
89
+ so these timings are less reliable than water's (see Assumptions).
90
+
91
+ ## Using it from Python
92
+
93
+ `floating_dynamics.scenario` sets the parameters, runs the simulations and
94
+ returns the results as plain data (lists, dicts and floats), for programs
95
+ which draw the ships themselves, such as a web page running
96
+ [Pyodide](https://pyodide.org):
97
+
98
+ ```python
99
+ from floating_dynamics.scenario import Scenario, run
100
+
101
+ scenario = Scenario.from_dict({'heel': 10, 'cargoes': ['free', 'divided'],
102
+ 'design': {'cargo_depth': 2, 'tanks': 3}})
103
+ result = run(scenario)
104
+ for ship in result.ships:
105
+ print(ship.ship.title, ship.summary())
106
+ data = result.to_dict() # JSON-serialisable
107
+ ```
108
+
109
+ The main parameters, with their defaults, are:
110
+
111
+ | Parameter | Default | Meaning |
112
+ |------------------------|-------------------------------|-----------------------------------------------|
113
+ | `cargoes` | `['solid', 'free', 'divided']`| which ships to simulate |
114
+ | `heel` | 5 | initial heel (degrees) |
115
+ | `duration`, `dt` | 60, 0.1 | length of the simulation, time per frame (s) |
116
+ | `design.cargo_depth` | 2.5 | depth of cargo when upright (m) |
117
+ | `design.cargo_density` | 1.0 | density of cargo (t/m³) |
118
+ | `design.tanks` | 2 | number of tanks for the `'divided'` cargo |
119
+
120
+ Unlike `free-surface --density`, setting `cargo_density` here does not change
121
+ the depth: set `cargo_depth` too to keep the mass the same. The ship's
122
+ dimensions, lightship mass and hydrodynamic coefficients are also fields of
123
+ `CoasterDesign` (in `floating_dynamics.ships`), with comments. Unknown or
124
+ unphysical parameters raise `ValueError`. The format of the results is
125
+ described in `scenario.py`. Only NumPy and SciPy are needed for this.
126
+
127
+ ## Modules
128
+
129
+ - `geometry`: polygon area, centroid, second moment, clipping and filling.
130
+ - `body`: `FloatingBody` (hydrostatics, equations of motion, righting lever,
131
+ metacentric height) and `Compartment` (a tank of liquid).
132
+ - `ships`: a coaster's midship section and its three ways of carrying cargo.
133
+ - `simulation`: integrating the motion with SciPy's `solve_ivp`.
134
+ - `scenario`: the parameters and results, as plain data.
135
+ - `drawing`: animating ships side by side, with their heel against time.
136
+ - `free_surface`: the command-line demonstration.
137
+
138
+ ## Assumptions
139
+
140
+ - The motion is two-dimensional: a cross-section of a long ship, rolling and
141
+ heaving but not pitching.
142
+ - The liquid's surface stays level (quasi-static). This is reasonable while
143
+ the liquid sloshes much faster than the ship rolls; the demonstration
144
+ prints both periods. Shallow layers slosh slowly (the 0.18 m of mercury
145
+ above takes 18 s across the whole hold), and then the real liquid would lag
146
+ behind the roll, so the motion would develop more slowly. Whether the ship
147
+ is stable depends only on GM, which is unaffected.
148
+ - The hull is watertight up to its main deck: there is no flooding, so a
149
+ capsized ship floats upside down.
150
+ - The surrounding water is represented by added mass and linear damping.
151
+
152
+ ## Development
153
+
154
+ git clone https://github.com/xnx/floating-dynamics
155
+ cd floating-dynamics
156
+ pip install -e '.[animation,test]'
157
+ pytest
158
+
159
+ The editable install (`-e`) uses the source files directly, so changes take
160
+ effect without reinstalling. To run the demonstration without installing at
161
+ all, from the project folder:
162
+
163
+ PYTHONPATH=src python -m floating_dynamics
164
+
165
+ ## Licence
166
+
167
+ MIT: see `LICENSE`.
@@ -0,0 +1,15 @@
1
+ floating_dynamics/__init__.py,sha256=bKqlRxPA5Nu6GmlmgcT5xiSmtsj77XP3bvq05yycI6Y,125
2
+ floating_dynamics/__main__.py,sha256=9ymqIlAO3w0bsd6IbDK8oUpZ8gLsCBNSBWin5Jo1948,115
3
+ floating_dynamics/body.py,sha256=b0KoJ4FLDsgEdQ6-laCdug4iAfDcLlUHGERgJlNVvlo,10926
4
+ floating_dynamics/drawing.py,sha256=XaOTvvsJUrMfNmQB_SyJkkPgfZrG1hO3r3oG2CWxxk8,5433
5
+ floating_dynamics/free_surface.py,sha256=FTdoQU0U_X2qeUku9paQ_n94hEr7wb7y_mK-L-2HNis,4350
6
+ floating_dynamics/geometry.py,sha256=Y0W2YX2wGpyJ5c95H_ynq-eBLrW-J6TwQnUlu_sSCXM,3812
7
+ floating_dynamics/scenario.py,sha256=r8tAXiBcRygr8TGhXj3GQ6JOHLS6a2HgtkcJowL6-64,5938
8
+ floating_dynamics/ships.py,sha256=CbQF_3C8a0PaKdUo1LLqSBT4NOBpevt1ADC4nhkzFBg,6672
9
+ floating_dynamics/simulation.py,sha256=JrUsjrflqKYVI0fZ0b7R2OQqiBUGbqslf68q_0JMLjU,762
10
+ floating_dynamics-0.1.0.dist-info/licenses/LICENSE,sha256=fwbJgWO5bKz_xUdjqMYqZpZFe_S4cz1gS9j8chkHd_o,1071
11
+ floating_dynamics-0.1.0.dist-info/METADATA,sha256=BfRxS8Myhen0NlfdhV30C5Sk_qxX-nw5Q2mx4Yb9SjI,7657
12
+ floating_dynamics-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
13
+ floating_dynamics-0.1.0.dist-info/entry_points.txt,sha256=cQaTG-MWizDUAyqPOPlYsy8s8yVWIO3VaAKqTEE_IUA,69
14
+ floating_dynamics-0.1.0.dist-info/top_level.txt,sha256=pWFiphkvjolW_yfF6DGO9sU9trEJuqOSjIsyrRPA1Yg,18
15
+ floating_dynamics-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ free-surface = floating_dynamics.free_surface:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Christian Hill
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ floating_dynamics