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 ADDED
@@ -0,0 +1,3 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.0.1"
@@ -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()