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