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
@@ -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