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,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
|
drones_sim/rl/actions.py
ADDED
|
@@ -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
|