drones-sim 0.2.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.
Files changed (45) hide show
  1. drones_sim/__init__.py +15 -0
  2. drones_sim/control/__init__.py +15 -0
  3. drones_sim/control/allocation.py +76 -0
  4. drones_sim/control/cascaded.py +204 -0
  5. drones_sim/control/geometric.py +170 -0
  6. drones_sim/control/lqr.py +174 -0
  7. drones_sim/control/pid.py +50 -0
  8. drones_sim/dynamics/__init__.py +13 -0
  9. drones_sim/dynamics/config.py +117 -0
  10. drones_sim/dynamics/disturbances.py +262 -0
  11. drones_sim/dynamics/quadcopter.py +317 -0
  12. drones_sim/estimation/__init__.py +2 -0
  13. drones_sim/estimation/ahrs.py +79 -0
  14. drones_sim/estimation/ekf.py +594 -0
  15. drones_sim/logging/__init__.py +13 -0
  16. drones_sim/logging/csv_logger.py +76 -0
  17. drones_sim/logging/json_logger.py +53 -0
  18. drones_sim/math_utils.py +119 -0
  19. drones_sim/models/__init__.py +21 -0
  20. drones_sim/models/quadcopter.urdf +296 -0
  21. drones_sim/models/urdf_loader.py +444 -0
  22. drones_sim/rl/__init__.py +16 -0
  23. drones_sim/rl/actions.py +219 -0
  24. drones_sim/rl/env.py +195 -0
  25. drones_sim/rl/observations.py +40 -0
  26. drones_sim/rl/reward.py +69 -0
  27. drones_sim/rl/tasks.py +73 -0
  28. drones_sim/sensors/__init__.py +3 -0
  29. drones_sim/sensors/gps.py +158 -0
  30. drones_sim/sensors/imu.py +196 -0
  31. drones_sim/sensors/models.py +113 -0
  32. drones_sim/simulation.py +283 -0
  33. drones_sim/state.py +133 -0
  34. drones_sim/trajectory.py +391 -0
  35. drones_sim/visualization/__init__.py +23 -0
  36. drones_sim/visualization/api.py +50 -0
  37. drones_sim/visualization/dashboard.py +74 -0
  38. drones_sim/visualization/plots.py +183 -0
  39. drones_sim/visualization/rerun_viewer.py +411 -0
  40. drones_sim/visualization/viewer.py +452 -0
  41. drones_sim-0.2.0.dist-info/METADATA +323 -0
  42. drones_sim-0.2.0.dist-info/RECORD +45 -0
  43. drones_sim-0.2.0.dist-info/WHEEL +5 -0
  44. drones_sim-0.2.0.dist-info/licenses/LICENSE +21 -0
  45. drones_sim-0.2.0.dist-info/top_level.txt +1 -0
