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