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