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.
- floating_dynamics/__init__.py +4 -0
- floating_dynamics/__main__.py +5 -0
- floating_dynamics/body.py +282 -0
- floating_dynamics/drawing.py +130 -0
- floating_dynamics/free_surface.py +97 -0
- floating_dynamics/geometry.py +119 -0
- floating_dynamics/scenario.py +170 -0
- floating_dynamics/ships.py +164 -0
- floating_dynamics/simulation.py +22 -0
- floating_dynamics-0.1.0.dist-info/METADATA +167 -0
- floating_dynamics-0.1.0.dist-info/RECORD +15 -0
- floating_dynamics-0.1.0.dist-info/WHEEL +5 -0
- floating_dynamics-0.1.0.dist-info/entry_points.txt +2 -0
- floating_dynamics-0.1.0.dist-info/licenses/LICENSE +21 -0
- floating_dynamics-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|