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,286 @@
1
+ """
2
+ Starts/stops the sim container. The user never runs Docker themselves.
3
+ File: lang2sim/runtime/docker_runner.py
4
+
5
+ `build_run_args()` is pure (RunOptions -> argv) so every display/GPU/mount
6
+ combination is unit-tested without Docker. `DockerRunner` shells out to the
7
+ docker CLI (no SDK dependency) and takes an injectable `run` for tests.
8
+
9
+ Ports are published on 127.0.0.1 only (noVNC has no password):
10
+ 9090 control API, 8765 foxglove_bridge, 6080 noVNC.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import importlib
16
+ import json
17
+ import os
18
+ import subprocess
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path, PurePosixPath
21
+ from typing import Callable, Dict, List, Optional, Sequence
22
+
23
+ from lang2sim.runtime.display import DisplayPlan
24
+
25
+ # NB: `from lang2sim.core import config` yields the Config *instance* (core/__init__
26
+ # re-exports it under the module's name), so load the module explicitly.
27
+ cfg = importlib.import_module("lang2sim.core.config")
28
+
29
+ CONTAINER_NAME = "lang2sim-sim"
30
+ LABEL = "dev.lang2sim.role=sim"
31
+ SIM_USER = "lang2sim" # runtime user in docker/sim/Dockerfile
32
+
33
+ # In-container ports are fixed by the image; host ports are configurable.
34
+ CONTAINER_API_PORT = 9090
35
+ CONTAINER_FOXGLOVE_PORT = 8765
36
+ CONTAINER_NOVNC_PORT = 6080
37
+
38
+ RunFn = Callable[..., "subprocess.CompletedProcess[str]"]
39
+
40
+
41
+ class DockerError(RuntimeError):
42
+ def __init__(self, message: str, stderr: str = ""):
43
+ super().__init__(message if not stderr else f"{message}: {stderr.strip()}")
44
+ self.stderr = stderr
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class RunOptions:
49
+ image: str
50
+ plan: DisplayPlan
51
+ projects_dir: Path
52
+ catalog_dir: Optional[Path] = None
53
+ src_dir: Optional[Path] = None # dev: bind-mount the repo over the baked-in package
54
+ host_uid: Optional[int] = None
55
+ host_gid: Optional[int] = None
56
+ api_port: int = CONTAINER_API_PORT
57
+ foxglove_port: int = CONTAINER_FOXGLOVE_PORT
58
+ novnc_port: int = CONTAINER_NOVNC_PORT
59
+ host_env: Dict[str, str] = field(default_factory=dict) # DISPLAY, WAYLAND_DISPLAY, XAUTHORITY
60
+ has_dxg: bool = False
61
+ extra_env: Dict[str, str] = field(default_factory=dict)
62
+ name: str = CONTAINER_NAME
63
+ shm_size: str = "2g"
64
+ command: Sequence[str] = ("serve",)
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Pure helpers
69
+ # ---------------------------------------------------------------------------
70
+
71
+ def image_for(base_image: str, plan: DisplayPlan) -> str:
72
+ """Pick the `-vnc` tag variant when the plan needs it.
73
+
74
+ ghcr.io/x/lang2sim-sim:staging + vnc -> ghcr.io/x/lang2sim-sim:staging-vnc
75
+ """
76
+ if plan.image_variant != "sim-vnc":
77
+ return base_image
78
+ name, sep, tag = base_image.rpartition(":")
79
+ if not sep or "/" in tag: # no tag (the ':' was a registry port)
80
+ return f"{base_image}:latest-vnc"
81
+ return base_image if tag.endswith("-vnc") else f"{name}:{tag}-vnc"
82
+
83
+
84
+ def host_to_container_path(host_path: Path, projects_dir: Path) -> str:
85
+ """Map a host path under projects_dir to its path inside the container."""
86
+ rel = Path(host_path).resolve().relative_to(Path(projects_dir).resolve())
87
+ return str(PurePosixPath(cfg.CONTAINER_PROJECTS_DIR, *rel.parts))
88
+
89
+
90
+ def build_run_args(opts: RunOptions) -> List[str]:
91
+ """argv after `docker` for starting the sim container (detached)."""
92
+ plan = opts.plan
93
+ a: List[str] = ["run", "-d", "--name", opts.name, "--label", LABEL,
94
+ "--shm-size", opts.shm_size]
95
+
96
+ def env(k: str, v: object) -> None:
97
+ a.extend(["-e", f"{k}={v}"])
98
+
99
+ def mount(src: object, dst: str, ro: bool = False) -> None:
100
+ a.extend(["-v", f"{src}:{dst}{':ro' if ro else ''}"])
101
+
102
+ # Ports (localhost only)
103
+ a.extend(["-p", f"127.0.0.1:{opts.api_port}:{CONTAINER_API_PORT}"])
104
+ if plan.foxglove:
105
+ a.extend(["-p", f"127.0.0.1:{opts.foxglove_port}:{CONTAINER_FOXGLOVE_PORT}"])
106
+ if plan.mode == "vnc":
107
+ a.extend(["-p", f"127.0.0.1:{opts.novnc_port}:{CONTAINER_NOVNC_PORT}"])
108
+
109
+ # Identity + plan (read by docker/sim/entrypoint.sh)
110
+ if opts.host_uid is not None:
111
+ env("L2S_HOST_UID", opts.host_uid)
112
+ if opts.host_gid is not None:
113
+ env("L2S_HOST_GID", opts.host_gid)
114
+ env("L2S_DISPLAY_MODE", plan.mode)
115
+ env("L2S_GPU", plan.gpu)
116
+ env("L2S_FOXGLOVE", 1 if plan.foxglove else 0)
117
+
118
+ # Data
119
+ mount(opts.projects_dir, cfg.CONTAINER_PROJECTS_DIR)
120
+ if opts.catalog_dir is not None:
121
+ mount(opts.catalog_dir, cfg.CONTAINER_CATALOG_DIR, ro=True)
122
+ if opts.src_dir is not None:
123
+ mount(opts.src_dir, cfg.CONTAINER_SRC_DIR, ro=True)
124
+
125
+ # Display
126
+ if plan.mode == "x11":
127
+ mount("/tmp/.X11-unix", "/tmp/.X11-unix")
128
+ env("DISPLAY", opts.host_env.get("DISPLAY", ":0"))
129
+ xauth = opts.host_env.get("XAUTHORITY")
130
+ if xauth:
131
+ mount(xauth, "/tmp/.Xauthority", ro=True)
132
+ env("XAUTHORITY", "/tmp/.Xauthority")
133
+ elif plan.mode == "wslg":
134
+ mount("/tmp/.X11-unix", "/tmp/.X11-unix")
135
+ mount("/mnt/wslg", "/mnt/wslg")
136
+ env("DISPLAY", opts.host_env.get("DISPLAY", ":0"))
137
+ env("WAYLAND_DISPLAY", opts.host_env.get("WAYLAND_DISPLAY", "wayland-0"))
138
+ env("XDG_RUNTIME_DIR", "/mnt/wslg/runtime-dir")
139
+ env("PULSE_SERVER", "/mnt/wslg/PulseServer")
140
+
141
+ # GPU
142
+ if plan.gpu == "nvidia":
143
+ a.extend(["--gpus", "all"])
144
+ elif plan.gpu == "dri":
145
+ if plan.mode == "wslg" and opts.has_dxg:
146
+ a.extend(["--device", "/dev/dxg"])
147
+ mount("/usr/lib/wsl", "/usr/lib/wsl", ro=True)
148
+ else:
149
+ a.extend(["--device", "/dev/dri"])
150
+
151
+ for k, v in sorted(opts.extra_env.items()):
152
+ env(k, v)
153
+
154
+ a.append(opts.image)
155
+ a.extend(opts.command)
156
+ return a
157
+
158
+
159
+ def default_run_options(plan: DisplayPlan, *, image: Optional[str] = None,
160
+ project_src: Optional[Path] = None,
161
+ host_env: Optional[Dict[str, str]] = None,
162
+ has_dxg: bool = False) -> RunOptions:
163
+ """RunOptions from core.config for this user (paths, ports, uid)."""
164
+ projects = cfg.get_projects_dir()
165
+ catalog = cfg.get_catalog_dir()
166
+ uid = os.getuid() if hasattr(os, "getuid") else None
167
+ gid = os.getgid() if hasattr(os, "getgid") else None
168
+ return RunOptions(
169
+ image=image_for(image or cfg.DEFAULT_SIM_IMAGE, plan),
170
+ plan=plan,
171
+ projects_dir=projects,
172
+ catalog_dir=catalog if catalog.is_dir() else None,
173
+ src_dir=project_src,
174
+ host_uid=uid,
175
+ host_gid=gid,
176
+ api_port=cfg.DEFAULT_CONTROL_PORT,
177
+ foxglove_port=cfg.DEFAULT_FOXGLOVE_PORT,
178
+ novnc_port=cfg.DEFAULT_NOVNC_PORT,
179
+ host_env=dict(host_env or {}),
180
+ has_dxg=has_dxg,
181
+ )
182
+
183
+
184
+ def allow_local_x11_clients() -> bool:
185
+ """`xhost +SI:localuser:<me>`: let containers running as our UID draw on X.
186
+
187
+ Needed on Ubuntu desktops (X/XWayland reject unknown clients by default).
188
+ No-op where xhost or DISPLAY is missing. Returns True if it ran.
189
+ """
190
+ import getpass
191
+ import shutil
192
+
193
+ if not os.environ.get("DISPLAY") or not shutil.which("xhost"):
194
+ return False
195
+ try:
196
+ subprocess.run(["xhost", f"+SI:localuser:{getpass.getuser()}"],
197
+ capture_output=True, timeout=5, check=False)
198
+ except (OSError, subprocess.SubprocessError):
199
+ return False
200
+ return True
201
+
202
+
203
+ # ---------------------------------------------------------------------------
204
+ # Docker CLI wrapper
205
+ # ---------------------------------------------------------------------------
206
+
207
+ class DockerRunner:
208
+ def __init__(self, docker: str = "docker", run: RunFn = subprocess.run):
209
+ self.docker = docker
210
+ self._run = run
211
+
212
+ def _call(self, args: Sequence[str], check: bool = True, timeout: Optional[float] = 60,
213
+ ) -> "subprocess.CompletedProcess[str]":
214
+ try:
215
+ cp = self._run([self.docker, *args], capture_output=True, text=True,
216
+ timeout=timeout, check=False)
217
+ except FileNotFoundError as e:
218
+ raise DockerError("docker CLI not found") from e
219
+ except subprocess.TimeoutExpired as e:
220
+ raise DockerError(f"`docker {args[0]}` timed out") from e
221
+ if check and cp.returncode != 0:
222
+ raise DockerError(f"`docker {' '.join(args[:2])}` failed", cp.stderr)
223
+ return cp
224
+
225
+ # images
226
+ def image_present(self, image: str) -> bool:
227
+ return self._call(["image", "inspect", image], check=False).returncode == 0
228
+
229
+ def pull(self, image: str) -> None:
230
+ self._call(["pull", image], timeout=None)
231
+
232
+ def ensure_image(self, image: str) -> bool:
233
+ """Pull if missing. Returns True if a pull happened."""
234
+ if self.image_present(image):
235
+ return False
236
+ self.pull(image)
237
+ return True
238
+
239
+ # containers
240
+ def inspect(self, name: str = CONTAINER_NAME) -> Optional[dict]:
241
+ cp = self._call(["container", "inspect", name], check=False)
242
+ if cp.returncode != 0:
243
+ return None
244
+ data = json.loads(cp.stdout or "[]")
245
+ return data[0] if data else None
246
+
247
+ def state(self, name: str = CONTAINER_NAME) -> Optional[dict]:
248
+ """Docker's .State (Status, Running, ExitCode, Health, ...) or None if absent."""
249
+ info = self.inspect(name)
250
+ return info.get("State") if info else None
251
+
252
+ def is_running(self, name: str = CONTAINER_NAME) -> bool:
253
+ st = self.state(name)
254
+ return bool(st and st.get("Running"))
255
+
256
+ def start(self, opts: RunOptions, replace: bool = True) -> str:
257
+ """Start the container; returns its id. Replaces a stale one by default."""
258
+ if self.inspect(opts.name) is not None:
259
+ if not replace:
260
+ raise DockerError(f"container {opts.name} already exists")
261
+ self.remove(opts.name)
262
+ if opts.plan.mode == "x11":
263
+ allow_local_x11_clients()
264
+ cp = self._call(build_run_args(opts), timeout=120)
265
+ return cp.stdout.strip()
266
+
267
+ def stop(self, name: str = CONTAINER_NAME, timeout: int = 10) -> None:
268
+ self._call(["stop", "-t", str(timeout), name], check=False, timeout=timeout + 30)
269
+
270
+ def remove(self, name: str = CONTAINER_NAME) -> None:
271
+ self._call(["rm", "-f", name], check=False)
272
+
273
+ def logs(self, name: str = CONTAINER_NAME, tail: int = 200) -> str:
274
+ cp = self._call(["logs", "--tail", str(tail), name], check=False)
275
+ return (cp.stdout or "") + (cp.stderr or "")
276
+
277
+ def exec(self, cmd: Sequence[str], name: str = CONTAINER_NAME,
278
+ timeout: Optional[float] = 60, user: str = SIM_USER,
279
+ ) -> "subprocess.CompletedProcess[str]":
280
+ """Run a command in the container as the sim user.
281
+
282
+ Must match the user the sim runs as: ROS 2's default (Fast DDS) shared
283
+ memory transport is per-user, so a root `ros2 topic echo` discovers
284
+ topics but never receives data.
285
+ """
286
+ return self._call(["exec", "-u", user, name, *cmd], check=False, timeout=timeout)
@@ -0,0 +1,220 @@
1
+ """
2
+ `lang2sim doctor`: can this machine run the simulation, and how will it look?
3
+ File: lang2sim/runtime/doctor.py
4
+
5
+ Part 2 wires the CLI subcommand to `run_checks()` / `format_report()`.
6
+ Standalone: python -m lang2sim.runtime.doctor [--json] [--display vnc] [--api]
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import asyncio
13
+ import importlib
14
+ import json
15
+ import shutil
16
+ import socket
17
+ import sys
18
+ from dataclasses import asdict, dataclass
19
+ from typing import List, Literal, Optional
20
+
21
+ from lang2sim.runtime import hardware_probe
22
+ from lang2sim.runtime import platform as host_platform
23
+ from lang2sim.runtime.display import DISPLAY_MODES, DisplayPlan, choose_display
24
+ from lang2sim.runtime.docker_runner import CONTAINER_NAME, DockerRunner, image_for
25
+
26
+ # NB: `from lang2sim.core import config` yields the Config *instance* (core/__init__
27
+ # re-exports it under the module's name), so load the module explicitly.
28
+ cfg = importlib.import_module("lang2sim.core.config")
29
+
30
+ Status = Literal["ok", "warn", "fail"]
31
+
32
+ MIN_DISK_GB = 10
33
+ MIN_RAM_MB = 8 * 1024
34
+ MIN_DOCKER_MEM_MB = 6 * 1024
35
+ MIN_DOCKER_CPUS = 4
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class CheckResult:
40
+ name: str
41
+ status: Status
42
+ detail: str
43
+ fix: Optional[str] = None
44
+
45
+
46
+ def _port_free(port: int) -> bool:
47
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
48
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
49
+ try:
50
+ s.bind(("127.0.0.1", port))
51
+ except OSError:
52
+ return False
53
+ return True
54
+
55
+
56
+ def run_checks(plat: Optional[host_platform.HostPlatform] = None,
57
+ hw: Optional[hardware_probe.HardwareInfo] = None,
58
+ prefer_display: Optional[str] = None,
59
+ image: Optional[str] = None,
60
+ runner: Optional[DockerRunner] = None,
61
+ check_api: bool = False) -> List[CheckResult]:
62
+ plat = plat or host_platform.detect()
63
+ hw = hw or hardware_probe.probe()
64
+ runner = runner or DockerRunner()
65
+ r: List[CheckResult] = []
66
+ d = plat.docker
67
+
68
+ # Docker
69
+ if not d.installed:
70
+ fix = {"macos": "Install Docker Desktop: https://docs.docker.com/desktop/setup/install/mac-install/",
71
+ "windows": "Install Docker Desktop with the WSL 2 backend",
72
+ "wsl": "Install Docker Desktop on Windows and enable WSL integration for this distro",
73
+ }.get(plat.os, "Install Docker Engine: https://docs.docker.com/engine/install/ubuntu/")
74
+ r.append(CheckResult("docker", "fail", "docker CLI not found", fix))
75
+ elif not d.server_reachable:
76
+ r.append(CheckResult("docker", "fail", d.error or "daemon not reachable",
77
+ "Start Docker Desktop" if plat.os in ("macos", "windows", "wsl")
78
+ else "sudo systemctl start docker && sudo usermod -aG docker $USER"))
79
+ else:
80
+ r.append(CheckResult("docker", "ok", f"{d.operating_system or 'docker'} {d.server_version} ({d.arch})"))
81
+ if plat.arch != "unknown" and d.arch != "unknown" and plat.arch != d.arch:
82
+ r.append(CheckResult("docker arch", "warn",
83
+ f"host is {plat.arch} but daemon is {d.arch}; image runs emulated (slow)"))
84
+
85
+ # Docker Desktop's VM often gets less than the machine has; Gazebo + Nav2 need room.
86
+ if d.mem_total_mb and d.mem_total_mb < MIN_DOCKER_MEM_MB:
87
+ r.append(CheckResult("docker memory", "warn",
88
+ f"Docker can use {d.mem_total_mb / 1024:.1f} GB; Gazebo + Nav2 want 6 GB+",
89
+ "Docker Desktop > Settings > Resources > Memory: 8 GB"
90
+ if d.is_desktop else "free memory or add swap"))
91
+ if d.cpus and d.cpus < MIN_DOCKER_CPUS:
92
+ r.append(CheckResult("docker cpus", "warn",
93
+ f"Docker can use {d.cpus} CPUs; the sim will run below real time",
94
+ "Docker Desktop > Settings > Resources > CPUs: 4+" if d.is_desktop else None))
95
+ if plat.os == "windows":
96
+ r.append(CheckResult("windows", "warn",
97
+ "running outside WSL2: no native windows, the sim opens in a browser (noVNC)",
98
+ "for Gazebo/RViz windows, run lang2sim from an Ubuntu terminal inside WSL2"))
99
+
100
+ # Display plan
101
+ plan: Optional[DisplayPlan] = None
102
+ try:
103
+ plan = choose_display(plat, hw, prefer=prefer_display) # type: ignore[arg-type]
104
+ r.append(CheckResult("display", "ok", plan.describe() + " — " + "; ".join(plan.reasons)))
105
+ except ValueError as e:
106
+ r.append(CheckResult("display", "fail", str(e), f"use one of {', '.join(DISPLAY_MODES)}"))
107
+
108
+ if plan and plan.mode == "x11" and plat.display:
109
+ r.append(CheckResult("x11", "ok", f"DISPLAY={plat.display}",
110
+ None))
111
+ if not shutil.which("xhost"):
112
+ r.append(CheckResult("xhost", "warn", "xhost not found; container may be refused by X",
113
+ "sudo apt install x11-xserver-utils"))
114
+ if plan and plan.mode == "wslg" and not hw.has_dxg:
115
+ r.append(CheckResult("wsl gpu", "warn", "/dev/dxg missing: software rendering",
116
+ "Update the Windows GPU driver and `wsl --update`"))
117
+ nv = hw.nvidia
118
+ if nv and d.server_reachable and not d.has_nvidia_runtime:
119
+ r.append(CheckResult("nvidia toolkit", "warn", "NVIDIA GPU but Docker has no nvidia runtime",
120
+ "Install nvidia-container-toolkit, then `sudo nvidia-ctk runtime configure "
121
+ "--runtime=docker && sudo systemctl restart docker`"))
122
+
123
+ # Image
124
+ if plan and d.server_reachable:
125
+ img = image_for(image or cfg.DEFAULT_SIM_IMAGE, plan)
126
+ try:
127
+ present = runner.image_present(img)
128
+ except Exception as e: # noqa: BLE001 - doctor must not crash
129
+ present, err = False, str(e)
130
+ else:
131
+ err = ""
132
+ if present:
133
+ r.append(CheckResult("image", "ok", img))
134
+ else:
135
+ r.append(CheckResult("image", "warn", f"{img} not pulled yet {err}".strip(),
136
+ f"docker pull {img} (~1.5 GB; first `lang2sim` run does this)"))
137
+
138
+ # Ports
139
+ ours = False
140
+ try:
141
+ ours = d.server_reachable and runner.is_running(CONTAINER_NAME)
142
+ except Exception: # noqa: BLE001
143
+ pass
144
+ ports = {"control API": cfg.DEFAULT_CONTROL_PORT}
145
+ if plan and plan.foxglove:
146
+ ports["foxglove"] = cfg.DEFAULT_FOXGLOVE_PORT
147
+ if plan and plan.mode == "vnc":
148
+ ports["noVNC"] = cfg.DEFAULT_NOVNC_PORT
149
+ for label, port in ports.items():
150
+ if _port_free(port):
151
+ r.append(CheckResult(f"port {port}", "ok", f"{label} port free"))
152
+ elif ours:
153
+ r.append(CheckResult(f"port {port}", "ok", f"{label} served by running {CONTAINER_NAME}"))
154
+ else:
155
+ r.append(CheckResult(f"port {port}", "fail", f"{label} port {port} in use",
156
+ f"free it or set L2S_{'API' if label == 'control API' else label.upper()}_PORT"))
157
+
158
+ # Resources
159
+ try:
160
+ free_gb = shutil.disk_usage(cfg.get_projects_dir()).free / 1e9
161
+ r.append(CheckResult("disk", "ok" if free_gb >= MIN_DISK_GB else "warn",
162
+ f"{free_gb:.0f} GB free in {cfg.get_projects_dir()}"))
163
+ except OSError as e:
164
+ r.append(CheckResult("disk", "warn", str(e)))
165
+ if hw.mem_total_mb:
166
+ r.append(CheckResult("memory", "ok" if hw.mem_total_mb >= MIN_RAM_MB else "warn",
167
+ f"{hw.mem_total_mb // 1024} GB RAM"
168
+ + ("" if hw.mem_total_mb >= MIN_RAM_MB else " (Gazebo + Nav2 want 8 GB+)")))
169
+
170
+ # Live API (optional)
171
+ if check_api:
172
+ r.append(asyncio.run(_check_api()))
173
+ return r
174
+
175
+
176
+ async def _check_api() -> CheckResult:
177
+ from lang2sim.runtime.control_api.client import ControlAPIError, ControlClient
178
+
179
+ try:
180
+ async with ControlClient(timeout=3) as api:
181
+ h = await api.health()
182
+ i = await api.info()
183
+ return CheckResult("control API", "ok",
184
+ f"v{h.get('version')} display={i.get('display_mode')} gpu={i.get('gpu')}")
185
+ except ControlAPIError as e:
186
+ return CheckResult("control API", "warn", str(e), "Start the sim (it boots the container)")
187
+
188
+
189
+ def format_report(results: List[CheckResult]) -> str:
190
+ icon = {"ok": "✓", "warn": "!", "fail": "✗"}
191
+ lines = []
192
+ for c in results:
193
+ lines.append(f" {icon[c.status]} {c.name:<16} {c.detail}")
194
+ if c.fix and c.status != "ok":
195
+ lines.append(f" {'':<16} fix: {c.fix}")
196
+ worst = "fail" if any(c.status == "fail" for c in results) else \
197
+ "warn" if any(c.status == "warn" for c in results) else "ok"
198
+ lines.append({"ok": "\nReady to simulate.", "warn": "\nUsable, with warnings.",
199
+ "fail": "\nNot ready: fix the ✗ items above."}[worst])
200
+ return "\n".join(lines)
201
+
202
+
203
+ def exit_code(results: List[CheckResult]) -> int:
204
+ return 1 if any(c.status == "fail" for c in results) else 0
205
+
206
+
207
+ def main(argv: Optional[List[str]] = None) -> int:
208
+ ap = argparse.ArgumentParser(prog="lang2sim doctor")
209
+ ap.add_argument("--json", action="store_true")
210
+ ap.add_argument("--display", choices=DISPLAY_MODES)
211
+ ap.add_argument("--image")
212
+ ap.add_argument("--api", action="store_true", help="also query a running control API")
213
+ a = ap.parse_args(argv)
214
+ res = run_checks(prefer_display=a.display, image=a.image, check_api=a.api)
215
+ print(json.dumps([asdict(c) for c in res], indent=2) if a.json else format_report(res))
216
+ return exit_code(res)
217
+
218
+
219
+ if __name__ == "__main__":
220
+ sys.exit(main())
@@ -0,0 +1,144 @@
1
+ """
2
+ Host hardware introspection: GPUs, VRAM, RAM, render nodes.
3
+ File: lang2sim/runtime/hardware_probe.py
4
+
5
+ Parsing is split from probing so the parsers are unit-testable without the
6
+ hardware. `probe()` never raises; missing tools just mean less information.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import glob
12
+ import os
13
+ import platform as _platform
14
+ import shutil
15
+ import subprocess
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import List, Literal, Optional
19
+
20
+ GpuVendor = Literal["nvidia", "intel", "amd", "apple", "unknown"]
21
+
22
+ # Decision 4: below this much VRAM we render in software (GUI stays on).
23
+ LOW_VRAM_MB = 2048
24
+
25
+ _PCI_VENDORS = {"0x10de": "nvidia", "0x8086": "intel", "0x1002": "amd"}
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class GpuInfo:
30
+ vendor: GpuVendor
31
+ name: str = ""
32
+ vram_total_mb: Optional[int] = None
33
+ vram_free_mb: Optional[int] = None
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class HardwareInfo:
38
+ cpu_count: int = 1
39
+ mem_total_mb: Optional[int] = None
40
+ gpus: List[GpuInfo] = field(default_factory=list)
41
+ dri_render_nodes: List[str] = field(default_factory=list)
42
+ has_dxg: bool = False # WSL2 GPU paravirtualisation (/dev/dxg)
43
+
44
+ @property
45
+ def nvidia(self) -> Optional[GpuInfo]:
46
+ return next((g for g in self.gpus if g.vendor == "nvidia"), None)
47
+
48
+ @property
49
+ def max_vram_mb(self) -> Optional[int]:
50
+ vals = [g.vram_total_mb for g in self.gpus if g.vram_total_mb is not None]
51
+ return max(vals) if vals else None
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Parsers (pure)
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def parse_nvidia_smi(csv_text: str) -> List[GpuInfo]:
59
+ """Parse `nvidia-smi --query-gpu=name,memory.total,memory.free --format=csv,noheader,nounits`."""
60
+ gpus: List[GpuInfo] = []
61
+ for line in csv_text.strip().splitlines():
62
+ parts = [p.strip() for p in line.split(",")]
63
+ if len(parts) < 3:
64
+ continue
65
+ name, total, free = parts[0], parts[1], parts[2]
66
+ gpus.append(GpuInfo("nvidia", name, _to_int(total), _to_int(free)))
67
+ return gpus
68
+
69
+
70
+ def parse_meminfo(text: str) -> Optional[int]:
71
+ """MemTotal from /proc/meminfo, in MB."""
72
+ for line in text.splitlines():
73
+ if line.startswith("MemTotal:"):
74
+ kb = _to_int(line.split()[1])
75
+ return kb // 1024 if kb is not None else None
76
+ return None
77
+
78
+
79
+ def vendor_from_pci_id(vendor_id: str) -> GpuVendor:
80
+ return _PCI_VENDORS.get(vendor_id.strip().lower(), "unknown") # type: ignore[return-value]
81
+
82
+
83
+ def _to_int(s: str) -> Optional[int]:
84
+ try:
85
+ return int(float(s))
86
+ except (TypeError, ValueError):
87
+ return None
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Probes
92
+ # ---------------------------------------------------------------------------
93
+
94
+ def _run(cmd: List[str], timeout: float = 5.0) -> Optional[str]:
95
+ if not shutil.which(cmd[0]):
96
+ return None
97
+ try:
98
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False)
99
+ except (OSError, subprocess.SubprocessError):
100
+ return None
101
+ return out.stdout if out.returncode == 0 else None
102
+
103
+
104
+ def _probe_nvidia() -> List[GpuInfo]:
105
+ out = _run(["nvidia-smi", "--query-gpu=name,memory.total,memory.free",
106
+ "--format=csv,noheader,nounits"])
107
+ return parse_nvidia_smi(out) if out else []
108
+
109
+
110
+ def _probe_dri() -> List[GpuInfo]:
111
+ gpus: List[GpuInfo] = []
112
+ for node in sorted(glob.glob("/sys/class/drm/renderD*/device/vendor")):
113
+ try:
114
+ vendor = vendor_from_pci_id(Path(node).read_text())
115
+ except OSError:
116
+ continue
117
+ if vendor != "nvidia": # NVIDIA is covered (with VRAM) by nvidia-smi
118
+ gpus.append(GpuInfo(vendor))
119
+ return gpus
120
+
121
+
122
+ def _probe_mem_mb() -> Optional[int]:
123
+ try:
124
+ return parse_meminfo(Path("/proc/meminfo").read_text())
125
+ except OSError:
126
+ pass
127
+ out = _run(["sysctl", "-n", "hw.memsize"]) # macOS
128
+ if out and out.strip().isdigit():
129
+ return int(out.strip()) // (1024 * 1024)
130
+ return None
131
+
132
+
133
+ def probe() -> HardwareInfo:
134
+ """Best-effort snapshot of this host. Never raises."""
135
+ gpus = _probe_nvidia() + _probe_dri()
136
+ if _platform.system() == "Darwin" and _platform.machine() == "arm64":
137
+ gpus.append(GpuInfo("apple", "Apple Silicon (not passed through to Docker)"))
138
+ return HardwareInfo(
139
+ cpu_count=os.cpu_count() or 1,
140
+ mem_total_mb=_probe_mem_mb(),
141
+ gpus=gpus,
142
+ dri_render_nodes=sorted(glob.glob("/dev/dri/renderD*")),
143
+ has_dxg=os.path.exists("/dev/dxg"),
144
+ )