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,444 @@
1
+ """URDF-based quadcopter model loader.
2
+
3
+ Parses the bundled quadcopter URDF file and provides data structures
4
+ for visualization and physics integration. Uses only the Python
5
+ standard library (xml.etree.ElementTree) — no external URDF packages
6
+ are required.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import xml.etree.ElementTree as ET
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ from numpy.typing import NDArray
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Data structures
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ @dataclass
24
+ class URDFGeometry:
25
+ """A single URDF geometry primitive."""
26
+
27
+ geom_type: str # "box", "cylinder", "sphere"
28
+ size: tuple[
29
+ float, ...
30
+ ] = () # box: (x,y,z); cylinder: (radius,length); sphere: (radius,)
31
+
32
+ # Origin relative to the owning link frame.
33
+ origin_xyz: NDArray = field(default_factory=lambda: np.zeros(3))
34
+ origin_rpy: NDArray = field(default_factory=lambda: np.zeros(3))
35
+
36
+
37
+ @dataclass
38
+ class URDFLink:
39
+ """Parsed URDF link."""
40
+
41
+ name: str
42
+ mass: float = 0.0
43
+ inertia: NDArray = field(default_factory=lambda: np.zeros((3, 3)))
44
+ inertia_origin_xyz: NDArray = field(default_factory=lambda: np.zeros(3))
45
+
46
+ visual: URDFGeometry | None = None
47
+ collision: URDFGeometry | None = None
48
+ color_rgba: tuple[float, float, float, float] = (0.5, 0.5, 0.5, 1.0)
49
+
50
+ @property
51
+ def color_uint8(self) -> tuple[int, int, int]:
52
+ """Return ``(R, G, B)`` as uint8 values in [0, 255]."""
53
+ r, g, b, _ = self.color_rgba
54
+ return (int(r * 255), int(g * 255), int(b * 255))
55
+
56
+
57
+ @dataclass
58
+ class URDFJoint:
59
+ """Parsed URDF joint."""
60
+
61
+ name: str
62
+ joint_type: str # "fixed", "revolute", …
63
+ parent: str
64
+ child: str
65
+ origin_xyz: NDArray = field(default_factory=lambda: np.zeros(3))
66
+ origin_rpy: NDArray = field(default_factory=lambda: np.zeros(3))
67
+
68
+
69
+ @dataclass
70
+ class DroneURDFModel:
71
+ """Complete parsed URDF model."""
72
+
73
+ name: str
74
+ links: list[URDFLink] = field(default_factory=list)
75
+ joints: list[URDFJoint] = field(default_factory=list)
76
+ materials: dict[str, tuple[float, float, float, float]] = field(
77
+ default_factory=dict
78
+ )
79
+
80
+ # Convenience aggregates (filled after parsing).
81
+ total_mass: float = 0.0
82
+ total_inertia: NDArray = field(default_factory=lambda: np.zeros((3, 3)))
83
+
84
+ def __repr__(self) -> str:
85
+ link_names = [link.name for link in self.links]
86
+ return (
87
+ f"DroneURDFModel(name={self.name!r}, "
88
+ f"links={link_names}, "
89
+ f"total_mass={self.total_mass:.3f} kg)"
90
+ )
91
+
92
+ # ----- kinematic helpers -----
93
+
94
+ def link_world_position(self, link_name: str) -> NDArray:
95
+ """Compute the position of *link_name* relative to base_link by
96
+ walking up the kinematic chain (fixed joints only)."""
97
+ pos = np.zeros(3)
98
+ current = link_name
99
+ visited: set[str] = set()
100
+ while current != "base_link" and current not in visited:
101
+ visited.add(current)
102
+ for j in self.joints:
103
+ if j.child == current:
104
+ pos = pos + j.origin_xyz
105
+ current = j.parent
106
+ break
107
+ else:
108
+ break
109
+ return pos
110
+
111
+ def link_world_rpy(self, link_name: str) -> NDArray:
112
+ """Accumulate RPY along the kinematic chain (additive for fixed joints)."""
113
+ rpy = np.zeros(3)
114
+ current = link_name
115
+ visited: set[str] = set()
116
+ while current != "base_link" and current not in visited:
117
+ visited.add(current)
118
+ for j in self.joints:
119
+ if j.child == current:
120
+ rpy = rpy + j.origin_rpy
121
+ current = j.parent
122
+ break
123
+ else:
124
+ break
125
+ return rpy
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # URDF file path helper
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ def get_urdf_path() -> Path:
134
+ """Return the absolute path to the bundled ``quadcopter.urdf``."""
135
+ return Path(__file__).parent / "quadcopter.urdf"
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Parser
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ def _parse_origin(elem: ET.Element | None) -> tuple[NDArray, NDArray]:
144
+ """Extract xyz and rpy from an <origin> element."""
145
+ if elem is None:
146
+ return np.zeros(3), np.zeros(3)
147
+ xyz = np.array([float(v) for v in elem.get("xyz", "0 0 0").split()])
148
+ rpy = np.array([float(v) for v in elem.get("rpy", "0 0 0").split()])
149
+ return xyz, rpy
150
+
151
+
152
+ def _parse_geometry(parent: ET.Element | None) -> URDFGeometry | None:
153
+ """Parse a <visual> or <collision> block into a URDFGeometry."""
154
+ if parent is None:
155
+ return None
156
+ geom_el = parent.find("geometry")
157
+ if geom_el is None:
158
+ return None
159
+
160
+ origin_xyz, origin_rpy = _parse_origin(parent.find("origin"))
161
+
162
+ for prim in ("box", "cylinder", "sphere"):
163
+ el = geom_el.find(prim)
164
+ if el is not None:
165
+ if prim == "box":
166
+ size = tuple(float(v) for v in el.get("size", "0 0 0").split())
167
+ elif prim == "cylinder":
168
+ size = (
169
+ float(el.get("radius", "0")),
170
+ float(el.get("length", "0")),
171
+ )
172
+ else: # sphere
173
+ size = (float(el.get("radius", "0")),)
174
+ return URDFGeometry(
175
+ geom_type=prim,
176
+ size=size,
177
+ origin_xyz=origin_xyz,
178
+ origin_rpy=origin_rpy,
179
+ )
180
+ return None
181
+
182
+
183
+ def load_drone_urdf(urdf_path: str | Path | None = None) -> DroneURDFModel:
184
+ """Parse a URDF file and return a :class:`DroneURDFModel`.
185
+
186
+ Parameters
187
+ ----------
188
+ urdf_path:
189
+ Path to the ``.urdf`` file. Defaults to the bundled quadcopter model.
190
+ """
191
+ if urdf_path is None:
192
+ urdf_path = get_urdf_path()
193
+ tree = ET.parse(str(urdf_path))
194
+ root = tree.getroot()
195
+
196
+ model = DroneURDFModel(name=root.get("name", "unnamed"))
197
+
198
+ # --- materials (top-level) ---
199
+ for mat_el in root.findall("material"):
200
+ name = mat_el.get("name", "")
201
+ color_el = mat_el.find("color")
202
+ if color_el is not None:
203
+ rgba = tuple(
204
+ float(v) for v in color_el.get("rgba", "0.5 0.5 0.5 1").split()
205
+ )
206
+ model.materials[name] = rgba # type: ignore[assignment]
207
+
208
+ # --- links ---
209
+ for link_el in root.findall("link"):
210
+ link = URDFLink(name=link_el.get("name", ""))
211
+
212
+ # Inertial
213
+ inertial = link_el.find("inertial")
214
+ if inertial is not None:
215
+ mass_el = inertial.find("mass")
216
+ if mass_el is not None:
217
+ link.mass = float(mass_el.get("value", "0"))
218
+ inertia_el = inertial.find("inertia")
219
+ if inertia_el is not None:
220
+ ixx = float(inertia_el.get("ixx", "0"))
221
+ ixy = float(inertia_el.get("ixy", "0"))
222
+ ixz = float(inertia_el.get("ixz", "0"))
223
+ iyy = float(inertia_el.get("iyy", "0"))
224
+ iyz = float(inertia_el.get("iyz", "0"))
225
+ izz = float(inertia_el.get("izz", "0"))
226
+ link.inertia = np.array(
227
+ [
228
+ [ixx, ixy, ixz],
229
+ [ixy, iyy, iyz],
230
+ [ixz, iyz, izz],
231
+ ]
232
+ )
233
+ origin_el = inertial.find("origin")
234
+ if origin_el is not None:
235
+ link.inertia_origin_xyz, _ = _parse_origin(origin_el)
236
+
237
+ # Visual
238
+ link.visual = _parse_geometry(link_el.find("visual"))
239
+
240
+ # Collision
241
+ link.collision = _parse_geometry(link_el.find("collision"))
242
+
243
+ # Color — look up material by name
244
+ vis_el = link_el.find("visual")
245
+ if vis_el is not None:
246
+ mat_el = vis_el.find("material")
247
+ if mat_el is not None:
248
+ mat_name = mat_el.get("name", "")
249
+ # Inline color overrides material name reference
250
+ color_el = mat_el.find("color")
251
+ if color_el is not None:
252
+ link.color_rgba = tuple( # type: ignore[assignment]
253
+ float(v)
254
+ for v in color_el.get("rgba", "0.5 0.5 0.5 1").split()
255
+ )
256
+ elif mat_name in model.materials:
257
+ link.color_rgba = model.materials[mat_name]
258
+
259
+ model.links.append(link)
260
+
261
+ # --- joints ---
262
+ for joint_el in root.findall("joint"):
263
+ origin_xyz, origin_rpy = _parse_origin(joint_el.find("origin"))
264
+ parent_el = joint_el.find("parent")
265
+ child_el = joint_el.find("child")
266
+ joint = URDFJoint(
267
+ name=joint_el.get("name", ""),
268
+ joint_type=joint_el.get("type", "fixed"),
269
+ parent=parent_el.get("link", "") if parent_el is not None else "",
270
+ child=child_el.get("link", "") if child_el is not None else "",
271
+ origin_xyz=origin_xyz,
272
+ origin_rpy=origin_rpy,
273
+ )
274
+ model.joints.append(joint)
275
+
276
+ # --- aggregates ---
277
+ model.total_mass = sum(link.mass for link in model.links)
278
+ model.total_inertia = sum(link.inertia for link in model.links) # type: ignore[assignment]
279
+
280
+ return model
281
+
282
+
283
+ # ---------------------------------------------------------------------------
284
+ # Mesh generation from URDF geometry primitives
285
+ # ---------------------------------------------------------------------------
286
+
287
+
288
+ def _box_mesh(sx: float, sy: float, sz: float) -> tuple[NDArray, NDArray]:
289
+ """Generate vertices and faces for an axis-aligned box centred at origin."""
290
+ hx, hy, hz = sx / 2, sy / 2, sz / 2
291
+ vertices = np.array(
292
+ [
293
+ [-hx, -hy, -hz],
294
+ [hx, -hy, -hz],
295
+ [hx, hy, -hz],
296
+ [-hx, hy, -hz],
297
+ [-hx, -hy, hz],
298
+ [hx, -hy, hz],
299
+ [hx, hy, hz],
300
+ [-hx, hy, hz],
301
+ ],
302
+ dtype=np.float32,
303
+ )
304
+ faces = np.array(
305
+ [
306
+ [0, 1, 2],
307
+ [0, 2, 3], # bottom
308
+ [4, 6, 5],
309
+ [4, 7, 6], # top
310
+ [0, 4, 5],
311
+ [0, 5, 1], # front
312
+ [2, 6, 7],
313
+ [2, 7, 3], # back
314
+ [0, 3, 7],
315
+ [0, 7, 4], # left
316
+ [1, 5, 6],
317
+ [1, 6, 2], # right
318
+ ],
319
+ dtype=np.uint32,
320
+ )
321
+ return vertices, faces
322
+
323
+
324
+ def _cylinder_mesh(
325
+ radius: float,
326
+ length: float,
327
+ n_segments: int = 16,
328
+ ) -> tuple[NDArray, NDArray]:
329
+ """Generate vertices and faces for a Z-axis cylinder centred at origin."""
330
+ angles = np.linspace(0, 2 * np.pi, n_segments, endpoint=False)
331
+ cos_a = np.cos(angles)
332
+ sin_a = np.sin(angles)
333
+ hz = length / 2
334
+
335
+ # Bottom + top ring vertices, then two cap centres
336
+ bottom = np.stack(
337
+ [cos_a * radius, sin_a * radius, np.full(n_segments, -hz)], axis=-1
338
+ )
339
+ top = np.stack(
340
+ [cos_a * radius, sin_a * radius, np.full(n_segments, hz)], axis=-1
341
+ )
342
+ centres = np.array([[0, 0, -hz], [0, 0, hz]], dtype=np.float64)
343
+
344
+ vertices = np.vstack([bottom, top, centres]).astype(np.float32)
345
+ n = n_segments
346
+ bc = 2 * n # bottom centre index
347
+ tc = 2 * n + 1 # top centre index
348
+
349
+ faces_list: list[list[int]] = []
350
+ for i in range(n):
351
+ j = (i + 1) % n
352
+ # Side quad as two triangles
353
+ faces_list.append([i, j, j + n])
354
+ faces_list.append([i, j + n, i + n])
355
+ # Bottom cap
356
+ faces_list.append([bc, j, i])
357
+ # Top cap
358
+ faces_list.append([tc, i + n, j + n])
359
+
360
+ faces = np.array(faces_list, dtype=np.uint32)
361
+ return vertices, faces
362
+
363
+
364
+ def _sphere_mesh(
365
+ radius: float,
366
+ n_lat: int = 8,
367
+ n_lon: int = 16,
368
+ ) -> tuple[NDArray, NDArray]:
369
+ """Generate vertices and faces for a UV sphere centred at origin."""
370
+ verts: list[list[float]] = []
371
+ # Poles
372
+ verts.append([0, 0, radius]) # north
373
+ verts.append([0, 0, -radius]) # south
374
+
375
+ for i in range(1, n_lat):
376
+ theta = np.pi * i / n_lat
377
+ for j in range(n_lon):
378
+ phi = 2 * np.pi * j / n_lon
379
+ verts.append(
380
+ [
381
+ radius * np.sin(theta) * np.cos(phi),
382
+ radius * np.sin(theta) * np.sin(phi),
383
+ radius * np.cos(theta),
384
+ ]
385
+ )
386
+
387
+ vertices = np.array(verts, dtype=np.float32)
388
+
389
+ faces_list: list[list[int]] = []
390
+ # North-pole fan
391
+ for j in range(n_lon):
392
+ jn = (j + 1) % n_lon
393
+ faces_list.append([0, 2 + j, 2 + jn])
394
+ # South-pole fan
395
+ south_ring_start = 2 + (n_lat - 2) * n_lon
396
+ for j in range(n_lon):
397
+ jn = (j + 1) % n_lon
398
+ faces_list.append([1, south_ring_start + jn, south_ring_start + j])
399
+ # Body quads
400
+ for i in range(n_lat - 2):
401
+ ring = 2 + i * n_lon
402
+ next_ring = ring + n_lon
403
+ for j in range(n_lon):
404
+ jn = (j + 1) % n_lon
405
+ faces_list.append([ring + j, next_ring + j, next_ring + jn])
406
+ faces_list.append([ring + j, next_ring + jn, ring + jn])
407
+
408
+ faces = np.array(faces_list, dtype=np.uint32)
409
+ return vertices, faces
410
+
411
+
412
+ def _rpy_to_matrix(rpy: NDArray) -> NDArray:
413
+ """Convert roll-pitch-yaw (XYZ extrinsic) to a 3×3 rotation matrix."""
414
+ cr, sr = np.cos(rpy[0]), np.sin(rpy[0])
415
+ cp, sp = np.cos(rpy[1]), np.sin(rpy[1])
416
+ cy, sy = np.cos(rpy[2]), np.sin(rpy[2])
417
+ return np.array(
418
+ [
419
+ [cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr],
420
+ [sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr],
421
+ [-sp, cp * sr, cp * cr],
422
+ ]
423
+ )
424
+
425
+
426
+ def geometry_to_mesh(geom: URDFGeometry) -> tuple[NDArray, NDArray]:
427
+ """Convert a :class:`URDFGeometry` to ``(vertices, faces)`` arrays.
428
+
429
+ The returned vertices are already transformed by the geometry's local
430
+ origin (xyz + rpy).
431
+ """
432
+ if geom.geom_type == "box":
433
+ verts, faces = _box_mesh(*geom.size)
434
+ elif geom.geom_type == "cylinder":
435
+ verts, faces = _cylinder_mesh(geom.size[0], geom.size[1])
436
+ elif geom.geom_type == "sphere":
437
+ verts, faces = _sphere_mesh(geom.size[0])
438
+ else:
439
+ raise ValueError(f"Unknown geometry type: {geom.geom_type!r}")
440
+
441
+ # Apply the geometry-local origin transform
442
+ R = _rpy_to_matrix(geom.origin_rpy)
443
+ verts = (R @ verts.T).T + geom.origin_xyz.astype(np.float32)
444
+ return verts.astype(np.float32), faces
@@ -0,0 +1,16 @@
1
+ """Gymnasium environment for quadcopter reinforcement learning.
2
+
3
+ The primary entry point is ``QuadcopterEnv``, which wraps
4
+ ``QuadcopterDynamics`` into a standard ``gymnasium.Env``.
5
+ """
6
+
7
+ from .actions import ( # noqa: F401
8
+ LQRResidualAction,
9
+ MotorSpeedAction,
10
+ ThrustBodyRatesAction,
11
+ VelocityLevelAction,
12
+ )
13
+ from .env import QuadcopterEnv # noqa: F401
14
+ from .observations import RelativeStateObs # noqa: F401
15
+ from .reward import RewardConfig, reward # noqa: F401
16
+ from .tasks import HoverTask, TrackingTask, WaypointTask # noqa: F401
@@ -0,0 +1,219 @@
1
+ """Action parameterizations for the quadcopter RL environment.
2
+
3
+ Each parameterization defines ``low``, ``high``, ``dim`` and implements
4
+ ``to_motors(action) -> (4,)`` — converting a policy action vector into
5
+ four motor speed commands (rad/s).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ from numpy.typing import NDArray
12
+
13
+ from ..control.allocation import ControlAllocator
14
+
15
+
16
+ class MotorSpeedAction:
17
+ """Lowest level: raw motor speeds for each of 4 rotors.
18
+
19
+ Hardest to learn, suitable for acrobatic maneuvers.
20
+ """
21
+
22
+ dim = 4
23
+ low = np.zeros(4, dtype=np.float32)
24
+ high = np.full(4, 4000.0, dtype=np.float32)
25
+
26
+ def to_motors(self, quad, action: NDArray) -> NDArray:
27
+ return np.clip(action, self.low, self.high)
28
+
29
+
30
+ class ThrustBodyRatesAction:
31
+ """Mid-level: collective thrust + 3 body-rate commands.
32
+
33
+ Action: [thrust (N), omega_x, omega_y, omega_z (rad/s)].
34
+
35
+ The thrust is allocated equally across 4 motors (hover thrust added),
36
+ and the body-rate error is mapped to differential motor commands via
37
+ the quadcopter allocation matrix inverted as a proportional controller.
38
+ """
39
+
40
+ dim = 4
41
+ # Thrust is defined as delta from hover (mass * g).
42
+ # Negative → descend, positive → ascend, zero → exactly hover.
43
+ # Total thrust is clipped to [0.2*hover, 2.0*hover] in to_motors.
44
+ _thrust_low = -0.8 * 9.81 # total = 0.2 * hover (minimum)
45
+ _thrust_high = 1.0 * 9.81 # total = 2.0 * hover (maximum)
46
+ _rate_low = -5.0
47
+ _rate_high = 5.0
48
+
49
+ @property
50
+ def low(self) -> np.ndarray:
51
+ return np.array([self._thrust_low, self._rate_low, self._rate_low, self._rate_low], dtype=np.float32)
52
+
53
+ @property
54
+ def high(self) -> np.ndarray:
55
+ return np.array([self._thrust_high, self._rate_high, self._rate_high, self._rate_high], dtype=np.float32)
56
+
57
+ def to_motors(self, quad, action: NDArray) -> NDArray:
58
+ thrust, wx, wy, wz = action
59
+ # Hover feedforward
60
+ hover_thrust = quad.mass * quad.g
61
+ total_thrust = thrust + hover_thrust
62
+
63
+ # Rate errors → torque commands (proportional rate controller)
64
+ omega = quad.get_angular_velocity()
65
+ tau = np.array([wx - omega[0], wy - omega[1], wz - omega[2]])
66
+
67
+ # Build wrench and invert allocation
68
+ wrench = np.array([
69
+ np.clip(total_thrust, 0.2 * hover_thrust, 2.0 * hover_thrust),
70
+ tau[0] * 0.05,
71
+ tau[1] * 0.05,
72
+ tau[2] * 0.02,
73
+ ])
74
+
75
+ allocator = ControlAllocator(
76
+ quad.allocation_matrix(), quad.min_motor_speed, quad.max_motor_speed
77
+ )
78
+ return allocator.allocate(wrench).motor_speeds
79
+
80
+
81
+ class LQRResidualAction:
82
+ """LQR baseline + learned residual.
83
+
84
+ Action: 4-dim delta in [-1, 1] applied as a multiplicative offset
85
+ to the LQR-computed motor speeds.
86
+
87
+ The built-in ``LQRController`` provides a stable hover baseline;
88
+ the policy only needs to learn corrections to tighten tracking.
89
+ This makes the learning problem dramatically easier — the drone
90
+ never crashes during exploration because LQR always keeps it stable.
91
+ """
92
+
93
+ dim = 4
94
+ _scale: float = 500.0 # max motor speed delta (rad/s) from LQR baseline
95
+
96
+ def __init__(self, target_yaw: float = 0.0, **kwargs):
97
+ self._target_yaw = target_yaw
98
+ for k, v in kwargs.items():
99
+ setattr(self, k, v)
100
+
101
+ @property
102
+ def low(self) -> np.ndarray:
103
+ return np.full(self.dim, -1.0, dtype=np.float32)
104
+
105
+ @property
106
+ def high(self) -> np.ndarray:
107
+ return np.full(self.dim, 1.0, dtype=np.float32)
108
+
109
+ def to_motors(self, quad, action: NDArray) -> NDArray:
110
+ from ..control.lqr import LQRController
111
+
112
+ # Ensure we have a cached LQR instance keyed on the quad object
113
+ lqr = getattr(self, "_lqr", None)
114
+ if lqr is None or lqr.quad is not quad:
115
+ lqr = LQRController(quad)
116
+ self._lqr = lqr
117
+
118
+ # Use the stored task target (set by env before calling to_motors)
119
+ target = getattr(self, "_task_target", quad.get_position())
120
+ lqr_motors = lqr.compute(target, self._target_yaw, dt=0.01)
121
+
122
+ # Apply learned delta
123
+ delta = action * self._scale
124
+ motors = lqr_motors + delta
125
+ return np.clip(motors, 0.0, 4000.0)
126
+
127
+
128
+ class VelocityLevelAction:
129
+ """High-level: desired world-frame velocity + yaw rate.
130
+
131
+ Action: [vx, vy, vz (m/s world), yaw_rate (rad/s)].
132
+
133
+ A built-in cascaded P-controller converts the desired velocity into
134
+ motor speeds::
135
+
136
+ vel_error → desired accel → desired attitude + thrust
137
+ attitude_error → angular velocity → torque
138
+ yaw_rate_error → torque
139
+
140
+ This avoids the accumulating-attitude problem of body-rate commands
141
+ and makes the policy's job much easier — the drone is inherently
142
+ stable and the policy only needs to decide *where* to go.
143
+
144
+ Gains are tuned for the default quadcopter parameters (m=1.0 kg,
145
+ I=diag(0.01,0.01,0.018), k_f=1e-6, arm=0.2).
146
+ """
147
+
148
+ dim = 4
149
+ _vel_low = -3.0
150
+ _vel_high = 3.0
151
+ _vz_low = -2.0
152
+ _vz_high = 2.0
153
+ _yaw_rate_low = -1.0
154
+ _yaw_rate_high = 1.0
155
+
156
+ # ── controller gains (tunable) ──
157
+ kp_vel: float = 2.0 # velocity → acceleration
158
+ kp_att: float = 6.0 # attitude error → angular velocity
159
+ kp_rate: float = 4.0 # angular-velocity error → torque
160
+ kp_yaw: float = 2.0 # yaw-rate error → torque
161
+
162
+ def __init__(self, **gains):
163
+ for k, v in gains.items():
164
+ setattr(self, k, v)
165
+
166
+ @property
167
+ def low(self) -> np.ndarray:
168
+ return np.array([self._vel_low, self._vel_low, self._vz_low, self._yaw_rate_low], dtype=np.float32)
169
+
170
+ @property
171
+ def high(self) -> np.ndarray:
172
+ return np.array([self._vel_high, self._vel_high, self._vz_high, self._yaw_rate_high], dtype=np.float32)
173
+
174
+ def to_motors(self, quad, action: NDArray) -> NDArray:
175
+ vx_des, vy_des, vz_des, yaw_rate_des = action
176
+
177
+ m = quad.mass
178
+ g = quad.g
179
+ hover = m * g
180
+
181
+ # ── 1. velocity → desired acceleration (world frame) ──
182
+ vel = quad.get_velocity()
183
+ ax_des = self.kp_vel * (vx_des - vel[0])
184
+ ay_des = self.kp_vel * (vy_des - vel[1])
185
+ az_des = self.kp_vel * (vz_des - vel[2])
186
+
187
+ # ── 2. desired accel → thrust + desired attitude ──
188
+ total_thrust = np.clip(m * (g + az_des), 0.2 * hover, 2.0 * hover)
189
+
190
+ # Small-angle mapping: ax_des ≈ g * tan(theta), ay_des ≈ -g * tan(phi)
191
+ phi_des = np.arctan2(-ay_des, g)
192
+ theta_des = np.arctan2(ax_des, g)
193
+ phi_des = np.clip(phi_des, -0.8, 0.8) # ~45° limit
194
+ theta_des = np.clip(theta_des, -0.8, 0.8)
195
+
196
+ # ── 3. attitude error → angular velocity command ──
197
+ att = quad.get_attitude() # [roll, pitch, yaw]
198
+ e_phi = phi_des - att[0]
199
+ e_theta = theta_des - att[1]
200
+ # Wrap to [-pi, pi]
201
+ e_phi = (e_phi + np.pi) % (2 * np.pi) - np.pi
202
+ e_theta = (e_theta + np.pi) % (2 * np.pi) - np.pi
203
+
204
+ p_des = self.kp_att * e_phi
205
+ q_des = self.kp_att * e_theta
206
+ r_des = yaw_rate_des
207
+
208
+ # ── 4. angular-velocity error → torque ──
209
+ omega = quad.get_angular_velocity() # [p, q, r] body frame
210
+ tau_phi = self.kp_rate * (p_des - omega[0])
211
+ tau_theta = self.kp_rate * (q_des - omega[1])
212
+ tau_psi = self.kp_yaw * (r_des - omega[2])
213
+
214
+ # ── 5. wrench → motor speeds ──
215
+ wrench = np.array([total_thrust, tau_phi, tau_theta, tau_psi])
216
+ allocator = ControlAllocator(
217
+ quad.allocation_matrix(), quad.min_motor_speed, quad.max_motor_speed
218
+ )
219
+ return allocator.allocate(wrench).motor_speeds