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,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The control API's window into ROS: readiness, telemetry, RTF.
|
|
3
|
+
File: lang2sim/runtime/ros_bridge.py
|
|
4
|
+
|
|
5
|
+
Runs inside the container, in the API server process, as one rclpy node on its
|
|
6
|
+
own executor thread. It only listens (/clock, /odom, /scan, TF, Nav2 lifecycle
|
|
7
|
+
states); commands go through nav2_dispatcher.
|
|
8
|
+
|
|
9
|
+
Pure helpers (yaw_from_quaternion, rtf_from_samples, readiness) carry the logic
|
|
10
|
+
and are unit-tested on the host; the RosBridge class needs rclpy and is created
|
|
11
|
+
with RosBridge.try_create(), which returns None where ROS is not installed.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import collections
|
|
17
|
+
import math
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Deque, Dict, Optional, Sequence, Tuple
|
|
22
|
+
|
|
23
|
+
FRESH_S = 3.0 # a topic counts as alive if a message arrived this recently (wall time)
|
|
24
|
+
RTF_WINDOW_S = 5.0
|
|
25
|
+
NAV2_NODES = ("amcl", "bt_navigator", "controller_server", "planner_server")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def yaw_from_quaternion(x: float, y: float, z: float, w: float) -> float:
|
|
29
|
+
return math.atan2(2.0 * (w * z + x * y), 1.0 - 2.0 * (y * y + z * z))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def rtf_from_samples(samples: Sequence[Tuple[float, float]]) -> Optional[float]:
|
|
33
|
+
"""(wall_s, sim_s) pairs, oldest first -> real-time factor over the span, or None."""
|
|
34
|
+
if len(samples) < 2:
|
|
35
|
+
return None
|
|
36
|
+
(w0, s0), (w1, s1) = samples[0], samples[-1]
|
|
37
|
+
if w1 - w0 <= 0.2:
|
|
38
|
+
return None
|
|
39
|
+
return max(0.0, (s1 - s0) / (w1 - w0))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class RosSnapshot:
|
|
44
|
+
now: float = 0.0 # wall time of the snapshot
|
|
45
|
+
clock_wall: Optional[float] = None # wall time of the last /clock
|
|
46
|
+
sim_time: float = 0.0
|
|
47
|
+
odom_wall: Optional[float] = None
|
|
48
|
+
scan_wall: Optional[float] = None
|
|
49
|
+
odom_pose: Optional[Tuple[float, float, float]] = None # x, y, yaw in odom
|
|
50
|
+
map_pose: Optional[Tuple[float, float, float]] = None # x, y, yaw in map (TF)
|
|
51
|
+
velocity: Optional[Tuple[float, float, float]] = None # vx, vy, wz
|
|
52
|
+
lifecycle: Dict[str, str] = field(default_factory=dict) # node -> "active", ...
|
|
53
|
+
rtf: Optional[float] = None
|
|
54
|
+
|
|
55
|
+
def fresh(self, t: Optional[float]) -> bool:
|
|
56
|
+
return t is not None and self.now - t <= FRESH_S
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def readiness(s: RosSnapshot) -> Dict[str, bool]:
|
|
60
|
+
"""What 'the simulation is ready to navigate' means, check by check."""
|
|
61
|
+
return {
|
|
62
|
+
"gazebo_clock": s.fresh(s.clock_wall),
|
|
63
|
+
"odometry": s.fresh(s.odom_wall),
|
|
64
|
+
"laser_scan": s.fresh(s.scan_wall),
|
|
65
|
+
"nav2_active": all(s.lifecycle.get(n) == "active" for n in NAV2_NODES),
|
|
66
|
+
"localized": s.map_pose is not None,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RosBridge:
|
|
71
|
+
"""rclpy node + executor thread. Create with try_create()."""
|
|
72
|
+
|
|
73
|
+
def __init__(self) -> None:
|
|
74
|
+
import rclpy # type: ignore[import-not-found]
|
|
75
|
+
from lifecycle_msgs.srv import GetState # type: ignore[import-not-found]
|
|
76
|
+
from nav_msgs.msg import Odometry # type: ignore[import-not-found]
|
|
77
|
+
from rclpy.executors import MultiThreadedExecutor # type: ignore[import-not-found]
|
|
78
|
+
from rclpy.node import Node # type: ignore[import-not-found]
|
|
79
|
+
from rclpy.parameter import Parameter # type: ignore[import-not-found]
|
|
80
|
+
from rclpy.qos import qos_profile_sensor_data # type: ignore[import-not-found]
|
|
81
|
+
from rosgraph_msgs.msg import Clock # type: ignore[import-not-found]
|
|
82
|
+
from sensor_msgs.msg import LaserScan # type: ignore[import-not-found]
|
|
83
|
+
from tf2_ros import Buffer, TransformListener # type: ignore[import-not-found]
|
|
84
|
+
|
|
85
|
+
if not rclpy.ok():
|
|
86
|
+
rclpy.init()
|
|
87
|
+
self._rclpy = rclpy
|
|
88
|
+
self._lock = threading.Lock()
|
|
89
|
+
self._s = RosSnapshot()
|
|
90
|
+
self._clock_samples: Deque[Tuple[float, float]] = collections.deque(maxlen=400)
|
|
91
|
+
|
|
92
|
+
node = Node("l2s_control_api", parameter_overrides=[Parameter("use_sim_time", value=True)])
|
|
93
|
+
self.node = node
|
|
94
|
+
node.create_subscription(Clock, "/clock", self._on_clock, qos_profile_sensor_data)
|
|
95
|
+
node.create_subscription(Odometry, "/odom", self._on_odom, qos_profile_sensor_data)
|
|
96
|
+
node.create_subscription(LaserScan, "/scan", self._on_scan, qos_profile_sensor_data)
|
|
97
|
+
self._tf = Buffer()
|
|
98
|
+
self._tf_listener = TransformListener(self._tf, node, spin_thread=False)
|
|
99
|
+
self._GetState = GetState
|
|
100
|
+
self._lc_clients = {n: node.create_client(GetState, f"/{n}/get_state") for n in NAV2_NODES}
|
|
101
|
+
node.create_timer(1.0, self._poll)
|
|
102
|
+
|
|
103
|
+
self._executor = MultiThreadedExecutor(num_threads=2)
|
|
104
|
+
self._executor.add_node(node)
|
|
105
|
+
self._thread = threading.Thread(target=self._executor.spin, name="l2s-ros", daemon=True)
|
|
106
|
+
self._thread.start()
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def try_create(cls) -> Optional["RosBridge"]:
|
|
110
|
+
try:
|
|
111
|
+
return cls()
|
|
112
|
+
except Exception: # noqa: BLE001 - no ROS here (host tests) or rclpy failed to init
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
# --- callbacks (executor threads) -------------------------------------
|
|
116
|
+
def _on_clock(self, msg) -> None:
|
|
117
|
+
now = time.time()
|
|
118
|
+
sim = msg.clock.sec + msg.clock.nanosec * 1e-9
|
|
119
|
+
with self._lock:
|
|
120
|
+
self._s.clock_wall, self._s.sim_time = now, sim
|
|
121
|
+
self._clock_samples.append((now, sim))
|
|
122
|
+
while self._clock_samples and now - self._clock_samples[0][0] > RTF_WINDOW_S:
|
|
123
|
+
self._clock_samples.popleft()
|
|
124
|
+
|
|
125
|
+
def _on_odom(self, msg) -> None:
|
|
126
|
+
p, q, t = msg.pose.pose.position, msg.pose.pose.orientation, msg.twist.twist
|
|
127
|
+
with self._lock:
|
|
128
|
+
self._s.odom_wall = time.time()
|
|
129
|
+
self._s.odom_pose = (p.x, p.y, yaw_from_quaternion(q.x, q.y, q.z, q.w))
|
|
130
|
+
self._s.velocity = (t.linear.x, t.linear.y, t.angular.z)
|
|
131
|
+
|
|
132
|
+
def _on_scan(self, _msg) -> None:
|
|
133
|
+
with self._lock:
|
|
134
|
+
self._s.scan_wall = time.time()
|
|
135
|
+
|
|
136
|
+
def _poll(self) -> None:
|
|
137
|
+
# Map pose from TF (AMCL map->odom + odom->base_footprint)
|
|
138
|
+
try:
|
|
139
|
+
from rclpy.time import Time
|
|
140
|
+
tr = self._tf.lookup_transform("map", "base_footprint", Time())
|
|
141
|
+
tt, q = tr.transform.translation, tr.transform.rotation
|
|
142
|
+
pose = (tt.x, tt.y, yaw_from_quaternion(q.x, q.y, q.z, q.w))
|
|
143
|
+
except Exception: # noqa: BLE001 - not localized yet
|
|
144
|
+
pose = None
|
|
145
|
+
with self._lock:
|
|
146
|
+
self._s.map_pose = pose
|
|
147
|
+
# Nav2 lifecycle states, asynchronously
|
|
148
|
+
for name, client in self._lc_clients.items():
|
|
149
|
+
if not client.service_is_ready():
|
|
150
|
+
with self._lock:
|
|
151
|
+
self._s.lifecycle.pop(name, None)
|
|
152
|
+
continue
|
|
153
|
+
fut = client.call_async(self._GetState.Request())
|
|
154
|
+
fut.add_done_callback(lambda f, n=name: self._on_state(n, f))
|
|
155
|
+
|
|
156
|
+
def _on_state(self, name: str, fut) -> None:
|
|
157
|
+
try:
|
|
158
|
+
label = fut.result().current_state.label
|
|
159
|
+
except Exception: # noqa: BLE001
|
|
160
|
+
return
|
|
161
|
+
with self._lock:
|
|
162
|
+
self._s.lifecycle[name] = label
|
|
163
|
+
|
|
164
|
+
# --- API ---------------------------------------------------------------
|
|
165
|
+
def snapshot(self) -> RosSnapshot:
|
|
166
|
+
with self._lock:
|
|
167
|
+
s = RosSnapshot(**{k: (dict(v) if isinstance(v, dict) else v) for k, v in vars(self._s).items()})
|
|
168
|
+
s.rtf = rtf_from_samples(list(self._clock_samples))
|
|
169
|
+
s.now = time.time()
|
|
170
|
+
return s
|
|
171
|
+
|
|
172
|
+
def reset(self) -> None:
|
|
173
|
+
"""Forget state from a previous run (called on /sim/start)."""
|
|
174
|
+
with self._lock:
|
|
175
|
+
self._s = RosSnapshot()
|
|
176
|
+
self._clock_samples.clear()
|
|
177
|
+
|
|
178
|
+
def stop(self) -> None:
|
|
179
|
+
try:
|
|
180
|
+
self._executor.shutdown(timeout_sec=2)
|
|
181
|
+
self.node.destroy_node()
|
|
182
|
+
except Exception: # noqa: BLE001
|
|
183
|
+
pass
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run folder: the one input Part 3 accepts (decision 1).
|
|
3
|
+
File: lang2sim/runtime/run_folder.py
|
|
4
|
+
|
|
5
|
+
~/.lang2sim/projects/<project>/runs/<timestamp>/
|
|
6
|
+
world.sdf Gazebo Harmonic world (Part 1)
|
|
7
|
+
map.yaml/.pgm Nav2 map, map frame == world frame (Part 1)
|
|
8
|
+
spec.json the SimSpec (Part 2 / Part 1)
|
|
9
|
+
manifest.json RunManifest: file names, world name, robot, initial pose
|
|
10
|
+
logs/ written by Part 3 (sim.log, heal_log.jsonl)
|
|
11
|
+
|
|
12
|
+
Host side only: load the manifest (or infer it from spec.json), check the files
|
|
13
|
+
exist, and turn it into the SimStartRequest the container API expects.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import importlib
|
|
19
|
+
import json
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from lang2sim.core.models import Pose2D, RunManifest, SimSpec, SimStartRequest
|
|
24
|
+
from lang2sim.runtime.docker_runner import host_to_container_path
|
|
25
|
+
|
|
26
|
+
cfg = importlib.import_module("lang2sim.core.config")
|
|
27
|
+
|
|
28
|
+
MANIFEST = "manifest.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class RunFolderError(ValueError):
|
|
32
|
+
"""The folder does not satisfy the run-folder contract."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_manifest(run_dir: Path) -> RunManifest:
|
|
36
|
+
"""manifest.json if present, else inferred from spec.json (initial pose from its mission)."""
|
|
37
|
+
run_dir = Path(run_dir)
|
|
38
|
+
if not run_dir.is_dir():
|
|
39
|
+
raise RunFolderError(f"run folder not found: {run_dir}")
|
|
40
|
+
mpath = run_dir / MANIFEST
|
|
41
|
+
if mpath.is_file():
|
|
42
|
+
try:
|
|
43
|
+
manifest = RunManifest.model_validate_json(mpath.read_text())
|
|
44
|
+
except ValueError as e:
|
|
45
|
+
raise RunFolderError(f"{mpath} is invalid: {e}") from e
|
|
46
|
+
else:
|
|
47
|
+
spec_path = run_dir / "spec.json"
|
|
48
|
+
if not spec_path.is_file():
|
|
49
|
+
raise RunFolderError(f"{run_dir} has neither {MANIFEST} nor spec.json")
|
|
50
|
+
spec = SimSpec.model_validate_json(spec_path.read_text())
|
|
51
|
+
manifest = RunManifest(initial_pose=spec.mission.initial_pose)
|
|
52
|
+
|
|
53
|
+
missing = [n for n in (manifest.world_sdf, manifest.map_yaml) if not (run_dir / n).is_file()]
|
|
54
|
+
if missing:
|
|
55
|
+
raise RunFolderError(f"{run_dir} is missing {', '.join(missing)}")
|
|
56
|
+
return manifest
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_manifest(run_dir: Path, manifest: RunManifest) -> Path:
|
|
60
|
+
path = Path(run_dir) / MANIFEST
|
|
61
|
+
path.write_text(json.dumps(manifest.model_dump(mode="json"), indent=2) + "\n")
|
|
62
|
+
return path
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def to_start_request(run_dir: Path, manifest: Optional[RunManifest] = None, *,
|
|
66
|
+
projects_dir: Optional[Path] = None, gui: bool = True, rviz: bool = True,
|
|
67
|
+
initial_pose: Optional[Pose2D] = None) -> SimStartRequest:
|
|
68
|
+
"""Container-side SimStartRequest for a host run folder under projects_dir."""
|
|
69
|
+
run_dir = Path(run_dir)
|
|
70
|
+
manifest = manifest or load_manifest(run_dir)
|
|
71
|
+
projects = Path(projects_dir) if projects_dir else cfg.get_projects_dir()
|
|
72
|
+
try:
|
|
73
|
+
c_run = host_to_container_path(run_dir, projects)
|
|
74
|
+
except ValueError as e:
|
|
75
|
+
raise RunFolderError(f"{run_dir} must be inside the projects folder {projects}") from e
|
|
76
|
+
return SimStartRequest(
|
|
77
|
+
run_dir=c_run,
|
|
78
|
+
world_sdf_path=f"{c_run}/{manifest.world_sdf}",
|
|
79
|
+
map_yaml_path=f"{c_run}/{manifest.map_yaml}",
|
|
80
|
+
robot_model=manifest.robot_model,
|
|
81
|
+
initial_pose=initial_pose or manifest.initial_pose,
|
|
82
|
+
use_gui=gui,
|
|
83
|
+
use_rviz=rviz,
|
|
84
|
+
world_name=manifest.world_name,
|
|
85
|
+
nav2_params_path=f"{c_run}/{manifest.nav2_params}" if manifest.nav2_params else None,
|
|
86
|
+
)
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Self-healing supervisor: classify -> plan_recovery -> rate-limited executor.
|
|
3
|
+
File: lang2sim/runtime/self_healing.py
|
|
4
|
+
|
|
5
|
+
Runs inside the container, in the API server, every few seconds while a sim is
|
|
6
|
+
up. Inputs are what the server already has: new sim log lines, the ROS
|
|
7
|
+
snapshot/readiness, the nav status and which node processes are alive.
|
|
8
|
+
|
|
9
|
+
Four rules (issue #3, plan v2):
|
|
10
|
+
|
|
11
|
+
| Fault | Seen as | Recovery |
|
|
12
|
+
|------------------|----------------------------------------------------------|-----------------------------------|
|
|
13
|
+
| missing_package | "Package 'x' not found" / ModuleNotFoundError in the log | report only: never install at runtime |
|
|
14
|
+
| missing_tf | robot_state_publisher dead (process gone, "process has | restart robot_state_publisher |
|
|
15
|
+
| | died", or TF errors while it is not running) | (TF errors alone: reported once) |
|
|
16
|
+
| unlinked_scan | ros_gz_bridge dead, or no /scan while the clock runs | restart the bridge |
|
|
17
|
+
| goal_in_collision| planner says the goal is occupied / no valid path | move the goal to the nearest free |
|
|
18
|
+
| | | map cell and resend it |
|
|
19
|
+
|
|
20
|
+
Each action is rate-limited per fault (cooldown + max attempts in a window) and
|
|
21
|
+
written to <run>/logs/heal_log.jsonl as a HealEvent plus details.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import math
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import subprocess
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Callable, Dict, Iterable, List, Optional, Tuple
|
|
36
|
+
|
|
37
|
+
from lang2sim.core.models import HealEvent, NavStatus, Pose2D
|
|
38
|
+
from lang2sim.runtime.ros_bridge import RosSnapshot
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Faults
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
PKG_PATTERNS = (
|
|
45
|
+
re.compile(r"[Pp]ackage '([\w\-]+)' not found"),
|
|
46
|
+
re.compile(r"PackageNotFoundError: .*?\"?'?([\w\-]+)'?\"?$"),
|
|
47
|
+
re.compile(r"No module named '([\w\.]+)'"),
|
|
48
|
+
)
|
|
49
|
+
TF_PATTERNS = (
|
|
50
|
+
re.compile(r"Could not find a connection between '(\w+)' and '(\w+)'"),
|
|
51
|
+
re.compile(r"Timed out waiting for transform from (\w+) to (\w+)"),
|
|
52
|
+
re.compile(r'"(\w+)" passed to lookupTransform argument \w+ does not exist'),
|
|
53
|
+
)
|
|
54
|
+
GOAL_PATTERNS = (
|
|
55
|
+
re.compile(r"[Gg]oal (?:coordinates|pose)?.*(?:occupied|in collision|lethal)"),
|
|
56
|
+
re.compile(r"(?:GridBased|NavFn|Smac).*[Ff]ailed to create (?:a )?plan"),
|
|
57
|
+
re.compile(r"[Ff]ailed to create plan with tolerance"),
|
|
58
|
+
)
|
|
59
|
+
DIED = re.compile(r"\[(robot_state_publisher|parameter_bridge|ros_gz_bridge)[\w\-]*\]: process has died")
|
|
60
|
+
|
|
61
|
+
# healer name -> (ROS package, executable); processes are matched on "lib/<pkg>/<exe>"
|
|
62
|
+
NODES = {"robot_state_publisher": ("robot_state_publisher", "robot_state_publisher"),
|
|
63
|
+
"bridge": ("ros_gz_bridge", "parameter_bridge")}
|
|
64
|
+
SCAN_GRACE_S = 5.0
|
|
65
|
+
TF_GRACE_S = 10.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Fault:
|
|
70
|
+
kind: str # missing_package | missing_tf | unlinked_scan | goal_in_collision
|
|
71
|
+
signature: str # stable text used for rate limiting and the log
|
|
72
|
+
evidence: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class Observation:
|
|
77
|
+
lines: List[str] # new log lines since the last check
|
|
78
|
+
snap: Optional[RosSnapshot]
|
|
79
|
+
alive: Dict[str, bool] # node process name -> running
|
|
80
|
+
nav: Optional[NavStatus] = None
|
|
81
|
+
sim_running: bool = True
|
|
82
|
+
was_localized: bool = False # robot was localized earlier in this run
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def classify(obs: Observation) -> List[Fault]:
|
|
86
|
+
faults: Dict[str, Fault] = {}
|
|
87
|
+
|
|
88
|
+
def add(kind: str, sig: str, ev: str) -> None:
|
|
89
|
+
faults.setdefault(f"{kind}:{sig}", Fault(kind, sig, ev))
|
|
90
|
+
|
|
91
|
+
for line in obs.lines:
|
|
92
|
+
for p in PKG_PATTERNS:
|
|
93
|
+
m = p.search(line)
|
|
94
|
+
if m:
|
|
95
|
+
add("missing_package", m.group(1), line.strip()[:240])
|
|
96
|
+
m = DIED.search(line)
|
|
97
|
+
if m:
|
|
98
|
+
kind = "missing_tf" if m.group(1) == "robot_state_publisher" else "unlinked_scan"
|
|
99
|
+
add(kind, f"{m.group(1)} died", line.strip()[:240])
|
|
100
|
+
if obs.nav is not None and (obs.nav.is_active or obs.nav.status == "failed"):
|
|
101
|
+
for p in GOAL_PATTERNS:
|
|
102
|
+
if p.search(line):
|
|
103
|
+
add("goal_in_collision", "goal not plannable", line.strip()[:240])
|
|
104
|
+
for p in TF_PATTERNS:
|
|
105
|
+
if p.search(line) and obs.was_localized:
|
|
106
|
+
# Nav2 keeps logging these for a while after robot_state_publisher is
|
|
107
|
+
# restarted; only a dead publisher justifies restarting it. Otherwise
|
|
108
|
+
# report once, never kill a healthy node over stale warnings.
|
|
109
|
+
if obs.alive.get("robot_state_publisher") is False:
|
|
110
|
+
add("missing_tf", "tf lookup failing", line.strip()[:240])
|
|
111
|
+
else:
|
|
112
|
+
add("tf_warning", "tf lookups failing", line.strip()[:240])
|
|
113
|
+
|
|
114
|
+
if obs.sim_running:
|
|
115
|
+
if obs.alive.get("robot_state_publisher") is False:
|
|
116
|
+
add("missing_tf", "robot_state_publisher not running", "process not found")
|
|
117
|
+
if obs.alive.get("bridge") is False:
|
|
118
|
+
add("unlinked_scan", "ros_gz_bridge not running", "process not found")
|
|
119
|
+
s = obs.snap
|
|
120
|
+
if s is not None and s.fresh(s.clock_wall):
|
|
121
|
+
if s.scan_wall is None or s.now - s.scan_wall > SCAN_GRACE_S:
|
|
122
|
+
if obs.was_localized: # scan worked before in this run
|
|
123
|
+
add("unlinked_scan", "no /scan while the clock runs",
|
|
124
|
+
f"last scan {'never' if s.scan_wall is None else f'{s.now - s.scan_wall:.0f} s ago'}")
|
|
125
|
+
return list(faults.values())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Recovery plans
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class Action:
|
|
134
|
+
kind: str # report | restart_node | snap_goal
|
|
135
|
+
target: str = ""
|
|
136
|
+
detail: Dict[str, object] = field(default_factory=dict)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def plan_recovery(f: Fault) -> Action:
|
|
140
|
+
if f.kind == "missing_package":
|
|
141
|
+
return Action("report", f.signature, {"hint": "the sim image is missing this; rebuild it - "
|
|
142
|
+
"nothing is installed at runtime"})
|
|
143
|
+
if f.kind == "missing_tf":
|
|
144
|
+
return Action("restart_node", "robot_state_publisher")
|
|
145
|
+
if f.kind == "unlinked_scan":
|
|
146
|
+
return Action("restart_node", "bridge")
|
|
147
|
+
if f.kind == "goal_in_collision":
|
|
148
|
+
return Action("snap_goal")
|
|
149
|
+
if f.kind == "tf_warning":
|
|
150
|
+
return Action("report", f.signature, {"hint": "TF lookups failed while robot_state_publisher "
|
|
151
|
+
"was running; no restart (often transient)"})
|
|
152
|
+
return Action("report", f.signature)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Map helpers (goal snapping)
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def read_map(map_yaml: Path) -> Tuple[List[bytes], int, int, float, Tuple[float, float], float]:
|
|
160
|
+
"""Parse a Nav2 map.yaml + binary PGM. Returns rows (top first), w, h, resolution, origin, free_thresh."""
|
|
161
|
+
meta: Dict[str, str] = {}
|
|
162
|
+
for line in Path(map_yaml).read_text().splitlines():
|
|
163
|
+
if ":" in line:
|
|
164
|
+
k, v = line.split(":", 1)
|
|
165
|
+
meta[k.strip()] = v.strip()
|
|
166
|
+
res = float(meta["resolution"])
|
|
167
|
+
ox, oy = [float(x) for x in meta["origin"].strip("[]").split(",")[:2]]
|
|
168
|
+
negate = meta.get("negate", "0").strip() == "1"
|
|
169
|
+
free_thresh = float(meta.get("free_thresh", "0.196"))
|
|
170
|
+
data = (Path(map_yaml).parent / meta["image"]).read_bytes()
|
|
171
|
+
tokens, i = [], 0
|
|
172
|
+
while len(tokens) < 4: # P5 header: magic, w, h, maxval (skip comments)
|
|
173
|
+
while data[i:i + 1].isspace():
|
|
174
|
+
i += 1
|
|
175
|
+
if data[i:i + 1] == b"#":
|
|
176
|
+
while data[i:i + 1] not in (b"\n", b""):
|
|
177
|
+
i += 1
|
|
178
|
+
continue
|
|
179
|
+
j = i
|
|
180
|
+
while not data[j:j + 1].isspace():
|
|
181
|
+
j += 1
|
|
182
|
+
tokens.append(data[i:j])
|
|
183
|
+
i = j
|
|
184
|
+
if tokens[0] != b"P5":
|
|
185
|
+
raise ValueError("only binary PGM (P5) maps are supported")
|
|
186
|
+
w, h = int(tokens[1]), int(tokens[2])
|
|
187
|
+
pix = data[i + 1:i + 1 + w * h]
|
|
188
|
+
if negate:
|
|
189
|
+
pix = bytes(255 - b for b in pix)
|
|
190
|
+
rows = [pix[r * w:(r + 1) * w] for r in range(h)]
|
|
191
|
+
return rows, w, h, res, (ox, oy), free_thresh
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def nearest_free(map_yaml: Path, x: float, y: float, clearance: float = 0.45,
|
|
195
|
+
max_radius: float = 3.0) -> Optional[Pose2D]:
|
|
196
|
+
"""Closest map point to (x, y) with every cell within `clearance` free. None if none nearby."""
|
|
197
|
+
rows, w, h, res, (ox, oy), free_thresh = read_map(map_yaml)
|
|
198
|
+
free_min = int((1 - free_thresh) * 255) # brighter than this = free
|
|
199
|
+
|
|
200
|
+
def free(c: int, r: int) -> bool:
|
|
201
|
+
return 0 <= c < w and 0 <= r < h and rows[r][c] >= free_min
|
|
202
|
+
|
|
203
|
+
k = int(math.ceil(clearance / res))
|
|
204
|
+
disk = [(dc, dr) for dc in range(-k, k + 1) for dr in range(-k, k + 1) if dc * dc + dr * dr <= k * k]
|
|
205
|
+
|
|
206
|
+
def clear(c: int, r: int) -> bool:
|
|
207
|
+
return all(free(c + dc, r + dr) for dc, dr in disk)
|
|
208
|
+
|
|
209
|
+
c0, r0 = int((x - ox) / res), h - 1 - int((y - oy) / res)
|
|
210
|
+
for ring in range(0, int(max_radius / res) + 1):
|
|
211
|
+
best = None
|
|
212
|
+
for dc in range(-ring, ring + 1):
|
|
213
|
+
for dr in (-ring, ring) if abs(dc) != ring else range(-ring, ring + 1):
|
|
214
|
+
c, r = c0 + dc, r0 + dr
|
|
215
|
+
if clear(c, r):
|
|
216
|
+
d = dc * dc + dr * dr
|
|
217
|
+
if best is None or d < best[0]:
|
|
218
|
+
best = (d, c, r)
|
|
219
|
+
if best:
|
|
220
|
+
_, c, r = best
|
|
221
|
+
return Pose2D(x=round(ox + (c + 0.5) * res, 3), y=round(oy + (h - 1 - r + 0.5) * res, 3))
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ---------------------------------------------------------------------------
|
|
226
|
+
# Executor
|
|
227
|
+
# ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
@dataclass
|
|
230
|
+
class RateLimit:
|
|
231
|
+
cooldown_s: float = 15.0
|
|
232
|
+
max_attempts: int = 3
|
|
233
|
+
window_s: float = 300.0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class SelfHealer:
|
|
237
|
+
"""Plans and executes recoveries. Side effects are injected so tests can fake them."""
|
|
238
|
+
|
|
239
|
+
def __init__(self, run_dir: Path, *, restart_node: Callable[[str], bool],
|
|
240
|
+
resend_goal: Optional[Callable[[Pose2D], bool]] = None,
|
|
241
|
+
current_goal: Optional[Callable[[], Optional[Pose2D]]] = None,
|
|
242
|
+
map_yaml: Optional[Path] = None, limit: Optional[RateLimit] = None,
|
|
243
|
+
clock: Callable[[], float] = time.time) -> None:
|
|
244
|
+
self.run_dir = Path(run_dir)
|
|
245
|
+
self.log_path = self.run_dir / "logs" / "heal_log.jsonl"
|
|
246
|
+
self._restart = restart_node
|
|
247
|
+
self._resend = resend_goal
|
|
248
|
+
self._goal = current_goal
|
|
249
|
+
self._map = map_yaml
|
|
250
|
+
self.limit = limit or RateLimit()
|
|
251
|
+
self._clock = clock
|
|
252
|
+
self._history: Dict[str, List[float]] = {}
|
|
253
|
+
self._reported: set = set()
|
|
254
|
+
self.events: List[dict] = []
|
|
255
|
+
|
|
256
|
+
def _allowed(self, sig: str) -> Tuple[bool, str]:
|
|
257
|
+
now = self._clock()
|
|
258
|
+
hist = [t for t in self._history.get(sig, []) if now - t <= self.limit.window_s]
|
|
259
|
+
self._history[sig] = hist
|
|
260
|
+
if hist and now - hist[-1] < self.limit.cooldown_s:
|
|
261
|
+
return False, "cooldown"
|
|
262
|
+
if len(hist) >= self.limit.max_attempts:
|
|
263
|
+
return False, "attempt limit reached"
|
|
264
|
+
return True, ""
|
|
265
|
+
|
|
266
|
+
def handle(self, faults: Iterable[Fault]) -> List[dict]:
|
|
267
|
+
"""Plan and run recoveries. Rate limits are per recovery action (e.g. 'restart_node bridge'),
|
|
268
|
+
so several symptoms of one failure trigger one restart, at most once per pass."""
|
|
269
|
+
out: List[dict] = []
|
|
270
|
+
done_this_pass: set = set()
|
|
271
|
+
for f in faults:
|
|
272
|
+
action = plan_recovery(f)
|
|
273
|
+
if action.kind == "report":
|
|
274
|
+
key = f"{f.kind}:{f.signature}"
|
|
275
|
+
if key in self._reported:
|
|
276
|
+
continue
|
|
277
|
+
self._reported.add(key)
|
|
278
|
+
out.append(self._log(f, action, success=False, note=str(action.detail.get("hint", ""))))
|
|
279
|
+
continue
|
|
280
|
+
key = f"{action.kind}:{action.target}"
|
|
281
|
+
if key in done_this_pass:
|
|
282
|
+
continue
|
|
283
|
+
done_this_pass.add(key)
|
|
284
|
+
ok, why = self._allowed(key)
|
|
285
|
+
if not ok:
|
|
286
|
+
if why == "attempt limit reached" and f"{key}:gaveup" not in self._reported:
|
|
287
|
+
self._reported.add(f"{key}:gaveup")
|
|
288
|
+
out.append(self._log(f, Action("give_up", action.target), success=False,
|
|
289
|
+
note=f"{self.limit.max_attempts} attempts in "
|
|
290
|
+
f"{self.limit.window_s:.0f} s; needs a person"))
|
|
291
|
+
continue
|
|
292
|
+
self._history.setdefault(key, []).append(self._clock())
|
|
293
|
+
success, note = self._execute(action)
|
|
294
|
+
out.append(self._log(f, action, success, note))
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
def _execute(self, a: Action) -> Tuple[bool, str]:
|
|
298
|
+
try:
|
|
299
|
+
if a.kind == "restart_node":
|
|
300
|
+
return bool(self._restart(a.target)), f"restarted {a.target}"
|
|
301
|
+
if a.kind == "snap_goal":
|
|
302
|
+
goal = self._goal() if self._goal else None
|
|
303
|
+
if goal is None or self._map is None or self._resend is None:
|
|
304
|
+
return False, "no active goal or map to snap against"
|
|
305
|
+
free = nearest_free(self._map, goal.x, goal.y)
|
|
306
|
+
if free is None:
|
|
307
|
+
return False, f"no free cell within 3 m of ({goal.x}, {goal.y})"
|
|
308
|
+
free = free.model_copy(update={"yaw": goal.yaw})
|
|
309
|
+
return bool(self._resend(free)), f"goal moved ({goal.x}, {goal.y}) -> ({free.x}, {free.y})"
|
|
310
|
+
except Exception as e: # noqa: BLE001 - a failed recovery must never take the server down
|
|
311
|
+
return False, f"{type(e).__name__}: {e}"
|
|
312
|
+
return False, f"unknown action {a.kind}"
|
|
313
|
+
|
|
314
|
+
def _log(self, f: Fault, a: Action, success: bool, note: str = "") -> dict:
|
|
315
|
+
ev = HealEvent(timestamp=round(self._clock(), 3), error_signature=f"{f.kind}: {f.signature}",
|
|
316
|
+
action_taken=f"{a.kind} {a.target}".strip(), success=success)
|
|
317
|
+
rec = {**ev.model_dump(), "evidence": f.evidence, "note": note}
|
|
318
|
+
self.events.append(rec)
|
|
319
|
+
try:
|
|
320
|
+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
321
|
+
with open(self.log_path, "a") as fh:
|
|
322
|
+
fh.write(json.dumps(rec) + "\n")
|
|
323
|
+
except OSError:
|
|
324
|
+
pass
|
|
325
|
+
return rec
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# Container-side node control (used by the API server)
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
class NodeControl:
|
|
333
|
+
"""Find and restart the nodes the healer knows how to replace."""
|
|
334
|
+
|
|
335
|
+
def __init__(self, run_dir: Path, robot: str = "waffle") -> None:
|
|
336
|
+
self.run_dir = Path(run_dir)
|
|
337
|
+
self.robot = robot
|
|
338
|
+
self._procs: List[subprocess.Popen] = []
|
|
339
|
+
self._lock = threading.Lock()
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _pgrep(pattern: str) -> bool:
|
|
343
|
+
return subprocess.run(["pgrep", "-f", pattern], capture_output=True).returncode == 0
|
|
344
|
+
|
|
345
|
+
def alive(self) -> Dict[str, bool]:
|
|
346
|
+
return {name: self._pgrep(f"lib/{pkg}/{exe}") for name, (pkg, exe) in NODES.items()}
|
|
347
|
+
|
|
348
|
+
def _command(self, name: str) -> List[str]:
|
|
349
|
+
share = f"/opt/ros/{os.getenv('ROS_DISTRO', 'jazzy')}/share/turtlebot3_gazebo"
|
|
350
|
+
model = f"turtlebot3_{self.robot}"
|
|
351
|
+
if name == "robot_state_publisher":
|
|
352
|
+
import yaml # in the sim image (ROS dependency)
|
|
353
|
+
|
|
354
|
+
urdf = Path(f"{share}/urdf/{model}.urdf").read_text()
|
|
355
|
+
params = self.run_dir / "logs" / "rsp_params.l2s.yaml"
|
|
356
|
+
params.write_text(yaml.safe_dump({"robot_state_publisher": {"ros__parameters": {
|
|
357
|
+
"use_sim_time": True, "robot_description": urdf}}}))
|
|
358
|
+
return ["ros2", "run", "robot_state_publisher", "robot_state_publisher",
|
|
359
|
+
"--ros-args", "--params-file", str(params)]
|
|
360
|
+
if name == "bridge":
|
|
361
|
+
return ["ros2", "run", "ros_gz_bridge", "parameter_bridge", "--ros-args",
|
|
362
|
+
"-p", f"config_file:={share}/params/{model}_bridge.yaml", "-p", "use_sim_time:=true"]
|
|
363
|
+
raise ValueError(f"no restart recipe for {name}")
|
|
364
|
+
|
|
365
|
+
def restart(self, name: str) -> bool:
|
|
366
|
+
pkg, exe = NODES[name]
|
|
367
|
+
subprocess.run(["pkill", "-f", f"lib/{pkg}/{exe}"], capture_output=True)
|
|
368
|
+
with self._lock:
|
|
369
|
+
log = open(self.run_dir / "logs" / f"healed_{name}.log", "a")
|
|
370
|
+
self._procs.append(subprocess.Popen(self._command(name), stdout=log, stderr=subprocess.STDOUT,
|
|
371
|
+
start_new_session=True))
|
|
372
|
+
for _ in range(50):
|
|
373
|
+
if self.alive().get(name):
|
|
374
|
+
return True
|
|
375
|
+
time.sleep(0.1)
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
def stop_all(self) -> None:
|
|
379
|
+
with self._lock:
|
|
380
|
+
for p in self._procs:
|
|
381
|
+
if p.poll() is None:
|
|
382
|
+
try:
|
|
383
|
+
os.killpg(p.pid, 2)
|
|
384
|
+
p.wait(timeout=5)
|
|
385
|
+
except Exception: # noqa: BLE001
|
|
386
|
+
p.kill()
|
|
387
|
+
self._procs.clear()
|