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/state.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Typed data exchanged between the simulation, controllers, and estimators.
|
|
2
|
+
|
|
3
|
+
The original package passed partially documented NumPy arrays between modules.
|
|
4
|
+
These small value objects make frame conventions and units explicit while still
|
|
5
|
+
offering vector conversions for numerical algorithms and legacy callers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from numpy.typing import NDArray
|
|
14
|
+
|
|
15
|
+
from .math_utils import quat_normalize, quat_to_euler, quat_to_rotation_matrix
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _vector(value: NDArray, size: int, name: str) -> NDArray:
|
|
19
|
+
array = np.asarray(value, dtype=float).copy()
|
|
20
|
+
if array.shape != (size,):
|
|
21
|
+
raise ValueError(f"{name} must have shape ({size},), got {array.shape}")
|
|
22
|
+
if not np.all(np.isfinite(array)):
|
|
23
|
+
raise ValueError(f"{name} must contain only finite values")
|
|
24
|
+
return array
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class VehicleState:
|
|
29
|
+
"""Complete quadcopter state using ENU/world and FRU/body conventions.
|
|
30
|
+
|
|
31
|
+
``quaternion`` is Hamilton ``[w, x, y, z]`` and rotates body vectors into
|
|
32
|
+
the world frame. ``body_rates`` are expressed in the body frame [rad/s].
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
position: NDArray = field(default_factory=lambda: np.zeros(3))
|
|
36
|
+
velocity: NDArray = field(default_factory=lambda: np.zeros(3))
|
|
37
|
+
quaternion: NDArray = field(
|
|
38
|
+
default_factory=lambda: np.array([1.0, 0.0, 0.0, 0.0])
|
|
39
|
+
)
|
|
40
|
+
body_rates: NDArray = field(default_factory=lambda: np.zeros(3))
|
|
41
|
+
motor_speeds: NDArray = field(default_factory=lambda: np.zeros(4))
|
|
42
|
+
time: float = 0.0
|
|
43
|
+
|
|
44
|
+
def __post_init__(self) -> None:
|
|
45
|
+
self.position = _vector(self.position, 3, "position")
|
|
46
|
+
self.velocity = _vector(self.velocity, 3, "velocity")
|
|
47
|
+
self.quaternion = quat_normalize(_vector(self.quaternion, 4, "quaternion"))
|
|
48
|
+
if np.linalg.norm(self.quaternion) < 1e-12:
|
|
49
|
+
raise ValueError("quaternion must have non-zero norm")
|
|
50
|
+
self.body_rates = _vector(self.body_rates, 3, "body_rates")
|
|
51
|
+
self.motor_speeds = _vector(self.motor_speeds, 4, "motor_speeds")
|
|
52
|
+
self.time = float(self.time)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_vector(
|
|
56
|
+
cls,
|
|
57
|
+
vector: NDArray,
|
|
58
|
+
*,
|
|
59
|
+
motor_speeds: NDArray | None = None,
|
|
60
|
+
time: float = 0.0,
|
|
61
|
+
) -> VehicleState:
|
|
62
|
+
"""Construct from ``[position, velocity, quaternion, body_rates]``."""
|
|
63
|
+
value = _vector(vector, 13, "state vector")
|
|
64
|
+
return cls(
|
|
65
|
+
position=value[0:3],
|
|
66
|
+
velocity=value[3:6],
|
|
67
|
+
quaternion=value[6:10],
|
|
68
|
+
body_rates=value[10:13],
|
|
69
|
+
motor_speeds=np.zeros(4) if motor_speeds is None else motor_speeds,
|
|
70
|
+
time=time,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def as_vector(self) -> NDArray:
|
|
74
|
+
return np.concatenate(
|
|
75
|
+
[self.position, self.velocity, self.quaternion, self.body_rates]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def rotation_matrix(self) -> NDArray:
|
|
80
|
+
"""World-from-body rotation matrix."""
|
|
81
|
+
return quat_to_rotation_matrix(self.quaternion)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def euler(self) -> NDArray:
|
|
85
|
+
"""Roll, pitch, yaw view for display and compatibility only."""
|
|
86
|
+
return quat_to_euler(self.quaternion)
|
|
87
|
+
|
|
88
|
+
def copy(self) -> VehicleState:
|
|
89
|
+
return VehicleState(
|
|
90
|
+
self.position,
|
|
91
|
+
self.velocity,
|
|
92
|
+
self.quaternion,
|
|
93
|
+
self.body_rates,
|
|
94
|
+
self.motor_speeds,
|
|
95
|
+
self.time,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class TrajectorySetpoint:
|
|
101
|
+
"""Flat-output reference consumed by position controllers."""
|
|
102
|
+
|
|
103
|
+
position: NDArray = field(default_factory=lambda: np.zeros(3))
|
|
104
|
+
velocity: NDArray = field(default_factory=lambda: np.zeros(3))
|
|
105
|
+
acceleration: NDArray = field(default_factory=lambda: np.zeros(3))
|
|
106
|
+
yaw: float = 0.0
|
|
107
|
+
yaw_rate: float = 0.0
|
|
108
|
+
|
|
109
|
+
def __post_init__(self) -> None:
|
|
110
|
+
self.position = _vector(self.position, 3, "setpoint.position")
|
|
111
|
+
self.velocity = _vector(self.velocity, 3, "setpoint.velocity")
|
|
112
|
+
self.acceleration = _vector(self.acceleration, 3, "setpoint.acceleration")
|
|
113
|
+
self.yaw = float(self.yaw)
|
|
114
|
+
self.yaw_rate = float(self.yaw_rate)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class ControlOutput:
|
|
119
|
+
"""Controller output and allocator diagnostics."""
|
|
120
|
+
|
|
121
|
+
thrust: float
|
|
122
|
+
torque: NDArray
|
|
123
|
+
motor_speeds: NDArray
|
|
124
|
+
achieved_wrench: NDArray
|
|
125
|
+
saturated: bool = False
|
|
126
|
+
|
|
127
|
+
def __post_init__(self) -> None:
|
|
128
|
+
self.thrust = float(self.thrust)
|
|
129
|
+
self.torque = _vector(self.torque, 3, "torque")
|
|
130
|
+
self.motor_speeds = _vector(self.motor_speeds, 4, "motor_speeds")
|
|
131
|
+
self.achieved_wrench = _vector(
|
|
132
|
+
self.achieved_wrench, 4, "achieved_wrench"
|
|
133
|
+
)
|
drones_sim/trajectory.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Trajectory generators for drone simulation.
|
|
2
|
+
|
|
3
|
+
All generators return arrays of shape (N, 3) for position, velocity,
|
|
4
|
+
acceleration, and (N, 4) for quaternion orientation [w,x,y,z].
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from math import factorial
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from numpy.polynomial.polynomial import polyder
|
|
14
|
+
from numpy.polynomial.polynomial import polyval as polyval1d
|
|
15
|
+
from numpy.typing import NDArray
|
|
16
|
+
from scipy.linalg import lstsq
|
|
17
|
+
from scipy.spatial.transform import Rotation as R
|
|
18
|
+
|
|
19
|
+
from .math_utils import quat_from_euler
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class TrajectoryData:
|
|
24
|
+
"""Container for a generated trajectory."""
|
|
25
|
+
t: NDArray
|
|
26
|
+
position: NDArray
|
|
27
|
+
velocity: NDArray
|
|
28
|
+
acceleration: NDArray
|
|
29
|
+
orientation_quat: NDArray # (N, 4) wxyz
|
|
30
|
+
angular_velocity: NDArray # (N, 3)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def generate_hover_accel_cruise(
|
|
34
|
+
duration: float = 10.0,
|
|
35
|
+
sample_rate: int = 100,
|
|
36
|
+
max_vel: float = 5.0,
|
|
37
|
+
hover_time: float = 1.0,
|
|
38
|
+
accel_time: float = 2.0,
|
|
39
|
+
cruise_time: float = 4.0,
|
|
40
|
+
decel_time: float = 2.0,
|
|
41
|
+
) -> TrajectoryData:
|
|
42
|
+
"""Generate a drone-like trajectory: hover -> accelerate -> cruise -> decelerate -> hover.
|
|
43
|
+
|
|
44
|
+
This is the canonical test trajectory used across the sensor-fusion examples.
|
|
45
|
+
"""
|
|
46
|
+
t = np.linspace(0, duration, int(duration * sample_rate))
|
|
47
|
+
dt = 1.0 / sample_rate
|
|
48
|
+
n = len(t)
|
|
49
|
+
|
|
50
|
+
position = np.zeros((n, 3))
|
|
51
|
+
velocity = np.zeros((n, 3))
|
|
52
|
+
acceleration = np.zeros((n, 3))
|
|
53
|
+
orientation_quat = np.zeros((n, 4))
|
|
54
|
+
orientation_quat[:, 0] = 1.0 # identity
|
|
55
|
+
|
|
56
|
+
hover_samples = int(hover_time * sample_rate)
|
|
57
|
+
accel_samples = int(accel_time * sample_rate)
|
|
58
|
+
cruise_samples = int(cruise_time * sample_rate)
|
|
59
|
+
decel_samples = int(decel_time * sample_rate)
|
|
60
|
+
|
|
61
|
+
max_accel = max_vel / accel_time
|
|
62
|
+
|
|
63
|
+
# --- Acceleration phase ---
|
|
64
|
+
for i in range(hover_samples, hover_samples + accel_samples):
|
|
65
|
+
t_a = (i - hover_samples) * dt
|
|
66
|
+
acceleration[i, 0] = max_accel
|
|
67
|
+
velocity[i, 0] = max_accel * t_a
|
|
68
|
+
position[i, 0] = 0.5 * max_accel * t_a**2
|
|
69
|
+
|
|
70
|
+
pitch = -0.2 * min(1.0, t_a / accel_time)
|
|
71
|
+
orientation_quat[i] = quat_from_euler(0, pitch, 0)
|
|
72
|
+
|
|
73
|
+
# --- Cruise phase ---
|
|
74
|
+
cruise_start = hover_samples + accel_samples
|
|
75
|
+
pos_at_cruise = position[cruise_start - 1, 0] if cruise_start > 0 else 0.0
|
|
76
|
+
for i in range(cruise_start, cruise_start + cruise_samples):
|
|
77
|
+
t_c = (i - cruise_start) * dt
|
|
78
|
+
velocity[i, 0] = max_vel
|
|
79
|
+
position[i, 0] = pos_at_cruise + max_vel * t_c
|
|
80
|
+
|
|
81
|
+
position[i, 2] = 0.2 * np.sin(t_c * 2)
|
|
82
|
+
velocity[i, 2] = 0.4 * np.cos(t_c * 2)
|
|
83
|
+
acceleration[i, 2] = -0.8 * np.sin(t_c * 2)
|
|
84
|
+
|
|
85
|
+
roll = 0.1 * np.sin(t_c * 3)
|
|
86
|
+
orientation_quat[i] = quat_from_euler(roll, -0.05, 0)
|
|
87
|
+
|
|
88
|
+
# --- Deceleration phase ---
|
|
89
|
+
decel_start = cruise_start + cruise_samples
|
|
90
|
+
pos_at_decel = position[decel_start - 1, 0] if decel_start > 0 else 0.0
|
|
91
|
+
for i in range(decel_start, decel_start + decel_samples):
|
|
92
|
+
t_d = (i - decel_start) * dt
|
|
93
|
+
acceleration[i, 0] = -max_accel
|
|
94
|
+
velocity[i, 0] = max(0, max_vel - max_accel * t_d)
|
|
95
|
+
position[i, 0] = pos_at_decel + max_vel * t_d - 0.5 * max_accel * t_d**2
|
|
96
|
+
|
|
97
|
+
pitch = 0.2 * min(1.0, t_d / decel_time)
|
|
98
|
+
orientation_quat[i] = quat_from_euler(0, pitch, 0)
|
|
99
|
+
|
|
100
|
+
# --- Final hover ---
|
|
101
|
+
final_start = decel_start + decel_samples
|
|
102
|
+
if final_start < n:
|
|
103
|
+
final_pos = position[final_start - 1].copy()
|
|
104
|
+
for i in range(final_start, n):
|
|
105
|
+
position[i] = final_pos
|
|
106
|
+
|
|
107
|
+
# --- Angular velocity from quaternion differences ---
|
|
108
|
+
angular_velocity = np.zeros((n, 3))
|
|
109
|
+
for i in range(1, n):
|
|
110
|
+
q_curr = np.roll(orientation_quat[i], -1) # to xyzw
|
|
111
|
+
q_prev = np.roll(orientation_quat[i - 1], -1)
|
|
112
|
+
r_diff = R.from_quat(q_curr) * R.from_quat(q_prev).inv()
|
|
113
|
+
angular_velocity[i] = r_diff.as_rotvec() / dt
|
|
114
|
+
|
|
115
|
+
return TrajectoryData(
|
|
116
|
+
t=t,
|
|
117
|
+
position=position,
|
|
118
|
+
velocity=velocity,
|
|
119
|
+
acceleration=acceleration,
|
|
120
|
+
orientation_quat=orientation_quat,
|
|
121
|
+
angular_velocity=angular_velocity,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def generate_circular(
|
|
126
|
+
duration: float = 10.0,
|
|
127
|
+
sample_rate: int = 100,
|
|
128
|
+
radius: float = 5.0,
|
|
129
|
+
angular_vel: float = 0.5,
|
|
130
|
+
) -> TrajectoryData:
|
|
131
|
+
"""Generate circular motion with varying height and orientation."""
|
|
132
|
+
t = np.linspace(0, duration, int(duration * sample_rate))
|
|
133
|
+
dt = 1.0 / sample_rate
|
|
134
|
+
n = len(t)
|
|
135
|
+
|
|
136
|
+
position = np.zeros((n, 3))
|
|
137
|
+
velocity = np.zeros((n, 3))
|
|
138
|
+
acceleration = np.zeros((n, 3))
|
|
139
|
+
orientation_quat = np.zeros((n, 4))
|
|
140
|
+
orientation_quat[:, 0] = 1.0
|
|
141
|
+
|
|
142
|
+
for i in range(n):
|
|
143
|
+
ti = t[i]
|
|
144
|
+
position[i, 0] = radius * np.cos(angular_vel * ti)
|
|
145
|
+
position[i, 1] = radius * np.sin(angular_vel * ti)
|
|
146
|
+
position[i, 2] = 0.5 * np.sin(0.5 * angular_vel * ti)
|
|
147
|
+
|
|
148
|
+
velocity[i, 0] = -radius * angular_vel * np.sin(angular_vel * ti)
|
|
149
|
+
velocity[i, 1] = radius * angular_vel * np.cos(angular_vel * ti)
|
|
150
|
+
velocity[i, 2] = 0.25 * angular_vel * np.cos(0.5 * angular_vel * ti)
|
|
151
|
+
|
|
152
|
+
roll = 0.2 * np.sin(ti)
|
|
153
|
+
pitch = 0.1 * np.cos(2 * ti)
|
|
154
|
+
yaw = np.arctan2(velocity[i, 1], velocity[i, 0])
|
|
155
|
+
orientation_quat[i] = quat_from_euler(roll, pitch, yaw)
|
|
156
|
+
|
|
157
|
+
# Numerical acceleration
|
|
158
|
+
for i in range(1, n):
|
|
159
|
+
acceleration[i] = (velocity[i] - velocity[i - 1]) / dt
|
|
160
|
+
|
|
161
|
+
# Angular velocity from quaternion differences
|
|
162
|
+
angular_velocity = np.zeros((n, 3))
|
|
163
|
+
for i in range(1, n):
|
|
164
|
+
q_curr = np.roll(orientation_quat[i], -1)
|
|
165
|
+
q_prev = np.roll(orientation_quat[i - 1], -1)
|
|
166
|
+
r_diff = R.from_quat(q_curr) * R.from_quat(q_prev).inv()
|
|
167
|
+
angular_velocity[i] = r_diff.as_rotvec() / dt
|
|
168
|
+
|
|
169
|
+
return TrajectoryData(
|
|
170
|
+
t=t,
|
|
171
|
+
position=position,
|
|
172
|
+
velocity=velocity,
|
|
173
|
+
acceleration=acceleration,
|
|
174
|
+
orientation_quat=orientation_quat,
|
|
175
|
+
angular_velocity=angular_velocity,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def generate_waypoint_trajectory(
|
|
180
|
+
waypoints: list[tuple[float, float, float]],
|
|
181
|
+
waypoint_times: list[float],
|
|
182
|
+
dt: float = 0.01,
|
|
183
|
+
) -> TrajectoryData:
|
|
184
|
+
"""Generate a piecewise-linear trajectory through waypoints.
|
|
185
|
+
|
|
186
|
+
Used by the quadcopter controller simulation.
|
|
187
|
+
"""
|
|
188
|
+
t_max = waypoint_times[-1]
|
|
189
|
+
t = np.arange(0, t_max, dt)
|
|
190
|
+
n = len(t)
|
|
191
|
+
|
|
192
|
+
position = np.zeros((n, 3))
|
|
193
|
+
velocity = np.zeros((n, 3))
|
|
194
|
+
acceleration = np.zeros((n, 3))
|
|
195
|
+
|
|
196
|
+
wp = np.array(waypoints)
|
|
197
|
+
for i in range(n):
|
|
198
|
+
ti = t[i]
|
|
199
|
+
for j in range(len(waypoint_times) - 1):
|
|
200
|
+
if waypoint_times[j] <= ti < waypoint_times[j + 1]:
|
|
201
|
+
alpha = (ti - waypoint_times[j]) / (waypoint_times[j + 1] - waypoint_times[j])
|
|
202
|
+
idx_next = min(j + 1, len(waypoints) - 1)
|
|
203
|
+
position[i] = wp[j] * (1 - alpha) + wp[idx_next] * alpha
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
# Numerical velocity and acceleration
|
|
207
|
+
for i in range(1, n):
|
|
208
|
+
velocity[i] = (position[i] - position[i - 1]) / dt
|
|
209
|
+
for i in range(1, n):
|
|
210
|
+
acceleration[i] = (velocity[i] - velocity[i - 1]) / dt
|
|
211
|
+
|
|
212
|
+
orientation_quat = np.zeros((n, 4))
|
|
213
|
+
orientation_quat[:, 0] = 1.0
|
|
214
|
+
angular_velocity = np.zeros((n, 3))
|
|
215
|
+
|
|
216
|
+
return TrajectoryData(
|
|
217
|
+
t=t,
|
|
218
|
+
position=position,
|
|
219
|
+
velocity=velocity,
|
|
220
|
+
acceleration=acceleration,
|
|
221
|
+
orientation_quat=orientation_quat,
|
|
222
|
+
angular_velocity=angular_velocity,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def generate_minimum_snap(
|
|
227
|
+
waypoints: list[tuple[float, float, float]],
|
|
228
|
+
times: list[float] | None = None,
|
|
229
|
+
order: int = 7,
|
|
230
|
+
dt: float = 0.01,
|
|
231
|
+
) -> TrajectoryData:
|
|
232
|
+
"""Generate a C^3-continuous minimum-snap polynomial trajectory.
|
|
233
|
+
|
|
234
|
+
Fits piecewise polynomials of ``order`` through ``waypoints``, minimising
|
|
235
|
+
the integral of squared snap (4th time-derivative of position).
|
|
236
|
+
|
|
237
|
+
Constraints enforced:
|
|
238
|
+
|
|
239
|
+
* Position equality at each waypoint.
|
|
240
|
+
* Zero velocity, acceleration, and jerk at the first and the last waypoint.
|
|
241
|
+
* C^3 continuity (velocity, acceleration, jerk) at every interior junction.
|
|
242
|
+
|
|
243
|
+
The resulting QP is solved via the KKT bordered system using
|
|
244
|
+
``scipy.linalg.lstsq``.
|
|
245
|
+
|
|
246
|
+
Parameters
|
|
247
|
+
----------
|
|
248
|
+
waypoints:
|
|
249
|
+
List of (x, y, z) positions. Requires at least 2 points.
|
|
250
|
+
times:
|
|
251
|
+
Absolute time-stamps [s] for each waypoint (length = len(waypoints)).
|
|
252
|
+
If ``None``, segment durations are allocated proportionally to
|
|
253
|
+
Euclidean inter-waypoint distances (minimum 0.1 s per segment).
|
|
254
|
+
order:
|
|
255
|
+
Polynomial degree per segment. Must be >= 7 for minimum-snap.
|
|
256
|
+
dt:
|
|
257
|
+
Sampling period [s] for the returned ``TrajectoryData``.
|
|
258
|
+
|
|
259
|
+
Returns
|
|
260
|
+
-------
|
|
261
|
+
TrajectoryData
|
|
262
|
+
Consistent position, velocity, and acceleration arrays.
|
|
263
|
+
Orientation is the identity quaternion (yaw tracking not computed).
|
|
264
|
+
"""
|
|
265
|
+
order = max(order, 7) # minimum degree for 4 BCs per side
|
|
266
|
+
n = order + 1 # coefficients per segment
|
|
267
|
+
snap = 4 # derivative order being minimised
|
|
268
|
+
|
|
269
|
+
wp = np.array(waypoints, dtype=float) # (M+1, 3)
|
|
270
|
+
M = len(wp) - 1
|
|
271
|
+
if M < 1:
|
|
272
|
+
raise ValueError("At least 2 waypoints are required.")
|
|
273
|
+
|
|
274
|
+
# ------------------------------------------------------------------ #
|
|
275
|
+
# Time allocation
|
|
276
|
+
# ------------------------------------------------------------------ #
|
|
277
|
+
if times is None:
|
|
278
|
+
dists = np.linalg.norm(np.diff(wp, axis=0), axis=1)
|
|
279
|
+
total = float(np.sum(dists))
|
|
280
|
+
seg_T = (dists / total * float(M)) if total > 1e-9 else np.ones(M)
|
|
281
|
+
seg_T = np.maximum(seg_T, 0.1)
|
|
282
|
+
t_arr = np.concatenate([[0.0], np.cumsum(seg_T)])
|
|
283
|
+
else:
|
|
284
|
+
t_arr = np.array(times, dtype=float)
|
|
285
|
+
if len(t_arr) != M + 1:
|
|
286
|
+
raise ValueError("len(times) must equal len(waypoints).")
|
|
287
|
+
seg_T = np.diff(t_arr)
|
|
288
|
+
|
|
289
|
+
T = seg_T # durations, shape (M,)
|
|
290
|
+
N = M * n # total unknowns per axis
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------ #
|
|
293
|
+
# Block-diagonal snap cost matrix Q (N x N)
|
|
294
|
+
# ------------------------------------------------------------------ #
|
|
295
|
+
Q_full = np.zeros((N, N))
|
|
296
|
+
for k in range(M):
|
|
297
|
+
Tk = T[k]
|
|
298
|
+
for i in range(snap, n):
|
|
299
|
+
for j in range(snap, n):
|
|
300
|
+
r = i + j - 2 * snap + 1
|
|
301
|
+
Q_full[k*n + i, k*n + j] = (
|
|
302
|
+
factorial(i) // factorial(i - snap) *
|
|
303
|
+
factorial(j) // factorial(j - snap) *
|
|
304
|
+
Tk**r / r
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# ------------------------------------------------------------------ #
|
|
308
|
+
# Constraint matrix A (same for all axes; only b changes per axis)
|
|
309
|
+
# ------------------------------------------------------------------ #
|
|
310
|
+
def poly_row(k: int, tau: float, deriv: int) -> NDArray:
|
|
311
|
+
"""Global row vector for p_k^(deriv)(tau)."""
|
|
312
|
+
row = np.zeros(N)
|
|
313
|
+
for j in range(deriv, n):
|
|
314
|
+
row[k * n + j] = factorial(j) / factorial(j - deriv) * tau ** (j - deriv)
|
|
315
|
+
return row
|
|
316
|
+
|
|
317
|
+
con_rows: list[NDArray] = []
|
|
318
|
+
# 1. Start position of each segment
|
|
319
|
+
for k in range(M):
|
|
320
|
+
con_rows.append(poly_row(k, 0.0, 0))
|
|
321
|
+
# 2. End position of each segment
|
|
322
|
+
for k in range(M):
|
|
323
|
+
con_rows.append(poly_row(k, T[k], 0))
|
|
324
|
+
# 3. Zero vel/acc/jerk at trajectory start
|
|
325
|
+
for deriv in range(1, snap):
|
|
326
|
+
con_rows.append(poly_row(0, 0.0, deriv))
|
|
327
|
+
# 4. Zero vel/acc/jerk at trajectory end
|
|
328
|
+
for deriv in range(1, snap):
|
|
329
|
+
con_rows.append(poly_row(M - 1, T[M - 1], deriv))
|
|
330
|
+
# 5. C^3 continuity at interior junctions (vel, acc, jerk)
|
|
331
|
+
for k in range(M - 1):
|
|
332
|
+
for deriv in range(1, snap):
|
|
333
|
+
con_rows.append(poly_row(k, T[k], deriv) - poly_row(k + 1, 0.0, deriv))
|
|
334
|
+
|
|
335
|
+
A = np.array(con_rows) # (n_con, N)
|
|
336
|
+
n_con = A.shape[0]
|
|
337
|
+
|
|
338
|
+
# ------------------------------------------------------------------ #
|
|
339
|
+
# KKT bordered system min c'Qc s.t. Ac = b
|
|
340
|
+
# ------------------------------------------------------------------ #
|
|
341
|
+
KKT = np.block([
|
|
342
|
+
[Q_full, A.T ],
|
|
343
|
+
[A, np.zeros((n_con, n_con)) ],
|
|
344
|
+
])
|
|
345
|
+
|
|
346
|
+
coeffs = np.zeros((M, n, 3)) # [segment, coeff_idx, axis]
|
|
347
|
+
for d in range(3):
|
|
348
|
+
b = np.zeros(n_con)
|
|
349
|
+
for k in range(M):
|
|
350
|
+
b[k] = wp[k, d] # start of segment k
|
|
351
|
+
b[M + k] = wp[k + 1, d] # end of segment k
|
|
352
|
+
# All other entries are 0 (zero BCs and continuity)
|
|
353
|
+
|
|
354
|
+
rhs = np.zeros(N + n_con)
|
|
355
|
+
rhs[N:] = b
|
|
356
|
+
sol, *_ = lstsq(KKT, rhs, check_finite=False)
|
|
357
|
+
coeffs[:, :, d] = sol[:N].reshape(M, n)
|
|
358
|
+
|
|
359
|
+
# ------------------------------------------------------------------ #
|
|
360
|
+
# Sample trajectory
|
|
361
|
+
# ------------------------------------------------------------------ #
|
|
362
|
+
t_total = float(t_arr[-1])
|
|
363
|
+
t_out = np.arange(0.0, t_total, dt)
|
|
364
|
+
nn = len(t_out)
|
|
365
|
+
position = np.zeros((nn, 3))
|
|
366
|
+
velocity = np.zeros((nn, 3))
|
|
367
|
+
acceleration = np.zeros((nn, 3))
|
|
368
|
+
|
|
369
|
+
for k in range(M):
|
|
370
|
+
t_lo = t_arr[k]
|
|
371
|
+
t_hi = t_arr[k + 1] if k < M - 1 else t_arr[-1] + dt
|
|
372
|
+
mask = (t_out >= t_lo) & (t_out < t_hi)
|
|
373
|
+
tau = t_out[mask] - t_lo
|
|
374
|
+
for d in range(3):
|
|
375
|
+
c = coeffs[k, :, d]
|
|
376
|
+
position[mask, d] = polyval1d(tau, c)
|
|
377
|
+
velocity[mask, d] = polyval1d(tau, polyder(c, 1))
|
|
378
|
+
acceleration[mask, d] = polyval1d(tau, polyder(c, 2))
|
|
379
|
+
|
|
380
|
+
orientation_quat = np.zeros((nn, 4))
|
|
381
|
+
orientation_quat[:, 0] = 1.0 # identity
|
|
382
|
+
angular_velocity = np.zeros((nn, 3))
|
|
383
|
+
|
|
384
|
+
return TrajectoryData(
|
|
385
|
+
t=t_out,
|
|
386
|
+
position=position,
|
|
387
|
+
velocity=velocity,
|
|
388
|
+
acceleration=acceleration,
|
|
389
|
+
orientation_quat=orientation_quat,
|
|
390
|
+
angular_velocity=angular_velocity,
|
|
391
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .api import VisualizationBackend, visualize
|
|
2
|
+
from .dashboard import plot_simulation
|
|
3
|
+
from .plots import plot_ekf_results, plot_quadcopter_results # noqa: F401
|
|
4
|
+
from .rerun_viewer import (
|
|
5
|
+
RerunConfig,
|
|
6
|
+
log_simulation_rerun,
|
|
7
|
+
rerun_blueprint,
|
|
8
|
+
visualize_rerun,
|
|
9
|
+
)
|
|
10
|
+
from .viewer import DroneViewer # noqa: F401
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"DroneViewer",
|
|
14
|
+
"RerunConfig",
|
|
15
|
+
"VisualizationBackend",
|
|
16
|
+
"log_simulation_rerun",
|
|
17
|
+
"plot_ekf_results",
|
|
18
|
+
"plot_quadcopter_results",
|
|
19
|
+
"plot_simulation",
|
|
20
|
+
"rerun_blueprint",
|
|
21
|
+
"visualize",
|
|
22
|
+
"visualize_rerun",
|
|
23
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Backend-neutral visualization entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from ..simulation import SimulationResult
|
|
8
|
+
from .dashboard import plot_simulation
|
|
9
|
+
from .rerun_viewer import visualize_rerun
|
|
10
|
+
|
|
11
|
+
VisualizationBackend = Literal["rerun", "matplotlib", "viser"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def visualize(
|
|
15
|
+
result: SimulationResult,
|
|
16
|
+
*,
|
|
17
|
+
backend: VisualizationBackend = "rerun",
|
|
18
|
+
**kwargs,
|
|
19
|
+
):
|
|
20
|
+
"""Visualize a simulation result with Rerun by default.
|
|
21
|
+
|
|
22
|
+
``rerun`` accepts the arguments of :func:`visualize_rerun`, including
|
|
23
|
+
``mode``, ``path``, and ``url``. ``matplotlib`` accepts ``title``.
|
|
24
|
+
``viser`` accepts ``host``, ``port``, ``urdf_model``, or a prebuilt
|
|
25
|
+
``viewer`` and starts its blocking playback UI.
|
|
26
|
+
"""
|
|
27
|
+
if backend == "rerun":
|
|
28
|
+
return visualize_rerun(result, **kwargs)
|
|
29
|
+
if backend == "matplotlib":
|
|
30
|
+
return plot_simulation(result, **kwargs)
|
|
31
|
+
if backend == "viser":
|
|
32
|
+
# Keep the legacy backend lazy so Rerun/Matplotlib users do not need
|
|
33
|
+
# the optional Viser dependency installed.
|
|
34
|
+
from .viewer import DroneViewer
|
|
35
|
+
|
|
36
|
+
viewer = kwargs.pop("viewer", None)
|
|
37
|
+
host = kwargs.pop("host", "0.0.0.0")
|
|
38
|
+
port = kwargs.pop("port", 8080)
|
|
39
|
+
urdf_model = kwargs.pop("urdf_model", None)
|
|
40
|
+
if kwargs:
|
|
41
|
+
names = ", ".join(sorted(kwargs))
|
|
42
|
+
raise TypeError(f"unexpected Viser options: {names}")
|
|
43
|
+
if viewer is None:
|
|
44
|
+
viewer = DroneViewer(host=host, port=port)
|
|
45
|
+
viewer.playback_result(result, urdf_model=urdf_model)
|
|
46
|
+
return viewer
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"unknown visualization backend {backend!r}; "
|
|
49
|
+
"expected 'rerun', 'matplotlib', or 'viser'"
|
|
50
|
+
)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Result-native diagnostic figures for closed-loop simulations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from ..math_utils import quat_to_euler
|
|
9
|
+
from ..simulation import SimulationResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def plot_simulation(result: SimulationResult, *, title: str | None = None):
|
|
13
|
+
"""Build a compact engineering dashboard from a ``SimulationResult``."""
|
|
14
|
+
time = result.time
|
|
15
|
+
figure = plt.figure(figsize=(15, 9), constrained_layout=True)
|
|
16
|
+
grid = figure.add_gridspec(2, 3)
|
|
17
|
+
|
|
18
|
+
axis_path = figure.add_subplot(grid[:, 0], projection="3d")
|
|
19
|
+
axis_path.plot(*result.reference_position.T, "--", color="#2ca02c", label="reference")
|
|
20
|
+
axis_path.plot(*result.position.T, color="#1f77b4", label="truth")
|
|
21
|
+
axis_path.plot(*result.estimated_position.T, color="#ff7f0e", alpha=0.7, label="estimate")
|
|
22
|
+
axis_path.set(xlabel="x [m]", ylabel="y [m]", zlabel="z [m]", title="Flight path")
|
|
23
|
+
axis_path.legend(loc="best")
|
|
24
|
+
|
|
25
|
+
axis_position = figure.add_subplot(grid[0, 1])
|
|
26
|
+
labels = ("x", "y", "z")
|
|
27
|
+
for index, label in enumerate(labels):
|
|
28
|
+
axis_position.plot(time, result.position[:, index], label=f"{label} truth")
|
|
29
|
+
axis_position.plot(time, result.reference_position[:, index], "--", alpha=0.65)
|
|
30
|
+
axis_position.set(title="Position tracking", ylabel="position [m]")
|
|
31
|
+
axis_position.grid(alpha=0.25)
|
|
32
|
+
axis_position.legend(ncol=3, fontsize=8)
|
|
33
|
+
|
|
34
|
+
axis_error = figure.add_subplot(grid[0, 2])
|
|
35
|
+
tracking = np.linalg.norm(result.tracking_error, axis=1)
|
|
36
|
+
estimation = np.linalg.norm(result.estimation_error, axis=1)
|
|
37
|
+
axis_error.plot(time, tracking, label="tracking")
|
|
38
|
+
axis_error.plot(time, estimation, label="estimation")
|
|
39
|
+
axis_error.set(title="Position errors", ylabel="norm [m]")
|
|
40
|
+
axis_error.grid(alpha=0.25)
|
|
41
|
+
axis_error.legend()
|
|
42
|
+
|
|
43
|
+
axis_attitude = figure.add_subplot(grid[1, 1])
|
|
44
|
+
euler = np.array([quat_to_euler(value) for value in result.quaternion])
|
|
45
|
+
for index, label in enumerate(("roll", "pitch", "yaw")):
|
|
46
|
+
axis_attitude.plot(time, np.rad2deg(euler[:, index]), label=label)
|
|
47
|
+
axis_attitude.set(title="Attitude", xlabel="time [s]", ylabel="angle [deg]")
|
|
48
|
+
axis_attitude.grid(alpha=0.25)
|
|
49
|
+
axis_attitude.legend(ncol=3, fontsize=8)
|
|
50
|
+
|
|
51
|
+
axis_motor = figure.add_subplot(grid[1, 2])
|
|
52
|
+
axis_motor.plot(time, result.motor_speeds)
|
|
53
|
+
if np.any(result.allocation_saturated):
|
|
54
|
+
axis_motor.fill_between(
|
|
55
|
+
time,
|
|
56
|
+
0.0,
|
|
57
|
+
np.max(result.motor_speeds),
|
|
58
|
+
where=result.allocation_saturated,
|
|
59
|
+
color="#d62728",
|
|
60
|
+
alpha=0.12,
|
|
61
|
+
label="allocation limited",
|
|
62
|
+
)
|
|
63
|
+
axis_motor.set(
|
|
64
|
+
title="Actuator commands", xlabel="time [s]", ylabel="rotor speed [rad/s]"
|
|
65
|
+
)
|
|
66
|
+
axis_motor.grid(alpha=0.25)
|
|
67
|
+
|
|
68
|
+
metrics = result.summary()
|
|
69
|
+
heading = title or "Drone simulation"
|
|
70
|
+
figure.suptitle(
|
|
71
|
+
f"{heading} | tracking RMSE {metrics['tracking_rmse_m']:.3f} m | "
|
|
72
|
+
f"estimation RMSE {metrics['estimation_rmse_m']:.3f} m"
|
|
73
|
+
)
|
|
74
|
+
return figure
|