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.
- drones_sim/__init__.py +15 -0
- drones_sim/control/__init__.py +15 -0
- drones_sim/control/allocation.py +76 -0
- drones_sim/control/cascaded.py +204 -0
- drones_sim/control/geometric.py +170 -0
- drones_sim/control/lqr.py +174 -0
- drones_sim/control/pid.py +50 -0
- drones_sim/dynamics/__init__.py +13 -0
- drones_sim/dynamics/config.py +117 -0
- drones_sim/dynamics/disturbances.py +262 -0
- drones_sim/dynamics/quadcopter.py +317 -0
- drones_sim/estimation/__init__.py +2 -0
- drones_sim/estimation/ahrs.py +79 -0
- drones_sim/estimation/ekf.py +594 -0
- drones_sim/logging/__init__.py +13 -0
- drones_sim/logging/csv_logger.py +76 -0
- drones_sim/logging/json_logger.py +53 -0
- drones_sim/math_utils.py +119 -0
- drones_sim/models/__init__.py +21 -0
- drones_sim/models/quadcopter.urdf +296 -0
- drones_sim/models/urdf_loader.py +444 -0
- drones_sim/rl/__init__.py +16 -0
- drones_sim/rl/actions.py +219 -0
- drones_sim/rl/env.py +195 -0
- drones_sim/rl/observations.py +40 -0
- drones_sim/rl/reward.py +69 -0
- drones_sim/rl/tasks.py +73 -0
- drones_sim/sensors/__init__.py +3 -0
- drones_sim/sensors/gps.py +158 -0
- drones_sim/sensors/imu.py +196 -0
- drones_sim/sensors/models.py +113 -0
- drones_sim/simulation.py +283 -0
- drones_sim/state.py +133 -0
- drones_sim/trajectory.py +391 -0
- drones_sim/visualization/__init__.py +23 -0
- drones_sim/visualization/api.py +50 -0
- drones_sim/visualization/dashboard.py +74 -0
- drones_sim/visualization/plots.py +183 -0
- drones_sim/visualization/rerun_viewer.py +411 -0
- drones_sim/visualization/viewer.py +452 -0
- drones_sim-0.2.0.dist-info/METADATA +323 -0
- drones_sim-0.2.0.dist-info/RECORD +45 -0
- drones_sim-0.2.0.dist-info/WHEEL +5 -0
- drones_sim-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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()
|