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.
Files changed (84) hide show
  1. devagent_physical_engine/__init__.py +44 -0
  2. devagent_physical_engine/agent/__init__.py +40 -0
  3. devagent_physical_engine/agent/compiler.py +285 -0
  4. devagent_physical_engine/agent/contracts.py +129 -0
  5. devagent_physical_engine/agent/coordinator.py +108 -0
  6. devagent_physical_engine/agent/critic.py +72 -0
  7. devagent_physical_engine/agent/evidence.py +34 -0
  8. devagent_physical_engine/agent/interpreter.py +179 -0
  9. devagent_physical_engine/agent/planner.py +105 -0
  10. devagent_physical_engine/agent/recovery.py +54 -0
  11. devagent_physical_engine/agent/routing.py +76 -0
  12. devagent_physical_engine/agent/runtime.py +270 -0
  13. devagent_physical_engine/agent/semantic.py +304 -0
  14. devagent_physical_engine/agent/structured.py +423 -0
  15. devagent_physical_engine/ai_cli.py +226 -0
  16. devagent_physical_engine/cli.py +392 -0
  17. devagent_physical_engine/doctor.py +20 -0
  18. devagent_physical_engine/engineering_agent.py +243 -0
  19. devagent_physical_engine/engineering_request.py +630 -0
  20. devagent_physical_engine/execution.py +90 -0
  21. devagent_physical_engine/models.py +143 -0
  22. devagent_physical_engine/operating_envelope.py +120 -0
  23. devagent_physical_engine/optimization/__init__.py +50 -0
  24. devagent_physical_engine/optimization/benchmark.py +122 -0
  25. devagent_physical_engine/optimization/candidates.py +198 -0
  26. devagent_physical_engine/optimization/contracts.py +235 -0
  27. devagent_physical_engine/optimization/evaluator.py +107 -0
  28. devagent_physical_engine/optimization/evidence.py +53 -0
  29. devagent_physical_engine/optimization/experience.py +105 -0
  30. devagent_physical_engine/optimization/measured.py +125 -0
  31. devagent_physical_engine/optimization/optimizer.py +215 -0
  32. devagent_physical_engine/optimization/orchestrator.py +155 -0
  33. devagent_physical_engine/physical_campaign.py +413 -0
  34. devagent_physical_engine/physical_evidence.py +214 -0
  35. devagent_physical_engine/physical_motion.py +196 -0
  36. devagent_physical_engine/planning.py +80 -0
  37. devagent_physical_engine/preexecution_contract.py +65 -0
  38. devagent_physical_engine/provider_adapters/__init__.py +22 -0
  39. devagent_physical_engine/provider_adapters/anthropic.py +112 -0
  40. devagent_physical_engine/provider_adapters/common.py +187 -0
  41. devagent_physical_engine/provider_adapters/factory.py +20 -0
  42. devagent_physical_engine/provider_adapters/gemini.py +126 -0
  43. devagent_physical_engine/provider_adapters/openai.py +95 -0
  44. devagent_physical_engine/provider_qualification.py +268 -0
  45. devagent_physical_engine/providers.py +94 -0
  46. devagent_physical_engine/qualification.py +44 -0
  47. devagent_physical_engine/qualification_cli.py +195 -0
  48. devagent_physical_engine/qualification_harness.py +917 -0
  49. devagent_physical_engine/robot_platform.py +411 -0
  50. devagent_physical_engine/robots.py +76 -0
  51. devagent_physical_engine/ros2/__init__.py +35 -0
  52. devagent_physical_engine/ros2/acceptance.py +324 -0
  53. devagent_physical_engine/ros2/commands.py +175 -0
  54. devagent_physical_engine/ros2/doctor.py +116 -0
  55. devagent_physical_engine/ros2/fk_probe.py +83 -0
  56. devagent_physical_engine/ros2/frame_alignment.py +61 -0
  57. devagent_physical_engine/ros2/gazebo_world.py +125 -0
  58. devagent_physical_engine/ros2/joint_state_recorder.py +64 -0
  59. devagent_physical_engine/ros2/measured_motion.py +233 -0
  60. devagent_physical_engine/ros2/moveit_scene.py +121 -0
  61. devagent_physical_engine/ros2/preexecution.py +113 -0
  62. devagent_physical_engine/ros2/qualification.py +81 -0
  63. devagent_physical_engine/ros2/qualification_v10.py +252 -0
  64. devagent_physical_engine/ros2/scene_probe.py +219 -0
  65. devagent_physical_engine/ros2/state_validity_probe.py +125 -0
  66. devagent_physical_engine/ros2/tf_probe.py +51 -0
  67. devagent_physical_engine/ros2/trajectory.py +188 -0
  68. devagent_physical_engine/ros2/ur5e.py +59 -0
  69. devagent_physical_engine/ros2/ur5e_adapter.py +349 -0
  70. devagent_physical_engine/ros2/ur5e_v10_adapter.py +292 -0
  71. devagent_physical_engine/setup_profile.py +356 -0
  72. devagent_physical_engine/simulation.py +32 -0
  73. devagent_physical_engine/simulation_platform.py +269 -0
  74. devagent_physical_engine/trajectory_qualification.py +201 -0
  75. devagent_physical_engine/twin.py +939 -0
  76. devagent_physical_engine/twin_builder.py +309 -0
  77. devagent_physical_engine/twin_materialization.py +404 -0
  78. devagent_physical_engine/verification.py +46 -0
  79. devagent_physical_engine-0.10.0.dist-info/METADATA +315 -0
  80. devagent_physical_engine-0.10.0.dist-info/RECORD +84 -0
  81. devagent_physical_engine-0.10.0.dist-info/WHEEL +5 -0
  82. devagent_physical_engine-0.10.0.dist-info/entry_points.txt +3 -0
  83. devagent_physical_engine-0.10.0.dist-info/licenses/NOTICE +2 -0
  84. 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())