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