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,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Health monitor: healthy / degraded / crashed, with the reasons.
|
|
3
|
+
File: lang2sim/runtime/health_monitor.py
|
|
4
|
+
|
|
5
|
+
Pure function over what the API server already knows (process status, the ROS
|
|
6
|
+
snapshot, readiness), plus a little memory so a slow simulation is only
|
|
7
|
+
reported once RTF has stayed low for a while. Feeds /v1/telemetry's
|
|
8
|
+
health_status, /v1/sim/health and the self-healer.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Dict, List, Literal, Optional
|
|
15
|
+
|
|
16
|
+
from lang2sim.runtime.ros_bridge import RosSnapshot, readiness
|
|
17
|
+
|
|
18
|
+
Health = Literal["healthy", "degraded", "crashed"]
|
|
19
|
+
|
|
20
|
+
LOW_RTF = 0.2 # below this the sim is too slow to be useful
|
|
21
|
+
LOW_RTF_FOR_S = 15.0 # ...for this long before we call it degraded
|
|
22
|
+
STALL_S = 5.0 # clock not advancing this long while the process runs = stalled
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class HealthReport:
|
|
27
|
+
health: Health
|
|
28
|
+
issues: List[str] = field(default_factory=list)
|
|
29
|
+
rtf: Optional[float] = None
|
|
30
|
+
sim_time: float = 0.0
|
|
31
|
+
readiness: Dict[str, bool] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HealthMonitor:
|
|
35
|
+
def __init__(self, low_rtf: float = LOW_RTF, low_rtf_for_s: float = LOW_RTF_FOR_S) -> None:
|
|
36
|
+
self.low_rtf, self.low_rtf_for_s = low_rtf, low_rtf_for_s
|
|
37
|
+
self._low_since: Optional[float] = None
|
|
38
|
+
|
|
39
|
+
def reset(self) -> None:
|
|
40
|
+
self._low_since = None
|
|
41
|
+
|
|
42
|
+
def evaluate(self, process_status: str, snap: Optional[RosSnapshot]) -> HealthReport:
|
|
43
|
+
"""process_status: SimProcess.status() value (idle/launching/running/degraded/stopped/error)."""
|
|
44
|
+
if process_status == "error":
|
|
45
|
+
return HealthReport("crashed", ["simulation process exited with an error"])
|
|
46
|
+
if process_status in ("idle", "stopped") or snap is None:
|
|
47
|
+
self.reset()
|
|
48
|
+
return HealthReport("healthy")
|
|
49
|
+
|
|
50
|
+
checks = readiness(snap)
|
|
51
|
+
issues: List[str] = []
|
|
52
|
+
if process_status == "launching":
|
|
53
|
+
return HealthReport("healthy", [], snap.rtf, snap.sim_time, checks)
|
|
54
|
+
|
|
55
|
+
# Running (or degraded): every check should hold.
|
|
56
|
+
issues += [f"{name} lost" for name, ok in checks.items() if not ok]
|
|
57
|
+
if snap.clock_wall is not None and snap.now - snap.clock_wall > STALL_S:
|
|
58
|
+
issues.append(f"simulation clock stalled for {snap.now - snap.clock_wall:.0f} s")
|
|
59
|
+
if snap.rtf is not None and snap.rtf < self.low_rtf:
|
|
60
|
+
self._low_since = self._low_since or snap.now
|
|
61
|
+
if snap.now - self._low_since >= self.low_rtf_for_s:
|
|
62
|
+
issues.append(f"real-time factor {snap.rtf:.2f} below {self.low_rtf} for "
|
|
63
|
+
f"{snap.now - self._low_since:.0f} s")
|
|
64
|
+
else:
|
|
65
|
+
self._low_since = None
|
|
66
|
+
return HealthReport("degraded" if issues else "healthy", issues, snap.rtf, snap.sim_time, checks)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Launch generator: one self-contained sim_launch.py per run.
|
|
3
|
+
File: lang2sim/runtime/launch_builder.py
|
|
4
|
+
|
|
5
|
+
Placed in runtime/ rather than compiler/ because the container imports it and
|
|
6
|
+
compiler/__init__.py pulls in jinja2/shapely, which the sim image does not ship.
|
|
7
|
+
|
|
8
|
+
The generated file is plain ROS 2 Python launch code with this run's settings
|
|
9
|
+
baked in as a dict literal at the top, so it can be read, diffed and re-run by
|
|
10
|
+
hand: ros2 launch <run_dir>/sim_launch.py
|
|
11
|
+
|
|
12
|
+
It starts: Gazebo server (+ optional GUI that may be closed without stopping the
|
|
13
|
+
sim), robot_state_publisher, the TurtleBot3 Waffle spawned at the manifest's
|
|
14
|
+
pose, ros_gz_bridge (+ optional camera bridge), Nav2 with this run's map and AMCL
|
|
15
|
+
seeded at the spawn pose, and optional RViz2.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import pprint
|
|
21
|
+
from dataclasses import asdict, dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import List
|
|
24
|
+
|
|
25
|
+
LAUNCH_FILE = "sim_launch.py"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class LaunchConfig:
|
|
30
|
+
run_dir: str # container path; logs and generated params go here
|
|
31
|
+
world_sdf: str # container path, already checked/normalized
|
|
32
|
+
world_name: str
|
|
33
|
+
map_yaml: str
|
|
34
|
+
x: float = 0.0
|
|
35
|
+
y: float = 0.0
|
|
36
|
+
yaw: float = 0.0
|
|
37
|
+
gui: bool = True
|
|
38
|
+
rviz: bool = True
|
|
39
|
+
camera: bool = True # image bridge; heavy under software rendering
|
|
40
|
+
nav2_params: str = "" # "" -> TurtleBot3 Waffle params
|
|
41
|
+
nav2_delay_s: float = 5.0
|
|
42
|
+
resource_paths: List[str] = field(default_factory=list) # extra GZ_SIM_RESOURCE_PATH entries
|
|
43
|
+
robot: str = "waffle"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_TEMPLATE = '''\
|
|
47
|
+
"""Generated by lang2sim (lang2sim/runtime/launch_builder.py). Do not edit; regenerate instead."""
|
|
48
|
+
import math
|
|
49
|
+
import os
|
|
50
|
+
|
|
51
|
+
import yaml
|
|
52
|
+
from ament_index_python.packages import get_package_share_directory
|
|
53
|
+
from launch import LaunchDescription
|
|
54
|
+
from launch.actions import AppendEnvironmentVariable, IncludeLaunchDescription, TimerAction
|
|
55
|
+
from launch.launch_description_sources import PythonLaunchDescriptionSource
|
|
56
|
+
from launch_ros.actions import Node
|
|
57
|
+
|
|
58
|
+
CONFIG = __CONFIG__
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _nav2_params(tb3_nav):
|
|
62
|
+
"""Base Nav2 params with AMCL seeded at the spawn pose, written into the run folder."""
|
|
63
|
+
base = CONFIG["nav2_params"] or os.path.join(tb3_nav, "param", CONFIG["robot"] + ".yaml")
|
|
64
|
+
with open(base) as f:
|
|
65
|
+
params = yaml.safe_load(f)
|
|
66
|
+
amcl = params.setdefault("amcl", {}).setdefault("ros__parameters", {})
|
|
67
|
+
amcl["set_initial_pose"] = True
|
|
68
|
+
amcl["initial_pose"] = {"x": float(CONFIG["x"]), "y": float(CONFIG["y"]), "z": 0.0,
|
|
69
|
+
"yaw": float(CONFIG["yaw"])}
|
|
70
|
+
out = os.path.join(CONFIG["run_dir"], "nav2_params.l2s.yaml")
|
|
71
|
+
with open(out, "w") as f:
|
|
72
|
+
yaml.safe_dump(params, f, sort_keys=False)
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def generate_launch_description():
|
|
77
|
+
tb3_gz = get_package_share_directory("turtlebot3_gazebo")
|
|
78
|
+
tb3_nav = get_package_share_directory("turtlebot3_navigation2")
|
|
79
|
+
ros_gz_sim = get_package_share_directory("ros_gz_sim")
|
|
80
|
+
nav2_bringup = get_package_share_directory("nav2_bringup")
|
|
81
|
+
model = "turtlebot3_" + CONFIG["robot"]
|
|
82
|
+
|
|
83
|
+
with open(os.path.join(tb3_gz, "urdf", model + ".urdf")) as f:
|
|
84
|
+
robot_description = f.read()
|
|
85
|
+
|
|
86
|
+
actions = [AppendEnvironmentVariable("GZ_SIM_RESOURCE_PATH", p)
|
|
87
|
+
for p in [os.path.join(tb3_gz, "models"), *CONFIG["resource_paths"]]]
|
|
88
|
+
|
|
89
|
+
actions.append(IncludeLaunchDescription(
|
|
90
|
+
PythonLaunchDescriptionSource(os.path.join(ros_gz_sim, "launch", "gz_sim.launch.py")),
|
|
91
|
+
launch_arguments={"gz_args": "-r -s -v2 " + CONFIG["world_sdf"], "on_exit_shutdown": "true"}.items()))
|
|
92
|
+
if CONFIG["gui"]:
|
|
93
|
+
actions.append(IncludeLaunchDescription(
|
|
94
|
+
PythonLaunchDescriptionSource(os.path.join(ros_gz_sim, "launch", "gz_sim.launch.py")),
|
|
95
|
+
launch_arguments={"gz_args": "-g -v2", "on_exit_shutdown": "false"}.items()))
|
|
96
|
+
|
|
97
|
+
actions.append(Node(
|
|
98
|
+
package="robot_state_publisher", executable="robot_state_publisher", name="robot_state_publisher",
|
|
99
|
+
output="screen", parameters=[{"use_sim_time": True, "robot_description": robot_description}]))
|
|
100
|
+
actions.append(Node(
|
|
101
|
+
package="ros_gz_sim", executable="create", output="screen",
|
|
102
|
+
arguments=["-world", CONFIG["world_name"], "-name", CONFIG["robot"],
|
|
103
|
+
"-file", os.path.join(tb3_gz, "models", model, "model.sdf"),
|
|
104
|
+
"-x", str(CONFIG["x"]), "-y", str(CONFIG["y"]), "-z", "0.01", "-Y", str(CONFIG["yaw"])]))
|
|
105
|
+
actions.append(Node(
|
|
106
|
+
package="ros_gz_bridge", executable="parameter_bridge", name="ros_gz_bridge", output="screen",
|
|
107
|
+
parameters=[{"config_file": os.path.join(tb3_gz, "params", model + "_bridge.yaml"),
|
|
108
|
+
"use_sim_time": True}]))
|
|
109
|
+
if CONFIG["camera"]:
|
|
110
|
+
actions.append(Node(package="ros_gz_image", executable="image_bridge", name="ros_gz_image_bridge",
|
|
111
|
+
arguments=["/camera/image_raw"], output="screen"))
|
|
112
|
+
|
|
113
|
+
actions.append(TimerAction(period=float(CONFIG["nav2_delay_s"]), actions=[IncludeLaunchDescription(
|
|
114
|
+
PythonLaunchDescriptionSource(os.path.join(nav2_bringup, "launch", "bringup_launch.py")),
|
|
115
|
+
launch_arguments={"map": CONFIG["map_yaml"], "use_sim_time": "true", "autostart": "true",
|
|
116
|
+
"params_file": _nav2_params(tb3_nav)}.items())]))
|
|
117
|
+
|
|
118
|
+
if CONFIG["rviz"]:
|
|
119
|
+
actions.append(Node(package="rviz2", executable="rviz2", name="rviz2", output="log",
|
|
120
|
+
arguments=["-d", os.path.join(tb3_nav, "rviz", "tb3_navigation2.rviz")],
|
|
121
|
+
parameters=[{"use_sim_time": True}]))
|
|
122
|
+
return LaunchDescription(actions)
|
|
123
|
+
'''
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def render(cfg: LaunchConfig) -> str:
|
|
127
|
+
"""Python source of the launch file for this config."""
|
|
128
|
+
if not cfg.world_name:
|
|
129
|
+
raise ValueError("world_name is required (the robot is spawned into it by name)")
|
|
130
|
+
literal = pprint.pformat(asdict(cfg), indent=4, sort_dicts=True, width=100)
|
|
131
|
+
return _TEMPLATE.replace("__CONFIG__", literal)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def write(cfg: LaunchConfig, out_dir: Path | None = None) -> Path:
|
|
135
|
+
path = Path(out_dir or cfg.run_dir) / LAUNCH_FILE
|
|
136
|
+
path.write_text(render(cfg), encoding="utf-8")
|
|
137
|
+
return path
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nav2 dispatcher: initial pose, goals, waypoint missions, cancel, live status.
|
|
3
|
+
File: lang2sim/runtime/nav2_dispatcher.py
|
|
4
|
+
|
|
5
|
+
Runs inside the container, in the API server. One worker thread owns one
|
|
6
|
+
nav2_simple_commander.BasicNavigator (it spins itself, so it must never be
|
|
7
|
+
touched from another thread); API handlers only enqueue commands and read a
|
|
8
|
+
lock-protected NavStatus.
|
|
9
|
+
|
|
10
|
+
Waypoint missions are driven leg by leg with goToPose, so every leg reports
|
|
11
|
+
distance remaining, ETA and recoveries. A new goal preempts the current task.
|
|
12
|
+
|
|
13
|
+
BasicNavigator.waitUntilNav2Active() is deliberately not used: until AMCL
|
|
14
|
+
answers, it republishes its default (0, 0) pose, which would teleport the
|
|
15
|
+
localization estimate. The server only dispatches once readiness says Nav2 is
|
|
16
|
+
active and the robot is localized.
|
|
17
|
+
|
|
18
|
+
The navigator is injected via `navigator_factory` / `make_pose` so the logic is
|
|
19
|
+
unit-tested on the host without ROS.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import math
|
|
25
|
+
import queue
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Any, Callable, List, Optional, Sequence
|
|
30
|
+
|
|
31
|
+
from lang2sim.core.models import NavStatus, Pose2D
|
|
32
|
+
|
|
33
|
+
POLL_S = 0.1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _ros_navigator() -> Any:
|
|
37
|
+
from nav2_simple_commander.robot_navigator import BasicNavigator # type: ignore[import-not-found]
|
|
38
|
+
from rclpy.parameter import Parameter # type: ignore[import-not-found]
|
|
39
|
+
|
|
40
|
+
nav = BasicNavigator("l2s_nav_dispatcher")
|
|
41
|
+
nav.set_parameters([Parameter("use_sim_time", value=True)])
|
|
42
|
+
return nav
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ros_pose(nav: Any, p: Pose2D) -> Any:
|
|
46
|
+
from geometry_msgs.msg import PoseStamped # type: ignore[import-not-found]
|
|
47
|
+
|
|
48
|
+
ps = PoseStamped()
|
|
49
|
+
ps.header.frame_id = "map"
|
|
50
|
+
ps.header.stamp = nav.get_clock().now().to_msg()
|
|
51
|
+
ps.pose.position.x, ps.pose.position.y = p.x, p.y
|
|
52
|
+
ps.pose.orientation.z, ps.pose.orientation.w = math.sin(p.yaw / 2), math.cos(p.yaw / 2)
|
|
53
|
+
return ps
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _seconds(duration: Any) -> float:
|
|
57
|
+
"""builtin_interfaces/Duration (or a float in tests) -> seconds."""
|
|
58
|
+
if duration is None:
|
|
59
|
+
return 0.0
|
|
60
|
+
if isinstance(duration, (int, float)):
|
|
61
|
+
return float(duration)
|
|
62
|
+
return duration.sec + duration.nanosec * 1e-9
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class _Task:
|
|
67
|
+
kind: str # "goal" | "waypoints" | "initial_pose"
|
|
68
|
+
poses: List[Pose2D]
|
|
69
|
+
timeout_s: float = 120.0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class NavDispatcher:
|
|
73
|
+
def __init__(self, navigator_factory: Callable[[], Any] = _ros_navigator,
|
|
74
|
+
make_pose: Callable[[Any, Pose2D], Any] = _ros_pose) -> None:
|
|
75
|
+
self._factory = navigator_factory
|
|
76
|
+
self._make_pose = make_pose
|
|
77
|
+
self._lock = threading.Lock()
|
|
78
|
+
self._status = NavStatus()
|
|
79
|
+
self._queue: "queue.Queue[_Task]" = queue.Queue()
|
|
80
|
+
self._cancel = threading.Event()
|
|
81
|
+
self._stop = threading.Event()
|
|
82
|
+
self._thread: Optional[threading.Thread] = None
|
|
83
|
+
self._nav: Any = None
|
|
84
|
+
self._last: Optional[_Task] = None
|
|
85
|
+
|
|
86
|
+
# --- API (any thread) ---------------------------------------------------
|
|
87
|
+
def status(self) -> NavStatus:
|
|
88
|
+
with self._lock:
|
|
89
|
+
return self._status.model_copy()
|
|
90
|
+
|
|
91
|
+
def set_initial_pose(self, pose: Pose2D) -> None:
|
|
92
|
+
self._submit(_Task("initial_pose", [pose]))
|
|
93
|
+
|
|
94
|
+
def go_to(self, goal: Pose2D, timeout_s: float = 120.0) -> NavStatus:
|
|
95
|
+
return self._submit(_Task("goal", [goal], timeout_s))
|
|
96
|
+
|
|
97
|
+
def follow(self, waypoints: Sequence[Pose2D], timeout_s: float = 180.0) -> NavStatus:
|
|
98
|
+
if not waypoints:
|
|
99
|
+
raise ValueError("at least one waypoint is required")
|
|
100
|
+
return self._submit(_Task("waypoints", list(waypoints), timeout_s))
|
|
101
|
+
|
|
102
|
+
def remaining(self) -> List[Pose2D]:
|
|
103
|
+
"""Poses of the current/last task from the leg in progress onwards (for re-planning)."""
|
|
104
|
+
with self._lock:
|
|
105
|
+
task, idx = self._last, self._status.waypoint_index
|
|
106
|
+
return list(task.poses[idx:]) if task else []
|
|
107
|
+
|
|
108
|
+
def resume_with(self, first: Pose2D, timeout_s: Optional[float] = None) -> NavStatus:
|
|
109
|
+
"""Replace the current leg's target with `first`, keep the rest of the mission."""
|
|
110
|
+
rest = self.remaining()[1:]
|
|
111
|
+
timeout = timeout_s if timeout_s is not None else (self._last.timeout_s if self._last else 120.0)
|
|
112
|
+
if rest:
|
|
113
|
+
return self.follow([first, *rest], timeout)
|
|
114
|
+
return self.go_to(first, timeout)
|
|
115
|
+
|
|
116
|
+
def cancel(self) -> NavStatus:
|
|
117
|
+
self._drain()
|
|
118
|
+
if self.status().is_active:
|
|
119
|
+
self._cancel.set()
|
|
120
|
+
return self.status()
|
|
121
|
+
|
|
122
|
+
def shutdown(self) -> None:
|
|
123
|
+
self._stop.set()
|
|
124
|
+
self._cancel.set()
|
|
125
|
+
if self._thread:
|
|
126
|
+
self._thread.join(timeout=5)
|
|
127
|
+
|
|
128
|
+
# --- internals -------------------------------------------------------------
|
|
129
|
+
def _submit(self, task: _Task) -> NavStatus:
|
|
130
|
+
self._ensure_worker()
|
|
131
|
+
if task.kind != "initial_pose":
|
|
132
|
+
self._drain() # newest command wins
|
|
133
|
+
if self.status().is_active:
|
|
134
|
+
self._cancel.set() # preempt the running task
|
|
135
|
+
with self._lock: # report the new task immediately
|
|
136
|
+
self._status = NavStatus(is_active=True, status="navigating", current_task=task.kind,
|
|
137
|
+
target_pose=task.poses[0], waypoints_total=len(task.poses))
|
|
138
|
+
self._queue.put(task)
|
|
139
|
+
return self.status()
|
|
140
|
+
|
|
141
|
+
def _drain(self) -> None:
|
|
142
|
+
try:
|
|
143
|
+
while True:
|
|
144
|
+
self._queue.get_nowait()
|
|
145
|
+
except queue.Empty:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
def _ensure_worker(self) -> None:
|
|
149
|
+
if self._thread and self._thread.is_alive():
|
|
150
|
+
return
|
|
151
|
+
self._thread = threading.Thread(target=self._worker, name="l2s-nav", daemon=True)
|
|
152
|
+
self._thread.start()
|
|
153
|
+
|
|
154
|
+
def _set(self, **kw: Any) -> None:
|
|
155
|
+
with self._lock:
|
|
156
|
+
self._status = self._status.model_copy(update=kw)
|
|
157
|
+
|
|
158
|
+
def _worker(self) -> None:
|
|
159
|
+
self._nav = self._factory()
|
|
160
|
+
while not self._stop.is_set():
|
|
161
|
+
try:
|
|
162
|
+
task = self._queue.get(timeout=0.2)
|
|
163
|
+
except queue.Empty:
|
|
164
|
+
continue
|
|
165
|
+
if task.kind == "initial_pose":
|
|
166
|
+
self._nav.setInitialPose(self._make_pose(self._nav, task.poses[0]))
|
|
167
|
+
continue
|
|
168
|
+
self._cancel.clear()
|
|
169
|
+
self._last = task
|
|
170
|
+
self._run(task)
|
|
171
|
+
|
|
172
|
+
def _run(self, task: _Task) -> None:
|
|
173
|
+
total = len(task.poses)
|
|
174
|
+
for i, pose in enumerate(task.poses):
|
|
175
|
+
self._set(is_active=True, status="navigating", current_task=task.kind, target_pose=pose,
|
|
176
|
+
waypoint_index=i, waypoints_total=total, distance_remaining=0.0,
|
|
177
|
+
navigation_time=0.0, eta_seconds=None, recoveries=0, message=None)
|
|
178
|
+
outcome, msg = self._leg(pose, task.timeout_s)
|
|
179
|
+
if outcome != "succeeded":
|
|
180
|
+
if outcome == "canceled" and not self._queue.empty():
|
|
181
|
+
return # preempted: the next task's status is already showing
|
|
182
|
+
self._set(is_active=False, status=outcome, message=msg)
|
|
183
|
+
return
|
|
184
|
+
self._set(is_active=False, status="succeeded", distance_remaining=0.0, eta_seconds=0.0,
|
|
185
|
+
message=None)
|
|
186
|
+
|
|
187
|
+
def _leg(self, pose: Pose2D, timeout_s: float):
|
|
188
|
+
nav = self._nav
|
|
189
|
+
if not nav.goToPose(self._make_pose(nav, pose)):
|
|
190
|
+
return "failed", "goal rejected by Nav2 (bt_navigator not active?)"
|
|
191
|
+
timed_out = False
|
|
192
|
+
while not nav.isTaskComplete():
|
|
193
|
+
fb = nav.getFeedback()
|
|
194
|
+
if fb is not None:
|
|
195
|
+
t = _seconds(getattr(fb, "navigation_time", None))
|
|
196
|
+
self._set(distance_remaining=round(float(getattr(fb, "distance_remaining", 0.0)), 3),
|
|
197
|
+
navigation_time=round(t, 2),
|
|
198
|
+
eta_seconds=round(_seconds(getattr(fb, "estimated_time_remaining", None)), 1),
|
|
199
|
+
recoveries=int(getattr(fb, "number_of_recoveries", 0)))
|
|
200
|
+
if t > timeout_s and not timed_out:
|
|
201
|
+
timed_out = True
|
|
202
|
+
nav.cancelTask()
|
|
203
|
+
if self._cancel.is_set():
|
|
204
|
+
nav.cancelTask()
|
|
205
|
+
self._cancel.clear()
|
|
206
|
+
return "canceled", "canceled by request"
|
|
207
|
+
time.sleep(POLL_S)
|
|
208
|
+
name = getattr(nav.getResult(), "name", str(nav.getResult())).upper()
|
|
209
|
+
if timed_out:
|
|
210
|
+
return "failed", f"timed out after {timeout_s:.0f} s (sim time)"
|
|
211
|
+
if "SUCCEEDED" in name:
|
|
212
|
+
return "succeeded", None
|
|
213
|
+
if "CANCELED" in name:
|
|
214
|
+
return "canceled", "canceled by Nav2"
|
|
215
|
+
return "failed", f"Nav2 result {name}"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Host platform + Docker detection.
|
|
3
|
+
File: lang2sim/runtime/platform.py
|
|
4
|
+
|
|
5
|
+
Answers: which OS family are we on (linux / wsl / macos / windows), is there a
|
|
6
|
+
display, and can we reach a Docker daemon (and does it have the NVIDIA runtime).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import platform as _platform
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Dict, List, Literal, Optional
|
|
19
|
+
|
|
20
|
+
HostOS = Literal["linux", "wsl", "macos", "windows"]
|
|
21
|
+
Arch = Literal["amd64", "arm64", "unknown"]
|
|
22
|
+
|
|
23
|
+
_ARCH_ALIASES = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class DockerInfo:
|
|
28
|
+
cli_path: Optional[str] = None
|
|
29
|
+
server_reachable: bool = False
|
|
30
|
+
server_version: Optional[str] = None
|
|
31
|
+
operating_system: Optional[str] = None # "Docker Desktop", "Ubuntu 24.04 LTS", ...
|
|
32
|
+
arch: Arch = "unknown"
|
|
33
|
+
runtimes: List[str] = field(default_factory=list)
|
|
34
|
+
mem_total_mb: Optional[int] = None # memory the daemon's VM/host gives containers
|
|
35
|
+
cpus: Optional[int] = None
|
|
36
|
+
error: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def installed(self) -> bool:
|
|
40
|
+
return self.cli_path is not None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_desktop(self) -> bool:
|
|
44
|
+
return bool(self.operating_system and "Docker Desktop" in self.operating_system)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def has_nvidia_runtime(self) -> bool:
|
|
48
|
+
return "nvidia" in self.runtimes
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class HostPlatform:
|
|
53
|
+
os: HostOS
|
|
54
|
+
arch: Arch
|
|
55
|
+
env: Dict[str, str] = field(default_factory=dict) # DISPLAY / WAYLAND_DISPLAY / XAUTHORITY
|
|
56
|
+
has_wslg: bool = False
|
|
57
|
+
has_x11_socket_dir: bool = False
|
|
58
|
+
docker: DockerInfo = field(default_factory=DockerInfo)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def display(self) -> Optional[str]:
|
|
62
|
+
return self.env.get("DISPLAY") or None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Pure helpers
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
def normalize_arch(machine: str) -> Arch:
|
|
70
|
+
return _ARCH_ALIASES.get(machine.lower(), "unknown") # type: ignore[return-value]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def classify_os(system: str, release: str, wsl_interop: bool = False) -> HostOS:
|
|
74
|
+
s = system.lower()
|
|
75
|
+
if s == "darwin":
|
|
76
|
+
return "macos"
|
|
77
|
+
if s == "windows":
|
|
78
|
+
return "windows"
|
|
79
|
+
if s == "linux" and (wsl_interop or "microsoft" in release.lower()):
|
|
80
|
+
return "wsl"
|
|
81
|
+
return "linux"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_docker_info(info_json: str, cli_path: Optional[str] = None) -> DockerInfo:
|
|
85
|
+
"""Parse `docker info --format '{{json .}}'`."""
|
|
86
|
+
try:
|
|
87
|
+
d = json.loads(info_json)
|
|
88
|
+
except json.JSONDecodeError as e:
|
|
89
|
+
return DockerInfo(cli_path=cli_path, error=f"bad docker info output: {e}")
|
|
90
|
+
if d.get("ServerErrors"):
|
|
91
|
+
return DockerInfo(cli_path=cli_path, error="; ".join(d["ServerErrors"]))
|
|
92
|
+
return DockerInfo(
|
|
93
|
+
cli_path=cli_path,
|
|
94
|
+
server_reachable=True,
|
|
95
|
+
server_version=d.get("ServerVersion"),
|
|
96
|
+
operating_system=d.get("OperatingSystem"),
|
|
97
|
+
arch=normalize_arch(d.get("Architecture", "")),
|
|
98
|
+
runtimes=sorted((d.get("Runtimes") or {}).keys()),
|
|
99
|
+
mem_total_mb=int(d["MemTotal"]) // (1024 * 1024) if d.get("MemTotal") else None,
|
|
100
|
+
cpus=d.get("NCPU"),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Probes
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def detect_docker(docker: str = "docker", timeout: float = 10.0) -> DockerInfo:
|
|
109
|
+
cli = shutil.which(docker)
|
|
110
|
+
if not cli:
|
|
111
|
+
return DockerInfo(error="docker CLI not found on PATH")
|
|
112
|
+
try:
|
|
113
|
+
out = subprocess.run([cli, "info", "--format", "{{json .}}"],
|
|
114
|
+
capture_output=True, text=True, timeout=timeout, check=False)
|
|
115
|
+
except subprocess.TimeoutExpired:
|
|
116
|
+
return DockerInfo(cli_path=cli, error="`docker info` timed out (daemon starting?)")
|
|
117
|
+
except OSError as e:
|
|
118
|
+
return DockerInfo(cli_path=cli, error=str(e))
|
|
119
|
+
if out.returncode != 0 or not out.stdout.strip().startswith("{"):
|
|
120
|
+
msg = (out.stderr or out.stdout).strip().splitlines()
|
|
121
|
+
return DockerInfo(cli_path=cli, error=msg[-1] if msg else "docker daemon not reachable")
|
|
122
|
+
return parse_docker_info(out.stdout, cli)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def detect(docker: str = "docker", probe_docker: bool = True) -> HostPlatform:
|
|
126
|
+
host_os = classify_os(_platform.system(), _platform.release(),
|
|
127
|
+
wsl_interop=os.path.exists("/proc/sys/fs/binfmt_misc/WSLInterop"))
|
|
128
|
+
env = {k: os.environ[k] for k in ("DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR")
|
|
129
|
+
if os.environ.get(k)}
|
|
130
|
+
return HostPlatform(
|
|
131
|
+
os=host_os,
|
|
132
|
+
arch=normalize_arch(_platform.machine()),
|
|
133
|
+
env=env,
|
|
134
|
+
has_wslg=Path("/mnt/wslg").is_dir(),
|
|
135
|
+
has_x11_socket_dir=Path("/tmp/.X11-unix").is_dir(),
|
|
136
|
+
docker=detect_docker(docker) if probe_docker else DockerInfo(),
|
|
137
|
+
)
|