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,258 @@
|
|
|
1
|
+
"""Robot trajectory container shared by the telemetry collectors and the NQI scorer.
|
|
2
|
+
|
|
3
|
+
A :class:`Trajectory` stores time-stamped planar kinematics (``t, x, y, yaw, v, w``)
|
|
4
|
+
as NumPy arrays. It can be built from raw odometry samples, loaded from / saved to
|
|
5
|
+
JSON or CSV, and resampled onto a uniform time grid (required before numerically
|
|
6
|
+
differentiating velocity into acceleration and jerk).
|
|
7
|
+
|
|
8
|
+
This module is pure Python + NumPy so it runs anywhere (no ROS required).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import csv
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Iterable, Mapping, Optional, Sequence
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
__all__ = ["Trajectory"]
|
|
22
|
+
|
|
23
|
+
_FIELDS = ("t", "x", "y", "yaw", "v", "w")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _as_array(values: Optional[Iterable[float]], n: int) -> Optional[np.ndarray]:
|
|
27
|
+
if values is None:
|
|
28
|
+
return None
|
|
29
|
+
arr = np.asarray(list(values) if not isinstance(values, np.ndarray) else values, dtype=float)
|
|
30
|
+
if arr.shape != (n,):
|
|
31
|
+
raise ValueError(f"expected array of length {n}, got shape {arr.shape}")
|
|
32
|
+
return arr
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Trajectory:
|
|
37
|
+
"""Time-stamped planar robot trajectory.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
t: Timestamps in seconds (strictly increasing after :meth:`__post_init__`).
|
|
41
|
+
x, y: Position in meters (world / map frame).
|
|
42
|
+
yaw: Heading in radians. Derived from motion direction if not supplied.
|
|
43
|
+
v: Signed linear speed in m/s. Derived from positions if not supplied.
|
|
44
|
+
w: Angular speed in rad/s. Derived from yaw if not supplied.
|
|
45
|
+
goal: Optional ``(x, y)`` mission goal.
|
|
46
|
+
l_optimal: Optional optimal (A*) path length in meters.
|
|
47
|
+
collisions: Number of force-qualified collision events during the run.
|
|
48
|
+
label: Free-form run label (e.g. ``"nominal"``).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
t: np.ndarray
|
|
52
|
+
x: np.ndarray
|
|
53
|
+
y: np.ndarray
|
|
54
|
+
yaw: Optional[np.ndarray] = None
|
|
55
|
+
v: Optional[np.ndarray] = None
|
|
56
|
+
w: Optional[np.ndarray] = None
|
|
57
|
+
goal: Optional[tuple[float, float]] = None
|
|
58
|
+
l_optimal: Optional[float] = None
|
|
59
|
+
collisions: int = 0
|
|
60
|
+
label: str = ""
|
|
61
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
62
|
+
slam_x: Optional[np.ndarray] = None
|
|
63
|
+
slam_y: Optional[np.ndarray] = None
|
|
64
|
+
ate: Optional[np.ndarray] = None
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------ init
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
self.t = np.asarray(self.t, dtype=float)
|
|
69
|
+
n = self.t.shape[0]
|
|
70
|
+
if self.t.ndim != 1 or n < 2:
|
|
71
|
+
raise ValueError("trajectory needs at least 2 samples")
|
|
72
|
+
self.x = _as_array(self.x, n) # type: ignore[assignment]
|
|
73
|
+
self.y = _as_array(self.y, n) # type: ignore[assignment]
|
|
74
|
+
self.yaw = _as_array(self.yaw, n)
|
|
75
|
+
self.v = _as_array(self.v, n)
|
|
76
|
+
self.w = _as_array(self.w, n)
|
|
77
|
+
self.slam_x = _as_array(self.slam_x, n)
|
|
78
|
+
self.slam_y = _as_array(self.slam_y, n)
|
|
79
|
+
self.ate = _as_array(self.ate, n)
|
|
80
|
+
|
|
81
|
+
# Sort by time and drop duplicate timestamps (odom can repeat stamps).
|
|
82
|
+
order = np.argsort(self.t, kind="stable")
|
|
83
|
+
keep = np.concatenate(([True], np.diff(self.t[order]) > 1e-9))
|
|
84
|
+
idx = order[keep]
|
|
85
|
+
for name in _FIELDS + ("slam_x", "slam_y", "ate"):
|
|
86
|
+
arr = getattr(self, name)
|
|
87
|
+
if arr is not None:
|
|
88
|
+
setattr(self, name, arr[idx])
|
|
89
|
+
if self.t.shape[0] < 2:
|
|
90
|
+
raise ValueError("trajectory needs at least 2 distinct timestamps")
|
|
91
|
+
|
|
92
|
+
if self.goal is not None:
|
|
93
|
+
self.goal = (float(self.goal[0]), float(self.goal[1]))
|
|
94
|
+
self._derive_missing()
|
|
95
|
+
|
|
96
|
+
def _derive_missing(self) -> None:
|
|
97
|
+
dx = np.gradient(self.x, self.t)
|
|
98
|
+
dy = np.gradient(self.y, self.t)
|
|
99
|
+
if self.yaw is None:
|
|
100
|
+
heading = np.arctan2(dy, dx)
|
|
101
|
+
# Hold the last valid heading while (almost) stationary.
|
|
102
|
+
moving = np.hypot(dx, dy) > 1e-6
|
|
103
|
+
if moving.any():
|
|
104
|
+
first = int(np.argmax(moving))
|
|
105
|
+
heading[:first] = heading[first]
|
|
106
|
+
for i in range(first + 1, heading.size):
|
|
107
|
+
if not moving[i]:
|
|
108
|
+
heading[i] = heading[i - 1]
|
|
109
|
+
else:
|
|
110
|
+
heading[:] = 0.0
|
|
111
|
+
self.yaw = heading
|
|
112
|
+
if self.v is None:
|
|
113
|
+
speed = np.hypot(dx, dy)
|
|
114
|
+
# Sign by projection on heading (reverse motion => negative).
|
|
115
|
+
proj = dx * np.cos(self.yaw) + dy * np.sin(self.yaw)
|
|
116
|
+
self.v = np.where(proj < 0, -speed, speed)
|
|
117
|
+
if self.w is None:
|
|
118
|
+
self.w = np.gradient(np.unwrap(self.yaw), self.t)
|
|
119
|
+
if self.ate is None and self.slam_x is not None and self.slam_y is not None:
|
|
120
|
+
self.ate = np.hypot(self.x - self.slam_x, self.y - self.slam_y)
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------ properties
|
|
123
|
+
@property
|
|
124
|
+
def ate_mean(self) -> float:
|
|
125
|
+
return float(np.mean(self.ate)) if self.ate is not None and len(self.ate) > 0 else 0.0
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def ate_max(self) -> float:
|
|
129
|
+
return float(np.max(self.ate)) if self.ate is not None and len(self.ate) > 0 else 0.0
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def ate_rmse(self) -> float:
|
|
133
|
+
return float(np.sqrt(np.mean(self.ate ** 2))) if self.ate is not None and len(self.ate) > 0 else 0.0
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def n(self) -> int:
|
|
137
|
+
return int(self.t.shape[0])
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def duration(self) -> float:
|
|
141
|
+
return float(self.t[-1] - self.t[0])
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def positions(self) -> np.ndarray:
|
|
145
|
+
"""``(N, 2)`` array of positions."""
|
|
146
|
+
return np.column_stack((self.x, self.y))
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def start(self) -> tuple[float, float]:
|
|
150
|
+
return float(self.x[0]), float(self.y[0])
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def end(self) -> tuple[float, float]:
|
|
154
|
+
return float(self.x[-1]), float(self.y[-1])
|
|
155
|
+
|
|
156
|
+
# ------------------------------------------------------------ transforms
|
|
157
|
+
def resample(self, dt: float = 0.05) -> "Trajectory":
|
|
158
|
+
"""Return a copy linearly interpolated onto a uniform grid of step ``dt``."""
|
|
159
|
+
if dt <= 0:
|
|
160
|
+
raise ValueError("dt must be positive")
|
|
161
|
+
t_new = np.arange(self.t[0], self.t[-1] + 1e-9, dt)
|
|
162
|
+
if t_new.size < 2:
|
|
163
|
+
t_new = np.array([self.t[0], self.t[-1]])
|
|
164
|
+
yaw_u = np.unwrap(self.yaw) # type: ignore[arg-type]
|
|
165
|
+
return Trajectory(
|
|
166
|
+
t=t_new,
|
|
167
|
+
x=np.interp(t_new, self.t, self.x),
|
|
168
|
+
y=np.interp(t_new, self.t, self.y),
|
|
169
|
+
yaw=np.angle(np.exp(1j * np.interp(t_new, self.t, yaw_u))),
|
|
170
|
+
v=np.interp(t_new, self.t, self.v), # type: ignore[arg-type]
|
|
171
|
+
w=np.interp(t_new, self.t, self.w), # type: ignore[arg-type]
|
|
172
|
+
goal=self.goal,
|
|
173
|
+
l_optimal=self.l_optimal,
|
|
174
|
+
collisions=self.collisions,
|
|
175
|
+
label=self.label,
|
|
176
|
+
meta=dict(self.meta),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------ factories
|
|
180
|
+
@classmethod
|
|
181
|
+
def from_samples(
|
|
182
|
+
cls,
|
|
183
|
+
samples: Sequence[Mapping[str, float]],
|
|
184
|
+
**kwargs: Any,
|
|
185
|
+
) -> "Trajectory":
|
|
186
|
+
"""Build from a list of dicts with keys ``t, x, y`` and optional ``yaw, v, w``."""
|
|
187
|
+
if len(samples) < 2:
|
|
188
|
+
raise ValueError("need at least 2 samples")
|
|
189
|
+
cols: dict[str, Optional[list[float]]] = {}
|
|
190
|
+
for name in _FIELDS + ("slam_x", "slam_y", "ate"):
|
|
191
|
+
if all(name in s and s[name] is not None for s in samples):
|
|
192
|
+
cols[name] = [float(s[name]) for s in samples]
|
|
193
|
+
else:
|
|
194
|
+
cols[name] = None
|
|
195
|
+
for req in ("t", "x", "y"):
|
|
196
|
+
if cols[req] is None:
|
|
197
|
+
raise ValueError(f"every sample needs key '{req}'")
|
|
198
|
+
return cls(**cols, **kwargs) # type: ignore[arg-type]
|
|
199
|
+
|
|
200
|
+
# Alias matching the plan's naming.
|
|
201
|
+
from_odom_samples = from_samples
|
|
202
|
+
|
|
203
|
+
def to_dict(self) -> dict[str, Any]:
|
|
204
|
+
samples = []
|
|
205
|
+
for i in range(self.n):
|
|
206
|
+
s = {name: float(getattr(self, name)[i]) for name in _FIELDS}
|
|
207
|
+
if self.slam_x is not None:
|
|
208
|
+
s["slam_x"] = float(self.slam_x[i])
|
|
209
|
+
if self.slam_y is not None:
|
|
210
|
+
s["slam_y"] = float(self.slam_y[i])
|
|
211
|
+
if self.ate is not None:
|
|
212
|
+
s["ate"] = float(self.ate[i])
|
|
213
|
+
samples.append(s)
|
|
214
|
+
res = {
|
|
215
|
+
"label": self.label,
|
|
216
|
+
"goal": list(self.goal) if self.goal is not None else None,
|
|
217
|
+
"l_optimal": self.l_optimal,
|
|
218
|
+
"collisions": int(self.collisions),
|
|
219
|
+
"meta": self.meta,
|
|
220
|
+
"samples": samples,
|
|
221
|
+
}
|
|
222
|
+
if self.ate is not None:
|
|
223
|
+
res["ate_mean"] = round(self.ate_mean, 4)
|
|
224
|
+
res["ate_max"] = round(self.ate_max, 4)
|
|
225
|
+
res["ate_rmse"] = round(self.ate_rmse, 4)
|
|
226
|
+
return res
|
|
227
|
+
|
|
228
|
+
@classmethod
|
|
229
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "Trajectory":
|
|
230
|
+
goal = data.get("goal")
|
|
231
|
+
return cls.from_samples(
|
|
232
|
+
data["samples"],
|
|
233
|
+
goal=tuple(goal) if goal is not None else None,
|
|
234
|
+
l_optimal=data.get("l_optimal"),
|
|
235
|
+
collisions=int(data.get("collisions", 0)),
|
|
236
|
+
label=str(data.get("label", "")),
|
|
237
|
+
meta=dict(data.get("meta") or {}),
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def to_json(self, path: str | Path) -> Path:
|
|
241
|
+
path = Path(path)
|
|
242
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
path.write_text(json.dumps(self.to_dict(), indent=2), encoding="utf-8")
|
|
244
|
+
return path
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def from_json(cls, path: str | Path) -> "Trajectory":
|
|
248
|
+
return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
|
|
249
|
+
|
|
250
|
+
@classmethod
|
|
251
|
+
def from_csv(cls, path: str | Path, **kwargs: Any) -> "Trajectory":
|
|
252
|
+
"""Load a CSV with a header row containing at least ``t,x,y``."""
|
|
253
|
+
with Path(path).open(newline="", encoding="utf-8") as fh:
|
|
254
|
+
rows = [
|
|
255
|
+
{k.strip(): float(v) for k, v in row.items() if v not in (None, "")}
|
|
256
|
+
for row in csv.DictReader(fh)
|
|
257
|
+
]
|
|
258
|
+
return cls.from_samples(rows, **kwargs)
|