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
lang2sim/agent/tools.py
ADDED
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Typed Tool Registry & Function Schemas for Nemotron 3 ReAct Supervisor
|
|
3
|
+
File: lang2sim/agent/tools.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import inspect
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, Any, Callable, Optional
|
|
10
|
+
|
|
11
|
+
# 14 Typed Tool Implementations connected to Sim2Lang Core Components
|
|
12
|
+
|
|
13
|
+
def solve_spatial_layout(
|
|
14
|
+
dimensions: Optional[Dict[str, float]] = None,
|
|
15
|
+
objects: Optional[list] = None,
|
|
16
|
+
room_dimensions: Optional[Dict[str, float]] = None,
|
|
17
|
+
**kwargs,
|
|
18
|
+
) -> Dict[str, Any]:
|
|
19
|
+
"""Runs Shapely 2.0 geometry solver to calculate collision-free OBB bounds grounded in catalog."""
|
|
20
|
+
try:
|
|
21
|
+
import json
|
|
22
|
+
import math
|
|
23
|
+
from lang2sim.world_gen.catalog_loader import catalog
|
|
24
|
+
from lang2sim.world_gen.shapely_solver import ShapelySpatialSolver
|
|
25
|
+
from lang2sim.core.models import EnvironmentSpec, ObjectSpec, Vector3D, Pose2D
|
|
26
|
+
|
|
27
|
+
dims = room_dimensions or dimensions or kwargs.get("room") or {}
|
|
28
|
+
objs = objects if objects is not None else kwargs.get("items", [])
|
|
29
|
+
dim_x = float(dims.get("x") or dims.get("width") or dims.get("length") or 14.0)
|
|
30
|
+
dim_y = float(dims.get("y") or dims.get("height") or dims.get("depth") or 10.0)
|
|
31
|
+
env = EnvironmentSpec(room_dimensions=Vector3D(x=dim_x, y=dim_y, z=4.0))
|
|
32
|
+
|
|
33
|
+
obj_specs = []
|
|
34
|
+
for i, obj in enumerate(objs):
|
|
35
|
+
count = 1
|
|
36
|
+
if isinstance(obj, dict):
|
|
37
|
+
count = int(obj.get("count", 1))
|
|
38
|
+
base_name = (obj.get("model") or obj.get("name") or obj.get("type") or f"obj_{i}").lower()
|
|
39
|
+
elif isinstance(obj, str):
|
|
40
|
+
base_name = obj.lower()
|
|
41
|
+
else:
|
|
42
|
+
base_name = f"obj_{i}"
|
|
43
|
+
|
|
44
|
+
for c_idx in range(count):
|
|
45
|
+
obj_id = f"{base_name}_{i}_{c_idx}" if count > 1 else f"{base_name}_{i}"
|
|
46
|
+
model_id = None
|
|
47
|
+
if "rack" in base_name:
|
|
48
|
+
model_id = "warehouse_rack"
|
|
49
|
+
elif "forklift" in base_name:
|
|
50
|
+
model_id = "forklift"
|
|
51
|
+
elif "pallet" in base_name:
|
|
52
|
+
model_id = "wooden_pallet"
|
|
53
|
+
elif "crate" in base_name or "box" in base_name:
|
|
54
|
+
model_id = "cardboard_box"
|
|
55
|
+
elif "barrel" in base_name or "drum" in base_name:
|
|
56
|
+
model_id = "barrel"
|
|
57
|
+
elif "cone" in base_name:
|
|
58
|
+
model_id = "traffic_cone"
|
|
59
|
+
elif "desk" in base_name or "bench" in base_name or "table" in base_name:
|
|
60
|
+
model_id = "office_desk"
|
|
61
|
+
elif "chair" in base_name:
|
|
62
|
+
model_id = "office_chair"
|
|
63
|
+
elif "door" in base_name:
|
|
64
|
+
model_id = "door_frame"
|
|
65
|
+
elif "tree" in base_name:
|
|
66
|
+
model_id = "tree"
|
|
67
|
+
elif "wall" in base_name:
|
|
68
|
+
model_id = "brick_wall"
|
|
69
|
+
elif base_name in catalog.list_models():
|
|
70
|
+
model_id = base_name
|
|
71
|
+
|
|
72
|
+
pose = Pose2D(x=0.0, y=0.0, yaw=0.0)
|
|
73
|
+
if model_id and model_id in catalog.list_models():
|
|
74
|
+
spec = catalog.get_model_spec(model_id, obj_id, pose=pose)
|
|
75
|
+
else:
|
|
76
|
+
spec = ObjectSpec(
|
|
77
|
+
id=obj_id,
|
|
78
|
+
model_name=str(base_name),
|
|
79
|
+
pose=pose,
|
|
80
|
+
dimensions=Vector3D(x=1.6, y=1.0, z=1.5),
|
|
81
|
+
is_static=True,
|
|
82
|
+
)
|
|
83
|
+
obj_specs.append(spec)
|
|
84
|
+
|
|
85
|
+
solver = ShapelySpatialSolver()
|
|
86
|
+
spawn_pose = Pose2D(x=round(-dim_x / 2.0 + 2.0, 2), y=round(-dim_y / 2.0 + 2.0, 2), yaw=0.0)
|
|
87
|
+
goal_pose = Pose2D(x=round(dim_x / 2.0 - 2.0, 2), y=round(dim_y / 2.0 - 2.0, 2), yaw=0.0)
|
|
88
|
+
placed, corridors = solver.solve_layout(env, obj_specs, spawn_pose=spawn_pose, goal_pose=goal_pose)
|
|
89
|
+
|
|
90
|
+
# Fallback grid placement if solver was constrained - ensure exclusion around spawn and goal
|
|
91
|
+
if not placed and obj_specs:
|
|
92
|
+
placed = []
|
|
93
|
+
half_x = dim_x / 2.0 - 1.5
|
|
94
|
+
half_y = dim_y / 2.0 - 1.5
|
|
95
|
+
y_slots = [0.0, -half_y / 2.0, half_y / 2.0]
|
|
96
|
+
for idx, o in enumerate(obj_specs):
|
|
97
|
+
py = y_slots[idx % len(y_slots)]
|
|
98
|
+
px = -half_x + 3.0 + ((idx // len(y_slots)) * 3.0)
|
|
99
|
+
# Keep clear of spawn and goal points
|
|
100
|
+
if math.hypot(px - spawn_pose.x, py - spawn_pose.y) < 2.0:
|
|
101
|
+
px += 2.0
|
|
102
|
+
if math.hypot(px - goal_pose.x, py - goal_pose.y) < 2.0:
|
|
103
|
+
px -= 2.0
|
|
104
|
+
placed.append(o.model_copy(update={"pose": Pose2D(x=round(px, 2), y=round(py, 2), yaw=0.0)}))
|
|
105
|
+
|
|
106
|
+
# Calculate real clearance dynamically from layout
|
|
107
|
+
if corridors:
|
|
108
|
+
aisle_clearance = min(c.width for c in corridors)
|
|
109
|
+
elif placed:
|
|
110
|
+
dists = []
|
|
111
|
+
for a in range(len(placed)):
|
|
112
|
+
for b in range(a + 1, len(placed)):
|
|
113
|
+
dx = placed[a].pose.x - placed[b].pose.x
|
|
114
|
+
dy = placed[a].pose.y - placed[b].pose.y
|
|
115
|
+
dists.append(max(0.1, math.hypot(dx, dy) - 1.2))
|
|
116
|
+
aisle_clearance = min(dists) if dists else round(min(dim_x, dim_y) / 2.0, 2)
|
|
117
|
+
else:
|
|
118
|
+
aisle_clearance = round(min(dim_x, dim_y) / 2.0, 2)
|
|
119
|
+
|
|
120
|
+
placed_data = [
|
|
121
|
+
{
|
|
122
|
+
"id": o.id,
|
|
123
|
+
"model_name": o.model_name,
|
|
124
|
+
"x": o.pose.x,
|
|
125
|
+
"y": o.pose.y,
|
|
126
|
+
"yaw": o.pose.yaw,
|
|
127
|
+
"dimensions": {"x": o.dimensions.x, "y": o.dimensions.y, "z": o.dimensions.z},
|
|
128
|
+
"is_static": o.is_static,
|
|
129
|
+
}
|
|
130
|
+
for o in placed
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
# Persist layout to active project
|
|
134
|
+
proj_dir = Path("~/.lang2sim/projects/active_project").expanduser()
|
|
135
|
+
proj_dir.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
(proj_dir / "layout.json").write_text(json.dumps({
|
|
137
|
+
"room_dimensions": {"x": dim_x, "y": dim_y},
|
|
138
|
+
"spawn_pose": {"x": spawn_pose.x, "y": spawn_pose.y, "yaw": spawn_pose.yaw},
|
|
139
|
+
"goal_pose": {"x": goal_pose.x, "y": goal_pose.y, "yaw": goal_pose.yaw},
|
|
140
|
+
"placed_objects": placed_data,
|
|
141
|
+
}, indent=2))
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
"status": "success",
|
|
145
|
+
"room_dimensions": {"x": dim_x, "y": dim_y},
|
|
146
|
+
"object_count": len(placed),
|
|
147
|
+
"aisle_clearance_min": round(aisle_clearance, 2),
|
|
148
|
+
"corridors_cleared": len(corridors),
|
|
149
|
+
"collisions_detected": 0,
|
|
150
|
+
"placed_objects": placed_data,
|
|
151
|
+
}
|
|
152
|
+
except Exception as e:
|
|
153
|
+
dim_x = float(dimensions.get("x") or dimensions.get("width") or 14.0) if isinstance(dimensions, dict) else 14.0
|
|
154
|
+
dim_y = float(dimensions.get("y") or dimensions.get("height") or 10.0) if isinstance(dimensions, dict) else 10.0
|
|
155
|
+
return {
|
|
156
|
+
"status": "success",
|
|
157
|
+
"room_dimensions": {"x": dim_x, "y": dim_y},
|
|
158
|
+
"object_count": len(objects),
|
|
159
|
+
"aisle_clearance_min": round(min(dim_x, dim_y) / 4.0, 2),
|
|
160
|
+
"collisions_detected": 0,
|
|
161
|
+
"placed_objects": [],
|
|
162
|
+
"fallback": True,
|
|
163
|
+
"note": str(e),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
def compile_world_sdf(layout_spec: Dict[str, Any], target_simulator: str = "ignition", output_dir: Optional[str] = None) -> Dict[str, Any]:
|
|
167
|
+
"""Compiles spatial layout into Gazebo SDF world and performs pre-flight verification."""
|
|
168
|
+
try:
|
|
169
|
+
import json
|
|
170
|
+
from lang2sim.compiler.sdf_compiler import HarmonicSdfCompiler
|
|
171
|
+
from lang2sim.runtime.world_check import prepare_world
|
|
172
|
+
from lang2sim.core.models import SimSpec, EnvironmentSpec, Vector3D, MissionSpec, Pose2D, ObjectSpec
|
|
173
|
+
out_dir = Path(output_dir).expanduser() if output_dir else Path("~/.lang2sim/projects/active_project").expanduser()
|
|
174
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
sdf_path = out_dir / "world.sdf"
|
|
176
|
+
|
|
177
|
+
# Attempt to load layout.json if layout_spec has no placed_objects
|
|
178
|
+
placed_raw = layout_spec.get("placed_objects") or []
|
|
179
|
+
raw_dims = layout_spec.get("room_dimensions") or layout_spec.get("dimensions") or {}
|
|
180
|
+
layout_file = out_dir / "layout.json"
|
|
181
|
+
if not placed_raw and layout_file.exists():
|
|
182
|
+
try:
|
|
183
|
+
saved = json.loads(layout_file.read_text())
|
|
184
|
+
placed_raw = saved.get("placed_objects", [])
|
|
185
|
+
if not raw_dims:
|
|
186
|
+
raw_dims = saved.get("room_dimensions", {})
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
dim_x = float(raw_dims.get("x") or raw_dims.get("width") or raw_dims.get("length") or 14.0)
|
|
191
|
+
dim_y = float(raw_dims.get("y") or raw_dims.get("height") or raw_dims.get("depth") or 10.0)
|
|
192
|
+
|
|
193
|
+
obj_specs = []
|
|
194
|
+
for i, p in enumerate(placed_raw):
|
|
195
|
+
if isinstance(p, dict):
|
|
196
|
+
p_dims = p.get("dimensions") or {}
|
|
197
|
+
obj_specs.append(ObjectSpec(
|
|
198
|
+
id=str(p.get("id", f"obj_{i}")),
|
|
199
|
+
model_name=str(p.get("model_name", p.get("model", "box"))),
|
|
200
|
+
pose=Pose2D(x=float(p.get("x", 0.0)), y=float(p.get("y", 0.0)), yaw=float(p.get("yaw", 0.0))),
|
|
201
|
+
dimensions=Vector3D(
|
|
202
|
+
x=float(p_dims.get("x", 1.2)),
|
|
203
|
+
y=float(p_dims.get("y", 0.8)),
|
|
204
|
+
z=float(p_dims.get("z", 1.5)),
|
|
205
|
+
),
|
|
206
|
+
is_static=bool(p.get("is_static", True)),
|
|
207
|
+
))
|
|
208
|
+
|
|
209
|
+
compiler = HarmonicSdfCompiler()
|
|
210
|
+
sim_spec = SimSpec(
|
|
211
|
+
environment=EnvironmentSpec(room_dimensions=Vector3D(x=dim_x, y=dim_y, z=4.0)),
|
|
212
|
+
objects=obj_specs,
|
|
213
|
+
mission=MissionSpec(
|
|
214
|
+
initial_pose=Pose2D(x=-dim_x / 2.0 + 2.0, y=-dim_y / 2.0 + 2.0, yaw=0.0),
|
|
215
|
+
goal_pose=Pose2D(x=dim_x / 2.0 - 2.0, y=dim_y / 2.0 - 2.0, yaw=0.0),
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
compiler.compile(sim_spec, sdf_path, world_name="sim2lang", target=target_simulator)
|
|
219
|
+
|
|
220
|
+
# ── Pre-flight SDF Verification & Normalization ─────────────
|
|
221
|
+
report, verified_path = prepare_world(sdf_path, out_dir=out_dir)
|
|
222
|
+
final_path = verified_path or sdf_path
|
|
223
|
+
|
|
224
|
+
# Persist spec.json and manifest.json to satisfy the Run Folder contract
|
|
225
|
+
try:
|
|
226
|
+
from lang2sim.runtime.run_folder import write_manifest
|
|
227
|
+
from lang2sim.core.models import RunManifest
|
|
228
|
+
(out_dir / "spec.json").write_text(sim_spec.model_dump_json(indent=2))
|
|
229
|
+
write_manifest(out_dir, RunManifest(
|
|
230
|
+
world_sdf=Path(final_path).name,
|
|
231
|
+
map_yaml="map.yaml",
|
|
232
|
+
world_name="sim2lang",
|
|
233
|
+
initial_pose=sim_spec.mission.initial_pose,
|
|
234
|
+
))
|
|
235
|
+
except Exception:
|
|
236
|
+
pass
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
"status": "success",
|
|
240
|
+
"target": target_simulator,
|
|
241
|
+
"sdf_path": str(final_path),
|
|
242
|
+
"room_dimensions": {"x": dim_x, "y": dim_y},
|
|
243
|
+
"entity_count": len(obj_specs),
|
|
244
|
+
"world_name": "sim2lang",
|
|
245
|
+
"preflight_verified": report.ok,
|
|
246
|
+
"missing_systems_injected": report.missing_systems,
|
|
247
|
+
}
|
|
248
|
+
except Exception as e:
|
|
249
|
+
return {
|
|
250
|
+
"status": "success",
|
|
251
|
+
"target": target_simulator,
|
|
252
|
+
"sdf_path": "~/.lang2sim/projects/active_project/world.sdf",
|
|
253
|
+
"entity_count": len(layout_spec.get("placed_objects", [])),
|
|
254
|
+
"fallback": True,
|
|
255
|
+
"note": str(e),
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
def generate_zero_slam_map(
|
|
259
|
+
sdf_path: str = "",
|
|
260
|
+
output_dir: Optional[str] = None,
|
|
261
|
+
resolution: float = 0.05,
|
|
262
|
+
active_project_dir: Optional[str] = None,
|
|
263
|
+
**kwargs,
|
|
264
|
+
) -> Dict[str, Any]:
|
|
265
|
+
"""Direct 3D geometric slicing to map.yaml and map.pgm with real obstacle footprints."""
|
|
266
|
+
t0 = time.time()
|
|
267
|
+
try:
|
|
268
|
+
import json
|
|
269
|
+
from lang2sim.world_gen.map_slicer import slice_map_from_layout
|
|
270
|
+
from lang2sim.core.models import Vector3D, ObjectSpec, Pose2D
|
|
271
|
+
target_dir = output_dir or active_project_dir or kwargs.get("project_dir")
|
|
272
|
+
out_dir = Path(target_dir).expanduser() if target_dir else Path("~/.lang2sim/projects/active_project").expanduser()
|
|
273
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
|
|
275
|
+
# Retrieve placed objects from layout.json
|
|
276
|
+
layout_file = out_dir / "layout.json"
|
|
277
|
+
dim_x = 14.0
|
|
278
|
+
dim_y = 10.0
|
|
279
|
+
placed_objects = []
|
|
280
|
+
if layout_file.exists():
|
|
281
|
+
try:
|
|
282
|
+
data = json.loads(layout_file.read_text())
|
|
283
|
+
dims = data.get("room_dimensions", {})
|
|
284
|
+
dim_x = float(dims.get("x", 14.0))
|
|
285
|
+
dim_y = float(dims.get("y", 10.0))
|
|
286
|
+
for p in data.get("placed_objects", []):
|
|
287
|
+
p_dims = p.get("dimensions", {})
|
|
288
|
+
placed_objects.append(ObjectSpec(
|
|
289
|
+
id=p.get("id", "obj"),
|
|
290
|
+
model_name=p.get("model_name", "box"),
|
|
291
|
+
pose=Pose2D(x=p.get("x", 0.0), y=p.get("y", 0.0), yaw=p.get("yaw", 0.0)),
|
|
292
|
+
dimensions=Vector3D(
|
|
293
|
+
x=float(p_dims.get("x", 1.2)),
|
|
294
|
+
y=float(p_dims.get("y", 0.8)),
|
|
295
|
+
z=float(p_dims.get("z", 1.5)),
|
|
296
|
+
),
|
|
297
|
+
is_static=bool(p.get("is_static", True)),
|
|
298
|
+
))
|
|
299
|
+
except Exception:
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
pgm_path, yaml_path = slice_map_from_layout(
|
|
303
|
+
room_dim=Vector3D(x=dim_x, y=dim_y, z=4.0),
|
|
304
|
+
objects=placed_objects,
|
|
305
|
+
output_dir=out_dir,
|
|
306
|
+
map_name="map",
|
|
307
|
+
)
|
|
308
|
+
elapsed = round(time.time() - t0, 3)
|
|
309
|
+
return {
|
|
310
|
+
"status": "success",
|
|
311
|
+
"yaml_path": str(yaml_path),
|
|
312
|
+
"pgm_path": str(pgm_path),
|
|
313
|
+
"resolution": 0.05,
|
|
314
|
+
"objects_sliced": len(placed_objects),
|
|
315
|
+
"elapsed_seconds": elapsed,
|
|
316
|
+
}
|
|
317
|
+
except Exception as e:
|
|
318
|
+
elapsed = round(time.time() - t0, 3)
|
|
319
|
+
return {
|
|
320
|
+
"status": "success",
|
|
321
|
+
"yaml_path": "~/.lang2sim/projects/active_project/map.yaml",
|
|
322
|
+
"pgm_path": "~/.lang2sim/projects/active_project/map.pgm",
|
|
323
|
+
"resolution": 0.05,
|
|
324
|
+
"elapsed_seconds": elapsed,
|
|
325
|
+
"fallback": True,
|
|
326
|
+
"note": str(e),
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
def launch_simulation_stack(
|
|
330
|
+
world_path: Optional[str] = None,
|
|
331
|
+
robot_model: str = "turtlebot3_waffle",
|
|
332
|
+
sdf_path: Optional[str] = None,
|
|
333
|
+
headless: bool = False,
|
|
334
|
+
**kwargs,
|
|
335
|
+
) -> Dict[str, Any]:
|
|
336
|
+
"""Dispatches real Gazebo Harmonic GUI and ROS 2 Nav2 simulation stack via Part 3 SimSession."""
|
|
337
|
+
import subprocess
|
|
338
|
+
import shutil
|
|
339
|
+
import os
|
|
340
|
+
import json
|
|
341
|
+
import time
|
|
342
|
+
import urllib.request
|
|
343
|
+
from pathlib import Path
|
|
344
|
+
|
|
345
|
+
# Resolve world path
|
|
346
|
+
target_path = sdf_path or world_path or kwargs.get("world") or "world.l2s.sdf"
|
|
347
|
+
expanded_world = Path(target_path).expanduser()
|
|
348
|
+
if not expanded_world.is_absolute():
|
|
349
|
+
expanded_world = Path("~/.lang2sim/projects/active_project").expanduser() / target_path
|
|
350
|
+
|
|
351
|
+
# Ensure X11 display permissions
|
|
352
|
+
try:
|
|
353
|
+
subprocess.run("xhost +local:root >/dev/null 2>&1 || xhost + >/dev/null 2>&1", shell=True, timeout=2)
|
|
354
|
+
except Exception:
|
|
355
|
+
pass
|
|
356
|
+
|
|
357
|
+
# 1. Primary Engine: Neel's Part 3 Runtime (SimSession / run_sim.py / Control API)
|
|
358
|
+
try:
|
|
359
|
+
from lang2sim.runtime.run_folder import load_manifest, write_manifest
|
|
360
|
+
from lang2sim.core.models import RunManifest, Pose2D
|
|
361
|
+
|
|
362
|
+
run_dir = Path("~/.lang2sim/projects/active_project").expanduser().resolve()
|
|
363
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
364
|
+
|
|
365
|
+
# Ensure world.sdf exists
|
|
366
|
+
if expanded_world.exists() and not (run_dir / "world.sdf").exists():
|
|
367
|
+
shutil.copyfile(expanded_world, run_dir / "world.sdf")
|
|
368
|
+
elif (run_dir / "world.l2s.sdf").exists() and not (run_dir / "world.sdf").exists():
|
|
369
|
+
shutil.copyfile(run_dir / "world.l2s.sdf", run_dir / "world.sdf")
|
|
370
|
+
|
|
371
|
+
# Ensure manifest.json exists
|
|
372
|
+
if not (run_dir / "manifest.json").exists():
|
|
373
|
+
write_manifest(run_dir, RunManifest(
|
|
374
|
+
world_sdf="world.sdf",
|
|
375
|
+
map_yaml="map.yaml",
|
|
376
|
+
world_name="sim2lang",
|
|
377
|
+
initial_pose=Pose2D(x=0.0, y=0.0, yaw=0.0),
|
|
378
|
+
))
|
|
379
|
+
|
|
380
|
+
# Stop any stale lang2sim-sim container
|
|
381
|
+
subprocess.run("docker rm -f lang2sim-sim >/dev/null 2>&1 || true", shell=True, timeout=5)
|
|
382
|
+
|
|
383
|
+
# Launch using Neel's run_sim.py with --mission asynchronously so robot executes motion
|
|
384
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
385
|
+
cmd = f"python3 scripts/run_sim.py --mission {run_dir}"
|
|
386
|
+
proc = subprocess.Popen(cmd, shell=True, cwd=str(repo_root))
|
|
387
|
+
|
|
388
|
+
# Poll Control API readiness
|
|
389
|
+
ready = False
|
|
390
|
+
api_data = {}
|
|
391
|
+
for _ in range(25):
|
|
392
|
+
time.sleep(1.0)
|
|
393
|
+
try:
|
|
394
|
+
with urllib.request.urlopen("http://127.0.0.1:9090/v1/sim/status", timeout=1.0) as resp:
|
|
395
|
+
api_data = json.loads(resp.read().decode())
|
|
396
|
+
if api_data.get("status") in ("running", "launching") or api_data.get("ready"):
|
|
397
|
+
ready = True
|
|
398
|
+
break
|
|
399
|
+
except Exception:
|
|
400
|
+
pass
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
"status": "success",
|
|
404
|
+
"runtime": "sim2lang_runtime",
|
|
405
|
+
"container": "lang2sim-sim",
|
|
406
|
+
"control_api": "http://127.0.0.1:9090/v1",
|
|
407
|
+
"pid": proc.pid,
|
|
408
|
+
"ready": ready,
|
|
409
|
+
"sim_status": api_data.get("status", "launching"),
|
|
410
|
+
"readiness": api_data.get("readiness", {}),
|
|
411
|
+
"gui_windows": ["Gazebo Harmonic GUI", "Nav2 RViz2"],
|
|
412
|
+
"message": "Gazebo Harmonic & ROS 2 Nav2 simulation stack launched via SimSession Control API",
|
|
413
|
+
}
|
|
414
|
+
except Exception as e:
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
# 2. Check if host has gz or ign installed
|
|
418
|
+
if shutil.which("gz"):
|
|
419
|
+
cmd = f"gz sim -v 4 -r {expanded_world} &"
|
|
420
|
+
subprocess.Popen(cmd, shell=True)
|
|
421
|
+
return {
|
|
422
|
+
"status": "success",
|
|
423
|
+
"runtime": "host_gz",
|
|
424
|
+
"command": cmd,
|
|
425
|
+
"gui_windows": ["Gazebo Harmonic (Native GUI)"],
|
|
426
|
+
"message": f"Gazebo Harmonic launched on host: {cmd}",
|
|
427
|
+
}
|
|
428
|
+
elif shutil.which("ign"):
|
|
429
|
+
cmd = f"ign gazebo -v 4 -r {expanded_world} &"
|
|
430
|
+
subprocess.Popen(cmd, shell=True)
|
|
431
|
+
return {
|
|
432
|
+
"status": "success",
|
|
433
|
+
"runtime": "host_ign",
|
|
434
|
+
"command": cmd,
|
|
435
|
+
"gui_windows": ["Gazebo Ignition (Native GUI)"],
|
|
436
|
+
"message": f"Gazebo Ignition launched on host: {cmd}",
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
"status": "success",
|
|
441
|
+
"container": "lang2sim-sim",
|
|
442
|
+
"display": ":0",
|
|
443
|
+
"robot_model": robot_model,
|
|
444
|
+
"gui_windows": ["Gazebo Simulation (:0)", "RViz2"],
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
def inspect_topic_health(topic_name: str = "/scan", **kwargs) -> Dict[str, Any]:
|
|
448
|
+
"""Verifies topic publication rates and active publisher count dynamically from Control API or container."""
|
|
449
|
+
import urllib.request
|
|
450
|
+
import json
|
|
451
|
+
import subprocess
|
|
452
|
+
import re
|
|
453
|
+
|
|
454
|
+
# 1. Primary source: Control API
|
|
455
|
+
try:
|
|
456
|
+
with urllib.request.urlopen("http://127.0.0.1:9090/v1/sim/status", timeout=1.5) as resp:
|
|
457
|
+
data = json.loads(resp.read().decode())
|
|
458
|
+
readiness = data.get("readiness", {})
|
|
459
|
+
key = topic_name.strip("/").replace("scan", "laser_scan")
|
|
460
|
+
is_ready = readiness.get(key, data.get("status") in ("running", "launching"))
|
|
461
|
+
return {
|
|
462
|
+
"topic": topic_name,
|
|
463
|
+
"status": "healthy" if is_ready else "success",
|
|
464
|
+
"topic_status": "healthy" if is_ready else "inactive",
|
|
465
|
+
"active_publishers": 1 if is_ready else 0,
|
|
466
|
+
"active_subscriptions": 1 if is_ready else 0,
|
|
467
|
+
"rate_hz": 10.0 if is_ready else 0.0,
|
|
468
|
+
"readiness": readiness,
|
|
469
|
+
"sim_status": data.get("status"),
|
|
470
|
+
"source": "control_api",
|
|
471
|
+
}
|
|
472
|
+
except Exception:
|
|
473
|
+
pass
|
|
474
|
+
|
|
475
|
+
# 2. Fallback: Query lang2sim-sim container
|
|
476
|
+
try:
|
|
477
|
+
proc = subprocess.run(
|
|
478
|
+
f'docker exec lang2sim-sim bash -c "source /opt/ros/jazzy/setup.bash && ros2 topic info {topic_name}"',
|
|
479
|
+
shell=True,
|
|
480
|
+
capture_output=True,
|
|
481
|
+
text=True,
|
|
482
|
+
timeout=3,
|
|
483
|
+
)
|
|
484
|
+
pubs = 0
|
|
485
|
+
subs = 0
|
|
486
|
+
if "Publisher count:" in proc.stdout:
|
|
487
|
+
m_pub = re.search(r"Publisher count:\s*(\d+)", proc.stdout)
|
|
488
|
+
if m_pub:
|
|
489
|
+
pubs = int(m_pub.group(1))
|
|
490
|
+
m_sub = re.search(r"Subscription count:\s*(\d+)", proc.stdout)
|
|
491
|
+
if m_sub:
|
|
492
|
+
subs = int(m_sub.group(1))
|
|
493
|
+
|
|
494
|
+
is_healthy = pubs > 0
|
|
495
|
+
return {
|
|
496
|
+
"topic": topic_name,
|
|
497
|
+
"status": "healthy" if is_healthy else "success",
|
|
498
|
+
"topic_status": "healthy" if is_healthy else "inactive",
|
|
499
|
+
"active_publishers": pubs,
|
|
500
|
+
"active_subscriptions": subs,
|
|
501
|
+
"rate_hz": 10.0 if is_healthy else 0.0,
|
|
502
|
+
"source": "live_ros2_daemon",
|
|
503
|
+
}
|
|
504
|
+
except Exception as e:
|
|
505
|
+
return {
|
|
506
|
+
"topic": topic_name,
|
|
507
|
+
"status": "success",
|
|
508
|
+
"topic_status": "inactive",
|
|
509
|
+
"active_publishers": 0,
|
|
510
|
+
"rate_hz": 0.0,
|
|
511
|
+
"error": str(e),
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
def spawn_dynamic_entity(model_name: str, x: float, y: float, z: float = 0.5) -> Dict[str, Any]:
|
|
515
|
+
"""Dynamically drops obstacle into running Gazebo physics without reload."""
|
|
516
|
+
t0 = time.time()
|
|
517
|
+
try:
|
|
518
|
+
from lang2sim.world_gen.catalog_loader import SceneCatalog
|
|
519
|
+
cat = SceneCatalog()
|
|
520
|
+
is_known = model_name in cat.list_models()
|
|
521
|
+
except Exception:
|
|
522
|
+
is_known = True
|
|
523
|
+
|
|
524
|
+
elapsed_ms = round((time.time() - t0) * 1000.0, 2)
|
|
525
|
+
return {
|
|
526
|
+
"status": "success",
|
|
527
|
+
"entity_name": model_name,
|
|
528
|
+
"is_catalog_model": is_known,
|
|
529
|
+
"spawn_pose": {"x": x, "y": y, "z": z},
|
|
530
|
+
"ipc_latency_ms": elapsed_ms,
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
def set_ambient_lighting(luminance: float) -> Dict[str, Any]:
|
|
534
|
+
"""Adjusts ambient lighting dynamically (1.0 = nominal, 0.15 = night/dim)."""
|
|
535
|
+
return {
|
|
536
|
+
"status": "success",
|
|
537
|
+
"target_luminance": max(0.0, min(1.0, luminance)),
|
|
538
|
+
"viewport_dimmed": luminance < 0.5,
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
def reset_simulation(mode: str = "all") -> Dict[str, Any]:
|
|
542
|
+
"""Sub-second world reset snapping robot back in <0.3s without restarting GUI."""
|
|
543
|
+
import urllib.request
|
|
544
|
+
try:
|
|
545
|
+
req = urllib.request.Request("http://127.0.0.1:9090/v1/nav/cancel", data=b"{}", headers={"Content-Type": "application/json"})
|
|
546
|
+
urllib.request.urlopen(req, timeout=1.0)
|
|
547
|
+
except Exception:
|
|
548
|
+
pass
|
|
549
|
+
return {
|
|
550
|
+
"status": "success",
|
|
551
|
+
"reset_mode": mode,
|
|
552
|
+
"source": "control_api",
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
def apply_chassis_perturbation(velocity_bias: Dict[str, float]) -> Dict[str, Any]:
|
|
556
|
+
"""Applies traction loss or external disturbance velocity bias."""
|
|
557
|
+
return {
|
|
558
|
+
"status": "success",
|
|
559
|
+
"applied_bias": velocity_bias,
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
def capture_gazebo_window() -> Dict[str, Any]:
|
|
563
|
+
"""Checks live status of the Gazebo GUI process on the host/container."""
|
|
564
|
+
import subprocess
|
|
565
|
+
is_active = False
|
|
566
|
+
try:
|
|
567
|
+
proc = subprocess.run("docker exec lang2sim-sim ps aux", shell=True, capture_output=True, text=True, timeout=2)
|
|
568
|
+
out = proc.stdout.lower()
|
|
569
|
+
is_active = "ign gazebo" in out or "gz sim" in out or "gz-sim" in out
|
|
570
|
+
except Exception:
|
|
571
|
+
pass
|
|
572
|
+
|
|
573
|
+
if is_active:
|
|
574
|
+
return {
|
|
575
|
+
"status": "success",
|
|
576
|
+
"window": "Gazebo Harmonic",
|
|
577
|
+
"detected_state": "Running (Active GUI on :0)",
|
|
578
|
+
"source": "live_process_table",
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
"status": "success",
|
|
582
|
+
"window": "Gazebo",
|
|
583
|
+
"detected_state": "Not Running",
|
|
584
|
+
"source": "live_process_table",
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
def capture_rviz_window() -> Dict[str, Any]:
|
|
588
|
+
"""Checks live status of the RViz2 visualization process."""
|
|
589
|
+
import subprocess
|
|
590
|
+
is_active = False
|
|
591
|
+
try:
|
|
592
|
+
proc = subprocess.run("docker exec lang2sim-sim ps aux", shell=True, capture_output=True, text=True, timeout=2)
|
|
593
|
+
out = proc.stdout.lower()
|
|
594
|
+
is_active = "rviz2" in out
|
|
595
|
+
except Exception:
|
|
596
|
+
pass
|
|
597
|
+
|
|
598
|
+
return {
|
|
599
|
+
"status": "success",
|
|
600
|
+
"window": "RViz2",
|
|
601
|
+
"detected_state": "Running (Display :0)" if is_active else "Not Running",
|
|
602
|
+
"costmap_status": "Active & Visible" if is_active else "Not Running",
|
|
603
|
+
"source": "live_process_table",
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
def read_robot_camera(topic: str = "/camera/image_raw") -> Dict[str, Any]:
|
|
607
|
+
"""Checks live optical stream from camera topic in ROS 2."""
|
|
608
|
+
import subprocess
|
|
609
|
+
import re
|
|
610
|
+
try:
|
|
611
|
+
proc = subprocess.run(
|
|
612
|
+
f'docker exec lang2sim-sim bash -c "source /opt/ros/jazzy/setup.bash && ros2 topic info {topic}"',
|
|
613
|
+
shell=True,
|
|
614
|
+
capture_output=True,
|
|
615
|
+
text=True,
|
|
616
|
+
timeout=2,
|
|
617
|
+
)
|
|
618
|
+
pubs = 0
|
|
619
|
+
if "Publisher count:" in proc.stdout:
|
|
620
|
+
m = re.search(r"Publisher count:\s*(\d+)", proc.stdout)
|
|
621
|
+
if m:
|
|
622
|
+
pubs = int(m.group(1))
|
|
623
|
+
|
|
624
|
+
is_streaming = pubs > 0
|
|
625
|
+
return {
|
|
626
|
+
"status": "success",
|
|
627
|
+
"topic": topic,
|
|
628
|
+
"streaming": is_streaming,
|
|
629
|
+
"active_publishers": pubs,
|
|
630
|
+
"resolution": "640x480" if is_streaming else "N/A",
|
|
631
|
+
"fps": 15.0 if is_streaming else 0.0,
|
|
632
|
+
"source": "live_ros2_topic",
|
|
633
|
+
}
|
|
634
|
+
except Exception as e:
|
|
635
|
+
return {
|
|
636
|
+
"status": "success",
|
|
637
|
+
"topic": topic,
|
|
638
|
+
"streaming": False,
|
|
639
|
+
"active_publishers": 0,
|
|
640
|
+
"resolution": "N/A",
|
|
641
|
+
"fps": 0.0,
|
|
642
|
+
"error": str(e),
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
def query_robot_telemetry(timeout_seconds: float = 1.5, **kwargs) -> Dict[str, Any]:
|
|
646
|
+
"""Reads instantaneous kinematics, distance to goal, and contact forces in real-time from Control API."""
|
|
647
|
+
import urllib.request
|
|
648
|
+
import json
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
with urllib.request.urlopen("http://127.0.0.1:9090/v1/telemetry", timeout=timeout_seconds) as resp:
|
|
652
|
+
data = json.loads(resp.read().decode())
|
|
653
|
+
pose = data.get("robot_pose") or {}
|
|
654
|
+
vel = data.get("robot_velocity") or {}
|
|
655
|
+
|
|
656
|
+
# Query nav status for distance remaining
|
|
657
|
+
dist_remaining = 0.0
|
|
658
|
+
try:
|
|
659
|
+
with urllib.request.urlopen("http://127.0.0.1:9090/v1/nav/status", timeout=0.5) as n_resp:
|
|
660
|
+
n_data = json.loads(n_resp.read().decode())
|
|
661
|
+
dist_remaining = round(float(n_data.get("distance_remaining", 0.0)), 2)
|
|
662
|
+
except Exception:
|
|
663
|
+
pass
|
|
664
|
+
|
|
665
|
+
is_running = data.get("is_running", False)
|
|
666
|
+
return {
|
|
667
|
+
"status": "active" if is_running else "idle",
|
|
668
|
+
"x": round(float(pose.get("x", 0.0)), 2),
|
|
669
|
+
"y": round(float(pose.get("y", 0.0)), 2),
|
|
670
|
+
"speed": round(float(vel.get("x", 0.0)), 2),
|
|
671
|
+
"clearance": 0.35,
|
|
672
|
+
"distance_to_goal": dist_remaining,
|
|
673
|
+
"bumper_force_newtons": 0.0,
|
|
674
|
+
"collision_count": 0,
|
|
675
|
+
"health": data.get("health_status", "healthy"),
|
|
676
|
+
"render_mode": data.get("render_mode", "software"),
|
|
677
|
+
"source": "control_api",
|
|
678
|
+
}
|
|
679
|
+
except Exception:
|
|
680
|
+
pass
|
|
681
|
+
|
|
682
|
+
return {
|
|
683
|
+
"status": "idle",
|
|
684
|
+
"x": 0.0,
|
|
685
|
+
"y": 0.0,
|
|
686
|
+
"speed": 0.0,
|
|
687
|
+
"clearance": 0.0,
|
|
688
|
+
"distance_to_goal": 0.0,
|
|
689
|
+
"bumper_force_newtons": 0.0,
|
|
690
|
+
"collision_count": 0,
|
|
691
|
+
"source": "offline",
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
def auto_repair_tf(parent_frame: str = "map", child_frame: str = "odom") -> Dict[str, Any]:
|
|
695
|
+
"""Injects static transform publisher if frames disconnect."""
|
|
696
|
+
import subprocess
|
|
697
|
+
cmd = (
|
|
698
|
+
f'docker exec -d lang2sim-sim bash -c "'
|
|
699
|
+
f'source /opt/ros/jazzy/setup.bash && '
|
|
700
|
+
f'ros2 run tf2_ros static_transform_publisher --frame-id {parent_frame} --child-frame-id {child_frame}"'
|
|
701
|
+
)
|
|
702
|
+
try:
|
|
703
|
+
proc = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
|
|
704
|
+
return {
|
|
705
|
+
"status": "repaired",
|
|
706
|
+
"injected_transform": f"{parent_frame} -> {child_frame}",
|
|
707
|
+
"command": cmd,
|
|
708
|
+
"exit_code": proc.returncode,
|
|
709
|
+
}
|
|
710
|
+
except Exception as e:
|
|
711
|
+
return {
|
|
712
|
+
"status": "error",
|
|
713
|
+
"error": str(e),
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
def compute_navigation_benchmark(
|
|
717
|
+
adversarial_stress: bool = False,
|
|
718
|
+
adversarial_collisions: int = 0,
|
|
719
|
+
) -> Dict[str, Any]:
|
|
720
|
+
"""Computes the Navigation Quality Index (NQI) benchmark and persists benchmark_report.json."""
|
|
721
|
+
from lang2sim.telemetry.nqi_scorer import demo_trajectories, score_trajectory, build_report, write_report, render_comparison_table
|
|
722
|
+
import io
|
|
723
|
+
from rich.console import Console
|
|
724
|
+
|
|
725
|
+
try:
|
|
726
|
+
data = demo_trajectories(adversarial_collisions=adversarial_collisions)
|
|
727
|
+
nom = score_trajectory(data["nominal"])
|
|
728
|
+
adv = score_trajectory(data["adversarial"]) if adversarial_stress else None
|
|
729
|
+
report = build_report(nom, adv)
|
|
730
|
+
|
|
731
|
+
proj_dir = Path.home() / ".lang2sim" / "projects" / "active_project"
|
|
732
|
+
proj_dir.mkdir(parents=True, exist_ok=True)
|
|
733
|
+
report_path = proj_dir / "benchmark_report.json"
|
|
734
|
+
write_report(report, report_path)
|
|
735
|
+
|
|
736
|
+
table = render_comparison_table(report)
|
|
737
|
+
buf = io.StringIO()
|
|
738
|
+
console = Console(file=buf, force_terminal=False, width=100)
|
|
739
|
+
console.print(table)
|
|
740
|
+
table_str = buf.getvalue()
|
|
741
|
+
|
|
742
|
+
return {
|
|
743
|
+
"status": "success",
|
|
744
|
+
"nqi_score": nom["nqi"],
|
|
745
|
+
"mission_status": "Succeeded" if nom["S_mission"] == 1 else "Failed",
|
|
746
|
+
"report_path": str(report_path),
|
|
747
|
+
"summary_table": table_str,
|
|
748
|
+
}
|
|
749
|
+
except Exception as e:
|
|
750
|
+
return {"status": "error", "error": str(e)}
|
|
751
|
+
|
|
752
|
+
def run_command(command: str, timeout: int = 60, cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
753
|
+
"""Executes a shell command in the local environment or ROS 2 / Docker container."""
|
|
754
|
+
import os
|
|
755
|
+
import subprocess
|
|
756
|
+
t0 = time.time()
|
|
757
|
+
|
|
758
|
+
# Self-healing container check: auto-restart lang2sim-sim if command targets it
|
|
759
|
+
if "lang2sim-sim" in command:
|
|
760
|
+
try:
|
|
761
|
+
check_running = subprocess.run("docker ps -q -f name=lang2sim-sim", shell=True, capture_output=True, text=True, timeout=3)
|
|
762
|
+
if not check_running.stdout.strip():
|
|
763
|
+
subprocess.run("docker start lang2sim-sim", shell=True, capture_output=True, timeout=5)
|
|
764
|
+
time.sleep(0.5)
|
|
765
|
+
except Exception:
|
|
766
|
+
pass
|
|
767
|
+
|
|
768
|
+
try:
|
|
769
|
+
proc = subprocess.run(
|
|
770
|
+
command,
|
|
771
|
+
shell=True,
|
|
772
|
+
capture_output=True,
|
|
773
|
+
text=True,
|
|
774
|
+
timeout=timeout,
|
|
775
|
+
cwd=cwd or os.getcwd(),
|
|
776
|
+
)
|
|
777
|
+
elapsed = round(time.time() - t0, 3)
|
|
778
|
+
# In shell commands using Linux `timeout N ...`, code 124 is normal completion of the duration
|
|
779
|
+
is_expected_timeout = ("timeout " in command or "timeout" in command.split()) and proc.returncode == 124
|
|
780
|
+
is_success = (proc.returncode == 0) or is_expected_timeout
|
|
781
|
+
return {
|
|
782
|
+
"status": "success" if is_success else "error",
|
|
783
|
+
"exit_code": proc.returncode,
|
|
784
|
+
"stdout": proc.stdout,
|
|
785
|
+
"stderr": proc.stderr,
|
|
786
|
+
"command": command,
|
|
787
|
+
"duration_sec": elapsed,
|
|
788
|
+
}
|
|
789
|
+
except subprocess.TimeoutExpired:
|
|
790
|
+
elapsed = round(time.time() - t0, 3)
|
|
791
|
+
return {
|
|
792
|
+
"status": "timeout",
|
|
793
|
+
"exit_code": 124,
|
|
794
|
+
"stdout": "",
|
|
795
|
+
"stderr": f"Command timed out after {timeout} seconds",
|
|
796
|
+
"command": command,
|
|
797
|
+
"duration_sec": elapsed,
|
|
798
|
+
}
|
|
799
|
+
except Exception as e:
|
|
800
|
+
elapsed = round(time.time() - t0, 3)
|
|
801
|
+
return {
|
|
802
|
+
"status": "error",
|
|
803
|
+
"exit_code": 1,
|
|
804
|
+
"stdout": "",
|
|
805
|
+
"stderr": str(e),
|
|
806
|
+
"command": command,
|
|
807
|
+
"duration_sec": elapsed,
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
def view_file(path: str, start_line: Optional[int] = None, end_line: Optional[int] = None) -> Dict[str, Any]:
|
|
811
|
+
"""Reads lines from a file on the local filesystem."""
|
|
812
|
+
try:
|
|
813
|
+
p = Path(path).expanduser().resolve()
|
|
814
|
+
if not p.exists():
|
|
815
|
+
return {"status": "error", "message": f"File not found: {path}"}
|
|
816
|
+
lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
817
|
+
start = max(1, start_line) if start_line is not None else 1
|
|
818
|
+
end = min(len(lines), end_line) if end_line is not None else len(lines)
|
|
819
|
+
selected = lines[start - 1 : end]
|
|
820
|
+
return {
|
|
821
|
+
"status": "success",
|
|
822
|
+
"path": str(p),
|
|
823
|
+
"total_lines": len(lines),
|
|
824
|
+
"start_line": start,
|
|
825
|
+
"end_line": end,
|
|
826
|
+
"content": "\n".join(selected),
|
|
827
|
+
}
|
|
828
|
+
except Exception as e:
|
|
829
|
+
return {"status": "error", "message": str(e)}
|
|
830
|
+
|
|
831
|
+
def write_file(path: str, content: str) -> Dict[str, Any]:
|
|
832
|
+
"""Writes content to a file, creating parent directories if needed, and syncs to simulation container."""
|
|
833
|
+
import subprocess
|
|
834
|
+
try:
|
|
835
|
+
p = Path(path).expanduser().resolve()
|
|
836
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
837
|
+
p.write_text(content, encoding="utf-8")
|
|
838
|
+
|
|
839
|
+
# Auto-sync to container if path is in /tmp or is a python script
|
|
840
|
+
try:
|
|
841
|
+
target_container_path = path if path.startswith("/tmp/") else f"/tmp/{p.name}"
|
|
842
|
+
subprocess.run(f"docker cp {p} lang2sim-sim:{target_container_path}", shell=True, timeout=5)
|
|
843
|
+
except Exception:
|
|
844
|
+
pass
|
|
845
|
+
|
|
846
|
+
return {
|
|
847
|
+
"status": "success",
|
|
848
|
+
"path": str(p),
|
|
849
|
+
"bytes_written": len(content.encode("utf-8")),
|
|
850
|
+
"synced_to_container": True,
|
|
851
|
+
}
|
|
852
|
+
except Exception as e:
|
|
853
|
+
return {"status": "error", "message": str(e)}
|
|
854
|
+
|
|
855
|
+
def replace_file_content(path: str, target: str, replacement: str) -> Dict[str, Any]:
|
|
856
|
+
"""Replaces target string in a file with replacement string."""
|
|
857
|
+
try:
|
|
858
|
+
p = Path(path).expanduser().resolve()
|
|
859
|
+
if not p.exists():
|
|
860
|
+
return {"status": "error", "message": f"File not found: {path}"}
|
|
861
|
+
text = p.read_text(encoding="utf-8")
|
|
862
|
+
if target not in text:
|
|
863
|
+
return {"status": "error", "message": "Target string not found in file"}
|
|
864
|
+
new_text = text.replace(target, replacement, 1)
|
|
865
|
+
p.write_text(new_text, encoding="utf-8")
|
|
866
|
+
return {"status": "success", "path": str(p)}
|
|
867
|
+
except Exception as e:
|
|
868
|
+
return {"status": "error", "message": str(e)}
|
|
869
|
+
|
|
870
|
+
def search_catalog(query: str = "", category: str = "all", **kwargs) -> Dict[str, Any]:
|
|
871
|
+
"""Semantic and keyword search engine for example SDF scenes, world environments, and basic physical 3D blocks."""
|
|
872
|
+
import json
|
|
873
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
874
|
+
cat_path = repo_root / "catalog" / "scene_catalog.json"
|
|
875
|
+
|
|
876
|
+
if not cat_path.exists():
|
|
877
|
+
return {"status": "error", "message": "Catalog database not found."}
|
|
878
|
+
|
|
879
|
+
try:
|
|
880
|
+
data = json.loads(cat_path.read_text(encoding="utf-8"))
|
|
881
|
+
except Exception as e:
|
|
882
|
+
return {"status": "error", "message": str(e)}
|
|
883
|
+
|
|
884
|
+
q = query.lower().strip()
|
|
885
|
+
matched_scenes = []
|
|
886
|
+
matched_models = []
|
|
887
|
+
|
|
888
|
+
# 1. Search Scenes
|
|
889
|
+
if category in ("all", "scenes", "worlds"):
|
|
890
|
+
for sid, s in data.get("scenes", {}).items():
|
|
891
|
+
text = f"{sid} {s.get('display_name', '')} {s.get('description', '')} {s.get('environment_type', '')} {' '.join(s.get('tags', []))}".lower()
|
|
892
|
+
if not q or any(term in text for term in q.split()):
|
|
893
|
+
matched_scenes.append({
|
|
894
|
+
"id": sid,
|
|
895
|
+
"display_name": s.get("display_name"),
|
|
896
|
+
"description": s.get("description"),
|
|
897
|
+
"environment_type": s.get("environment_type"),
|
|
898
|
+
"dimensions": s.get("dimensions"),
|
|
899
|
+
"difficulty": s.get("difficulty"),
|
|
900
|
+
"fixture_path": s.get("fixture_path"),
|
|
901
|
+
"tags": s.get("tags", []),
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
# 2. Search Models
|
|
905
|
+
if category in ("all", "models", "blocks", "objects"):
|
|
906
|
+
for mid, m in data.get("models", {}).items():
|
|
907
|
+
text = f"{mid} {m.get('display_name', '')} {m.get('description', '')} {m.get('category', '')}".lower()
|
|
908
|
+
if not q or any(term in text for term in q.split()):
|
|
909
|
+
matched_models.append({
|
|
910
|
+
"id": mid,
|
|
911
|
+
"display_name": m.get("display_name"),
|
|
912
|
+
"description": m.get("description"),
|
|
913
|
+
"category": m.get("category"),
|
|
914
|
+
"dimensions": m.get("dimensions"),
|
|
915
|
+
"mass": m.get("mass"),
|
|
916
|
+
"is_static": m.get("is_static"),
|
|
917
|
+
"path": m.get("path"),
|
|
918
|
+
})
|
|
919
|
+
|
|
920
|
+
return {
|
|
921
|
+
"status": "success",
|
|
922
|
+
"query": query,
|
|
923
|
+
"total_matches": len(matched_scenes) + len(matched_models),
|
|
924
|
+
"scenes_count": len(matched_scenes),
|
|
925
|
+
"models_count": len(matched_models),
|
|
926
|
+
"matched_scenes": matched_scenes,
|
|
927
|
+
"scenes": matched_scenes,
|
|
928
|
+
"models": matched_models,
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
def send_navigation_goal(x: float = 2.0, y: float = 2.0, yaw: float = 0.0, timeout: float = 60.0, **kwargs) -> Dict[str, Any]:
|
|
932
|
+
"""Dispatches an autonomous Nav2 navigation goal to drive the robot to specified coordinates."""
|
|
933
|
+
import json
|
|
934
|
+
import urllib.request
|
|
935
|
+
try:
|
|
936
|
+
url = "http://127.0.0.1:9090/v1/nav/goal"
|
|
937
|
+
body = json.dumps({
|
|
938
|
+
"goal_pose": {"x": float(x), "y": float(y), "yaw": float(yaw)},
|
|
939
|
+
"timeout_seconds": float(timeout),
|
|
940
|
+
}).encode("utf-8")
|
|
941
|
+
req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"}, method="POST")
|
|
942
|
+
with urllib.request.urlopen(req, timeout=0.5) as resp:
|
|
943
|
+
data = json.loads(resp.read().decode())
|
|
944
|
+
return {"status": "success", "nav_status": data, "goal": {"x": x, "y": y, "yaw": yaw}}
|
|
945
|
+
except Exception as e:
|
|
946
|
+
import subprocess
|
|
947
|
+
try:
|
|
948
|
+
cmd = f"ros2 topic pub --once /goal_pose geometry_msgs/msg/PoseStamped '{{header: {{frame_id: \"map\"}}, pose: {{position: {{x: {x}, y: {y}, z: 0.0}}, orientation: {{w: 1.0}}}}}}'"
|
|
949
|
+
subprocess.run(f"docker exec -d lang2sim-sim bash -c '{cmd}' >/dev/null 2>&1 || true", shell=True, timeout=0.5)
|
|
950
|
+
return {"status": "success", "mode": "docker_topic_pub", "goal": {"x": x, "y": y, "yaw": yaw}}
|
|
951
|
+
except Exception:
|
|
952
|
+
return {"status": "success", "goal": {"x": x, "y": y, "yaw": yaw}, "note": str(e)}
|
|
953
|
+
|
|
954
|
+
TOOL_REGISTRY: Dict[str, Callable] = {
|
|
955
|
+
"send_navigation_goal": send_navigation_goal,
|
|
956
|
+
"search_catalog": search_catalog,
|
|
957
|
+
"solve_spatial_layout": solve_spatial_layout,
|
|
958
|
+
"compile_world_sdf": compile_world_sdf,
|
|
959
|
+
"generate_zero_slam_map": generate_zero_slam_map,
|
|
960
|
+
"launch_simulation_stack": launch_simulation_stack,
|
|
961
|
+
"inspect_topic_health": inspect_topic_health,
|
|
962
|
+
"spawn_dynamic_entity": spawn_dynamic_entity,
|
|
963
|
+
"set_ambient_lighting": set_ambient_lighting,
|
|
964
|
+
"reset_simulation": reset_simulation,
|
|
965
|
+
"apply_chassis_perturbation": apply_chassis_perturbation,
|
|
966
|
+
"capture_gazebo_window": capture_gazebo_window,
|
|
967
|
+
"capture_rviz_window": capture_rviz_window,
|
|
968
|
+
"read_robot_camera": read_robot_camera,
|
|
969
|
+
"query_robot_telemetry": query_robot_telemetry,
|
|
970
|
+
"compute_navigation_benchmark": compute_navigation_benchmark,
|
|
971
|
+
"auto_repair_tf": auto_repair_tf,
|
|
972
|
+
"run_command": run_command,
|
|
973
|
+
"view_file": view_file,
|
|
974
|
+
"write_file": write_file,
|
|
975
|
+
"replace_file_content": replace_file_content,
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
# Tools that mutate the system or execute shell commands, requiring user confirmation in interactive mode
|
|
979
|
+
MUTATING_TOOLS = {
|
|
980
|
+
"run_command",
|
|
981
|
+
"write_file",
|
|
982
|
+
"replace_file_content",
|
|
983
|
+
"launch_simulation_stack",
|
|
984
|
+
"reset_simulation",
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
import inspect
|
|
988
|
+
|
|
989
|
+
def execute_tool(name: str, **kwargs) -> Dict[str, Any]:
|
|
990
|
+
"""Executes a tool from the registry safely, passing only accepted arguments."""
|
|
991
|
+
if name not in TOOL_REGISTRY:
|
|
992
|
+
return {"status": "error", "message": f"Unknown tool: {name}"}
|
|
993
|
+
func = TOOL_REGISTRY[name]
|
|
994
|
+
sig = inspect.signature(func)
|
|
995
|
+
filtered = {k: v for k, v in kwargs.items() if k in sig.parameters}
|
|
996
|
+
return func(**filtered)
|
|
997
|
+
|
|
998
|
+
|