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
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
|
+
])
|
drones_sim/rl/reward.py
ADDED
|
@@ -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,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
|
+
)
|