devagent-physical-engine 0.10.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.
- devagent_physical_engine/__init__.py +44 -0
- devagent_physical_engine/agent/__init__.py +40 -0
- devagent_physical_engine/agent/compiler.py +285 -0
- devagent_physical_engine/agent/contracts.py +129 -0
- devagent_physical_engine/agent/coordinator.py +108 -0
- devagent_physical_engine/agent/critic.py +72 -0
- devagent_physical_engine/agent/evidence.py +34 -0
- devagent_physical_engine/agent/interpreter.py +179 -0
- devagent_physical_engine/agent/planner.py +105 -0
- devagent_physical_engine/agent/recovery.py +54 -0
- devagent_physical_engine/agent/routing.py +76 -0
- devagent_physical_engine/agent/runtime.py +270 -0
- devagent_physical_engine/agent/semantic.py +304 -0
- devagent_physical_engine/agent/structured.py +423 -0
- devagent_physical_engine/ai_cli.py +226 -0
- devagent_physical_engine/cli.py +392 -0
- devagent_physical_engine/doctor.py +20 -0
- devagent_physical_engine/engineering_agent.py +243 -0
- devagent_physical_engine/engineering_request.py +630 -0
- devagent_physical_engine/execution.py +90 -0
- devagent_physical_engine/models.py +143 -0
- devagent_physical_engine/operating_envelope.py +120 -0
- devagent_physical_engine/optimization/__init__.py +50 -0
- devagent_physical_engine/optimization/benchmark.py +122 -0
- devagent_physical_engine/optimization/candidates.py +198 -0
- devagent_physical_engine/optimization/contracts.py +235 -0
- devagent_physical_engine/optimization/evaluator.py +107 -0
- devagent_physical_engine/optimization/evidence.py +53 -0
- devagent_physical_engine/optimization/experience.py +105 -0
- devagent_physical_engine/optimization/measured.py +125 -0
- devagent_physical_engine/optimization/optimizer.py +215 -0
- devagent_physical_engine/optimization/orchestrator.py +155 -0
- devagent_physical_engine/physical_campaign.py +413 -0
- devagent_physical_engine/physical_evidence.py +214 -0
- devagent_physical_engine/physical_motion.py +196 -0
- devagent_physical_engine/planning.py +80 -0
- devagent_physical_engine/preexecution_contract.py +65 -0
- devagent_physical_engine/provider_adapters/__init__.py +22 -0
- devagent_physical_engine/provider_adapters/anthropic.py +112 -0
- devagent_physical_engine/provider_adapters/common.py +187 -0
- devagent_physical_engine/provider_adapters/factory.py +20 -0
- devagent_physical_engine/provider_adapters/gemini.py +126 -0
- devagent_physical_engine/provider_adapters/openai.py +95 -0
- devagent_physical_engine/provider_qualification.py +268 -0
- devagent_physical_engine/providers.py +94 -0
- devagent_physical_engine/qualification.py +44 -0
- devagent_physical_engine/qualification_cli.py +195 -0
- devagent_physical_engine/qualification_harness.py +917 -0
- devagent_physical_engine/robot_platform.py +411 -0
- devagent_physical_engine/robots.py +76 -0
- devagent_physical_engine/ros2/__init__.py +35 -0
- devagent_physical_engine/ros2/acceptance.py +324 -0
- devagent_physical_engine/ros2/commands.py +175 -0
- devagent_physical_engine/ros2/doctor.py +116 -0
- devagent_physical_engine/ros2/fk_probe.py +83 -0
- devagent_physical_engine/ros2/frame_alignment.py +61 -0
- devagent_physical_engine/ros2/gazebo_world.py +125 -0
- devagent_physical_engine/ros2/joint_state_recorder.py +64 -0
- devagent_physical_engine/ros2/measured_motion.py +233 -0
- devagent_physical_engine/ros2/moveit_scene.py +121 -0
- devagent_physical_engine/ros2/preexecution.py +113 -0
- devagent_physical_engine/ros2/qualification.py +81 -0
- devagent_physical_engine/ros2/qualification_v10.py +252 -0
- devagent_physical_engine/ros2/scene_probe.py +219 -0
- devagent_physical_engine/ros2/state_validity_probe.py +125 -0
- devagent_physical_engine/ros2/tf_probe.py +51 -0
- devagent_physical_engine/ros2/trajectory.py +188 -0
- devagent_physical_engine/ros2/ur5e.py +59 -0
- devagent_physical_engine/ros2/ur5e_adapter.py +349 -0
- devagent_physical_engine/ros2/ur5e_v10_adapter.py +292 -0
- devagent_physical_engine/setup_profile.py +356 -0
- devagent_physical_engine/simulation.py +32 -0
- devagent_physical_engine/simulation_platform.py +269 -0
- devagent_physical_engine/trajectory_qualification.py +201 -0
- devagent_physical_engine/twin.py +939 -0
- devagent_physical_engine/twin_builder.py +309 -0
- devagent_physical_engine/twin_materialization.py +404 -0
- devagent_physical_engine/verification.py +46 -0
- devagent_physical_engine-0.10.0.dist-info/METADATA +315 -0
- devagent_physical_engine-0.10.0.dist-info/RECORD +84 -0
- devagent_physical_engine-0.10.0.dist-info/WHEEL +5 -0
- devagent_physical_engine-0.10.0.dist-info/entry_points.txt +3 -0
- devagent_physical_engine-0.10.0.dist-info/licenses/NOTICE +2 -0
- devagent_physical_engine-0.10.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from time import monotonic, sleep
|
|
10
|
+
from typing import Mapping
|
|
11
|
+
|
|
12
|
+
from .commands import CommandResult, RosCommandRunner
|
|
13
|
+
from .doctor import RosDoctor
|
|
14
|
+
from .ur5e import UR5eSimulationLauncher, official_ur_motion_smoke_command
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class AcceptanceStage:
|
|
19
|
+
name: str
|
|
20
|
+
passed: bool
|
|
21
|
+
detail: str
|
|
22
|
+
duration_s: float = 0.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class LaptopAcceptanceReport:
|
|
27
|
+
started_at: str
|
|
28
|
+
robot: str
|
|
29
|
+
stages: tuple[AcceptanceStage, ...]
|
|
30
|
+
simulation_log: str
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def passed(self) -> bool:
|
|
34
|
+
return all(stage.passed for stage in self.stages)
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict:
|
|
37
|
+
return {
|
|
38
|
+
"started_at": self.started_at,
|
|
39
|
+
"robot": self.robot,
|
|
40
|
+
"passed": self.passed,
|
|
41
|
+
"simulation_log": self.simulation_log,
|
|
42
|
+
"stages": [asdict(stage) for stage in self.stages],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def write_json(self, path: Path) -> None:
|
|
46
|
+
target = path.expanduser().resolve()
|
|
47
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
target.write_text(json.dumps(self.to_dict(), indent=2) + "\n", encoding="utf-8")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class UR5eLaptopAcceptance:
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
runner: RosCommandRunner,
|
|
55
|
+
*,
|
|
56
|
+
ros_setup: Path | str = "/opt/ros/jazzy/setup.bash",
|
|
57
|
+
os_release_path: Path | str = "/etc/os-release",
|
|
58
|
+
environment: Mapping[str, str] | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.runner = runner
|
|
61
|
+
self.ros_setup = Path(ros_setup)
|
|
62
|
+
self.os_release_path = Path(os_release_path)
|
|
63
|
+
self.environment = dict(os.environ if environment is None else environment)
|
|
64
|
+
|
|
65
|
+
def _display_stage(self) -> AcceptanceStage:
|
|
66
|
+
display = self.environment.get("DISPLAY", "").strip()
|
|
67
|
+
wayland = self.environment.get("WAYLAND_DISPLAY", "").strip()
|
|
68
|
+
available = bool(display or wayland)
|
|
69
|
+
if display:
|
|
70
|
+
detail = f"DISPLAY={display}"
|
|
71
|
+
elif wayland:
|
|
72
|
+
detail = f"WAYLAND_DISPLAY={wayland}"
|
|
73
|
+
else:
|
|
74
|
+
detail = "no graphical display environment"
|
|
75
|
+
return AcceptanceStage("visual_display", available, detail)
|
|
76
|
+
|
|
77
|
+
def _joint_positions(self) -> tuple[float, ...]:
|
|
78
|
+
result = self.runner.run(
|
|
79
|
+
[
|
|
80
|
+
"ros2",
|
|
81
|
+
"topic",
|
|
82
|
+
"echo",
|
|
83
|
+
"/joint_states",
|
|
84
|
+
"--once",
|
|
85
|
+
"--field",
|
|
86
|
+
"position",
|
|
87
|
+
],
|
|
88
|
+
timeout_s=8.0,
|
|
89
|
+
)
|
|
90
|
+
if not result.ok:
|
|
91
|
+
raise RuntimeError(result.stderr.strip() or "joint_state_snapshot_failed")
|
|
92
|
+
values = tuple(
|
|
93
|
+
float(match)
|
|
94
|
+
for match in re.findall(
|
|
95
|
+
r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?",
|
|
96
|
+
result.stdout,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
if len(values) < 6:
|
|
100
|
+
raise RuntimeError(f"joint_state_snapshot_invalid:{len(values)}")
|
|
101
|
+
return values[:6]
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _motion_observed(
|
|
105
|
+
before: tuple[float, ...], after: tuple[float, ...]
|
|
106
|
+
) -> tuple[bool, str]:
|
|
107
|
+
if len(before) != len(after):
|
|
108
|
+
return False, "joint_vector_size_changed"
|
|
109
|
+
deltas = tuple(abs(a - b) for a, b in zip(before, after))
|
|
110
|
+
max_delta = max(deltas, default=0.0)
|
|
111
|
+
return max_delta >= 0.01, f"max_joint_delta_rad={max_delta:.6f}"
|
|
112
|
+
|
|
113
|
+
def _ready_probe(self) -> tuple[bool, str]:
|
|
114
|
+
controllers = self.runner.run(
|
|
115
|
+
["ros2", "control", "list_controllers"], timeout_s=5.0
|
|
116
|
+
)
|
|
117
|
+
if not controllers.ok:
|
|
118
|
+
return False, controllers.stderr.strip() or "controller_manager_unavailable"
|
|
119
|
+
controller_active = any(
|
|
120
|
+
"joint_trajectory_controller" in line.lower()
|
|
121
|
+
and line.split()
|
|
122
|
+
and line.split()[-1].lower() == "active"
|
|
123
|
+
for line in controllers.stdout.splitlines()
|
|
124
|
+
)
|
|
125
|
+
if not controller_active:
|
|
126
|
+
return False, "joint_trajectory_controller_not_active"
|
|
127
|
+
|
|
128
|
+
topics = self.runner.run(["ros2", "topic", "list"], timeout_s=5.0)
|
|
129
|
+
if not topics.ok:
|
|
130
|
+
return False, topics.stderr.strip() or "topic_list_failed"
|
|
131
|
+
if "/joint_states" not in {line.strip() for line in topics.stdout.splitlines()}:
|
|
132
|
+
return False, "joint_states_missing"
|
|
133
|
+
return True, "controller active; /joint_states present"
|
|
134
|
+
|
|
135
|
+
def _wait_ready(self, simulation, *, timeout_s: float) -> AcceptanceStage:
|
|
136
|
+
started = monotonic()
|
|
137
|
+
last_detail = "not_ready"
|
|
138
|
+
while monotonic() - started < timeout_s:
|
|
139
|
+
if not simulation.running:
|
|
140
|
+
return AcceptanceStage(
|
|
141
|
+
"simulation_ready",
|
|
142
|
+
False,
|
|
143
|
+
"simulation_process_exited_early",
|
|
144
|
+
monotonic() - started,
|
|
145
|
+
)
|
|
146
|
+
try:
|
|
147
|
+
ready, last_detail = self._ready_probe()
|
|
148
|
+
except Exception as exc: # environment may still be starting
|
|
149
|
+
last_detail = f"probe_error:{type(exc).__name__}"
|
|
150
|
+
ready = False
|
|
151
|
+
if ready:
|
|
152
|
+
return AcceptanceStage(
|
|
153
|
+
"simulation_ready",
|
|
154
|
+
True,
|
|
155
|
+
last_detail,
|
|
156
|
+
monotonic() - started,
|
|
157
|
+
)
|
|
158
|
+
sleep(2.0)
|
|
159
|
+
return AcceptanceStage(
|
|
160
|
+
"simulation_ready",
|
|
161
|
+
False,
|
|
162
|
+
f"startup_timeout:{last_detail}",
|
|
163
|
+
monotonic() - started,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def run(
|
|
167
|
+
self,
|
|
168
|
+
*,
|
|
169
|
+
log_dir: Path,
|
|
170
|
+
startup_timeout_s: float = 120.0,
|
|
171
|
+
motion_timeout_s: float = 90.0,
|
|
172
|
+
keep_open: bool = False,
|
|
173
|
+
) -> LaptopAcceptanceReport:
|
|
174
|
+
if startup_timeout_s <= 0 or motion_timeout_s <= 0:
|
|
175
|
+
raise ValueError("acceptance_timeouts_must_be_positive")
|
|
176
|
+
|
|
177
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
178
|
+
root = log_dir.expanduser().resolve()
|
|
179
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
sim_log = root / f"ur5e-gazebo-{timestamp}.log"
|
|
181
|
+
stages: list[AcceptanceStage] = []
|
|
182
|
+
|
|
183
|
+
display_stage = self._display_stage()
|
|
184
|
+
stages.append(display_stage)
|
|
185
|
+
if not display_stage.passed:
|
|
186
|
+
return LaptopAcceptanceReport(timestamp, "ur5e", tuple(stages), str(sim_log))
|
|
187
|
+
|
|
188
|
+
doctor_started = monotonic()
|
|
189
|
+
doctor = RosDoctor(
|
|
190
|
+
self.runner,
|
|
191
|
+
ros_setup=self.ros_setup,
|
|
192
|
+
os_release_path=self.os_release_path,
|
|
193
|
+
).run()
|
|
194
|
+
stages.append(
|
|
195
|
+
AcceptanceStage(
|
|
196
|
+
"ros_doctor",
|
|
197
|
+
doctor.passed,
|
|
198
|
+
(
|
|
199
|
+
"all required checks passed"
|
|
200
|
+
if doctor.passed
|
|
201
|
+
else "; ".join(
|
|
202
|
+
f"{check.name}:{check.detail}" for check in doctor.failures
|
|
203
|
+
)
|
|
204
|
+
),
|
|
205
|
+
monotonic() - doctor_started,
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
if not doctor.passed:
|
|
209
|
+
return LaptopAcceptanceReport(timestamp, "ur5e", tuple(stages), str(sim_log))
|
|
210
|
+
|
|
211
|
+
launcher = UR5eSimulationLauncher(self.runner)
|
|
212
|
+
launch_started = monotonic()
|
|
213
|
+
simulation = launcher.launch(log_path=sim_log)
|
|
214
|
+
stages.append(
|
|
215
|
+
AcceptanceStage(
|
|
216
|
+
"simulation_launch",
|
|
217
|
+
simulation.running,
|
|
218
|
+
"process started",
|
|
219
|
+
monotonic() - launch_started,
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
if not simulation.running:
|
|
223
|
+
return LaptopAcceptanceReport(timestamp, "ur5e", tuple(stages), str(sim_log))
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
ready = self._wait_ready(simulation, timeout_s=startup_timeout_s)
|
|
227
|
+
stages.append(ready)
|
|
228
|
+
if not ready.passed:
|
|
229
|
+
return LaptopAcceptanceReport(timestamp, "ur5e", tuple(stages), str(sim_log))
|
|
230
|
+
|
|
231
|
+
snapshot_started = monotonic()
|
|
232
|
+
before: tuple[float, ...] | None = None
|
|
233
|
+
try:
|
|
234
|
+
before = self._joint_positions()
|
|
235
|
+
stages.append(
|
|
236
|
+
AcceptanceStage(
|
|
237
|
+
"pre_motion_joint_state",
|
|
238
|
+
True,
|
|
239
|
+
"captured 6 joint positions",
|
|
240
|
+
monotonic() - snapshot_started,
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
stages.append(
|
|
245
|
+
AcceptanceStage(
|
|
246
|
+
"pre_motion_joint_state",
|
|
247
|
+
False,
|
|
248
|
+
f"{type(exc).__name__}:{exc}",
|
|
249
|
+
monotonic() - snapshot_started,
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if stages[-1].passed:
|
|
254
|
+
motion_started = monotonic()
|
|
255
|
+
try:
|
|
256
|
+
motion: CommandResult = self.runner.run(
|
|
257
|
+
official_ur_motion_smoke_command(),
|
|
258
|
+
timeout_s=motion_timeout_s,
|
|
259
|
+
)
|
|
260
|
+
detail = (
|
|
261
|
+
motion.stdout.strip()[-1000:]
|
|
262
|
+
if motion.ok
|
|
263
|
+
else (
|
|
264
|
+
motion.stderr.strip()[-1000:] or "motion_smoke_failed"
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
stages.append(
|
|
268
|
+
AcceptanceStage(
|
|
269
|
+
"ur5e_motion_smoke",
|
|
270
|
+
motion.ok,
|
|
271
|
+
detail,
|
|
272
|
+
monotonic() - motion_started,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
except Exception as exc:
|
|
276
|
+
stages.append(
|
|
277
|
+
AcceptanceStage(
|
|
278
|
+
"ur5e_motion_smoke",
|
|
279
|
+
False,
|
|
280
|
+
f"{type(exc).__name__}:{exc}",
|
|
281
|
+
monotonic() - motion_started,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if stages[-1].passed and before is not None:
|
|
286
|
+
observed_started = monotonic()
|
|
287
|
+
try:
|
|
288
|
+
after = self._joint_positions()
|
|
289
|
+
moved, motion_detail = self._motion_observed(before, after)
|
|
290
|
+
stages.append(
|
|
291
|
+
AcceptanceStage(
|
|
292
|
+
"joint_motion_observed",
|
|
293
|
+
moved,
|
|
294
|
+
motion_detail,
|
|
295
|
+
monotonic() - observed_started,
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
except Exception as exc:
|
|
299
|
+
stages.append(
|
|
300
|
+
AcceptanceStage(
|
|
301
|
+
"joint_motion_observed",
|
|
302
|
+
False,
|
|
303
|
+
f"{type(exc).__name__}:{exc}",
|
|
304
|
+
monotonic() - observed_started,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if stages[-1].passed:
|
|
309
|
+
probe_started = monotonic()
|
|
310
|
+
post_ok, post_detail = self._ready_probe()
|
|
311
|
+
stages.append(
|
|
312
|
+
AcceptanceStage(
|
|
313
|
+
"post_motion_health",
|
|
314
|
+
post_ok,
|
|
315
|
+
post_detail,
|
|
316
|
+
monotonic() - probe_started,
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
finally:
|
|
320
|
+
current_success = bool(stages) and all(stage.passed for stage in stages)
|
|
321
|
+
if not keep_open or not current_success:
|
|
322
|
+
simulation.stop()
|
|
323
|
+
|
|
324
|
+
return LaptopAcceptanceReport(timestamp, "ur5e", tuple(stages), str(sim_log))
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import signal
|
|
8
|
+
import subprocess
|
|
9
|
+
from time import monotonic
|
|
10
|
+
from typing import Protocol, Sequence, TextIO
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CommandExecutionError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class CommandResult:
|
|
19
|
+
command: tuple[str, ...]
|
|
20
|
+
returncode: int
|
|
21
|
+
stdout: str
|
|
22
|
+
stderr: str
|
|
23
|
+
duration_s: float
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def ok(self) -> bool:
|
|
27
|
+
return self.returncode == 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RunningProcess(Protocol):
|
|
31
|
+
pid: int
|
|
32
|
+
|
|
33
|
+
def poll(self) -> int | None: ...
|
|
34
|
+
def wait(self, timeout: float | None = None) -> int: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class RosCommandRunner(Protocol):
|
|
38
|
+
def run(self, command: Sequence[str], *, timeout_s: float = 15.0) -> CommandResult: ...
|
|
39
|
+
def start(self, command: Sequence[str], *, log_path: Path) -> RunningProcess: ...
|
|
40
|
+
def stop(self, process: RunningProcess, *, timeout_s: float = 10.0) -> None: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _validate_setup(path: Path) -> Path:
|
|
44
|
+
resolved = path.expanduser().resolve()
|
|
45
|
+
if not resolved.is_file():
|
|
46
|
+
raise FileNotFoundError(f"ros_setup_not_found:{resolved}")
|
|
47
|
+
return resolved
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _clean_ros_environment() -> dict[str, str]:
|
|
51
|
+
"""Return a ROS child environment isolated from Python virtual environments.
|
|
52
|
+
|
|
53
|
+
ROS executable scripts such as ``example_move.py`` commonly use
|
|
54
|
+
``#!/usr/bin/env python3``. If DevAgent is launched from an activated virtualenv,
|
|
55
|
+
inheriting that PATH can make ROS load the virtualenv interpreter while importing
|
|
56
|
+
ROS modules from /opt/ros, which then misses distro-managed dependencies such as
|
|
57
|
+
PyYAML or NumPy. Keep desktop/session variables, but remove Python environment
|
|
58
|
+
overrides and the active virtualenv's bin directory before sourcing ROS.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
environment = dict(os.environ)
|
|
62
|
+
virtual_env = environment.pop("VIRTUAL_ENV", "").strip()
|
|
63
|
+
environment.pop("PYTHONHOME", None)
|
|
64
|
+
environment.pop("PYTHONPATH", None)
|
|
65
|
+
|
|
66
|
+
path_entries = environment.get("PATH", "").split(os.pathsep)
|
|
67
|
+
if virtual_env:
|
|
68
|
+
virtual_bin = os.path.normpath(os.path.join(virtual_env, "bin"))
|
|
69
|
+
path_entries = [
|
|
70
|
+
entry
|
|
71
|
+
for entry in path_entries
|
|
72
|
+
if entry and os.path.normpath(entry) != virtual_bin
|
|
73
|
+
]
|
|
74
|
+
environment["PATH"] = os.pathsep.join(path_entries)
|
|
75
|
+
return environment
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SubprocessRosRunner:
|
|
79
|
+
"""Run ROS commands in a sourced, virtualenv-isolated environment."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
*,
|
|
84
|
+
ros_setup: Path | str = "/opt/ros/jazzy/setup.bash",
|
|
85
|
+
workspace_setup: Path | str | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
self.ros_setup = Path(ros_setup)
|
|
88
|
+
self.workspace_setup = Path(workspace_setup) if workspace_setup else None
|
|
89
|
+
self._logs: dict[int, TextIO] = {}
|
|
90
|
+
|
|
91
|
+
def _script(self, command: Sequence[str]) -> str:
|
|
92
|
+
if not command or any(not isinstance(part, str) or not part for part in command):
|
|
93
|
+
raise ValueError("command_parts_must_be_nonempty_strings")
|
|
94
|
+
setup = _validate_setup(self.ros_setup)
|
|
95
|
+
# ROS/ament setup scripts may legitimately reference variables before they are
|
|
96
|
+
# defined, so nounset (-u) is deliberately not enabled while sourcing them.
|
|
97
|
+
pieces = ["set -eo pipefail", f"source {shlex.quote(str(setup))}"]
|
|
98
|
+
if self.workspace_setup is not None:
|
|
99
|
+
workspace = _validate_setup(self.workspace_setup)
|
|
100
|
+
pieces.append(f"source {shlex.quote(str(workspace))}")
|
|
101
|
+
pieces.append("exec " + shlex.join(tuple(command)))
|
|
102
|
+
return "; ".join(pieces)
|
|
103
|
+
|
|
104
|
+
def run(self, command: Sequence[str], *, timeout_s: float = 15.0) -> CommandResult:
|
|
105
|
+
if timeout_s <= 0:
|
|
106
|
+
raise ValueError("timeout_s_must_be_positive")
|
|
107
|
+
script = self._script(command)
|
|
108
|
+
started = monotonic()
|
|
109
|
+
try:
|
|
110
|
+
completed = subprocess.run(
|
|
111
|
+
["bash", "--noprofile", "--norc", "-lc", script],
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
timeout=timeout_s,
|
|
115
|
+
check=False,
|
|
116
|
+
env=_clean_ros_environment(),
|
|
117
|
+
)
|
|
118
|
+
except subprocess.TimeoutExpired as exc:
|
|
119
|
+
raise CommandExecutionError(
|
|
120
|
+
f"command_timeout:{command[0]}:{timeout_s}s"
|
|
121
|
+
) from exc
|
|
122
|
+
return CommandResult(
|
|
123
|
+
tuple(command),
|
|
124
|
+
completed.returncode,
|
|
125
|
+
completed.stdout,
|
|
126
|
+
completed.stderr,
|
|
127
|
+
monotonic() - started,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def start(self, command: Sequence[str], *, log_path: Path) -> subprocess.Popen[str]:
|
|
131
|
+
script = self._script(command)
|
|
132
|
+
path = log_path.expanduser().resolve()
|
|
133
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
log = path.open("a", encoding="utf-8", buffering=1)
|
|
135
|
+
try:
|
|
136
|
+
process = subprocess.Popen(
|
|
137
|
+
["bash", "--noprofile", "--norc", "-lc", script],
|
|
138
|
+
stdout=log,
|
|
139
|
+
stderr=subprocess.STDOUT,
|
|
140
|
+
text=True,
|
|
141
|
+
start_new_session=True,
|
|
142
|
+
env=_clean_ros_environment(),
|
|
143
|
+
)
|
|
144
|
+
except Exception:
|
|
145
|
+
log.close()
|
|
146
|
+
raise
|
|
147
|
+
self._logs[process.pid] = log
|
|
148
|
+
return process
|
|
149
|
+
|
|
150
|
+
def stop(self, process: RunningProcess, *, timeout_s: float = 10.0) -> None:
|
|
151
|
+
try:
|
|
152
|
+
if process.poll() is None:
|
|
153
|
+
try:
|
|
154
|
+
os.killpg(process.pid, signal.SIGINT)
|
|
155
|
+
except ProcessLookupError:
|
|
156
|
+
pass
|
|
157
|
+
try:
|
|
158
|
+
process.wait(timeout=timeout_s)
|
|
159
|
+
except subprocess.TimeoutExpired:
|
|
160
|
+
try:
|
|
161
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
162
|
+
except ProcessLookupError:
|
|
163
|
+
pass
|
|
164
|
+
try:
|
|
165
|
+
process.wait(timeout=3.0)
|
|
166
|
+
except subprocess.TimeoutExpired:
|
|
167
|
+
try:
|
|
168
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
169
|
+
except ProcessLookupError:
|
|
170
|
+
pass
|
|
171
|
+
process.wait(timeout=3.0)
|
|
172
|
+
finally:
|
|
173
|
+
log = self._logs.pop(process.pid, None)
|
|
174
|
+
if log is not None:
|
|
175
|
+
log.close()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import platform
|
|
6
|
+
|
|
7
|
+
from .commands import RosCommandRunner
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class RosCheck:
|
|
12
|
+
name: str
|
|
13
|
+
passed: bool
|
|
14
|
+
detail: str
|
|
15
|
+
required: bool = True
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class RosDoctorReport:
|
|
20
|
+
checks: tuple[RosCheck, ...]
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def passed(self) -> bool:
|
|
24
|
+
return all(check.passed for check in self.checks if check.required)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def failures(self) -> tuple[RosCheck, ...]:
|
|
28
|
+
return tuple(check for check in self.checks if check.required and not check.passed)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
REQUIRED_PACKAGES = (
|
|
32
|
+
"ur_simulation_gz",
|
|
33
|
+
"ur_robot_driver",
|
|
34
|
+
"ur_moveit_config",
|
|
35
|
+
"controller_manager",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RosDoctor:
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
runner: RosCommandRunner,
|
|
43
|
+
*,
|
|
44
|
+
ros_setup: Path | str = "/opt/ros/jazzy/setup.bash",
|
|
45
|
+
os_release_path: Path | str = "/etc/os-release",
|
|
46
|
+
) -> None:
|
|
47
|
+
self.runner = runner
|
|
48
|
+
self.ros_setup = Path(ros_setup)
|
|
49
|
+
self.os_release_path = Path(os_release_path)
|
|
50
|
+
|
|
51
|
+
def _os_release(self) -> dict[str, str]:
|
|
52
|
+
values: dict[str, str] = {}
|
|
53
|
+
try:
|
|
54
|
+
text = self.os_release_path.read_text(encoding="utf-8")
|
|
55
|
+
except OSError:
|
|
56
|
+
return values
|
|
57
|
+
for line in text.splitlines():
|
|
58
|
+
if "=" not in line or line.lstrip().startswith("#"):
|
|
59
|
+
continue
|
|
60
|
+
key, value = line.split("=", 1)
|
|
61
|
+
values[key.strip()] = value.strip().strip('"')
|
|
62
|
+
return values
|
|
63
|
+
|
|
64
|
+
def run(self) -> RosDoctorReport:
|
|
65
|
+
checks: list[RosCheck] = []
|
|
66
|
+
checks.append(
|
|
67
|
+
RosCheck(
|
|
68
|
+
"linux",
|
|
69
|
+
platform.system() == "Linux",
|
|
70
|
+
platform.system(),
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
os_release = self._os_release()
|
|
74
|
+
checks.append(
|
|
75
|
+
RosCheck(
|
|
76
|
+
"ubuntu_24_04",
|
|
77
|
+
os_release.get("ID") == "ubuntu" and os_release.get("VERSION_ID") == "24.04",
|
|
78
|
+
f"{os_release.get('ID', 'unknown')} {os_release.get('VERSION_ID', 'unknown')}",
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
checks.append(
|
|
82
|
+
RosCheck(
|
|
83
|
+
"ros_setup",
|
|
84
|
+
self.ros_setup.expanduser().is_file(),
|
|
85
|
+
str(self.ros_setup),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
if not checks[-1].passed:
|
|
89
|
+
return RosDoctorReport(tuple(checks))
|
|
90
|
+
|
|
91
|
+
distro = self.runner.run(["bash", "-lc", "printf %s \"$ROS_DISTRO\""], timeout_s=5.0)
|
|
92
|
+
checks.append(
|
|
93
|
+
RosCheck(
|
|
94
|
+
"ros_distro_jazzy",
|
|
95
|
+
distro.ok and distro.stdout.strip() == "jazzy",
|
|
96
|
+
distro.stdout.strip() or distro.stderr.strip() or "unknown",
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
ros_cli = self.runner.run(["ros2", "--help"], timeout_s=5.0)
|
|
101
|
+
checks.append(RosCheck("ros2_cli", ros_cli.ok, "available" if ros_cli.ok else ros_cli.stderr.strip()))
|
|
102
|
+
|
|
103
|
+
gz = self.runner.run(["bash", "-lc", "command -v gz"], timeout_s=5.0)
|
|
104
|
+
checks.append(RosCheck("gazebo_gz", gz.ok and bool(gz.stdout.strip()), gz.stdout.strip() or "missing"))
|
|
105
|
+
|
|
106
|
+
for package in REQUIRED_PACKAGES:
|
|
107
|
+
result = self.runner.run(["ros2", "pkg", "prefix", package], timeout_s=5.0)
|
|
108
|
+
checks.append(
|
|
109
|
+
RosCheck(
|
|
110
|
+
f"package:{package}",
|
|
111
|
+
result.ok and bool(result.stdout.strip()),
|
|
112
|
+
result.stdout.strip() if result.ok else (result.stderr.strip() or "missing"),
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
return RosDoctorReport(tuple(checks))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from math import dist
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _wait(node, future, timeout_s: float) -> bool:
|
|
11
|
+
import rclpy
|
|
12
|
+
rclpy.spin_until_future_complete(node, future, timeout_sec=timeout_s)
|
|
13
|
+
return future.done()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def compute(path: Path, timeout_s: float) -> dict[str, Any]:
|
|
17
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
18
|
+
joint_names = list(payload["joint_names"])
|
|
19
|
+
samples = list(payload["samples"])
|
|
20
|
+
target = list(payload["target_positions_rad"])
|
|
21
|
+
planning_frame = str(payload["planning_frame"])
|
|
22
|
+
link_name = str(payload["link_name"])
|
|
23
|
+
|
|
24
|
+
import rclpy
|
|
25
|
+
from moveit_msgs.msg import MoveItErrorCodes
|
|
26
|
+
from moveit_msgs.srv import GetPositionFK
|
|
27
|
+
|
|
28
|
+
rclpy.init()
|
|
29
|
+
node = rclpy.create_node("devagent_fk_probe")
|
|
30
|
+
client = node.create_client(GetPositionFK, "/compute_fk")
|
|
31
|
+
try:
|
|
32
|
+
if not client.wait_for_service(timeout_sec=timeout_s):
|
|
33
|
+
return {"success": False, "code": "compute_fk_service_unavailable"}
|
|
34
|
+
|
|
35
|
+
def fk(positions: list[float]) -> tuple[float, float, float]:
|
|
36
|
+
request = GetPositionFK.Request()
|
|
37
|
+
request.header.frame_id = planning_frame
|
|
38
|
+
request.fk_link_names = [link_name]
|
|
39
|
+
request.robot_state.is_diff = True
|
|
40
|
+
request.robot_state.joint_state.name = joint_names
|
|
41
|
+
request.robot_state.joint_state.position = positions
|
|
42
|
+
future = client.call_async(request)
|
|
43
|
+
if not _wait(node, future, timeout_s):
|
|
44
|
+
raise RuntimeError("compute_fk_timeout")
|
|
45
|
+
response = future.result()
|
|
46
|
+
if response is None or response.error_code.val != MoveItErrorCodes.SUCCESS or not response.pose_stamped:
|
|
47
|
+
raise RuntimeError("compute_fk_failed")
|
|
48
|
+
pose = response.pose_stamped[0].pose.position
|
|
49
|
+
return float(pose.x), float(pose.y), float(pose.z)
|
|
50
|
+
|
|
51
|
+
positions = [fk([float(value) for value in sample["positions_rad"]]) for sample in samples]
|
|
52
|
+
target_position = fk([float(value) for value in target])
|
|
53
|
+
path_length = sum(dist(first, second) for first, second in zip(positions, positions[1:]))
|
|
54
|
+
final_error = dist(positions[-1], target_position) if positions else None
|
|
55
|
+
return {
|
|
56
|
+
"success": bool(positions),
|
|
57
|
+
"code": "moveit_fk_metrics_measured" if positions else "moveit_fk_samples_empty",
|
|
58
|
+
"path_length_m": path_length if positions else None,
|
|
59
|
+
"final_tcp_error_m": final_error,
|
|
60
|
+
"sample_count": len(positions),
|
|
61
|
+
"link_name": link_name,
|
|
62
|
+
"planning_frame": planning_frame,
|
|
63
|
+
}
|
|
64
|
+
finally:
|
|
65
|
+
node.destroy_node()
|
|
66
|
+
rclpy.shutdown()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main() -> int:
|
|
70
|
+
parser = argparse.ArgumentParser()
|
|
71
|
+
parser.add_argument("--input", required=True)
|
|
72
|
+
parser.add_argument("--timeout", type=float, default=10.0)
|
|
73
|
+
args = parser.parse_args()
|
|
74
|
+
try:
|
|
75
|
+
result = compute(Path(args.input).expanduser().resolve(), float(args.timeout))
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
result = {"success": False, "code": f"fk_probe_error:{type(exc).__name__}"}
|
|
78
|
+
print(json.dumps(result, sort_keys=True))
|
|
79
|
+
return 0 if result.get("success") else 2
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
raise SystemExit(main())
|