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,50 @@
1
+ """Generic PID controller with anti-windup and output saturation.
2
+
3
+ Consolidated from quadcopter_simulation.py.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+
10
+
11
+ class PIDController:
12
+ """Scalar PID with integral anti-windup and output clamping."""
13
+
14
+ def __init__(
15
+ self,
16
+ kp: float,
17
+ ki: float,
18
+ kd: float,
19
+ output_limits: tuple[float, float] | None = None,
20
+ windup_limits: tuple[float, float] | None = None,
21
+ ):
22
+ self.kp = kp
23
+ self.ki = ki
24
+ self.kd = kd
25
+ self.output_limits = output_limits
26
+ self.windup_limits = windup_limits
27
+
28
+ self._prev_error = 0.0
29
+ self._integral = 0.0
30
+
31
+ def reset(self) -> None:
32
+ self._prev_error = 0.0
33
+ self._integral = 0.0
34
+
35
+ def update(self, setpoint: float, measurement: float, dt: float) -> float:
36
+ error = setpoint - measurement
37
+ dt = max(dt, 1e-6)
38
+
39
+ self._integral += error * dt
40
+ if self.windup_limits is not None:
41
+ self._integral = np.clip(self._integral, *self.windup_limits)
42
+
43
+ derivative = (error - self._prev_error) / dt
44
+ self._prev_error = error
45
+
46
+ output = self.kp * error + self.ki * self._integral + self.kd * derivative
47
+ if self.output_limits is not None:
48
+ output = np.clip(output, *self.output_limits)
49
+
50
+ return float(output)
@@ -0,0 +1,13 @@
1
+ from .config import QuadcopterConfig
2
+ from .disturbances import ( # noqa: F401
3
+ ConstantWind,
4
+ Disturbance,
5
+ DrydenGust,
6
+ GroundEffect,
7
+ MotorFailure,
8
+ PayloadDrop,
9
+ StepWind,
10
+ )
11
+ from .quadcopter import QuadcopterDynamics
12
+
13
+ __all__ = ["QuadcopterConfig", "QuadcopterDynamics"]
@@ -0,0 +1,117 @@
1
+ """Physical configuration for the quadcopter plant."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ import numpy as np
8
+ from numpy.typing import NDArray
9
+
10
+
11
+ @dataclass
12
+ class QuadcopterConfig:
13
+ """Validated physical and actuator parameters.
14
+
15
+ The default rotor positions form a ``+`` layout compatible with the
16
+ historical model: front, right, rear, left. Positive rotor directions
17
+ produce positive yaw moments.
18
+ """
19
+
20
+ mass: float = 1.0
21
+ inertia: NDArray = field(
22
+ default_factory=lambda: np.diag([0.01, 0.01, 0.018])
23
+ )
24
+ arm_length: float = 0.2
25
+ thrust_coefficient: float = 1.0e-6
26
+ moment_coefficient: float = 1.0e-7
27
+ linear_drag: NDArray = field(default_factory=lambda: np.full(3, 0.1))
28
+ quadratic_drag: NDArray = field(default_factory=lambda: np.zeros(3))
29
+ gravity: float = 9.81
30
+ motor_time_constant: float = 0.04
31
+ min_motor_speed: float = 0.0
32
+ max_motor_speed: float = 4000.0
33
+ rotor_directions: NDArray = field(
34
+ default_factory=lambda: np.array([1.0, -1.0, 1.0, -1.0])
35
+ )
36
+
37
+ def __post_init__(self) -> None:
38
+ self.mass = float(self.mass)
39
+ self.arm_length = float(self.arm_length)
40
+ self.thrust_coefficient = float(self.thrust_coefficient)
41
+ self.moment_coefficient = float(self.moment_coefficient)
42
+ self.gravity = float(self.gravity)
43
+ self.motor_time_constant = float(self.motor_time_constant)
44
+ self.min_motor_speed = float(self.min_motor_speed)
45
+ self.max_motor_speed = float(self.max_motor_speed)
46
+ self.inertia = np.asarray(self.inertia, dtype=float).copy()
47
+ if self.inertia.shape == (3,):
48
+ self.inertia = np.diag(self.inertia)
49
+ self.linear_drag = self._axis_value(self.linear_drag, "linear_drag")
50
+ self.quadratic_drag = self._axis_value(
51
+ self.quadratic_drag, "quadratic_drag"
52
+ )
53
+ self.rotor_directions = np.asarray(
54
+ self.rotor_directions, dtype=float
55
+ ).copy()
56
+ self.validate()
57
+
58
+ @staticmethod
59
+ def _axis_value(value: NDArray | float, name: str) -> NDArray:
60
+ array = np.asarray(value, dtype=float)
61
+ if array.ndim == 0:
62
+ array = np.full(3, float(array))
63
+ if array.shape != (3,):
64
+ raise ValueError(f"{name} must be scalar or shape (3,), got {array.shape}")
65
+ return array.copy()
66
+
67
+ def validate(self) -> None:
68
+ if self.mass <= 0.0:
69
+ raise ValueError("mass must be positive")
70
+ if self.arm_length <= 0.0:
71
+ raise ValueError("arm_length must be positive")
72
+ if self.thrust_coefficient <= 0.0 or self.moment_coefficient <= 0.0:
73
+ raise ValueError("rotor coefficients must be positive")
74
+ if self.inertia.shape != (3, 3):
75
+ raise ValueError("inertia must have shape (3, 3)")
76
+ if not np.allclose(self.inertia, self.inertia.T):
77
+ raise ValueError("inertia must be symmetric")
78
+ if np.min(np.linalg.eigvalsh(self.inertia)) <= 0.0:
79
+ raise ValueError("inertia must be positive definite")
80
+ if np.any(self.linear_drag < 0.0) or np.any(self.quadratic_drag < 0.0):
81
+ raise ValueError("drag coefficients must be non-negative")
82
+ if self.gravity <= 0.0:
83
+ raise ValueError("gravity must be positive")
84
+ if self.motor_time_constant < 0.0:
85
+ raise ValueError("motor_time_constant cannot be negative")
86
+ if not 0.0 <= self.min_motor_speed < self.max_motor_speed:
87
+ raise ValueError("motor speed limits are invalid")
88
+ if self.rotor_directions.shape != (4,):
89
+ raise ValueError("rotor_directions must have shape (4,)")
90
+
91
+ @property
92
+ def rotor_positions(self) -> NDArray:
93
+ length = self.arm_length
94
+ return np.array(
95
+ [[length, 0.0, 0.0], [0.0, length, 0.0],
96
+ [-length, 0.0, 0.0], [0.0, -length, 0.0]]
97
+ )
98
+
99
+ def allocation_matrix(self, efficiencies: NDArray | None = None) -> NDArray:
100
+ """Map squared rotor speeds to ``[thrust, tau_x, tau_y, tau_z]``."""
101
+ efficiency = (
102
+ np.ones(4)
103
+ if efficiencies is None
104
+ else np.asarray(efficiencies, dtype=float)
105
+ )
106
+ if efficiency.shape != (4,):
107
+ raise ValueError("efficiencies must have shape (4,)")
108
+ force_gain = self.thrust_coefficient * efficiency
109
+ positions = self.rotor_positions
110
+ return np.vstack(
111
+ [
112
+ force_gain,
113
+ positions[:, 1] * force_gain,
114
+ -positions[:, 0] * force_gain,
115
+ self.rotor_directions * self.moment_coefficient * efficiency,
116
+ ]
117
+ )
@@ -0,0 +1,262 @@
1
+ """Disturbance models for quadcopter dynamics.
2
+
3
+ Each disturbance can inject an external force (world frame), an external torque
4
+ (body frame), or modify the physical parameters of the quadcopter (mass, inertia,
5
+ rotor coefficients) at a given simulation time.
6
+
7
+ All disturbances derive from the abstract ``Disturbance`` base and are wired into
8
+ ``QuadcopterDynamics._derivatives`` via the ``disturbances=`` list parameter in the
9
+ constructor (see ``quadcopter.py``).
10
+
11
+ Reference
12
+ ---------
13
+ - Dryden gust model: MIL-F-8785C / U.S. Military Specification on Flying Qualities
14
+ of Piloted Airplanes (1980).
15
+ - Ground effect: Cheeseman & Bennett (1955), "The Effect of the Ground on a
16
+ Helicopter Rotor in Forward Flight", ARC R&M 3021.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from abc import ABC
22
+
23
+ import numpy as np
24
+ from numpy.typing import NDArray
25
+
26
+
27
+ class Disturbance(ABC):
28
+ """Abstract base for any external disturbance acting on the quadcopter.
29
+
30
+ Subclasses override one or more of the three hooks. The default
31
+ implementations are no-ops.
32
+ """
33
+
34
+ def reset(self) -> None:
35
+ """Re-seed any internal state (called at the start of a new episode)."""
36
+
37
+ def advance(self, t: float, dt: float, state: NDArray) -> None:
38
+ """Advance stochastic state once per integration step.
39
+
40
+ This is separate from force evaluation because RK4 evaluates the force
41
+ model four times per integration step.
42
+ """
43
+
44
+ def external_force(self, t: float, dt: float, state: NDArray) -> NDArray:
45
+ """External force in the world frame [N].
46
+
47
+ Called inside ``_derivatives``; result is added directly to the total
48
+ force vector before dividing by mass.
49
+ """
50
+ return np.zeros(3)
51
+
52
+ def external_torque(self, t: float, dt: float, state: NDArray) -> NDArray:
53
+ """External torque in the body frame [N·m]."""
54
+ return np.zeros(3)
55
+
56
+ def modify_dynamics(self, quad: object, t: float) -> None:
57
+ """Mutate quadcopter physical attributes in-place (mass, inertia, etc.).
58
+
59
+ Called once per ``update()`` call, before ``_derivatives``.
60
+ The ``quad`` argument is the ``QuadcopterDynamics`` instance.
61
+ """
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Wind models
66
+ # ---------------------------------------------------------------------------
67
+
68
+ class ConstantWind(Disturbance):
69
+ """Steady world-frame wind force.
70
+
71
+ The wind pushes the drone with a force proportional to the wind velocity:
72
+
73
+ F = k_d * v_wind
74
+
75
+ where *k_d* is matched to the quadcopter's drag coefficient so the
76
+ simulation remains dimensionally consistent.
77
+ """
78
+
79
+ def __init__(self, velocity: NDArray, k_d: float = 0.1) -> None:
80
+ self.velocity = np.asarray(velocity, dtype=float)
81
+ self.k_d = k_d
82
+
83
+ def external_force(self, t: float, dt: float, state: NDArray) -> NDArray:
84
+ # Retained for direct/legacy callers. The plant calls ``advance`` once
85
+ # and obtains the resulting wind through ``wind_velocity``.
86
+ self.advance(t, dt, state)
87
+ return np.zeros(3)
88
+
89
+ def wind_velocity(self, t: float = 0.0) -> NDArray:
90
+ return self.velocity.copy()
91
+
92
+
93
+ class StepWind(Disturbance):
94
+ """Wind that switches on at time *t_on* with a given world-frame velocity."""
95
+
96
+ def __init__(self, velocity: NDArray, t_on: float = 0.0) -> None:
97
+ self.velocity = np.asarray(velocity, dtype=float)
98
+ self.t_on = t_on
99
+
100
+ def external_force(self, t: float, dt: float, state: NDArray) -> NDArray:
101
+ return np.zeros(3)
102
+
103
+ def wind_velocity(self, t: float = 0.0) -> NDArray:
104
+ return self.velocity.copy() if t >= self.t_on else np.zeros(3)
105
+
106
+
107
+ class DrydenGust(Disturbance):
108
+ """Dryden turbulence model — continuous random gust in the world frame.
109
+
110
+ The Dryden spectrum is approximated in the time domain as a first-order
111
+ Gauss-Markov process with correlation length *L* (m) and intensity *sigma*
112
+ (m/s). The filter is:
113
+
114
+ d(wind)/dt = -(V / L) * wind + sigma * sqrt(2*V / (pi*L)) * eta(t)
115
+
116
+ where *V* is the reference speed (default: hover-induced downwash proxy) and
117
+ *eta* ~ N(0,1/dt) per step.
118
+
119
+ Reference
120
+ ---------
121
+ MIL-F-8785C, §3.7.2 "Discrete Gust and Continuous Turbulence Models"
122
+ """
123
+
124
+ def __init__(
125
+ self,
126
+ intensity: float = 2.0, # sigma [m/s]
127
+ length_scale: float = 50.0, # L [m]
128
+ reference_speed: float = 5.0, # V [m/s]
129
+ seed: int | None = None,
130
+ ) -> None:
131
+ self.intensity = intensity
132
+ self.length_scale = length_scale
133
+ self.reference_speed = reference_speed
134
+
135
+ self._seed = seed
136
+ self._rng = np.random.default_rng(seed)
137
+ self._wind = np.zeros(3)
138
+ self._alpha_cache: float | None = None # exp(-V/L * dt), cached per dt
139
+
140
+ def reset(self) -> None:
141
+ self._rng = np.random.default_rng(self._seed)
142
+ self._wind = np.zeros(3)
143
+ self._alpha_cache = None
144
+
145
+ def advance(self, t: float, dt: float, state: NDArray) -> None:
146
+ if dt <= 0.0:
147
+ return
148
+ # Build the Dryden filter coefficients for this dt (cached).
149
+ if self._alpha_cache is None or self._alpha_cache != dt:
150
+ V, L = self.reference_speed, self.length_scale
151
+ self._alpha_cache = float(dt)
152
+ self._alpha = float(np.exp(-V / L * dt))
153
+ self._beta = self.intensity * float(np.sqrt(2.0 * V / (np.pi * L)))
154
+
155
+ # Discrete Gauss-Markov step
156
+ drive = self._rng.normal(0.0, 1.0, 3) / np.sqrt(dt + 1e-12)
157
+ self._wind = self._alpha * self._wind + self._beta * drive
158
+
159
+ def external_force(self, t: float, dt: float, state: NDArray) -> NDArray:
160
+ self.advance(t, dt, state)
161
+ return np.zeros(3)
162
+
163
+ def wind_velocity(self, t: float = 0.0) -> NDArray:
164
+ return self._wind.copy()
165
+
166
+
167
+ # ---------------------------------------------------------------------------
168
+ # Mechanical / failure disturbances
169
+ # ---------------------------------------------------------------------------
170
+
171
+ class MotorFailure(Disturbance):
172
+ """Degrade one or more rotors' thrust coefficient *k_f* at time *t_fail*.
173
+
174
+ The affected motor(s) produce ``efficiency * k_f`` after failure, simulating
175
+ a partial loss-of-thrust event (propeller damage, ESC brownout).
176
+ """
177
+
178
+ def __init__(
179
+ self,
180
+ motor_indices: list[int],
181
+ efficiency: float = 0.3,
182
+ t_fail: float = 1.0,
183
+ ) -> None:
184
+ self.motor_indices = motor_indices
185
+ self.efficiency = efficiency
186
+ self.t_fail = t_fail
187
+ self._failed = False
188
+
189
+ def reset(self) -> None:
190
+ self._failed = False
191
+
192
+ def modify_dynamics(self, quad: object, t: float) -> None:
193
+ if not self._failed and t >= self.t_fail:
194
+ self._failed = True
195
+
196
+ def rotor_efficiencies(self, t: float) -> NDArray:
197
+ efficiency = np.ones(4)
198
+ if t >= self.t_fail:
199
+ efficiency[self.motor_indices] = self.efficiency
200
+ return efficiency
201
+
202
+
203
+ class PayloadDrop(Disturbance):
204
+ """Instantaneous mass change at time *t_drop* (simulates payload release).
205
+
206
+ At ``t >= t_drop`` the drone's mass is set to the post-drop value.
207
+ On ``reset()`` the mass is restored to the original constructor value.
208
+ """
209
+
210
+ def __init__(self, post_drop_mass: float, t_drop: float = 2.0) -> None:
211
+ self.post_drop_mass = post_drop_mass
212
+ self.t_drop = t_drop
213
+ self._active = False
214
+ self._original_mass: float | None = None
215
+
216
+ def reset(self) -> None:
217
+ self._active = False
218
+
219
+ def modify_dynamics(self, quad: object, t: float) -> None:
220
+ # Capture the drone's mass on the very first call (before any mutation).
221
+ if self._original_mass is None:
222
+ self._original_mass = float(quad.mass)
223
+ if not self._active and t >= self.t_drop:
224
+ self._active = True
225
+ if self._active:
226
+ quad.mass = self.post_drop_mass
227
+ else:
228
+ quad.mass = self._original_mass
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # Environmental disturbances
233
+ # ---------------------------------------------------------------------------
234
+
235
+ class GroundEffect(Disturbance):
236
+ """Thrust augmentation near the ground.
237
+
238
+ The thrust multiplier follows an exponential decay with altitude:
239
+
240
+ T/T∞ = 1 + 0.3 * exp(-h / r)
241
+
242
+ where *h* is altitude above ground [m] and *r* is the rotor radius [m].
243
+ This gives:
244
+ - h = 0: T/T∞ ≈ 1.3 (maximum augmentation)
245
+ - h = r: T/T∞ ≈ 1.11
246
+ - h = 2r: T/T∞ ≈ 1.04
247
+ - h → ∞: T/T∞ → 1.0
248
+
249
+ The multiplier is applied inside ``_derivatives`` by calling
250
+ ``ground_effect.thrust_multiplier(altitude)`` and scaling the total thrust.
251
+
252
+ Attributes
253
+ ----------
254
+ radius: Rotor radius [m] (default 0.13 m for a 10" propeller).
255
+ """
256
+
257
+ def __init__(self, radius: float = 0.13) -> None:
258
+ self.radius = radius
259
+
260
+ def thrust_multiplier(self, alt: float) -> float:
261
+ alt_safe = max(alt, 0.0)
262
+ return float(1.0 + 0.3 * np.exp(-alt_safe / self.radius))