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,317 @@
1
+ """Nonlinear six-degree-of-freedom quadcopter rigid-body dynamics.
2
+
3
+ Frames use ENU world coordinates (z up) and a front-right-up body frame.
4
+ Quaternions are Hamilton ``[w, x, y, z]`` and rotate body vectors to world.
5
+ The numerical state remains a 13-vector for compatibility::
6
+
7
+ [position(3), velocity(3), quaternion(4), body_rates(3)]
8
+
9
+ The plant models bounded first-order actuators, arbitrary rigid-body rotation,
10
+ anisotropic linear/quadratic air drag, rotor failures, and external wrenches.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import numpy as np
16
+ from numpy.typing import NDArray
17
+
18
+ from ..math_utils import (
19
+ quat_derivative,
20
+ quat_from_euler,
21
+ quat_normalize,
22
+ quat_to_euler,
23
+ quat_to_rotation_matrix,
24
+ )
25
+ from ..state import VehicleState
26
+ from .config import QuadcopterConfig
27
+
28
+
29
+ class QuadcopterDynamics:
30
+ """A validated 13-state quaternion quadcopter model.
31
+
32
+ Legacy scalar constructor arguments are retained. New code should prefer a
33
+ :class:`QuadcopterConfig`, which makes units and actuator limits explicit.
34
+ """
35
+
36
+ STATE_SIZE = 13
37
+
38
+ def __init__(
39
+ self,
40
+ mass: float = 1.0,
41
+ arm_length: float = 0.2,
42
+ inertia: NDArray | None = None,
43
+ k_f: float = 1.0e-6,
44
+ k_m: float = 1.0e-7,
45
+ k_d: float | NDArray = 0.1,
46
+ g: float = 9.81,
47
+ motor_time_constant: float = 0.0,
48
+ disturbances: list | None = None,
49
+ *,
50
+ config: QuadcopterConfig | None = None,
51
+ quadratic_drag: float | NDArray = 0.0,
52
+ max_motor_speed: float = 4000.0,
53
+ ) -> None:
54
+ cfg = config or QuadcopterConfig(
55
+ mass=mass,
56
+ arm_length=arm_length,
57
+ inertia=np.diag([0.01, 0.01, 0.018]) if inertia is None else inertia,
58
+ thrust_coefficient=k_f,
59
+ moment_coefficient=k_m,
60
+ linear_drag=k_d,
61
+ quadratic_drag=quadratic_drag,
62
+ gravity=g,
63
+ motor_time_constant=motor_time_constant,
64
+ max_motor_speed=max_motor_speed,
65
+ )
66
+ self.config = cfg
67
+
68
+ # Public aliases preserve the long-standing API used by examples/RL.
69
+ self.mass = cfg.mass
70
+ self.arm_length = cfg.arm_length
71
+ self.I = cfg.inertia.copy()
72
+ self.k_f = cfg.thrust_coefficient
73
+ self.k_m = cfg.moment_coefficient
74
+ self.k_d = float(cfg.linear_drag[0]) if np.allclose(
75
+ cfg.linear_drag, cfg.linear_drag[0]
76
+ ) else cfg.linear_drag.copy()
77
+ self.g = cfg.gravity
78
+ self.motor_time_constant = cfg.motor_time_constant
79
+ self.min_motor_speed = cfg.min_motor_speed
80
+ self.max_motor_speed = cfg.max_motor_speed
81
+ self.disturbances = list(disturbances or [])
82
+
83
+ self.state = np.zeros(self.STATE_SIZE)
84
+ self.state[6] = 1.0
85
+ self.motor_states = np.zeros(4)
86
+ self.last_acceleration = np.zeros(3)
87
+ self.last_angular_acceleration = np.zeros(3)
88
+ self.last_wrench = np.zeros(4)
89
+ self._sim_time = 0.0
90
+ self._step_wind = np.zeros(3)
91
+ self._rotor_efficiencies = np.ones(4)
92
+ self._ground_effect_multiplier = 1.0
93
+
94
+ def reset(
95
+ self,
96
+ position: NDArray | None = None,
97
+ attitude: NDArray | None = None,
98
+ *,
99
+ state: VehicleState | None = None,
100
+ ) -> None:
101
+ """Reset the plant, actuators, clock, and disturbance episode state."""
102
+ if state is not None and (position is not None or attitude is not None):
103
+ raise ValueError("provide either state or position/attitude, not both")
104
+ if state is None:
105
+ self.state = np.zeros(self.STATE_SIZE)
106
+ self.state[6] = 1.0
107
+ if position is not None:
108
+ value = np.asarray(position, dtype=float)
109
+ if value.shape != (3,):
110
+ raise ValueError("position must have shape (3,)")
111
+ self.state[:3] = value
112
+ if attitude is not None:
113
+ value = np.asarray(attitude, dtype=float)
114
+ if value.shape != (3,):
115
+ raise ValueError("attitude must have shape (3,)")
116
+ self.state[6:10] = quat_from_euler(*value)
117
+ self.motor_states = np.zeros(4)
118
+ else:
119
+ self.state = state.as_vector()
120
+ self.motor_states = state.motor_speeds.copy()
121
+ self._sim_time = 0.0
122
+ self.last_acceleration.fill(0.0)
123
+ self.last_angular_acceleration.fill(0.0)
124
+ self.last_wrench.fill(0.0)
125
+ for disturbance in self.disturbances:
126
+ disturbance.reset()
127
+ disturbance.modify_dynamics(self, 0.0)
128
+ self._prepare_environment(0.0, 0.0)
129
+
130
+ # -- state accessors -------------------------------------------------
131
+
132
+ def get_state(self) -> VehicleState:
133
+ return VehicleState.from_vector(
134
+ self.state, motor_speeds=self.motor_states, time=self._sim_time
135
+ )
136
+
137
+ def get_position(self) -> NDArray:
138
+ return self.state[:3].copy()
139
+
140
+ def get_velocity(self) -> NDArray:
141
+ return self.state[3:6].copy()
142
+
143
+ def get_attitude(self) -> NDArray:
144
+ return quat_to_euler(self.state[6:10])
145
+
146
+ def get_quaternion(self) -> NDArray:
147
+ return self.state[6:10].copy()
148
+
149
+ def get_angular_velocity(self) -> NDArray:
150
+ return self.state[10:13].copy()
151
+
152
+ def get_motor_speeds(self) -> NDArray:
153
+ return self.motor_states.copy()
154
+
155
+ def rotation_matrix(self) -> NDArray:
156
+ return quat_to_rotation_matrix(self.state[6:10])
157
+
158
+ def specific_force_body(self) -> NDArray:
159
+ """Ideal accelerometer specific force at the current state [m/s²]."""
160
+ gravity_up = np.array([0.0, 0.0, self.g])
161
+ return self.rotation_matrix().T @ (self.last_acceleration + gravity_up)
162
+
163
+ # -- forces and integration -----------------------------------------
164
+
165
+ @property
166
+ def _linear_drag(self) -> NDArray:
167
+ value = np.asarray(self.k_d, dtype=float)
168
+ return np.full(3, float(value)) if value.ndim == 0 else value
169
+
170
+ def _prepare_environment(self, t: float, dt: float) -> None:
171
+ self._step_wind = np.zeros(3)
172
+ self._rotor_efficiencies = np.ones(4)
173
+ self._ground_effect_multiplier = 1.0
174
+ for disturbance in self.disturbances:
175
+ advance = getattr(disturbance, "advance", None)
176
+ if advance is not None:
177
+ advance(t, dt, self.state)
178
+ wind_velocity = getattr(disturbance, "wind_velocity", None)
179
+ if wind_velocity is not None:
180
+ self._step_wind += np.asarray(wind_velocity(t), dtype=float)
181
+ rotor_efficiencies = getattr(disturbance, "rotor_efficiencies", None)
182
+ if rotor_efficiencies is not None:
183
+ self._rotor_efficiencies *= np.asarray(
184
+ rotor_efficiencies(t), dtype=float
185
+ )
186
+ thrust_multiplier = getattr(disturbance, "thrust_multiplier", None)
187
+ if thrust_multiplier is not None:
188
+ self._ground_effect_multiplier *= float(
189
+ thrust_multiplier(self.state[2])
190
+ )
191
+
192
+ def _wrench(self, motor_speeds: NDArray) -> NDArray:
193
+ allocation = self.allocation_matrix(self._rotor_efficiencies)
194
+ wrench = allocation @ np.square(motor_speeds)
195
+ wrench[0] *= self._ground_effect_multiplier
196
+ return wrench
197
+
198
+ def _derivatives(
199
+ self,
200
+ state: NDArray,
201
+ motor_speeds: NDArray,
202
+ *,
203
+ t: float | None = None,
204
+ dt: float = 0.01,
205
+ ) -> NDArray:
206
+ wrench = self._wrench(motor_speeds)
207
+ thrust = wrench[0]
208
+ torque = wrench[1:4]
209
+ velocity = state[3:6]
210
+ quaternion = state[6:10]
211
+ body_rates = state[10:13]
212
+ rotation = quat_to_rotation_matrix(quaternion)
213
+
214
+ air_velocity_body = rotation.T @ (velocity - self._step_wind)
215
+ drag_body = (
216
+ -self._linear_drag * air_velocity_body
217
+ - self.config.quadratic_drag
218
+ * np.abs(air_velocity_body)
219
+ * air_velocity_body
220
+ )
221
+ thrust_world = rotation @ np.array([0.0, 0.0, thrust])
222
+ drag_world = rotation @ drag_body
223
+ gravity_world = np.array([0.0, 0.0, -self.mass * self.g])
224
+ total_force = thrust_world + drag_world + gravity_world
225
+ total_torque = torque.copy()
226
+
227
+ eval_time = self._sim_time if t is None else t
228
+ for disturbance in self.disturbances:
229
+ # Wind disturbances are already represented by relative airspeed.
230
+ if not hasattr(disturbance, "wind_velocity"):
231
+ total_force += disturbance.external_force(eval_time, dt, state)
232
+ total_torque += disturbance.external_torque(eval_time, dt, state)
233
+
234
+ acceleration = total_force / self.mass
235
+ angular_acceleration = np.linalg.solve(
236
+ self.I,
237
+ total_torque - np.cross(body_rates, self.I @ body_rates),
238
+ )
239
+ derivative = np.zeros(self.STATE_SIZE)
240
+ derivative[:3] = velocity
241
+ derivative[3:6] = acceleration
242
+ derivative[6:10] = quat_derivative(quaternion, body_rates)
243
+ derivative[10:13] = angular_acceleration
244
+ return derivative
245
+
246
+ def update(self, dt: float, motor_speeds: NDArray) -> NDArray:
247
+ """Advance one fixed step with RK4 and an exact motor-lag update."""
248
+ if not np.isfinite(dt) or dt <= 0.0:
249
+ raise ValueError("dt must be a finite positive number")
250
+ commands = np.asarray(motor_speeds, dtype=float)
251
+ if commands.shape != (4,) or not np.all(np.isfinite(commands)):
252
+ raise ValueError("motor_speeds must be a finite shape-(4,) vector")
253
+ commands = np.clip(commands, self.min_motor_speed, self.max_motor_speed)
254
+
255
+ if self.motor_time_constant > 0.0:
256
+ alpha = 1.0 - np.exp(-dt / self.motor_time_constant)
257
+ self.motor_states += alpha * (commands - self.motor_states)
258
+ else:
259
+ self.motor_states = commands.copy()
260
+ self.motor_states = np.clip(
261
+ self.motor_states, self.min_motor_speed, self.max_motor_speed
262
+ )
263
+
264
+ for disturbance in self.disturbances:
265
+ disturbance.modify_dynamics(self, self._sim_time)
266
+ self._prepare_environment(self._sim_time, dt)
267
+
268
+ t0 = self._sim_time
269
+ motors = self.motor_states
270
+ k1 = self._derivatives(self.state, motors, t=t0, dt=dt)
271
+ k2 = self._derivatives(
272
+ self.state + 0.5 * dt * k1, motors, t=t0 + 0.5 * dt, dt=dt
273
+ )
274
+ k3 = self._derivatives(
275
+ self.state + 0.5 * dt * k2, motors, t=t0 + 0.5 * dt, dt=dt
276
+ )
277
+ k4 = self._derivatives(self.state + dt * k3, motors, t=t0 + dt, dt=dt)
278
+ self.state += dt / 6.0 * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
279
+ self.state[6:10] = quat_normalize(self.state[6:10])
280
+ self._sim_time += dt
281
+
282
+ final_derivative = self._derivatives(
283
+ self.state, motors, t=self._sim_time, dt=dt
284
+ )
285
+ self.last_acceleration = final_derivative[3:6].copy()
286
+ self.last_angular_acceleration = final_derivative[10:13].copy()
287
+ wrench = self._wrench(motors)
288
+ self.last_wrench = wrench.copy()
289
+ if not np.all(np.isfinite(self.state)):
290
+ raise FloatingPointError(
291
+ f"quadcopter state became non-finite at t={self._sim_time:.6f}s"
292
+ )
293
+ return self.state.copy()
294
+
295
+ # -- actuator model --------------------------------------------------
296
+
297
+ def allocation_matrix(
298
+ self, efficiencies: NDArray | None = None
299
+ ) -> NDArray:
300
+ """Map squared motor speeds to collective thrust and body torque."""
301
+ positions = self.config.rotor_positions
302
+ efficiency = (
303
+ np.ones(4)
304
+ if efficiencies is None
305
+ else np.asarray(efficiencies, dtype=float)
306
+ )
307
+ if efficiency.shape != (4,):
308
+ raise ValueError("efficiencies must have shape (4,)")
309
+ force_gain = self.k_f * efficiency
310
+ return np.vstack(
311
+ [
312
+ force_gain,
313
+ positions[:, 1] * force_gain,
314
+ -positions[:, 0] * force_gain,
315
+ self.config.rotor_directions * self.k_m * efficiency,
316
+ ]
317
+ )
@@ -0,0 +1,2 @@
1
+ from .ahrs import AHRS # noqa: F401
2
+ from .ekf import EKFDiagnostics, ExtendedKalmanFilter # noqa: F401
@@ -0,0 +1,79 @@
1
+ """Attitude and Heading Reference System (AHRS) using complementary filtering.
2
+
3
+ Fuses accelerometer, gyroscope, and magnetometer data for attitude estimation.
4
+ Consolidated from imu_ekf_fusion_enhanced.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+ from numpy.typing import NDArray
11
+
12
+ from ..math_utils import quat_normalize, quat_to_rotation_matrix
13
+
14
+
15
+ class AHRS:
16
+ """Complementary-filter AHRS with gyro bias learning.
17
+
18
+ Uses accelerometer and magnetometer error feedback to correct
19
+ gyroscope-integrated orientation.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ dt: float,
25
+ accel_weight: float = 0.02,
26
+ mag_weight: float = 0.01,
27
+ bias_learn_rate: float = 0.001,
28
+ gravity: NDArray | None = None,
29
+ mag_ref: NDArray | None = None,
30
+ ):
31
+ self.dt = dt
32
+ self.accel_weight = accel_weight
33
+ self.mag_weight = mag_weight
34
+ self.bias_learn_rate = bias_learn_rate
35
+
36
+ self.gravity = gravity if gravity is not None else np.array([0.0, 0.0, 9.81])
37
+ self.mag_ref = mag_ref if mag_ref is not None else np.array([25.0, 5.0, -40.0])
38
+
39
+ self.quaternion = np.array([1.0, 0.0, 0.0, 0.0])
40
+ self.gyro_bias = np.zeros(3)
41
+
42
+ def _normalize(self, v: NDArray) -> NDArray:
43
+ n = np.linalg.norm(v)
44
+ return v / n if n > 0 else v
45
+
46
+ def update(self, gyro: NDArray, accel: NDArray, mag: NDArray) -> tuple[NDArray, NDArray]:
47
+ """Process one time-step of sensor data.
48
+
49
+ Returns (quaternion, gyro_bias).
50
+ """
51
+ gyro_corrected = gyro - self.gyro_bias
52
+
53
+ accel_norm = self._normalize(accel)
54
+ mag_norm = self._normalize(mag)
55
+
56
+ R = quat_to_rotation_matrix(self.quaternion)
57
+
58
+ expected_gravity = R.T @ self._normalize(self.gravity)
59
+ expected_mag = R.T @ self._normalize(self.mag_ref)
60
+
61
+ accel_error = np.cross(accel_norm, expected_gravity)
62
+ mag_error = np.cross(mag_norm, expected_mag)
63
+
64
+ error = accel_error * self.accel_weight + mag_error * self.mag_weight
65
+ self.gyro_bias += error * self.bias_learn_rate
66
+ gyro_corrected = gyro_corrected + error
67
+
68
+ w, x, y, z = self.quaternion
69
+ wx, wy, wz = gyro_corrected
70
+
71
+ q_dot = 0.5 * np.array([
72
+ -x * wx - y * wy - z * wz,
73
+ w * wx + y * wz - z * wy,
74
+ w * wy + z * wx - x * wz,
75
+ w * wz + x * wy - y * wx,
76
+ ])
77
+
78
+ self.quaternion = quat_normalize(self.quaternion + q_dot * self.dt)
79
+ return self.quaternion.copy(), self.gyro_bias.copy()