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
@@ -0,0 +1,594 @@
1
+ """Extended Kalman Filter for IMU sensor fusion.
2
+
3
+ Two modes of operation:
4
+ 1. **Full-state EKF** (n=10): position + velocity + quaternion.
5
+ Uses proper analytical Jacobians for accel/mag measurement updates.
6
+ Consolidated from imu_ekf_simulation.py (the most mathematically rigorous version).
7
+
8
+ 2. **AHRS-aided EKF** (n=9): position + velocity + accel_bias.
9
+ Attitude handled by the AHRS complementary filter; EKF estimates translational
10
+ states and accelerometer bias. Supports adaptive noise from innovation monitoring.
11
+ Consolidated from imu_ekf_fusion_enhanced.py.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import deque
17
+ from dataclasses import dataclass, field
18
+
19
+ import numpy as np
20
+ from numpy.typing import NDArray
21
+
22
+ from ..math_utils import (
23
+ quat_angular_velocity_jacobian,
24
+ quat_derivative,
25
+ quat_normalize,
26
+ quat_to_rotation_matrix,
27
+ )
28
+ from ..state import VehicleState
29
+ from .ahrs import AHRS
30
+
31
+
32
+ @dataclass
33
+ class EKFDiagnostics:
34
+ """Innovation statistics and measurement acceptance counts."""
35
+
36
+ accepted: dict[str, int] = field(default_factory=dict)
37
+ rejected: dict[str, int] = field(default_factory=dict)
38
+ last_nis: dict[str, float] = field(default_factory=dict)
39
+
40
+ def record(self, sensor: str, nis: float, accepted: bool) -> None:
41
+ target = self.accepted if accepted else self.rejected
42
+ target[sensor] = target.get(sensor, 0) + 1
43
+ self.last_nis[sensor] = float(nis)
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Full-state EKF (10-state) — from imu_ekf_simulation.py
47
+ # ---------------------------------------------------------------------------
48
+
49
+ class ExtendedKalmanFilter:
50
+ """16-state EKF for quadcopter navigation.
51
+
52
+ State vector x[16]::
53
+
54
+ x[0:3] position [m] ENU world frame (z-up)
55
+ x[3:6] velocity [m/s] ENU world frame
56
+ x[6:10] quaternion [-] Hamilton [w, x, y, z], body→world
57
+ x[10:13] accel_bias [m/s²] body-frame accelerometer bias (random walk)
58
+ x[13:16] gyro_bias [rad/s] body-frame gyroscope bias (random walk)
59
+
60
+ Gravity convention (ENU, z-up)::
61
+
62
+ self.gravity = [0, 0, 9.81] # upward reaction direction
63
+
64
+ At rest : IMU reads Rᵀ @ [0,0,9.81] in body frame (positive body-z)
65
+ Velocity : a_world = R @ f_body - self.gravity
66
+ Correct : expected = Rᵀ @ self.gravity (NOT -Rᵀ @ g)
67
+
68
+ Key identity used throughout::
69
+
70
+ f_body = Rᵀ (a_linear + g_up) # specific force = what IMU measures
71
+ a_linear = R @ f_body - g_up # recover linear accel for integration
72
+
73
+ Backward compatibility:
74
+ ``initial_state`` may be a 10-element vector (legacy). It is padded
75
+ with zeros for the six bias states automatically.
76
+
77
+ Covariance updates use the Joseph form
78
+ ``P = (I-KH) P (I-KH)ᵀ + K R Kᵀ``
79
+ for numerical stability (avoids asymmetric drift in P).
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ dt: float,
85
+ initial_state: NDArray | None = None,
86
+ gravity: NDArray | None = None,
87
+ mag_ref: NDArray | None = None,
88
+ innovation_gate: float | None = 16.27,
89
+ reacquisition_limit: int = 3,
90
+ ):
91
+ self.n = 16
92
+ self.dt = dt
93
+
94
+ # Accept legacy 10-element initial_state and pad with zero biases.
95
+ if initial_state is not None:
96
+ if len(initial_state) == 10:
97
+ s = np.zeros(16)
98
+ s[:10] = initial_state
99
+ self.x = s
100
+ else:
101
+ self.x = initial_state.copy()
102
+ else:
103
+ self.x = np.zeros(self.n)
104
+ self.x[6] = 1.0 # identity quaternion
105
+
106
+ self.gravity = gravity if gravity is not None else np.array([0.0, 0.0, 9.81])
107
+ self.mag_ref = mag_ref if mag_ref is not None else np.array([20.0, 0.0, -40.0])
108
+ self.innovation_gate = innovation_gate
109
+ self.reacquisition_limit = max(int(reacquisition_limit), 1)
110
+ self._rejection_streak: dict[str, int] = {}
111
+ self.diagnostics = EKFDiagnostics()
112
+
113
+ # Covariance
114
+ self.P = np.eye(self.n) * 0.01
115
+ self.P[0:3, 0:3] *= 0.01
116
+ self.P[3:6, 3:6] *= 0.1
117
+ self.P[6:10, 6:10] *= 0.001
118
+ self.P[10:13, 10:13] = np.eye(3) * 0.01 # accel bias init uncertainty
119
+ self.P[13:16, 13:16] = np.eye(3) * 0.0001 # gyro bias init uncertainty
120
+
121
+ # Process noise — diagonal, tuned to sensor noise spectral densities.
122
+ # Bias states model slow random walks; their Q entries are tiny.
123
+ self.Q = np.zeros((self.n, self.n))
124
+ self.Q[0:3, 0:3] = np.eye(3) * 1e-6 # position (driven by velocity)
125
+ self.Q[3:6, 3:6] = np.eye(3) * 0.01 # velocity (accel noise ×dt)
126
+ self.Q[6:10, 6:10] = np.eye(4) * 0.002 # quaternion (gyro noise ×dt)
127
+ self.Q[10:13, 10:13] = np.eye(3) * 2.5e-5 # accel bias random walk
128
+ self.Q[13:16, 13:16] = np.eye(3) * 1e-7 # gyro bias random walk
129
+
130
+ # Measurement noise
131
+ self.R_accel = np.eye(3) * 0.003 # σ≈0.05 m/s² → var≈0.0025
132
+ self.R_mag = np.eye(3) * 0.5
133
+
134
+ def _measurement_update(
135
+ self,
136
+ residual: NDArray,
137
+ H: NDArray,
138
+ measurement_noise: NDArray,
139
+ sensor: str,
140
+ ) -> bool:
141
+ """Apply a gated Joseph-form update and return whether it was accepted."""
142
+ S = H @ self.P @ H.T + measurement_noise
143
+ try:
144
+ solved_residual = np.linalg.solve(S, residual)
145
+ gain = np.linalg.solve(S, H @ self.P).T
146
+ except np.linalg.LinAlgError:
147
+ self.diagnostics.record(sensor, float("inf"), False)
148
+ return False
149
+ nis = float(residual @ solved_residual)
150
+ absolute_sensor = sensor in {"gps", "position", "velocity", "altitude"}
151
+ accepted = self.innovation_gate is None or nis <= self.innovation_gate
152
+ if accepted:
153
+ self._rejection_streak[sensor] = 0
154
+ elif absolute_sensor:
155
+ streak = self._rejection_streak.get(sensor, 0) + 1
156
+ self._rejection_streak[sensor] = streak
157
+ # A gate that can never re-acquire turns a temporary model error
158
+ # into permanent dead reckoning. After several consecutive fixes,
159
+ # treat the stream as a state jump rather than isolated outliers.
160
+ if streak >= self.reacquisition_limit:
161
+ accepted = True
162
+ self._rejection_streak[sensor] = 0
163
+ self.diagnostics.record(sensor, nis, accepted)
164
+ if not accepted:
165
+ # Absolute sensors must be able to re-acquire after a real jump or
166
+ # temporary model mismatch. Inflate only the observed subspace;
167
+ # the state is left untouched, so a one-off outlier is still fully
168
+ # rejected while a later consistent fix can pass the gate.
169
+ if absolute_sensor:
170
+ gate = max(float(self.innovation_gate), 1e-9)
171
+ scale = min(max(nis / gate, 1.0), 100.0)
172
+ self.P += H.T @ measurement_noise @ H * (scale - 1.0)
173
+ self.P = 0.5 * (self.P + self.P.T)
174
+ return False
175
+ self.x += gain @ residual
176
+ self.x[6:10] = quat_normalize(self.x[6:10])
177
+ identity_minus_gain = np.eye(self.n) - gain @ H
178
+ self.P = (
179
+ identity_minus_gain @ self.P @ identity_minus_gain.T
180
+ + gain @ measurement_noise @ gain.T
181
+ )
182
+ self.P = 0.5 * (self.P + self.P.T)
183
+ return True
184
+
185
+ # -- prediction --------------------------------------------------------
186
+
187
+ def predict(self, gyro: NDArray, accel: NDArray | None = None) -> None:
188
+ """Propagate state forward by dt.
189
+
190
+ Args:
191
+ gyro: Body-frame angular velocity (rad/s), 3-vector.
192
+ accel: Body-frame specific force (m/s²), 3-vector.
193
+ When provided, linear acceleration is integrated into velocity
194
+ and position (midpoint scheme: pos += v*dt + ½*a*dt²).
195
+ If None the velocity/position states are held constant.
196
+ """
197
+ pos = self.x[:3]
198
+ vel = self.x[3:6]
199
+ quat = self.x[6:10]
200
+ accel_bias = self.x[10:13]
201
+ gyro_bias = self.x[13:16]
202
+
203
+ # Bias-corrected measurements
204
+ gyro_c = gyro - gyro_bias
205
+ accel_c = (accel - accel_bias) if accel is not None else None
206
+
207
+ # Quaternion kinematics
208
+ q_dot = quat_derivative(quat, gyro_c)
209
+ new_quat = quat_normalize(quat + q_dot * self.dt)
210
+
211
+ # Velocity + position propagation from specific force
212
+ if accel_c is not None:
213
+ R = quat_to_rotation_matrix(quat)
214
+ a_world = R @ accel_c - self.gravity # linear acceleration in world
215
+ new_vel = vel + a_world * self.dt
216
+ # Midpoint scheme: pos += v*dt + ½*a*dt²
217
+ new_pos = pos + vel * self.dt + 0.5 * a_world * self.dt ** 2
218
+ else:
219
+ new_vel = vel
220
+ new_pos = pos + vel * self.dt
221
+
222
+ self.x[:3] = new_pos
223
+ self.x[3:6] = new_vel
224
+ self.x[6:10] = new_quat
225
+ # Bias states integrate as a random walk (no deterministic dynamics)
226
+
227
+ # --- State transition Jacobian F (16×16) ---------------------------
228
+ F = np.eye(self.n)
229
+
230
+ # Position → velocity coupling
231
+ F[0:3, 3:6] = np.eye(3) * self.dt
232
+
233
+ # Quaternion kinematics Jacobian (∂new_q / ∂q)
234
+ F[6:10, 6:10] = np.eye(4) + quat_angular_velocity_jacobian(gyro_c) * self.dt
235
+
236
+ if accel_c is not None:
237
+ R = quat_to_rotation_matrix(quat)
238
+
239
+ # Translational sensitivity to attitude. Omitting this coupling
240
+ # makes GPS innovations over-confident during maneuvering.
241
+ ax, ay, az = accel_c
242
+ w, x, y, z = quat
243
+ accel_rotation_jacobian = np.array([
244
+ [-2*z*ay + 2*y*az,
245
+ 2*y*ay + 2*z*az,
246
+ -4*y*ax + 2*x*ay + 2*w*az,
247
+ -4*z*ax - 2*w*ay + 2*x*az],
248
+ [2*z*ax - 2*x*az,
249
+ 2*y*ax - 4*x*ay - 2*w*az,
250
+ 2*x*ax + 2*z*az,
251
+ 2*w*ax - 4*z*ay + 2*y*az],
252
+ [-2*y*ax + 2*x*ay,
253
+ 2*z*ax + 2*w*ay - 4*x*az,
254
+ -2*w*ax + 2*z*ay - 4*y*az,
255
+ 2*x*ax + 2*y*ay],
256
+ ])
257
+ accel_rotation_jacobian -= np.outer(
258
+ accel_rotation_jacobian @ quat, quat
259
+ )
260
+ F[3:6, 6:10] = accel_rotation_jacobian * self.dt
261
+ F[0:3, 6:10] = accel_rotation_jacobian * (0.5 * self.dt**2)
262
+
263
+ # Velocity sensitivity to accel_bias: ∂vel_new/∂b_a = -R*dt
264
+ F[3:6, 10:13] = -R * self.dt
265
+
266
+ # Position sensitivity to accel_bias (from midpoint term)
267
+ F[0:3, 10:13] = -0.5 * R * self.dt ** 2
268
+
269
+ # Quaternion sensitivity to gyro_bias: ∂new_q/∂b_g = -∂q_dot/∂ω * dt
270
+ # q_dot = 0.5 * quat_multiply(q, [0, ω]), so ∂q_dot/∂ω is the 4×3 matrix Xi(q):
271
+ # Xi(q) = 0.5 * [[-x,-y,-z], [w,-z,y], [z,w,-x], [-y,x,w]]
272
+ w, x, y, z = quat
273
+ Xi = 0.5 * np.array([
274
+ [-x, -y, -z],
275
+ [ w, -z, y],
276
+ [ z, w, -x],
277
+ [-y, x, w],
278
+ ])
279
+ F[6:10, 13:16] = -Xi * self.dt
280
+
281
+ self.P = F @ self.P @ F.T + self.Q
282
+
283
+ # -- accelerometer correction ------------------------------------------
284
+
285
+ def correct_accel(self, accel: NDArray) -> bool:
286
+ """Attitude correction from accelerometer (gravity direction).
287
+
288
+ Only corrects quaternion — velocity is propagated in predict() from
289
+ the same accel measurement. The attitude update columns of H are
290
+ non-zero; position/velocity/bias columns are zero.
291
+
292
+ This pseudo-measurement assumes negligible translational acceleration.
293
+ Do not apply it continuously during aggressive flight; use it during
294
+ detected quasi-static periods or rely on gyro/magnetometer propagation.
295
+ """
296
+ quat = self.x[6:10]
297
+ accel_bias = self.x[10:13]
298
+ R = quat_to_rotation_matrix(quat)
299
+ expected = R.T @ self.gravity
300
+ residual = (accel - accel_bias) - expected
301
+
302
+ H = self._accel_jacobian(quat)
303
+ return self._measurement_update(residual, H, self.R_accel, "accel")
304
+
305
+ # -- magnetometer correction -------------------------------------------
306
+
307
+ def correct_mag(self, mag: NDArray) -> bool:
308
+ quat = self.x[6:10]
309
+ R = quat_to_rotation_matrix(quat)
310
+ expected = R.T @ self.mag_ref
311
+ residual = mag - expected # mag has its own fixed bias; no state bias term
312
+
313
+ H = self._mag_jacobian(quat)
314
+ return self._measurement_update(residual, H, self.R_mag, "mag")
315
+
316
+ # -- GPS / barometer position correction -------------------------------
317
+
318
+ def correct_position(
319
+ self, pos_meas: NDArray, R_pos: NDArray | None = None
320
+ ) -> bool:
321
+ """Full 3-D GPS position update. pos_meas shape: (3,)."""
322
+ m = len(pos_meas)
323
+ if R_pos is None:
324
+ R_pos = np.eye(m) * 0.1
325
+ H = np.zeros((m, self.n))
326
+ H[:m, :m] = np.eye(m)
327
+ residual = pos_meas - self.x[:m]
328
+ return self._measurement_update(residual, H, R_pos, "position")
329
+
330
+ def correct_velocity(
331
+ self, vel_meas: NDArray, R_vel: NDArray | None = None
332
+ ) -> bool:
333
+ """3-D velocity update from GPS Doppler / DVL. vel_meas shape: (3,)."""
334
+ if R_vel is None:
335
+ R_vel = np.eye(3) * 0.01 # GPS vel std~0.1 m/s → var=0.01
336
+ H = np.zeros((3, self.n))
337
+ H[0:3, 3:6] = np.eye(3)
338
+ residual = vel_meas - self.x[3:6]
339
+ return self._measurement_update(residual, H, R_vel, "velocity")
340
+
341
+ def correct_gps(
342
+ self,
343
+ position: NDArray,
344
+ velocity: NDArray,
345
+ covariance: NDArray | None = None,
346
+ ) -> bool:
347
+ """Joint position/velocity GNSS update with one consistency gate."""
348
+ measurement = np.concatenate([position, velocity])
349
+ H = np.zeros((6, self.n))
350
+ H[:3, :3] = np.eye(3)
351
+ H[3:, 3:6] = np.eye(3)
352
+ noise = np.diag([0.25, 0.25, 0.25, 0.01, 0.01, 0.01])
353
+ if covariance is not None:
354
+ noise = np.asarray(covariance, dtype=float)
355
+ if noise.shape != (6, 6):
356
+ raise ValueError("GPS covariance must have shape (6, 6)")
357
+ residual = measurement - self.x[:6]
358
+ return self._measurement_update(residual, H, noise, "gps")
359
+
360
+ def correct_altitude(self, z_meas: float, r_z: float = 0.05) -> bool:
361
+ """1-D barometer altitude update. Anchors vertical dead-reckoning."""
362
+ H = np.zeros((1, self.n))
363
+ H[0, 2] = 1.0
364
+ residual = np.array([z_meas - self.x[2]])
365
+ R_z = np.array([[r_z]])
366
+ return self._measurement_update(residual, H, R_z, "altitude")
367
+
368
+ # -- state access ------------------------------------------------------
369
+
370
+ def get_state(self) -> dict:
371
+ return {
372
+ "position": self.x[:3].copy(),
373
+ "velocity": self.x[3:6].copy(),
374
+ "quaternion": self.x[6:10].copy(),
375
+ "accel_bias": self.x[10:13].copy(),
376
+ "gyro_bias": self.x[13:16].copy(),
377
+ }
378
+
379
+ def get_vehicle_state(
380
+ self, body_rates: NDArray | None = None, *, time: float = 0.0
381
+ ) -> VehicleState:
382
+ """Return the estimate through the package-wide typed state API."""
383
+ rates = np.zeros(3) if body_rates is None else body_rates
384
+ return VehicleState(
385
+ position=self.x[:3],
386
+ velocity=self.x[3:6],
387
+ quaternion=self.x[6:10],
388
+ body_rates=rates,
389
+ time=time,
390
+ )
391
+
392
+ # -- Jacobians (analytical) --------------------------------------------
393
+
394
+ def _accel_jacobian(self, q: NDArray) -> NDArray:
395
+ """Analytical Jacobian d(R(q/|q|)^T @ g)/dq for the accelerometer measurement model.
396
+
397
+ Derived from the Hamilton quaternion rotation matrix::
398
+
399
+ R[w,x,y,z] (body→world), so R^T maps world→body.
400
+
401
+ Because quat_to_rotation_matrix() normalizes q internally, the true
402
+ Jacobian w.r.t. the raw quaternion parameters is the tangent-space
403
+ projection of the algebraic derivative::
404
+
405
+ H_proj[:, 6:10] = H_raw[:, 6:10] - outer(H_raw[:, 6:10] @ q, q)
406
+
407
+ This removes the radial (along-q) component that normalization kills.
408
+ State layout (16-state): indices 6-9 = [qw, qx, qy, qz]; bias columns are zero.
409
+ """
410
+ w, x, y, z = q
411
+ gx, gy, gz = self.gravity
412
+ H = np.zeros((3, self.n))
413
+
414
+ # Row 0: d(R^T @ g)[0] / d[w, x, y, z] (unnormalized)
415
+ H[0, 6] = 2 * z * gy - 2 * y * gz
416
+ H[0, 7] = 2 * y * gy + 2 * z * gz
417
+ H[0, 8] = -4 * y * gx + 2 * x * gy - 2 * w * gz
418
+ H[0, 9] = -4 * z * gx + 2 * w * gy + 2 * x * gz
419
+
420
+ # Row 1: d(R^T @ g)[1] / d[w, x, y, z]
421
+ H[1, 6] = -2 * z * gx + 2 * x * gz
422
+ H[1, 7] = 2 * y * gx - 4 * x * gy + 2 * w * gz
423
+ H[1, 8] = 2 * x * gx + 2 * z * gz
424
+ H[1, 9] = -2 * w * gx - 4 * z * gy + 2 * y * gz
425
+
426
+ # Row 2: d(R^T @ g)[2] / d[w, x, y, z]
427
+ H[2, 6] = 2 * y * gx - 2 * x * gy
428
+ H[2, 7] = 2 * z * gx - 2 * w * gy - 4 * x * gz
429
+ H[2, 8] = 2 * w * gx + 2 * z * gy - 4 * y * gz
430
+ H[2, 9] = 2 * x * gx + 2 * y * gy
431
+
432
+ # Tangent-space projection: remove the component along q (radial direction)
433
+ Hq = H[:, 6:10] # (3, 4)
434
+ H[:, 6:10] = Hq - np.outer(Hq @ q, q)
435
+
436
+ return H
437
+
438
+ def _mag_jacobian(self, q: NDArray) -> NDArray:
439
+ """d(R(q/|q|)^T m)/dq — analytical Jacobian of magnetometer measurement model.
440
+
441
+ Identical structure to _accel_jacobian: both compute d(R^T v)/dq for a
442
+ constant reference vector v (gravity vs. mag_ref).
443
+ Includes tangent-space projection (see _accel_jacobian docstring).
444
+ State layout (16-state): indices 6-9 = [qw, qx, qy, qz]; bias columns are zero.
445
+ """
446
+ w, x, y, z = q
447
+ mx, my, mz = self.mag_ref
448
+ H = np.zeros((3, self.n))
449
+
450
+ # Row 0: d(R^T m)[0] / d[w, x, y, z]
451
+ H[0, 6] = 2 * z * my - 2 * y * mz
452
+ H[0, 7] = 2 * y * my + 2 * z * mz
453
+ H[0, 8] = -4 * y * mx + 2 * x * my - 2 * w * mz
454
+ H[0, 9] = -4 * z * mx + 2 * w * my + 2 * x * mz
455
+
456
+ # Row 1: d(R^T m)[1] / d[w, x, y, z]
457
+ H[1, 6] = -2 * z * mx + 2 * x * mz
458
+ H[1, 7] = 2 * y * mx - 4 * x * my + 2 * w * mz
459
+ H[1, 8] = 2 * x * mx + 2 * z * mz
460
+ H[1, 9] = -2 * w * mx - 4 * z * my + 2 * y * mz
461
+
462
+ # Row 2: d(R^T m)[2] / d[w, x, y, z]
463
+ H[2, 6] = 2 * y * mx - 2 * x * my
464
+ H[2, 7] = 2 * z * mx - 2 * w * my - 4 * x * mz
465
+ H[2, 8] = 2 * w * mx + 2 * z * my - 4 * y * mz
466
+ H[2, 9] = 2 * x * mx + 2 * y * my
467
+
468
+ # Tangent-space projection
469
+ Hq = H[:, 6:10]
470
+ H[:, 6:10] = Hq - np.outer(Hq @ q, q)
471
+
472
+ return H
473
+
474
+
475
+ # ---------------------------------------------------------------------------
476
+ # AHRS-aided adaptive EKF (9-state) — from imu_ekf_fusion_enhanced.py
477
+ # ---------------------------------------------------------------------------
478
+
479
+ class AdaptiveEKF:
480
+ """9-state EKF: [pos(3), vel(3), accel_bias(3)].
481
+
482
+ Attitude is estimated separately by an AHRS complementary filter.
483
+ Process and measurement noise are adapted based on innovation statistics.
484
+ """
485
+
486
+ def __init__(
487
+ self,
488
+ dt: float,
489
+ init_pos: NDArray | None = None,
490
+ init_vel: NDArray | None = None,
491
+ innovation_window: int = 20,
492
+ gravity: NDArray | None = None,
493
+ mag_ref: NDArray | None = None,
494
+ ):
495
+ self.n = 9
496
+ self.dt = dt
497
+
498
+ self.x = np.zeros(self.n)
499
+ if init_pos is not None:
500
+ self.x[:3] = init_pos
501
+ if init_vel is not None:
502
+ self.x[3:6] = init_vel
503
+
504
+ self.gravity = gravity if gravity is not None else np.array([0.0, 0.0, 9.81])
505
+
506
+ self.P = np.eye(self.n)
507
+ self.P[:3, :3] *= 0.01
508
+ self.P[3:6, 3:6] *= 0.1
509
+ self.P[6:9, 6:9] *= 0.01
510
+
511
+ self.base_Q = np.eye(self.n)
512
+ self.base_Q[:3, :3] *= 0.01
513
+ self.base_Q[3:6, 3:6] *= 0.1
514
+ self.base_Q[6:9, 6:9] *= 0.001
515
+ self.Q = self.base_Q.copy()
516
+
517
+ self.base_R_accel = np.eye(3) * 0.1
518
+ self.R_accel = self.base_R_accel.copy()
519
+
520
+ self._innovations: deque[NDArray] = deque(maxlen=innovation_window)
521
+
522
+ self.ahrs = AHRS(
523
+ dt,
524
+ accel_weight=0.02,
525
+ mag_weight=0.01,
526
+ gravity=self.gravity,
527
+ mag_ref=mag_ref,
528
+ )
529
+ self.orientation = np.array([1.0, 0.0, 0.0, 0.0])
530
+ self.gyro_bias = np.zeros(3)
531
+
532
+ # -- prediction --------------------------------------------------------
533
+
534
+ def predict(self, gyro: NDArray, accel: NDArray, mag: NDArray, adaptive_factor: float = 1.0) -> None:
535
+ self.orientation, self.gyro_bias = self.ahrs.update(gyro, accel, mag)
536
+ R = quat_to_rotation_matrix(self.orientation)
537
+
538
+ accel_bias = self.x[6:9]
539
+ accel_world = R @ (accel - accel_bias) - self.gravity
540
+
541
+ pos, vel = self.x[:3], self.x[3:6]
542
+ self.x[:3] = pos + vel * self.dt + 0.5 * accel_world * self.dt**2
543
+ self.x[3:6] = vel + accel_world * self.dt
544
+
545
+ F = np.eye(self.n)
546
+ F[:3, 3:6] = np.eye(3) * self.dt
547
+ F[3:6, 6:9] = -R * self.dt
548
+
549
+ self.Q = self.base_Q * adaptive_factor
550
+ self.P = F @ self.P @ F.T + self.Q
551
+
552
+ # -- measurement update ------------------------------------------------
553
+
554
+ def correct(self, accel: NDArray, adaptive_factor: float = 1.0) -> None:
555
+ R = quat_to_rotation_matrix(self.orientation)
556
+ accel_bias = self.x[6:9]
557
+ expected = R.T @ self.gravity + accel_bias
558
+ innovation = accel - expected
559
+
560
+ self._innovations.append(innovation)
561
+
562
+ # Adaptive measurement noise
563
+ if len(self._innovations) >= self._innovations.maxlen:
564
+ cov = np.zeros((3, 3))
565
+ for inn in self._innovations:
566
+ cov += np.outer(inn, inn)
567
+ cov /= len(self._innovations)
568
+ self.R_accel = (self.base_R_accel + np.diag(np.diag(cov))) * adaptive_factor
569
+ else:
570
+ self.R_accel = self.base_R_accel * adaptive_factor
571
+
572
+ H = np.zeros((3, self.n))
573
+ H[:, 6:9] = np.eye(3)
574
+
575
+ S = H @ self.P @ H.T + self.R_accel
576
+ K = self.P @ H.T @ np.linalg.inv(S)
577
+
578
+ self.x += K @ innovation
579
+
580
+ # Joseph form for numerical stability
581
+ I_mat = np.eye(self.n)
582
+ IKH = I_mat - K @ H
583
+ self.P = IKH @ self.P @ IKH.T + K @ self.R_accel @ K.T
584
+
585
+ # -- state access ------------------------------------------------------
586
+
587
+ def get_state(self) -> dict:
588
+ return {
589
+ "position": self.x[:3].copy(),
590
+ "velocity": self.x[3:6].copy(),
591
+ "accel_bias": self.x[6:9].copy(),
592
+ "quaternion": self.orientation.copy(),
593
+ "gyro_bias": self.gyro_bias.copy(),
594
+ }
@@ -0,0 +1,13 @@
1
+ """Telemetry loggers for persisting simulation state to disk.
2
+
3
+ Loggers implement a common interface::
4
+
5
+ logger.log(t, state, estimate=None, motors=None)
6
+ logger.save()
7
+
8
+ so the simulation loop can write to disk without knowing which format
9
+ (CSV, JSON Lines, …) is active.
10
+ """
11
+
12
+ from .csv_logger import CsvLogger # noqa: F401
13
+ from .json_logger import JsonLogger # noqa: F401
@@ -0,0 +1,76 @@
1
+ """CSV telemetry logger."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+
7
+ import numpy as np
8
+
9
+
10
+ class CsvLogger:
11
+ """Append simulation state rows to a CSV file.
12
+
13
+ Usage::
14
+
15
+ logger = CsvLogger("flight.csv")
16
+ for ...:
17
+ logger.log(t, quad.get_position(), motors=motors)
18
+ logger.close()
19
+
20
+ The header is written on the first ``log()`` call so the file is
21
+ self-documenting.
22
+ """
23
+
24
+ HEADER = [
25
+ "t", "x", "y", "z", "vx", "vy", "vz",
26
+ "qw", "qx", "qy", "qz", "p", "q", "r",
27
+ "m1", "m2", "m3", "m4",
28
+ "est_x", "est_y", "est_z",
29
+ ]
30
+
31
+ def __init__(self, path: str) -> None:
32
+ self._path = path
33
+ self._file = open(path, "w", newline="", encoding="utf-8") # noqa: SIM115
34
+ self._writer = csv.writer(self._file)
35
+ self._header_written = False
36
+
37
+ def log(
38
+ self,
39
+ t: float,
40
+ state: np.ndarray,
41
+ estimate: np.ndarray | None = None,
42
+ motors: np.ndarray | None = None,
43
+ ) -> None:
44
+ """Append one row.
45
+
46
+ *state* must be the 13-element dynamics state (see
47
+ ``QuadcopterDynamics.state``). *estimate* (if given) is a 3-element
48
+ estimated position; *motors* (if given) is a 4-element motor-speed array.
49
+ """
50
+ if not self._header_written:
51
+ self._writer.writerow(self.HEADER)
52
+ self._header_written = True
53
+
54
+ qw, qx, qy, qz = state[6:10]
55
+ p, q, r = state[10:13]
56
+
57
+ row = [
58
+ t,
59
+ state[0], state[1], state[2], # pos
60
+ state[3], state[4], state[5], # vel
61
+ qw, qx, qy, qz, # quat
62
+ p, q, r, # omega
63
+ *(motors if motors is not None else [0, 0, 0, 0]),
64
+ *(estimate if estimate is not None else [np.nan, np.nan, np.nan]),
65
+ ]
66
+ self._writer.writerow(row)
67
+
68
+ def __enter__(self):
69
+ return self
70
+
71
+ def __exit__(self, *args):
72
+ self.close()
73
+
74
+ def close(self) -> None:
75
+ if not self._file.closed:
76
+ self._file.close()