lang2sim 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lang2sim/__init__.py +47 -0
- lang2sim/agent/__init__.py +5 -0
- lang2sim/agent/nemotron_client.py +109 -0
- lang2sim/agent/react_loop.py +848 -0
- lang2sim/agent/tools.py +998 -0
- lang2sim/cli/app.py +183 -0
- lang2sim/cli/screens/__init__.py +8 -0
- lang2sim/cli/screens/command_palette_modal.py +146 -0
- lang2sim/cli/screens/landing_screen.py +96 -0
- lang2sim/cli/screens/sessions_modal.py +236 -0
- lang2sim/cli/screens/workspace_screen.py +709 -0
- lang2sim/cli/widgets/__init__.py +16 -0
- lang2sim/cli/widgets/action_pill.py +67 -0
- lang2sim/cli/widgets/command_card.py +265 -0
- lang2sim/cli/widgets/plan_card.py +106 -0
- lang2sim/cli/widgets/prompt_card.py +96 -0
- lang2sim/cli/widgets/telemetry_sidebar.py +117 -0
- lang2sim/cli/widgets/thought_box.py +64 -0
- lang2sim/compiler/__init__.py +15 -0
- lang2sim/compiler/sdf_compiler.py +65 -0
- lang2sim/compiler/sir_generator.py +46 -0
- lang2sim/compiler/sir_validator.py +87 -0
- lang2sim/core/__init__.py +50 -0
- lang2sim/core/config.py +116 -0
- lang2sim/core/models.py +279 -0
- lang2sim/core/session_manager.py +189 -0
- lang2sim/perturbation/__init__.py +50 -0
- lang2sim/perturbation/_compat.py +19 -0
- lang2sim/perturbation/gazebo_bridge.py +520 -0
- lang2sim/perturbation/mcp_server.py +129 -0
- lang2sim/perturbation/routers/__init__.py +19 -0
- lang2sim/perturbation/routers/world.py +110 -0
- lang2sim/perturbation/sdf_snippets.py +112 -0
- lang2sim/runtime/__init__.py +7 -0
- lang2sim/runtime/control_api/__init__.py +7 -0
- lang2sim/runtime/control_api/client.py +187 -0
- lang2sim/runtime/control_api/server.py +504 -0
- lang2sim/runtime/display.py +141 -0
- lang2sim/runtime/docker_runner.py +286 -0
- lang2sim/runtime/doctor.py +220 -0
- lang2sim/runtime/hardware_probe.py +144 -0
- lang2sim/runtime/health_monitor.py +66 -0
- lang2sim/runtime/launch_builder.py +137 -0
- lang2sim/runtime/nav2_dispatcher.py +215 -0
- lang2sim/runtime/platform.py +137 -0
- lang2sim/runtime/ros_bridge.py +183 -0
- lang2sim/runtime/run_folder.py +86 -0
- lang2sim/runtime/self_healing.py +387 -0
- lang2sim/runtime/session.py +136 -0
- lang2sim/runtime/world_check.py +197 -0
- lang2sim/telemetry/__init__.py +85 -0
- lang2sim/telemetry/nqi_scorer.py +645 -0
- lang2sim/telemetry/ros_subscribers.py +489 -0
- lang2sim/telemetry/trajectory.py +258 -0
- lang2sim/telemetry/vision.py +477 -0
- lang2sim/world_gen/__init__.py +16 -0
- lang2sim/world_gen/catalog_loader.py +83 -0
- lang2sim/world_gen/map_slicer.py +131 -0
- lang2sim/world_gen/shapely_solver.py +256 -0
- lang2sim-2.0.0.dist-info/METADATA +53 -0
- lang2sim-2.0.0.dist-info/RECORD +65 -0
- lang2sim-2.0.0.dist-info/WHEEL +5 -0
- lang2sim-2.0.0.dist-info/entry_points.txt +2 -0
- lang2sim-2.0.0.dist-info/licenses/LICENSE +21 -0
- lang2sim-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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())
|