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,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)