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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""2D matplotlib plots for EKF and quadcopter simulation results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import matplotlib.pyplot as plt
|
|
6
|
+
import numpy as np
|
|
7
|
+
from numpy.typing import NDArray
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def plot_ekf_results(
|
|
11
|
+
t: NDArray,
|
|
12
|
+
true_pos: NDArray,
|
|
13
|
+
true_vel: NDArray,
|
|
14
|
+
true_quat: NDArray,
|
|
15
|
+
filt_pos: NDArray,
|
|
16
|
+
filt_vel: NDArray,
|
|
17
|
+
filt_quat: NDArray,
|
|
18
|
+
accel: NDArray | None = None,
|
|
19
|
+
gyro: NDArray | None = None,
|
|
20
|
+
mag: NDArray | None = None,
|
|
21
|
+
true_accel_bias: NDArray | None = None,
|
|
22
|
+
filt_accel_bias: NDArray | None = None,
|
|
23
|
+
true_gyro_bias: NDArray | None = None,
|
|
24
|
+
filt_gyro_bias: NDArray | None = None,
|
|
25
|
+
) -> plt.Figure:
|
|
26
|
+
"""Create a multi-panel figure comparing true vs filtered states."""
|
|
27
|
+
has_bias = true_accel_bias is not None and filt_accel_bias is not None
|
|
28
|
+
|
|
29
|
+
nrows = 3 if has_bias else 3
|
|
30
|
+
ncols = 2
|
|
31
|
+
fig, axes = plt.subplots(nrows, ncols, figsize=(14, 4 * nrows))
|
|
32
|
+
|
|
33
|
+
labels = ["X", "Y", "Z"]
|
|
34
|
+
colors = ["blue", "green", "red"]
|
|
35
|
+
|
|
36
|
+
# Position
|
|
37
|
+
ax = axes[0, 0]
|
|
38
|
+
for j in range(3):
|
|
39
|
+
ax.plot(t, true_pos[:, j], colors[j], ls="-", label=f"True {labels[j]}")
|
|
40
|
+
ax.plot(t, filt_pos[:, j], colors[j], ls="--", label=f"Filtered {labels[j]}")
|
|
41
|
+
ax.set_ylabel("Position (m)")
|
|
42
|
+
ax.set_title("Position")
|
|
43
|
+
ax.legend(fontsize=7)
|
|
44
|
+
ax.grid(True)
|
|
45
|
+
|
|
46
|
+
# Velocity
|
|
47
|
+
ax = axes[0, 1]
|
|
48
|
+
for j in range(3):
|
|
49
|
+
ax.plot(t, true_vel[:, j], colors[j], ls="-", label=f"True V{labels[j].lower()}")
|
|
50
|
+
ax.plot(t, filt_vel[:, j], colors[j], ls="--", label=f"Filtered V{labels[j].lower()}")
|
|
51
|
+
ax.set_ylabel("Velocity (m/s)")
|
|
52
|
+
ax.set_title("Velocity")
|
|
53
|
+
ax.legend(fontsize=7)
|
|
54
|
+
ax.grid(True)
|
|
55
|
+
|
|
56
|
+
# Orientation (quaternion)
|
|
57
|
+
ax = axes[1, 0]
|
|
58
|
+
quat_labels = ["qw", "qx", "qy", "qz"]
|
|
59
|
+
quat_colors = ["blue", "green", "red", "cyan"]
|
|
60
|
+
for j in range(4):
|
|
61
|
+
ax.plot(t, true_quat[:, j], quat_colors[j], ls="-", label=f"True {quat_labels[j]}")
|
|
62
|
+
ax.plot(t, filt_quat[:, j], quat_colors[j], ls="--", label=f"Filtered {quat_labels[j]}")
|
|
63
|
+
ax.set_ylabel("Quaternion Components")
|
|
64
|
+
ax.set_title("Orientation (Quaternion)")
|
|
65
|
+
ax.legend(fontsize=6)
|
|
66
|
+
ax.grid(True)
|
|
67
|
+
|
|
68
|
+
# Sensor readings (accel)
|
|
69
|
+
ax = axes[1, 1]
|
|
70
|
+
if accel is not None:
|
|
71
|
+
for j in range(3):
|
|
72
|
+
ax.plot(t, accel[:, j], colors[j], label=f"Accel {labels[j]}")
|
|
73
|
+
ax.set_title("Accelerometer Readings")
|
|
74
|
+
ax.set_ylabel("Acceleration (m/s²)")
|
|
75
|
+
ax.legend(fontsize=7)
|
|
76
|
+
ax.grid(True)
|
|
77
|
+
|
|
78
|
+
# Gyro or bias
|
|
79
|
+
ax = axes[2, 0]
|
|
80
|
+
if gyro is not None:
|
|
81
|
+
for j in range(3):
|
|
82
|
+
ax.plot(t, gyro[:, j], colors[j], label=f"Gyro {labels[j]}")
|
|
83
|
+
ax.set_title("Gyroscope Readings")
|
|
84
|
+
ax.set_ylabel("Angular Velocity (rad/s)")
|
|
85
|
+
ax.legend(fontsize=7)
|
|
86
|
+
ax.grid(True)
|
|
87
|
+
|
|
88
|
+
# 3D trajectory
|
|
89
|
+
ax3d = fig.add_subplot(nrows, ncols, nrows * ncols, projection="3d")
|
|
90
|
+
axes[-1, -1].set_visible(False)
|
|
91
|
+
ax3d.plot(true_pos[:, 0], true_pos[:, 1], true_pos[:, 2], "b-", label="True")
|
|
92
|
+
ax3d.plot(filt_pos[:, 0], filt_pos[:, 1], filt_pos[:, 2], "r--", label="Filtered")
|
|
93
|
+
ax3d.set_xlabel("X (m)")
|
|
94
|
+
ax3d.set_ylabel("Y (m)")
|
|
95
|
+
ax3d.set_zlabel("Z (m)")
|
|
96
|
+
ax3d.set_title("3D Trajectory")
|
|
97
|
+
ax3d.legend(fontsize=7)
|
|
98
|
+
|
|
99
|
+
for row in axes:
|
|
100
|
+
for a in row:
|
|
101
|
+
if a.get_visible():
|
|
102
|
+
a.set_xlabel("Time (s)")
|
|
103
|
+
|
|
104
|
+
fig.tight_layout()
|
|
105
|
+
return fig
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def plot_quadcopter_results(
|
|
109
|
+
t: NDArray,
|
|
110
|
+
position: NDArray,
|
|
111
|
+
attitude: NDArray,
|
|
112
|
+
target_position: NDArray,
|
|
113
|
+
motor_speeds: NDArray,
|
|
114
|
+
waypoints: list[tuple[float, float, float]] | None = None,
|
|
115
|
+
) -> plt.Figure:
|
|
116
|
+
"""Create a 6-panel figure of quadcopter simulation results."""
|
|
117
|
+
fig = plt.figure(figsize=(16, 12))
|
|
118
|
+
|
|
119
|
+
# 3D trajectory
|
|
120
|
+
ax1 = fig.add_subplot(2, 3, 1, projection="3d")
|
|
121
|
+
ax1.plot(position[:, 0], position[:, 1], position[:, 2], "b-", label="Actual")
|
|
122
|
+
ax1.plot(target_position[:, 0], target_position[:, 1], target_position[:, 2], "r--", label="Target")
|
|
123
|
+
if waypoints is not None:
|
|
124
|
+
wp = np.array(waypoints)
|
|
125
|
+
ax1.scatter(wp[:, 0], wp[:, 1], wp[:, 2], c="red", s=100, label="Waypoints")
|
|
126
|
+
ax1.set_xlabel("X (m)")
|
|
127
|
+
ax1.set_ylabel("Y (m)")
|
|
128
|
+
ax1.set_zlabel("Z (m)")
|
|
129
|
+
ax1.set_title("Quadcopter Trajectory")
|
|
130
|
+
ax1.legend(fontsize=7)
|
|
131
|
+
|
|
132
|
+
# Position vs time
|
|
133
|
+
ax2 = fig.add_subplot(2, 3, 2)
|
|
134
|
+
for j, (c, label) in enumerate(zip(["r", "g", "b"], ["X", "Y", "Z"])):
|
|
135
|
+
ax2.plot(t, position[:, j], f"{c}-", label=label)
|
|
136
|
+
ax2.plot(t, target_position[:, j], f"{c}--", alpha=0.5)
|
|
137
|
+
ax2.set_xlabel("Time (s)")
|
|
138
|
+
ax2.set_ylabel("Position (m)")
|
|
139
|
+
ax2.set_title("Position vs Time")
|
|
140
|
+
ax2.legend()
|
|
141
|
+
ax2.grid(True)
|
|
142
|
+
|
|
143
|
+
# Attitude
|
|
144
|
+
ax3 = fig.add_subplot(2, 3, 3)
|
|
145
|
+
for j, (c, label) in enumerate(zip(["r", "g", "b"], ["Roll", "Pitch", "Yaw"])):
|
|
146
|
+
ax3.plot(t, np.rad2deg(attitude[:, j]), f"{c}-", label=label)
|
|
147
|
+
ax3.set_xlabel("Time (s)")
|
|
148
|
+
ax3.set_ylabel("Angle (deg)")
|
|
149
|
+
ax3.set_title("Attitude vs Time")
|
|
150
|
+
ax3.legend()
|
|
151
|
+
ax3.grid(True)
|
|
152
|
+
|
|
153
|
+
# Motor speeds
|
|
154
|
+
ax4 = fig.add_subplot(2, 3, 4)
|
|
155
|
+
for j in range(4):
|
|
156
|
+
ax4.plot(t, motor_speeds[:, j], label=f"Motor {j + 1}")
|
|
157
|
+
ax4.set_xlabel("Time (s)")
|
|
158
|
+
ax4.set_ylabel("Motor Speed")
|
|
159
|
+
ax4.set_title("Motor Speeds vs Time")
|
|
160
|
+
ax4.legend()
|
|
161
|
+
ax4.grid(True)
|
|
162
|
+
|
|
163
|
+
# Position error
|
|
164
|
+
ax5 = fig.add_subplot(2, 3, 5)
|
|
165
|
+
err = target_position - position
|
|
166
|
+
for j, (c, label) in enumerate(zip(["r", "g", "b"], ["X", "Y", "Z"])):
|
|
167
|
+
ax5.plot(t, err[:, j], f"{c}-", label=f"{label} Error")
|
|
168
|
+
ax5.set_xlabel("Time (s)")
|
|
169
|
+
ax5.set_ylabel("Error (m)")
|
|
170
|
+
ax5.set_title("Position Error vs Time")
|
|
171
|
+
ax5.legend()
|
|
172
|
+
ax5.grid(True)
|
|
173
|
+
|
|
174
|
+
# Error magnitude
|
|
175
|
+
ax6 = fig.add_subplot(2, 3, 6)
|
|
176
|
+
ax6.plot(t, np.linalg.norm(err, axis=1), "k-")
|
|
177
|
+
ax6.set_xlabel("Time (s)")
|
|
178
|
+
ax6.set_ylabel("Error Magnitude (m)")
|
|
179
|
+
ax6.set_title("Total Position Error Magnitude")
|
|
180
|
+
ax6.grid(True)
|
|
181
|
+
|
|
182
|
+
fig.tight_layout()
|
|
183
|
+
return fig
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Rerun-backed synchronized 3D playback and telemetry visualization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from ..simulation import SimulationResult
|
|
14
|
+
|
|
15
|
+
RerunMode = Literal["spawn", "connect", "save"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class RerunConfig:
|
|
20
|
+
"""Output and scene configuration for a Rerun recording."""
|
|
21
|
+
|
|
22
|
+
application_id: str = "drones_sim"
|
|
23
|
+
timeline: str = "sim_time"
|
|
24
|
+
arm_length: float = 0.2
|
|
25
|
+
trail_radius: float = 0.012
|
|
26
|
+
velocity_scale: float = 0.25
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if not self.application_id:
|
|
30
|
+
raise ValueError("application_id cannot be empty")
|
|
31
|
+
if not self.timeline:
|
|
32
|
+
raise ValueError("timeline cannot be empty")
|
|
33
|
+
if self.arm_length <= 0.0:
|
|
34
|
+
raise ValueError("arm_length must be positive")
|
|
35
|
+
if self.trail_radius <= 0.0:
|
|
36
|
+
raise ValueError("trail_radius must be positive")
|
|
37
|
+
if self.velocity_scale <= 0.0:
|
|
38
|
+
raise ValueError("velocity_scale must be positive")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _load_rerun():
|
|
42
|
+
try:
|
|
43
|
+
rr = importlib.import_module("rerun")
|
|
44
|
+
rrb = importlib.import_module("rerun.blueprint")
|
|
45
|
+
except ImportError as exc:
|
|
46
|
+
raise ImportError(
|
|
47
|
+
"The default visualization backend requires rerun-sdk. "
|
|
48
|
+
"Reinstall drones-sim or run: pip install rerun-sdk"
|
|
49
|
+
) from exc
|
|
50
|
+
return rr, rrb
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _validate_result(result: SimulationResult) -> int:
|
|
54
|
+
count = len(result.time)
|
|
55
|
+
if count == 0:
|
|
56
|
+
raise ValueError("SimulationResult cannot be empty")
|
|
57
|
+
expected = {
|
|
58
|
+
"position": (count, 3),
|
|
59
|
+
"velocity": (count, 3),
|
|
60
|
+
"quaternion": (count, 4),
|
|
61
|
+
"body_rates": (count, 3),
|
|
62
|
+
"estimated_position": (count, 3),
|
|
63
|
+
"estimated_velocity": (count, 3),
|
|
64
|
+
"estimated_quaternion": (count, 4),
|
|
65
|
+
"reference_position": (count, 3),
|
|
66
|
+
"reference_velocity": (count, 3),
|
|
67
|
+
"motor_speeds": (count, 4),
|
|
68
|
+
"commanded_wrench": (count, 4),
|
|
69
|
+
"allocation_saturated": (count,),
|
|
70
|
+
}
|
|
71
|
+
for name, shape in expected.items():
|
|
72
|
+
value = np.asarray(getattr(result, name))
|
|
73
|
+
if value.shape != shape:
|
|
74
|
+
raise ValueError(
|
|
75
|
+
f"SimulationResult.{name} must have shape {shape}, got {value.shape}"
|
|
76
|
+
)
|
|
77
|
+
if not np.all(np.isfinite(value)):
|
|
78
|
+
raise ValueError(f"SimulationResult.{name} contains non-finite values")
|
|
79
|
+
if not np.all(np.isfinite(result.time)):
|
|
80
|
+
raise ValueError("SimulationResult.time contains non-finite values")
|
|
81
|
+
if np.any(np.diff(result.time) < 0.0):
|
|
82
|
+
raise ValueError("SimulationResult.time must be monotonic")
|
|
83
|
+
return count
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def rerun_blueprint():
|
|
87
|
+
"""Return the default flight-analysis layout."""
|
|
88
|
+
_, rrb = _load_rerun()
|
|
89
|
+
return rrb.Blueprint(
|
|
90
|
+
rrb.Horizontal(
|
|
91
|
+
rrb.Spatial3DView(
|
|
92
|
+
origin="/world",
|
|
93
|
+
contents="/world/**",
|
|
94
|
+
name="Flight",
|
|
95
|
+
background=[18, 22, 30],
|
|
96
|
+
line_grid=True,
|
|
97
|
+
),
|
|
98
|
+
rrb.Vertical(
|
|
99
|
+
rrb.TimeSeriesView(
|
|
100
|
+
origin="/telemetry/errors", name="Position errors [m]"
|
|
101
|
+
),
|
|
102
|
+
rrb.TimeSeriesView(
|
|
103
|
+
origin="/telemetry/position", name="Position [m]"
|
|
104
|
+
),
|
|
105
|
+
rrb.TimeSeriesView(
|
|
106
|
+
origin="/telemetry/motors", name="Rotor speeds [rad/s]"
|
|
107
|
+
),
|
|
108
|
+
row_shares=[1.0, 1.2, 1.0],
|
|
109
|
+
),
|
|
110
|
+
column_shares=[1.7, 1.0],
|
|
111
|
+
),
|
|
112
|
+
collapse_panels=True,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _log_series_styles(recording, rr) -> None:
|
|
117
|
+
series = {
|
|
118
|
+
"/telemetry/errors/position": (
|
|
119
|
+
["tracking", "estimation"],
|
|
120
|
+
[[230, 85, 70], [245, 155, 55]],
|
|
121
|
+
),
|
|
122
|
+
"/telemetry/position/truth": (
|
|
123
|
+
["truth x", "truth y", "truth z"],
|
|
124
|
+
[[75, 145, 255], [60, 190, 225], [80, 205, 145]],
|
|
125
|
+
),
|
|
126
|
+
"/telemetry/position/reference": (
|
|
127
|
+
["reference x", "reference y", "reference z"],
|
|
128
|
+
[[150, 220, 135], [110, 200, 105], [75, 180, 80]],
|
|
129
|
+
),
|
|
130
|
+
"/telemetry/position/estimate": (
|
|
131
|
+
["estimate x", "estimate y", "estimate z"],
|
|
132
|
+
[[255, 185, 70], [245, 145, 55], [225, 105, 45]],
|
|
133
|
+
),
|
|
134
|
+
"/telemetry/motors/speed": (
|
|
135
|
+
["front", "right", "rear", "left"],
|
|
136
|
+
[[230, 75, 75], [80, 190, 100], [75, 125, 240], [210, 90, 220]],
|
|
137
|
+
),
|
|
138
|
+
"/telemetry/control/wrench": (
|
|
139
|
+
["thrust", "torque x", "torque y", "torque z"],
|
|
140
|
+
[[235, 205, 80], [225, 80, 80], [80, 190, 100], [75, 125, 240]],
|
|
141
|
+
),
|
|
142
|
+
"/telemetry/state/body_rates": (
|
|
143
|
+
["p", "q", "r"],
|
|
144
|
+
[[225, 80, 80], [80, 190, 100], [75, 125, 240]],
|
|
145
|
+
),
|
|
146
|
+
"/telemetry/control/allocation_saturated": (
|
|
147
|
+
["saturated"],
|
|
148
|
+
[[230, 70, 60]],
|
|
149
|
+
),
|
|
150
|
+
}
|
|
151
|
+
for path, (names, colors) in series.items():
|
|
152
|
+
recording.log(
|
|
153
|
+
path,
|
|
154
|
+
rr.SeriesLines(names=names, colors=colors),
|
|
155
|
+
static=True,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _log_static_scene(
|
|
160
|
+
result: SimulationResult,
|
|
161
|
+
recording,
|
|
162
|
+
rr,
|
|
163
|
+
config: RerunConfig,
|
|
164
|
+
) -> None:
|
|
165
|
+
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
|
166
|
+
recording.log(
|
|
167
|
+
"/world/paths/reference",
|
|
168
|
+
rr.LineStrips3D(
|
|
169
|
+
[result.reference_position],
|
|
170
|
+
colors=[[80, 210, 110]],
|
|
171
|
+
radii=config.trail_radius,
|
|
172
|
+
labels=["reference"],
|
|
173
|
+
),
|
|
174
|
+
static=True,
|
|
175
|
+
)
|
|
176
|
+
recording.log(
|
|
177
|
+
"/world/paths/truth",
|
|
178
|
+
rr.LineStrips3D(
|
|
179
|
+
[result.position],
|
|
180
|
+
colors=[[55, 135, 255]],
|
|
181
|
+
radii=config.trail_radius * 1.2,
|
|
182
|
+
labels=["truth"],
|
|
183
|
+
),
|
|
184
|
+
static=True,
|
|
185
|
+
)
|
|
186
|
+
recording.log(
|
|
187
|
+
"/world/paths/estimate",
|
|
188
|
+
rr.LineStrips3D(
|
|
189
|
+
[result.estimated_position],
|
|
190
|
+
colors=[[255, 145, 45]],
|
|
191
|
+
radii=config.trail_radius,
|
|
192
|
+
labels=["estimate"],
|
|
193
|
+
),
|
|
194
|
+
static=True,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
all_positions = np.vstack([result.position, result.reference_position])
|
|
198
|
+
horizontal_extent = max(
|
|
199
|
+
2.0,
|
|
200
|
+
float(np.max(np.ptp(all_positions[:, :2], axis=0))) * 0.75,
|
|
201
|
+
)
|
|
202
|
+
ground_center = np.mean(all_positions[:, :2], axis=0)
|
|
203
|
+
recording.log(
|
|
204
|
+
"/world/ground",
|
|
205
|
+
rr.Boxes3D(
|
|
206
|
+
centers=[[ground_center[0], ground_center[1], -0.015]],
|
|
207
|
+
half_sizes=[[horizontal_extent, horizontal_extent, 0.01]],
|
|
208
|
+
colors=[[50, 58, 68, 120]],
|
|
209
|
+
),
|
|
210
|
+
static=True,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
arm = config.arm_length
|
|
214
|
+
recording.log(
|
|
215
|
+
"/world/drone/body/geometry/arms",
|
|
216
|
+
rr.LineStrips3D(
|
|
217
|
+
[
|
|
218
|
+
[[-arm, 0.0, 0.0], [arm, 0.0, 0.0]],
|
|
219
|
+
[[0.0, -arm, 0.0], [0.0, arm, 0.0]],
|
|
220
|
+
],
|
|
221
|
+
colors=[[220, 225, 235], [220, 225, 235]],
|
|
222
|
+
radii=0.018,
|
|
223
|
+
),
|
|
224
|
+
static=True,
|
|
225
|
+
)
|
|
226
|
+
recording.log(
|
|
227
|
+
"/world/drone/body/geometry/rotors",
|
|
228
|
+
rr.Points3D(
|
|
229
|
+
[[arm, 0.0, 0.0], [0.0, arm, 0.0], [-arm, 0.0, 0.0], [0.0, -arm, 0.0]],
|
|
230
|
+
colors=[[230, 75, 75], [80, 190, 100], [75, 125, 240], [210, 90, 220]],
|
|
231
|
+
radii=0.065,
|
|
232
|
+
labels=["front", "right", "rear", "left"],
|
|
233
|
+
),
|
|
234
|
+
static=True,
|
|
235
|
+
)
|
|
236
|
+
recording.log(
|
|
237
|
+
"/world/drone/body/geometry/axes",
|
|
238
|
+
rr.Arrows3D(
|
|
239
|
+
origins=np.zeros((3, 3)),
|
|
240
|
+
vectors=np.eye(3) * arm * 1.4,
|
|
241
|
+
colors=[[235, 70, 70], [70, 215, 95], [70, 125, 245]],
|
|
242
|
+
radii=0.01,
|
|
243
|
+
labels=["body x", "body y", "body z"],
|
|
244
|
+
),
|
|
245
|
+
static=True,
|
|
246
|
+
)
|
|
247
|
+
_log_series_styles(recording, rr)
|
|
248
|
+
|
|
249
|
+
summary = result.summary()
|
|
250
|
+
summary_lines = ["# Drone simulation", ""]
|
|
251
|
+
summary_lines.extend(f"- **{key}**: {value:.5g}" for key, value in summary.items())
|
|
252
|
+
if result.metadata:
|
|
253
|
+
summary_lines.extend(
|
|
254
|
+
["", "## Metadata", "", "```json", json.dumps(result.metadata, indent=2, default=str), "```"]
|
|
255
|
+
)
|
|
256
|
+
recording.log(
|
|
257
|
+
"/report/summary",
|
|
258
|
+
rr.TextDocument("\n".join(summary_lines), media_type="text/markdown"),
|
|
259
|
+
static=True,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def log_simulation_rerun(
|
|
264
|
+
result: SimulationResult,
|
|
265
|
+
recording,
|
|
266
|
+
*,
|
|
267
|
+
config: RerunConfig | None = None,
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Log a complete ``SimulationResult`` to an existing recording stream."""
|
|
270
|
+
rr, _ = _load_rerun()
|
|
271
|
+
cfg = config or RerunConfig()
|
|
272
|
+
count = _validate_result(result)
|
|
273
|
+
_log_static_scene(result, recording, rr, cfg)
|
|
274
|
+
|
|
275
|
+
previous_saturation = False
|
|
276
|
+
for index in range(count):
|
|
277
|
+
recording.set_time(cfg.timeline, duration=float(result.time[index]))
|
|
278
|
+
quaternion_xyzw = np.roll(result.quaternion[index], -1)
|
|
279
|
+
recording.log(
|
|
280
|
+
"/world/drone/body",
|
|
281
|
+
rr.Transform3D(
|
|
282
|
+
translation=result.position[index],
|
|
283
|
+
rotation=rr.Quaternion(xyzw=quaternion_xyzw),
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
recording.log(
|
|
287
|
+
"/world/current/reference",
|
|
288
|
+
rr.Points3D(
|
|
289
|
+
[result.reference_position[index]],
|
|
290
|
+
colors=[[80, 220, 110]],
|
|
291
|
+
radii=0.055,
|
|
292
|
+
labels=["reference"],
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
recording.log(
|
|
296
|
+
"/world/current/estimate",
|
|
297
|
+
rr.Points3D(
|
|
298
|
+
[result.estimated_position[index]],
|
|
299
|
+
colors=[[255, 145, 45]],
|
|
300
|
+
radii=0.035,
|
|
301
|
+
labels=["estimate"],
|
|
302
|
+
),
|
|
303
|
+
)
|
|
304
|
+
recording.log(
|
|
305
|
+
"/world/vectors/velocity",
|
|
306
|
+
rr.Arrows3D(
|
|
307
|
+
origins=[result.position[index]],
|
|
308
|
+
vectors=[result.velocity[index] * cfg.velocity_scale],
|
|
309
|
+
colors=[[85, 190, 245]],
|
|
310
|
+
radii=0.012,
|
|
311
|
+
labels=["velocity"],
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
recording.log(
|
|
315
|
+
"/world/vectors/tracking_error",
|
|
316
|
+
rr.Arrows3D(
|
|
317
|
+
origins=[result.position[index]],
|
|
318
|
+
vectors=[result.reference_position[index] - result.position[index]],
|
|
319
|
+
colors=[[235, 75, 65]],
|
|
320
|
+
radii=0.01,
|
|
321
|
+
labels=["tracking error"],
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
tracking_error = float(np.linalg.norm(result.tracking_error[index]))
|
|
326
|
+
estimation_error = float(np.linalg.norm(result.estimation_error[index]))
|
|
327
|
+
recording.log(
|
|
328
|
+
"/telemetry/errors/position",
|
|
329
|
+
rr.Scalars([tracking_error, estimation_error]),
|
|
330
|
+
)
|
|
331
|
+
recording.log(
|
|
332
|
+
"/telemetry/position/truth", rr.Scalars(result.position[index])
|
|
333
|
+
)
|
|
334
|
+
recording.log(
|
|
335
|
+
"/telemetry/position/reference",
|
|
336
|
+
rr.Scalars(result.reference_position[index]),
|
|
337
|
+
)
|
|
338
|
+
recording.log(
|
|
339
|
+
"/telemetry/position/estimate",
|
|
340
|
+
rr.Scalars(result.estimated_position[index]),
|
|
341
|
+
)
|
|
342
|
+
recording.log(
|
|
343
|
+
"/telemetry/motors/speed", rr.Scalars(result.motor_speeds[index])
|
|
344
|
+
)
|
|
345
|
+
recording.log(
|
|
346
|
+
"/telemetry/control/wrench",
|
|
347
|
+
rr.Scalars(result.commanded_wrench[index]),
|
|
348
|
+
)
|
|
349
|
+
recording.log(
|
|
350
|
+
"/telemetry/state/body_rates", rr.Scalars(result.body_rates[index])
|
|
351
|
+
)
|
|
352
|
+
saturated = bool(result.allocation_saturated[index])
|
|
353
|
+
recording.log(
|
|
354
|
+
"/telemetry/control/allocation_saturated",
|
|
355
|
+
rr.Scalars([float(saturated)]),
|
|
356
|
+
)
|
|
357
|
+
if saturated and not previous_saturation:
|
|
358
|
+
recording.log(
|
|
359
|
+
"/events/control",
|
|
360
|
+
rr.TextLog(
|
|
361
|
+
f"Control allocation saturated at t={result.time[index]:.3f}s",
|
|
362
|
+
level="WARN",
|
|
363
|
+
),
|
|
364
|
+
)
|
|
365
|
+
previous_saturation = saturated
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def visualize_rerun(
|
|
369
|
+
result: SimulationResult,
|
|
370
|
+
*,
|
|
371
|
+
mode: RerunMode = "spawn",
|
|
372
|
+
path: str | Path | None = None,
|
|
373
|
+
url: str | None = None,
|
|
374
|
+
config: RerunConfig | None = None,
|
|
375
|
+
):
|
|
376
|
+
"""Visualize, stream, or save a synchronized Rerun recording.
|
|
377
|
+
|
|
378
|
+
Parameters
|
|
379
|
+
----------
|
|
380
|
+
mode:
|
|
381
|
+
``"spawn"`` opens a local viewer, ``"connect"`` streams to a running
|
|
382
|
+
Rerun gRPC server, and ``"save"`` writes an ``.rrd`` recording.
|
|
383
|
+
path:
|
|
384
|
+
Required in save mode. ``.rrd`` is appended when no suffix is present.
|
|
385
|
+
url:
|
|
386
|
+
Optional gRPC endpoint used in connect mode.
|
|
387
|
+
"""
|
|
388
|
+
rr, _ = _load_rerun()
|
|
389
|
+
cfg = config or RerunConfig()
|
|
390
|
+
if mode not in {"spawn", "connect", "save"}:
|
|
391
|
+
raise ValueError(f"unsupported Rerun mode: {mode!r}")
|
|
392
|
+
blueprint = rerun_blueprint()
|
|
393
|
+
recording = rr.RecordingStream(cfg.application_id)
|
|
394
|
+
output_path: Path | None = None
|
|
395
|
+
if mode == "spawn":
|
|
396
|
+
recording.spawn(default_blueprint=blueprint)
|
|
397
|
+
elif mode == "connect":
|
|
398
|
+
recording.connect_grpc(url, default_blueprint=blueprint)
|
|
399
|
+
else:
|
|
400
|
+
if path is None:
|
|
401
|
+
raise ValueError("path is required when mode='save'")
|
|
402
|
+
output_path = Path(path).expanduser()
|
|
403
|
+
if output_path.suffix == "":
|
|
404
|
+
output_path = output_path.with_suffix(".rrd")
|
|
405
|
+
if output_path.suffix.lower() != ".rrd":
|
|
406
|
+
raise ValueError("Rerun recording path must use the .rrd suffix")
|
|
407
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
408
|
+
recording.save(output_path, default_blueprint=blueprint)
|
|
409
|
+
log_simulation_rerun(result, recording, config=cfg)
|
|
410
|
+
recording.flush()
|
|
411
|
+
return output_path if output_path is not None else recording
|