lang2sim 2.0.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 (65) hide show
  1. lang2sim/__init__.py +47 -0
  2. lang2sim/agent/__init__.py +5 -0
  3. lang2sim/agent/nemotron_client.py +109 -0
  4. lang2sim/agent/react_loop.py +848 -0
  5. lang2sim/agent/tools.py +998 -0
  6. lang2sim/cli/app.py +183 -0
  7. lang2sim/cli/screens/__init__.py +8 -0
  8. lang2sim/cli/screens/command_palette_modal.py +146 -0
  9. lang2sim/cli/screens/landing_screen.py +96 -0
  10. lang2sim/cli/screens/sessions_modal.py +236 -0
  11. lang2sim/cli/screens/workspace_screen.py +709 -0
  12. lang2sim/cli/widgets/__init__.py +16 -0
  13. lang2sim/cli/widgets/action_pill.py +67 -0
  14. lang2sim/cli/widgets/command_card.py +265 -0
  15. lang2sim/cli/widgets/plan_card.py +106 -0
  16. lang2sim/cli/widgets/prompt_card.py +96 -0
  17. lang2sim/cli/widgets/telemetry_sidebar.py +117 -0
  18. lang2sim/cli/widgets/thought_box.py +64 -0
  19. lang2sim/compiler/__init__.py +15 -0
  20. lang2sim/compiler/sdf_compiler.py +65 -0
  21. lang2sim/compiler/sir_generator.py +46 -0
  22. lang2sim/compiler/sir_validator.py +87 -0
  23. lang2sim/core/__init__.py +50 -0
  24. lang2sim/core/config.py +116 -0
  25. lang2sim/core/models.py +279 -0
  26. lang2sim/core/session_manager.py +189 -0
  27. lang2sim/perturbation/__init__.py +50 -0
  28. lang2sim/perturbation/_compat.py +19 -0
  29. lang2sim/perturbation/gazebo_bridge.py +520 -0
  30. lang2sim/perturbation/mcp_server.py +129 -0
  31. lang2sim/perturbation/routers/__init__.py +19 -0
  32. lang2sim/perturbation/routers/world.py +110 -0
  33. lang2sim/perturbation/sdf_snippets.py +112 -0
  34. lang2sim/runtime/__init__.py +7 -0
  35. lang2sim/runtime/control_api/__init__.py +7 -0
  36. lang2sim/runtime/control_api/client.py +187 -0
  37. lang2sim/runtime/control_api/server.py +504 -0
  38. lang2sim/runtime/display.py +141 -0
  39. lang2sim/runtime/docker_runner.py +286 -0
  40. lang2sim/runtime/doctor.py +220 -0
  41. lang2sim/runtime/hardware_probe.py +144 -0
  42. lang2sim/runtime/health_monitor.py +66 -0
  43. lang2sim/runtime/launch_builder.py +137 -0
  44. lang2sim/runtime/nav2_dispatcher.py +215 -0
  45. lang2sim/runtime/platform.py +137 -0
  46. lang2sim/runtime/ros_bridge.py +183 -0
  47. lang2sim/runtime/run_folder.py +86 -0
  48. lang2sim/runtime/self_healing.py +387 -0
  49. lang2sim/runtime/session.py +136 -0
  50. lang2sim/runtime/world_check.py +197 -0
  51. lang2sim/telemetry/__init__.py +85 -0
  52. lang2sim/telemetry/nqi_scorer.py +645 -0
  53. lang2sim/telemetry/ros_subscribers.py +489 -0
  54. lang2sim/telemetry/trajectory.py +258 -0
  55. lang2sim/telemetry/vision.py +477 -0
  56. lang2sim/world_gen/__init__.py +16 -0
  57. lang2sim/world_gen/catalog_loader.py +83 -0
  58. lang2sim/world_gen/map_slicer.py +131 -0
  59. lang2sim/world_gen/shapely_solver.py +256 -0
  60. lang2sim-2.0.0.dist-info/METADATA +53 -0
  61. lang2sim-2.0.0.dist-info/RECORD +65 -0
  62. lang2sim-2.0.0.dist-info/WHEEL +5 -0
  63. lang2sim-2.0.0.dist-info/entry_points.txt +2 -0
  64. lang2sim-2.0.0.dist-info/licenses/LICENSE +21 -0
  65. lang2sim-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,489 @@
