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,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
+ ]