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,61 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ import json
5
+ from math import acos
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..simulation_platform import SimulationPlatformError
10
+ from .commands import RosCommandRunner
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class FrameAlignmentReceipt:
15
+ verified: bool
16
+ code: str
17
+ parent_frame: str
18
+ child_frame: str
19
+ translation_error_m: float
20
+ rotation_error_rad: float
21
+
22
+ def to_dict(self) -> dict[str, Any]:
23
+ return asdict(self)
24
+
25
+
26
+ class FrameAlignmentVerifier:
27
+ """Verify the simulator world and MoveIt planning frame share the expected origin."""
28
+
29
+ def __init__(self, runner: RosCommandRunner, *, timeout_s: float = 10.0, translation_tolerance_m: float = 1e-5, rotation_tolerance_rad: float = 1e-5) -> None:
30
+ if timeout_s <= 0 or translation_tolerance_m <= 0 or rotation_tolerance_rad <= 0:
31
+ raise ValueError("frame_alignment_limits_invalid")
32
+ self.runner = runner
33
+ self.timeout_s = float(timeout_s)
34
+ self.translation_tolerance_m = float(translation_tolerance_m)
35
+ self.rotation_tolerance_rad = float(rotation_tolerance_rad)
36
+
37
+ def verify_identity(self, *, parent_frame: str = "world", child_frame: str = "base_link") -> FrameAlignmentReceipt:
38
+ helper = Path(__file__).with_name("tf_probe.py").resolve()
39
+ result = self.runner.run(
40
+ ("python3", str(helper), "--parent", parent_frame, "--child", child_frame, "--timeout", format(self.timeout_s, ".6g")),
41
+ timeout_s=self.timeout_s + 5.0,
42
+ )
43
+ try:
44
+ payload = json.loads(result.stdout.strip().splitlines()[-1])
45
+ except (IndexError, json.JSONDecodeError) as exc:
46
+ raise SimulationPlatformError("tf_probe_output_invalid") from exc
47
+ if not result.ok or not isinstance(payload, dict) or not payload.get("success"):
48
+ code = payload.get("code") if isinstance(payload, dict) else "tf_probe_failed"
49
+ raise SimulationPlatformError(f"frame_alignment_probe_failed:{code}")
50
+ translation = tuple(float(v) for v in payload.get("translation_m") or ())
51
+ quaternion = tuple(float(v) for v in payload.get("quaternion_xyzw") or ())
52
+ if len(translation) != 3 or len(quaternion) != 4:
53
+ raise SimulationPlatformError("tf_probe_shape_invalid")
54
+ translation_error = max(abs(value) for value in translation)
55
+ # q and -q represent the same rotation; identity angular distance is 2*acos(|w|).
56
+ rotation_error = 2.0 * acos(max(0.0, min(1.0, abs(quaternion[3]))))
57
+ if translation_error > self.translation_tolerance_m:
58
+ raise SimulationPlatformError(f"frame_translation_mismatch:{translation_error:.9f}")
59
+ if rotation_error > self.rotation_tolerance_rad:
60
+ raise SimulationPlatformError(f"frame_rotation_mismatch:{rotation_error:.9f}")
61
+ return FrameAlignmentReceipt(True, "frame_alignment_verified", parent_frame, child_frame, translation_error, rotation_error)
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from math import asin, atan2, pi
5
+ import re
6
+ from typing import Any
7
+
8
+ from ..simulation_platform import SimulationPlatformError
9
+ from ..twin_materialization import CanonicalTwinMaterialization
10
+ from .commands import RosCommandRunner
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class GazeboWorldReceipt:
15
+ verified: bool
16
+ code: str
17
+ twin_hash: str
18
+ materialization_hash: str
19
+ expected_model_count: int
20
+ observed_model_count: int
21
+ max_position_error_m: float | None = None
22
+ max_orientation_error_rad: float | None = None
23
+
24
+ def to_dict(self) -> dict[str, Any]:
25
+ return asdict(self)
26
+
27
+
28
+ def _rpy(quaternion: tuple[float, float, float, float]) -> tuple[float, float, float]:
29
+ x, y, z, w = quaternion
30
+ return (
31
+ atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)),
32
+ asin(max(-1.0, min(1.0, 2 * (w * y - z * x)))),
33
+ atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)),
34
+ )
35
+
36
+
37
+ def _angle_error(first: float, second: float) -> float:
38
+ return abs((first - second + pi) % (2 * pi) - pi)
39
+
40
+
41
+ def _parse_model_names(text: str) -> set[str]:
42
+ names: set[str] = set()
43
+ for line in text.splitlines():
44
+ match = re.match(r"\s*-\s+(.+?)\s*$", line)
45
+ if match:
46
+ names.add(match.group(1))
47
+ return names
48
+
49
+
50
+ def _parse_pose(text: str):
51
+ triples: list[tuple[float, float, float]] = []
52
+ pattern = re.compile(r"\[\s*([-+0-9.eE]+)\s*\|\s*([-+0-9.eE]+)\s*\|\s*([-+0-9.eE]+)\s*\]")
53
+ for match in pattern.finditer(text):
54
+ triples.append(tuple(float(match.group(index)) for index in range(1, 4)))
55
+ if len(triples) < 2:
56
+ raise SimulationPlatformError("gazebo_model_pose_output_invalid")
57
+ return triples[0], triples[1]
58
+
59
+
60
+ class GazeboWorldVerifier:
61
+ """Read back canonical model identities and poses from running Gazebo."""
62
+
63
+ def __init__(
64
+ self,
65
+ runner: RosCommandRunner,
66
+ *,
67
+ timeout_s: float = 10.0,
68
+ position_tolerance_m: float = 1e-4,
69
+ orientation_tolerance_rad: float = 1e-4,
70
+ dynamic_position_tolerance_m: float = 0.01,
71
+ dynamic_orientation_tolerance_rad: float = 0.01,
72
+ ) -> None:
73
+ if (
74
+ timeout_s <= 0
75
+ or position_tolerance_m <= 0
76
+ or orientation_tolerance_rad <= 0
77
+ or dynamic_position_tolerance_m <= 0
78
+ or dynamic_orientation_tolerance_rad <= 0
79
+ ):
80
+ raise ValueError("gazebo_world_verifier_limits_invalid")
81
+ self.runner = runner
82
+ self.timeout_s = float(timeout_s)
83
+ self.position_tolerance_m = float(position_tolerance_m)
84
+ self.orientation_tolerance_rad = float(orientation_tolerance_rad)
85
+ self.dynamic_position_tolerance_m = float(dynamic_position_tolerance_m)
86
+ self.dynamic_orientation_tolerance_rad = float(dynamic_orientation_tolerance_rad)
87
+
88
+ def verify(self, materialization: CanonicalTwinMaterialization) -> GazeboWorldReceipt:
89
+ listed = self.runner.run(("gz", "model", "--list"), timeout_s=self.timeout_s)
90
+ if not listed.ok:
91
+ raise SimulationPlatformError("gazebo_model_list_failed")
92
+ observed = _parse_model_names(listed.stdout + "\n" + listed.stderr)
93
+ expected = {item.backend_id for item in materialization.objects}
94
+ missing = sorted(expected - observed)
95
+ if missing:
96
+ raise SimulationPlatformError("gazebo_materialized_models_missing:" + ",".join(missing))
97
+
98
+ max_position = 0.0
99
+ max_orientation = 0.0
100
+ for item in materialization.objects:
101
+ result = self.runner.run(("gz", "model", "-m", item.backend_id, "--pose"), timeout_s=self.timeout_s)
102
+ if not result.ok:
103
+ raise SimulationPlatformError(f"gazebo_model_pose_failed:{item.backend_id}")
104
+ position, orientation = _parse_pose(result.stdout + "\n" + result.stderr)
105
+ position_error = max(abs(first - second) for first, second in zip(position, item.position_m))
106
+ orientation_error = max(_angle_error(first, second) for first, second in zip(orientation, _rpy(item.quaternion_xyzw)))
107
+ max_position = max(max_position, position_error)
108
+ max_orientation = max(max_orientation, orientation_error)
109
+ position_tolerance = self.position_tolerance_m if item.static else self.dynamic_position_tolerance_m
110
+ orientation_tolerance = self.orientation_tolerance_rad if item.static else self.dynamic_orientation_tolerance_rad
111
+ if position_error > position_tolerance:
112
+ raise SimulationPlatformError(f"gazebo_model_position_mismatch:{item.backend_id}:{position_error:.9f}")
113
+ if orientation_error > orientation_tolerance:
114
+ raise SimulationPlatformError(f"gazebo_model_orientation_mismatch:{item.backend_id}:{orientation_error:.9f}")
115
+
116
+ return GazeboWorldReceipt(
117
+ True,
118
+ "gazebo_world_readback_verified",
119
+ materialization.twin_hash,
120
+ materialization.fingerprint,
121
+ len(expected),
122
+ len(expected & observed),
123
+ max_position,
124
+ max_orientation,
125
+ )
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+
8
+ def main() -> int:
9
+ parser = argparse.ArgumentParser()
10
+ parser.add_argument("--output", required=True)
11
+ parser.add_argument("--topic", default="/joint_states")
12
+ parser.add_argument("--max-samples", type=int, default=20000)
13
+ args = parser.parse_args()
14
+ output = Path(args.output).expanduser().resolve()
15
+ output.parent.mkdir(parents=True, exist_ok=True)
16
+ samples: list[dict[str, object]] = []
17
+ overflow = False
18
+
19
+ import rclpy
20
+ from sensor_msgs.msg import JointState
21
+
22
+ rclpy.init()
23
+ node = rclpy.create_node("devagent_joint_state_recorder")
24
+ started_ns: int | None = None
25
+
26
+ def callback(message: JointState) -> None:
27
+ nonlocal started_ns, overflow
28
+ stamp = message.header.stamp
29
+ stamped_ns = int(stamp.sec) * 1_000_000_000 + int(stamp.nanosec)
30
+ now_ns = stamped_ns if stamped_ns > 0 else node.get_clock().now().nanoseconds
31
+ if started_ns is None:
32
+ started_ns = now_ns
33
+ if len(samples) >= int(args.max_samples):
34
+ overflow = True
35
+ return
36
+ samples.append({
37
+ "time_s": (now_ns - started_ns) / 1_000_000_000.0,
38
+ "names": list(message.name),
39
+ "positions_rad": list(message.position),
40
+ })
41
+
42
+ subscription = node.create_subscription(JointState, args.topic, callback, 50)
43
+ try:
44
+ rclpy.spin(node)
45
+ except KeyboardInterrupt:
46
+ pass
47
+ finally:
48
+ payload = {
49
+ "topic": args.topic,
50
+ "sample_count": len(samples),
51
+ "overflow": overflow,
52
+ "samples": samples,
53
+ }
54
+ temporary = output.with_suffix(output.suffix + ".tmp")
55
+ temporary.write_text(json.dumps(payload, separators=(",", ":"), allow_nan=False), encoding="utf-8")
56
+ temporary.replace(output)
57
+ node.destroy_subscription(subscription)
58
+ node.destroy_node()
59
+ rclpy.shutdown()
60
+ return 0 if samples and not overflow else 2
61
+
62
+
63
+ if __name__ == "__main__":
64
+ raise SystemExit(main())
@@ -0,0 +1,233 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ import json
5
+ from math import isfinite
6
+ from pathlib import Path
7
+ from typing import Any, Mapping, Sequence
8
+
9
+ from ..simulation_platform import SimulationPlatformError
10
+ from .commands import RosCommandRunner
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class RecordedJointSample:
15
+ time_s: float
16
+ positions_rad: tuple[float, ...]
17
+
18
+ def __post_init__(self) -> None:
19
+ if not isfinite(self.time_s) or self.time_s < 0:
20
+ raise ValueError("recorded_joint_time_invalid")
21
+ if not self.positions_rad or any(not isfinite(value) for value in self.positions_rad):
22
+ raise ValueError("recorded_joint_positions_invalid")
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class JointMotionAnalysis:
27
+ duration_s: float
28
+ measured_joint_travel_rad: float
29
+ final_tracking_error_rad: float
30
+ samples: tuple[RecordedJointSample, ...]
31
+
32
+ def __post_init__(self) -> None:
33
+ for value in (self.duration_s, self.measured_joint_travel_rad, self.final_tracking_error_rad):
34
+ if not isfinite(value) or value < 0:
35
+ raise ValueError("joint_motion_analysis_invalid")
36
+ if len(self.samples) < 2:
37
+ raise ValueError("joint_motion_analysis_samples_required")
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ return {
41
+ "duration_s": self.duration_s,
42
+ "measured_joint_travel_rad": self.measured_joint_travel_rad,
43
+ "final_tracking_error_rad": self.final_tracking_error_rad,
44
+ "sample_count": len(self.samples),
45
+ }
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class FkMetricReceipt:
50
+ verified: bool
51
+ code: str
52
+ path_length_m: float
53
+ final_tcp_error_m: float
54
+ sample_count: int
55
+ link_name: str
56
+ planning_frame: str
57
+
58
+ def __post_init__(self) -> None:
59
+ if not self.code.strip() or not self.link_name.strip() or not self.planning_frame.strip():
60
+ raise ValueError("fk_metric_receipt_identity_invalid")
61
+ if not self.verified:
62
+ raise ValueError("fk_metric_receipt_requires_verified")
63
+ if (
64
+ not isfinite(self.path_length_m)
65
+ or self.path_length_m < 0
66
+ or not isfinite(self.final_tcp_error_m)
67
+ or self.final_tcp_error_m < 0
68
+ or self.sample_count < 1
69
+ ):
70
+ raise ValueError("fk_metric_receipt_value_invalid")
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ return asdict(self)
74
+
75
+
76
+ def _ordered_positions(sample: Mapping[str, Any], joint_names: tuple[str, ...]) -> tuple[float, ...]:
77
+ names_raw = sample.get("names")
78
+ positions_raw = sample.get("positions_rad")
79
+ if not isinstance(names_raw, list) or not isinstance(positions_raw, list):
80
+ raise ValueError("joint_record_sample_shape_invalid")
81
+ values = {str(name): float(position) for name, position in zip(names_raw, positions_raw)}
82
+ missing = [name for name in joint_names if name not in values]
83
+ if missing:
84
+ raise ValueError("joint_record_missing:" + ",".join(sorted(missing)))
85
+ ordered = tuple(values[name] for name in joint_names)
86
+ if any(not isfinite(value) for value in ordered):
87
+ raise ValueError("joint_record_non_finite")
88
+ return ordered
89
+
90
+
91
+ def analyze_joint_state_record(
92
+ payload: Mapping[str, Any],
93
+ *,
94
+ joint_names: tuple[str, ...],
95
+ start_positions_rad: tuple[float, ...],
96
+ final_positions_rad: tuple[float, ...],
97
+ departure_threshold_rad: float = 1e-4,
98
+ final_tolerance_rad: float = 0.05,
99
+ ) -> JointMotionAnalysis:
100
+ if (
101
+ departure_threshold_rad <= 0
102
+ or final_tolerance_rad <= 0
103
+ or len(start_positions_rad) != len(joint_names)
104
+ or len(final_positions_rad) != len(joint_names)
105
+ ):
106
+ raise ValueError("joint_motion_analysis_limits_invalid")
107
+ if payload.get("overflow"):
108
+ raise ValueError("joint_record_overflow")
109
+ raw_samples = payload.get("samples")
110
+ if not isinstance(raw_samples, list) or len(raw_samples) < 2:
111
+ raise ValueError("joint_record_samples_missing")
112
+
113
+ samples = tuple(
114
+ RecordedJointSample(float(item["time_s"]), _ordered_positions(item, joint_names))
115
+ for item in raw_samples
116
+ if isinstance(item, Mapping)
117
+ )
118
+ if len(samples) < 2:
119
+ raise ValueError("joint_record_samples_missing")
120
+
121
+ start_index = 0
122
+ for index, sample in enumerate(samples):
123
+ departure = max(abs(actual - expected) for actual, expected in zip(sample.positions_rad, start_positions_rad))
124
+ if departure >= departure_threshold_rad:
125
+ start_index = max(0, index - 1)
126
+ break
127
+
128
+ end_index = len(samples) - 1
129
+ for index in range(start_index + 1, len(samples)):
130
+ error = max(abs(actual - expected) for actual, expected in zip(samples[index].positions_rad, final_positions_rad))
131
+ if error <= final_tolerance_rad:
132
+ end_index = index
133
+ break
134
+
135
+ selected = samples[start_index : end_index + 1]
136
+ if len(selected) < 2:
137
+ raise ValueError("joint_record_motion_window_too_small")
138
+ duration = max(0.0, selected[-1].time_s - selected[0].time_s)
139
+ travel = 0.0
140
+ previous = selected[0].positions_rad
141
+ for sample in selected[1:]:
142
+ travel += sum(abs(first - second) for first, second in zip(previous, sample.positions_rad))
143
+ previous = sample.positions_rad
144
+ final_error = max(abs(actual - expected) for actual, expected in zip(selected[-1].positions_rad, final_positions_rad))
145
+ return JointMotionAnalysis(duration, travel, final_error, selected)
146
+
147
+
148
+ def load_joint_state_record(path: Path | str) -> Mapping[str, Any]:
149
+ target = Path(path).expanduser().resolve()
150
+ try:
151
+ payload = json.loads(target.read_text(encoding="utf-8"))
152
+ except (OSError, json.JSONDecodeError) as exc:
153
+ raise SimulationPlatformError("joint_state_measurement_invalid") from exc
154
+ if not isinstance(payload, Mapping):
155
+ raise SimulationPlatformError("joint_state_measurement_invalid")
156
+ return payload
157
+
158
+
159
+ class MoveItFkMetricBridge:
160
+ """Convert measured joint-state samples into measured TCP path metrics."""
161
+
162
+ def __init__(
163
+ self,
164
+ runner: RosCommandRunner,
165
+ *,
166
+ work_dir: Path | str = "~/.devagent/physical-simulation/fk-metrics",
167
+ timeout_s: float = 10.0,
168
+ max_samples: int = 500,
169
+ planning_frame: str = "base_link",
170
+ link_name: str = "tool0",
171
+ ) -> None:
172
+ if timeout_s <= 0 or max_samples < 2:
173
+ raise ValueError("fk_metric_bridge_limits_invalid")
174
+ if not planning_frame.strip() or not link_name.strip():
175
+ raise ValueError("fk_metric_bridge_identity_invalid")
176
+ self.runner = runner
177
+ self.work_dir = Path(work_dir).expanduser().resolve()
178
+ self.timeout_s = float(timeout_s)
179
+ self.max_samples = int(max_samples)
180
+ self.planning_frame = planning_frame
181
+ self.link_name = link_name
182
+
183
+ @staticmethod
184
+ def _downsample(samples: Sequence[RecordedJointSample], limit: int) -> tuple[RecordedJointSample, ...]:
185
+ if len(samples) <= limit:
186
+ return tuple(samples)
187
+ indices = {round(index * (len(samples) - 1) / (limit - 1)) for index in range(limit)}
188
+ return tuple(samples[index] for index in sorted(indices))
189
+
190
+ def compute(
191
+ self,
192
+ *,
193
+ motion_hash: str,
194
+ joint_names: tuple[str, ...],
195
+ samples: Sequence[RecordedJointSample],
196
+ target_positions_rad: tuple[float, ...],
197
+ ) -> FkMetricReceipt:
198
+ selected = self._downsample(samples, self.max_samples)
199
+ if len(selected) < 2:
200
+ raise SimulationPlatformError("fk_metric_samples_insufficient")
201
+ self.work_dir.mkdir(parents=True, exist_ok=True)
202
+ payload = {
203
+ "joint_names": list(joint_names),
204
+ "samples": [{"time_s": item.time_s, "positions_rad": list(item.positions_rad)} for item in selected],
205
+ "target_positions_rad": list(target_positions_rad),
206
+ "planning_frame": self.planning_frame,
207
+ "link_name": self.link_name,
208
+ }
209
+ input_path = self.work_dir / f"{motion_hash}.fk.json"
210
+ temporary = input_path.with_suffix(input_path.suffix + ".tmp")
211
+ temporary.write_text(json.dumps(payload, sort_keys=True, separators=(",", ":"), allow_nan=False), encoding="utf-8")
212
+ temporary.replace(input_path)
213
+ helper = Path(__file__).with_name("fk_probe.py").resolve()
214
+ result = self.runner.run(
215
+ ("python3", str(helper), "--input", str(input_path), "--timeout", format(self.timeout_s, ".6g")),
216
+ timeout_s=max(self.timeout_s * (len(selected) + 2), self.timeout_s + 10.0),
217
+ )
218
+ try:
219
+ output = json.loads(result.stdout.strip().splitlines()[-1])
220
+ except (IndexError, json.JSONDecodeError) as exc:
221
+ raise SimulationPlatformError("fk_probe_output_invalid") from exc
222
+ if not result.ok or not isinstance(output, dict) or not output.get("success"):
223
+ code = str(output.get("code")) if isinstance(output, dict) else "fk_probe_failed"
224
+ raise SimulationPlatformError(f"fk_metric_collection_failed:{code}")
225
+ return FkMetricReceipt(
226
+ True,
227
+ str(output["code"]),
228
+ float(output["path_length_m"]),
229
+ float(output["final_tcp_error_m"]),
230
+ int(output["sample_count"]),
231
+ str(output["link_name"]),
232
+ str(output["planning_frame"]),
233
+ )
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ..simulation_platform import SimulationPlatformError
9
+ from ..twin_materialization import CanonicalTwinMaterialization
10
+ from .commands import RosCommandRunner
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class MoveItSceneReceipt:
15
+ verified: bool
16
+ code: str
17
+ materialization_hash: str
18
+ twin_hash: str
19
+ expected_scene_hash: str
20
+ observed_scene_hash: str | None
21
+ planning_frame: str
22
+ expected_object_count: int
23
+ observed_object_count: int | None
24
+
25
+ def __post_init__(self) -> None:
26
+ if not self.code.strip():
27
+ raise ValueError("moveit_scene_receipt_code_required")
28
+ if len(self.materialization_hash) != 64 or len(self.twin_hash) != 64:
29
+ raise ValueError("moveit_scene_receipt_hash_invalid")
30
+ if len(self.expected_scene_hash) != 64:
31
+ raise ValueError("moveit_scene_receipt_scene_hash_invalid")
32
+ if self.observed_scene_hash is not None and len(self.observed_scene_hash) != 64:
33
+ raise ValueError("moveit_scene_receipt_observed_hash_invalid")
34
+ if not self.planning_frame.strip():
35
+ raise ValueError("moveit_scene_receipt_planning_frame_required")
36
+ if self.expected_object_count < 0:
37
+ raise ValueError("moveit_scene_receipt_object_count_invalid")
38
+ if self.observed_object_count is not None and self.observed_object_count < 0:
39
+ raise ValueError("moveit_scene_receipt_object_count_invalid")
40
+
41
+ def to_dict(self) -> dict[str, Any]:
42
+ return asdict(self)
43
+
44
+
45
+ class MoveItSceneBridge:
46
+ """Apply canonical Twin collision geometry to MoveIt and read it back."""
47
+
48
+ def __init__(
49
+ self,
50
+ runner: RosCommandRunner,
51
+ *,
52
+ work_dir: Path | str = "~/.devagent/physical-simulation/materialized-scenes",
53
+ timeout_s: float = 20.0,
54
+ ) -> None:
55
+ if timeout_s <= 0:
56
+ raise ValueError("moveit_scene_timeout_must_be_positive")
57
+ self.runner = runner
58
+ self.work_dir = Path(work_dir).expanduser().resolve()
59
+ self.timeout_s = float(timeout_s)
60
+
61
+ def apply_and_verify(
62
+ self, materialization: CanonicalTwinMaterialization
63
+ ) -> MoveItSceneReceipt:
64
+ self.work_dir.mkdir(parents=True, exist_ok=True)
65
+ payload_path = self.work_dir / f"{materialization.fingerprint}.moveit-scene.json"
66
+ temporary = payload_path.with_suffix(payload_path.suffix + ".tmp")
67
+ temporary.write_text(
68
+ json.dumps(
69
+ materialization.moveit_scene_payload(),
70
+ sort_keys=True,
71
+ separators=(",", ":"),
72
+ allow_nan=False,
73
+ ),
74
+ encoding="utf-8",
75
+ )
76
+ temporary.replace(payload_path)
77
+ helper = Path(__file__).with_name("scene_probe.py").resolve()
78
+ result = self.runner.run(
79
+ (
80
+ "python3",
81
+ str(helper),
82
+ "apply-verify",
83
+ "--input",
84
+ str(payload_path),
85
+ "--timeout",
86
+ format(self.timeout_s, ".6g"),
87
+ ),
88
+ timeout_s=self.timeout_s + 10.0,
89
+ )
90
+ try:
91
+ output = json.loads(result.stdout.strip().splitlines()[-1])
92
+ except (IndexError, json.JSONDecodeError) as exc:
93
+ raise SimulationPlatformError("moveit_scene_probe_output_invalid") from exc
94
+ if not isinstance(output, dict):
95
+ raise SimulationPlatformError("moveit_scene_probe_output_invalid")
96
+ code = str(output.get("code") or "moveit_scene_probe_failed")
97
+ verified = bool(output.get("success")) and result.ok
98
+ receipt = MoveItSceneReceipt(
99
+ verified=verified,
100
+ code=code,
101
+ materialization_hash=materialization.fingerprint,
102
+ twin_hash=materialization.twin_hash,
103
+ expected_scene_hash=materialization.scene_hash,
104
+ observed_scene_hash=(
105
+ str(output["observed_scene_hash"])
106
+ if output.get("observed_scene_hash") is not None
107
+ else None
108
+ ),
109
+ planning_frame=materialization.planning_frame,
110
+ expected_object_count=len(materialization.objects),
111
+ observed_object_count=(
112
+ int(output["observed_object_count"])
113
+ if output.get("observed_object_count") is not None
114
+ else None
115
+ ),
116
+ )
117
+ if not receipt.verified:
118
+ raise SimulationPlatformError(f"moveit_scene_verification_failed:{receipt.code}")
119
+ if receipt.observed_scene_hash != receipt.expected_scene_hash:
120
+ raise SimulationPlatformError("moveit_scene_hash_mismatch")
121
+ return receipt