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.
@@ -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,3 @@
1
+ from flysplat.env import FlySplatEnv
2
+
3
+ __all__ = ["FlySplatEnv"]
@@ -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