1
+ """ROS 2 Telemetry Subscribers & Collision Event Monitor.
2
+
3
+ Asynchronously monitors:
4
+ - /odom (nav_msgs/Odometry): Linear velocity v(t), angular velocity w(t), pose (x, y, yaw).
5
+ - /tf (tf2_msgs/TFMessage): Coordinate transforms (map -> odom -> base_link).
6
+ - /lang2sim/contact_states: Chassis bumper contacts from Gazebo plugins.
7
+
8
+ Includes:
9
+ - CollisionCounter: Rising-edge filter counting collisions when ||F||_2 > 5.0 N with 0.5s cooldown.
10
+ - TelemetryCollector: Thread-safe ring buffer and run recorder producing Trajectory objects.
11
+ - ReplaySource: Synthetic / recorded replay provider for testing without ROS/Gazebo.
12
+ - query_robot_telemetry(): High-level async inspector consumed by the CLI / TUI sidebar.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import collections
19
+ import math
20
+ import os
21
+ import sys
22
+ import threading
23
+ import time
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any, Callable, Deque, Mapping, Optional, Sequence
27
+
28
+ import numpy as np
29
+
30
+ from lang2sim.telemetry.trajectory import Trajectory
31
+
32
+ __all__ = [
33
+ "CollisionCounter",
34
+ "TelemetrySample",
35
+ "TelemetryCollector",
36
+ "ReplaySource",
37
+ "get_telemetry_collector",
38
+ "query_robot_telemetry",
39
+ ]
40
+
41
+ FORCE_THRESHOLD_NEWTONS = 5.0
42
+ COOLDOWN_SECONDS = 0.5
43
+ RING_BUFFER_SIZE = 18000 # ~10 minutes @ 30 Hz
44
+
45
+
46
+ # --------------------------------------------------------------------------- Collision Counter
47
+ class CollisionCounter:
48
+ """Rising-edge collision detector filtering out minor ground contact and vibrations."""
49
+
50
+ def __init__(
51
+ self,
52
+ threshold_n: float = FORCE_THRESHOLD_NEWTONS,
53
+ cooldown_s: float = COOLDOWN_SECONDS,
54
+ ignored_entities: Optional[Sequence[str]] = None,
55
+ ) -> None:
56
+ self.threshold_n = threshold_n
57
+ self.cooldown_s = cooldown_s
58
+ self.ignored = {e.lower() for e in (ignored_entities or ["ground_plane", "floor", "ground", "world"])}
59
+ self.collision_count = 0
60
+ self.last_collision_time = 0.0
61
+ self.in_collision = False
62
+ self.last_force_n = 0.0
63
+ self.last_contact_entity = ""
64
+
65
+ def process_contact(
66
+ self,
67
+ force_vector: Sequence[float],
68
+ entity_name: str = "",
69
+ timestamp: Optional[float] = None,
70
+ ) -> bool:
71
+ """Process a contact force sample. Returns True if a new structural collision is registered."""
72
+ t_now = time.time() if timestamp is None else timestamp
73
+ mag = float(math.hypot(force_vector[0], force_vector[1], force_vector[2] if len(force_vector) > 2 else 0.0))
74
+ self.last_force_n = mag
75
+ self.last_contact_entity = entity_name
76
+
77
+ if any(ign in entity_name.lower() for ign in self.ignored):
78
+ return False
79
+
80
+ if mag > self.threshold_n:
81
+ if not self.in_collision and (t_now - self.last_collision_time >= self.cooldown_s):
82
+ self.collision_count += 1
83
+ self.last_collision_time = t_now
84
+ self.in_collision = True
85
+ return True
86
+ else:
87
+ self.in_collision = False
88
+ return False
89
+
90
+ def reset(self) -> None:
91
+ self.collision_count = 0
92
+ self.last_collision_time = 0.0
93
+ self.in_collision = False
94
+ self.last_force_n = 0.0
95
+ self.last_contact_entity = ""
96
+
97
+
98
+ # --------------------------------------------------------------------------- Telemetry Sample
99
+ @dataclass
100
+ class TelemetrySample:
101
+ t: float
102
+ x: float
103
+ y: float
104
+ yaw: float
105
+ v: float
106
+ w: float
107
+ collision_count: int = 0
108
+ last_force_n: float = 0.0
109
+ slam_x: Optional[float] = None
110
+ slam_y: Optional[float] = None
111
+ ate: Optional[float] = None
112
+
113
+
114
+ # --------------------------------------------------------------------------- Telemetry Collector
115
+ class TelemetryCollector:
116
+ """Thread-safe telemetry observer and trajectory recorder with live SLAM ATE drift tracking."""
117
+
118
+ def __init__(
119
+ self,
120
+ buffer_size: int = RING_BUFFER_SIZE,
121
+ api_base_url: Optional[str] = None,
122
+ poll_interval: float = 0.1,
123
+ ) -> None:
124
+ self.lock = threading.Lock()
125
+ self.ring_buffer: Deque[TelemetrySample] = collections.deque(maxlen=buffer_size)
126
+ self.bumper = CollisionCounter()
127
+ self.active_run_samples: Optional[list[dict[str, float]]] = None
128
+ self.run_label: str = ""
129
+ self.goal: Optional[tuple[float, float]] = None
130
+ self._executor = None
131
+ self._thread: Optional[threading.Thread] = None
132
+ self._ros_ok = False
133
+ self._api_ok = False
134
+ self._api_running = False
135
+ self._api_thread: Optional[threading.Thread] = None
136
+ self.rtf: float = 1.0
137
+ self.sim_time: float = 0.0
138
+ self.poll_interval = poll_interval
139
+ self.api_base_url = (
140
+ api_base_url
141
+ or os.environ.get("LANG2SIM_CONTROL_API")
142
+ or "http://127.0.0.1:9090"
143
+ )
144
+ # SLAM drift tracking metrics
145
+ self.latest_slam_pose: Optional[tuple[float, float, float]] = None
146
+ self.current_ate: float = 0.0
147
+ self.max_ate: float = 0.0
148
+ self.ate_history: list[float] = []
149
+ self.ate_divergence_threshold: float = 0.35
150
+
151
+ self._init_ros()
152
+ if not self._ros_ok:
153
+ self._start_api_poll()
154
+
155
+ def _init_ros(self) -> None:
156
+ try:
157
+ import rclpy # type: ignore[import-not-found]
158
+ from nav_msgs.msg import Odometry # type: ignore[import-not-found]
159
+
160
+ if not rclpy.ok():
161
+ rclpy.init(args=None)
162
+ node = rclpy.create_node("lang2sim_telemetry_subscriber")
163
+
164
+ def _odom_cb(msg: Odometry):
165
+ p = msg.pose.pose.position
166
+ q = msg.pose.pose.orientation
167
+ # Quaternion -> Yaw
168
+ siny_cosp = 2 * (q.w * q.z + q.x * q.y)
169
+ cosy_cosp = 1 - 2 * (q.y * q.y + q.z * q.z)
170
+ yaw = math.atan2(siny_cosp, cosy_cosp)
171
+ v = msg.twist.twist.linear.x
172
+ w = msg.twist.twist.angular.z
173
+ self.record_kinematics(time.time(), p.x, p.y, yaw, v, w)
174
+
175
+ node.create_subscription(Odometry, "/odom", _odom_cb, 20)
176
+
177
+ # Contact states (dynamic type check for gazebo_msgs or ros_gz_interfaces)
178
+ try:
179
+ from gazebo_msgs.msg import ContactsState # type: ignore[import-not-found]
180
+
181
+ def _contact_cb(msg: ContactsState):
182
+ for state in msg.states:
183
+ total_force = [0.0, 0.0, 0.0]
184
+ for f in state.total_wrench:
185
+ total_force[0] += f.force.x
186
+ total_force[1] += f.force.y
187
+ total_force[2] += f.force.z
188
+ self.bumper.process_contact(total_force, state.collision2_name)
189
+
190
+ node.create_subscription(ContactsState, "/lang2sim/contact_states", _contact_cb, 20)
191
+ except ImportError:
192
+ pass
193
+
194
+ from rclpy.executors import MultiThreadedExecutor # type: ignore[import-not-found]
195
+ self._executor = MultiThreadedExecutor()
196
+ self._executor.add_node(node)
197
+ self._thread = threading.Thread(target=self._executor.spin, daemon=True)
198
+ self._thread.start()
199
+ self._ros_ok = True
200
+ except Exception:
201
+ self._ros_ok = False
202
+
203
+ def record_kinematics(
204
+ self,
205
+ t: float,
206
+ x: float,
207
+ y: float,
208
+ yaw: float,
209
+ v: float,
210
+ w: float,
211
+ slam_x: Optional[float] = None,
212
+ slam_y: Optional[float] = None,
213
+ ) -> None:
214
+ calc_ate = None
215
+ if slam_x is not None and slam_y is not None:
216
+ calc_ate = math.hypot(x - slam_x, y - slam_y)
217
+ self.current_ate = calc_ate
218
+ if calc_ate > self.max_ate:
219
+ self.max_ate = calc_ate
220
+ self.ate_history.append(calc_ate)
221
+
222
+ sample = TelemetrySample(
223
+ t=t,
224
+ x=x,
225
+ y=y,
226
+ yaw=yaw,
227
+ v=v,
228
+ w=w,
229
+ collision_count=self.bumper.collision_count,
230
+ last_force_n=self.bumper.last_force_n,
231
+ slam_x=slam_x,
232
+ slam_y=slam_y,
233
+ ate=calc_ate,
234
+ )
235
+ with self.lock:
236
+ self.ring_buffer.append(sample)
237
+ if self.active_run_samples is not None:
238
+ sample_dict: dict[str, float] = {
239
+ "t": t,
240
+ "x": x,
241
+ "y": y,
242
+ "yaw": yaw,
243
+ "v": v,
244
+ "w": w,
245
+ }
246
+ if slam_x is not None:
247
+ sample_dict["slam_x"] = slam_x
248
+ if slam_y is not None:
249
+ sample_dict["slam_y"] = slam_y
250
+ if calc_ate is not None:
251
+ sample_dict["ate"] = calc_ate
252
+ self.active_run_samples.append(sample_dict)
253
+
254
+ def record_slam_pose(self, t: float, x: float, y: float, yaw: float) -> float:
255
+ """Record SLAM estimated pose and update real-time ATE drift tracking.
256
+
257
+ ATE = ||p_gt - p_slam||_2
258
+ Returns the instantaneous ATE drift in meters.
259
+ """
260
+ with self.lock:
261
+ self.latest_slam_pose = (x, y, yaw)
262
+ ate = 0.0
263
+ if self.ring_buffer:
264
+ # Find the GT sample closest in timestamp t
265
+ best_gt = min(self.ring_buffer, key=lambda s: abs(s.t - t))
266
+ ate = math.hypot(best_gt.x - x, best_gt.y - y)
267
+ self.current_ate = ate
268
+ if ate > self.max_ate:
269
+ self.max_ate = ate
270
+ self.ate_history.append(ate)
271
+ best_gt.slam_x = x
272
+ best_gt.slam_y = y
273
+ best_gt.ate = ate
274
+ if self.active_run_samples and len(self.active_run_samples) > 0:
275
+ best_active = min(self.active_run_samples, key=lambda s: abs(s["t"] - t))
276
+ best_active["slam_x"] = x
277
+ best_active["slam_y"] = y
278
+ best_active["ate"] = ate
279
+ return ate
280
+
281
+ def start_run(self, label: str = "run", goal: Optional[tuple[float, float]] = None) -> None:
282
+ """Begin recording an evaluation run for NQI scoring."""
283
+ with self.lock:
284
+ self.run_label = label
285
+ self.goal = goal
286
+ self.active_run_samples = []
287
+ self.bumper.reset()
288
+ self.current_ate = 0.0
289
+ self.max_ate = 0.0
290
+ self.ate_history.clear()
291
+
292
+ def stop_run(self) -> Trajectory:
293
+ """Stop recording and return captured Trajectory."""
294
+ with self.lock:
295
+ if self.active_run_samples and len(self.active_run_samples) >= 2:
296
+ samples = list(self.active_run_samples)
297
+ elif len(self.ring_buffer) >= 2:
298
+ samples = [
299
+ {
300
+ "t": s.t,
301
+ "x": s.x,
302
+ "y": s.y,
303
+ "yaw": s.yaw,
304
+ "v": s.v,
305
+ "w": s.w,
306
+ **({"slam_x": s.slam_x} if s.slam_x is not None else {}),
307
+ **({"slam_y": s.slam_y} if s.slam_y is not None else {}),
308
+ **({"ate": s.ate} if s.ate is not None else {}),
309
+ }
310
+ for s in self.ring_buffer
311
+ ]
312
+ else:
313
+ t_now = time.time()
314
+ samples = [
315
+ {"t": t_now - 1.0, "x": 0.0, "y": 0.0, "yaw": 0.0, "v": 0.0, "w": 0.0},
316
+ {"t": t_now, "x": 0.01, "y": 0.0, "yaw": 0.0, "v": 0.01, "w": 0.0},
317
+ ]
318
+ traj = Trajectory.from_samples(
319
+ samples,
320
+ goal=self.goal,
321
+ collisions=self.bumper.collision_count,
322
+ label=self.run_label,
323
+ )
324
+ self.active_run_samples = None
325
+ return traj
326
+
327
+ def _start_api_poll(self) -> None:
328
+ if self._api_thread is not None and self._api_thread.is_alive():
329
+ return
330
+ self._api_running = True
331
+ self._api_thread = threading.Thread(target=self._api_worker, daemon=True)
332
+ self._api_thread.start()
333
+
334
+ def _api_worker(self) -> None:
335
+ import json
336
+ import urllib.request
337
+ target_url = f"{self.api_base_url.rstrip('/')}/v1/sim/status"
338
+ while self._api_running:
339
+ try:
340
+ req = urllib.request.Request(target_url, headers={"User-Agent": "Lang2Sim-Telemetry"})
341
+ with urllib.request.urlopen(req, timeout=0.8) as resp:
342
+ if resp.status == 200:
343
+ payload = json.loads(resp.read().decode("utf-8"))
344
+ self._api_ok = True
345
+ self.rtf = float(payload.get("real_time_factor") or 1.0)
346
+ self.sim_time = float(payload.get("sim_time") or 0.0)
347
+ pose = payload.get("robot_pose") or {}
348
+ vel = payload.get("robot_velocity") or {}
349
+ px = float(pose.get("x", 0.0))
350
+ py = float(pose.get("y", 0.0))
351
+ yaw = float(pose.get("yaw", 0.0))
352
+ vx = float(vel.get("x", 0.0)) if isinstance(vel, dict) else 0.0
353
+ wz = float(vel.get("yaw", 0.0)) if isinstance(vel, dict) else 0.0
354
+ self.record_kinematics(time.time(), px, py, yaw, vx, wz)
355
+ except Exception:
356
+ self._api_ok = False
357
+ time.sleep(self.poll_interval)
358
+
359
+ def close(self) -> None:
360
+ """Cleanly terminate background polling threads."""
361
+ self._api_running = False
362
+ if self._thread and self._thread.is_alive():
363
+ try:
364
+ if self._executor:
365
+ self._executor.shutdown()
366
+ except Exception:
367
+ pass
368
+
369
+ def snapshot(self) -> dict[str, Any]:
370
+ """Return instantaneous state for the Textual TUI sidebar at 10Hz+."""
371
+ with self.lock:
372
+ if self.ring_buffer:
373
+ latest = self.ring_buffer[-1]
374
+ x, y, yaw, v, w = latest.x, latest.y, latest.yaw, latest.v, latest.w
375
+ else:
376
+ x, y, yaw, v, w = 0.0, 0.0, 0.0, 0.0, 0.0
377
+ backend = "ros2" if self._ros_ok else ("control_api" if self._api_ok else "mock")
378
+ mean_ate = (sum(self.ate_history) / len(self.ate_history)) if self.ate_history else 0.0
379
+ return {
380
+ "active": self._ros_ok or self._api_ok,
381
+ "backend": backend,
382
+ "x": round(x, 2),
383
+ "y": round(y, 2),
384
+ "yaw": round(yaw, 3),
385
+ "v": round(v, 2),
386
+ "w": round(w, 2),
387
+ "rtf": round(self.rtf, 2),
388
+ "sim_time": round(self.sim_time, 2),
389
+ "collisions": self.bumper.collision_count,
390
+ "last_force_n": round(self.bumper.last_force_n, 1),
391
+ "last_contact_entity": self.bumper.last_contact_entity,
392
+ "samples_buffered": len(self.ring_buffer),
393
+ "ate": round(self.current_ate, 3),
394
+ "max_ate": round(self.max_ate, 3),
395
+ "mean_ate": round(mean_ate, 3),
396
+ "ate_diverged": bool(self.current_ate > self.ate_divergence_threshold),
397
+ }
398
+
399
+ get_snapshot = snapshot
400
+
401
+
402
+ # --------------------------------------------------------------------------- Replay Source
403
+ class ReplaySource:
404
+ """Mock telemetry provider replaying synthetic trajectories or recorded JSON files."""
405
+
406
+ def __init__(self, trajectory: Optional[Trajectory] = None) -> None:
407
+ self.trajectory = trajectory
408
+ self.index = 0
409
+ self.collisions = 0
410
+ self.t_start = time.time()
411
+
412
+ @classmethod
413
+ def from_json(cls, json_path: str | Path) -> "ReplaySource":
414
+ traj = Trajectory.from_json(json_path)
415
+ return cls(traj)
416
+
417
+ def snapshot(self) -> dict[str, Any]:
418
+ if self.trajectory is None or self.trajectory.n == 0:
419
+ return {"active": False, "x": 0.0, "y": 0.0, "yaw": 0.0, "v": 0.0, "w": 0.0, "collisions": 0}
420
+ elapsed = time.time() - self.t_start
421
+ # Find closest sample
422
+ dt_arr = np.abs((self.trajectory.t - self.trajectory.t[0]) - elapsed)
423
+ idx = int(np.argmin(dt_arr))
424
+ return {
425
+ "active": True,
426
+ "x": round(float(self.trajectory.x[idx]), 2),
427
+ "y": round(float(self.trajectory.y[idx]), 2),
428
+ "yaw": round(float(self.trajectory.yaw[idx]), 3),
429
+ "v": round(float(self.trajectory.v[idx]), 2),
430
+ "w": round(float(self.trajectory.w[idx]), 2),
431
+ "collisions": self.trajectory.collisions,
432
+ "last_force_n": 0.0,
433
+ "samples_buffered": self.trajectory.n,
434
+ }
435
+
436
+
437
+ _GLOBAL_COLLECTOR: Optional[TelemetryCollector] = None
438
+
439
+
440
+ def get_telemetry_collector() -> TelemetryCollector:
441
+ global _GLOBAL_COLLECTOR
442
+ if _GLOBAL_COLLECTOR is None:
443
+ _GLOBAL_COLLECTOR = TelemetryCollector()
444
+ return _GLOBAL_COLLECTOR
445
+
446
+
447
+ async def query_robot_telemetry() -> dict[str, Any]:
448
+ """Asynchronous query function returning live robot kinematics and collision status."""
449
+ return get_telemetry_collector().snapshot()
450
+
451
+
452
+ # --------------------------------------------------------------------------- CLI
453
+ def main() -> int:
454
+ parser = argparse.ArgumentParser(description="Lang2Sim Telemetry Subscriber & Collision Monitor")
455
+ parser.add_argument("--live", action="store_true", help="Monitor live /odom and /lang2sim/contact_states")
456
+ parser.add_argument("--print-hz", type=float, default=2.0, help="Print rate in Hz")
457
+ parser.add_argument("--record", help="Record live run to JSON file on exit")
458
+ parser.add_argument("--goal", nargs=2, type=float, metavar=("X", "Y"), help="Mission goal for recorded run")
459
+ args = parser.parse_args()
460
+
461
+ collector = get_telemetry_collector()
462
+ goal = tuple(args.goal) if args.goal else None
463
+ if args.record:
464
+ collector.start_run(label="recorded_run", goal=goal)
465
+ print(f"Recording telemetry run to '{args.record}'... Press Ctrl+C to stop.")
466
+
467
+ dt = 1.0 / max(0.1, args.print_hz)
468
+ try:
469
+ while True:
470
+ snap = collector.snapshot()
471
+ status = "ROS ACTIVE" if snap["active"] else "STANDBY / REPLAY"
472
+ sys.stdout.write(
473
+ f"\r[{status}] Pose: ({snap['x']:6.2f}, {snap['y']:6.2f}) | "
474
+ f"Yaw: {snap['yaw']:5.2f} | Vel: {snap['v']:4.2f} m/s | "
475
+ f"Collisions: {snap['collisions']:2d} | Force: {snap['last_force_n']:4.1f} N"
476
+ )
477
+ sys.stdout.flush()
478
+ time.sleep(dt)
479
+ except KeyboardInterrupt:
480
+ print("\nStopping telemetry monitor.")
481
+ if args.record:
482
+ traj = collector.stop_run()
483
+ traj.to_json(args.record)
484
+ print(f"Saved {traj.n} samples to {args.record}")
485
+ return 0
486
+
487
+
488
+ if __name__ == "__main__":
489
+ sys.exit(main())