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