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,645 @@
1
+ """Composite Navigation Quality Index (NQI) evaluator.
2
+
3
+ Implements the scoring model of the Lang2Sim architecture (Section 7 / Issue #4 §3.5):
4
+
5
+ 1. Path traversal efficiency ``E_p = L_optimal / L_actual`` (clipped to [0, 1])
6
+ 2. Trajectory smoothness ``S_tau = ∫ (‖ȧ(t)‖² + ω̇(t)²) dt`` (jerk + angular accel.)
7
+ 3. Minimum obstacle clearance ``C_min = min_t min_o ‖p(t) − p_o‖ − r_o − r_robot``
8
+ 4. Mission success ``S_mission = 1[‖p(T) − p_goal‖ ≤ ε] · 1[C_force == 0]``
9
+ 5. Composite score ``NQI = 100·[w1·E_p + w2/(1+α·S_tau) + w3·min(1, C_min/C_safe)] − P·C_force``
10
+
11
+ Outputs a ``benchmark_report.json`` and a Rich comparison table (nominal vs adversarial).
12
+
13
+ Run ``python -m lang2sim.telemetry.nqi_scorer --demo`` for a synthetic demonstration.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import heapq
20
+ import json
21
+ import math
22
+ import os
23
+ import sys
24
+ from dataclasses import asdict, dataclass
25
+ from datetime import datetime, timezone
26
+ from pathlib import Path
27
+ from typing import Any, Iterable, Mapping, Optional, Sequence
28
+
29
+ import numpy as np
30
+
31
+ from lang2sim.telemetry.trajectory import Trajectory
32
+
33
+ __all__ = [
34
+ "NQIParams",
35
+ "OccupancyGrid",
36
+ "path_length",
37
+ "optimal_length",
38
+ "path_efficiency",
39
+ "trajectory_smoothness",
40
+ "min_clearance",
41
+ "mission_success",
42
+ "compute_nqi",
43
+ "score_trajectory",
44
+ "build_report",
45
+ "write_report",
46
+ "default_report_path",
47
+ "render_comparison_table",
48
+ "render_to_console",
49
+ "demo_trajectories",
50
+ ]
51
+
52
+ REPORT_SCHEMA_VERSION = "1.0"
53
+
54
+ # np.trapz was renamed to np.trapezoid in NumPy 2.0.
55
+ _trapezoid = getattr(np, "trapezoid", None) or getattr(np, "trapz")
56
+
57
+
58
+ # --------------------------------------------------------------------------- params
59
+ @dataclass(frozen=True)
60
+ class NQIParams:
61
+ """Tunable constants of the NQI model (defaults from Issue #4)."""
62
+
63
+ w1: float = 0.35 # path efficiency weight
64
+ w2: float = 0.25 # smoothness weight
65
+ w3: float = 0.40 # clearance weight
66
+ alpha: float = 0.05 # smoothness scaling
67
+ c_safe: float = 0.5 # m, clearance at which the clearance term saturates
68
+ p_collision: float = 50.0 # penalty per force-qualified collision
69
+ r_robot: float = 0.22 # m, TurtleBot3 Waffle circumscribed radius (approx.)
70
+ eps_pos: float = 0.15 # m, goal position tolerance
71
+ resample_dt: float = 0.05 # s, uniform grid used for differentiation
72
+ smooth_window: int = 5 # samples, moving-average window before differentiating
73
+
74
+
75
+ # --------------------------------------------------------------------------- occupancy grid
76
+ @dataclass
77
+ class OccupancyGrid:
78
+ """Minimal Nav2-style occupancy grid (``map.yaml`` + ``map.pgm``)."""
79
+
80
+ occupied: np.ndarray # bool (H, W); row 0 is the TOP of the image
81
+ resolution: float # m / cell
82
+ origin: tuple[float, float] # world coords of the bottom-left cell corner
83
+
84
+ @classmethod
85
+ def from_yaml(cls, yaml_path: str | Path) -> "OccupancyGrid":
86
+ """Load a Nav2 map. Uses a tiny ``key: value`` parser to avoid a PyYAML dependency."""
87
+ from PIL import Image # local import: Pillow only needed for maps
88
+
89
+ yaml_path = Path(yaml_path)
90
+ meta: dict[str, str] = {}
91
+ for raw in yaml_path.read_text(encoding="utf-8").splitlines():
92
+ line = raw.split("#", 1)[0].strip()
93
+ if ":" in line:
94
+ key, val = line.split(":", 1)
95
+ meta[key.strip()] = val.strip().strip("'\"")
96
+ image_path = Path(meta["image"])
97
+ if not image_path.is_absolute():
98
+ image_path = yaml_path.parent / image_path
99
+ resolution = float(meta["resolution"])
100
+ origin_vals = [float(v) for v in meta.get("origin", "[0,0,0]").strip("[]").split(",")]
101
+ negate = int(float(meta.get("negate", "0")))
102
+ occ_thresh = float(meta.get("occupied_thresh", "0.65"))
103
+
104
+ img = np.asarray(Image.open(image_path).convert("L"), dtype=float)
105
+ p = img / 255.0 if negate else (255.0 - img) / 255.0
106
+ return cls(occupied=p > occ_thresh, resolution=resolution, origin=(origin_vals[0], origin_vals[1]))
107
+
108
+ # world <-> grid (row, col)
109
+ def world_to_cell(self, x: float, y: float) -> tuple[int, int]:
110
+ h = self.occupied.shape[0]
111
+ col = int(math.floor((x - self.origin[0]) / self.resolution))
112
+ row_from_bottom = int(math.floor((y - self.origin[1]) / self.resolution))
113
+ return h - 1 - row_from_bottom, col
114
+
115
+ def cell_to_world(self, row: int, col: int) -> tuple[float, float]:
116
+ h = self.occupied.shape[0]
117
+ x = self.origin[0] + (col + 0.5) * self.resolution
118
+ y = self.origin[1] + (h - 1 - row + 0.5) * self.resolution
119
+ return x, y
120
+
121
+ def in_bounds(self, row: int, col: int) -> bool:
122
+ h, w = self.occupied.shape
123
+ return 0 <= row < h and 0 <= col < w
124
+
125
+ def inflated(self, radius_m: float) -> np.ndarray:
126
+ """Return the occupancy mask dilated by a disk of ``radius_m``."""
127
+ r = int(math.ceil(radius_m / self.resolution))
128
+ if r <= 0:
129
+ return self.occupied.copy()
130
+ src = self.occupied
131
+ out = src.copy()
132
+ h, w = src.shape
133
+ for dr in range(-r, r + 1):
134
+ for dc in range(-r, r + 1):
135
+ if dr * dr + dc * dc > r * r or (dr == 0 and dc == 0):
136
+ continue
137
+ r0, r1 = max(0, dr), min(h, h + dr)
138
+ c0, c1 = max(0, dc), min(w, w + dc)
139
+ out[r0:r1, c0:c1] |= src[r0 - dr : r1 - dr, c0 - dc : c1 - dc]
140
+ return out
141
+
142
+
143
+ # --------------------------------------------------------------------------- metrics
144
+ def path_length(traj: Trajectory) -> float:
145
+ """Integrated travelled distance Σ‖Δp‖ (meters)."""
146
+ d = np.diff(traj.positions, axis=0)
147
+ return float(np.sum(np.hypot(d[:, 0], d[:, 1])))
148
+
149
+
150
+ def _astar_length(grid: np.ndarray, start: tuple[int, int], goal: tuple[int, int], res: float) -> Optional[float]:
151
+ """8-connected A* on a boolean blocked-grid; returns path length in meters or None."""
152
+ h, w = grid.shape
153
+ if not (0 <= start[0] < h and 0 <= start[1] < w and 0 <= goal[0] < h and 0 <= goal[1] < w):
154
+ return None
155
+ if grid[start] or grid[goal]:
156
+ return None
157
+ sq2 = math.sqrt(2.0)
158
+ moves = [(-1, 0, 1.0), (1, 0, 1.0), (0, -1, 1.0), (0, 1, 1.0),
159
+ (-1, -1, sq2), (-1, 1, sq2), (1, -1, sq2), (1, 1, sq2)]
160
+
161
+ def heur(r: int, c: int) -> float: # octile distance (admissible)
162
+ dr, dc = abs(r - goal[0]), abs(c - goal[1])
163
+ return (dr + dc) + (sq2 - 2.0) * min(dr, dc)
164
+
165
+ g = np.full(grid.shape, np.inf)
166
+ g[start] = 0.0
167
+ closed = np.zeros(grid.shape, dtype=bool)
168
+ heap: list[tuple[float, float, int, int]] = [(heur(*start), 0.0, start[0], start[1])]
169
+ while heap:
170
+ _, cost, r, c = heapq.heappop(heap)
171
+ if closed[r, c]:
172
+ continue
173
+ if (r, c) == goal:
174
+ return cost * res
175
+ closed[r, c] = True
176
+ for dr, dc, step in moves:
177
+ nr, nc = r + dr, c + dc
178
+ if not (0 <= nr < h and 0 <= nc < w) or grid[nr, nc] or closed[nr, nc]:
179
+ continue
180
+ if dr and dc and (grid[r + dr, c] or grid[r, c + dc]):
181
+ continue # no corner cutting
182
+ nc_cost = cost + step
183
+ if nc_cost < g[nr, nc]:
184
+ g[nr, nc] = nc_cost
185
+ heapq.heappush(heap, (nc_cost + heur(nr, nc), nc_cost, nr, nc))
186
+ return None
187
+
188
+
189
+ def optimal_length(
190
+ start: Sequence[float],
191
+ goal: Sequence[float],
192
+ occupancy: Optional[OccupancyGrid] = None,
193
+ r_robot: float = NQIParams.r_robot,
194
+ ) -> tuple[float, str]:
195
+ """Shortest collision-free path length and the method used.
196
+
197
+ Uses A* over the occupancy grid inflated by ``r_robot`` when a grid is given,
198
+ otherwise (or if A* fails) the straight-line Euclidean distance.
199
+ """
200
+ euclid = float(math.hypot(goal[0] - start[0], goal[1] - start[1]))
201
+ if occupancy is None:
202
+ return euclid, "euclidean"
203
+ blocked = occupancy.inflated(r_robot)
204
+ s = occupancy.world_to_cell(start[0], start[1])
205
+ gl = occupancy.world_to_cell(goal[0], goal[1])
206
+ length = _astar_length(blocked, s, gl, occupancy.resolution)
207
+ if length is None:
208
+ return euclid, "euclidean_fallback"
209
+ return max(length, euclid), "astar"
210
+
211
+
212
+ def path_efficiency(l_optimal: float, l_actual: float) -> float:
213
+ """``E_p = L_optimal / L_actual`` clipped to [0, 1]."""
214
+ if l_actual < 1e-6:
215
+ return 0.0
216
+ return float(np.clip(l_optimal / l_actual, 0.0, 1.0))
217
+
218
+
219
+ def _moving_average(a: np.ndarray, window: int) -> np.ndarray:
220
+ if window <= 1 or a.size < window:
221
+ return a
222
+ pad = window // 2
223
+ padded = np.pad(a, (pad, window - 1 - pad), mode="edge")
224
+ return np.convolve(padded, np.ones(window) / window, mode="valid")
225
+
226
+
227
+ def trajectory_smoothness(traj: Trajectory, params: NQIParams = NQIParams()) -> float:
228
+ """``S_tau = ∫ (‖ȧ‖² + ω̇²) dt`` on a uniformly resampled, lightly smoothed trajectory."""
229
+ tr = traj.resample(params.resample_dt)
230
+ if tr.n < 3:
231
+ return 0.0
232
+ t = tr.t
233
+ vx = _moving_average(tr.v * np.cos(tr.yaw), params.smooth_window) # type: ignore[operator]
234
+ vy = _moving_average(tr.v * np.sin(tr.yaw), params.smooth_window) # type: ignore[operator]
235
+ w = _moving_average(tr.w, params.smooth_window) # type: ignore[arg-type]
236
+ ax, ay = np.gradient(vx, t), np.gradient(vy, t)
237
+ jx, jy = np.gradient(ax, t), np.gradient(ay, t)
238
+ wdot = np.gradient(w, t)
239
+ integrand = jx**2 + jy**2 + wdot**2
240
+ return float(_trapezoid(integrand, t))
241
+
242
+
243
+ def _normalize_obstacles(obstacles: Optional[Iterable[Any]]) -> np.ndarray:
244
+ """Accept ``[(x, y, r)]``, ``[(x, y)]`` or ``[{"x":..,"y":..,"r":..}]`` -> (M, 3) array."""
245
+ rows: list[tuple[float, float, float]] = []
246
+ for ob in obstacles or []:
247
+ if isinstance(ob, Mapping):
248
+ rows.append((float(ob["x"]), float(ob["y"]), float(ob.get("r", ob.get("radius", 0.0)))))
249
+ else:
250
+ vals = list(ob)
251
+ rows.append((float(vals[0]), float(vals[1]), float(vals[2]) if len(vals) > 2 else 0.0))
252
+ return np.asarray(rows, dtype=float).reshape(-1, 3)
253
+
254
+
255
+ def _grid_clearance(points: np.ndarray, grid: OccupancyGrid, search_m: float = 3.0) -> float:
256
+ occ_rows, occ_cols = np.nonzero(grid.occupied)
257
+ if occ_rows.size == 0:
258
+ return math.inf
259
+ h = grid.occupied.shape[0]
260
+ occ_x = grid.origin[0] + (occ_cols + 0.5) * grid.resolution
261
+ occ_y = grid.origin[1] + (h - 1 - occ_rows + 0.5) * grid.resolution
262
+ best = math.inf
263
+ for px, py in points:
264
+ m = (np.abs(occ_x - px) <= search_m) & (np.abs(occ_y - py) <= search_m)
265
+ if m.any():
266
+ d = float(np.min(np.hypot(occ_x[m] - px, occ_y[m] - py))) - grid.resolution / 2
267
+ best = min(best, d)
268
+ return best
269
+
270
+
271
+ def min_clearance(
272
+ traj: Trajectory,
273
+ obstacles: Optional[Iterable[Any]] = None,
274
+ occupancy: Optional[OccupancyGrid] = None,
275
+ r_robot: float = NQIParams.r_robot,
276
+ ) -> Optional[float]:
277
+ """Minimum distance between the robot boundary and any obstacle boundary.
278
+
279
+ Returns ``None`` when no obstacle information is available.
280
+ """
281
+ pts = traj.positions
282
+ best = math.inf
283
+ obs = _normalize_obstacles(obstacles)
284
+ if obs.size:
285
+ d = np.hypot(pts[:, None, 0] - obs[None, :, 0], pts[:, None, 1] - obs[None, :, 1]) - obs[None, :, 2]
286
+ best = min(best, float(d.min()))
287
+ if occupancy is not None:
288
+ best = min(best, _grid_clearance(pts, occupancy))
289
+ if math.isinf(best):
290
+ return None
291
+ return best - r_robot
292
+
293
+
294
+ def mission_success(
295
+ final_pos: Sequence[float],
296
+ goal: Optional[Sequence[float]],
297
+ collisions: int,
298
+ eps_pos: float = NQIParams.eps_pos,
299
+ ) -> Optional[int]:
300
+ """1 if the goal was reached within ``eps_pos`` with zero collisions; ``None`` if no goal."""
301
+ if goal is None:
302
+ return None
303
+ reached = math.hypot(final_pos[0] - goal[0], final_pos[1] - goal[1]) <= eps_pos
304
+ return int(reached and collisions == 0)
305
+
306
+
307
+ def compute_nqi(
308
+ e_p: float,
309
+ s_tau: float,
310
+ c_min: Optional[float],
311
+ c_force: int,
312
+ params: NQIParams = NQIParams(),
313
+ ) -> tuple[float, dict[str, float]]:
314
+ """Return ``(nqi_raw, term_breakdown)``. ``c_min=None`` counts the clearance term as satisfied."""
315
+ t_eff = params.w1 * e_p
316
+ t_smooth = params.w2 / (1.0 + params.alpha * s_tau)
317
+ clear_ratio = 1.0 if c_min is None else float(np.clip(c_min / params.c_safe, 0.0, 1.0))
318
+ t_clear = params.w3 * clear_ratio
319
+ penalty = params.p_collision * c_force
320
+ raw = 100.0 * (t_eff + t_smooth + t_clear) - penalty
321
+ terms = {
322
+ "efficiency": 100.0 * t_eff,
323
+ "smoothness": 100.0 * t_smooth,
324
+ "clearance": 100.0 * t_clear,
325
+ "collision_penalty": -penalty,
326
+ }
327
+ return raw, terms
328
+
329
+
330
+ def score_trajectory(
331
+ traj: Trajectory,
332
+ *,
333
+ goal: Optional[Sequence[float]] = None,
334
+ obstacles: Optional[Iterable[Any]] = None,
335
+ occupancy: Optional[OccupancyGrid] = None,
336
+ l_optimal: Optional[float] = None,
337
+ collisions: Optional[int] = None,
338
+ params: NQIParams = NQIParams(),
339
+ ) -> dict[str, Any]:
340
+ """Compute every metric for one run and return a JSON-serializable dict."""
341
+ goal = tuple(goal) if goal is not None else traj.goal
342
+ c_force = int(traj.collisions if collisions is None else collisions)
343
+ l_actual = path_length(traj)
344
+
345
+ if l_optimal is None:
346
+ l_optimal = traj.l_optimal
347
+ if l_optimal is not None:
348
+ l_opt, l_opt_method = float(l_optimal), "provided"
349
+ else:
350
+ target = goal if goal is not None else traj.end
351
+ l_opt, l_opt_method = optimal_length(traj.start, target, occupancy, params.r_robot)
352
+
353
+ e_p = path_efficiency(l_opt, l_actual)
354
+ s_tau = trajectory_smoothness(traj, params)
355
+ c_min = min_clearance(traj, obstacles, occupancy, params.r_robot)
356
+ s_mission = mission_success(traj.end, goal, c_force, params.eps_pos)
357
+ nqi_raw, terms = compute_nqi(e_p, s_tau, c_min, c_force, params)
358
+
359
+ slam_metrics = None
360
+ if traj.ate is not None:
361
+ mean_ate = float(traj.ate_mean) if traj.ate_mean is not None else 0.0
362
+ max_ate = float(traj.ate_max) if traj.ate_max is not None else 0.0
363
+ rmse_ate = float(traj.ate_rmse) if traj.ate_rmse is not None else 0.0
364
+ diverged = bool(max_ate > 0.35)
365
+ slam_metrics = {
366
+ "mean_ate": round(mean_ate, 4),
367
+ "max_ate": round(max_ate, 4),
368
+ "rmse_ate": round(rmse_ate, 4),
369
+ "diverged": diverged,
370
+ }
371
+
372
+ return {
373
+ "label": traj.label,
374
+ "samples": traj.n,
375
+ "duration_s": round(traj.duration, 4),
376
+ "path_length_m": round(l_actual, 4),
377
+ "optimal_length_m": round(l_opt, 4),
378
+ "optimal_length_method": l_opt_method,
379
+ "goal": list(goal) if goal is not None else None,
380
+ "final_position": [round(v, 4) for v in traj.end],
381
+ "E_p": round(e_p, 4),
382
+ "S_tau": round(s_tau, 4),
383
+ "C_min_m": None if c_min is None else round(c_min, 4),
384
+ "clearance_source": _clearance_source(obstacles, occupancy),
385
+ "C_force": c_force,
386
+ "S_mission": s_mission,
387
+ "terms": {k: round(v, 3) for k, v in terms.items()},
388
+ "nqi_raw": round(nqi_raw, 3),
389
+ "nqi": round(float(np.clip(nqi_raw, 0.0, 100.0)), 3),
390
+ "ate_mean_m": None if slam_metrics is None else slam_metrics["mean_ate"],
391
+ "ate_max_m": None if slam_metrics is None else slam_metrics["max_ate"],
392
+ "ate_rmse_m": None if slam_metrics is None else slam_metrics["rmse_ate"],
393
+ "slam_diverged": None if slam_metrics is None else slam_metrics["diverged"],
394
+ "slam_metrics": slam_metrics,
395
+ }
396
+
397
+
398
+ def _clearance_source(obstacles: Optional[Iterable[Any]], occupancy: Optional[OccupancyGrid]) -> str:
399
+ has_obs = _normalize_obstacles(obstacles).size > 0
400
+ if has_obs and occupancy is not None:
401
+ return "obstacles+map"
402
+ if has_obs:
403
+ return "obstacles"
404
+ if occupancy is not None:
405
+ return "map"
406
+ return "none (clearance term assumed satisfied)"
407
+
408
+
409
+ # --------------------------------------------------------------------------- report
410
+ _DELTA_KEYS = {
411
+ "duration_pct": "duration_s",
412
+ "path_length_pct": "path_length_m",
413
+ "E_p_pct": "E_p",
414
+ "S_tau_pct": "S_tau",
415
+ "C_min_pct": "C_min_m",
416
+ "nqi_pct": "nqi",
417
+ "ate_mean_pct": "ate_mean_m",
418
+ }
419
+
420
+
421
+ def _pct(new: Optional[float], old: Optional[float]) -> Optional[float]:
422
+ if new is None or old is None or abs(old) < 1e-12:
423
+ return None
424
+ return round((new - old) / abs(old) * 100.0, 2)
425
+
426
+
427
+ def build_report(
428
+ nominal: Mapping[str, Any],
429
+ adversarial: Optional[Mapping[str, Any]] = None,
430
+ params: NQIParams = NQIParams(),
431
+ ) -> dict[str, Any]:
432
+ """Assemble the ``benchmark_report.json`` structure from scored runs."""
433
+ runs: dict[str, Any] = {"nominal": dict(nominal)}
434
+ deltas: Optional[dict[str, Optional[float]]] = None
435
+ if adversarial is not None:
436
+ runs["adversarial"] = dict(adversarial)
437
+ deltas = {k: _pct(adversarial.get(src), nominal.get(src)) for k, src in _DELTA_KEYS.items()}
438
+ deltas["C_force_delta"] = int(adversarial.get("C_force", 0)) - int(nominal.get("C_force", 0))
439
+ return {
440
+ "schema_version": REPORT_SCHEMA_VERSION,
441
+ "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
442
+ "params": asdict(params),
443
+ "runs": runs,
444
+ "deltas": deltas,
445
+ }
446
+
447
+
448
+ def default_report_path(project_name: str = "active_project", timestamp: Optional[str] = None) -> Path:
449
+ run_dir = os.environ.get("LANG2SIM_RUN_DIR")
450
+ if run_dir:
451
+ return Path(run_dir).expanduser() / "benchmark_report.json"
452
+ try:
453
+ from lang2sim.core.config import get_run_dir
454
+ return get_run_dir(project_name=project_name, timestamp=timestamp) / "benchmark_report.json"
455
+ except Exception:
456
+ base = os.environ.get("LANG2SIM_PROJECT_DIR") or str(Path.home() / ".lang2sim" / "projects" / project_name)
457
+ return Path(base).expanduser() / "benchmark_report.json"
458
+
459
+
460
+ def write_report(report: Mapping[str, Any], path: Optional[str | Path] = None) -> Path:
461
+ out = Path(path) if path is not None else default_report_path()
462
+ out.parent.mkdir(parents=True, exist_ok=True)
463
+ out.write_text(json.dumps(report, indent=2), encoding="utf-8")
464
+ return out
465
+
466
+
467
+ # --------------------------------------------------------------------------- rich output
468
+ def _fmt(v: Any, unit: str = "", digits: int = 2) -> str:
469
+ if v is None:
470
+ return "n/a"
471
+ if isinstance(v, float):
472
+ return f"{v:.{digits}f}{unit}"
473
+ return f"{v}{unit}"
474
+
475
+
476
+ def _mission_str(s: Any) -> str:
477
+ return {1: "[green]Succeeded[/]", 0: "[red]Failed[/]"}.get(s, "n/a")
478
+
479
+
480
+ def _delta_str(pct: Optional[float], higher_is_better: bool, note: str = "") -> str:
481
+ if pct is None:
482
+ return "n/a"
483
+ good = (pct >= 0) == higher_is_better or abs(pct) < 1e-9
484
+ color = "green" if good else "red"
485
+ suffix = f" ({note})" if note else ""
486
+ return f"[{color}]{pct:+.1f}%[/]{suffix}"
487
+
488
+
489
+ def render_comparison_table(report: Mapping[str, Any]):
490
+ """Build a Rich table mirroring the architecture's §8.4 benchmark table."""
491
+ from rich.table import Table
492
+
493
+ runs = report["runs"]
494
+ nom = runs["nominal"]
495
+ adv = runs.get("adversarial")
496
+ deltas = report.get("deltas") or {}
497
+
498
+ table = Table(title="Lang2Sim Navigation Benchmark (NQI)", header_style="bold cyan", title_style="bold")
499
+ table.add_column("Evaluation Metric", style="bold")
500
+ table.add_column("Nominal Run", justify="right")
501
+ if adv is not None:
502
+ table.add_column("Adversarial Stress Run", justify="right")
503
+ table.add_column("Performance Delta", justify="right")
504
+
505
+ def row(name: str, n_val: str, a_val: str = "", delta: str = "") -> None:
506
+ if adv is not None:
507
+ table.add_row(name, n_val, a_val, delta)
508
+ else:
509
+ table.add_row(name, n_val)
510
+
511
+ a = adv or {}
512
+ mission_delta = ""
513
+ if adv is not None:
514
+ mission_delta = "Baseline maintained" if a.get("S_mission") == nom.get("S_mission") else "[red]Changed[/]"
515
+ row("Mission Completion Status", _mission_str(nom.get("S_mission")), _mission_str(a.get("S_mission")), mission_delta)
516
+ row("Mission Duration (T)", _fmt(nom.get("duration_s"), " s", 1), _fmt(a.get("duration_s"), " s", 1),
517
+ _delta_str(deltas.get("duration_pct"), False))
518
+ row("Traversed Path Length (L)", _fmt(nom.get("path_length_m"), " m"), _fmt(a.get("path_length_m"), " m"),
519
+ _delta_str(deltas.get("path_length_pct"), False))
520
+ row("Path Traversal Efficiency (E_p)", _fmt(nom.get("E_p")), _fmt(a.get("E_p")),
521
+ _delta_str(deltas.get("E_p_pct"), True))
522
+ row("Trajectory Smoothness (S_tau)", _fmt(nom.get("S_tau")), _fmt(a.get("S_tau")),
523
+ _delta_str(deltas.get("S_tau_pct"), False))
524
+ row("Minimum Obstacle Margin (C_min)", _fmt(nom.get("C_min_m"), " m"), _fmt(a.get("C_min_m"), " m"),
525
+ _delta_str(deltas.get("C_min_pct"), True))
526
+ c_delta = deltas.get("C_force_delta")
527
+ row("Physical Collision Count (C_force)", str(nom.get("C_force", 0)), str(a.get("C_force", "")),
528
+ "" if c_delta is None else ("[green]0[/]" if c_delta == 0 else f"[red]{c_delta:+d}[/]"))
529
+ row("Navigation Quality Index (NQI)", f"[bold]{_fmt(nom.get('nqi'), ' / 100', 1)}[/]",
530
+ f"[bold]{_fmt(a.get('nqi'), ' / 100', 1)}[/]" if adv is not None else "",
531
+ _delta_str(deltas.get("nqi_pct"), True))
532
+
533
+ if nom.get("ate_mean_m") is not None or (adv and adv.get("ate_mean_m") is not None):
534
+ row(
535
+ "SLAM Drift (ATE Mean)",
536
+ _fmt(nom.get("ate_mean_m"), " m"),
537
+ _fmt(a.get("ate_mean_m"), " m"),
538
+ _delta_str(deltas.get("ate_mean_pct"), False),
539
+ )
540
+ def _health_str(div: Any) -> str:
541
+ if div is None:
542
+ return "n/a"
543
+ return "[red]Diverged (>0.35m)[/]" if div else "[green]Healthy (<0.1m)[/]"
544
+ row(
545
+ "SLAM Tracking Health",
546
+ _health_str(nom.get("slam_diverged")),
547
+ _health_str(a.get("slam_diverged")),
548
+ "",
549
+ )
550
+ return table
551
+
552
+
553
+ def render_to_console(report: Mapping[str, Any], console: Any = None) -> None:
554
+ from rich.console import Console
555
+
556
+ (console or Console()).print(render_comparison_table(report))
557
+
558
+
559
+ # --------------------------------------------------------------------------- demo data
560
+ def demo_trajectories(adversarial_collisions: int = 0, seed: int = 7) -> dict[str, Any]:
561
+ """Synthetic nominal (straight) vs adversarial (crate detour + jitter) runs on a 10 m corridor."""
562
+ rng = np.random.default_rng(seed)
563
+ goal = (10.0, 0.0)
564
+
565
+ # Nominal: straight line at a constant 0.5 m/s.
566
+ t_n = np.arange(0.0, 20.0 + 1e-9, 0.05)
567
+ nominal = Trajectory(t=t_n, x=0.5 * t_n, y=np.zeros_like(t_n), goal=goal, label="nominal")
568
+
569
+ # Adversarial: slower, Gaussian detour around a crate at (5, 0) plus small steering jitter.
570
+ t_a = np.arange(0.0, 30.0 + 1e-9, 0.05)
571
+ x_a = 10.0 * t_a / t_a[-1]
572
+ y_a = 0.9 * np.exp(-(((x_a - 5.0) / 1.2) ** 2)) + 0.03 * np.sin(2 * np.pi * x_a / 0.8)
573
+ y_a += rng.normal(0.0, 0.005, size=y_a.shape)
574
+ y_a[0] = 0.0
575
+ y_a[-1] = 0.0
576
+ adversarial = Trajectory(t=t_a, x=x_a, y=y_a, goal=goal, collisions=adversarial_collisions, label="adversarial")
577
+
578
+ static_obstacles = [(5.0, 1.6, 0.3)] # rack beside the corridor
579
+ return {
580
+ "goal": goal,
581
+ "nominal": nominal,
582
+ "adversarial": adversarial,
583
+ "obstacles_nominal": static_obstacles,
584
+ "obstacles_adversarial": static_obstacles + [(5.0, 0.0, 0.3)], # + spawned crate
585
+ }
586
+
587
+
588
+ # --------------------------------------------------------------------------- CLI
589
+ def _load_traj(path: str) -> Trajectory:
590
+ return Trajectory.from_csv(path) if path.lower().endswith(".csv") else Trajectory.from_json(path)
591
+
592
+
593
+ def _load_obstacles(path: Optional[str]) -> Optional[list[Any]]:
594
+ if not path:
595
+ return None
596
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
597
+ return data.get("obstacles", data) if isinstance(data, dict) else data
598
+
599
+
600
+ def main(argv: Optional[Sequence[str]] = None) -> int:
601
+ ap = argparse.ArgumentParser(prog="python -m lang2sim.telemetry.nqi_scorer", description=__doc__.splitlines()[0])
602
+ ap.add_argument("--nominal", help="nominal run trajectory (.json from ros_subscribers --record, or .csv)")
603
+ ap.add_argument("--adversarial", help="adversarial run trajectory (.json or .csv)")
604
+ ap.add_argument("--goal", nargs=2, type=float, metavar=("X", "Y"), help="mission goal (overrides file)")
605
+ ap.add_argument("--obstacles", help="JSON file: [[x, y, r], ...] or {\"obstacles\": [...]} ")
606
+ ap.add_argument("--map", help="Nav2 map.yaml (enables A* optimal length and map clearance)")
607
+ ap.add_argument("--l-optimal", type=float, help="override optimal path length (m)")
608
+ ap.add_argument("--out", help="report path (default: $LANG2SIM_PROJECT_DIR/benchmark_report.json)")
609
+ ap.add_argument("--demo", action="store_true", help="score synthetic nominal vs adversarial runs")
610
+ ap.add_argument("--demo-collisions", type=int, default=0, help="collisions injected in demo adversarial run")
611
+ ap.add_argument("--save-demo-trajectories", metavar="DIR", help="also write demo trajectories as JSON")
612
+ ap.add_argument("--quiet", action="store_true", help="don't print the Rich table")
613
+ args = ap.parse_args(argv)
614
+
615
+ params = NQIParams()
616
+ occupancy = OccupancyGrid.from_yaml(args.map) if args.map else None
617
+ goal = tuple(args.goal) if args.goal else None
618
+
619
+ if args.demo:
620
+ d = demo_trajectories(args.demo_collisions)
621
+ if args.save_demo_trajectories:
622
+ out_dir = Path(args.save_demo_trajectories)
623
+ d["nominal"].to_json(out_dir / "nominal.json")
624
+ d["adversarial"].to_json(out_dir / "adversarial.json")
625
+ (out_dir / "obstacles.json").write_text(json.dumps({"obstacles": d["obstacles_adversarial"]}), encoding="utf-8")
626
+ nom = score_trajectory(d["nominal"], obstacles=d["obstacles_nominal"], params=params)
627
+ adv = score_trajectory(d["adversarial"], obstacles=d["obstacles_adversarial"], params=params)
628
+ else:
629
+ if not args.nominal:
630
+ ap.error("--nominal is required unless --demo is used")
631
+ obstacles = _load_obstacles(args.obstacles)
632
+ common = dict(goal=goal, obstacles=obstacles, occupancy=occupancy, l_optimal=args.l_optimal, params=params)
633
+ nom = score_trajectory(_load_traj(args.nominal), **common) # type: ignore[arg-type]
634
+ adv = score_trajectory(_load_traj(args.adversarial), **common) if args.adversarial else None # type: ignore[arg-type]
635
+
636
+ report = build_report(nom, adv, params)
637
+ out = write_report(report, args.out)
638
+ if not args.quiet:
639
+ render_to_console(report)
640
+ print(f"benchmark report written to: {out}")
641
+ return 0
642
+
643
+
644
+ if __name__ == "__main__":
645
+ sys.exit(main())