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,349 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from hashlib import sha256
5
+ from pathlib import Path
6
+ from time import monotonic, sleep
7
+ from typing import Any, Mapping
8
+
9
+ from ..physical_motion import MotionExecutionMetrics, PhysicalMotionPlan
10
+ from ..robot_platform import QualificationState, SimulationTier
11
+ from ..simulation_platform import (
12
+ AdapterHealth,
13
+ SimulationAdapterDescriptor,
14
+ SimulationPlatformError,
15
+ )
16
+ from ..twin import TwinSpec
17
+ from .commands import RosCommandRunner
18
+ from .doctor import RosDoctor
19
+ from .trajectory import (
20
+ RosTrajectoryError,
21
+ execute_joint_trajectory,
22
+ read_joint_state,
23
+ single_point_motion,
24
+ )
25
+ from .ur5e import UR5eSimulation, UR5eSimulationLauncher
26
+
27
+
28
+ UR5E_JOINTS = (
29
+ "shoulder_pan_joint",
30
+ "shoulder_lift_joint",
31
+ "elbow_joint",
32
+ "wrist_1_joint",
33
+ "wrist_2_joint",
34
+ "wrist_3_joint",
35
+ )
36
+ _RESET_GRAPH_HASH = sha256(b"devagent-ur5e-simulation-reset-v1").hexdigest()
37
+
38
+
39
+ class UR5eGazeboAdapter:
40
+ """ROS 2 Gazebo adapter for executing already-compiled UR5e trajectories.
41
+
42
+ The trajectory and limited metric scopes start EXPERIMENTAL until this exact
43
+ v0.9 runtime passes DGX qualification. Twin materialization and pre-execution
44
+ verification remain NOT_QUALIFIED, so this adapter cannot enter a
45
+ commissioning-grade PhysicalCampaignRunner.
46
+ """
47
+
48
+ descriptor = SimulationAdapterDescriptor(
49
+ adapter_id="ur5e_gazebo_measured_v1",
50
+ backend="gazebo_moveit_ros2",
51
+ robot_profile_keys=("ur5e",),
52
+ tier=SimulationTier.PHYSICS,
53
+ qualification=QualificationState.EXPERIMENTAL,
54
+ visual_capable=True,
55
+ headless_capable=False,
56
+ replay_capable=True,
57
+ metric_names=frozenset(
58
+ {
59
+ "ros_action_round_trip_duration_s",
60
+ "planned_duration_s",
61
+ "planned_joint_travel_rad",
62
+ "final_joint_tracking_error_rad",
63
+ "endpoint_joint_delta_rad",
64
+ }
65
+ ),
66
+ notes=(
67
+ "Executes compiled joint trajectories only; high-level task graphs are not executable.",
68
+ "Cartesian path length and minimum clearance remain unavailable until a measured scene/MoveIt metric collector is attached.",
69
+ ),
70
+ trajectory_runtime_qualification=QualificationState.EXPERIMENTAL,
71
+ twin_environment_qualification=QualificationState.NOT_QUALIFIED,
72
+ preexecution_verification_qualification=QualificationState.NOT_QUALIFIED,
73
+ metric_collection_qualification=QualificationState.EXPERIMENTAL,
74
+ )
75
+
76
+ def __init__(
77
+ self,
78
+ runner: RosCommandRunner,
79
+ *,
80
+ ros_setup: Path | str = "/opt/ros/jazzy/setup.bash",
81
+ log_dir: Path | str = "~/.devagent/physical-simulation",
82
+ startup_timeout_s: float = 120.0,
83
+ reset_duration_s: float = 3.0,
84
+ start_tolerance_rad: float = 0.03,
85
+ final_tolerance_rad: float = 0.05,
86
+ ) -> None:
87
+ if startup_timeout_s <= 0 or reset_duration_s <= 0:
88
+ raise ValueError("ur5e_adapter_timeouts_must_be_positive")
89
+ if start_tolerance_rad <= 0 or final_tolerance_rad <= 0:
90
+ raise ValueError("ur5e_adapter_tolerances_must_be_positive")
91
+ self.runner = runner
92
+ self.ros_setup = Path(ros_setup)
93
+ self.log_dir = Path(log_dir).expanduser().resolve()
94
+ self.startup_timeout_s = float(startup_timeout_s)
95
+ self.reset_duration_s = float(reset_duration_s)
96
+ self.start_tolerance_rad = float(start_tolerance_rad)
97
+ self.final_tolerance_rad = float(final_tolerance_rad)
98
+ self._simulation: UR5eSimulation | None = None
99
+ self._twin: TwinSpec | None = None
100
+ self._session_id: str | None = None
101
+ self._active_case_id: str | None = None
102
+ self._active_reset_state: dict[str, Any] | None = None
103
+ self._last_metrics: MotionExecutionMetrics | None = None
104
+ self._last_state: dict[str, Any] = {}
105
+ self._records: dict[str, tuple[dict[str, Any], PhysicalMotionPlan]] = {}
106
+
107
+ def doctor(self, *, robot_profile_key: str) -> AdapterHealth:
108
+ if robot_profile_key != "ur5e":
109
+ return AdapterHealth(False, "unsupported_robot_profile", (robot_profile_key,))
110
+ try:
111
+ report = RosDoctor(self.runner, ros_setup=self.ros_setup).run()
112
+ except Exception as exc:
113
+ return AdapterHealth(False, "ros_doctor_error", (type(exc).__name__,))
114
+ if not report.passed:
115
+ return AdapterHealth(
116
+ False,
117
+ "ros_doctor_failed",
118
+ tuple(f"{item.name}:{item.detail}" for item in report.failures),
119
+ )
120
+ return AdapterHealth(True, "ready", ("ROS 2 Jazzy UR5e simulation stack available",))
121
+
122
+ def _ready_probe(self) -> tuple[bool, str]:
123
+ controllers = self.runner.run(
124
+ ["ros2", "control", "list_controllers"], timeout_s=5.0
125
+ )
126
+ if not controllers.ok:
127
+ return False, "controller_manager_unavailable"
128
+ active = any(
129
+ "joint_trajectory_controller" in line.lower()
130
+ and line.split()
131
+ and line.split()[-1].lower() == "active"
132
+ for line in controllers.stdout.splitlines()
133
+ )
134
+ if not active:
135
+ return False, "joint_trajectory_controller_not_active"
136
+ topics = self.runner.run(["ros2", "topic", "list"], timeout_s=5.0)
137
+ if not topics.ok:
138
+ return False, "topic_list_failed"
139
+ if "/joint_states" not in {line.strip() for line in topics.stdout.splitlines()}:
140
+ return False, "joint_states_missing"
141
+ actions = self.runner.run(["ros2", "action", "list"], timeout_s=5.0)
142
+ if not actions.ok:
143
+ return False, "action_list_failed"
144
+ if "/joint_trajectory_controller/follow_joint_trajectory" not in {
145
+ line.strip() for line in actions.stdout.splitlines()
146
+ }:
147
+ return False, "trajectory_action_missing"
148
+ return True, "controller/topic/action ready"
149
+
150
+ def _wait_ready(self) -> None:
151
+ started = monotonic()
152
+ last = "not_ready"
153
+ while monotonic() - started < self.startup_timeout_s:
154
+ simulation = self._simulation
155
+ if simulation is None or not simulation.running:
156
+ raise SimulationPlatformError("ur5e_simulation_exited_early")
157
+ try:
158
+ ready, last = self._ready_probe()
159
+ except Exception as exc:
160
+ ready = False
161
+ last = f"probe_error:{type(exc).__name__}"
162
+ if ready:
163
+ return
164
+ sleep(2.0)
165
+ raise SimulationPlatformError(f"ur5e_simulation_startup_timeout:{last}")
166
+
167
+ def prepare(self, *, twin: TwinSpec) -> str:
168
+ if self._simulation is not None:
169
+ raise SimulationPlatformError("ur5e_adapter_already_prepared")
170
+ if twin.robot_profile_key != "ur5e":
171
+ raise SimulationPlatformError("ur5e_adapter_twin_robot_mismatch")
172
+ health = self.doctor(robot_profile_key="ur5e")
173
+ if not health.ready:
174
+ raise SimulationPlatformError(f"ur5e_adapter_not_ready:{health.code}")
175
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
176
+ self.log_dir.mkdir(parents=True, exist_ok=True)
177
+ log_path = self.log_dir / f"ur5e-measured-{timestamp}.log"
178
+ self._simulation = UR5eSimulationLauncher(self.runner).launch(log_path=log_path)
179
+ self._twin = twin
180
+ self._session_id = f"ur5e-gazebo-{timestamp}"
181
+ try:
182
+ self._wait_ready()
183
+ snapshot = read_joint_state(self.runner)
184
+ snapshot.ordered(UR5E_JOINTS)
185
+ except Exception:
186
+ self.stop()
187
+ raise
188
+ return self._session_id
189
+
190
+ def _require_prepared(self) -> TwinSpec:
191
+ if self._simulation is None or not self._simulation.running or self._twin is None:
192
+ raise SimulationPlatformError("ur5e_adapter_not_prepared")
193
+ return self._twin
194
+
195
+ def reset(self, *, case_id: str, state: Mapping[str, Any]) -> None:
196
+ twin = self._require_prepared()
197
+ if not case_id.strip():
198
+ raise SimulationPlatformError("physical_case_id_required")
199
+ allowed = {"initial_joint_positions_rad"}
200
+ unknown = set(state) - allowed
201
+ if unknown:
202
+ raise SimulationPlatformError(
203
+ "unsupported_physical_reset_state:" + ",".join(sorted(unknown))
204
+ )
205
+ target_raw = state.get("initial_joint_positions_rad")
206
+ if not isinstance(target_raw, (list, tuple)) or len(target_raw) != len(UR5E_JOINTS):
207
+ raise SimulationPlatformError("physical_case_initial_joint_positions_required")
208
+ target = tuple(float(value) for value in target_raw)
209
+ snapshot = read_joint_state(self.runner)
210
+ current = snapshot.ordered(UR5E_JOINTS)
211
+ reset_motion = single_point_motion(
212
+ motion_id=f"RESET-{case_id}",
213
+ robot_profile_key="ur5e",
214
+ joint_names=UR5E_JOINTS,
215
+ current_positions_rad=current,
216
+ target_positions_rad=target,
217
+ duration_s=self.reset_duration_s,
218
+ source_graph_hash=_RESET_GRAPH_HASH,
219
+ twin_hash=twin.fingerprint,
220
+ )
221
+ execute_joint_trajectory(self.runner, reset_motion)
222
+ after = read_joint_state(self.runner).ordered(UR5E_JOINTS)
223
+ error = max(abs(a - b) for a, b in zip(target, after))
224
+ if error > self.start_tolerance_rad:
225
+ raise SimulationPlatformError(f"physical_reset_tracking_error:{error:.6f}")
226
+ self._active_case_id = case_id
227
+ self._active_reset_state = {"initial_joint_positions_rad": list(target)}
228
+ self._last_metrics = None
229
+ self._last_state = {
230
+ "case_id": case_id,
231
+ "reset_positions_rad": list(after),
232
+ "reset_error_rad": error,
233
+ }
234
+
235
+ def execute_motion(self, *, motion: PhysicalMotionPlan) -> MotionExecutionMetrics:
236
+ twin = self._require_prepared()
237
+ if self._active_case_id is None or self._active_reset_state is None:
238
+ raise SimulationPlatformError("physical_case_not_reset")
239
+ if motion.robot_profile_key != "ur5e":
240
+ raise SimulationPlatformError("motion_robot_profile_mismatch")
241
+ if motion.twin_hash != twin.fingerprint:
242
+ raise SimulationPlatformError("motion_twin_hash_mismatch")
243
+ if set(motion.joint_names) != set(UR5E_JOINTS) or len(motion.joint_names) != 6:
244
+ raise SimulationPlatformError("ur5e_motion_joint_set_invalid")
245
+
246
+ before = read_joint_state(self.runner).ordered(motion.joint_names)
247
+ start_error = max(
248
+ abs(actual - expected)
249
+ for actual, expected in zip(before, motion.points[0].positions_rad)
250
+ )
251
+ if start_error > self.start_tolerance_rad:
252
+ raise SimulationPlatformError(f"motion_start_state_mismatch:{start_error:.6f}")
253
+
254
+ try:
255
+ command = execute_joint_trajectory(self.runner, motion)
256
+ after = read_joint_state(self.runner).ordered(motion.joint_names)
257
+ final_error = max(
258
+ abs(actual - expected)
259
+ for actual, expected in zip(after, motion.points[-1].positions_rad)
260
+ )
261
+ endpoint_delta = sum(abs(a - b) for a, b in zip(before, after))
262
+ success = final_error <= self.final_tolerance_rad
263
+ failures = () if success else ("final_tracking_error_exceeded",)
264
+ metrics = MotionExecutionMetrics(
265
+ success=success,
266
+ observed_duration_s=command.duration_s,
267
+ planned_duration_s=motion.planned_duration_s,
268
+ planned_joint_travel_rad=motion.joint_travel_rad,
269
+ measured_joint_travel_rad=None,
270
+ max_tracking_error_rad=final_error,
271
+ path_length_m=None,
272
+ min_clearance_m=None,
273
+ final_tcp_error_m=None,
274
+ energy_proxy=None,
275
+ failure_codes=failures,
276
+ metrics_origin="ros2_action_round_trip_simulation",
277
+ )
278
+ except RosTrajectoryError as exc:
279
+ metrics = MotionExecutionMetrics(
280
+ success=False,
281
+ observed_duration_s=None,
282
+ planned_duration_s=motion.planned_duration_s,
283
+ planned_joint_travel_rad=motion.joint_travel_rad,
284
+ failure_codes=(str(exc).split(":", 1)[0],),
285
+ metrics_origin="ros2_action_round_trip_simulation",
286
+ )
287
+ endpoint_delta = None
288
+ after = before
289
+
290
+ self._last_metrics = metrics
291
+ self._last_state = {
292
+ "case_id": self._active_case_id,
293
+ "motion_id": motion.motion_id,
294
+ "motion_hash": motion.fingerprint,
295
+ "final_joint_positions_rad": list(after),
296
+ "endpoint_joint_delta_rad": endpoint_delta,
297
+ "metrics": metrics.to_dict(),
298
+ }
299
+ self._records[self._active_case_id] = (dict(self._active_reset_state), motion)
300
+ return metrics
301
+
302
+ def collect_metrics(self) -> Mapping[str, float | int | bool | str | None]:
303
+ if self._last_metrics is None:
304
+ raise SimulationPlatformError("physical_metrics_not_available")
305
+ metrics = self._last_metrics
306
+ return {
307
+ "success": metrics.success,
308
+ "ros_action_round_trip_duration_s": metrics.observed_duration_s,
309
+ "planned_duration_s": metrics.planned_duration_s,
310
+ "planned_joint_travel_rad": metrics.planned_joint_travel_rad,
311
+ "final_joint_tracking_error_rad": metrics.max_tracking_error_rad,
312
+ "endpoint_joint_delta_rad": self._last_state.get("endpoint_joint_delta_rad"),
313
+ "path_length_m": metrics.path_length_m,
314
+ "min_clearance_m": metrics.min_clearance_m,
315
+ "metrics_origin": metrics.metrics_origin,
316
+ }
317
+
318
+ def capture_state(self) -> Mapping[str, Any]:
319
+ self._require_prepared()
320
+ return dict(self._last_state)
321
+
322
+ def replay(self, *, case_id: str) -> Mapping[str, Any]:
323
+ record = self._records.get(case_id)
324
+ if record is None:
325
+ raise SimulationPlatformError(f"physical_replay_case_not_found:{case_id}")
326
+ reset_state, motion = record
327
+ original_hash = motion.fingerprint
328
+ self.reset(case_id=case_id, state=reset_state)
329
+ metrics = self.execute_motion(motion=motion)
330
+ return {
331
+ "case_id": case_id,
332
+ "motion_hash": original_hash,
333
+ "replayed_motion_hash": motion.fingerprint,
334
+ "same_motion": original_hash == motion.fingerprint,
335
+ "metrics": metrics.to_dict(),
336
+ "state": dict(self._last_state),
337
+ }
338
+
339
+ def stop(self) -> None:
340
+ simulation = self._simulation
341
+ self._simulation = None
342
+ self._twin = None
343
+ self._session_id = None
344
+ self._active_case_id = None
345
+ self._active_reset_state = None
346
+ self._last_metrics = None
347
+ self._last_state = {}
348
+ if simulation is not None:
349
+ simulation.stop()
@@ -0,0 +1,292 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import replace
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from time import sleep
7
+ from typing import Any, Mapping
8
+
9
+ from ..physical_motion import MotionExecutionMetrics, PhysicalMotionPlan
10
+ from ..robot_platform import QualificationState, SimulationTier
11
+ from ..simulation_platform import SimulationAdapterDescriptor, SimulationPlatformError
12
+ from ..twin import TwinSpec
13
+ from ..twin_materialization import CanonicalTwinMaterialization, TwinMaterializer
14
+ from .frame_alignment import FrameAlignmentReceipt, FrameAlignmentVerifier
15
+ from .gazebo_world import GazeboWorldReceipt, GazeboWorldVerifier
16
+ from .measured_motion import FkMetricReceipt, JointMotionAnalysis, MoveItFkMetricBridge, analyze_joint_state_record, load_joint_state_record
17
+ from .moveit_scene import MoveItSceneBridge, MoveItSceneReceipt
18
+ from .preexecution import MoveItPreExecutionVerifier
19
+ from .trajectory import read_joint_state
20
+ from .ur5e import UR5eSimulationLauncher
21
+ from .ur5e_adapter import UR5E_JOINTS, UR5eGazeboAdapter
22
+
23
+
24
+ class UR5eCanonicalTwinAdapter(UR5eGazeboAdapter):
25
+ """v0.10 UR5e adapter binding one Twin to Gazebo, TF, and MoveIt.
26
+
27
+ The adapter remains EXPERIMENTAL until executable qualification promotes
28
+ each scope. Pre-execution checking is discrete rather than continuous and
29
+ minimum-clearance measurement remains unavailable.
30
+ """
31
+
32
+ descriptor = SimulationAdapterDescriptor(
33
+ adapter_id="ur5e_gazebo_canonical_v2",
34
+ backend="gazebo_moveit_ros2",
35
+ robot_profile_keys=("ur5e",),
36
+ tier=SimulationTier.PHYSICS,
37
+ qualification=QualificationState.EXPERIMENTAL,
38
+ visual_capable=True,
39
+ headless_capable=False,
40
+ replay_capable=True,
41
+ metric_names=frozenset({
42
+ "measured_motion_duration_s",
43
+ "planned_duration_s",
44
+ "planned_joint_travel_rad",
45
+ "measured_joint_travel_rad",
46
+ "final_joint_tracking_error_rad",
47
+ "measured_tcp_path_length_m",
48
+ "final_tcp_error_m",
49
+ "materialization_hash",
50
+ "moveit_scene_hash",
51
+ }),
52
+ notes=(
53
+ "One canonical Twin materialization drives Gazebo and MoveIt.",
54
+ "Gazebo models, TF alignment, and MoveIt PlanningScene are read back and verified.",
55
+ "Pre-execution state validity is sampled/discrete, not continuous collision checking.",
56
+ "Minimum-clearance measurement is not yet qualified and remains unavailable.",
57
+ "Real robot execution remains locked.",
58
+ ),
59
+ trajectory_runtime_qualification=QualificationState.EXPERIMENTAL,
60
+ twin_environment_qualification=QualificationState.EXPERIMENTAL,
61
+ preexecution_verification_qualification=QualificationState.EXPERIMENTAL,
62
+ metric_collection_qualification=QualificationState.EXPERIMENTAL,
63
+ )
64
+
65
+ def __init__(
66
+ self,
67
+ runner,
68
+ *,
69
+ ros_setup: Path | str = "/opt/ros/jazzy/setup.bash",
70
+ log_dir: Path | str = "~/.devagent/physical-simulation-v10",
71
+ startup_timeout_s: float = 120.0,
72
+ reset_duration_s: float = 3.0,
73
+ start_tolerance_rad: float = 0.03,
74
+ final_tolerance_rad: float = 0.05,
75
+ planning_frame: str = "base_link",
76
+ planning_group: str = "ur_manipulator",
77
+ fk_link_name: str = "tool0",
78
+ ) -> None:
79
+ super().__init__(
80
+ runner,
81
+ ros_setup=ros_setup,
82
+ log_dir=log_dir,
83
+ startup_timeout_s=startup_timeout_s,
84
+ reset_duration_s=reset_duration_s,
85
+ start_tolerance_rad=start_tolerance_rad,
86
+ final_tolerance_rad=final_tolerance_rad,
87
+ )
88
+ self.materializer = TwinMaterializer(planning_frame=planning_frame)
89
+ self.frame_verifier = FrameAlignmentVerifier(runner)
90
+ self.gazebo_verifier = GazeboWorldVerifier(runner)
91
+ self.scene_bridge = MoveItSceneBridge(runner, work_dir=self.log_dir / "moveit-scenes")
92
+ self.preexecution_verifier = MoveItPreExecutionVerifier(
93
+ runner,
94
+ work_dir=self.log_dir / "preexecution",
95
+ planning_group=planning_group,
96
+ )
97
+ self.fk_bridge = MoveItFkMetricBridge(
98
+ runner,
99
+ work_dir=self.log_dir / "fk-metrics",
100
+ planning_frame=planning_frame,
101
+ link_name=fk_link_name,
102
+ )
103
+ self._materialization: CanonicalTwinMaterialization | None = None
104
+ self._frame_receipt: FrameAlignmentReceipt | None = None
105
+ self._gazebo_receipt: GazeboWorldReceipt | None = None
106
+ self._scene_receipt: MoveItSceneReceipt | None = None
107
+ self._last_preexecution = None
108
+ self._last_joint_analysis: JointMotionAnalysis | None = None
109
+ self._last_fk_receipt: FkMetricReceipt | None = None
110
+
111
+ def prepare(self, *, twin: TwinSpec) -> str:
112
+ if self._simulation is not None:
113
+ raise SimulationPlatformError("ur5e_adapter_already_prepared")
114
+ if twin.robot_profile_key != "ur5e":
115
+ raise SimulationPlatformError("ur5e_adapter_twin_robot_mismatch")
116
+
117
+ materialization_result = self.materializer.compile(twin)
118
+ if not materialization_result.ready:
119
+ codes = ",".join(issue.code for issue in materialization_result.issues)
120
+ raise SimulationPlatformError(f"twin_materialization_blocked:{codes}")
121
+ materialization = materialization_result.materialization
122
+ assert materialization is not None
123
+
124
+ health = self.doctor(robot_profile_key="ur5e")
125
+ if not health.ready:
126
+ raise SimulationPlatformError(f"ur5e_adapter_not_ready:{health.code}")
127
+
128
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
129
+ self.log_dir.mkdir(parents=True, exist_ok=True)
130
+ world_path = materialization.write_sdf(self.log_dir / "worlds" / f"{materialization.fingerprint}.sdf")
131
+ log_path = self.log_dir / f"ur5e-canonical-{timestamp}.log"
132
+ self._simulation = UR5eSimulationLauncher(self.runner).launch(log_path=log_path, world_file=world_path)
133
+ self._twin = twin
134
+ self._materialization = materialization
135
+ self._session_id = f"ur5e-gazebo-canonical-{timestamp}"
136
+ try:
137
+ self._wait_ready()
138
+ snapshot = read_joint_state(self.runner)
139
+ snapshot.ordered(UR5E_JOINTS)
140
+ self._frame_receipt = self.frame_verifier.verify_identity(
141
+ parent_frame="world",
142
+ child_frame=materialization.planning_frame,
143
+ )
144
+ self._gazebo_receipt = self.gazebo_verifier.verify(materialization)
145
+ self._scene_receipt = self.scene_bridge.apply_and_verify(materialization)
146
+ except Exception:
147
+ self.stop()
148
+ raise
149
+ return self._session_id
150
+
151
+ def _require_materialization(self) -> CanonicalTwinMaterialization:
152
+ self._require_prepared()
153
+ if self._materialization is None:
154
+ raise SimulationPlatformError("canonical_materialization_missing")
155
+ if self._frame_receipt is None or self._gazebo_receipt is None or self._scene_receipt is None:
156
+ raise SimulationPlatformError("canonical_materialization_unverified")
157
+ return self._materialization
158
+
159
+ def _start_joint_recorder(self, *, motion_hash: str) -> tuple[object, Path]:
160
+ helper = Path(__file__).with_name("joint_state_recorder.py").resolve()
161
+ output = self.log_dir / "measurements" / f"{motion_hash}.joint-states.json"
162
+ output.parent.mkdir(parents=True, exist_ok=True)
163
+ log_path = output.with_suffix(".recorder.log")
164
+ process = self.runner.start(
165
+ (
166
+ "python3",
167
+ str(helper),
168
+ "--output",
169
+ str(output),
170
+ "--topic",
171
+ "/joint_states",
172
+ "--max-samples",
173
+ "20000",
174
+ ),
175
+ log_path=log_path,
176
+ )
177
+ sleep(0.25)
178
+ return process, output
179
+
180
+ def execute_motion(self, *, motion: PhysicalMotionPlan) -> MotionExecutionMetrics:
181
+ materialization = self._require_materialization()
182
+ if motion.twin_hash != materialization.twin_hash:
183
+ raise SimulationPlatformError("motion_twin_hash_mismatch")
184
+
185
+ twin = self._require_prepared()
186
+ customer_plan = motion.metadata.get("customer_plan") is not False
187
+ if customer_plan:
188
+ if twin.tool is not None and motion.metadata.get("tool_collision_model_verified") is not True:
189
+ raise SimulationPlatformError("tool_collision_model_unverified")
190
+ if twin.workpiece_entity_id is not None and motion.metadata.get("workpiece_manipulation") is not False:
191
+ raise SimulationPlatformError("workpiece_attachment_state_unqualified")
192
+
193
+ preexecution = self.preexecution_verifier.verify(motion=motion, materialization=materialization)
194
+ self._last_preexecution = preexecution
195
+ if not preexecution.verified:
196
+ raise SimulationPlatformError(
197
+ "motion_preexecution_verification_failed:" + ",".join(preexecution.failure_codes)
198
+ )
199
+
200
+ recorder = None
201
+ record_path: Path | None = None
202
+ try:
203
+ recorder, record_path = self._start_joint_recorder(motion_hash=motion.fingerprint)
204
+ metrics = super().execute_motion(motion=motion)
205
+ finally:
206
+ if recorder is not None:
207
+ self.runner.stop(recorder, timeout_s=3.0)
208
+
209
+ if not metrics.success:
210
+ return metrics
211
+ if record_path is None:
212
+ raise SimulationPlatformError("joint_state_measurement_missing")
213
+
214
+ payload = load_joint_state_record(record_path)
215
+ analysis = analyze_joint_state_record(
216
+ payload,
217
+ joint_names=motion.joint_names,
218
+ start_positions_rad=motion.points[0].positions_rad,
219
+ final_positions_rad=motion.points[-1].positions_rad,
220
+ final_tolerance_rad=self.final_tolerance_rad,
221
+ )
222
+ fk_receipt = self.fk_bridge.compute(
223
+ motion_hash=motion.fingerprint,
224
+ joint_names=motion.joint_names,
225
+ samples=analysis.samples,
226
+ target_positions_rad=motion.points[-1].positions_rad,
227
+ )
228
+
229
+ enhanced = replace(
230
+ metrics,
231
+ observed_duration_s=analysis.duration_s,
232
+ measured_joint_travel_rad=analysis.measured_joint_travel_rad,
233
+ max_tracking_error_rad=analysis.final_tracking_error_rad,
234
+ path_length_m=fk_receipt.path_length_m,
235
+ final_tcp_error_m=fk_receipt.final_tcp_error_m,
236
+ metrics_origin="ros_joint_state_plus_moveit_fk_simulation",
237
+ )
238
+ self._last_metrics = enhanced
239
+ self._last_joint_analysis = analysis
240
+ self._last_fk_receipt = fk_receipt
241
+ self._last_state = {
242
+ **self._last_state,
243
+ "metrics": enhanced.to_dict(),
244
+ "joint_motion_analysis": analysis.to_dict(),
245
+ "fk_metrics": fk_receipt.to_dict(),
246
+ "preexecution": preexecution.to_dict(),
247
+ }
248
+ return enhanced
249
+
250
+ def collect_metrics(self) -> Mapping[str, float | int | bool | str | None]:
251
+ metrics = dict(super().collect_metrics())
252
+ materialization = self._materialization
253
+ scene = self._scene_receipt
254
+ if self._last_metrics is not None:
255
+ metrics.update({
256
+ "measured_motion_duration_s": self._last_metrics.observed_duration_s,
257
+ "measured_joint_travel_rad": self._last_metrics.measured_joint_travel_rad,
258
+ "measured_tcp_path_length_m": self._last_metrics.path_length_m,
259
+ "final_tcp_error_m": self._last_metrics.final_tcp_error_m,
260
+ })
261
+ metrics["materialization_hash"] = materialization.fingerprint if materialization is not None else None
262
+ metrics["moveit_scene_hash"] = scene.observed_scene_hash if scene is not None else None
263
+ return metrics
264
+
265
+ def capture_state(self) -> Mapping[str, Any]:
266
+ state = dict(super().capture_state())
267
+ state.update({
268
+ "materialization": (
269
+ {
270
+ "twin_hash": self._materialization.twin_hash,
271
+ "materialization_hash": self._materialization.fingerprint,
272
+ "scene_hash": self._materialization.scene_hash,
273
+ }
274
+ if self._materialization is not None
275
+ else None
276
+ ),
277
+ "frame_alignment": self._frame_receipt.to_dict() if self._frame_receipt is not None else None,
278
+ "gazebo_world": self._gazebo_receipt.to_dict() if self._gazebo_receipt is not None else None,
279
+ "moveit_scene": self._scene_receipt.to_dict() if self._scene_receipt is not None else None,
280
+ "preexecution": self._last_preexecution.to_dict() if self._last_preexecution is not None else None,
281
+ })
282
+ return state
283
+
284
+ def stop(self) -> None:
285
+ self._materialization = None
286
+ self._frame_receipt = None
287
+ self._gazebo_receipt = None
288
+ self._scene_receipt = None
289
+ self._last_preexecution = None
290
+ self._last_joint_analysis = None
291
+ self._last_fk_receipt = None
292
+ super().stop()