shoeboxpy 0.0.1__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.
- shoeboxpy/__init__.py +3 -0
- shoeboxpy/__version__.py +34 -0
- shoeboxpy/animate.py +233 -0
- shoeboxpy/model3dof.py +247 -0
- shoeboxpy/model6dof.py +458 -0
- shoeboxpy/utils.py +21 -0
- shoeboxpy-0.0.1.dist-info/METADATA +99 -0
- shoeboxpy-0.0.1.dist-info/RECORD +11 -0
- shoeboxpy-0.0.1.dist-info/WHEEL +5 -0
- shoeboxpy-0.0.1.dist-info/licenses/LICENSE +287 -0
- shoeboxpy-0.0.1.dist-info/top_level.txt +1 -0
shoeboxpy/__init__.py
ADDED
shoeboxpy/__version__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.0.1'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 1)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
shoeboxpy/animate.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Animation utilities.
|
|
2
|
+
|
|
3
|
+
This module contains a (now optimized) routine for animating a vessel's
|
|
4
|
+
6-DoF (or 3-DoF position only) history. The original implementation recreated
|
|
5
|
+
matplotlib artists and performed Python list -> ndarray conversions every
|
|
6
|
+
frame, which caused quadratic time growth and excessive CPU load for small
|
|
7
|
+
``dt`` (high frame counts / high FPS).
|
|
8
|
+
|
|
9
|
+
Main performance improvements:
|
|
10
|
+
* Pre-allocate trace arrays instead of appending + copying each frame.
|
|
11
|
+
* Keep a single ``Poly3DCollection`` instance and update its vertices in-place
|
|
12
|
+
(``set_verts``) instead of removing/adding each frame.
|
|
13
|
+
* Optional frame decimation via ``max_fps`` so very small ``dt`` values do not
|
|
14
|
+
request more frames than a typical display can show.
|
|
15
|
+
* Optional static axes (``follow=False``) to avoid per-frame axis limit updates.
|
|
16
|
+
* Avoid ``scipy`` ``Rotation`` object construction per frame by using a tiny
|
|
17
|
+
local Euler->matrix implementation (keeps dependency optional at runtime).
|
|
18
|
+
* Optional ``show`` flag so tests / scripts can build an animation without
|
|
19
|
+
blocking on GUI.
|
|
20
|
+
|
|
21
|
+
Note: 3D blitting is still unreliable across backends, so ``blit=False``.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
import matplotlib.pyplot as plt
|
|
28
|
+
from matplotlib import animation
|
|
29
|
+
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
|
30
|
+
from typing import Tuple
|
|
31
|
+
|
|
32
|
+
__all__ = ["animate_history"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _euler_xyz_matrix(phi: float, theta: float, psi: float) -> np.ndarray:
|
|
36
|
+
"""Return rotation matrix for XYZ (roll, pitch, yaw) intrinsic sequence.
|
|
37
|
+
|
|
38
|
+
Equivalent to Rz(psi) * Ry(theta) * Rx(phi) when using right-handed coords.
|
|
39
|
+
"""
|
|
40
|
+
cphi, sphi = np.cos(phi), np.sin(phi)
|
|
41
|
+
ctheta, stheta = np.cos(theta), np.sin(theta)
|
|
42
|
+
cpsi, spsi = np.cos(psi), np.sin(psi)
|
|
43
|
+
|
|
44
|
+
# Rx
|
|
45
|
+
R_x = np.array([[1, 0, 0], [0, cphi, -sphi], [0, sphi, cphi]])
|
|
46
|
+
# Ry
|
|
47
|
+
R_y = np.array([[ctheta, 0, stheta], [0, 1, 0], [-stheta, 0, ctheta]])
|
|
48
|
+
# Rz
|
|
49
|
+
R_z = np.array([[cpsi, -spsi, 0], [spsi, cpsi, 0], [0, 0, 1]])
|
|
50
|
+
return R_z @ R_y @ R_x
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def animate_history(
|
|
54
|
+
pos_history: np.ndarray,
|
|
55
|
+
dt: float,
|
|
56
|
+
L: float = 1.0,
|
|
57
|
+
B: float = 0.5,
|
|
58
|
+
T: float = 0.3,
|
|
59
|
+
*,
|
|
60
|
+
follow: bool = True,
|
|
61
|
+
max_fps: float = 60.0,
|
|
62
|
+
show: bool = True,
|
|
63
|
+
static_margin_factor: float = 2.0,
|
|
64
|
+
) -> animation.FuncAnimation:
|
|
65
|
+
"""Animate a 3D vessel trajectory (position + optional Euler angles).
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
pos_history : (N,3) or (N,6) ndarray
|
|
70
|
+
Columns 0:3 are x,y,z. If 6 columns, 3:6 are Euler angles (phi,theta,psi).
|
|
71
|
+
dt : float
|
|
72
|
+
Simulation time step between successive ``pos_history`` rows (seconds).
|
|
73
|
+
L, B, T : float
|
|
74
|
+
Vessel dimensions (length, beam/width, height/thickness).
|
|
75
|
+
follow : bool, default True
|
|
76
|
+
If True, axes move to keep vessel centered. If False, a static bounding
|
|
77
|
+
box that encloses the whole trajectory is used (faster for large N).
|
|
78
|
+
max_fps : float, default 60
|
|
79
|
+
Cap the displayed frames per second. If 1/dt > max_fps frames are
|
|
80
|
+
requested, frames are decimated (skipping) to not exceed this.
|
|
81
|
+
show : bool, default True
|
|
82
|
+
Call ``plt.show()`` at the end. Set False for scripts/tests.
|
|
83
|
+
static_margin_factor : float, default 2.0
|
|
84
|
+
When ``follow=False`` the static axis cube extends this multiple of the
|
|
85
|
+
vessel length beyond the min/max trajectory extents.
|
|
86
|
+
|
|
87
|
+
Returns
|
|
88
|
+
-------
|
|
89
|
+
matplotlib.animation.FuncAnimation
|
|
90
|
+
The created animation object (can be saved with ``anim.save``).
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
if pos_history.ndim != 2 or pos_history.shape[1] not in (3, 6):
|
|
94
|
+
raise ValueError("pos_history must have shape (N,3) or (N,6)")
|
|
95
|
+
if dt <= 0:
|
|
96
|
+
raise ValueError("dt must be positive")
|
|
97
|
+
|
|
98
|
+
n_total = pos_history.shape[0]
|
|
99
|
+
|
|
100
|
+
# Frame decimation factor if dt smaller than display refresh interval target.
|
|
101
|
+
desired_interval_ms = max(1000.0 / max_fps, 1.0) # at least 1 ms
|
|
102
|
+
frame_interval_ms = dt * 1000.0
|
|
103
|
+
skip = int(np.ceil(desired_interval_ms / frame_interval_ms)) if frame_interval_ms < desired_interval_ms else 1
|
|
104
|
+
indices = np.arange(0, n_total, skip, dtype=int)
|
|
105
|
+
n_frames = len(indices)
|
|
106
|
+
|
|
107
|
+
# Split positions & angles (angles may be zeros if not provided).
|
|
108
|
+
pos = pos_history[:, :3]
|
|
109
|
+
if pos_history.shape[1] == 6:
|
|
110
|
+
angles = pos_history[:, 3:6]
|
|
111
|
+
else:
|
|
112
|
+
angles = np.zeros_like(pos)
|
|
113
|
+
|
|
114
|
+
# Precompute static axis limits if follow disabled.
|
|
115
|
+
margin_geom = L * static_margin_factor
|
|
116
|
+
if not follow:
|
|
117
|
+
mins = pos.min(axis=0) - margin_geom
|
|
118
|
+
maxs = pos.max(axis=0) + margin_geom
|
|
119
|
+
|
|
120
|
+
# Base vertices of vessel centered at origin.
|
|
121
|
+
base_vertices = np.array(
|
|
122
|
+
[
|
|
123
|
+
[-L / 2, -B / 2, -T / 2],
|
|
124
|
+
[L / 2, -B / 2, -T / 2],
|
|
125
|
+
[L / 2, B / 2, -T / 2],
|
|
126
|
+
[-L / 2, B / 2, -T / 2],
|
|
127
|
+
[-L / 2, -B / 2, T / 2],
|
|
128
|
+
[L / 2, -B / 2, T / 2],
|
|
129
|
+
[L / 2, B / 2, T / 2],
|
|
130
|
+
[-L / 2, B / 2, T / 2],
|
|
131
|
+
],
|
|
132
|
+
dtype=float,
|
|
133
|
+
)
|
|
134
|
+
# Face index lists into the (8,3) vertex array.
|
|
135
|
+
face_indices = [
|
|
136
|
+
[0, 1, 2, 3], # bottom
|
|
137
|
+
[4, 5, 6, 7], # top
|
|
138
|
+
[0, 1, 5, 4], # side
|
|
139
|
+
[2, 3, 7, 6], # side
|
|
140
|
+
[1, 2, 6, 5], # side
|
|
141
|
+
[4, 7, 3, 0], # side
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
# Pre-allocate trace arrays (avoid list append + copy each frame).
|
|
145
|
+
trace_x = np.empty(n_frames)
|
|
146
|
+
trace_y = np.empty(n_frames)
|
|
147
|
+
trace_z = np.empty(n_frames)
|
|
148
|
+
|
|
149
|
+
fig = plt.figure()
|
|
150
|
+
ax = fig.add_subplot(111, projection="3d")
|
|
151
|
+
ax.set_xlabel("X")
|
|
152
|
+
ax.set_ylabel("Y")
|
|
153
|
+
ax.set_zlabel("Z")
|
|
154
|
+
ax.set_title("Vessel animation")
|
|
155
|
+
|
|
156
|
+
if follow:
|
|
157
|
+
# Start with a reasonable cube around first position.
|
|
158
|
+
p0 = pos[0]
|
|
159
|
+
ax.set_xlim(p0[0] - margin_geom, p0[0] + margin_geom)
|
|
160
|
+
ax.set_ylim(p0[1] - margin_geom, p0[1] + margin_geom)
|
|
161
|
+
ax.set_zlim(p0[2] - margin_geom, p0[2] + margin_geom)
|
|
162
|
+
else:
|
|
163
|
+
ax.set_xlim(mins[0], maxs[0])
|
|
164
|
+
ax.set_ylim(mins[1], maxs[1])
|
|
165
|
+
ax.set_zlim(mins[2], maxs[2])
|
|
166
|
+
|
|
167
|
+
(trace_line,) = ax.plot([], [], [], "b-", lw=1.5)
|
|
168
|
+
|
|
169
|
+
# Create initial vessel poly (will update verts in-place each frame).
|
|
170
|
+
initial_faces = [[base_vertices[j] for j in face] for face in face_indices]
|
|
171
|
+
vessel_poly = Poly3DCollection(
|
|
172
|
+
initial_faces, facecolors="cyan", edgecolors="black", alpha=0.5
|
|
173
|
+
)
|
|
174
|
+
ax.add_collection3d(vessel_poly)
|
|
175
|
+
|
|
176
|
+
def init(): # noqa: D401
|
|
177
|
+
trace_line.set_data([], [])
|
|
178
|
+
trace_line.set_3d_properties([])
|
|
179
|
+
return trace_line, vessel_poly
|
|
180
|
+
|
|
181
|
+
def _frame(k: int) -> Tuple[object, object]: # returns artists for FuncAnimation
|
|
182
|
+
i = indices[k]
|
|
183
|
+
p = pos[i]
|
|
184
|
+
a = angles[i]
|
|
185
|
+
|
|
186
|
+
# Update trace slices.
|
|
187
|
+
trace_x[k] = p[0]
|
|
188
|
+
trace_y[k] = p[1]
|
|
189
|
+
trace_z[k] = p[2]
|
|
190
|
+
trace_line.set_data(trace_x[: k + 1], trace_y[: k + 1])
|
|
191
|
+
trace_line.set_3d_properties(trace_z[: k + 1])
|
|
192
|
+
|
|
193
|
+
# Axis follow (only if enabled).
|
|
194
|
+
if follow:
|
|
195
|
+
ax.set_xlim(p[0] - margin_geom, p[0] + margin_geom)
|
|
196
|
+
ax.set_ylim(p[1] - margin_geom, p[1] + margin_geom)
|
|
197
|
+
ax.set_zlim(p[2] - margin_geom, p[2] + margin_geom)
|
|
198
|
+
|
|
199
|
+
# Rotation matrix.
|
|
200
|
+
Rm = _euler_xyz_matrix(a[0], a[1], a[2])
|
|
201
|
+
rotated = base_vertices @ Rm.T + p # (8,3)
|
|
202
|
+
# Build face list for set_verts.
|
|
203
|
+
faces = [[rotated[j] for j in face] for face in face_indices]
|
|
204
|
+
vessel_poly.set_verts(faces)
|
|
205
|
+
|
|
206
|
+
return trace_line, vessel_poly
|
|
207
|
+
|
|
208
|
+
anim = animation.FuncAnimation(
|
|
209
|
+
fig,
|
|
210
|
+
_frame,
|
|
211
|
+
init_func=init,
|
|
212
|
+
frames=n_frames,
|
|
213
|
+
interval=max(frame_interval_ms * skip, desired_interval_ms),
|
|
214
|
+
blit=False,
|
|
215
|
+
repeat=False,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if show:
|
|
219
|
+
plt.show()
|
|
220
|
+
|
|
221
|
+
return anim
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__": # pragma: no cover - manual use example
|
|
225
|
+
t = np.linspace(0, 20, 2000)
|
|
226
|
+
x = 5 * np.cos(0.3 * t)
|
|
227
|
+
y = 5 * np.sin(0.3 * t)
|
|
228
|
+
z = 0.5 * np.sin(0.6 * t)
|
|
229
|
+
phi = 0.2 * np.sin(0.5 * t)
|
|
230
|
+
theta = 0.2 * np.cos(0.4 * t)
|
|
231
|
+
psi = 0.5 * t
|
|
232
|
+
hist = np.column_stack([x, y, z, phi, theta, psi])
|
|
233
|
+
animate_history(hist, dt=t[1] - t[0], L=2.0, B=1.0, T=0.5)
|
shoeboxpy/model3dof.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import numpy.typing as npt
|
|
3
|
+
import typing as tp
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Shoebox:
|
|
7
|
+
r"""
|
|
8
|
+
3-DOF planar ship model (surge, sway, yaw).
|
|
9
|
+
|
|
10
|
+
States:
|
|
11
|
+
|
|
12
|
+
.. math::
|
|
13
|
+
\eta &= [x, y, \psi] \\
|
|
14
|
+
\nu &= [u, v, r]
|
|
15
|
+
|
|
16
|
+
Dynamics:
|
|
17
|
+
|
|
18
|
+
.. math::
|
|
19
|
+
& \dot{\eta} = J(\psi)\nu \\
|
|
20
|
+
& (M_{RB}+M_A)\dot{\nu} + \Big(C_{RB}(\nu)+C_A(\nu)\Big)\nu + D\nu = \tau
|
|
21
|
+
|
|
22
|
+
where :math:`\tau` is the control/input force/moment vector in surge, sway, and yaw.
|
|
23
|
+
|
|
24
|
+
:param L: Length of the vessel.
|
|
25
|
+
:param B: Breadth of the vessel.
|
|
26
|
+
:param T: Draft of the vessel.
|
|
27
|
+
:param rho: Density of water (default is 1000.0).
|
|
28
|
+
:param alpha_u: Added mass coefficient in surge (default is 0.1).
|
|
29
|
+
:param alpha_v: Added mass coefficient in sway (default is 0.2).
|
|
30
|
+
:param alpha_r: Added mass coefficient in yaw (default is 0.1).
|
|
31
|
+
:param beta_u: Damping factor in surge (default is 0.05).
|
|
32
|
+
:param beta_v: Damping factor in sway (default is 0.05).
|
|
33
|
+
:param beta_r: Damping factor in yaw (default is 0.05).
|
|
34
|
+
:param eta0: Initial state vector for position and orientation, i.e. [x, y, \psi]. Default is a zero array of length 3.
|
|
35
|
+
:param nu0: Initial state vector for velocities, i.e. [u, v, r]. Default is a zero array of length 3.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
L: float,
|
|
41
|
+
B: float,
|
|
42
|
+
T: float,
|
|
43
|
+
rho: float = 1000.0,
|
|
44
|
+
# Added mass coefficients for 3DOF
|
|
45
|
+
alpha_u: float = 0.1,
|
|
46
|
+
alpha_v: float = 0.2,
|
|
47
|
+
alpha_r: float = 0.1,
|
|
48
|
+
# Damping factors for 3DOF
|
|
49
|
+
beta_u: float = 0.05,
|
|
50
|
+
beta_v: float = 0.05,
|
|
51
|
+
beta_r: float = 0.05,
|
|
52
|
+
# Initial states: [x, y, psi] and [u, v, r]
|
|
53
|
+
eta0: npt.NDArray[np.float64] = np.zeros(3),
|
|
54
|
+
nu0: npt.NDArray[np.float64] = np.zeros(3),
|
|
55
|
+
):
|
|
56
|
+
# Rigid-body mass from volume
|
|
57
|
+
self.m = rho * L * B * T
|
|
58
|
+
|
|
59
|
+
# Yaw moment of inertia for a rectangular (shoebox) vessel
|
|
60
|
+
Izz = (1.0 / 12.0) * self.m * (L**2 + B**2)
|
|
61
|
+
|
|
62
|
+
# Rigid-body mass matrix (3x3)
|
|
63
|
+
self.MRB = np.diag([self.m, self.m, Izz])
|
|
64
|
+
|
|
65
|
+
# Added mass (diagonal)
|
|
66
|
+
self.MA = np.diag(
|
|
67
|
+
[alpha_u * self.m, alpha_v * self.m, alpha_r * Izz] # surge # sway # yaw
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self.M_eff = self.MRB + self.MA
|
|
71
|
+
|
|
72
|
+
# Damping matrix (diagonal)
|
|
73
|
+
self.D = np.diag([beta_u * self.m, beta_v * self.m, beta_r * Izz])
|
|
74
|
+
|
|
75
|
+
self.invM_eff = np.linalg.inv(self.M_eff)
|
|
76
|
+
|
|
77
|
+
# Store states
|
|
78
|
+
self.eta = eta0.astype(float) # [x, y, psi]
|
|
79
|
+
self.nu = nu0.astype(float) # [u, v, r]
|
|
80
|
+
# Initialize reusable buffers
|
|
81
|
+
self._init_buffers()
|
|
82
|
+
|
|
83
|
+
def _init_buffers(self) -> None:
|
|
84
|
+
"""Allocate reusable numpy arrays to reduce per-step allocations."""
|
|
85
|
+
self._k_eta = np.zeros((4, 3), dtype=float)
|
|
86
|
+
self._k_nu = np.zeros((4, 3), dtype=float)
|
|
87
|
+
self._eta_tmp = np.zeros(3, dtype=float)
|
|
88
|
+
self._nu_tmp = np.zeros(3, dtype=float)
|
|
89
|
+
self._eta_dot_buf = np.zeros(3, dtype=float)
|
|
90
|
+
self._nu_dot_buf = np.zeros(3, dtype=float)
|
|
91
|
+
self._zeros3 = np.zeros(3, dtype=float)
|
|
92
|
+
|
|
93
|
+
def J(self, eta: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
|
|
94
|
+
"""
|
|
95
|
+
Compute the 3x3 transformation matrix from body velocities to inertial frame.
|
|
96
|
+
"""
|
|
97
|
+
psi = eta[2]
|
|
98
|
+
c = np.cos(psi)
|
|
99
|
+
s = np.sin(psi)
|
|
100
|
+
return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])
|
|
101
|
+
|
|
102
|
+
def C_RB(self, nu: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
|
|
103
|
+
"""
|
|
104
|
+
Rigid-body Coriolis/centripetal matrix for 3DOF.
|
|
105
|
+
"""
|
|
106
|
+
u, v, r = nu
|
|
107
|
+
m = self.m
|
|
108
|
+
Izz = self.MRB[2, 2]
|
|
109
|
+
# Standard form for surge-sway-yaw
|
|
110
|
+
return np.array([[0, 0, -m * v], [0, 0, m * u], [m * v, -m * u, 0]])
|
|
111
|
+
|
|
112
|
+
def C_A(self, nu: npt.NDArray[np.float64]) -> npt.NDArray[np.float64]:
|
|
113
|
+
"""
|
|
114
|
+
Added mass Coriolis/centripetal matrix for 3DOF.
|
|
115
|
+
"""
|
|
116
|
+
u, v, r = nu
|
|
117
|
+
Xu_dot = self.MA[0, 0]
|
|
118
|
+
Yv_dot = self.MA[1, 1]
|
|
119
|
+
Nr_dot = self.MA[2, 2]
|
|
120
|
+
return np.array(
|
|
121
|
+
[[0, 0, -Yv_dot * v], [0, 0, Xu_dot * u], [Yv_dot * v, -Xu_dot * u, 0]]
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def dynamics(
|
|
125
|
+
self,
|
|
126
|
+
eta: npt.NDArray[np.float64],
|
|
127
|
+
nu: npt.NDArray[np.float64],
|
|
128
|
+
tau: npt.NDArray[np.float64],
|
|
129
|
+
tau_ext: npt.NDArray[np.float64] = None,
|
|
130
|
+
) -> tp.Tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
|
|
131
|
+
r"""
|
|
132
|
+
Returns the time derivatives :math:`(\dot{\eta}, \dot{\nu})` for the 3DOF model:
|
|
133
|
+
|
|
134
|
+
.. math:
|
|
135
|
+
\dot{eta} = J(\eta) \nu \\
|
|
136
|
+
(M_{RB} + M_A) \dot{\nu} + (C_{RB}(\nu) + C_A(\nu)) \nu + D \nu = \tau + \tau_{ext}.
|
|
137
|
+
|
|
138
|
+
External forces :math:`\tau_{ext}` can be provided (default is zero).
|
|
139
|
+
"""
|
|
140
|
+
# Public method keeps original semantics (returns new arrays) while
|
|
141
|
+
# delegating core math to in-place implementation to reduce internal allocations.
|
|
142
|
+
eta_dot = np.empty(3, dtype=float)
|
|
143
|
+
nu_dot = np.empty(3, dtype=float)
|
|
144
|
+
self._dynamics_into(eta, nu, tau, tau_ext, eta_dot, nu_dot)
|
|
145
|
+
return eta_dot, nu_dot
|
|
146
|
+
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
# Internal optimized dynamics: writes results into provided buffers.
|
|
149
|
+
# Avoids constructing intermediate small arrays (J, C matrices, rhs).
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
def _dynamics_into(
|
|
152
|
+
self,
|
|
153
|
+
eta: npt.NDArray[np.float64],
|
|
154
|
+
nu: npt.NDArray[np.float64],
|
|
155
|
+
tau: npt.NDArray[np.float64],
|
|
156
|
+
tau_ext: npt.NDArray[np.float64],
|
|
157
|
+
eta_dot_out: npt.NDArray[np.float64],
|
|
158
|
+
nu_dot_out: npt.NDArray[np.float64],
|
|
159
|
+
) -> None:
|
|
160
|
+
if tau_ext is None:
|
|
161
|
+
tau_ext = self._zeros3
|
|
162
|
+
|
|
163
|
+
# Unpack states
|
|
164
|
+
psi = eta[2]
|
|
165
|
+
u, v, r = nu
|
|
166
|
+
|
|
167
|
+
c = np.cos(psi)
|
|
168
|
+
s = np.sin(psi)
|
|
169
|
+
|
|
170
|
+
# eta_dot = J(psi) * nu (manual expand to avoid matrix mult allocation)
|
|
171
|
+
eta_dot_out[0] = c * u - s * v
|
|
172
|
+
eta_dot_out[1] = s * u + c * v
|
|
173
|
+
eta_dot_out[2] = r
|
|
174
|
+
|
|
175
|
+
# Coriolis * nu contributions (pre- multiplied form) for rigid-body & added mass
|
|
176
|
+
m = self.m
|
|
177
|
+
Xu_dot = self.MA[0, 0]
|
|
178
|
+
Yv_dot = self.MA[1, 1]
|
|
179
|
+
# C_total @ nu only has first two components non-zero
|
|
180
|
+
cnu0 = -(m + Yv_dot) * v * r
|
|
181
|
+
cnu1 = (m + Xu_dot) * u * r
|
|
182
|
+
|
|
183
|
+
# Damping (diagonal) * nu
|
|
184
|
+
Dnu0 = self.D[0, 0] * u
|
|
185
|
+
Dnu1 = self.D[1, 1] * v
|
|
186
|
+
Dnu2 = self.D[2, 2] * r
|
|
187
|
+
|
|
188
|
+
# rhs = tau + tau_ext - Dnu - Cnu (no restoring forces in this planar model)
|
|
189
|
+
rhs0 = tau[0] + tau_ext[0] - Dnu0 - cnu0
|
|
190
|
+
rhs1 = tau[1] + tau_ext[1] - Dnu1 - cnu1
|
|
191
|
+
rhs2 = tau[2] + tau_ext[2] - Dnu2 # cnu2 == 0
|
|
192
|
+
|
|
193
|
+
# nu_dot = invM_eff @ rhs (manual 3x3 matvec multiply)
|
|
194
|
+
M = self.invM_eff
|
|
195
|
+
nu_dot_out[0] = M[0, 0] * rhs0 + M[0, 1] * rhs1 + M[0, 2] * rhs2
|
|
196
|
+
nu_dot_out[1] = M[1, 0] * rhs0 + M[1, 1] * rhs1 + M[1, 2] * rhs2
|
|
197
|
+
nu_dot_out[2] = M[2, 0] * rhs0 + M[2, 1] * rhs1 + M[2, 2] * rhs2
|
|
198
|
+
|
|
199
|
+
def step(
|
|
200
|
+
self,
|
|
201
|
+
tau: npt.NDArray[np.float64] = None,
|
|
202
|
+
tau_ext: npt.NDArray[np.float64] = None,
|
|
203
|
+
dt: float = 0.01,
|
|
204
|
+
):
|
|
205
|
+
r"""
|
|
206
|
+
Advance the state :math:`(\eta, \nu)` one time step dt using 4th-order Runge-Kutta.
|
|
207
|
+
"""
|
|
208
|
+
if tau is None:
|
|
209
|
+
tau = self._zeros3.copy() # ensure user can mutate after call
|
|
210
|
+
if tau_ext is None:
|
|
211
|
+
tau_ext = self._zeros3 # safe shared read-only
|
|
212
|
+
|
|
213
|
+
eta0 = self.eta # references (updated at end)
|
|
214
|
+
nu0 = self.nu
|
|
215
|
+
|
|
216
|
+
k_eta = self._k_eta
|
|
217
|
+
k_nu = self._k_nu
|
|
218
|
+
eta_tmp = self._eta_tmp
|
|
219
|
+
nu_tmp = self._nu_tmp
|
|
220
|
+
|
|
221
|
+
# -- k1 --
|
|
222
|
+
self._dynamics_into(eta0, nu0, tau, tau_ext, k_eta[0], k_nu[0])
|
|
223
|
+
|
|
224
|
+
# -- k2 --
|
|
225
|
+
eta_tmp[:] = eta0 + 0.5 * dt * k_eta[0]
|
|
226
|
+
nu_tmp[:] = nu0 + 0.5 * dt * k_nu[0]
|
|
227
|
+
self._dynamics_into(eta_tmp, nu_tmp, tau, tau_ext, k_eta[1], k_nu[1])
|
|
228
|
+
|
|
229
|
+
# -- k3 --
|
|
230
|
+
eta_tmp[:] = eta0 + 0.5 * dt * k_eta[1]
|
|
231
|
+
nu_tmp[:] = nu0 + 0.5 * dt * k_nu[1]
|
|
232
|
+
self._dynamics_into(eta_tmp, nu_tmp, tau, tau_ext, k_eta[2], k_nu[2])
|
|
233
|
+
|
|
234
|
+
# -- k4 --
|
|
235
|
+
eta_tmp[:] = eta0 + dt * k_eta[2]
|
|
236
|
+
nu_tmp[:] = nu0 + dt * k_nu[2]
|
|
237
|
+
self._dynamics_into(eta_tmp, nu_tmp, tau, tau_ext, k_eta[3], k_nu[3])
|
|
238
|
+
|
|
239
|
+
# Combine increments (in-place update to existing state arrays)
|
|
240
|
+
eta0 += (dt / 6.0) * (k_eta[0] + 2 * k_eta[1] + 2 * k_eta[2] + k_eta[3])
|
|
241
|
+
nu0 += (dt / 6.0) * (k_nu[0] + 2 * k_nu[1] + 2 * k_nu[2] + k_nu[3])
|
|
242
|
+
|
|
243
|
+
def get_states(self) -> tp.Tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]:
|
|
244
|
+
r"""
|
|
245
|
+
Returns a copy of the current states: :math:`\eta, \nu`
|
|
246
|
+
"""
|
|
247
|
+
return self.eta.copy(), self.nu.copy()
|