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,520 @@
|
|
|
1
|
+
"""Gazebo Dynamic Mutation Bridge.
|
|
2
|
+
|
|
3
|
+
Dispatches live simulation mutations (spawning models, dynamic lighting,
|
|
4
|
+
sub-second resets, chassis velocity impulses) across Gazebo backends:
|
|
5
|
+
- Harmonic (GZ Sim): gz service CLI / gz transport / Docker pass-through
|
|
6
|
+
- Classic (Gazebo 11 / ROS 2): ros2 service call / rclpy
|
|
7
|
+
- DryRun: Deterministic simulation on non-Linux workstations or test suites
|
|
8
|
+
|
|
9
|
+
All subprocess executions are asynchronous with strict timeouts, ensuring zero
|
|
10
|
+
blocking of the terminal event loop.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import abc
|
|
16
|
+
import asyncio
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, Optional
|
|
22
|
+
|
|
23
|
+
from lang2sim.perturbation._compat import Pose2D
|
|
24
|
+
from lang2sim.perturbation.sdf_snippets import get_inline_sdf
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"BridgeResult",
|
|
28
|
+
"GazeboBridge",
|
|
29
|
+
"DryRunBridge",
|
|
30
|
+
"HarmonicBridge",
|
|
31
|
+
"ClassicBridge",
|
|
32
|
+
"get_bridge",
|
|
33
|
+
"reset_bridge_instance",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class BridgeResult:
|
|
39
|
+
"""Result envelope returned by all mutation bridge operations."""
|
|
40
|
+
ok: bool
|
|
41
|
+
backend: str
|
|
42
|
+
tool: str
|
|
43
|
+
command: str = ""
|
|
44
|
+
stdout: str = ""
|
|
45
|
+
stderr: str = ""
|
|
46
|
+
latency_ms: float = 0.0
|
|
47
|
+
message: str = ""
|
|
48
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
return {
|
|
52
|
+
"ok": self.ok,
|
|
53
|
+
"backend": self.backend,
|
|
54
|
+
"tool": self.tool,
|
|
55
|
+
"command": self.command,
|
|
56
|
+
"latency_ms": round(self.latency_ms, 2),
|
|
57
|
+
"message": self.message,
|
|
58
|
+
"data": self.data,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class GazeboBridge(abc.ABC):
|
|
63
|
+
"""Abstract Base Class for simulator dynamic mutation dispatchers."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, world_name: Optional[str] = None, exec_prefix: Optional[str] = None) -> None:
|
|
66
|
+
self.world_name = world_name or os.environ.get("LANG2SIM_WORLD_NAME") or "default"
|
|
67
|
+
self.exec_prefix = (
|
|
68
|
+
exec_prefix
|
|
69
|
+
if exec_prefix is not None
|
|
70
|
+
else (os.environ.get("LANG2SIM_EXEC_PREFIX") or "")
|
|
71
|
+
).strip()
|
|
72
|
+
self.spawn_counter = 0
|
|
73
|
+
self.spawned_entities: list[str] = []
|
|
74
|
+
self.current_luminance: float = 1.0
|
|
75
|
+
|
|
76
|
+
@abc.abstractmethod
|
|
77
|
+
async def spawn_entity(
|
|
78
|
+
self,
|
|
79
|
+
model_name: str,
|
|
80
|
+
pose: Pose2D,
|
|
81
|
+
entity_name: Optional[str] = None,
|
|
82
|
+
) -> BridgeResult:
|
|
83
|
+
"""Spawn a dynamic obstacle into the live simulation."""
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
@abc.abstractmethod
|
|
87
|
+
async def set_light(self, luminance: float, light_name: Optional[str] = None) -> BridgeResult:
|
|
88
|
+
"""Dynamically alter ambient/sun lighting luminance (0.0 to 1.0)."""
|
|
89
|
+
...
|
|
90
|
+
|
|
91
|
+
@abc.abstractmethod
|
|
92
|
+
async def reset(self, mode: str = "world") -> BridgeResult:
|
|
93
|
+
"""Execute sub-second simulation reset without closing GUI windows."""
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
@abc.abstractmethod
|
|
97
|
+
async def apply_perturbation(self, velocity_bias: Pose2D, duration_s: float = 1.0) -> BridgeResult:
|
|
98
|
+
"""Apply force impulse or chassis velocity bias to the robot."""
|
|
99
|
+
...
|
|
100
|
+
|
|
101
|
+
@abc.abstractmethod
|
|
102
|
+
def health(self) -> dict[str, Any]:
|
|
103
|
+
"""Query bridge status and connectivity health."""
|
|
104
|
+
...
|
|
105
|
+
|
|
106
|
+
def list_spawned_entities(self) -> list[str]:
|
|
107
|
+
return list(self.spawned_entities)
|
|
108
|
+
|
|
109
|
+
def _next_entity_name(self, model_name: str, requested_name: Optional[str]) -> str:
|
|
110
|
+
if requested_name:
|
|
111
|
+
return requested_name
|
|
112
|
+
self.spawn_counter += 1
|
|
113
|
+
return f"{model_name}_{self.spawn_counter}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --------------------------------------------------------------------------- DryRun Bridge
|
|
117
|
+
class DryRunBridge(GazeboBridge):
|
|
118
|
+
"""Safe, zero-dependency bridge used on development machines and in unit tests."""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
world_name: Optional[str] = None,
|
|
123
|
+
exec_prefix: Optional[str] = None,
|
|
124
|
+
simulated_target: str = "harmonic",
|
|
125
|
+
) -> None:
|
|
126
|
+
super().__init__(world_name, exec_prefix)
|
|
127
|
+
self.simulated_target = simulated_target
|
|
128
|
+
|
|
129
|
+
async def spawn_entity(
|
|
130
|
+
self,
|
|
131
|
+
model_name: str,
|
|
132
|
+
pose: Pose2D,
|
|
133
|
+
entity_name: Optional[str] = None,
|
|
134
|
+
) -> BridgeResult:
|
|
135
|
+
t0 = time.perf_counter()
|
|
136
|
+
name = self._next_entity_name(model_name, entity_name)
|
|
137
|
+
self.spawned_entities.append(name)
|
|
138
|
+
# Formulate the command that would have run
|
|
139
|
+
if self.simulated_target == "harmonic":
|
|
140
|
+
cmd = (
|
|
141
|
+
f"{self.exec_prefix} gz service -s /world/{self.world_name}/create "
|
|
142
|
+
f"--reqtype gz.msgs.EntityFactory "
|
|
143
|
+
f'--req "sdf_filename: \'model://{model_name}\', name: \'{name}\', '
|
|
144
|
+
f'pose: {{position: {{x: {pose.x}, y: {pose.y}, z: 0.3}}, orientation: {{z: {pose.yaw}}}}}\"'
|
|
145
|
+
).strip()
|
|
146
|
+
else:
|
|
147
|
+
cmd = (
|
|
148
|
+
f"{self.exec_prefix} ros2 service call /spawn_entity gazebo_msgs/srv/SpawnEntity "
|
|
149
|
+
f'"{{name: \'{name}\', initial_pose: {{position: {{x: {pose.x}, y: {pose.y}, z: 0.3}}}}}}"'
|
|
150
|
+
).strip()
|
|
151
|
+
|
|
152
|
+
latency = (time.perf_counter() - t0) * 1000.0 + 0.1
|
|
153
|
+
return BridgeResult(
|
|
154
|
+
ok=True,
|
|
155
|
+
backend=f"dryrun ({self.simulated_target})",
|
|
156
|
+
tool="spawn_dynamic_entity",
|
|
157
|
+
command=cmd,
|
|
158
|
+
stdout="[DryRun] Entity successfully registered in mock scene tree",
|
|
159
|
+
latency_ms=latency,
|
|
160
|
+
message=f"Spawned {name} at ({pose.x:.2f}, {pose.y:.2f})",
|
|
161
|
+
data={"entity_name": name, "model_name": model_name, "pose": pose.model_dump()},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
async def set_light(self, luminance: float, light_name: Optional[str] = None) -> BridgeResult:
|
|
165
|
+
t0 = time.perf_counter()
|
|
166
|
+
if not (0.0 <= luminance <= 1.0):
|
|
167
|
+
return BridgeResult(
|
|
168
|
+
ok=False,
|
|
169
|
+
backend="dryrun",
|
|
170
|
+
tool="set_ambient_lighting",
|
|
171
|
+
message=f"Luminance must be between 0.0 and 1.0, got {luminance}",
|
|
172
|
+
)
|
|
173
|
+
self.current_luminance = float(luminance)
|
|
174
|
+
light = light_name or os.environ.get("LANG2SIM_SUN_LIGHT") or "sun"
|
|
175
|
+
if self.simulated_target == "harmonic":
|
|
176
|
+
cmd = (
|
|
177
|
+
f"{self.exec_prefix} gz service -s /world/{self.world_name}/light_config "
|
|
178
|
+
f"--reqtype gz.msgs.Light "
|
|
179
|
+
f'--req "name: \'{light}\', diffuse: {{r: {luminance:.2f}, g: {luminance:.2f}, b: {luminance:.2f}, a: 1.0}}"'
|
|
180
|
+
).strip()
|
|
181
|
+
else:
|
|
182
|
+
cmd = (
|
|
183
|
+
f"{self.exec_prefix} ros2 service call /set_light_properties gazebo_msgs/srv/SetLightProperties "
|
|
184
|
+
f'"{{light_name: \'{light}\', diffuse: {{r: {luminance:.2f}, g: {luminance:.2f}, b: {luminance:.2f}, a: 1.0}}}}"'
|
|
185
|
+
).strip()
|
|
186
|
+
|
|
187
|
+
latency = (time.perf_counter() - t0) * 1000.0 + 0.1
|
|
188
|
+
return BridgeResult(
|
|
189
|
+
ok=True,
|
|
190
|
+
backend=f"dryrun ({self.simulated_target})",
|
|
191
|
+
tool="set_ambient_lighting",
|
|
192
|
+
command=cmd,
|
|
193
|
+
stdout="[DryRun] Lighting properties applied",
|
|
194
|
+
latency_ms=latency,
|
|
195
|
+
message=f"Ambient lighting adjusted to {luminance:.2f}",
|
|
196
|
+
data={"luminance": luminance, "light_name": light},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
async def reset(self, mode: str = "world") -> BridgeResult:
|
|
200
|
+
t0 = time.perf_counter()
|
|
201
|
+
if self.simulated_target == "harmonic":
|
|
202
|
+
cmd = (
|
|
203
|
+
f"{self.exec_prefix} gz service -s /world/{self.world_name}/control "
|
|
204
|
+
f'--reqtype gz.msgs.WorldControl --req "reset: {{all: true}}"'
|
|
205
|
+
).strip()
|
|
206
|
+
else:
|
|
207
|
+
cmd = f"{self.exec_prefix} ros2 service call /reset_world std_srvs/srv/Empty '{{}}'".strip()
|
|
208
|
+
|
|
209
|
+
latency = (time.perf_counter() - t0) * 1000.0 + 12.0 # simulates sub-300ms reset
|
|
210
|
+
return BridgeResult(
|
|
211
|
+
ok=True,
|
|
212
|
+
backend=f"dryrun ({self.simulated_target})",
|
|
213
|
+
tool="reset_simulation",
|
|
214
|
+
command=cmd,
|
|
215
|
+
stdout="[DryRun] Sub-second reset signal acknowledged",
|
|
216
|
+
latency_ms=latency,
|
|
217
|
+
message=f"Simulation reset completed ({mode}) in {latency:.1f}ms",
|
|
218
|
+
data={"mode": mode, "reset_ms": latency},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
async def apply_perturbation(self, velocity_bias: Pose2D, duration_s: float = 1.0) -> BridgeResult:
|
|
222
|
+
t0 = time.perf_counter()
|
|
223
|
+
robot = os.environ.get("LANG2SIM_ROBOT_NAME") or "turtlebot3_waffle"
|
|
224
|
+
cmd = (
|
|
225
|
+
f"{self.exec_prefix} ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist "
|
|
226
|
+
f'"{{linear: {{x: {velocity_bias.x}, y: {velocity_bias.y}}}, angular: {{z: {velocity_bias.yaw}}}}}"'
|
|
227
|
+
).strip()
|
|
228
|
+
latency = (time.perf_counter() - t0) * 1000.0 + 0.1
|
|
229
|
+
return BridgeResult(
|
|
230
|
+
ok=True,
|
|
231
|
+
backend=f"dryrun ({self.simulated_target})",
|
|
232
|
+
tool="apply_chassis_perturbation",
|
|
233
|
+
command=cmd,
|
|
234
|
+
stdout="[DryRun] Chassis perturbation velocity injected",
|
|
235
|
+
latency_ms=latency,
|
|
236
|
+
message=f"Applied velocity impulse (vx={velocity_bias.x}, vy={velocity_bias.y}) for {duration_s}s",
|
|
237
|
+
data={"velocity_bias": velocity_bias.model_dump(), "duration_s": duration_s, "robot": robot},
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def health(self) -> dict[str, Any]:
|
|
241
|
+
return {
|
|
242
|
+
"status": "ready",
|
|
243
|
+
"backend": f"dryrun ({self.simulated_target})",
|
|
244
|
+
"world": self.world_name,
|
|
245
|
+
"spawned_entities": len(self.spawned_entities),
|
|
246
|
+
"current_luminance": self.current_luminance,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# --------------------------------------------------------------------------- Subprocess execution helper
|
|
251
|
+
async def _run_command(cmd_str: str, timeout_s: float = 3.0) -> tuple[int, str, str]:
|
|
252
|
+
"""Execute command asynchronously with timeout. Returns (returncode, stdout, stderr)."""
|
|
253
|
+
try:
|
|
254
|
+
proc = await asyncio.create_subprocess_shell(
|
|
255
|
+
cmd_str,
|
|
256
|
+
stdout=asyncio.subprocess.PIPE,
|
|
257
|
+
stderr=asyncio.subprocess.PIPE,
|
|
258
|
+
)
|
|
259
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
|
|
260
|
+
return proc.returncode or 0, stdout.decode(errors="replace"), stderr.decode(errors="replace")
|
|
261
|
+
except asyncio.TimeoutError:
|
|
262
|
+
return -1, "", f"Command timed out after {timeout_s}s"
|
|
263
|
+
except Exception as exc:
|
|
264
|
+
return -2, "", str(exc)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# --------------------------------------------------------------------------- Harmonic Bridge
|
|
268
|
+
class HarmonicBridge(GazeboBridge):
|
|
269
|
+
"""Bridge for Gazebo Harmonic (GZ Sim 8+) via `gz service` CLI / Docker pass-through."""
|
|
270
|
+
|
|
271
|
+
async def spawn_entity(
|
|
272
|
+
self,
|
|
273
|
+
model_name: str,
|
|
274
|
+
pose: Pose2D,
|
|
275
|
+
entity_name: Optional[str] = None,
|
|
276
|
+
) -> BridgeResult:
|
|
277
|
+
t0 = time.perf_counter()
|
|
278
|
+
name = self._next_entity_name(model_name, entity_name)
|
|
279
|
+
|
|
280
|
+
# Check if model exists in catalog or local file, else use inline SDF
|
|
281
|
+
sdf_data = get_inline_sdf(model_name, name)
|
|
282
|
+
# Using gz service /world/<name>/create
|
|
283
|
+
req = (
|
|
284
|
+
f"sdf: '{sdf_data.replace(chr(10), ' ')}', "
|
|
285
|
+
f"name: '{name}', "
|
|
286
|
+
f"pose: {{position: {{x: {pose.x}, y: {pose.y}, z: 0.35}}, orientation: {{z: {pose.yaw}}}}}"
|
|
287
|
+
)
|
|
288
|
+
cmd = f"{self.exec_prefix} gz service -s /world/{self.world_name}/create --reqtype gz.msgs.EntityFactory --req \"{req}\"".strip()
|
|
289
|
+
|
|
290
|
+
rc, out, err = await _run_command(cmd)
|
|
291
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
292
|
+
ok = rc == 0 and "data: true" in out or "success" in out.lower()
|
|
293
|
+
if ok or rc == 0:
|
|
294
|
+
self.spawned_entities.append(name)
|
|
295
|
+
return BridgeResult(
|
|
296
|
+
ok=ok or (rc == 0),
|
|
297
|
+
backend="harmonic",
|
|
298
|
+
tool="spawn_dynamic_entity",
|
|
299
|
+
command=cmd,
|
|
300
|
+
stdout=out,
|
|
301
|
+
stderr=err,
|
|
302
|
+
latency_ms=latency,
|
|
303
|
+
message=f"Spawned {name} into {self.world_name}" if rc == 0 else f"Failed to spawn: {err or out}",
|
|
304
|
+
data={"entity_name": name, "model_name": model_name, "pose": pose.model_dump()},
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
async def set_light(self, luminance: float, light_name: Optional[str] = None) -> BridgeResult:
|
|
308
|
+
t0 = time.perf_counter()
|
|
309
|
+
if not (0.0 <= luminance <= 1.0):
|
|
310
|
+
return BridgeResult(ok=False, backend="harmonic", tool="set_ambient_lighting", message="Luminance must be in [0, 1]")
|
|
311
|
+
light = light_name or os.environ.get("LANG2SIM_SUN_LIGHT") or "sun"
|
|
312
|
+
req = f"name: '{light}', diffuse: {{r: {luminance:.2f}, g: {luminance:.2f}, b: {luminance:.2f}, a: 1.0}}"
|
|
313
|
+
cmd = f"{self.exec_prefix} gz service -s /world/{self.world_name}/light_config --reqtype gz.msgs.Light --req \"{req}\"".strip()
|
|
314
|
+
|
|
315
|
+
rc, out, err = await _run_command(cmd)
|
|
316
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
317
|
+
self.current_luminance = luminance
|
|
318
|
+
return BridgeResult(
|
|
319
|
+
ok=rc == 0,
|
|
320
|
+
backend="harmonic",
|
|
321
|
+
tool="set_ambient_lighting",
|
|
322
|
+
command=cmd,
|
|
323
|
+
stdout=out,
|
|
324
|
+
stderr=err,
|
|
325
|
+
latency_ms=latency,
|
|
326
|
+
message=f"Light '{light}' luminance set to {luminance:.2f}",
|
|
327
|
+
data={"luminance": luminance, "light_name": light},
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
async def reset(self, mode: str = "world") -> BridgeResult:
|
|
331
|
+
t0 = time.perf_counter()
|
|
332
|
+
req = "reset: {all: true}" if mode == "world" else "reset: {model_only: true}"
|
|
333
|
+
cmd = f"{self.exec_prefix} gz service -s /world/{self.world_name}/control --reqtype gz.msgs.WorldControl --req \"{req}\"".strip()
|
|
334
|
+
|
|
335
|
+
rc, out, err = await _run_command(cmd)
|
|
336
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
337
|
+
return BridgeResult(
|
|
338
|
+
ok=rc == 0,
|
|
339
|
+
backend="harmonic",
|
|
340
|
+
tool="reset_simulation",
|
|
341
|
+
command=cmd,
|
|
342
|
+
stdout=out,
|
|
343
|
+
stderr=err,
|
|
344
|
+
latency_ms=latency,
|
|
345
|
+
message=f"Sub-second reset ({mode}) finished in {latency:.1f}ms",
|
|
346
|
+
data={"mode": mode, "reset_ms": latency},
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
async def apply_perturbation(self, velocity_bias: Pose2D, duration_s: float = 1.0) -> BridgeResult:
|
|
350
|
+
t0 = time.perf_counter()
|
|
351
|
+
cmd = (
|
|
352
|
+
f"{self.exec_prefix} ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist "
|
|
353
|
+
f'"{{linear: {{x: {velocity_bias.x}, y: {velocity_bias.y}}}, angular: {{z: {velocity_bias.yaw}}}}}"'
|
|
354
|
+
).strip()
|
|
355
|
+
rc, out, err = await _run_command(cmd)
|
|
356
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
357
|
+
return BridgeResult(
|
|
358
|
+
ok=rc == 0,
|
|
359
|
+
backend="harmonic",
|
|
360
|
+
tool="apply_chassis_perturbation",
|
|
361
|
+
command=cmd,
|
|
362
|
+
stdout=out,
|
|
363
|
+
stderr=err,
|
|
364
|
+
latency_ms=latency,
|
|
365
|
+
message=f"Injected velocity bias ({velocity_bias.x}, {velocity_bias.y})",
|
|
366
|
+
data={"velocity_bias": velocity_bias.model_dump(), "duration_s": duration_s},
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def health(self) -> dict[str, Any]:
|
|
370
|
+
return {
|
|
371
|
+
"status": "ready",
|
|
372
|
+
"backend": "harmonic",
|
|
373
|
+
"world": self.world_name,
|
|
374
|
+
"exec_prefix": self.exec_prefix,
|
|
375
|
+
"spawned_entities": len(self.spawned_entities),
|
|
376
|
+
"current_luminance": self.current_luminance,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# --------------------------------------------------------------------------- Classic Bridge
|
|
381
|
+
class ClassicBridge(GazeboBridge):
|
|
382
|
+
"""Bridge for Gazebo Classic (11) via ROS 2 Humble service calls."""
|
|
383
|
+
|
|
384
|
+
async def spawn_entity(
|
|
385
|
+
self,
|
|
386
|
+
model_name: str,
|
|
387
|
+
pose: Pose2D,
|
|
388
|
+
entity_name: Optional[str] = None,
|
|
389
|
+
) -> BridgeResult:
|
|
390
|
+
t0 = time.perf_counter()
|
|
391
|
+
name = self._next_entity_name(model_name, entity_name)
|
|
392
|
+
sdf_data = get_inline_sdf(model_name, name)
|
|
393
|
+
# Using ros2 service call /spawn_entity
|
|
394
|
+
req = (
|
|
395
|
+
f"{{name: '{name}', xml: '{sdf_data.replace(chr(10), ' ')}', "
|
|
396
|
+
f"initial_pose: {{position: {{x: {pose.x}, y: {pose.y}, z: 0.35}}}}}}"
|
|
397
|
+
)
|
|
398
|
+
cmd = f"{self.exec_prefix} ros2 service call /spawn_entity gazebo_msgs/srv/SpawnEntity \"{req}\"".strip()
|
|
399
|
+
rc, out, err = await _run_command(cmd)
|
|
400
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
401
|
+
if rc == 0:
|
|
402
|
+
self.spawned_entities.append(name)
|
|
403
|
+
return BridgeResult(
|
|
404
|
+
ok=rc == 0,
|
|
405
|
+
backend="classic",
|
|
406
|
+
tool="spawn_dynamic_entity",
|
|
407
|
+
command=cmd,
|
|
408
|
+
stdout=out,
|
|
409
|
+
stderr=err,
|
|
410
|
+
latency_ms=latency,
|
|
411
|
+
message=f"Spawned {name} in Gazebo Classic" if rc == 0 else f"Spawn failed: {err or out}",
|
|
412
|
+
data={"entity_name": name, "model_name": model_name, "pose": pose.model_dump()},
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
async def set_light(self, luminance: float, light_name: Optional[str] = None) -> BridgeResult:
|
|
416
|
+
t0 = time.perf_counter()
|
|
417
|
+
if not (0.0 <= luminance <= 1.0):
|
|
418
|
+
return BridgeResult(ok=False, backend="classic", tool="set_ambient_lighting", message="Luminance must be in [0, 1]")
|
|
419
|
+
light = light_name or os.environ.get("LANG2SIM_SUN_LIGHT") or "sun"
|
|
420
|
+
req = f"{{light_name: '{light}', diffuse: {{r: {luminance:.2f}, g: {luminance:.2f}, b: {luminance:.2f}, a: 1.0}}}}"
|
|
421
|
+
cmd = f"{self.exec_prefix} ros2 service call /set_light_properties gazebo_msgs/srv/SetLightProperties \"{req}\"".strip()
|
|
422
|
+
rc, out, err = await _run_command(cmd)
|
|
423
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
424
|
+
self.current_luminance = luminance
|
|
425
|
+
return BridgeResult(
|
|
426
|
+
ok=rc == 0,
|
|
427
|
+
backend="classic",
|
|
428
|
+
tool="set_ambient_lighting",
|
|
429
|
+
command=cmd,
|
|
430
|
+
stdout=out,
|
|
431
|
+
stderr=err,
|
|
432
|
+
latency_ms=latency,
|
|
433
|
+
message=f"Classic light '{light}' adjusted to {luminance:.2f}",
|
|
434
|
+
data={"luminance": luminance, "light_name": light},
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
async def reset(self, mode: str = "world") -> BridgeResult:
|
|
438
|
+
t0 = time.perf_counter()
|
|
439
|
+
cmd = f"{self.exec_prefix} ros2 service call /reset_world std_srvs/srv/Empty '{{}}'".strip()
|
|
440
|
+
rc, out, err = await _run_command(cmd)
|
|
441
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
442
|
+
return BridgeResult(
|
|
443
|
+
ok=rc == 0,
|
|
444
|
+
backend="classic",
|
|
445
|
+
tool="reset_simulation",
|
|
446
|
+
command=cmd,
|
|
447
|
+
stdout=out,
|
|
448
|
+
stderr=err,
|
|
449
|
+
latency_ms=latency,
|
|
450
|
+
message=f"Gazebo Classic reset completed in {latency:.1f}ms",
|
|
451
|
+
data={"mode": mode, "reset_ms": latency},
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
async def apply_perturbation(self, velocity_bias: Pose2D, duration_s: float = 1.0) -> BridgeResult:
|
|
455
|
+
t0 = time.perf_counter()
|
|
456
|
+
cmd = (
|
|
457
|
+
f"{self.exec_prefix} ros2 topic pub --once /cmd_vel geometry_msgs/msg/Twist "
|
|
458
|
+
f'"{{linear: {{x: {velocity_bias.x}, y: {velocity_bias.y}}}, angular: {{z: {velocity_bias.yaw}}}}}"'
|
|
459
|
+
).strip()
|
|
460
|
+
rc, out, err = await _run_command(cmd)
|
|
461
|
+
latency = (time.perf_counter() - t0) * 1000.0
|
|
462
|
+
return BridgeResult(
|
|
463
|
+
ok=rc == 0,
|
|
464
|
+
backend="classic",
|
|
465
|
+
tool="apply_chassis_perturbation",
|
|
466
|
+
command=cmd,
|
|
467
|
+
stdout=out,
|
|
468
|
+
stderr=err,
|
|
469
|
+
latency_ms=latency,
|
|
470
|
+
message=f"Classic velocity bias applied ({velocity_bias.x}, {velocity_bias.y})",
|
|
471
|
+
data={"velocity_bias": velocity_bias.model_dump(), "duration_s": duration_s},
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def health(self) -> dict[str, Any]:
|
|
475
|
+
return {
|
|
476
|
+
"status": "ready",
|
|
477
|
+
"backend": "classic",
|
|
478
|
+
"world": self.world_name,
|
|
479
|
+
"spawned_entities": len(self.spawned_entities),
|
|
480
|
+
"current_luminance": self.current_luminance,
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# --------------------------------------------------------------------------- Bridge Factory
|
|
485
|
+
_GLOBAL_BRIDGE: Optional[GazeboBridge] = None
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def get_bridge(backend: Optional[str] = None) -> GazeboBridge:
|
|
489
|
+
"""Return cached singleton mutation bridge with automatic backend detection."""
|
|
490
|
+
global _GLOBAL_BRIDGE
|
|
491
|
+
if _GLOBAL_BRIDGE is not None and backend is None:
|
|
492
|
+
return _GLOBAL_BRIDGE
|
|
493
|
+
|
|
494
|
+
req = (backend or os.environ.get("LANG2SIM_SIM_BACKEND") or "auto").lower()
|
|
495
|
+
exec_prefix = os.environ.get("LANG2SIM_EXEC_PREFIX")
|
|
496
|
+
|
|
497
|
+
if req == "harmonic":
|
|
498
|
+
bridge = HarmonicBridge(exec_prefix=exec_prefix)
|
|
499
|
+
elif req == "classic":
|
|
500
|
+
bridge = ClassicBridge(exec_prefix=exec_prefix)
|
|
501
|
+
elif req == "dryrun":
|
|
502
|
+
bridge = DryRunBridge(exec_prefix=exec_prefix)
|
|
503
|
+
else: # "auto"
|
|
504
|
+
# Check if gz command exists on PATH or exec_prefix is supplied
|
|
505
|
+
if exec_prefix or shutil.which("gz"):
|
|
506
|
+
bridge = HarmonicBridge(exec_prefix=exec_prefix)
|
|
507
|
+
elif shutil.which("ros2"):
|
|
508
|
+
bridge = ClassicBridge(exec_prefix=exec_prefix)
|
|
509
|
+
else:
|
|
510
|
+
bridge = DryRunBridge(exec_prefix=exec_prefix)
|
|
511
|
+
|
|
512
|
+
if backend is None:
|
|
513
|
+
_GLOBAL_BRIDGE = bridge
|
|
514
|
+
return bridge
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def reset_bridge_instance() -> None:
|
|
518
|
+
"""Reset cached bridge instance (used by test suites)."""
|
|
519
|
+
global _GLOBAL_BRIDGE
|
|
520
|
+
_GLOBAL_BRIDGE = None
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""In-Process FastMCP Perturbation Server.
|
|
2
|
+
|
|
3
|
+
Exposes simulation stress-testing tools via FastMCP with 0ms in-memory IPC latency:
|
|
4
|
+
- spawn_dynamic_entity(model_name, pose)
|
|
5
|
+
- set_ambient_lighting(luminance)
|
|
6
|
+
- reset_simulation(mode)
|
|
7
|
+
- apply_chassis_perturbation(velocity_bias)
|
|
8
|
+
- bridge_health()
|
|
9
|
+
- list_spawned_entities()
|
|
10
|
+
|
|
11
|
+
Can also be launched standalone via HTTP for developer introspection with MCP Inspector:
|
|
12
|
+
python -m lang2sim.perturbation.mcp_server --http --port 8765
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import asyncio
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
from typing import Any, Mapping, Optional
|
|
22
|
+
|
|
23
|
+
from fastmcp import FastMCP
|
|
24
|
+
|
|
25
|
+
from lang2sim.perturbation._compat import Pose2D
|
|
26
|
+
from lang2sim.perturbation.gazebo_bridge import BridgeResult, get_bridge
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"mcp",
|
|
30
|
+
"call_tool_inprocess",
|
|
31
|
+
"spawn_dynamic_entity",
|
|
32
|
+
"set_ambient_lighting",
|
|
33
|
+
"reset_simulation",
|
|
34
|
+
"apply_chassis_perturbation",
|
|
35
|
+
"bridge_health",
|
|
36
|
+
"list_spawned_entities",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Create in-process FastMCP server
|
|
40
|
+
mcp = FastMCP("lang2sim-perturbation")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# --------------------------------------------------------------------------- Tool definitions
|
|
44
|
+
@mcp.tool
|
|
45
|
+
async def spawn_dynamic_entity(model_name: str, pose: Pose2D) -> dict[str, Any]:
|
|
46
|
+
"""Spawn an obstacle model (e.g. 'wooden_crate', 'barrel', 'cargo_box') at a given 2D pose."""
|
|
47
|
+
bridge = get_bridge()
|
|
48
|
+
res = await bridge.spawn_entity(model_name, pose)
|
|
49
|
+
return res.to_dict()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@mcp.tool
|
|
53
|
+
async def set_ambient_lighting(luminance: float) -> dict[str, Any]:
|
|
54
|
+
"""Adjust ambient/sun simulation lighting luminance between 0.0 (dark) and 1.0 (full daylight)."""
|
|
55
|
+
bridge = get_bridge()
|
|
56
|
+
res = await bridge.set_light(luminance)
|
|
57
|
+
return res.to_dict()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@mcp.tool
|
|
61
|
+
async def reset_simulation(mode: str = "world") -> dict[str, Any]:
|
|
62
|
+
"""Execute sub-second world/robot state reset without tearing down Gazebo or closing GUI windows."""
|
|
63
|
+
bridge = get_bridge()
|
|
64
|
+
res = await bridge.reset(mode)
|
|
65
|
+
return res.to_dict()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@mcp.tool
|
|
69
|
+
async def apply_chassis_perturbation(velocity_bias: Pose2D, duration_s: float = 1.0) -> dict[str, Any]:
|
|
70
|
+
"""Inject traction loss or force impulse (linear x/y, angular yaw) onto the mobile robot."""
|
|
71
|
+
bridge = get_bridge()
|
|
72
|
+
res = await bridge.apply_perturbation(velocity_bias, duration_s)
|
|
73
|
+
return res.to_dict()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@mcp.tool
|
|
77
|
+
def bridge_health() -> dict[str, Any]:
|
|
78
|
+
"""Query active bridge status, backend type, and connectivity."""
|
|
79
|
+
return get_bridge().health()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@mcp.tool
|
|
83
|
+
def list_spawned_entities() -> list[str]:
|
|
84
|
+
"""List entity identifiers dynamically spawned in this simulation session."""
|
|
85
|
+
return get_bridge().list_spawned_entities()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --------------------------------------------------------------------------- In-process invocation helper
|
|
89
|
+
async def call_tool_inprocess(name: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
|
|
90
|
+
"""Execute an MCP tool directly in-memory within the active asyncio loop with 0ms network latency."""
|
|
91
|
+
res = await mcp.call_tool(name, dict(arguments))
|
|
92
|
+
# Extract structured content or text content
|
|
93
|
+
if res.structured_content is not None and "result" in res.structured_content:
|
|
94
|
+
val = res.structured_content["result"]
|
|
95
|
+
return val if isinstance(val, dict) else {"result": val}
|
|
96
|
+
if res.content:
|
|
97
|
+
for item in res.content:
|
|
98
|
+
if getattr(item, "text", None):
|
|
99
|
+
try:
|
|
100
|
+
return json.loads(item.text)
|
|
101
|
+
except Exception:
|
|
102
|
+
return {"text": item.text}
|
|
103
|
+
return {"ok": not res.is_error}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# --------------------------------------------------------------------------- CLI for developer inspection
|
|
107
|
+
def main() -> int:
|
|
108
|
+
parser = argparse.ArgumentParser(description="Lang2Sim FastMCP Perturbation Server")
|
|
109
|
+
parser.add_argument("--http", action="store_true", help="Run HTTP transport for MCP Inspector")
|
|
110
|
+
parser.add_argument("--port", type=int, default=8765, help="HTTP server port (default: 8765)")
|
|
111
|
+
parser.add_argument("--host", default="127.0.0.1", help="HTTP server bind host (default: 127.0.0.1)")
|
|
112
|
+
parser.add_argument("--backend", choices=["auto", "dryrun", "harmonic", "classic"], default=None,
|
|
113
|
+
help="Override Gazebo bridge backend")
|
|
114
|
+
args = parser.parse_args()
|
|
115
|
+
|
|
116
|
+
if args.backend:
|
|
117
|
+
get_bridge(args.backend)
|
|
118
|
+
|
|
119
|
+
if args.http:
|
|
120
|
+
print(f"Starting FastMCP HTTP server at http://{args.host}:{args.port}/mcp (Streamable HTTP)")
|
|
121
|
+
print(f"Connect via MCP Inspector: npx @modelcontextprotocol/inspector")
|
|
122
|
+
mcp.run(transport="http", host=args.host, port=args.port)
|
|
123
|
+
else:
|
|
124
|
+
mcp.run()
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
sys.exit(main())
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Routers subpackage for FastAPI Control API integration."""
|
|
2
|
+
|
|
3
|
+
from lang2sim.perturbation.routers.world import (
|
|
4
|
+
router as world_router,
|
|
5
|
+
mount_world_router,
|
|
6
|
+
SpawnRequest,
|
|
7
|
+
LightRequest,
|
|
8
|
+
ResetRequest,
|
|
9
|
+
ChassisPerturbationRequest,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"world_router",
|
|
14
|
+
"mount_world_router",
|
|
15
|
+
"SpawnRequest",
|
|
16
|
+
"LightRequest",
|
|
17
|
+
"ResetRequest",
|
|
18
|
+
"ChassisPerturbationRequest",
|
|
19
|
+
]
|