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.
- lang2sim/__init__.py +47 -0
- lang2sim/agent/__init__.py +5 -0
- lang2sim/agent/nemotron_client.py +109 -0
- lang2sim/agent/react_loop.py +848 -0
- lang2sim/agent/tools.py +998 -0
- lang2sim/cli/app.py +183 -0
- lang2sim/cli/screens/__init__.py +8 -0
- lang2sim/cli/screens/command_palette_modal.py +146 -0
- lang2sim/cli/screens/landing_screen.py +96 -0
- lang2sim/cli/screens/sessions_modal.py +236 -0
- lang2sim/cli/screens/workspace_screen.py +709 -0
- lang2sim/cli/widgets/__init__.py +16 -0
- lang2sim/cli/widgets/action_pill.py +67 -0
- lang2sim/cli/widgets/command_card.py +265 -0
- lang2sim/cli/widgets/plan_card.py +106 -0
- lang2sim/cli/widgets/prompt_card.py +96 -0
- lang2sim/cli/widgets/telemetry_sidebar.py +117 -0
- lang2sim/cli/widgets/thought_box.py +64 -0
- lang2sim/compiler/__init__.py +15 -0
- lang2sim/compiler/sdf_compiler.py +65 -0
- lang2sim/compiler/sir_generator.py +46 -0
- lang2sim/compiler/sir_validator.py +87 -0
- lang2sim/core/__init__.py +50 -0
- lang2sim/core/config.py +116 -0
- lang2sim/core/models.py +279 -0
- lang2sim/core/session_manager.py +189 -0
- lang2sim/perturbation/__init__.py +50 -0
- lang2sim/perturbation/_compat.py +19 -0
- lang2sim/perturbation/gazebo_bridge.py +520 -0
- lang2sim/perturbation/mcp_server.py +129 -0
- lang2sim/perturbation/routers/__init__.py +19 -0
- lang2sim/perturbation/routers/world.py +110 -0
- lang2sim/perturbation/sdf_snippets.py +112 -0
- lang2sim/runtime/__init__.py +7 -0
- lang2sim/runtime/control_api/__init__.py +7 -0
- lang2sim/runtime/control_api/client.py +187 -0
- lang2sim/runtime/control_api/server.py +504 -0
- lang2sim/runtime/display.py +141 -0
- lang2sim/runtime/docker_runner.py +286 -0
- lang2sim/runtime/doctor.py +220 -0
- lang2sim/runtime/hardware_probe.py +144 -0
- lang2sim/runtime/health_monitor.py +66 -0
- lang2sim/runtime/launch_builder.py +137 -0
- lang2sim/runtime/nav2_dispatcher.py +215 -0
- lang2sim/runtime/platform.py +137 -0
- lang2sim/runtime/ros_bridge.py +183 -0
- lang2sim/runtime/run_folder.py +86 -0
- lang2sim/runtime/self_healing.py +387 -0
- lang2sim/runtime/session.py +136 -0
- lang2sim/runtime/world_check.py +197 -0
- lang2sim/telemetry/__init__.py +85 -0
- lang2sim/telemetry/nqi_scorer.py +645 -0
- lang2sim/telemetry/ros_subscribers.py +489 -0
- lang2sim/telemetry/trajectory.py +258 -0
- lang2sim/telemetry/vision.py +477 -0
- lang2sim/world_gen/__init__.py +16 -0
- lang2sim/world_gen/catalog_loader.py +83 -0
- lang2sim/world_gen/map_slicer.py +131 -0
- lang2sim/world_gen/shapely_solver.py +256 -0
- lang2sim-2.0.0.dist-info/METADATA +53 -0
- lang2sim-2.0.0.dist-info/RECORD +65 -0
- lang2sim-2.0.0.dist-info/WHEEL +5 -0
- lang2sim-2.0.0.dist-info/entry_points.txt +2 -0
- lang2sim-2.0.0.dist-info/licenses/LICENSE +21 -0
- 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())
|