drones_sim/state.py ADDED
@@ -0,0 +1,133 @@
1
+ """Typed data exchanged between the simulation, controllers, and estimators.
2
+
3
+ The original package passed partially documented NumPy arrays between modules.
4
+ These small value objects make frame conventions and units explicit while still
5
+ offering vector conversions for numerical algorithms and legacy callers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ import numpy as np
13
+ from numpy.typing import NDArray
14
+
15
+ from .math_utils import quat_normalize, quat_to_euler, quat_to_rotation_matrix
16
+
17
+
18
+ def _vector(value: NDArray, size: int, name: str) -> NDArray:
19
+ array = np.asarray(value, dtype=float).copy()
20
+ if array.shape != (size,):
21
+ raise ValueError(f"{name} must have shape ({size},), got {array.shape}")
22
+ if not np.all(np.isfinite(array)):
23
+ raise ValueError(f"{name} must contain only finite values")
24
+ return array
25
+
26
+
27
+ @dataclass
28
+ class VehicleState:
29
+ """Complete quadcopter state using ENU/world and FRU/body conventions.
30
+
31
+ ``quaternion`` is Hamilton ``[w, x, y, z]`` and rotates body vectors into
32
+ the world frame. ``body_rates`` are expressed in the body frame [rad/s].
33
+ """
34
+
35
+ position: NDArray = field(default_factory=lambda: np.zeros(3))
36
+ velocity: NDArray = field(default_factory=lambda: np.zeros(3))
37
+ quaternion: NDArray = field(
38
+ default_factory=lambda: np.array([1.0, 0.0, 0.0, 0.0])
39
+ )
40
+ body_rates: NDArray = field(default_factory=lambda: np.zeros(3))
41
+ motor_speeds: NDArray = field(default_factory=lambda: np.zeros(4))
42
+ time: float = 0.0
43
+
44
+ def __post_init__(self) -> None:
45
+ self.position = _vector(self.position, 3, "position")
46
+ self.velocity = _vector(self.velocity, 3, "velocity")
47
+ self.quaternion = quat_normalize(_vector(self.quaternion, 4, "quaternion"))
48
+ if np.linalg.norm(self.quaternion) < 1e-12:
49
+ raise ValueError("quaternion must have non-zero norm")
50
+ self.body_rates = _vector(self.body_rates, 3, "body_rates")
51
+ self.motor_speeds = _vector(self.motor_speeds, 4, "motor_speeds")
52
+ self.time = float(self.time)
53
+
54
+ @classmethod
55
+ def from_vector(
56
+ cls,
57
+ vector: NDArray,
58
+ *,
59
+ motor_speeds: NDArray | None = None,
60
+ time: float = 0.0,
61
+ ) -> VehicleState:
62
+ """Construct from ``[position, velocity, quaternion, body_rates]``."""
63
+ value = _vector(vector, 13, "state vector")
64
+ return cls(
65
+ position=value[0:3],
66
+ velocity=value[3:6],
67
+ quaternion=value[6:10],
68
+ body_rates=value[10:13],
69
+ motor_speeds=np.zeros(4) if motor_speeds is None else motor_speeds,
70
+ time=time,
71
+ )
72
+
73
+ def as_vector(self) -> NDArray:
74
+ return np.concatenate(
75
+ [self.position, self.velocity, self.quaternion, self.body_rates]
76
+ )
77
+
78
+ @property
79
+ def rotation_matrix(self) -> NDArray:
80
+ """World-from-body rotation matrix."""
81
+ return quat_to_rotation_matrix(self.quaternion)
82
+
83
+ @property
84
+ def euler(self) -> NDArray:
85
+ """Roll, pitch, yaw view for display and compatibility only."""
86
+ return quat_to_euler(self.quaternion)
87
+
88
+ def copy(self) -> VehicleState:
89
+ return VehicleState(
90
+ self.position,
91
+ self.velocity,
92
+ self.quaternion,
93
+ self.body_rates,
94
+ self.motor_speeds,
95
+ self.time,
96
+ )
97
+
98
+
99
+ @dataclass
100
+ class TrajectorySetpoint:
101
+ """Flat-output reference consumed by position controllers."""
102
+
103
+ position: NDArray = field(default_factory=lambda: np.zeros(3))
104
+ velocity: NDArray = field(default_factory=lambda: np.zeros(3))
105
+ acceleration: NDArray = field(default_factory=lambda: np.zeros(3))
106
+ yaw: float = 0.0
107
+ yaw_rate: float = 0.0
108
+
109
+ def __post_init__(self) -> None:
110
+ self.position = _vector(self.position, 3, "setpoint.position")
111
+ self.velocity = _vector(self.velocity, 3, "setpoint.velocity")
112
+ self.acceleration = _vector(self.acceleration, 3, "setpoint.acceleration")
113
+ self.yaw = float(self.yaw)
114
+ self.yaw_rate = float(self.yaw_rate)
115
+
116
+
117
+ @dataclass
118
+ class ControlOutput:
119
+ """Controller output and allocator diagnostics."""
120
+
121
+ thrust: float
122
+ torque: NDArray
123
+ motor_speeds: NDArray
124
+ achieved_wrench: NDArray
125
+ saturated: bool = False
126
+
127
+ def __post_init__(self) -> None:
128
+ self.thrust = float(self.thrust)
129
+ self.torque = _vector(self.torque, 3, "torque")
130
+ self.motor_speeds = _vector(self.motor_speeds, 4, "motor_speeds")
131
+ self.achieved_wrench = _vector(
132
+ self.achieved_wrench, 4, "achieved_wrench"
133
+ )
@@ -0,0 +1,391 @@
1
+ """Trajectory generators for drone simulation.
2
+
3
+ All generators return arrays of shape (N, 3) for position, velocity,
4
+ acceleration, and (N, 4) for quaternion orientation [w,x,y,z].
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from math import factorial
11
+
12
+ import numpy as np
13
+ from numpy.polynomial.polynomial import polyder
14
+ from numpy.polynomial.polynomial import polyval as polyval1d
15
+ from numpy.typing import NDArray
16
+ from scipy.linalg import lstsq
17
+ from scipy.spatial.transform import Rotation as R
18
+
19
+ from .math_utils import quat_from_euler
20
+
21
+
22
+ @dataclass
23
+ class TrajectoryData:
24
+ """Container for a generated trajectory."""
25
+ t: NDArray
26
+ position: NDArray
27
+ velocity: NDArray
28
+ acceleration: NDArray
29
+ orientation_quat: NDArray # (N, 4) wxyz
30
+ angular_velocity: NDArray # (N, 3)
31
+
32
+
33
+ def generate_hover_accel_cruise(
34
+ duration: float = 10.0,
35
+ sample_rate: int = 100,
36
+ max_vel: float = 5.0,
37
+ hover_time: float = 1.0,
38
+ accel_time: float = 2.0,
39
+ cruise_time: float = 4.0,
40
+ decel_time: float = 2.0,
41
+ ) -> TrajectoryData:
42
+ """Generate a drone-like trajectory: hover -> accelerate -> cruise -> decelerate -> hover.
43
+
44
+ This is the canonical test trajectory used across the sensor-fusion examples.
45
+ """
46
+ t = np.linspace(0, duration, int(duration * sample_rate))
47
+ dt = 1.0 / sample_rate
48
+ n = len(t)
49
+
50
+ position = np.zeros((n, 3))
51
+ velocity = np.zeros((n, 3))
52
+ acceleration = np.zeros((n, 3))
53
+ orientation_quat = np.zeros((n, 4))
54
+ orientation_quat[:, 0] = 1.0 # identity
55
+
56
+ hover_samples = int(hover_time * sample_rate)
57
+ accel_samples = int(accel_time * sample_rate)
58
+ cruise_samples = int(cruise_time * sample_rate)
59
+ decel_samples = int(decel_time * sample_rate)
60
+
61
+ max_accel = max_vel / accel_time
62
+
63
+ # --- Acceleration phase ---
64
+ for i in range(hover_samples, hover_samples + accel_samples):
65
+ t_a = (i - hover_samples) * dt
66
+ acceleration[i, 0] = max_accel
67
+ velocity[i, 0] = max_accel * t_a
68
+ position[i, 0] = 0.5 * max_accel * t_a**2
69
+
70
+ pitch = -0.2 * min(1.0, t_a / accel_time)
71
+ orientation_quat[i] = quat_from_euler(0, pitch, 0)
72
+
73
+ # --- Cruise phase ---
74
+ cruise_start = hover_samples + accel_samples
75
+ pos_at_cruise = position[cruise_start - 1, 0] if cruise_start > 0 else 0.0
76
+ for i in range(cruise_start, cruise_start + cruise_samples):
77
+ t_c = (i - cruise_start) * dt
78
+ velocity[i, 0] = max_vel
79
+ position[i, 0] = pos_at_cruise + max_vel * t_c
80
+
81
+ position[i, 2] = 0.2 * np.sin(t_c * 2)
82
+ velocity[i, 2] = 0.4 * np.cos(t_c * 2)
83
+ acceleration[i, 2] = -0.8 * np.sin(t_c * 2)
84
+
85
+ roll = 0.1 * np.sin(t_c * 3)
86
+ orientation_quat[i] = quat_from_euler(roll, -0.05, 0)
87
+
88
+ # --- Deceleration phase ---
89
+ decel_start = cruise_start + cruise_samples
90
+ pos_at_decel = position[decel_start - 1, 0] if decel_start > 0 else 0.0
91
+ for i in range(decel_start, decel_start + decel_samples):
92
+ t_d = (i - decel_start) * dt
93
+ acceleration[i, 0] = -max_accel
94
+ velocity[i, 0] = max(0, max_vel - max_accel * t_d)
95
+ position[i, 0] = pos_at_decel + max_vel * t_d - 0.5 * max_accel * t_d**2
96
+
97
+ pitch = 0.2 * min(1.0, t_d / decel_time)
98
+ orientation_quat[i] = quat_from_euler(0, pitch, 0)
99
+
100
+ # --- Final hover ---
101
+ final_start = decel_start + decel_samples
102
+ if final_start < n:
103
+ final_pos = position[final_start - 1].copy()
104
+ for i in range(final_start, n):
105
+ position[i] = final_pos
106
+
107
+ # --- Angular velocity from quaternion differences ---
108
+ angular_velocity = np.zeros((n, 3))
109
+ for i in range(1, n):
110
+ q_curr = np.roll(orientation_quat[i], -1) # to xyzw
111
+ q_prev = np.roll(orientation_quat[i - 1], -1)
112
+ r_diff = R.from_quat(q_curr) * R.from_quat(q_prev).inv()
113
+ angular_velocity[i] = r_diff.as_rotvec() / dt
114
+
115
+ return TrajectoryData(
116
+ t=t,
117
+ position=position,
118
+ velocity=velocity,
119
+ acceleration=acceleration,
120
+ orientation_quat=orientation_quat,
121
+ angular_velocity=angular_velocity,
122
+ )
123
+
124
+
125
+ def generate_circular(
126
+ duration: float = 10.0,
127
+ sample_rate: int = 100,
128
+ radius: float = 5.0,
129
+ angular_vel: float = 0.5,
130
+ ) -> TrajectoryData:
131
+ """Generate circular motion with varying height and orientation."""
132
+ t = np.linspace(0, duration, int(duration * sample_rate))
133
+ dt = 1.0 / sample_rate
134
+ n = len(t)
135
+
136
+ position = np.zeros((n, 3))
137
+ velocity = np.zeros((n, 3))
138
+ acceleration = np.zeros((n, 3))
139
+ orientation_quat = np.zeros((n, 4))
140
+ orientation_quat[:, 0] = 1.0
141
+
142
+ for i in range(n):
143
+ ti = t[i]
144
+ position[i, 0] = radius * np.cos(angular_vel * ti)
145
+ position[i, 1] = radius * np.sin(angular_vel * ti)
146
+ position[i, 2] = 0.5 * np.sin(0.5 * angular_vel * ti)
147
+
148
+ velocity[i, 0] = -radius * angular_vel * np.sin(angular_vel * ti)
149
+ velocity[i, 1] = radius * angular_vel * np.cos(angular_vel * ti)
150
+ velocity[i, 2] = 0.25 * angular_vel * np.cos(0.5 * angular_vel * ti)
151
+
152
+ roll = 0.2 * np.sin(ti)
153
+ pitch = 0.1 * np.cos(2 * ti)
154
+ yaw = np.arctan2(velocity[i, 1], velocity[i, 0])
155
+ orientation_quat[i] = quat_from_euler(roll, pitch, yaw)
156
+
157
+ # Numerical acceleration
158
+ for i in range(1, n):
159
+ acceleration[i] = (velocity[i] - velocity[i - 1]) / dt
160
+
161
+ # Angular velocity from quaternion differences
162
+ angular_velocity = np.zeros((n, 3))
163
+ for i in range(1, n):
164
+ q_curr = np.roll(orientation_quat[i], -1)
165
+ q_prev = np.roll(orientation_quat[i - 1], -1)
166
+ r_diff = R.from_quat(q_curr) * R.from_quat(q_prev).inv()
167
+ angular_velocity[i] = r_diff.as_rotvec() / dt
168
+
169
+ return TrajectoryData(
170
+ t=t,
171
+ position=position,
172
+ velocity=velocity,
173
+ acceleration=acceleration,
174
+ orientation_quat=orientation_quat,
175
+ angular_velocity=angular_velocity,
176
+ )
177
+
178
+
179
+ def generate_waypoint_trajectory(
180
+ waypoints: list[tuple[float, float, float]],
181
+ waypoint_times: list[float],
182
+ dt: float = 0.01,
183
+ ) -> TrajectoryData:
184
+ """Generate a piecewise-linear trajectory through waypoints.
185
+
186
+ Used by the quadcopter controller simulation.
187
+ """
188
+ t_max = waypoint_times[-1]
189
+ t = np.arange(0, t_max, dt)
190
+ n = len(t)
191
+
192
+ position = np.zeros((n, 3))
193
+ velocity = np.zeros((n, 3))
194
+ acceleration = np.zeros((n, 3))
195
+
196
+ wp = np.array(waypoints)
197
+ for i in range(n):
198
+ ti = t[i]
199
+ for j in range(len(waypoint_times) - 1):
200
+ if waypoint_times[j] <= ti < waypoint_times[j + 1]:
201
+ alpha = (ti - waypoint_times[j]) / (waypoint_times[j + 1] - waypoint_times[j])
202
+ idx_next = min(j + 1, len(waypoints) - 1)
203
+ position[i] = wp[j] * (1 - alpha) + wp[idx_next] * alpha
204
+ break
205
+
206
+ # Numerical velocity and acceleration
207
+ for i in range(1, n):
208
+ velocity[i] = (position[i] - position[i - 1]) / dt
209
+ for i in range(1, n):
210
+ acceleration[i] = (velocity[i] - velocity[i - 1]) / dt
211
+
212
+ orientation_quat = np.zeros((n, 4))
213
+ orientation_quat[:, 0] = 1.0
214
+ angular_velocity = np.zeros((n, 3))
215
+
216
+ return TrajectoryData(
217
+ t=t,
218
+ position=position,
219
+ velocity=velocity,
220
+ acceleration=acceleration,
221
+ orientation_quat=orientation_quat,
222
+ angular_velocity=angular_velocity,
223
+ )
224
+
225
+
226
+ def generate_minimum_snap(
227
+ waypoints: list[tuple[float, float, float]],
228
+ times: list[float] | None = None,
229
+ order: int = 7,
230
+ dt: float = 0.01,
231
+ ) -> TrajectoryData:
232
+ """Generate a C^3-continuous minimum-snap polynomial trajectory.
233
+
234
+ Fits piecewise polynomials of ``order`` through ``waypoints``, minimising
235
+ the integral of squared snap (4th time-derivative of position).
236
+
237
+ Constraints enforced:
238
+
239
+ * Position equality at each waypoint.
240
+ * Zero velocity, acceleration, and jerk at the first and the last waypoint.
241
+ * C^3 continuity (velocity, acceleration, jerk) at every interior junction.
242
+
243
+ The resulting QP is solved via the KKT bordered system using
244
+ ``scipy.linalg.lstsq``.
245
+
246
+ Parameters
247
+ ----------
248
+ waypoints:
249
+ List of (x, y, z) positions. Requires at least 2 points.
250
+ times:
251
+ Absolute time-stamps [s] for each waypoint (length = len(waypoints)).
252
+ If ``None``, segment durations are allocated proportionally to
253
+ Euclidean inter-waypoint distances (minimum 0.1 s per segment).
254
+ order:
255
+ Polynomial degree per segment. Must be >= 7 for minimum-snap.
256
+ dt:
257
+ Sampling period [s] for the returned ``TrajectoryData``.
258
+
259
+ Returns
260
+ -------
261
+ TrajectoryData
262
+ Consistent position, velocity, and acceleration arrays.
263
+ Orientation is the identity quaternion (yaw tracking not computed).
264
+ """
265
+ order = max(order, 7) # minimum degree for 4 BCs per side
266
+ n = order + 1 # coefficients per segment
267
+ snap = 4 # derivative order being minimised
268
+
269
+ wp = np.array(waypoints, dtype=float) # (M+1, 3)
270
+ M = len(wp) - 1
271
+ if M < 1:
272
+ raise ValueError("At least 2 waypoints are required.")
273
+
274
+ # ------------------------------------------------------------------ #
275
+ # Time allocation
276
+ # ------------------------------------------------------------------ #
277
+ if times is None:
278
+ dists = np.linalg.norm(np.diff(wp, axis=0), axis=1)
279
+ total = float(np.sum(dists))
280
+ seg_T = (dists / total * float(M)) if total > 1e-9 else np.ones(M)
281
+ seg_T = np.maximum(seg_T, 0.1)
282
+ t_arr = np.concatenate([[0.0], np.cumsum(seg_T)])
283
+ else:
284
+ t_arr = np.array(times, dtype=float)
285
+ if len(t_arr) != M + 1:
286
+ raise ValueError("len(times) must equal len(waypoints).")
287
+ seg_T = np.diff(t_arr)
288
+
289
+ T = seg_T # durations, shape (M,)
290
+ N = M * n # total unknowns per axis
291
+
292
+ # ------------------------------------------------------------------ #
293
+ # Block-diagonal snap cost matrix Q (N x N)
294
+ # ------------------------------------------------------------------ #
295
+ Q_full = np.zeros((N, N))
296
+ for k in range(M):
297
+ Tk = T[k]
298
+ for i in range(snap, n):
299
+ for j in range(snap, n):
300
+ r = i + j - 2 * snap + 1
301
+ Q_full[k*n + i, k*n + j] = (
302
+ factorial(i) // factorial(i - snap) *
303
+ factorial(j) // factorial(j - snap) *
304
+ Tk**r / r
305
+ )
306
+
307
+ # ------------------------------------------------------------------ #
308
+ # Constraint matrix A (same for all axes; only b changes per axis)
309
+ # ------------------------------------------------------------------ #
310
+ def poly_row(k: int, tau: float, deriv: int) -> NDArray:
311
+ """Global row vector for p_k^(deriv)(tau)."""
312
+ row = np.zeros(N)
313
+ for j in range(deriv, n):
314
+ row[k * n + j] = factorial(j) / factorial(j - deriv) * tau ** (j - deriv)
315
+ return row
316
+
317
+ con_rows: list[NDArray] = []
318
+ # 1. Start position of each segment
319
+ for k in range(M):
320
+ con_rows.append(poly_row(k, 0.0, 0))
321
+ # 2. End position of each segment
322
+ for k in range(M):
323
+ con_rows.append(poly_row(k, T[k], 0))
324
+ # 3. Zero vel/acc/jerk at trajectory start
325
+ for deriv in range(1, snap):
326
+ con_rows.append(poly_row(0, 0.0, deriv))
327
+ # 4. Zero vel/acc/jerk at trajectory end
328
+ for deriv in range(1, snap):
329
+ con_rows.append(poly_row(M - 1, T[M - 1], deriv))
330
+ # 5. C^3 continuity at interior junctions (vel, acc, jerk)
331
+ for k in range(M - 1):
332
+ for deriv in range(1, snap):
333
+ con_rows.append(poly_row(k, T[k], deriv) - poly_row(k + 1, 0.0, deriv))
334
+
335
+ A = np.array(con_rows) # (n_con, N)
336
+ n_con = A.shape[0]
337
+
338
+ # ------------------------------------------------------------------ #
339
+ # KKT bordered system min c'Qc s.t. Ac = b
340
+ # ------------------------------------------------------------------ #
341
+ KKT = np.block([
342
+ [Q_full, A.T ],
343
+ [A, np.zeros((n_con, n_con)) ],
344
+ ])
345
+
346
+ coeffs = np.zeros((M, n, 3)) # [segment, coeff_idx, axis]
347
+ for d in range(3):
348
+ b = np.zeros(n_con)
349
+ for k in range(M):
350
+ b[k] = wp[k, d] # start of segment k
351
+ b[M + k] = wp[k + 1, d] # end of segment k
352
+ # All other entries are 0 (zero BCs and continuity)
353
+
354
+ rhs = np.zeros(N + n_con)
355
+ rhs[N:] = b
356
+ sol, *_ = lstsq(KKT, rhs, check_finite=False)
357
+ coeffs[:, :, d] = sol[:N].reshape(M, n)
358
+
359
+ # ------------------------------------------------------------------ #
360
+ # Sample trajectory
361
+ # ------------------------------------------------------------------ #
362
+ t_total = float(t_arr[-1])
363
+ t_out = np.arange(0.0, t_total, dt)
364
+ nn = len(t_out)
365
+ position = np.zeros((nn, 3))
366
+ velocity = np.zeros((nn, 3))
367
+ acceleration = np.zeros((nn, 3))
368
+
369
+ for k in range(M):
370
+ t_lo = t_arr[k]
371
+ t_hi = t_arr[k + 1] if k < M - 1 else t_arr[-1] + dt
372
+ mask = (t_out >= t_lo) & (t_out < t_hi)
373
+ tau = t_out[mask] - t_lo
374
+ for d in range(3):
375
+ c = coeffs[k, :, d]
376
+ position[mask, d] = polyval1d(tau, c)
377
+ velocity[mask, d] = polyval1d(tau, polyder(c, 1))
378
+ acceleration[mask, d] = polyval1d(tau, polyder(c, 2))
379
+
380
+ orientation_quat = np.zeros((nn, 4))
381
+ orientation_quat[:, 0] = 1.0 # identity
382
+ angular_velocity = np.zeros((nn, 3))
383
+
384
+ return TrajectoryData(
385
+ t=t_out,
386
+ position=position,
387
+ velocity=velocity,
388
+ acceleration=acceleration,
389
+ orientation_quat=orientation_quat,
390
+ angular_velocity=angular_velocity,
391
+ )
@@ -0,0 +1,23 @@
1
+ from .api import VisualizationBackend, visualize
2
+ from .dashboard import plot_simulation
3
+ from .plots import plot_ekf_results, plot_quadcopter_results # noqa: F401
4
+ from .rerun_viewer import (
5
+ RerunConfig,
6
+ log_simulation_rerun,
7
+ rerun_blueprint,
8
+ visualize_rerun,
9
+ )
10
+ from .viewer import DroneViewer # noqa: F401
11
+
12
+ __all__ = [
13
+ "DroneViewer",
14
+ "RerunConfig",
15
+ "VisualizationBackend",
16
+ "log_simulation_rerun",
17
+ "plot_ekf_results",
18
+ "plot_quadcopter_results",
19
+ "plot_simulation",
20
+ "rerun_blueprint",
21
+ "visualize",
22
+ "visualize_rerun",
23
+ ]
@@ -0,0 +1,50 @@
1
+ """Backend-neutral visualization entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from ..simulation import SimulationResult
8
+ from .dashboard import plot_simulation
9
+ from .rerun_viewer import visualize_rerun
10
+
11
+ VisualizationBackend = Literal["rerun", "matplotlib", "viser"]
12
+
13
+
14
+ def visualize(
15
+ result: SimulationResult,
16
+ *,
17
+ backend: VisualizationBackend = "rerun",
18
+ **kwargs,
19
+ ):
20
+ """Visualize a simulation result with Rerun by default.
21
+
22
+ ``rerun`` accepts the arguments of :func:`visualize_rerun`, including
23
+ ``mode``, ``path``, and ``url``. ``matplotlib`` accepts ``title``.
24
+ ``viser`` accepts ``host``, ``port``, ``urdf_model``, or a prebuilt
25
+ ``viewer`` and starts its blocking playback UI.
26
+ """
27
+ if backend == "rerun":
28
+ return visualize_rerun(result, **kwargs)
29
+ if backend == "matplotlib":
30
+ return plot_simulation(result, **kwargs)
31
+ if backend == "viser":
32
+ # Keep the legacy backend lazy so Rerun/Matplotlib users do not need
33
+ # the optional Viser dependency installed.
34
+ from .viewer import DroneViewer
35
+
36
+ viewer = kwargs.pop("viewer", None)
37
+ host = kwargs.pop("host", "0.0.0.0")
38
+ port = kwargs.pop("port", 8080)
39
+ urdf_model = kwargs.pop("urdf_model", None)
40
+ if kwargs:
41
+ names = ", ".join(sorted(kwargs))
42
+ raise TypeError(f"unexpected Viser options: {names}")
43
+ if viewer is None:
44
+ viewer = DroneViewer(host=host, port=port)
45
+ viewer.playback_result(result, urdf_model=urdf_model)
46
+ return viewer
47
+ raise ValueError(
48
+ f"unknown visualization backend {backend!r}; "
49
+ "expected 'rerun', 'matplotlib', or 'viser'"
50
+ )
@@ -0,0 +1,74 @@
1
+ """Result-native diagnostic figures for closed-loop simulations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import matplotlib.pyplot as plt
6
+ import numpy as np
7
+
8
+ from ..math_utils import quat_to_euler
9
+ from ..simulation import SimulationResult
10
+
11
+
12
+ def plot_simulation(result: SimulationResult, *, title: str | None = None):
13
+ """Build a compact engineering dashboard from a ``SimulationResult``."""
14
+ time = result.time
15
+ figure = plt.figure(figsize=(15, 9), constrained_layout=True)
16
+ grid = figure.add_gridspec(2, 3)
17
+
18
+ axis_path = figure.add_subplot(grid[:, 0], projection="3d")
19
+ axis_path.plot(*result.reference_position.T, "--", color="#2ca02c", label="reference")
20
+ axis_path.plot(*result.position.T, color="#1f77b4", label="truth")
21
+ axis_path.plot(*result.estimated_position.T, color="#ff7f0e", alpha=0.7, label="estimate")
22
+ axis_path.set(xlabel="x [m]", ylabel="y [m]", zlabel="z [m]", title="Flight path")
23
+ axis_path.legend(loc="best")
24
+
25
+ axis_position = figure.add_subplot(grid[0, 1])
26
+ labels = ("x", "y", "z")
27
+ for index, label in enumerate(labels):
28
+ axis_position.plot(time, result.position[:, index], label=f"{label} truth")
29
+ axis_position.plot(time, result.reference_position[:, index], "--", alpha=0.65)
30
+ axis_position.set(title="Position tracking", ylabel="position [m]")
31
+ axis_position.grid(alpha=0.25)
32
+ axis_position.legend(ncol=3, fontsize=8)
33
+
34
+ axis_error = figure.add_subplot(grid[0, 2])
35
+ tracking = np.linalg.norm(result.tracking_error, axis=1)
36
+ estimation = np.linalg.norm(result.estimation_error, axis=1)
37
+ axis_error.plot(time, tracking, label="tracking")
38
+ axis_error.plot(time, estimation, label="estimation")
39
+ axis_error.set(title="Position errors", ylabel="norm [m]")
40
+ axis_error.grid(alpha=0.25)
41
+ axis_error.legend()
42
+
43
+ axis_attitude = figure.add_subplot(grid[1, 1])
44
+ euler = np.array([quat_to_euler(value) for value in result.quaternion])
45
+ for index, label in enumerate(("roll", "pitch", "yaw")):
46
+ axis_attitude.plot(time, np.rad2deg(euler[:, index]), label=label)
47
+ axis_attitude.set(title="Attitude", xlabel="time [s]", ylabel="angle [deg]")
48
+ axis_attitude.grid(alpha=0.25)
49
+ axis_attitude.legend(ncol=3, fontsize=8)
50
+
51
+ axis_motor = figure.add_subplot(grid[1, 2])
52
+ axis_motor.plot(time, result.motor_speeds)
53
+ if np.any(result.allocation_saturated):
54
+ axis_motor.fill_between(
55
+ time,
56
+ 0.0,
57
+ np.max(result.motor_speeds),
58
+ where=result.allocation_saturated,
59
+ color="#d62728",
60
+ alpha=0.12,
61
+ label="allocation limited",
62
+ )
63
+ axis_motor.set(
64
+ title="Actuator commands", xlabel="time [s]", ylabel="rotor speed [rad/s]"
65
+ )
66
+ axis_motor.grid(alpha=0.25)
67
+
68
+ metrics = result.summary()
69
+ heading = title or "Drone simulation"
70
+ figure.suptitle(
71
+ f"{heading} | tracking RMSE {metrics['tracking_rmse_m']:.3f} m | "
72
+ f"estimation RMSE {metrics['estimation_rmse_m']:.3f} m"
73
+ )
74
+ return figure