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
drones_sim/rl/env.py ADDED
@@ -0,0 +1,195 @@
1
+ """Gymnasium environment wrapping QuadcopterDynamics.
2
+
3
+ ``QuadcopterEnv`` is the single integration point::
4
+
5
+ env = QuadcopterEnv(task=HoverTask(), action_param=ThrustBodyRatesAction(),
6
+ obs_builder=RelativeStateObs(), reward_fn=reward_hover)
7
+ obs, _ = env.reset()
8
+ for _ in range(1000):
9
+ action = policy(obs)
10
+ obs, reward, terminated, truncated, info = env.step(action)
11
+
12
+ It follows the standard ``gymnasium.Env`` contract and is compatible with
13
+ Stable-Baselines3, CleanRL, Tianshou, and RLlib.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+
20
+ from drones_sim.dynamics import QuadcopterDynamics
21
+
22
+ # gymnasium is an optional dependency (rl extra). Every import site tests
23
+ # for availability so the module can be imported for type-checking and
24
+ # contract tests even without torch/gym installed.
25
+ try:
26
+ import gymnasium as gym
27
+ from gymnasium import spaces
28
+ except ImportError: # pragma: no cover
29
+ gym = None # type: ignore[assignment]
30
+ spaces = None # type: ignore[assignment]
31
+
32
+ from .actions import ThrustBodyRatesAction
33
+ from .observations import RelativeStateObs
34
+ from .reward import RewardConfig, reward
35
+ from .tasks import HoverTask
36
+
37
+ _GRAVITY = np.array([0.0, 0.0, 9.81])
38
+ _TILT_LIMIT = np.deg2rad(75.0) # crash threshold
39
+ _POS_LIMIT = 50.0 # out-of-bounds
40
+
41
+
42
+ class QuadcopterEnv(gym.Env if gym else object):
43
+ """Gymnasium wrapper around ``QuadcopterDynamics``.
44
+
45
+ Parameters
46
+ ----------
47
+ task : Task
48
+ Defines the target position/velocity (HoverTask, WaypointTask, etc.).
49
+ action_param : ActionParameterization
50
+ Converts policy action → motor speeds.
51
+ obs_builder : ObservationBuilder
52
+ Builds the observation vector from the quad state.
53
+ reward_fn : callable
54
+ ``reward(quad, task, action, step_idx, cfg, prev_action) -> float``.
55
+ reward_cfg : RewardConfig
56
+ Weights for the reward function.
57
+ dt : float
58
+ Simulation time-step [s].
59
+ episode_len_s : float
60
+ Maximum episode duration [s].
61
+ render_mode : str or None
62
+ 'viser' for live 3D viewer (requires running viser server).
63
+ seed : int or None
64
+ Random seed for reproducibility.
65
+ """
66
+
67
+ metadata = {"render_modes": ["human", "viser"]}
68
+
69
+ def __init__(
70
+ self,
71
+ task=None,
72
+ action_param=None,
73
+ obs_builder=None,
74
+ reward_fn=None,
75
+ reward_cfg=None,
76
+ dt: float = 0.01,
77
+ episode_len_s: float = 10.0,
78
+ render_mode: str | None = None,
79
+ seed: int | None = None,
80
+ ):
81
+ if gym is None:
82
+ raise ImportError("gymnasium is required for QuadcopterEnv. "
83
+ "Install it with: pip install gymnasium")
84
+ super().__init__()
85
+ self.dt = dt
86
+ self.max_steps = int(episode_len_s / dt)
87
+ self.task = task if task is not None else HoverTask()
88
+ self.action_param = action_param if action_param is not None else ThrustBodyRatesAction()
89
+ self.obs_builder = obs_builder if obs_builder is not None else RelativeStateObs()
90
+ self.reward_fn = reward_fn if reward_fn is not None else reward
91
+ self.reward_cfg = reward_cfg if reward_cfg is not None else RewardConfig()
92
+ self.render_mode = render_mode
93
+
94
+ self.observation_space = spaces.Box(
95
+ low=-np.inf, high=np.inf,
96
+ shape=(self.obs_builder.dim,), dtype=np.float32,
97
+ )
98
+ self.action_space = spaces.Box(
99
+ low=self.action_param.low, high=self.action_param.high, dtype=np.float32,
100
+ )
101
+
102
+ self.quad = QuadcopterDynamics(motor_time_constant=0.04)
103
+ self._step_idx = 0
104
+ self._rng = np.random.default_rng(seed)
105
+ self._prev_action: np.ndarray | None = None
106
+
107
+ # ------------------------------------------------------------------
108
+ # gym.Env contract
109
+ # ------------------------------------------------------------------
110
+
111
+ def reset(self, *, seed: int | None = None, options: dict | None = None):
112
+ super().reset(seed=seed)
113
+ self._rng = np.random.default_rng(seed)
114
+ # Seed the global RNG for SensorNoiseModel compatibility
115
+ np.random.seed(seed)
116
+ # Start at the task target position so the drone doesn't begin
117
+ # on the ground where a single negative-thrust step is fatal.
118
+ # Small random offset (±0.5 m) forces the policy to generalize.
119
+ target_pos = self.task.target_pos(self.quad)
120
+ offset = self._rng.uniform(-0.5, 0.5, size=3)
121
+ self.quad.reset(position=target_pos + offset)
122
+ if hasattr(self.task, 'reset'):
123
+ self.task.reset(self.quad, rng=self._rng)
124
+ if hasattr(self.obs_builder, '_prev_action'):
125
+ self.obs_builder._prev_action = np.zeros(4, dtype=np.float32)
126
+ self._step_idx = 0
127
+ self._prev_action = None
128
+ # Pre-fill motor lag with hover-speed so the drone doesn't dip.
129
+ hover_w = np.sqrt(self.quad.mass * self.quad.g / (4 * self.quad.k_f))
130
+ self.quad.motor_states = np.full(4, hover_w)
131
+ obs = self.obs_builder.build(self.quad, self.task, action=None)
132
+ return obs.astype(np.float32), {"t": 0.0}
133
+
134
+ def step(self, action):
135
+ # Let LQRResidualAction know the task target
136
+ self.action_param._task_target = self.task.target_pos(self.quad)
137
+ motor_speeds = self.action_param.to_motors(self.quad, action)
138
+ self.quad.update(self.dt, motor_speeds)
139
+ self._step_idx += 1
140
+
141
+ obs = self.obs_builder.build(self.quad, self.task, action=action)
142
+ r = self.reward_fn(
143
+ self.quad, self.task, action, self._step_idx,
144
+ self.reward_cfg, self._prev_action,
145
+ )
146
+ self._prev_action = action.copy()
147
+
148
+ terminated = self._is_crashed()
149
+ truncated = self._step_idx >= self.max_steps
150
+ info = {"t": self._step_idx * self.dt, "motor_speeds": motor_speeds}
151
+ return obs.astype(np.float32), float(r), terminated, truncated, info
152
+
153
+ def _is_crashed(self) -> bool:
154
+ pos = self.quad.get_position()
155
+ att = self.quad.get_attitude()
156
+ if pos[2] < 0.0:
157
+ return True
158
+ if abs(att[0]) > _TILT_LIMIT or abs(att[1]) > _TILT_LIMIT:
159
+ return True
160
+ if np.linalg.norm(pos) > _POS_LIMIT:
161
+ return True
162
+ return False
163
+
164
+ # ------------------------------------------------------------------
165
+ # Rendering
166
+ # ------------------------------------------------------------------
167
+
168
+ def render(self):
169
+ if self.render_mode == "viser":
170
+ _render_viser(self)
171
+
172
+ def close(self):
173
+ pass
174
+
175
+
176
+ def _render_viser(env):
177
+ """Minimal viser rendering — push current pose to a viser frame."""
178
+ try:
179
+ import viser
180
+ except ImportError: # pragma: no cover
181
+ return
182
+ server = getattr(env, "_viser_server", None)
183
+ handle = getattr(env, "_viser_handle", None)
184
+ if server is None:
185
+ server = viser.ViserServer(port=8082)
186
+ handle = server.scene.add_frame("/rl_quad")
187
+ env._viser_server = server
188
+ env._viser_handle = handle
189
+ pos = env.quad.get_position()
190
+ from viser import transforms as vtf
191
+
192
+ from drones_sim.math_utils import quat_to_rotation_matrix
193
+ wxyz = vtf.SO3.from_matrix(quat_to_rotation_matrix(env.quad.get_quaternion())).wxyz
194
+ handle.wxyz = tuple(wxyz)
195
+ handle.position = tuple(pos)
@@ -0,0 +1,40 @@
1
+ """Observation builders for the quadcopter RL environment.
2
+
3
+ Each builder produces a ``dim``-dimensional numpy vector.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import numpy as np
9
+ from numpy.typing import NDArray
10
+
11
+
12
+ class RelativeStateObs:
13
+ """Default observation: position error + velocity + quaternion + body rates.
14
+
15
+ 17-D vector: [pos_err(3), vel(3), quat(4), omega(3), prev_action(4)].
16
+ All quantities in the world frame where applicable.
17
+ """
18
+
19
+ dim = 17
20
+
21
+ def __init__(self) -> None:
22
+ self._prev_action = np.zeros(4, dtype=np.float32)
23
+
24
+ def build(self, quad, task, action: NDArray | None = None) -> NDArray:
25
+ target = task.target_pos(quad)
26
+ pos_err = target - quad.get_position()
27
+ vel = quad.get_velocity()
28
+ quat = quad.get_quaternion()
29
+ omega = quad.get_angular_velocity()
30
+
31
+ if action is not None:
32
+ self._prev_action = action.astype(np.float32)
33
+
34
+ return np.concatenate([
35
+ pos_err.astype(np.float32),
36
+ vel.astype(np.float32),
37
+ quat.astype(np.float32),
38
+ omega.astype(np.float32),
39
+ self._prev_action,
40
+ ])
@@ -0,0 +1,69 @@
1
+ """Modular reward function for the quadcopter environment.
2
+
3
+ Usage::
4
+
5
+ cfg = RewardConfig()
6
+ r = reward(quad, task, action, step_idx, cfg, prev_action)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ import numpy as np
14
+
15
+
16
+ @dataclass
17
+ class RewardConfig:
18
+ """Weights for the sum-of-terms reward function.
19
+
20
+ All terms are negative (cost) except survival and reach bonuses.
21
+ """
22
+
23
+ w_pos: float = 1.0 # position tracking (L2)
24
+ w_vel: float = 0.05 # velocity tracking
25
+ w_attitude: float = 0.1 # tilt penalty
26
+ w_action: float = 0.01 # control effort (L2)
27
+ w_action_d: float = 0.005 # control rate (Δu)
28
+ w_alive: float = 0.1 # survival bonus per step
29
+ w_reach: float = 10.0 # one-shot bonus for reaching target
30
+ w_crash: float = -10.0 # one-shot penalty for crashing
31
+ reach_radius: float = 0.1 # m — target-reached threshold
32
+
33
+
34
+ def reward(quad, task, action: np.ndarray, step_idx: int,
35
+ cfg: RewardConfig, prev_action: np.ndarray | None = None) -> float:
36
+ """Compute per-step reward.
37
+
38
+ Parameters
39
+ ----------
40
+ quad: QuadcopterDynamics instance.
41
+ task: Task instance with ``target_pos(quad)`` and ``target_vel(quad)``.
42
+ action: The action that was just executed.
43
+ step_idx: Current step (0-based).
44
+ cfg: Reward weight configuration.
45
+ prev_action: Action from the previous step (for Δu penalty).
46
+ """
47
+ pos_err = float(np.linalg.norm(task.target_pos(quad) - quad.get_position()))
48
+ vel = quad.get_velocity()
49
+ att = quad.get_attitude()
50
+ tilt = float(np.sqrt(att[0]**2 + att[1]**2))
51
+
52
+ r_pos = -cfg.w_pos * pos_err
53
+ r_vel = -cfg.w_vel * float(np.linalg.norm(vel - task.target_vel(quad)))
54
+ r_att = -cfg.w_attitude * tilt
55
+ r_action = -cfg.w_action * float(np.sum(action**2))
56
+
57
+ r_action_d = 0.0
58
+ if prev_action is not None:
59
+ r_action_d = -cfg.w_action_d * float(np.linalg.norm(action - prev_action))
60
+
61
+ r_alive = cfg.w_alive
62
+
63
+ r = r_pos + r_vel + r_att + r_action + r_action_d + r_alive
64
+
65
+ # Terminal bonus — only at the last step
66
+ if pos_err < cfg.reach_radius:
67
+ r += cfg.w_reach
68
+
69
+ return float(r)
drones_sim/rl/tasks.py ADDED
@@ -0,0 +1,73 @@
1
+ """Task definitions for the quadcopter RL environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from numpy.typing import NDArray
7
+
8
+
9
+ class HoverTask:
10
+ """Hover at a fixed target position — the simplest baseline task."""
11
+
12
+ def __init__(self, target: tuple[float, float, float] = (0.0, 0.0, 2.0)) -> None:
13
+ self._target = np.array(target, dtype=float)
14
+
15
+ def reset(self, quad, rng: np.random.Generator | None = None) -> None:
16
+ pass
17
+
18
+ def target_pos(self, _quad) -> NDArray:
19
+ return self._target
20
+
21
+ def target_vel(self, _quad) -> NDArray:
22
+ return np.zeros(3)
23
+
24
+
25
+ class WaypointTask:
26
+ """Sequence of waypoints — advance when within reach_radius."""
27
+
28
+ def __init__(
29
+ self,
30
+ waypoints: list[tuple[float, float, float]],
31
+ reach_radius: float = 0.2,
32
+ ) -> None:
33
+ self.waypoints = [np.array(w, dtype=float) for w in waypoints]
34
+ self.reach_radius = reach_radius
35
+ self._idx = 0
36
+
37
+ def reset(self, quad, rng: np.random.Generator | None = None) -> None:
38
+ self._idx = 0
39
+
40
+ def target_pos(self, quad) -> NDArray:
41
+ if self._idx < len(self.waypoints):
42
+ dist = np.linalg.norm(self.waypoints[self._idx] - quad.get_position())
43
+ if dist < self.reach_radius and self._idx < len(self.waypoints) - 1:
44
+ self._idx += 1
45
+ return self.waypoints[self._idx]
46
+ return self.waypoints[-1]
47
+
48
+ def target_vel(self, _quad) -> NDArray:
49
+ return np.zeros(3)
50
+
51
+
52
+ class TrackingTask:
53
+ """Track a pre-generated TrajectoryData (circular / minimum-snap).
54
+
55
+ The closest-in-time reference position is returned at each step.
56
+ """
57
+
58
+ def __init__(self, traj) -> None:
59
+ self.traj = traj
60
+ self._step = 0
61
+
62
+ def reset(self, quad, rng: np.random.Generator | None = None) -> None:
63
+ self._step = 0
64
+
65
+ def target_pos(self, _quad, advance: bool = True) -> NDArray:
66
+ if advance:
67
+ self._step += 1
68
+ idx = min(self._step, len(self.traj.position) - 1)
69
+ return self.traj.position[idx]
70
+
71
+ def target_vel(self, _quad) -> NDArray:
72
+ idx = min(self._step, len(self.traj.position) - 1)
73
+ return self.traj.velocity[idx] if hasattr(self.traj, 'velocity') else np.zeros(3)
@@ -0,0 +1,3 @@
1
+ from .gps import GPSConfig, GPSData, GPSSimulator # noqa: F401
2
+ from .imu import IMUConfig, IMUData, IMUMeasurement, IMUSimulator # noqa: F401
3
+ from .models import SensorNoiseModel, TemperatureModel # noqa: F401
@@ -0,0 +1,158 @@
1
+ """GPS sensor simulator following the IMUSimulator pattern.
2
+
3
+ Simulates a low-rate GNSS receiver producing noisy 3-D position and
4
+ optionally 3-D velocity measurements from a ground-truth trajectory.
5
+
6
+ Typical usage
7
+ -------------
8
+ >>> gps = GPSSimulator()
9
+ >>> gps_data = gps.simulate(traj)
10
+ >>> for k in range(len(gps_data.t)):
11
+ ... if k % gps_update_steps == 0:
12
+ ... ekf.correct_position(gps_data.position[k])
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ import numpy as np
20
+ from numpy.typing import NDArray
21
+
22
+ from ..trajectory import TrajectoryData
23
+
24
+
25
+ @dataclass
26
+ class GPSConfig:
27
+ """Configuration parameters for the GPS sensor model."""
28
+ position_noise_std: float = 1.0 # m (1-sigma, each axis)
29
+ velocity_noise_std: float = 0.1 # m/s (1-sigma, each axis)
30
+ update_rate: float = 5.0 # Hz — GPS fix rate
31
+ dropout_probability: float = 0.0 # probability of fix dropout per epoch
32
+
33
+
34
+ @dataclass
35
+ class GPSData:
36
+ """Container for GPS measurement sequences.
37
+
38
+ Attributes
39
+ ----------
40
+ t:
41
+ Time stamps of GPS epochs [s], shape (K,).
42
+ position:
43
+ Noisy 3-D position [m], shape (K, 3).
44
+ velocity:
45
+ Noisy 3-D velocity [m/s], shape (K, 3).
46
+ valid:
47
+ Boolean mask: True when the fix is valid (no dropout), shape (K,).
48
+ """
49
+ t: NDArray
50
+ position: NDArray # (K, 3)
51
+ velocity: NDArray # (K, 3)
52
+ valid: NDArray # (K,) bool
53
+
54
+
55
+ class GPSSimulator:
56
+ """Simulate a low-rate GPS receiver from a ground-truth trajectory.
57
+
58
+ The noise model adds independent Gaussian noise to each position and
59
+ velocity axis at the configured update rate. An optional dropout
60
+ probability marks epochs as invalid (simulating urban-canyon outages).
61
+
62
+ Parameters
63
+ ----------
64
+ config:
65
+ Sensor configuration. Defaults to ``GPSConfig()``.
66
+ seed:
67
+ Random seed for reproducibility. ``None`` for non-deterministic.
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ config: GPSConfig | None = None,
73
+ seed: int | None = None,
74
+ ) -> None:
75
+ self.config = config or GPSConfig()
76
+ self._seed = seed
77
+ self._rng = np.random.default_rng(seed)
78
+
79
+ def reset(self, seed: int | None = None) -> None:
80
+ """Reset the receiver's local random stream for a new episode."""
81
+ self._rng = np.random.default_rng(self._seed if seed is None else seed)
82
+
83
+ def simulate(self, traj: TrajectoryData) -> GPSData:
84
+ """Generate GPS measurements from a full ground-truth trajectory.
85
+
86
+ Epochs are spaced ``1 / update_rate`` seconds apart, aligned with
87
+ the nearest sample in ``traj.t``.
88
+
89
+ Parameters
90
+ ----------
91
+ traj:
92
+ Ground-truth trajectory.
93
+
94
+ Returns
95
+ -------
96
+ GPSData
97
+ GPS measurement sequences at the GPS update rate.
98
+ """
99
+ cfg = self.config
100
+ dt_gps = 1.0 / cfg.update_rate
101
+ t_max = float(traj.t[-1])
102
+ t_epochs = np.arange(0.0, t_max + dt_gps * 0.5, dt_gps)
103
+
104
+ K = len(t_epochs)
105
+ pos_meas = np.zeros((K, 3))
106
+ vel_meas = np.zeros((K, 3))
107
+ valid = np.ones(K, dtype=bool)
108
+
109
+ for i, te in enumerate(t_epochs):
110
+ # Nearest sample index
111
+ idx = int(np.argmin(np.abs(traj.t - te)))
112
+
113
+ # Add Gaussian noise
114
+ pos_noise = self._rng.normal(0.0, cfg.position_noise_std, 3)
115
+ vel_noise = self._rng.normal(0.0, cfg.velocity_noise_std, 3)
116
+
117
+ pos_meas[i] = traj.position[idx] + pos_noise
118
+ vel_meas[i] = traj.velocity[idx] + vel_noise
119
+
120
+ # Optional dropout
121
+ if cfg.dropout_probability > 0.0:
122
+ valid[i] = self._rng.random() >= cfg.dropout_probability
123
+
124
+ return GPSData(
125
+ t=t_epochs,
126
+ position=pos_meas,
127
+ velocity=vel_meas,
128
+ valid=valid,
129
+ )
130
+
131
+ def step(
132
+ self,
133
+ true_position: NDArray,
134
+ true_velocity: NDArray,
135
+ ) -> tuple[NDArray, NDArray, bool]:
136
+ """Single-step GPS measurement for closed-loop simulation.
137
+
138
+ Parameters
139
+ ----------
140
+ true_position:
141
+ Ground-truth 3-D position [m].
142
+ true_velocity:
143
+ Ground-truth 3-D velocity [m/s].
144
+
145
+ Returns
146
+ -------
147
+ pos_meas, vel_meas, is_valid
148
+ """
149
+ cfg = self.config
150
+ pos_noise = self._rng.normal(0.0, cfg.position_noise_std, 3)
151
+ vel_noise = self._rng.normal(0.0, cfg.velocity_noise_std, 3)
152
+ is_valid = (cfg.dropout_probability == 0.0 or
153
+ self._rng.random() >= cfg.dropout_probability)
154
+ return (
155
+ true_position + pos_noise,
156
+ true_velocity + vel_noise,
157
+ is_valid,
158
+ )