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,939 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import asdict, dataclass, field
|
|
4
|
+
from enum import Enum, IntEnum
|
|
5
|
+
from hashlib import sha256
|
|
6
|
+
import json
|
|
7
|
+
from math import isfinite, pi
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from .engineering_request import ValidatedEngineeringRequest
|
|
11
|
+
from .robot_platform import RobotPlatformError, RobotProfileRegistry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TwinError(ValueError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class EvidenceOrigin(str, Enum):
|
|
19
|
+
MEASURED = "measured"
|
|
20
|
+
IMPORTED = "imported"
|
|
21
|
+
USER_DECLARED = "user_declared"
|
|
22
|
+
ROBOT_PROFILE = "robot_profile"
|
|
23
|
+
ESTIMATED = "estimated"
|
|
24
|
+
DEFAULT = "default"
|
|
25
|
+
UNKNOWN = "unknown"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TwinConfidenceLevel(IntEnum):
|
|
29
|
+
CONCEPTUAL = 1
|
|
30
|
+
GEOMETRIC = 2
|
|
31
|
+
PHYSICAL = 3
|
|
32
|
+
REALITY_CALIBRATED = 4
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GeometryKind(str, Enum):
|
|
36
|
+
BOX = "box"
|
|
37
|
+
CYLINDER = "cylinder"
|
|
38
|
+
MESH = "mesh"
|
|
39
|
+
CAD = "cad"
|
|
40
|
+
UNKNOWN = "unknown"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class IssueSeverity(str, Enum):
|
|
44
|
+
ERROR = "error"
|
|
45
|
+
WARNING = "warning"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_JSON_MAX_DEPTH = 8
|
|
49
|
+
_JSON_MAX_ITEMS = 256
|
|
50
|
+
_JSON_MAX_TEXT = 8192
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _validate_json_like(value: Any, *, path: str = "metadata", depth: int = 0) -> None:
|
|
54
|
+
"""Reject unstable/non-serializable metadata before evidence hashing."""
|
|
55
|
+
|
|
56
|
+
if depth > _JSON_MAX_DEPTH:
|
|
57
|
+
raise TwinError(f"metadata_depth_exceeded:{path}")
|
|
58
|
+
if value is None or isinstance(value, (bool, int)):
|
|
59
|
+
return
|
|
60
|
+
if isinstance(value, float):
|
|
61
|
+
if not isfinite(value):
|
|
62
|
+
raise TwinError(f"metadata_non_finite:{path}")
|
|
63
|
+
return
|
|
64
|
+
if isinstance(value, str):
|
|
65
|
+
if len(value) > _JSON_MAX_TEXT:
|
|
66
|
+
raise TwinError(f"metadata_text_too_long:{path}")
|
|
67
|
+
return
|
|
68
|
+
if isinstance(value, (list, tuple)):
|
|
69
|
+
if len(value) > _JSON_MAX_ITEMS:
|
|
70
|
+
raise TwinError(f"metadata_array_too_large:{path}")
|
|
71
|
+
for index, item in enumerate(value):
|
|
72
|
+
_validate_json_like(item, path=f"{path}[{index}]", depth=depth + 1)
|
|
73
|
+
return
|
|
74
|
+
if isinstance(value, Mapping):
|
|
75
|
+
if len(value) > _JSON_MAX_ITEMS:
|
|
76
|
+
raise TwinError(f"metadata_object_too_large:{path}")
|
|
77
|
+
for key, item in value.items():
|
|
78
|
+
if not isinstance(key, str) or not key:
|
|
79
|
+
raise TwinError(f"metadata_key_invalid:{path}")
|
|
80
|
+
_validate_json_like(item, path=f"{path}.{key}", depth=depth + 1)
|
|
81
|
+
return
|
|
82
|
+
raise TwinError(f"metadata_type_unsupported:{path}:{type(value).__name__}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _canonical(value: Any) -> Any:
|
|
86
|
+
if isinstance(value, Enum):
|
|
87
|
+
return value.value
|
|
88
|
+
if isinstance(value, Mapping):
|
|
89
|
+
return {str(key): _canonical(item) for key, item in value.items()}
|
|
90
|
+
if isinstance(value, (list, tuple)):
|
|
91
|
+
return [_canonical(item) for item in value]
|
|
92
|
+
return value
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class EvidenceValue:
|
|
97
|
+
"""A scalar value plus provenance; uncertainty is never erased."""
|
|
98
|
+
|
|
99
|
+
value: float | int | str | bool | None
|
|
100
|
+
origin: EvidenceOrigin
|
|
101
|
+
source_ref: str | None = None
|
|
102
|
+
uncertainty: float | None = None
|
|
103
|
+
|
|
104
|
+
def __post_init__(self) -> None:
|
|
105
|
+
if isinstance(self.value, float) and not isfinite(self.value):
|
|
106
|
+
raise TwinError("evidence_value_non_finite")
|
|
107
|
+
if self.source_ref is not None and not self.source_ref.strip():
|
|
108
|
+
raise TwinError("empty_evidence_source_ref")
|
|
109
|
+
if self.uncertainty is not None and (
|
|
110
|
+
not isfinite(self.uncertainty) or self.uncertainty < 0
|
|
111
|
+
):
|
|
112
|
+
raise TwinError("evidence_uncertainty_invalid")
|
|
113
|
+
if self.origin is EvidenceOrigin.UNKNOWN and self.value is not None:
|
|
114
|
+
raise TwinError("unknown_origin_requires_null_value")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class Pose3D:
|
|
119
|
+
"""Canonical SI pose: translation in metres and RPY in radians."""
|
|
120
|
+
|
|
121
|
+
x_m: float
|
|
122
|
+
y_m: float
|
|
123
|
+
z_m: float
|
|
124
|
+
roll_rad: float = 0.0
|
|
125
|
+
pitch_rad: float = 0.0
|
|
126
|
+
yaw_rad: float = 0.0
|
|
127
|
+
frame_id: str = "world"
|
|
128
|
+
origin: EvidenceOrigin = EvidenceOrigin.USER_DECLARED
|
|
129
|
+
source_ref: str | None = None
|
|
130
|
+
|
|
131
|
+
def __post_init__(self) -> None:
|
|
132
|
+
values = (
|
|
133
|
+
self.x_m,
|
|
134
|
+
self.y_m,
|
|
135
|
+
self.z_m,
|
|
136
|
+
self.roll_rad,
|
|
137
|
+
self.pitch_rad,
|
|
138
|
+
self.yaw_rad,
|
|
139
|
+
)
|
|
140
|
+
if any(not isfinite(value) for value in values):
|
|
141
|
+
raise TwinError("pose_non_finite")
|
|
142
|
+
if not self.frame_id.strip():
|
|
143
|
+
raise TwinError("pose_frame_required")
|
|
144
|
+
if self.source_ref is not None and not self.source_ref.strip():
|
|
145
|
+
raise TwinError("empty_pose_source_ref")
|
|
146
|
+
if any(abs(value) > 2 * pi + 1e-9 for value in self.orientation):
|
|
147
|
+
raise TwinError("pose_orientation_out_of_canonical_range")
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def orientation(self) -> tuple[float, float, float]:
|
|
151
|
+
return self.roll_rad, self.pitch_rad, self.yaw_rad
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, slots=True)
|
|
155
|
+
class FrameTransform:
|
|
156
|
+
"""A named child frame located relative to an already-defined parent frame."""
|
|
157
|
+
|
|
158
|
+
child_frame_id: str
|
|
159
|
+
parent_frame_id: str
|
|
160
|
+
transform: Pose3D
|
|
161
|
+
|
|
162
|
+
def __post_init__(self) -> None:
|
|
163
|
+
if not self.child_frame_id.strip() or not self.parent_frame_id.strip():
|
|
164
|
+
raise TwinError("frame_identity_required")
|
|
165
|
+
if self.child_frame_id == "world":
|
|
166
|
+
raise TwinError("world_frame_cannot_have_parent")
|
|
167
|
+
if self.child_frame_id == self.parent_frame_id:
|
|
168
|
+
raise TwinError("self_parent_frame")
|
|
169
|
+
if self.transform.frame_id != self.parent_frame_id:
|
|
170
|
+
raise TwinError("frame_transform_parent_mismatch")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True, slots=True)
|
|
174
|
+
class GeometrySpec:
|
|
175
|
+
kind: GeometryKind
|
|
176
|
+
origin: EvidenceOrigin
|
|
177
|
+
dimensions_m: tuple[float, ...] = ()
|
|
178
|
+
asset_ref: str | None = None
|
|
179
|
+
collision_geometry: bool = True
|
|
180
|
+
source_ref: str | None = None
|
|
181
|
+
|
|
182
|
+
def __post_init__(self) -> None:
|
|
183
|
+
if any(not isfinite(value) or value <= 0 for value in self.dimensions_m):
|
|
184
|
+
raise TwinError("geometry_dimension_invalid")
|
|
185
|
+
if self.kind is GeometryKind.BOX and len(self.dimensions_m) != 3:
|
|
186
|
+
raise TwinError("box_requires_xyz_dimensions")
|
|
187
|
+
if self.kind is GeometryKind.CYLINDER and len(self.dimensions_m) != 2:
|
|
188
|
+
raise TwinError("cylinder_requires_radius_height")
|
|
189
|
+
if self.kind in {GeometryKind.MESH, GeometryKind.CAD}:
|
|
190
|
+
if not self.asset_ref or not self.asset_ref.strip():
|
|
191
|
+
raise TwinError("geometry_asset_required")
|
|
192
|
+
if self.kind is GeometryKind.UNKNOWN and (self.dimensions_m or self.asset_ref):
|
|
193
|
+
raise TwinError("unknown_geometry_cannot_have_shape_data")
|
|
194
|
+
if self.asset_ref is not None and not self.asset_ref.strip():
|
|
195
|
+
raise TwinError("empty_geometry_asset_ref")
|
|
196
|
+
if self.source_ref is not None and not self.source_ref.strip():
|
|
197
|
+
raise TwinError("empty_geometry_source_ref")
|
|
198
|
+
if self.origin is EvidenceOrigin.UNKNOWN and self.kind is not GeometryKind.UNKNOWN:
|
|
199
|
+
raise TwinError("unknown_geometry_origin_requires_unknown_kind")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True, slots=True)
|
|
203
|
+
class PhysicsSpec:
|
|
204
|
+
mass_kg: EvidenceValue = field(
|
|
205
|
+
default_factory=lambda: EvidenceValue(None, EvidenceOrigin.UNKNOWN)
|
|
206
|
+
)
|
|
207
|
+
friction_coefficient: EvidenceValue = field(
|
|
208
|
+
default_factory=lambda: EvidenceValue(None, EvidenceOrigin.UNKNOWN)
|
|
209
|
+
)
|
|
210
|
+
restitution: EvidenceValue = field(
|
|
211
|
+
default_factory=lambda: EvidenceValue(None, EvidenceOrigin.UNKNOWN)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def __post_init__(self) -> None:
|
|
215
|
+
for name in ("mass_kg", "friction_coefficient", "restitution"):
|
|
216
|
+
evidence = getattr(self, name)
|
|
217
|
+
if evidence.value is None:
|
|
218
|
+
continue
|
|
219
|
+
if isinstance(evidence.value, bool) or not isinstance(
|
|
220
|
+
evidence.value, (int, float)
|
|
221
|
+
):
|
|
222
|
+
raise TwinError(f"physics_{name}_must_be_numeric")
|
|
223
|
+
numeric = float(evidence.value)
|
|
224
|
+
if name == "mass_kg" and numeric <= 0:
|
|
225
|
+
raise TwinError("physics_mass_invalid")
|
|
226
|
+
if name == "friction_coefficient" and numeric < 0:
|
|
227
|
+
raise TwinError("physics_friction_invalid")
|
|
228
|
+
if name == "restitution" and not 0 <= numeric <= 1:
|
|
229
|
+
raise TwinError("physics_restitution_invalid")
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def complete_for_contact(self) -> bool:
|
|
233
|
+
return (
|
|
234
|
+
self.mass_kg.value is not None
|
|
235
|
+
and self.friction_coefficient.value is not None
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True, slots=True)
|
|
240
|
+
class TwinEntity:
|
|
241
|
+
entity_id: str
|
|
242
|
+
entity_type: str
|
|
243
|
+
pose: Pose3D | None
|
|
244
|
+
geometry: GeometrySpec
|
|
245
|
+
physics: PhysicsSpec = field(default_factory=PhysicsSpec)
|
|
246
|
+
enabled: bool = True
|
|
247
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
248
|
+
|
|
249
|
+
def __post_init__(self) -> None:
|
|
250
|
+
if not self.entity_id.strip():
|
|
251
|
+
raise TwinError("entity_id_required")
|
|
252
|
+
if not self.entity_type.strip():
|
|
253
|
+
raise TwinError("entity_type_required")
|
|
254
|
+
if not isinstance(self.metadata, Mapping):
|
|
255
|
+
raise TwinError("entity_metadata_must_be_mapping")
|
|
256
|
+
_validate_json_like(self.metadata, path=f"entity.{self.entity_id}.metadata")
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def geometric_ready(self) -> bool:
|
|
260
|
+
return (
|
|
261
|
+
self.pose is not None
|
|
262
|
+
and self.geometry.kind is not GeometryKind.UNKNOWN
|
|
263
|
+
and self.geometry.collision_geometry
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass(frozen=True, slots=True)
|
|
268
|
+
class ToolSpec:
|
|
269
|
+
tool_id: str
|
|
270
|
+
tcp: Pose3D | None
|
|
271
|
+
geometry: GeometrySpec
|
|
272
|
+
max_payload_kg: EvidenceValue = field(
|
|
273
|
+
default_factory=lambda: EvidenceValue(None, EvidenceOrigin.UNKNOWN)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def __post_init__(self) -> None:
|
|
277
|
+
if not self.tool_id.strip():
|
|
278
|
+
raise TwinError("tool_id_required")
|
|
279
|
+
if self.max_payload_kg.value is not None:
|
|
280
|
+
value = self.max_payload_kg.value
|
|
281
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
282
|
+
raise TwinError("tool_payload_limit_must_be_numeric")
|
|
283
|
+
if float(value) <= 0:
|
|
284
|
+
raise TwinError("tool_payload_limit_invalid")
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass(frozen=True, slots=True)
|
|
288
|
+
class SensorSpec:
|
|
289
|
+
sensor_id: str
|
|
290
|
+
sensor_type: str
|
|
291
|
+
pose: Pose3D | None
|
|
292
|
+
latency_ms: EvidenceValue = field(
|
|
293
|
+
default_factory=lambda: EvidenceValue(None, EvidenceOrigin.UNKNOWN)
|
|
294
|
+
)
|
|
295
|
+
noise_stddev: EvidenceValue = field(
|
|
296
|
+
default_factory=lambda: EvidenceValue(None, EvidenceOrigin.UNKNOWN)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
def __post_init__(self) -> None:
|
|
300
|
+
if not self.sensor_id.strip() or not self.sensor_type.strip():
|
|
301
|
+
raise TwinError("sensor_identity_required")
|
|
302
|
+
for name in ("latency_ms", "noise_stddev"):
|
|
303
|
+
evidence = getattr(self, name)
|
|
304
|
+
if evidence.value is None:
|
|
305
|
+
continue
|
|
306
|
+
if isinstance(evidence.value, bool) or not isinstance(
|
|
307
|
+
evidence.value, (int, float)
|
|
308
|
+
):
|
|
309
|
+
raise TwinError(f"sensor_{name}_must_be_numeric")
|
|
310
|
+
if float(evidence.value) < 0:
|
|
311
|
+
raise TwinError(f"sensor_{name}_invalid")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@dataclass(frozen=True, slots=True)
|
|
315
|
+
class ControllerModel:
|
|
316
|
+
speed_scale_pct: EvidenceValue = field(
|
|
317
|
+
default_factory=lambda: EvidenceValue(None, EvidenceOrigin.UNKNOWN)
|
|
318
|
+
)
|
|
319
|
+
command_latency_ms: EvidenceValue = field(
|
|
320
|
+
default_factory=lambda: EvidenceValue(None, EvidenceOrigin.UNKNOWN)
|
|
321
|
+
)
|
|
322
|
+
state_latency_ms: EvidenceValue = field(
|
|
323
|
+
default_factory=lambda: EvidenceValue(None, EvidenceOrigin.UNKNOWN)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def __post_init__(self) -> None:
|
|
327
|
+
if self.speed_scale_pct.value is not None:
|
|
328
|
+
value = self.speed_scale_pct.value
|
|
329
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
330
|
+
raise TwinError("controller_speed_scale_must_be_numeric")
|
|
331
|
+
if not 0 < float(value) <= 100:
|
|
332
|
+
raise TwinError("controller_speed_scale_invalid")
|
|
333
|
+
for name in ("command_latency_ms", "state_latency_ms"):
|
|
334
|
+
evidence = getattr(self, name)
|
|
335
|
+
if evidence.value is None:
|
|
336
|
+
continue
|
|
337
|
+
if isinstance(evidence.value, bool) or not isinstance(
|
|
338
|
+
evidence.value, (int, float)
|
|
339
|
+
):
|
|
340
|
+
raise TwinError(f"controller_{name}_must_be_numeric")
|
|
341
|
+
if float(evidence.value) < 0:
|
|
342
|
+
raise TwinError(f"controller_{name}_invalid")
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def measured_or_declared(self) -> bool:
|
|
346
|
+
return any(
|
|
347
|
+
evidence.value is not None
|
|
348
|
+
for evidence in (
|
|
349
|
+
self.speed_scale_pct,
|
|
350
|
+
self.command_latency_ms,
|
|
351
|
+
self.state_latency_ms,
|
|
352
|
+
)
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@dataclass(frozen=True, slots=True)
|
|
357
|
+
class UncertaintyModel:
|
|
358
|
+
pose_translation_mm: EvidenceValue = field(
|
|
359
|
+
default_factory=lambda: EvidenceValue(None, EvidenceOrigin.UNKNOWN)
|
|
360
|
+
)
|
|
361
|
+
pose_rotation_deg: EvidenceValue = field(
|
|
362
|
+
default_factory=lambda: EvidenceValue(None, EvidenceOrigin.UNKNOWN)
|
|
363
|
+
)
|
|
364
|
+
payload_kg: EvidenceValue = field(
|
|
365
|
+
default_factory=lambda: EvidenceValue(None, EvidenceOrigin.UNKNOWN)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def __post_init__(self) -> None:
|
|
369
|
+
for name in ("pose_translation_mm", "pose_rotation_deg", "payload_kg"):
|
|
370
|
+
evidence = getattr(self, name)
|
|
371
|
+
if evidence.value is None:
|
|
372
|
+
continue
|
|
373
|
+
if isinstance(evidence.value, bool) or not isinstance(
|
|
374
|
+
evidence.value, (int, float)
|
|
375
|
+
):
|
|
376
|
+
raise TwinError(f"uncertainty_{name}_must_be_numeric")
|
|
377
|
+
if float(evidence.value) < 0:
|
|
378
|
+
raise TwinError(f"uncertainty_{name}_invalid")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@dataclass(frozen=True, slots=True)
|
|
382
|
+
class RealityCalibrationEvidence:
|
|
383
|
+
observation_count: int = 0
|
|
384
|
+
cycle_time_gap_pct_p95: float | None = None
|
|
385
|
+
tcp_error_mm_p95: float | None = None
|
|
386
|
+
behavior_agreement: float | None = None
|
|
387
|
+
origin: EvidenceOrigin = EvidenceOrigin.UNKNOWN
|
|
388
|
+
dataset_ref: str | None = None
|
|
389
|
+
|
|
390
|
+
def __post_init__(self) -> None:
|
|
391
|
+
if self.observation_count < 0:
|
|
392
|
+
raise TwinError("reality_observation_count_invalid")
|
|
393
|
+
for name in ("cycle_time_gap_pct_p95", "tcp_error_mm_p95"):
|
|
394
|
+
value = getattr(self, name)
|
|
395
|
+
if value is not None and (not isfinite(value) or value < 0):
|
|
396
|
+
raise TwinError(f"reality_{name}_invalid")
|
|
397
|
+
if self.behavior_agreement is not None and (
|
|
398
|
+
not isfinite(self.behavior_agreement)
|
|
399
|
+
or not 0 <= self.behavior_agreement <= 1
|
|
400
|
+
):
|
|
401
|
+
raise TwinError("reality_behavior_agreement_invalid")
|
|
402
|
+
if self.dataset_ref is not None and not self.dataset_ref.strip():
|
|
403
|
+
raise TwinError("empty_reality_dataset_ref")
|
|
404
|
+
if self.observation_count > 0 and self.origin is not EvidenceOrigin.MEASURED:
|
|
405
|
+
raise TwinError("reality_calibration_requires_measured_origin")
|
|
406
|
+
|
|
407
|
+
@property
|
|
408
|
+
def calibrated(self) -> bool:
|
|
409
|
+
quantitative_gap = (
|
|
410
|
+
self.cycle_time_gap_pct_p95 is not None
|
|
411
|
+
or self.tcp_error_mm_p95 is not None
|
|
412
|
+
)
|
|
413
|
+
return (
|
|
414
|
+
self.observation_count > 0
|
|
415
|
+
and self.origin is EvidenceOrigin.MEASURED
|
|
416
|
+
and self.behavior_agreement is not None
|
|
417
|
+
and quantitative_gap
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@dataclass(frozen=True, slots=True)
|
|
422
|
+
class TwinSpec:
|
|
423
|
+
twin_id: str
|
|
424
|
+
robot_profile_key: str
|
|
425
|
+
robot_base_pose: Pose3D | None
|
|
426
|
+
entities: tuple[TwinEntity, ...]
|
|
427
|
+
source_entity_id: str
|
|
428
|
+
destination_entity_id: str
|
|
429
|
+
workpiece_entity_id: str | None = None
|
|
430
|
+
tool: ToolSpec | None = None
|
|
431
|
+
frames: tuple[FrameTransform, ...] = ()
|
|
432
|
+
sensors: tuple[SensorSpec, ...] = ()
|
|
433
|
+
controller: ControllerModel = field(default_factory=ControllerModel)
|
|
434
|
+
uncertainty: UncertaintyModel = field(default_factory=UncertaintyModel)
|
|
435
|
+
reality: RealityCalibrationEvidence = field(default_factory=RealityCalibrationEvidence)
|
|
436
|
+
metadata: Mapping[str, Any] = field(default_factory=dict)
|
|
437
|
+
|
|
438
|
+
def __post_init__(self) -> None:
|
|
439
|
+
for name, value in (
|
|
440
|
+
("twin_id", self.twin_id),
|
|
441
|
+
("robot_profile_key", self.robot_profile_key),
|
|
442
|
+
("source_entity_id", self.source_entity_id),
|
|
443
|
+
("destination_entity_id", self.destination_entity_id),
|
|
444
|
+
):
|
|
445
|
+
if not value.strip():
|
|
446
|
+
raise TwinError(f"{name}_required")
|
|
447
|
+
if self.workpiece_entity_id is not None and not self.workpiece_entity_id.strip():
|
|
448
|
+
raise TwinError("workpiece_entity_id_empty")
|
|
449
|
+
if not isinstance(self.metadata, Mapping):
|
|
450
|
+
raise TwinError("twin_metadata_must_be_mapping")
|
|
451
|
+
_validate_json_like(self.metadata)
|
|
452
|
+
|
|
453
|
+
def entity_map(self) -> dict[str, TwinEntity]:
|
|
454
|
+
return {entity.entity_id: entity for entity in self.entities}
|
|
455
|
+
|
|
456
|
+
def frame_ids(self) -> frozenset[str]:
|
|
457
|
+
return frozenset({"world", *(frame.child_frame_id for frame in self.frames)})
|
|
458
|
+
|
|
459
|
+
def to_dict(self) -> dict[str, Any]:
|
|
460
|
+
return {
|
|
461
|
+
"twin_id": self.twin_id,
|
|
462
|
+
"robot_profile_key": self.robot_profile_key,
|
|
463
|
+
"robot_base_pose": asdict(self.robot_base_pose) if self.robot_base_pose else None,
|
|
464
|
+
"entities": [asdict(entity) for entity in self.entities],
|
|
465
|
+
"source_entity_id": self.source_entity_id,
|
|
466
|
+
"destination_entity_id": self.destination_entity_id,
|
|
467
|
+
"workpiece_entity_id": self.workpiece_entity_id,
|
|
468
|
+
"tool": asdict(self.tool) if self.tool else None,
|
|
469
|
+
"frames": [asdict(frame) for frame in self.frames],
|
|
470
|
+
"sensors": [asdict(sensor) for sensor in self.sensors],
|
|
471
|
+
"controller": asdict(self.controller),
|
|
472
|
+
"uncertainty": asdict(self.uncertainty),
|
|
473
|
+
"reality": asdict(self.reality),
|
|
474
|
+
"metadata": dict(self.metadata),
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
@property
|
|
478
|
+
def fingerprint(self) -> str:
|
|
479
|
+
payload = json.dumps(
|
|
480
|
+
_canonical(self.to_dict()),
|
|
481
|
+
sort_keys=True,
|
|
482
|
+
separators=(",", ":"),
|
|
483
|
+
ensure_ascii=False,
|
|
484
|
+
)
|
|
485
|
+
return sha256(payload.encode("utf-8")).hexdigest()
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@dataclass(frozen=True, slots=True)
|
|
489
|
+
class TwinIssue:
|
|
490
|
+
severity: IssueSeverity
|
|
491
|
+
code: str
|
|
492
|
+
detail: str
|
|
493
|
+
entity_id: str | None = None
|
|
494
|
+
|
|
495
|
+
def __post_init__(self) -> None:
|
|
496
|
+
if not self.code.strip() or not self.detail.strip():
|
|
497
|
+
raise TwinError("twin_issue_content_required")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@dataclass(frozen=True, slots=True)
|
|
501
|
+
class TwinValidationReport:
|
|
502
|
+
twin_id: str
|
|
503
|
+
twin_hash: str
|
|
504
|
+
confidence_level: TwinConfidenceLevel
|
|
505
|
+
structurally_valid: bool
|
|
506
|
+
ready_for_planning: bool
|
|
507
|
+
ready_for_physics: bool
|
|
508
|
+
reality_calibrated: bool
|
|
509
|
+
issues: tuple[TwinIssue, ...]
|
|
510
|
+
|
|
511
|
+
@property
|
|
512
|
+
def errors(self) -> tuple[TwinIssue, ...]:
|
|
513
|
+
return tuple(
|
|
514
|
+
issue for issue in self.issues if issue.severity is IssueSeverity.ERROR
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
@property
|
|
518
|
+
def warnings(self) -> tuple[TwinIssue, ...]:
|
|
519
|
+
return tuple(
|
|
520
|
+
issue for issue in self.issues if issue.severity is IssueSeverity.WARNING
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
def to_dict(self) -> dict[str, Any]:
|
|
524
|
+
return {
|
|
525
|
+
"twin_id": self.twin_id,
|
|
526
|
+
"twin_hash": self.twin_hash,
|
|
527
|
+
"confidence_level": int(self.confidence_level),
|
|
528
|
+
"confidence_name": self.confidence_level.name.lower(),
|
|
529
|
+
"structurally_valid": self.structurally_valid,
|
|
530
|
+
"ready_for_planning": self.ready_for_planning,
|
|
531
|
+
"ready_for_physics": self.ready_for_physics,
|
|
532
|
+
"reality_calibrated": self.reality_calibrated,
|
|
533
|
+
"issues": [
|
|
534
|
+
{
|
|
535
|
+
"severity": issue.severity.value,
|
|
536
|
+
"code": issue.code,
|
|
537
|
+
"detail": issue.detail,
|
|
538
|
+
"entity_id": issue.entity_id,
|
|
539
|
+
}
|
|
540
|
+
for issue in self.issues
|
|
541
|
+
],
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class TwinValidator:
|
|
546
|
+
"""Fail-closed validation for a customer simulation twin."""
|
|
547
|
+
|
|
548
|
+
def __init__(self, robot_profiles: RobotProfileRegistry) -> None:
|
|
549
|
+
self.robot_profiles = robot_profiles
|
|
550
|
+
|
|
551
|
+
@staticmethod
|
|
552
|
+
def _validate_frames(twin: TwinSpec, issues: list[TwinIssue]) -> frozenset[str]:
|
|
553
|
+
children = [frame.child_frame_id for frame in twin.frames]
|
|
554
|
+
if len(children) != len(set(children)):
|
|
555
|
+
issues.append(
|
|
556
|
+
TwinIssue(
|
|
557
|
+
IssueSeverity.ERROR,
|
|
558
|
+
"duplicate_frame_id",
|
|
559
|
+
"Each child frame may be declared only once.",
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
frame_map = {frame.child_frame_id: frame.parent_frame_id for frame in twin.frames}
|
|
564
|
+
known = {"world", *frame_map.keys()}
|
|
565
|
+
for child, parent in frame_map.items():
|
|
566
|
+
if parent not in known:
|
|
567
|
+
issues.append(
|
|
568
|
+
TwinIssue(
|
|
569
|
+
IssueSeverity.ERROR,
|
|
570
|
+
"unknown_parent_frame",
|
|
571
|
+
f"Frame '{child}' references unknown parent '{parent}'.",
|
|
572
|
+
)
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
for child in frame_map:
|
|
576
|
+
visited: set[str] = set()
|
|
577
|
+
current = child
|
|
578
|
+
while current in frame_map:
|
|
579
|
+
if current in visited:
|
|
580
|
+
issues.append(
|
|
581
|
+
TwinIssue(
|
|
582
|
+
IssueSeverity.ERROR,
|
|
583
|
+
"frame_cycle",
|
|
584
|
+
f"Frame chain containing '{child}' is cyclic.",
|
|
585
|
+
)
|
|
586
|
+
)
|
|
587
|
+
break
|
|
588
|
+
visited.add(current)
|
|
589
|
+
current = frame_map[current]
|
|
590
|
+
|
|
591
|
+
return frozenset(known)
|
|
592
|
+
|
|
593
|
+
@staticmethod
|
|
594
|
+
def _check_pose_frame(
|
|
595
|
+
pose: Pose3D | None,
|
|
596
|
+
*,
|
|
597
|
+
known_frames: frozenset[str],
|
|
598
|
+
owner: str,
|
|
599
|
+
issues: list[TwinIssue],
|
|
600
|
+
entity_id: str | None = None,
|
|
601
|
+
) -> None:
|
|
602
|
+
if pose is not None and pose.frame_id not in known_frames:
|
|
603
|
+
issues.append(
|
|
604
|
+
TwinIssue(
|
|
605
|
+
IssueSeverity.ERROR,
|
|
606
|
+
"unknown_pose_frame",
|
|
607
|
+
f"{owner} references undeclared frame '{pose.frame_id}'.",
|
|
608
|
+
entity_id,
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
def validate(
|
|
613
|
+
self,
|
|
614
|
+
twin: TwinSpec,
|
|
615
|
+
*,
|
|
616
|
+
request: ValidatedEngineeringRequest | None = None,
|
|
617
|
+
) -> TwinValidationReport:
|
|
618
|
+
issues: list[TwinIssue] = []
|
|
619
|
+
try:
|
|
620
|
+
profile = self.robot_profiles.resolve(twin.robot_profile_key)
|
|
621
|
+
except RobotPlatformError:
|
|
622
|
+
profile = None
|
|
623
|
+
issues.append(
|
|
624
|
+
TwinIssue(
|
|
625
|
+
IssueSeverity.ERROR,
|
|
626
|
+
"unknown_robot_profile",
|
|
627
|
+
f"Robot profile '{twin.robot_profile_key}' is not registered.",
|
|
628
|
+
)
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
known_frames = self._validate_frames(twin, issues)
|
|
632
|
+
self._check_pose_frame(
|
|
633
|
+
twin.robot_base_pose,
|
|
634
|
+
known_frames=known_frames,
|
|
635
|
+
owner="Robot base pose",
|
|
636
|
+
issues=issues,
|
|
637
|
+
)
|
|
638
|
+
if twin.tool is not None:
|
|
639
|
+
self._check_pose_frame(
|
|
640
|
+
twin.tool.tcp,
|
|
641
|
+
known_frames=known_frames,
|
|
642
|
+
owner="Tool TCP",
|
|
643
|
+
issues=issues,
|
|
644
|
+
)
|
|
645
|
+
for sensor in twin.sensors:
|
|
646
|
+
self._check_pose_frame(
|
|
647
|
+
sensor.pose,
|
|
648
|
+
known_frames=known_frames,
|
|
649
|
+
owner=f"Sensor '{sensor.sensor_id}'",
|
|
650
|
+
issues=issues,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
ids = [entity.entity_id for entity in twin.entities]
|
|
654
|
+
duplicate_ids = sorted(
|
|
655
|
+
{entity_id for entity_id in ids if ids.count(entity_id) > 1}
|
|
656
|
+
)
|
|
657
|
+
for entity_id in duplicate_ids:
|
|
658
|
+
issues.append(
|
|
659
|
+
TwinIssue(
|
|
660
|
+
IssueSeverity.ERROR,
|
|
661
|
+
"duplicate_entity_id",
|
|
662
|
+
"Twin entity ids must be unique.",
|
|
663
|
+
entity_id,
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
entity_map = twin.entity_map()
|
|
668
|
+
for role, entity_id in (
|
|
669
|
+
("source", twin.source_entity_id),
|
|
670
|
+
("destination", twin.destination_entity_id),
|
|
671
|
+
):
|
|
672
|
+
if entity_id not in entity_map:
|
|
673
|
+
issues.append(
|
|
674
|
+
TwinIssue(
|
|
675
|
+
IssueSeverity.ERROR,
|
|
676
|
+
f"missing_{role}_entity",
|
|
677
|
+
f"The {role} entity '{entity_id}' is not present in the twin.",
|
|
678
|
+
entity_id,
|
|
679
|
+
)
|
|
680
|
+
)
|
|
681
|
+
if twin.workpiece_entity_id and twin.workpiece_entity_id not in entity_map:
|
|
682
|
+
issues.append(
|
|
683
|
+
TwinIssue(
|
|
684
|
+
IssueSeverity.ERROR,
|
|
685
|
+
"missing_workpiece_entity",
|
|
686
|
+
f"The workpiece entity '{twin.workpiece_entity_id}' is not present in the twin.",
|
|
687
|
+
twin.workpiece_entity_id,
|
|
688
|
+
)
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
if twin.robot_base_pose is None:
|
|
692
|
+
issues.append(
|
|
693
|
+
TwinIssue(
|
|
694
|
+
IssueSeverity.ERROR,
|
|
695
|
+
"robot_base_pose_missing",
|
|
696
|
+
"Robot base pose is required before geometric planning.",
|
|
697
|
+
)
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
if twin.tool is None:
|
|
701
|
+
issues.append(
|
|
702
|
+
TwinIssue(
|
|
703
|
+
IssueSeverity.WARNING,
|
|
704
|
+
"tool_not_specified",
|
|
705
|
+
"Tool/TCP is not specified; geometric confidence is limited.",
|
|
706
|
+
)
|
|
707
|
+
)
|
|
708
|
+
else:
|
|
709
|
+
if twin.tool.tcp is None:
|
|
710
|
+
issues.append(
|
|
711
|
+
TwinIssue(
|
|
712
|
+
IssueSeverity.ERROR,
|
|
713
|
+
"tool_tcp_missing",
|
|
714
|
+
"A configured tool requires a TCP pose before planning.",
|
|
715
|
+
)
|
|
716
|
+
)
|
|
717
|
+
if twin.tool.geometry.kind is GeometryKind.UNKNOWN:
|
|
718
|
+
issues.append(
|
|
719
|
+
TwinIssue(
|
|
720
|
+
IssueSeverity.WARNING,
|
|
721
|
+
"tool_geometry_unknown",
|
|
722
|
+
"Tool collision geometry is unknown.",
|
|
723
|
+
)
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
for entity in twin.entities:
|
|
727
|
+
if not entity.enabled:
|
|
728
|
+
continue
|
|
729
|
+
if entity.pose is None:
|
|
730
|
+
issues.append(
|
|
731
|
+
TwinIssue(
|
|
732
|
+
IssueSeverity.ERROR,
|
|
733
|
+
"entity_pose_missing",
|
|
734
|
+
"Enabled entity has no pose.",
|
|
735
|
+
entity.entity_id,
|
|
736
|
+
)
|
|
737
|
+
)
|
|
738
|
+
else:
|
|
739
|
+
self._check_pose_frame(
|
|
740
|
+
entity.pose,
|
|
741
|
+
known_frames=known_frames,
|
|
742
|
+
owner=f"Entity '{entity.entity_id}'",
|
|
743
|
+
issues=issues,
|
|
744
|
+
entity_id=entity.entity_id,
|
|
745
|
+
)
|
|
746
|
+
if entity.geometry.kind is GeometryKind.UNKNOWN:
|
|
747
|
+
issues.append(
|
|
748
|
+
TwinIssue(
|
|
749
|
+
IssueSeverity.ERROR,
|
|
750
|
+
"entity_geometry_unknown",
|
|
751
|
+
"Enabled entity has no usable geometry.",
|
|
752
|
+
entity.entity_id,
|
|
753
|
+
)
|
|
754
|
+
)
|
|
755
|
+
if not entity.geometry.collision_geometry:
|
|
756
|
+
issues.append(
|
|
757
|
+
TwinIssue(
|
|
758
|
+
IssueSeverity.WARNING,
|
|
759
|
+
"collision_geometry_disabled",
|
|
760
|
+
"Entity is excluded from collision geometry.",
|
|
761
|
+
entity.entity_id,
|
|
762
|
+
)
|
|
763
|
+
)
|
|
764
|
+
for dimension in entity.geometry.dimensions_m:
|
|
765
|
+
if dimension > 100:
|
|
766
|
+
issues.append(
|
|
767
|
+
TwinIssue(
|
|
768
|
+
IssueSeverity.ERROR,
|
|
769
|
+
"possible_unit_mismatch",
|
|
770
|
+
f"Geometry dimension {dimension:g} m is unusually large for a robot cell; confirm mm/m conversion before planning.",
|
|
771
|
+
entity.entity_id,
|
|
772
|
+
)
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
sensor_ids = [sensor.sensor_id for sensor in twin.sensors]
|
|
776
|
+
if len(sensor_ids) != len(set(sensor_ids)):
|
|
777
|
+
issues.append(
|
|
778
|
+
TwinIssue(
|
|
779
|
+
IssueSeverity.ERROR,
|
|
780
|
+
"duplicate_sensor_id",
|
|
781
|
+
"Sensor ids must be unique.",
|
|
782
|
+
)
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
if request is not None:
|
|
786
|
+
try:
|
|
787
|
+
request_robot = self.robot_profiles.resolve(request.robot_key).key
|
|
788
|
+
except RobotPlatformError:
|
|
789
|
+
request_robot = request.robot_key
|
|
790
|
+
twin_robot = profile.key if profile is not None else twin.robot_profile_key
|
|
791
|
+
if request_robot != twin_robot:
|
|
792
|
+
issues.append(
|
|
793
|
+
TwinIssue(
|
|
794
|
+
IssueSeverity.ERROR,
|
|
795
|
+
"request_robot_twin_mismatch",
|
|
796
|
+
f"Request robot '{request.robot_key}' differs from twin robot '{twin.robot_profile_key}'.",
|
|
797
|
+
)
|
|
798
|
+
)
|
|
799
|
+
if request.source != twin.source_entity_id:
|
|
800
|
+
issues.append(
|
|
801
|
+
TwinIssue(
|
|
802
|
+
IssueSeverity.ERROR,
|
|
803
|
+
"request_source_twin_mismatch",
|
|
804
|
+
f"Request source '{request.source}' differs from twin source '{twin.source_entity_id}'.",
|
|
805
|
+
)
|
|
806
|
+
)
|
|
807
|
+
if request.destination != twin.destination_entity_id:
|
|
808
|
+
issues.append(
|
|
809
|
+
TwinIssue(
|
|
810
|
+
IssueSeverity.ERROR,
|
|
811
|
+
"request_destination_twin_mismatch",
|
|
812
|
+
f"Request destination '{request.destination}' differs from twin destination '{twin.destination_entity_id}'.",
|
|
813
|
+
)
|
|
814
|
+
)
|
|
815
|
+
if request.payload_kg is not None and profile is not None:
|
|
816
|
+
if (
|
|
817
|
+
profile.limits.payload_kg is not None
|
|
818
|
+
and request.payload_kg > profile.limits.payload_kg
|
|
819
|
+
):
|
|
820
|
+
issues.append(
|
|
821
|
+
TwinIssue(
|
|
822
|
+
IssueSeverity.ERROR,
|
|
823
|
+
"robot_payload_limit_exceeded",
|
|
824
|
+
f"Requested payload {request.payload_kg:g} kg exceeds qualified profile limit {profile.limits.payload_kg:g} kg.",
|
|
825
|
+
)
|
|
826
|
+
)
|
|
827
|
+
if twin.tool is not None and twin.tool.max_payload_kg.value is not None:
|
|
828
|
+
tool_limit = float(twin.tool.max_payload_kg.value)
|
|
829
|
+
if request.payload_kg > tool_limit:
|
|
830
|
+
issues.append(
|
|
831
|
+
TwinIssue(
|
|
832
|
+
IssueSeverity.ERROR,
|
|
833
|
+
"tool_payload_limit_exceeded",
|
|
834
|
+
f"Requested payload {request.payload_kg:g} kg exceeds tool limit {tool_limit:g} kg.",
|
|
835
|
+
)
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
structural_errors = [
|
|
839
|
+
issue for issue in issues if issue.severity is IssueSeverity.ERROR
|
|
840
|
+
]
|
|
841
|
+
structurally_valid = not structural_errors
|
|
842
|
+
|
|
843
|
+
enabled_entities = [entity for entity in twin.entities if entity.enabled]
|
|
844
|
+
geometry_complete = (
|
|
845
|
+
twin.robot_base_pose is not None
|
|
846
|
+
and twin.tool is not None
|
|
847
|
+
and twin.tool.tcp is not None
|
|
848
|
+
and twin.tool.geometry.kind is not GeometryKind.UNKNOWN
|
|
849
|
+
and all(entity.geometric_ready for entity in enabled_entities)
|
|
850
|
+
and twin.source_entity_id in entity_map
|
|
851
|
+
and twin.destination_entity_id in entity_map
|
|
852
|
+
)
|
|
853
|
+
ready_for_planning = structurally_valid and geometry_complete
|
|
854
|
+
|
|
855
|
+
workpiece = (
|
|
856
|
+
entity_map.get(twin.workpiece_entity_id)
|
|
857
|
+
if twin.workpiece_entity_id is not None
|
|
858
|
+
else None
|
|
859
|
+
)
|
|
860
|
+
physics_complete = (
|
|
861
|
+
ready_for_planning
|
|
862
|
+
and workpiece is not None
|
|
863
|
+
and workpiece.physics.complete_for_contact
|
|
864
|
+
and twin.controller.measured_or_declared
|
|
865
|
+
)
|
|
866
|
+
if ready_for_planning and not physics_complete:
|
|
867
|
+
issues.append(
|
|
868
|
+
TwinIssue(
|
|
869
|
+
IssueSeverity.WARNING,
|
|
870
|
+
"physics_evidence_incomplete",
|
|
871
|
+
"Geometry is planning-ready, but workpiece physics/controller evidence is incomplete.",
|
|
872
|
+
)
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
reality_calibrated = physics_complete and twin.reality.calibrated
|
|
876
|
+
if reality_calibrated:
|
|
877
|
+
confidence = TwinConfidenceLevel.REALITY_CALIBRATED
|
|
878
|
+
elif physics_complete:
|
|
879
|
+
confidence = TwinConfidenceLevel.PHYSICAL
|
|
880
|
+
elif ready_for_planning:
|
|
881
|
+
confidence = TwinConfidenceLevel.GEOMETRIC
|
|
882
|
+
else:
|
|
883
|
+
confidence = TwinConfidenceLevel.CONCEPTUAL
|
|
884
|
+
|
|
885
|
+
return TwinValidationReport(
|
|
886
|
+
twin_id=twin.twin_id,
|
|
887
|
+
twin_hash=twin.fingerprint,
|
|
888
|
+
confidence_level=confidence,
|
|
889
|
+
structurally_valid=structurally_valid,
|
|
890
|
+
ready_for_planning=ready_for_planning,
|
|
891
|
+
ready_for_physics=physics_complete,
|
|
892
|
+
reality_calibrated=reality_calibrated,
|
|
893
|
+
issues=tuple(issues),
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def evidence_summary(twin: TwinSpec) -> dict[str, int]:
|
|
898
|
+
"""Count provenance across important twin values for report/UI use."""
|
|
899
|
+
|
|
900
|
+
origins: list[EvidenceOrigin] = []
|
|
901
|
+
if twin.robot_base_pose is not None:
|
|
902
|
+
origins.append(twin.robot_base_pose.origin)
|
|
903
|
+
for frame in twin.frames:
|
|
904
|
+
origins.append(frame.transform.origin)
|
|
905
|
+
if twin.tool is not None:
|
|
906
|
+
if twin.tool.tcp is not None:
|
|
907
|
+
origins.append(twin.tool.tcp.origin)
|
|
908
|
+
origins.append(twin.tool.geometry.origin)
|
|
909
|
+
origins.append(twin.tool.max_payload_kg.origin)
|
|
910
|
+
for entity in twin.entities:
|
|
911
|
+
if entity.pose is not None:
|
|
912
|
+
origins.append(entity.pose.origin)
|
|
913
|
+
origins.append(entity.geometry.origin)
|
|
914
|
+
origins.extend(
|
|
915
|
+
(
|
|
916
|
+
entity.physics.mass_kg.origin,
|
|
917
|
+
entity.physics.friction_coefficient.origin,
|
|
918
|
+
entity.physics.restitution.origin,
|
|
919
|
+
)
|
|
920
|
+
)
|
|
921
|
+
for sensor in twin.sensors:
|
|
922
|
+
if sensor.pose is not None:
|
|
923
|
+
origins.append(sensor.pose.origin)
|
|
924
|
+
origins.extend((sensor.latency_ms.origin, sensor.noise_stddev.origin))
|
|
925
|
+
origins.extend(
|
|
926
|
+
(
|
|
927
|
+
twin.controller.speed_scale_pct.origin,
|
|
928
|
+
twin.controller.command_latency_ms.origin,
|
|
929
|
+
twin.controller.state_latency_ms.origin,
|
|
930
|
+
twin.uncertainty.pose_translation_mm.origin,
|
|
931
|
+
twin.uncertainty.pose_rotation_deg.origin,
|
|
932
|
+
twin.uncertainty.payload_kg.origin,
|
|
933
|
+
twin.reality.origin,
|
|
934
|
+
)
|
|
935
|
+
)
|
|
936
|
+
return {
|
|
937
|
+
origin.value: sum(1 for value in origins if value is origin)
|
|
938
|
+
for origin in EvidenceOrigin
|
|
939
|
+
}
|