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,356 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import platform
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from typing import Callable, Protocol, Sequence
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
from urllib.request import Request, urlopen
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
UR5E_SIM_PROFILE = "ur5e-sim"
|
|
16
|
+
ROS_DISTRO = "jazzy"
|
|
17
|
+
QUALIFIED_UBUNTU_VERSION = "24.04"
|
|
18
|
+
QUALIFIED_UBUNTU_CODENAME = "noble"
|
|
19
|
+
ROS_SETUP = Path("/opt/ros/jazzy/setup.bash")
|
|
20
|
+
ROS_APT_RELEASE_API = "https://api.github.com/repos/ros-infrastructure/ros-apt-source/releases/latest"
|
|
21
|
+
|
|
22
|
+
UR5E_SIM_APT_PACKAGES: tuple[str, ...] = (
|
|
23
|
+
"ros-jazzy-desktop",
|
|
24
|
+
"ros-jazzy-moveit",
|
|
25
|
+
"ros-jazzy-ur",
|
|
26
|
+
"ros-jazzy-ur-simulation-gz",
|
|
27
|
+
"ros-jazzy-gz-ros2-control",
|
|
28
|
+
"ros-jazzy-ros2controlcli",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SetupError(RuntimeError):
|
|
33
|
+
"""Stable setup failure that is safe to surface to a user."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class SystemCommandResult:
|
|
38
|
+
command: tuple[str, ...]
|
|
39
|
+
returncode: int
|
|
40
|
+
stdout: str = ""
|
|
41
|
+
stderr: str = ""
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def ok(self) -> bool:
|
|
45
|
+
return self.returncode == 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SystemCommandRunner(Protocol):
|
|
49
|
+
def run(self, command: Sequence[str], *, timeout_s: float = 120.0) -> SystemCommandResult: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SubprocessSystemRunner:
|
|
53
|
+
"""Execute fixed setup commands without a shell.
|
|
54
|
+
|
|
55
|
+
Read-only probes are captured for deterministic inspection. When
|
|
56
|
+
``stream_mutations`` is enabled, apt/dpkg repository and package mutations inherit
|
|
57
|
+
the terminal so users see real installation progress instead of an apparently
|
|
58
|
+
frozen setup command.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
_STREAMED_PROGRAMS = frozenset({"apt-get", "dpkg", "add-apt-repository"})
|
|
62
|
+
|
|
63
|
+
def __init__(self, *, stream_mutations: bool = False) -> None:
|
|
64
|
+
self.stream_mutations = stream_mutations
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _program(command: Sequence[str]) -> str:
|
|
68
|
+
if command and command[0] == "sudo" and len(command) > 1:
|
|
69
|
+
return command[1]
|
|
70
|
+
return command[0] if command else ""
|
|
71
|
+
|
|
72
|
+
def run(self, command: Sequence[str], *, timeout_s: float = 120.0) -> SystemCommandResult:
|
|
73
|
+
if not command or any(not isinstance(part, str) or not part for part in command):
|
|
74
|
+
raise ValueError("setup_command_parts_invalid")
|
|
75
|
+
if timeout_s <= 0:
|
|
76
|
+
raise ValueError("setup_timeout_must_be_positive")
|
|
77
|
+
stream = self.stream_mutations and self._program(command) in self._STREAMED_PROGRAMS
|
|
78
|
+
try:
|
|
79
|
+
completed = subprocess.run(
|
|
80
|
+
list(command),
|
|
81
|
+
capture_output=not stream,
|
|
82
|
+
text=True,
|
|
83
|
+
timeout=timeout_s,
|
|
84
|
+
check=False,
|
|
85
|
+
)
|
|
86
|
+
except subprocess.TimeoutExpired as exc:
|
|
87
|
+
raise SetupError(f"setup_command_timeout:{command[0]}") from exc
|
|
88
|
+
return SystemCommandResult(
|
|
89
|
+
tuple(command),
|
|
90
|
+
completed.returncode,
|
|
91
|
+
completed.stdout or "",
|
|
92
|
+
completed.stderr or "",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True, slots=True)
|
|
97
|
+
class SetupCheck:
|
|
98
|
+
name: str
|
|
99
|
+
passed: bool
|
|
100
|
+
detail: str
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class SetupPlan:
|
|
105
|
+
profile: str
|
|
106
|
+
checks: tuple[SetupCheck, ...]
|
|
107
|
+
packages: tuple[str, ...]
|
|
108
|
+
needs_ros_apt_source: bool
|
|
109
|
+
needs_install: bool
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def supported(self) -> bool:
|
|
113
|
+
required = {"linux", "ubuntu_24_04", "architecture"}
|
|
114
|
+
by_name = {check.name: check for check in self.checks}
|
|
115
|
+
return required.issubset(by_name) and all(by_name[name].passed for name in required)
|
|
116
|
+
|
|
117
|
+
def to_dict(self) -> dict:
|
|
118
|
+
return {
|
|
119
|
+
"profile": self.profile,
|
|
120
|
+
"supported": self.supported,
|
|
121
|
+
"needs_ros_apt_source": self.needs_ros_apt_source,
|
|
122
|
+
"needs_install": self.needs_install,
|
|
123
|
+
"packages": list(self.packages),
|
|
124
|
+
"checks": [
|
|
125
|
+
{"name": check.name, "passed": check.passed, "detail": check.detail}
|
|
126
|
+
for check in self.checks
|
|
127
|
+
],
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True, slots=True)
|
|
132
|
+
class SetupResult:
|
|
133
|
+
plan: SetupPlan
|
|
134
|
+
changed_system: bool
|
|
135
|
+
verified: bool
|
|
136
|
+
failure_code: str | None = None
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def passed(self) -> bool:
|
|
140
|
+
return self.plan.supported and self.verified and self.failure_code is None
|
|
141
|
+
|
|
142
|
+
def to_dict(self) -> dict:
|
|
143
|
+
return {
|
|
144
|
+
"profile": self.plan.profile,
|
|
145
|
+
"passed": self.passed,
|
|
146
|
+
"changed_system": self.changed_system,
|
|
147
|
+
"verified": self.verified,
|
|
148
|
+
"failure_code": self.failure_code,
|
|
149
|
+
"plan": self.plan.to_dict(),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _read_os_release(path: Path) -> dict[str, str]:
|
|
154
|
+
values: dict[str, str] = {}
|
|
155
|
+
try:
|
|
156
|
+
text = path.read_text(encoding="utf-8")
|
|
157
|
+
except OSError:
|
|
158
|
+
return values
|
|
159
|
+
for line in text.splitlines():
|
|
160
|
+
if "=" not in line or line.lstrip().startswith("#"):
|
|
161
|
+
continue
|
|
162
|
+
key, value = line.split("=", 1)
|
|
163
|
+
values[key.strip()] = value.strip().strip('"')
|
|
164
|
+
return values
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _architecture_supported(machine: str) -> bool:
|
|
168
|
+
return machine.lower() in {"x86_64", "amd64", "aarch64", "arm64"}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _is_package_installed(runner: SystemCommandRunner, package: str) -> bool:
|
|
172
|
+
result = runner.run(["dpkg-query", "-W", "-f=${Status}", package], timeout_s=10.0)
|
|
173
|
+
return result.ok and result.stdout.strip() == "install ok installed"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _ros_apt_source_present(runner: SystemCommandRunner) -> bool:
|
|
177
|
+
return _is_package_installed(runner, "ros2-apt-source") or any(
|
|
178
|
+
path.exists()
|
|
179
|
+
for path in (
|
|
180
|
+
Path("/etc/apt/sources.list.d/ros2.sources"),
|
|
181
|
+
Path("/etc/apt/sources.list.d/ros2.list"),
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _safe_ros_apt_asset(payload: dict) -> tuple[str, str]:
|
|
187
|
+
expected_suffix = f".{QUALIFIED_UBUNTU_CODENAME}_all.deb"
|
|
188
|
+
matches: list[tuple[str, str]] = []
|
|
189
|
+
for asset in payload.get("assets", []):
|
|
190
|
+
name = str(asset.get("name", ""))
|
|
191
|
+
url = str(asset.get("browser_download_url", ""))
|
|
192
|
+
if not name.startswith("ros2-apt-source_") or not name.endswith(expected_suffix):
|
|
193
|
+
continue
|
|
194
|
+
parsed = urlparse(url)
|
|
195
|
+
if parsed.scheme != "https" or parsed.hostname not in {"github.com", "objects.githubusercontent.com"}:
|
|
196
|
+
continue
|
|
197
|
+
matches.append((name, url))
|
|
198
|
+
if len(matches) != 1:
|
|
199
|
+
raise SetupError("ros_apt_source_asset_unavailable")
|
|
200
|
+
return matches[0]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def download_latest_ros_apt_source(*, timeout_s: float = 30.0) -> Path:
|
|
204
|
+
headers = {"Accept": "application/vnd.github+json", "User-Agent": "devagent-physical-engine"}
|
|
205
|
+
try:
|
|
206
|
+
with urlopen(Request(ROS_APT_RELEASE_API, headers=headers), timeout=timeout_s) as response:
|
|
207
|
+
payload = json.loads(response.read().decode("utf-8"))
|
|
208
|
+
_name, url = _safe_ros_apt_asset(payload)
|
|
209
|
+
with urlopen(Request(url, headers={"User-Agent": "devagent-physical-engine"}), timeout=timeout_s) as response:
|
|
210
|
+
data = response.read()
|
|
211
|
+
except SetupError:
|
|
212
|
+
raise
|
|
213
|
+
except Exception as exc:
|
|
214
|
+
raise SetupError("ros_apt_source_download_failed") from exc
|
|
215
|
+
if not data or len(data) > 20 * 1024 * 1024:
|
|
216
|
+
raise SetupError("ros_apt_source_download_invalid")
|
|
217
|
+
fd, raw_path = tempfile.mkstemp(prefix="devagent-ros2-apt-source-", suffix=".deb")
|
|
218
|
+
path = Path(raw_path)
|
|
219
|
+
try:
|
|
220
|
+
with os.fdopen(fd, "wb") as handle:
|
|
221
|
+
handle.write(data)
|
|
222
|
+
except Exception:
|
|
223
|
+
path.unlink(missing_ok=True)
|
|
224
|
+
raise
|
|
225
|
+
return path
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class UR5eSimulationSetup:
|
|
229
|
+
def __init__(
|
|
230
|
+
self,
|
|
231
|
+
runner: SystemCommandRunner | None = None,
|
|
232
|
+
*,
|
|
233
|
+
os_release_path: Path | str = "/etc/os-release",
|
|
234
|
+
machine: str | None = None,
|
|
235
|
+
ros_setup: Path | str = ROS_SETUP,
|
|
236
|
+
downloader: Callable[[], Path] = download_latest_ros_apt_source,
|
|
237
|
+
verifier: Callable[[], bool] | None = None,
|
|
238
|
+
progress: Callable[[str], None] | None = None,
|
|
239
|
+
) -> None:
|
|
240
|
+
self.runner = runner or SubprocessSystemRunner()
|
|
241
|
+
self.os_release_path = Path(os_release_path)
|
|
242
|
+
self.machine = machine or platform.machine()
|
|
243
|
+
self.ros_setup = Path(ros_setup)
|
|
244
|
+
self.downloader = downloader
|
|
245
|
+
self.verifier = verifier
|
|
246
|
+
self.progress = progress
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def _sudo(self) -> tuple[str, ...]:
|
|
250
|
+
return () if hasattr(os, "geteuid") and os.geteuid() == 0 else ("sudo",)
|
|
251
|
+
|
|
252
|
+
def _emit(self, message: str) -> None:
|
|
253
|
+
if self.progress is not None:
|
|
254
|
+
self.progress(message)
|
|
255
|
+
|
|
256
|
+
def inspect(self) -> SetupPlan:
|
|
257
|
+
os_release = _read_os_release(self.os_release_path)
|
|
258
|
+
checks = [
|
|
259
|
+
SetupCheck("linux", platform.system() == "Linux", platform.system()),
|
|
260
|
+
SetupCheck(
|
|
261
|
+
"ubuntu_24_04",
|
|
262
|
+
os_release.get("ID") == "ubuntu" and os_release.get("VERSION_ID") == QUALIFIED_UBUNTU_VERSION,
|
|
263
|
+
f"{os_release.get('ID', 'unknown')} {os_release.get('VERSION_ID', 'unknown')}",
|
|
264
|
+
),
|
|
265
|
+
SetupCheck("architecture", _architecture_supported(self.machine), self.machine or "unknown"),
|
|
266
|
+
]
|
|
267
|
+
if not all(check.passed for check in checks):
|
|
268
|
+
return SetupPlan(UR5E_SIM_PROFILE, tuple(checks), UR5E_SIM_APT_PACKAGES, False, True)
|
|
269
|
+
|
|
270
|
+
ros_ready = self.ros_setup.expanduser().is_file()
|
|
271
|
+
checks.append(SetupCheck("ros_setup", ros_ready, str(self.ros_setup)))
|
|
272
|
+
installed: list[str] = []
|
|
273
|
+
for package in UR5E_SIM_APT_PACKAGES:
|
|
274
|
+
if _is_package_installed(self.runner, package):
|
|
275
|
+
installed.append(package)
|
|
276
|
+
missing = tuple(package for package in UR5E_SIM_APT_PACKAGES if package not in installed)
|
|
277
|
+
checks.append(
|
|
278
|
+
SetupCheck(
|
|
279
|
+
"system_packages",
|
|
280
|
+
not missing,
|
|
281
|
+
"all required packages installed" if not missing else "missing:" + ",".join(missing),
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
apt_source = _ros_apt_source_present(self.runner)
|
|
285
|
+
checks.append(SetupCheck("ros_apt_source", apt_source, "configured" if apt_source else "missing"))
|
|
286
|
+
return SetupPlan(
|
|
287
|
+
UR5E_SIM_PROFILE,
|
|
288
|
+
tuple(checks),
|
|
289
|
+
UR5E_SIM_APT_PACKAGES,
|
|
290
|
+
needs_ros_apt_source=not apt_source,
|
|
291
|
+
needs_install=(not ros_ready) or bool(missing),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
def _run_required(self, command: Sequence[str], *, timeout_s: float = 300.0) -> None:
|
|
295
|
+
result = self.runner.run(command, timeout_s=timeout_s)
|
|
296
|
+
if not result.ok:
|
|
297
|
+
raise SetupError(f"setup_command_failed:{command[-1] if command else 'unknown'}")
|
|
298
|
+
|
|
299
|
+
def install(self, plan: SetupPlan) -> SetupResult:
|
|
300
|
+
if not plan.supported:
|
|
301
|
+
return SetupResult(plan, changed_system=False, verified=False, failure_code="unsupported_platform")
|
|
302
|
+
if not plan.needs_install:
|
|
303
|
+
verified = self.verifier() if self.verifier is not None else True
|
|
304
|
+
return SetupResult(plan, changed_system=False, verified=verified, failure_code=None if verified else "post_install_verification_failed")
|
|
305
|
+
|
|
306
|
+
total_steps = 8 if plan.needs_ros_apt_source else 3
|
|
307
|
+
current_step = 0
|
|
308
|
+
|
|
309
|
+
def step(message: str) -> None:
|
|
310
|
+
nonlocal current_step
|
|
311
|
+
current_step += 1
|
|
312
|
+
self._emit(f"[{current_step}/{total_steps}] {message}")
|
|
313
|
+
|
|
314
|
+
downloaded: Path | None = None
|
|
315
|
+
try:
|
|
316
|
+
step("Updating apt indexes...")
|
|
317
|
+
self._run_required((*self._sudo, "apt-get", "update"))
|
|
318
|
+
if plan.needs_ros_apt_source:
|
|
319
|
+
step("Installing setup bootstrap packages...")
|
|
320
|
+
self._run_required((*self._sudo, "apt-get", "install", "-y", "software-properties-common", "curl"))
|
|
321
|
+
step("Enabling Ubuntu universe repository...")
|
|
322
|
+
self._run_required((*self._sudo, "add-apt-repository", "-y", "universe"))
|
|
323
|
+
step("Downloading official ROS 2 apt source...")
|
|
324
|
+
downloaded = self.downloader()
|
|
325
|
+
step("Installing official ROS 2 apt source...")
|
|
326
|
+
self._run_required((*self._sudo, "dpkg", "-i", str(downloaded)), timeout_s=120.0)
|
|
327
|
+
step("Refreshing ROS 2 apt indexes...")
|
|
328
|
+
self._run_required((*self._sudo, "apt-get", "update"))
|
|
329
|
+
step("Installing ROS 2 Jazzy, MoveIt, Gazebo and UR packages...")
|
|
330
|
+
self._run_required((*self._sudo, "apt-get", "install", "-y", *plan.packages), timeout_s=900.0)
|
|
331
|
+
step("Running DevAgent ROS verification...")
|
|
332
|
+
verified = self.verifier() if self.verifier is not None else self.ros_setup.expanduser().is_file()
|
|
333
|
+
return SetupResult(
|
|
334
|
+
plan,
|
|
335
|
+
changed_system=True,
|
|
336
|
+
verified=verified,
|
|
337
|
+
failure_code=None if verified else "post_install_verification_failed",
|
|
338
|
+
)
|
|
339
|
+
except SetupError as exc:
|
|
340
|
+
return SetupResult(plan, changed_system=True, verified=False, failure_code=str(exc))
|
|
341
|
+
finally:
|
|
342
|
+
if downloaded is not None:
|
|
343
|
+
downloaded.unlink(missing_ok=True)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def format_setup_plan(plan: SetupPlan) -> str:
|
|
347
|
+
lines = ["DevAgent UR5e Simulation Setup", f"Profile: {plan.profile}", ""]
|
|
348
|
+
for check in plan.checks:
|
|
349
|
+
lines.append(f"{'PASS' if check.passed else 'MISSING'} {check.name}: {check.detail}")
|
|
350
|
+
lines.extend(["", "Required system packages:"])
|
|
351
|
+
lines.extend(f" - {package}" for package in plan.packages)
|
|
352
|
+
if plan.needs_ros_apt_source:
|
|
353
|
+
lines.append(" - official ros2-apt-source repository configuration")
|
|
354
|
+
lines.append("")
|
|
355
|
+
lines.append("System changes required." if plan.needs_install else "System stack already installed.")
|
|
356
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from .models import TaskNode, WorldState
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class SimulationStep:
|
|
11
|
+
task_id: str
|
|
12
|
+
success: bool
|
|
13
|
+
satisfied_constraints: frozenset[str]
|
|
14
|
+
detail: str = ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SimulationBackend(Protocol):
|
|
18
|
+
def run_task(self, task: TaskNode, world: WorldState) -> SimulationStep: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DeterministicSimulationBackend:
|
|
22
|
+
"""Test backend with injected constraint failures; ROS/Gazebo will implement the same protocol."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, fail_constraints_for: set[str] | None = None) -> None:
|
|
25
|
+
self._fail_constraints_for = fail_constraints_for or set()
|
|
26
|
+
|
|
27
|
+
def run_task(self, task: TaskNode, world: WorldState) -> SimulationStep:
|
|
28
|
+
constraints = set(task.contract.constraints)
|
|
29
|
+
if task.task_id in self._fail_constraints_for:
|
|
30
|
+
constraints.discard("collision_free")
|
|
31
|
+
return SimulationStep(task.task_id, False, frozenset(constraints), "injected_constraint_failure")
|
|
32
|
+
return SimulationStep(task.task_id, True, frozenset(constraints))
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Mapping, Protocol
|
|
5
|
+
|
|
6
|
+
from .physical_motion import MotionExecutionMetrics, PhysicalMotionPlan
|
|
7
|
+
from .robot_platform import (
|
|
8
|
+
QualificationState,
|
|
9
|
+
RobotProfileRegistry,
|
|
10
|
+
SimulationTier,
|
|
11
|
+
)
|
|
12
|
+
from .twin import TwinSpec
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SimulationPlatformError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class AdapterHealth:
|
|
21
|
+
ready: bool
|
|
22
|
+
code: str = "ready"
|
|
23
|
+
details: tuple[str, ...] = ()
|
|
24
|
+
|
|
25
|
+
def __post_init__(self) -> None:
|
|
26
|
+
if not self.code.strip():
|
|
27
|
+
raise ValueError("adapter_health_code_required")
|
|
28
|
+
if any(not item.strip() for item in self.details):
|
|
29
|
+
raise ValueError("empty_adapter_health_detail")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class SimulationAdapterDescriptor:
|
|
34
|
+
adapter_id: str
|
|
35
|
+
backend: str
|
|
36
|
+
robot_profile_keys: tuple[str, ...]
|
|
37
|
+
tier: SimulationTier
|
|
38
|
+
qualification: QualificationState
|
|
39
|
+
visual_capable: bool
|
|
40
|
+
headless_capable: bool
|
|
41
|
+
replay_capable: bool
|
|
42
|
+
metric_names: frozenset[str] = frozenset()
|
|
43
|
+
notes: tuple[str, ...] = ()
|
|
44
|
+
trajectory_runtime_qualification: QualificationState = QualificationState.NOT_QUALIFIED
|
|
45
|
+
twin_environment_qualification: QualificationState = QualificationState.NOT_QUALIFIED
|
|
46
|
+
preexecution_verification_qualification: QualificationState = QualificationState.NOT_QUALIFIED
|
|
47
|
+
metric_collection_qualification: QualificationState = QualificationState.NOT_QUALIFIED
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if not self.adapter_id.strip() or not self.backend.strip():
|
|
51
|
+
raise ValueError("simulation_adapter_identity_required")
|
|
52
|
+
if not self.robot_profile_keys:
|
|
53
|
+
raise ValueError("simulation_adapter_robot_profiles_required")
|
|
54
|
+
if any(not key.strip() for key in self.robot_profile_keys):
|
|
55
|
+
raise ValueError("empty_simulation_adapter_robot_profile")
|
|
56
|
+
if len(set(self.robot_profile_keys)) != len(self.robot_profile_keys):
|
|
57
|
+
raise ValueError("duplicate_simulation_adapter_robot_profile")
|
|
58
|
+
if any(not metric.strip() for metric in self.metric_names):
|
|
59
|
+
raise ValueError("empty_simulation_metric_name")
|
|
60
|
+
if any(not note.strip() for note in self.notes):
|
|
61
|
+
raise ValueError("empty_simulation_adapter_note")
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def commissioning_scope_qualified(self) -> bool:
|
|
65
|
+
return all(
|
|
66
|
+
state is QualificationState.QUALIFIED
|
|
67
|
+
for state in (
|
|
68
|
+
self.qualification,
|
|
69
|
+
self.trajectory_runtime_qualification,
|
|
70
|
+
self.twin_environment_qualification,
|
|
71
|
+
self.preexecution_verification_qualification,
|
|
72
|
+
self.metric_collection_qualification,
|
|
73
|
+
)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class RobotSimulationAdapter(Protocol):
|
|
78
|
+
"""Universal simulation adapter consumed by measured physical campaigns.
|
|
79
|
+
|
|
80
|
+
High-level TaskGraph objects are deliberately not executable here. They must
|
|
81
|
+
first be compiled into a deterministic ``PhysicalMotionPlan``. An adapter is
|
|
82
|
+
commissioning-qualified only when trajectory execution, Twin materialization,
|
|
83
|
+
pre-execution verification, and metric collection are all separately qualified.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def descriptor(self) -> SimulationAdapterDescriptor: ...
|
|
88
|
+
|
|
89
|
+
def doctor(self, *, robot_profile_key: str) -> AdapterHealth: ...
|
|
90
|
+
|
|
91
|
+
def prepare(self, *, twin: TwinSpec) -> str: ...
|
|
92
|
+
|
|
93
|
+
def reset(self, *, case_id: str, state: Mapping[str, Any]) -> None: ...
|
|
94
|
+
|
|
95
|
+
def execute_motion(self, *, motion: PhysicalMotionPlan) -> MotionExecutionMetrics: ...
|
|
96
|
+
|
|
97
|
+
def collect_metrics(self) -> Mapping[str, float | int | bool | str | None]: ...
|
|
98
|
+
|
|
99
|
+
def capture_state(self) -> Mapping[str, Any]: ...
|
|
100
|
+
|
|
101
|
+
def replay(self, *, case_id: str) -> Mapping[str, Any]: ...
|
|
102
|
+
|
|
103
|
+
def stop(self) -> None: ...
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class SimulationSelection:
|
|
108
|
+
descriptor: SimulationAdapterDescriptor
|
|
109
|
+
robot_profile_key: str
|
|
110
|
+
profile_qualification: QualificationState
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def fully_qualified(self) -> bool:
|
|
114
|
+
return (
|
|
115
|
+
self.descriptor.qualification is QualificationState.QUALIFIED
|
|
116
|
+
and self.profile_qualification is QualificationState.QUALIFIED
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def commissioning_qualified(self) -> bool:
|
|
121
|
+
return (
|
|
122
|
+
self.profile_qualification is QualificationState.QUALIFIED
|
|
123
|
+
and self.descriptor.commissioning_scope_qualified
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SimulationAdapterRegistry:
|
|
128
|
+
"""Resolve simulator adapters without coupling DevAgent core to a vendor."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, robot_profiles: RobotProfileRegistry) -> None:
|
|
131
|
+
self.robot_profiles = robot_profiles
|
|
132
|
+
self._adapters: dict[str, RobotSimulationAdapter] = {}
|
|
133
|
+
|
|
134
|
+
def register(self, adapter: RobotSimulationAdapter, *, replace: bool = False) -> None:
|
|
135
|
+
descriptor = adapter.descriptor
|
|
136
|
+
adapter_id = descriptor.adapter_id.strip().lower()
|
|
137
|
+
if adapter_id in self._adapters and not replace:
|
|
138
|
+
raise SimulationPlatformError(f"duplicate_simulation_adapter:{adapter_id}")
|
|
139
|
+
for key in descriptor.robot_profile_keys:
|
|
140
|
+
profile = self.robot_profiles.get(key)
|
|
141
|
+
if profile.key != key:
|
|
142
|
+
raise SimulationPlatformError(
|
|
143
|
+
f"simulation_adapter_requires_canonical_robot_key:{key}"
|
|
144
|
+
)
|
|
145
|
+
self._adapters[adapter_id] = adapter
|
|
146
|
+
|
|
147
|
+
def get(self, adapter_id: str) -> RobotSimulationAdapter:
|
|
148
|
+
key = adapter_id.strip().lower()
|
|
149
|
+
try:
|
|
150
|
+
return self._adapters[key]
|
|
151
|
+
except KeyError as exc:
|
|
152
|
+
raise SimulationPlatformError(f"unknown_simulation_adapter:{key}") from exc
|
|
153
|
+
|
|
154
|
+
def descriptors(self) -> tuple[SimulationAdapterDescriptor, ...]:
|
|
155
|
+
return tuple(
|
|
156
|
+
self._adapters[key].descriptor for key in sorted(self._adapters)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def candidates(
|
|
160
|
+
self,
|
|
161
|
+
*,
|
|
162
|
+
robot_profile_key: str,
|
|
163
|
+
tier: SimulationTier,
|
|
164
|
+
require_qualified: bool = True,
|
|
165
|
+
require_commissioning_qualified: bool = False,
|
|
166
|
+
require_visual: bool = False,
|
|
167
|
+
require_replay: bool = False,
|
|
168
|
+
) -> tuple[SimulationSelection, ...]:
|
|
169
|
+
profile = self.robot_profiles.resolve(robot_profile_key)
|
|
170
|
+
canonical_key = profile.key
|
|
171
|
+
profile_state = profile.qualification.state_for(tier)
|
|
172
|
+
selections: list[SimulationSelection] = []
|
|
173
|
+
for descriptor in self.descriptors():
|
|
174
|
+
if canonical_key not in descriptor.robot_profile_keys:
|
|
175
|
+
continue
|
|
176
|
+
if descriptor.tier is not tier:
|
|
177
|
+
continue
|
|
178
|
+
if require_visual and not descriptor.visual_capable:
|
|
179
|
+
continue
|
|
180
|
+
if require_replay and not descriptor.replay_capable:
|
|
181
|
+
continue
|
|
182
|
+
selection = SimulationSelection(
|
|
183
|
+
descriptor=descriptor,
|
|
184
|
+
robot_profile_key=canonical_key,
|
|
185
|
+
profile_qualification=profile_state,
|
|
186
|
+
)
|
|
187
|
+
if require_commissioning_qualified and not selection.commissioning_qualified:
|
|
188
|
+
continue
|
|
189
|
+
if require_qualified and not selection.fully_qualified:
|
|
190
|
+
continue
|
|
191
|
+
selections.append(selection)
|
|
192
|
+
|
|
193
|
+
return tuple(
|
|
194
|
+
sorted(
|
|
195
|
+
selections,
|
|
196
|
+
key=lambda item: (
|
|
197
|
+
item.commissioning_qualified,
|
|
198
|
+
item.descriptor.qualification is QualificationState.QUALIFIED,
|
|
199
|
+
item.descriptor.replay_capable,
|
|
200
|
+
item.descriptor.headless_capable,
|
|
201
|
+
item.descriptor.adapter_id,
|
|
202
|
+
),
|
|
203
|
+
reverse=True,
|
|
204
|
+
)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def resolve(
|
|
208
|
+
self,
|
|
209
|
+
*,
|
|
210
|
+
robot_profile_key: str,
|
|
211
|
+
tier: SimulationTier,
|
|
212
|
+
require_qualified: bool = True,
|
|
213
|
+
require_commissioning_qualified: bool = False,
|
|
214
|
+
require_visual: bool = False,
|
|
215
|
+
require_replay: bool = False,
|
|
216
|
+
) -> SimulationSelection:
|
|
217
|
+
profile = self.robot_profiles.resolve(robot_profile_key)
|
|
218
|
+
selections = self.candidates(
|
|
219
|
+
robot_profile_key=profile.key,
|
|
220
|
+
tier=tier,
|
|
221
|
+
require_qualified=require_qualified,
|
|
222
|
+
require_commissioning_qualified=require_commissioning_qualified,
|
|
223
|
+
require_visual=require_visual,
|
|
224
|
+
require_replay=require_replay,
|
|
225
|
+
)
|
|
226
|
+
if selections:
|
|
227
|
+
return selections[0]
|
|
228
|
+
if require_commissioning_qualified:
|
|
229
|
+
mode = "commissioning_qualified"
|
|
230
|
+
else:
|
|
231
|
+
mode = "qualified" if require_qualified else "available"
|
|
232
|
+
raise SimulationPlatformError(
|
|
233
|
+
f"no_{mode}_simulation_adapter:{profile.key}:{tier.value}"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def capability_matrix(self) -> tuple[dict[str, Any], ...]:
|
|
237
|
+
rows: list[dict[str, Any]] = []
|
|
238
|
+
for profile in self.robot_profiles.all():
|
|
239
|
+
for tier in SimulationTier:
|
|
240
|
+
adapters = self.candidates(
|
|
241
|
+
robot_profile_key=profile.key,
|
|
242
|
+
tier=tier,
|
|
243
|
+
require_qualified=False,
|
|
244
|
+
)
|
|
245
|
+
rows.append(
|
|
246
|
+
{
|
|
247
|
+
"robot": profile.key,
|
|
248
|
+
"tier": tier.value,
|
|
249
|
+
"profile_qualification": profile.qualification.state_for(tier).value,
|
|
250
|
+
"adapters": [
|
|
251
|
+
{
|
|
252
|
+
"adapter_id": item.descriptor.adapter_id,
|
|
253
|
+
"backend": item.descriptor.backend,
|
|
254
|
+
"adapter_qualification": item.descriptor.qualification.value,
|
|
255
|
+
"fully_qualified": item.fully_qualified,
|
|
256
|
+
"commissioning_qualified": item.commissioning_qualified,
|
|
257
|
+
"trajectory_runtime": item.descriptor.trajectory_runtime_qualification.value,
|
|
258
|
+
"twin_environment": item.descriptor.twin_environment_qualification.value,
|
|
259
|
+
"preexecution_verification": item.descriptor.preexecution_verification_qualification.value,
|
|
260
|
+
"metric_collection": item.descriptor.metric_collection_qualification.value,
|
|
261
|
+
"visual": item.descriptor.visual_capable,
|
|
262
|
+
"headless": item.descriptor.headless_capable,
|
|
263
|
+
"replay": item.descriptor.replay_capable,
|
|
264
|
+
}
|
|
265
|
+
for item in adapters
|
|
266
|
+
],
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
return tuple(rows)
|