hypersplat 0.0.1__tar.gz
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.
- hypersplat-0.0.1/PKG-INFO +87 -0
- hypersplat-0.0.1/README.md +65 -0
- hypersplat-0.0.1/flysplat/__init__.py +3 -0
- hypersplat-0.0.1/flysplat/dynamics.py +388 -0
- hypersplat-0.0.1/flysplat/env.py +184 -0
- hypersplat-0.0.1/flysplat/eval_l2f_tflite.py +415 -0
- hypersplat-0.0.1/flysplat/frames.py +67 -0
- hypersplat-0.0.1/flysplat/l2f_visual_tflite.py +711 -0
- hypersplat-0.0.1/flysplat/renderer.py +213 -0
- hypersplat-0.0.1/flysplat/rewards.py +13 -0
- hypersplat-0.0.1/hypersplat.egg-info/PKG-INFO +87 -0
- hypersplat-0.0.1/hypersplat.egg-info/SOURCES.txt +18 -0
- hypersplat-0.0.1/hypersplat.egg-info/dependency_links.txt +1 -0
- hypersplat-0.0.1/hypersplat.egg-info/requires.txt +14 -0
- hypersplat-0.0.1/hypersplat.egg-info/top_level.txt +1 -0
- hypersplat-0.0.1/pyproject.toml +34 -0
- hypersplat-0.0.1/setup.cfg +4 -0
- hypersplat-0.0.1/tests/test_env.py +84 -0
- hypersplat-0.0.1/tests/test_frames.py +71 -0
- hypersplat-0.0.1/tests/test_l2f_visual_tflite.py +161 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hypersplat
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Quadrotor simulator combining RotorPY dynamics with gsplat camera rendering.
|
|
5
|
+
Author-email: Jonas Eschmann <jonas.eschmann@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: numpy
|
|
10
|
+
Requires-Dist: scipy
|
|
11
|
+
Requires-Dist: gymnasium>=1.0
|
|
12
|
+
Requires-Dist: rotorpy>=2.1
|
|
13
|
+
Requires-Dist: torch
|
|
14
|
+
Requires-Dist: gsplat
|
|
15
|
+
Requires-Dist: plyfile
|
|
16
|
+
Requires-Dist: h5py
|
|
17
|
+
Requires-Dist: imageio
|
|
18
|
+
Requires-Dist: imageio-ffmpeg
|
|
19
|
+
Requires-Dist: tqdm
|
|
20
|
+
Provides-Extra: tflite
|
|
21
|
+
Requires-Dist: tensorflow; extra == "tflite"
|
|
22
|
+
|
|
23
|
+
# flysplat
|
|
24
|
+
|
|
25
|
+
A small single-drone quadrotor simulator combining **RotorPY** dynamics with **gsplat** Gaussian-splat camera rendering, exposed through a `gymnasium.Env` API.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
.venv/bin/pip install -e . plyfile imageio
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
(The venv is expected to already contain `rotorpy`, `gsplat`, `torch`, `gymnasium`.)
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import numpy as np
|
|
39
|
+
from flysplat import FlySplatEnv
|
|
40
|
+
|
|
41
|
+
env = FlySplatEnv(
|
|
42
|
+
scene_path="scene.ply", # 3DGS .ply (INRIA / nerfstudio export); or pass scene=Scene.synthetic()
|
|
43
|
+
obs_mode="dict", # "dict" | "image" | "state"
|
|
44
|
+
control_mode="cmd_motor_speeds",
|
|
45
|
+
image_size=(50, 80), # (H, W)
|
|
46
|
+
fov_deg=90.0, # horizontal FOV; or pass camera_intrinsics={fx,fy,cx,cy}
|
|
47
|
+
sim_rate=100, # Hz
|
|
48
|
+
max_episode_steps=500,
|
|
49
|
+
initial_state="hover", # "hover" | "random" | dict
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
obs, info = env.reset(seed=0)
|
|
53
|
+
for _ in range(100):
|
|
54
|
+
obs, reward, terminated, truncated, info = env.step(env.action_space.sample())
|
|
55
|
+
if terminated or truncated:
|
|
56
|
+
break
|
|
57
|
+
env.close()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Examples
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
.venv/bin/python examples/view_scene.py # orbit a synthetic scene -> orbit.gif
|
|
64
|
+
.venv/bin/python examples/random_policy.py # full env rollout with rendering -> rollout.gif
|
|
65
|
+
.venv/bin/python examples/random_policy.py --scene path/to/scene.ply
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Conventions
|
|
69
|
+
|
|
70
|
+
- **Drone world frame**: ENU (`+X` east, `+Y` north, `+Z` up) — RotorPY native.
|
|
71
|
+
- **Body frame**: FLU (`+X` forward, `+Y` left, `+Z` up).
|
|
72
|
+
- **Camera frame**: OpenCV (`+X` right, `+Y` down, `+Z` forward) — what gsplat consumes.
|
|
73
|
+
- Default camera mounting points the camera along body `+X`. Override via `body_to_camera`.
|
|
74
|
+
- For most pretrained scenes, the `.ply` world frame won't match ENU; calibrate `scene_transform` (a 4×4 ENU→scene mapping) once per scene.
|
|
75
|
+
|
|
76
|
+
## Layout
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
flysplat/
|
|
80
|
+
env.py FlySplatEnv (gymnasium.Env)
|
|
81
|
+
dynamics.py RotorPY Multirotor adapter (normalized [-1,1] actions)
|
|
82
|
+
renderer.py gsplat rasterization + .ply / synthetic loader
|
|
83
|
+
frames.py coordinate-frame transforms
|
|
84
|
+
rewards.py default hover reward
|
|
85
|
+
examples/ view_scene.py, random_policy.py
|
|
86
|
+
tests/ test_frames.py, test_env.py
|
|
87
|
+
```
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# flysplat
|
|
2
|
+
|
|
3
|
+
A small single-drone quadrotor simulator combining **RotorPY** dynamics with **gsplat** Gaussian-splat camera rendering, exposed through a `gymnasium.Env` API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
.venv/bin/pip install -e . plyfile imageio
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
(The venv is expected to already contain `rotorpy`, `gsplat`, `torch`, `gymnasium`.)
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import numpy as np
|
|
17
|
+
from flysplat import FlySplatEnv
|
|
18
|
+
|
|
19
|
+
env = FlySplatEnv(
|
|
20
|
+
scene_path="scene.ply", # 3DGS .ply (INRIA / nerfstudio export); or pass scene=Scene.synthetic()
|
|
21
|
+
obs_mode="dict", # "dict" | "image" | "state"
|
|
22
|
+
control_mode="cmd_motor_speeds",
|
|
23
|
+
image_size=(50, 80), # (H, W)
|
|
24
|
+
fov_deg=90.0, # horizontal FOV; or pass camera_intrinsics={fx,fy,cx,cy}
|
|
25
|
+
sim_rate=100, # Hz
|
|
26
|
+
max_episode_steps=500,
|
|
27
|
+
initial_state="hover", # "hover" | "random" | dict
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
obs, info = env.reset(seed=0)
|
|
31
|
+
for _ in range(100):
|
|
32
|
+
obs, reward, terminated, truncated, info = env.step(env.action_space.sample())
|
|
33
|
+
if terminated or truncated:
|
|
34
|
+
break
|
|
35
|
+
env.close()
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Examples
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
.venv/bin/python examples/view_scene.py # orbit a synthetic scene -> orbit.gif
|
|
42
|
+
.venv/bin/python examples/random_policy.py # full env rollout with rendering -> rollout.gif
|
|
43
|
+
.venv/bin/python examples/random_policy.py --scene path/to/scene.ply
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Conventions
|
|
47
|
+
|
|
48
|
+
- **Drone world frame**: ENU (`+X` east, `+Y` north, `+Z` up) — RotorPY native.
|
|
49
|
+
- **Body frame**: FLU (`+X` forward, `+Y` left, `+Z` up).
|
|
50
|
+
- **Camera frame**: OpenCV (`+X` right, `+Y` down, `+Z` forward) — what gsplat consumes.
|
|
51
|
+
- Default camera mounting points the camera along body `+X`. Override via `body_to_camera`.
|
|
52
|
+
- For most pretrained scenes, the `.ply` world frame won't match ENU; calibrate `scene_transform` (a 4×4 ENU→scene mapping) once per scene.
|
|
53
|
+
|
|
54
|
+
## Layout
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
flysplat/
|
|
58
|
+
env.py FlySplatEnv (gymnasium.Env)
|
|
59
|
+
dynamics.py RotorPY Multirotor adapter (normalized [-1,1] actions)
|
|
60
|
+
renderer.py gsplat rasterization + .ply / synthetic loader
|
|
61
|
+
frames.py coordinate-frame transforms
|
|
62
|
+
rewards.py default hover reward
|
|
63
|
+
examples/ view_scene.py, random_policy.py
|
|
64
|
+
tests/ test_frames.py, test_env.py
|
|
65
|
+
```
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Dynamics backends for normalized-action gym-style use."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from rotorpy.vehicles.crazyflie_params import quad_params as default_quad_params
|
|
9
|
+
from rotorpy.vehicles.multirotor import Multirotor
|
|
10
|
+
|
|
11
|
+
ROTORPY_TO_L2F = np.array([1, 2, 3, 0])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _scale_unit_to_range(x: np.ndarray, lo: float | np.ndarray, hi: float | np.ndarray) -> np.ndarray:
|
|
15
|
+
"""Map x from [-1, 1] to [lo, hi] with clipping."""
|
|
16
|
+
x = np.clip(x, -1.0, 1.0)
|
|
17
|
+
return (x + 1.0) * 0.5 * (hi - lo) + lo
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Action shape per control abstraction (normalized [-1,1] in env-space).
|
|
21
|
+
ACTION_DIMS: dict[str, int] = {
|
|
22
|
+
"cmd_motor_speeds": 4,
|
|
23
|
+
"cmd_motor_thrusts": 4,
|
|
24
|
+
"cmd_ctbm": 4, # [thrust, mx, my, mz]
|
|
25
|
+
"cmd_ctbr": 4, # [thrust, p, q, r]
|
|
26
|
+
"cmd_ctatt": 4, # [thrust, qx, qy, qz] (qw inferred for unit quat)
|
|
27
|
+
"cmd_vel": 3, # [vx, vy, vz]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
DYNAMICS_MODELS = ("rotorpy_crazyflie", "crazyflie_openmv")
|
|
31
|
+
|
|
32
|
+
CRAZYFLIE_OPENMV_PARAMS: dict[str, Any] = {
|
|
33
|
+
"rotor_positions": np.array(
|
|
34
|
+
[
|
|
35
|
+
[0.028, -0.028, 0.0],
|
|
36
|
+
[-0.028, -0.028, 0.0],
|
|
37
|
+
[-0.028, 0.028, 0.0],
|
|
38
|
+
[0.028, 0.028, 0.0],
|
|
39
|
+
],
|
|
40
|
+
dtype=np.float64,
|
|
41
|
+
),
|
|
42
|
+
"rotor_thrust_directions": np.array(
|
|
43
|
+
[[0.0, 0.0, 1.0]] * 4,
|
|
44
|
+
dtype=np.float64,
|
|
45
|
+
),
|
|
46
|
+
"rotor_torque_directions": np.array(
|
|
47
|
+
[
|
|
48
|
+
[0.0, 0.0, -1.0],
|
|
49
|
+
[0.0, 0.0, 1.0],
|
|
50
|
+
[0.0, 0.0, -1.0],
|
|
51
|
+
[0.0, 0.0, 1.0],
|
|
52
|
+
],
|
|
53
|
+
dtype=np.float64,
|
|
54
|
+
),
|
|
55
|
+
"rotor_thrust_coefficients": np.array(
|
|
56
|
+
[[0.01244825, 0.05290432, 0.15135338]] * 4,
|
|
57
|
+
dtype=np.float64,
|
|
58
|
+
),
|
|
59
|
+
"rotor_torque_constants": np.array([4.665e-3] * 4, dtype=np.float64),
|
|
60
|
+
"rotor_time_constants_rising": np.array([0.061] * 4, dtype=np.float64),
|
|
61
|
+
"rotor_time_constants_falling": np.array([0.061] * 4, dtype=np.float64),
|
|
62
|
+
"mass": 0.0477,
|
|
63
|
+
"gravity": np.array([0.0, 0.0, -9.81], dtype=np.float64),
|
|
64
|
+
"J": np.array(
|
|
65
|
+
[
|
|
66
|
+
[1.543e-5, 0.0, 0.0],
|
|
67
|
+
[0.0, 1.946e-5, 0.0],
|
|
68
|
+
[0.0, 0.0, 3.1957e-5],
|
|
69
|
+
],
|
|
70
|
+
dtype=np.float64,
|
|
71
|
+
),
|
|
72
|
+
"J_inv": np.array(
|
|
73
|
+
[
|
|
74
|
+
[64808.0, 0.0, 0.0],
|
|
75
|
+
[0.0, 51387.0, 0.0],
|
|
76
|
+
[0.0, 0.0, 31292.0],
|
|
77
|
+
],
|
|
78
|
+
dtype=np.float64,
|
|
79
|
+
),
|
|
80
|
+
"hovering_throttle_relative": 0.68,
|
|
81
|
+
"action_limit": (0.0, 1.0),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def hover_rotor_speed(quad_params: dict, g: float = 9.81) -> float:
|
|
86
|
+
"""Per-rotor speed that produces a thrust equal to 1/N of weight."""
|
|
87
|
+
return float(
|
|
88
|
+
np.sqrt(quad_params["mass"] * g / (quad_params["num_rotors"] * quad_params["k_eta"]))
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def default_initial_state(quad_params: dict) -> dict:
|
|
93
|
+
n = int(quad_params["num_rotors"])
|
|
94
|
+
omega_h = hover_rotor_speed(quad_params)
|
|
95
|
+
return {
|
|
96
|
+
"x": np.zeros(3, dtype=np.float64),
|
|
97
|
+
"v": np.zeros(3, dtype=np.float64),
|
|
98
|
+
"q": np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float64), # xyzw
|
|
99
|
+
"w": np.zeros(3, dtype=np.float64),
|
|
100
|
+
"wind": np.zeros(3, dtype=np.float64),
|
|
101
|
+
"rotor_speeds": np.full(n, omega_h, dtype=np.float64),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def crazyflie_openmv_summary() -> dict[str, float | str]:
|
|
106
|
+
params = CRAZYFLIE_OPENMV_PARAMS
|
|
107
|
+
return {
|
|
108
|
+
"model": "crazyflie_openmv",
|
|
109
|
+
"mass": float(params["mass"]),
|
|
110
|
+
"hover_command": float(params["hovering_throttle_relative"]),
|
|
111
|
+
"motor_time_constant": float(params["rotor_time_constants_rising"][0]),
|
|
112
|
+
"yaw_torque_per_thrust": float(params["rotor_torque_constants"][0]),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _crazyflie_openmv_quad_params() -> dict[str, Any]:
|
|
117
|
+
params = CRAZYFLIE_OPENMV_PARAMS
|
|
118
|
+
return {
|
|
119
|
+
"num_rotors": 4,
|
|
120
|
+
"mass": float(params["mass"]),
|
|
121
|
+
"Ixx": float(params["J"][0, 0]),
|
|
122
|
+
"Iyy": float(params["J"][1, 1]),
|
|
123
|
+
"Izz": float(params["J"][2, 2]),
|
|
124
|
+
"Ixy": float(params["J"][0, 1]),
|
|
125
|
+
"Ixz": float(params["J"][0, 2]),
|
|
126
|
+
"Iyz": float(params["J"][1, 2]),
|
|
127
|
+
"rotor_speed_min": float(params["action_limit"][0]),
|
|
128
|
+
"rotor_speed_max": float(params["action_limit"][1]),
|
|
129
|
+
"rotor_speed_hover": float(params["hovering_throttle_relative"]),
|
|
130
|
+
"tau_m": float(params["rotor_time_constants_rising"][0]),
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _crazyflie_openmv_initial_state() -> dict:
|
|
135
|
+
hover = float(CRAZYFLIE_OPENMV_PARAMS["hovering_throttle_relative"])
|
|
136
|
+
return {
|
|
137
|
+
"x": np.zeros(3, dtype=np.float64),
|
|
138
|
+
"v": np.zeros(3, dtype=np.float64),
|
|
139
|
+
"q": np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float64),
|
|
140
|
+
"w": np.zeros(3, dtype=np.float64),
|
|
141
|
+
"wind": np.zeros(3, dtype=np.float64),
|
|
142
|
+
"rotor_speeds": np.full(4, hover, dtype=np.float64),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _quat_xyzw_to_wxyz(q: np.ndarray) -> np.ndarray:
|
|
147
|
+
return np.array([q[3], q[0], q[1], q[2]], dtype=np.float64)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _quat_wxyz_to_xyzw(q: np.ndarray) -> np.ndarray:
|
|
151
|
+
return np.array([q[1], q[2], q[3], q[0]], dtype=np.float64)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _normalize_quat(q: np.ndarray) -> np.ndarray:
|
|
155
|
+
norm = np.linalg.norm(q)
|
|
156
|
+
if norm == 0 or not np.isfinite(norm):
|
|
157
|
+
return np.array([1.0, 0.0, 0.0, 0.0], dtype=np.float64)
|
|
158
|
+
return q / norm
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _quat_derivative_wxyz(q: np.ndarray, omega: np.ndarray) -> np.ndarray:
|
|
162
|
+
return 0.5 * np.array(
|
|
163
|
+
[
|
|
164
|
+
-q[1] * omega[0] - q[2] * omega[1] - q[3] * omega[2],
|
|
165
|
+
q[0] * omega[0] + q[2] * omega[2] - q[3] * omega[1],
|
|
166
|
+
q[0] * omega[1] + q[3] * omega[0] - q[1] * omega[2],
|
|
167
|
+
q[0] * omega[2] + q[1] * omega[1] - q[2] * omega[0],
|
|
168
|
+
],
|
|
169
|
+
dtype=np.float64,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _rotate_vector_by_quat_wxyz(q: np.ndarray, v: np.ndarray) -> np.ndarray:
|
|
174
|
+
q_vec = q[1:]
|
|
175
|
+
twice_cross = 2.0 * np.cross(q_vec, v)
|
|
176
|
+
return v + q[0] * twice_cross + np.cross(q_vec, twice_cross)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Dynamics:
|
|
180
|
+
"""Normalized-action dynamics facade with RotorPY and RLtools-compatible backends."""
|
|
181
|
+
|
|
182
|
+
def __init__(
|
|
183
|
+
self,
|
|
184
|
+
control_mode: str = "cmd_motor_speeds",
|
|
185
|
+
quad_params: dict | None = None,
|
|
186
|
+
sim_rate: float = 100.0,
|
|
187
|
+
aero: bool = True,
|
|
188
|
+
dynamics_model: str = "rotorpy_crazyflie",
|
|
189
|
+
# Limits for higher-level control modes. Match RotorPY's QuadrotorEnv defaults.
|
|
190
|
+
max_body_rate: tuple[float, float, float] = (7.0, 7.0, 3.0),
|
|
191
|
+
max_vel: float = 4.0 / np.sqrt(4),
|
|
192
|
+
):
|
|
193
|
+
if control_mode not in ACTION_DIMS:
|
|
194
|
+
raise ValueError(f"unknown control_mode {control_mode!r}; supported: {list(ACTION_DIMS)}")
|
|
195
|
+
if dynamics_model not in DYNAMICS_MODELS:
|
|
196
|
+
raise ValueError(f"unknown dynamics_model {dynamics_model!r}; supported: {list(DYNAMICS_MODELS)}")
|
|
197
|
+
self.control_mode = control_mode
|
|
198
|
+
self.dynamics_model = dynamics_model
|
|
199
|
+
self.dt = 1.0 / float(sim_rate)
|
|
200
|
+
self.max_body_rate = np.asarray(max_body_rate, dtype=np.float64)
|
|
201
|
+
self.max_vel = float(max_vel)
|
|
202
|
+
|
|
203
|
+
if dynamics_model == "crazyflie_openmv":
|
|
204
|
+
if control_mode != "cmd_motor_speeds":
|
|
205
|
+
raise ValueError("crazyflie_openmv currently supports only control_mode='cmd_motor_speeds'")
|
|
206
|
+
if quad_params is not None:
|
|
207
|
+
raise ValueError("quad_params cannot be combined with dynamics_model='crazyflie_openmv'")
|
|
208
|
+
self.quad_params = _crazyflie_openmv_quad_params()
|
|
209
|
+
self._vehicle = None
|
|
210
|
+
self._rltools_params = deepcopy(CRAZYFLIE_OPENMV_PARAMS)
|
|
211
|
+
self.max_thrust = self._rltools_total_thrust(float(self._rltools_params["action_limit"][1]))
|
|
212
|
+
self.min_thrust = self._rltools_total_thrust(float(self._rltools_params["action_limit"][0]))
|
|
213
|
+
self.state: dict = _crazyflie_openmv_initial_state()
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
self.quad_params = deepcopy(quad_params if quad_params is not None else default_quad_params)
|
|
217
|
+
self._vehicle = Multirotor(
|
|
218
|
+
quad_params=self.quad_params,
|
|
219
|
+
initial_state=default_initial_state(self.quad_params),
|
|
220
|
+
control_abstraction=control_mode,
|
|
221
|
+
aero=aero,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Cache thrust limits derived from rotor limits (used by ctbr/ctbm/ctatt).
|
|
225
|
+
n = int(self.quad_params["num_rotors"])
|
|
226
|
+
k_eta = float(self.quad_params["k_eta"])
|
|
227
|
+
omega_max = float(self.quad_params["rotor_speed_max"])
|
|
228
|
+
omega_min = float(self.quad_params["rotor_speed_min"])
|
|
229
|
+
self.max_thrust = n * k_eta * omega_max ** 2
|
|
230
|
+
self.min_thrust = n * k_eta * omega_min ** 2
|
|
231
|
+
|
|
232
|
+
self.state: dict = default_initial_state(self.quad_params)
|
|
233
|
+
|
|
234
|
+
# ---- public API ------------------------------------------------------
|
|
235
|
+
@property
|
|
236
|
+
def action_dim(self) -> int:
|
|
237
|
+
return ACTION_DIMS[self.control_mode]
|
|
238
|
+
|
|
239
|
+
@property
|
|
240
|
+
def num_rotors(self) -> int:
|
|
241
|
+
return int(self.quad_params["num_rotors"])
|
|
242
|
+
|
|
243
|
+
def default_initial_state(self) -> dict:
|
|
244
|
+
if self.dynamics_model == "crazyflie_openmv":
|
|
245
|
+
return _crazyflie_openmv_initial_state()
|
|
246
|
+
return default_initial_state(self.quad_params)
|
|
247
|
+
|
|
248
|
+
def reset(self, initial_state: dict | None = None) -> dict:
|
|
249
|
+
self.state = (
|
|
250
|
+
deepcopy(initial_state) if initial_state is not None
|
|
251
|
+
else self.default_initial_state()
|
|
252
|
+
)
|
|
253
|
+
return deepcopy(self.state)
|
|
254
|
+
|
|
255
|
+
def step(self, action_norm: np.ndarray) -> dict:
|
|
256
|
+
action_norm = np.asarray(action_norm, dtype=np.float64).reshape(-1)
|
|
257
|
+
if action_norm.shape[0] != self.action_dim:
|
|
258
|
+
raise ValueError(f"action shape {action_norm.shape} != ({self.action_dim},)")
|
|
259
|
+
if self.dynamics_model == "crazyflie_openmv":
|
|
260
|
+
self.state = self._rltools_step(action_norm)
|
|
261
|
+
return deepcopy(self.state)
|
|
262
|
+
control = self._action_to_control(action_norm)
|
|
263
|
+
self.state = self._vehicle.step(self.state, control, self.dt)
|
|
264
|
+
return deepcopy(self.state)
|
|
265
|
+
|
|
266
|
+
def flat_state(self) -> np.ndarray:
|
|
267
|
+
"""[pos(3), vel(3), quat_xyzw(4), body_rates(3)] -> shape (13,) float32."""
|
|
268
|
+
s = self.state
|
|
269
|
+
return np.concatenate([s["x"], s["v"], s["q"], s["w"]]).astype(np.float32)
|
|
270
|
+
|
|
271
|
+
# ---- internals -------------------------------------------------------
|
|
272
|
+
def _action_to_control(self, a: np.ndarray) -> dict[str, Any]:
|
|
273
|
+
m = self.control_mode
|
|
274
|
+
if m == "cmd_motor_speeds":
|
|
275
|
+
speeds = _scale_unit_to_range(
|
|
276
|
+
a,
|
|
277
|
+
float(self.quad_params["rotor_speed_min"]),
|
|
278
|
+
float(self.quad_params["rotor_speed_max"]),
|
|
279
|
+
)
|
|
280
|
+
return {"cmd_motor_speeds": speeds}
|
|
281
|
+
if m == "cmd_motor_thrusts":
|
|
282
|
+
k_eta = float(self.quad_params["k_eta"])
|
|
283
|
+
thrust_max = k_eta * float(self.quad_params["rotor_speed_max"]) ** 2
|
|
284
|
+
thrust_min = k_eta * float(self.quad_params["rotor_speed_min"]) ** 2
|
|
285
|
+
thrusts = _scale_unit_to_range(a, thrust_min, thrust_max)
|
|
286
|
+
return {"cmd_motor_thrusts": thrusts}
|
|
287
|
+
if m == "cmd_ctbr":
|
|
288
|
+
thrust = _scale_unit_to_range(np.atleast_1d(a[0]), self.min_thrust, self.max_thrust)[0]
|
|
289
|
+
cmd_w = a[1:] * self.max_body_rate
|
|
290
|
+
return {"cmd_thrust": float(thrust), "cmd_w": cmd_w}
|
|
291
|
+
if m == "cmd_ctbm":
|
|
292
|
+
thrust = _scale_unit_to_range(np.atleast_1d(a[0]), self.min_thrust, self.max_thrust)[0]
|
|
293
|
+
# Rough moment caps: max_thrust * arm length is conservative; use inertia*max_rate / dt-ish.
|
|
294
|
+
inertia = np.diag([self.quad_params["Ixx"], self.quad_params["Iyy"], self.quad_params["Izz"]])
|
|
295
|
+
max_moment = inertia @ self.max_body_rate / 0.1 # 100ms time-to-rate envelope
|
|
296
|
+
cmd_moment = a[1:] * max_moment
|
|
297
|
+
return {"cmd_thrust": float(thrust), "cmd_moment": cmd_moment}
|
|
298
|
+
if m == "cmd_ctatt":
|
|
299
|
+
thrust = _scale_unit_to_range(np.atleast_1d(a[0]), self.min_thrust, self.max_thrust)[0]
|
|
300
|
+
qx, qy, qz = a[1] * 0.5, a[2] * 0.5, a[3] * 0.5 # bound to ~half-circle
|
|
301
|
+
qw = float(np.sqrt(max(0.0, 1.0 - qx * qx - qy * qy - qz * qz)))
|
|
302
|
+
return {"cmd_thrust": float(thrust), "cmd_q": np.array([qx, qy, qz, qw], dtype=np.float64)}
|
|
303
|
+
if m == "cmd_vel":
|
|
304
|
+
cmd_v = a * self.max_vel
|
|
305
|
+
return {"cmd_v": cmd_v}
|
|
306
|
+
raise AssertionError(f"unhandled control_mode {m}")
|
|
307
|
+
|
|
308
|
+
def _rltools_total_thrust(self, command: float) -> float:
|
|
309
|
+
coeff = self._rltools_params["rotor_thrust_coefficients"]
|
|
310
|
+
thrust = coeff[:, 0] + coeff[:, 1] * command + coeff[:, 2] * command * command
|
|
311
|
+
return float(np.sum(thrust * self._rltools_params["rotor_thrust_directions"][:, 2]))
|
|
312
|
+
|
|
313
|
+
def _pack_rltools_state(self, state: dict) -> np.ndarray:
|
|
314
|
+
rotor_speeds = np.asarray(state.get("rotor_speeds"), dtype=np.float64).reshape(4)
|
|
315
|
+
return np.concatenate(
|
|
316
|
+
[
|
|
317
|
+
np.asarray(state["x"], dtype=np.float64).reshape(3),
|
|
318
|
+
_quat_xyzw_to_wxyz(np.asarray(state["q"], dtype=np.float64).reshape(4)),
|
|
319
|
+
np.asarray(state["v"], dtype=np.float64).reshape(3),
|
|
320
|
+
np.asarray(state["w"], dtype=np.float64).reshape(3),
|
|
321
|
+
rotor_speeds[ROTORPY_TO_L2F],
|
|
322
|
+
]
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
def _unpack_rltools_state(self, y: np.ndarray) -> dict:
|
|
326
|
+
rpm_l2f = np.clip(y[13:17], self._rltools_params["action_limit"][0], self._rltools_params["action_limit"][1])
|
|
327
|
+
rpm_rotorpy = np.empty(4, dtype=np.float64)
|
|
328
|
+
rpm_rotorpy[ROTORPY_TO_L2F] = rpm_l2f
|
|
329
|
+
return {
|
|
330
|
+
"x": y[0:3].copy(),
|
|
331
|
+
"v": y[7:10].copy(),
|
|
332
|
+
"q": _quat_wxyz_to_xyzw(_normalize_quat(y[3:7])),
|
|
333
|
+
"w": y[10:13].copy(),
|
|
334
|
+
"wind": np.zeros(3, dtype=np.float64),
|
|
335
|
+
"rotor_speeds": rpm_rotorpy,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
def _rltools_step(self, action_norm: np.ndarray) -> dict:
|
|
339
|
+
lo, hi = self._rltools_params["action_limit"]
|
|
340
|
+
action_l2f = np.clip(action_norm, -1.0, 1.0)[ROTORPY_TO_L2F]
|
|
341
|
+
motor_command = _scale_unit_to_range(action_l2f, float(lo), float(hi))
|
|
342
|
+
y = self._pack_rltools_state(self.state)
|
|
343
|
+
k1 = self._rltools_derivative(y, motor_command)
|
|
344
|
+
k2 = self._rltools_derivative(y + 0.5 * self.dt * k1, motor_command)
|
|
345
|
+
k3 = self._rltools_derivative(y + 0.5 * self.dt * k2, motor_command)
|
|
346
|
+
k4 = self._rltools_derivative(y + self.dt * k3, motor_command)
|
|
347
|
+
next_y = y + (self.dt / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
|
|
348
|
+
next_y[3:7] = _normalize_quat(next_y[3:7])
|
|
349
|
+
next_y[13:17] = np.clip(next_y[13:17], lo, hi)
|
|
350
|
+
return self._unpack_rltools_state(next_y)
|
|
351
|
+
|
|
352
|
+
def _rltools_derivative(self, y: np.ndarray, motor_command: np.ndarray) -> np.ndarray:
|
|
353
|
+
params = self._rltools_params
|
|
354
|
+
q = y[3:7]
|
|
355
|
+
velocity = y[7:10]
|
|
356
|
+
omega = y[10:13]
|
|
357
|
+
rpm = y[13:17]
|
|
358
|
+
|
|
359
|
+
thrust = np.zeros(3, dtype=np.float64)
|
|
360
|
+
torque = np.zeros(3, dtype=np.float64)
|
|
361
|
+
coeff = params["rotor_thrust_coefficients"]
|
|
362
|
+
for rotor_i in range(4):
|
|
363
|
+
command = rpm[rotor_i]
|
|
364
|
+
thrust_magnitude = (
|
|
365
|
+
coeff[rotor_i, 0]
|
|
366
|
+
+ coeff[rotor_i, 1] * command
|
|
367
|
+
+ coeff[rotor_i, 2] * command * command
|
|
368
|
+
)
|
|
369
|
+
rotor_thrust = params["rotor_thrust_directions"][rotor_i] * thrust_magnitude
|
|
370
|
+
thrust += rotor_thrust
|
|
371
|
+
torque += params["rotor_torque_directions"][rotor_i] * (
|
|
372
|
+
thrust_magnitude * params["rotor_torque_constants"][rotor_i]
|
|
373
|
+
)
|
|
374
|
+
torque += np.cross(params["rotor_positions"][rotor_i], rotor_thrust)
|
|
375
|
+
|
|
376
|
+
y_dot = np.zeros_like(y)
|
|
377
|
+
y_dot[0:3] = velocity
|
|
378
|
+
y_dot[3:7] = _quat_derivative_wxyz(q, omega)
|
|
379
|
+
y_dot[7:10] = _rotate_vector_by_quat_wxyz(q, thrust) / params["mass"] + params["gravity"]
|
|
380
|
+
angular_momentum = params["J"] @ omega
|
|
381
|
+
y_dot[10:13] = params["J_inv"] @ (torque - np.cross(omega, angular_momentum))
|
|
382
|
+
tau = np.where(
|
|
383
|
+
motor_command >= rpm,
|
|
384
|
+
params["rotor_time_constants_rising"],
|
|
385
|
+
params["rotor_time_constants_falling"],
|
|
386
|
+
)
|
|
387
|
+
y_dot[13:17] = (motor_command - rpm) / tau
|
|
388
|
+
return y_dot
|