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,630 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass, field
4
+ from enum import Enum
5
+ from hashlib import sha256
6
+ import json
7
+ from math import isfinite
8
+ import re
9
+ from typing import Any, Mapping, Sequence
10
+
11
+ from .models import Goal, Resource
12
+ from .planning import SUPPORTED_GOAL_ACTIONS
13
+ from .robots import CATALOG
14
+
15
+
16
+ MAX_REQUEST_TEXT = 4096
17
+ MAX_LIST_ITEMS = 32
18
+ MAX_RUNS = 10_000
19
+
20
+
21
+ class EngineeringRequestError(ValueError):
22
+ pass
23
+
24
+
25
+ class RequestState(str, Enum):
26
+ DRAFT = "draft"
27
+ NEEDS_INFORMATION = "needs_information"
28
+ READY_FOR_VALIDATION = "ready_for_validation"
29
+ VALIDATED = "validated"
30
+ REJECTED = "rejected"
31
+
32
+
33
+ class RequestPurpose(str, Enum):
34
+ PLAN = "plan"
35
+ SIMULATE = "simulate"
36
+ QUALIFY = "qualify"
37
+ COMMISSIONING = "commissioning"
38
+
39
+
40
+ class Objective(str, Enum):
41
+ BALANCED = "balanced"
42
+ CYCLE_TIME = "cycle_time"
43
+ ROBUSTNESS = "robustness"
44
+ CLEARANCE = "clearance"
45
+ ENERGY = "energy"
46
+
47
+
48
+ class TestPreset(str, Enum):
49
+ QUICK = "quick"
50
+ ENGINEERING = "engineering"
51
+ ROBUSTNESS = "robustness"
52
+ STRESS = "stress"
53
+ CUSTOM = "custom"
54
+
55
+
56
+ class FaultIntent(str, Enum):
57
+ MISSING_OBJECT = "missing_object"
58
+ DESTINATION_OCCUPIED = "destination_occupied"
59
+ ROBOT_UNAVAILABLE = "robot_unavailable"
60
+ CONTROLLER_TIMEOUT = "controller_timeout"
61
+ COLLISION_OBSTACLE = "collision_obstacle"
62
+ SENSOR_LATENCY = "sensor_latency"
63
+ POSE_UNCERTAINTY = "pose_uncertainty"
64
+
65
+
66
+ _OPERATION_ALIASES = {
67
+ "load": "load",
68
+ "machine_load": "load",
69
+ "machine_loading": "load",
70
+ "machine loading": "load",
71
+ "pick_and_place": "pick_place",
72
+ "pick-place": "pick_place",
73
+ "pick place": "pick_place",
74
+ "pick_place": "pick_place",
75
+ "palletize": "palletize",
76
+ "palletizing": "palletize",
77
+ "inspect": "inspect",
78
+ "inspection": "inspect",
79
+ "transfer": "transfer",
80
+ }
81
+
82
+ _ROBOT_ALIASES = {
83
+ "ur5e": "ur5e",
84
+ "ur 5e": "ur5e",
85
+ "universal robots ur5e": "ur5e",
86
+ "universal robot ur5e": "ur5e",
87
+ "fanuc crx": "fanuc_crx",
88
+ "fanuc_crx": "fanuc_crx",
89
+ "crx": "fanuc_crx",
90
+ "kuka kr": "kuka_kr",
91
+ "kuka_kr": "kuka_kr",
92
+ "abb irb": "abb_irb",
93
+ "abb_irb": "abb_irb",
94
+ }
95
+
96
+ _CORE_REQUIRED_FIELDS = ("robot", "operation", "object_id", "source", "destination")
97
+ _CRITICAL_FIELDS = frozenset(_CORE_REQUIRED_FIELDS + ("payload_kg",))
98
+ _SAFE_SYSTEM_CONSTRAINTS = ("collision_free", "joint_limits")
99
+
100
+
101
+ def _clean_text(value: object, *, field_name: str, required: bool = False) -> str | None:
102
+ if value is None:
103
+ if required:
104
+ raise EngineeringRequestError(f"{field_name}_required")
105
+ return None
106
+ if not isinstance(value, str):
107
+ raise EngineeringRequestError(f"{field_name}_must_be_string")
108
+ text = value.strip()
109
+ if not text:
110
+ if required:
111
+ raise EngineeringRequestError(f"{field_name}_required")
112
+ return None
113
+ if len(text) > MAX_REQUEST_TEXT:
114
+ raise EngineeringRequestError(f"{field_name}_too_long")
115
+ return text
116
+
117
+
118
+ def _optional_number(value: object, *, field_name: str, minimum: float | None = None, maximum: float | None = None) -> float | None:
119
+ if value is None:
120
+ return None
121
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
122
+ raise EngineeringRequestError(f"{field_name}_must_be_number")
123
+ result = float(value)
124
+ if not isfinite(result):
125
+ raise EngineeringRequestError(f"{field_name}_non_finite")
126
+ if minimum is not None and result < minimum:
127
+ raise EngineeringRequestError(f"{field_name}_below_minimum")
128
+ if maximum is not None and result > maximum:
129
+ raise EngineeringRequestError(f"{field_name}_above_maximum")
130
+ return result
131
+
132
+
133
+ def _optional_int(value: object, *, field_name: str, minimum: int, maximum: int) -> int | None:
134
+ if value is None:
135
+ return None
136
+ if isinstance(value, bool) or not isinstance(value, int):
137
+ raise EngineeringRequestError(f"{field_name}_must_be_integer")
138
+ if not minimum <= value <= maximum:
139
+ raise EngineeringRequestError(f"{field_name}_out_of_range")
140
+ return value
141
+
142
+
143
+ def _string_tuple(value: object, *, field_name: str, allowed: set[str] | None = None) -> tuple[str, ...]:
144
+ if value is None:
145
+ return ()
146
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)):
147
+ raise EngineeringRequestError(f"{field_name}_must_be_array")
148
+ if len(value) > MAX_LIST_ITEMS:
149
+ raise EngineeringRequestError(f"{field_name}_too_many_items")
150
+ output: list[str] = []
151
+ for item in value:
152
+ text = _clean_text(item, field_name=field_name, required=True)
153
+ assert text is not None
154
+ normalized = text.strip().lower()
155
+ if allowed is not None and normalized not in allowed:
156
+ raise EngineeringRequestError(f"unknown_{field_name}:{normalized}")
157
+ if normalized not in output:
158
+ output.append(normalized)
159
+ return tuple(output)
160
+
161
+
162
+ def normalize_robot(value: str | None) -> str | None:
163
+ if value is None:
164
+ return None
165
+ key = re.sub(r"\s+", " ", value.strip().lower())
166
+ key = _ROBOT_ALIASES.get(key, key.replace("-", "_").replace(" ", "_"))
167
+ return key
168
+
169
+
170
+ def normalize_operation(value: str | None) -> str | None:
171
+ if value is None:
172
+ return None
173
+ key = re.sub(r"\s+", " ", value.strip().lower())
174
+ return _OPERATION_ALIASES.get(key, key.replace("-", "_").replace(" ", "_"))
175
+
176
+
177
+ @dataclass(frozen=True, slots=True)
178
+ class PhysicalVariation:
179
+ object_x_mm: float | None = None
180
+ object_y_mm: float | None = None
181
+ object_z_mm: float | None = None
182
+ yaw_deg: float | None = None
183
+ payload_min_kg: float | None = None
184
+ payload_max_kg: float | None = None
185
+ speed_min_pct: float | None = None
186
+ speed_max_pct: float | None = None
187
+
188
+ def __post_init__(self) -> None:
189
+ for name in ("object_x_mm", "object_y_mm", "object_z_mm", "yaw_deg"):
190
+ value = getattr(self, name)
191
+ if value is not None and (not isfinite(value) or value < 0):
192
+ raise EngineeringRequestError(f"{name}_invalid")
193
+ for name in ("payload_min_kg", "payload_max_kg"):
194
+ value = getattr(self, name)
195
+ if value is not None and (not isfinite(value) or value < 0):
196
+ raise EngineeringRequestError(f"{name}_invalid")
197
+ if self.payload_min_kg is not None and self.payload_max_kg is not None and self.payload_min_kg > self.payload_max_kg:
198
+ raise EngineeringRequestError("payload_variation_range_invalid")
199
+ for name in ("speed_min_pct", "speed_max_pct"):
200
+ value = getattr(self, name)
201
+ if value is not None and (not isfinite(value) or not 0 < value <= 100):
202
+ raise EngineeringRequestError(f"{name}_invalid")
203
+ if self.speed_min_pct is not None and self.speed_max_pct is not None and self.speed_min_pct > self.speed_max_pct:
204
+ raise EngineeringRequestError("speed_variation_range_invalid")
205
+
206
+ @property
207
+ def configured(self) -> bool:
208
+ return any(value is not None for value in asdict(self).values())
209
+
210
+
211
+ @dataclass(frozen=True, slots=True)
212
+ class EngineeringRequestDraft:
213
+ robot: str | None = None
214
+ operation: str | None = None
215
+ object_id: str | None = None
216
+ source: str | None = None
217
+ destination: str | None = None
218
+ tool: str | None = None
219
+ payload_kg: float | None = None
220
+ purpose: RequestPurpose = RequestPurpose.SIMULATE
221
+ objective: Objective = Objective.BALANCED
222
+ test_preset: TestPreset = TestPreset.ENGINEERING
223
+ runs: int | None = None
224
+ variation: PhysicalVariation = field(default_factory=PhysicalVariation)
225
+ faults: tuple[str, ...] = ()
226
+ minimum_clearance_mm: float | None = None
227
+ max_cycle_time_s: float | None = None
228
+ uncertain_fields: tuple[str, ...] = ()
229
+ notes: tuple[str, ...] = ()
230
+
231
+ def __post_init__(self) -> None:
232
+ if self.robot is not None:
233
+ object.__setattr__(self, "robot", normalize_robot(self.robot))
234
+ if self.operation is not None:
235
+ object.__setattr__(self, "operation", normalize_operation(self.operation))
236
+ if self.payload_kg is not None and (not isfinite(self.payload_kg) or self.payload_kg < 0):
237
+ raise EngineeringRequestError("payload_kg_invalid")
238
+ if self.minimum_clearance_mm is not None and (not isfinite(self.minimum_clearance_mm) or self.minimum_clearance_mm <= 0):
239
+ raise EngineeringRequestError("minimum_clearance_mm_invalid")
240
+ if self.max_cycle_time_s is not None and (not isfinite(self.max_cycle_time_s) or self.max_cycle_time_s <= 0):
241
+ raise EngineeringRequestError("max_cycle_time_s_invalid")
242
+ if self.runs is not None and not 1 <= self.runs <= MAX_RUNS:
243
+ raise EngineeringRequestError("runs_out_of_range")
244
+ unknown_uncertain = set(self.uncertain_fields) - {
245
+ "robot", "operation", "object_id", "source", "destination", "tool", "payload_kg",
246
+ "objective", "test_preset", "runs", "variation", "faults",
247
+ }
248
+ if unknown_uncertain:
249
+ raise EngineeringRequestError("unknown_uncertain_fields:" + ",".join(sorted(unknown_uncertain)))
250
+
251
+ def to_dict(self) -> dict[str, Any]:
252
+ return {
253
+ "robot": self.robot,
254
+ "operation": self.operation,
255
+ "object_id": self.object_id,
256
+ "source": self.source,
257
+ "destination": self.destination,
258
+ "tool": self.tool,
259
+ "payload_kg": self.payload_kg,
260
+ "purpose": self.purpose.value,
261
+ "objective": self.objective.value,
262
+ "test_preset": self.test_preset.value,
263
+ "runs": self.runs,
264
+ "variation": asdict(self.variation),
265
+ "faults": list(self.faults),
266
+ "minimum_clearance_mm": self.minimum_clearance_mm,
267
+ "max_cycle_time_s": self.max_cycle_time_s,
268
+ "uncertain_fields": list(self.uncertain_fields),
269
+ "notes": list(self.notes),
270
+ }
271
+
272
+
273
+ @dataclass(frozen=True, slots=True)
274
+ class InformationRequest:
275
+ field: str
276
+ question: str
277
+ reason: str
278
+
279
+ def to_dict(self) -> dict[str, str]:
280
+ return asdict(self)
281
+
282
+
283
+ @dataclass(frozen=True, slots=True)
284
+ class RequestAssessment:
285
+ state: RequestState
286
+ missing: tuple[InformationRequest, ...] = ()
287
+ rejection_codes: tuple[str, ...] = ()
288
+ open_items: tuple[str, ...] = ()
289
+
290
+ @property
291
+ def ready(self) -> bool:
292
+ return self.state is RequestState.READY_FOR_VALIDATION
293
+
294
+ def to_dict(self) -> dict[str, Any]:
295
+ return {
296
+ "state": self.state.value,
297
+ "missing": [item.to_dict() for item in self.missing],
298
+ "rejection_codes": list(self.rejection_codes),
299
+ "open_items": list(self.open_items),
300
+ }
301
+
302
+
303
+ @dataclass(frozen=True, slots=True)
304
+ class ValidatedEngineeringRequest:
305
+ request_id: str
306
+ robot_key: str
307
+ operation: str
308
+ object_id: str
309
+ source: str
310
+ destination: str
311
+ tool: str | None
312
+ payload_kg: float | None
313
+ purpose: RequestPurpose
314
+ objective: Objective
315
+ test_preset: TestPreset
316
+ runs: int
317
+ variation: PhysicalVariation
318
+ faults: tuple[str, ...]
319
+ hard_constraints: tuple[str, ...]
320
+ minimum_clearance_mm: float | None
321
+ max_cycle_time_s: float | None
322
+ open_items: tuple[str, ...]
323
+
324
+ def goal(self) -> Goal:
325
+ return Goal(
326
+ goal_id=f"{self.request_id}-goal",
327
+ action=self.operation,
328
+ object_id=self.object_id,
329
+ source=self.source,
330
+ destination=self.destination,
331
+ )
332
+
333
+ def resource(self) -> Resource:
334
+ return CATALOG[self.robot_key]()
335
+
336
+ def to_dict(self) -> dict[str, Any]:
337
+ return {
338
+ "request_id": self.request_id,
339
+ "state": RequestState.VALIDATED.value,
340
+ "robot": self.robot_key,
341
+ "operation": self.operation,
342
+ "object_id": self.object_id,
343
+ "source": self.source,
344
+ "destination": self.destination,
345
+ "tool": self.tool,
346
+ "payload_kg": self.payload_kg,
347
+ "purpose": self.purpose.value,
348
+ "objective": self.objective.value,
349
+ "test_preset": self.test_preset.value,
350
+ "runs": self.runs,
351
+ "variation": asdict(self.variation),
352
+ "faults": list(self.faults),
353
+ "hard_constraints": list(self.hard_constraints),
354
+ "minimum_clearance_mm": self.minimum_clearance_mm,
355
+ "max_cycle_time_s": self.max_cycle_time_s,
356
+ "open_items": list(self.open_items),
357
+ }
358
+
359
+
360
+ _QUESTIONS = {
361
+ "robot": ("Which robot or robot model should perform this task?", "A robot must be selected before capability validation."),
362
+ "operation": ("What operation should the robot perform?", "The requested task must be known before planning."),
363
+ "object_id": ("What object or part should the robot move or process?", "The workpiece is required to create a deterministic goal."),
364
+ "source": ("Where should the robot pick up or start with the object?", "The source location is required to create the task goal."),
365
+ "destination": ("Where should the robot place or deliver the object?", "The destination is required to create the task goal."),
366
+ "payload_kg": ("What is the expected payload or part weight in kilograms?", "Commissioning qualification needs the payload instead of silently assuming one."),
367
+ }
368
+
369
+
370
+ class RequestCompletenessValidator:
371
+ """Deterministically decide whether enough information exists to proceed.
372
+
373
+ Critical fields marked uncertain by the model are treated as missing even if
374
+ the model emitted a guessed value. Commissioning requests additionally
375
+ require payload because payload materially affects physical qualification.
376
+ """
377
+
378
+ def assess(self, draft: EngineeringRequestDraft) -> RequestAssessment:
379
+ uncertain = set(draft.uncertain_fields)
380
+ required = list(_CORE_REQUIRED_FIELDS)
381
+ if draft.purpose is RequestPurpose.COMMISSIONING:
382
+ required.append("payload_kg")
383
+
384
+ missing_fields: list[str] = []
385
+ for field_name in required:
386
+ value = getattr(draft, field_name)
387
+ if value is None or field_name in uncertain:
388
+ missing_fields.append(field_name)
389
+
390
+ if missing_fields:
391
+ return RequestAssessment(
392
+ state=RequestState.NEEDS_INFORMATION,
393
+ missing=tuple(
394
+ InformationRequest(field_name, *_QUESTIONS[field_name])
395
+ for field_name in missing_fields
396
+ ),
397
+ open_items=self._open_items(draft),
398
+ )
399
+
400
+ return RequestAssessment(
401
+ state=RequestState.READY_FOR_VALIDATION,
402
+ open_items=self._open_items(draft),
403
+ )
404
+
405
+ @staticmethod
406
+ def _open_items(draft: EngineeringRequestDraft) -> tuple[str, ...]:
407
+ output: list[str] = []
408
+ if draft.tool is None:
409
+ output.append("tool_not_specified")
410
+ if draft.payload_kg is None:
411
+ output.append("payload_not_specified")
412
+ if draft.purpose in {RequestPurpose.SIMULATE, RequestPurpose.QUALIFY, RequestPurpose.COMMISSIONING} and not draft.variation.configured:
413
+ output.append("physical_variation_not_specified")
414
+ return tuple(output)
415
+
416
+
417
+ def _default_runs(preset: TestPreset) -> int:
418
+ return {
419
+ TestPreset.QUICK: 25,
420
+ TestPreset.ENGINEERING: 250,
421
+ TestPreset.ROBUSTNESS: 1_000,
422
+ TestPreset.STRESS: 10_000,
423
+ TestPreset.CUSTOM: 250,
424
+ }[preset]
425
+
426
+
427
+ class EngineeringRequestCompiler:
428
+ """Compile a complete AI draft into bounded deterministic engine input."""
429
+
430
+ def __init__(self, completeness: RequestCompletenessValidator | None = None) -> None:
431
+ self.completeness = completeness or RequestCompletenessValidator()
432
+
433
+ def compile(self, draft: EngineeringRequestDraft) -> ValidatedEngineeringRequest:
434
+ assessment = self.completeness.assess(draft)
435
+ if not assessment.ready:
436
+ if assessment.state is RequestState.NEEDS_INFORMATION:
437
+ raise EngineeringRequestError(
438
+ "request_needs_information:" + ",".join(item.field for item in assessment.missing)
439
+ )
440
+ raise EngineeringRequestError("request_not_ready")
441
+
442
+ assert draft.robot is not None
443
+ assert draft.operation is not None
444
+ assert draft.object_id is not None
445
+ assert draft.source is not None
446
+ assert draft.destination is not None
447
+
448
+ rejection_codes: list[str] = []
449
+ if draft.robot not in CATALOG:
450
+ rejection_codes.append(f"unsupported_robot:{draft.robot}")
451
+ if draft.operation not in SUPPORTED_GOAL_ACTIONS:
452
+ rejection_codes.append(f"unsupported_operation:{draft.operation}")
453
+ if draft.runs is not None and draft.test_preset is not TestPreset.CUSTOM:
454
+ rejection_codes.append("runs_require_custom_test_preset")
455
+ if draft.test_preset is TestPreset.CUSTOM and draft.runs is None:
456
+ rejection_codes.append("custom_test_preset_requires_runs")
457
+ if rejection_codes:
458
+ raise EngineeringRequestError("request_rejected:" + ";".join(rejection_codes))
459
+
460
+ runs = draft.runs if draft.runs is not None else _default_runs(draft.test_preset)
461
+ hard_constraints = list(_SAFE_SYSTEM_CONSTRAINTS)
462
+ if draft.minimum_clearance_mm is not None:
463
+ hard_constraints.append("minimum_clearance")
464
+ if draft.max_cycle_time_s is not None:
465
+ hard_constraints.append("max_cycle_time")
466
+
467
+ canonical = {
468
+ "robot": draft.robot,
469
+ "operation": draft.operation,
470
+ "object_id": draft.object_id,
471
+ "source": draft.source,
472
+ "destination": draft.destination,
473
+ "tool": draft.tool,
474
+ "payload_kg": draft.payload_kg,
475
+ "purpose": draft.purpose.value,
476
+ "objective": draft.objective.value,
477
+ "test_preset": draft.test_preset.value,
478
+ "runs": runs,
479
+ "variation": asdict(draft.variation),
480
+ "faults": list(draft.faults),
481
+ "minimum_clearance_mm": draft.minimum_clearance_mm,
482
+ "max_cycle_time_s": draft.max_cycle_time_s,
483
+ }
484
+ digest = sha256(
485
+ json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode("utf-8")
486
+ ).hexdigest()[:12]
487
+
488
+ return ValidatedEngineeringRequest(
489
+ request_id=f"REQ-{digest}",
490
+ robot_key=draft.robot,
491
+ operation=draft.operation,
492
+ object_id=draft.object_id,
493
+ source=draft.source,
494
+ destination=draft.destination,
495
+ tool=draft.tool,
496
+ payload_kg=draft.payload_kg,
497
+ purpose=draft.purpose,
498
+ objective=draft.objective,
499
+ test_preset=draft.test_preset,
500
+ runs=runs,
501
+ variation=draft.variation,
502
+ faults=draft.faults,
503
+ hard_constraints=tuple(hard_constraints),
504
+ minimum_clearance_mm=draft.minimum_clearance_mm,
505
+ max_cycle_time_s=draft.max_cycle_time_s,
506
+ open_items=assessment.open_items,
507
+ )
508
+
509
+
510
+ def _reject_unknown(obj: Mapping[str, Any], allowed: set[str], name: str) -> None:
511
+ extra = set(obj) - allowed
512
+ if extra:
513
+ raise EngineeringRequestError(f"{name}_unknown_fields:" + ",".join(sorted(extra)))
514
+
515
+
516
+ def parse_engineering_request_draft(payload: Mapping[str, Any]) -> EngineeringRequestDraft:
517
+ if not isinstance(payload, Mapping):
518
+ raise EngineeringRequestError("engineering_request_must_be_object")
519
+ allowed = {
520
+ "robot", "operation", "object_id", "source", "destination", "tool",
521
+ "payload_kg", "purpose", "objective", "test_preset", "runs",
522
+ "variation", "faults", "minimum_clearance_mm", "max_cycle_time_s",
523
+ "uncertain_fields", "notes",
524
+ }
525
+ _reject_unknown(payload, allowed, "engineering_request")
526
+
527
+ variation_raw = payload.get("variation") or {}
528
+ if not isinstance(variation_raw, Mapping):
529
+ raise EngineeringRequestError("variation_must_be_object")
530
+ _reject_unknown(
531
+ variation_raw,
532
+ {
533
+ "object_x_mm", "object_y_mm", "object_z_mm", "yaw_deg",
534
+ "payload_min_kg", "payload_max_kg", "speed_min_pct", "speed_max_pct",
535
+ },
536
+ "variation",
537
+ )
538
+ variation = PhysicalVariation(
539
+ object_x_mm=_optional_number(variation_raw.get("object_x_mm"), field_name="object_x_mm", minimum=0),
540
+ object_y_mm=_optional_number(variation_raw.get("object_y_mm"), field_name="object_y_mm", minimum=0),
541
+ object_z_mm=_optional_number(variation_raw.get("object_z_mm"), field_name="object_z_mm", minimum=0),
542
+ yaw_deg=_optional_number(variation_raw.get("yaw_deg"), field_name="yaw_deg", minimum=0),
543
+ payload_min_kg=_optional_number(variation_raw.get("payload_min_kg"), field_name="payload_min_kg", minimum=0),
544
+ payload_max_kg=_optional_number(variation_raw.get("payload_max_kg"), field_name="payload_max_kg", minimum=0),
545
+ speed_min_pct=_optional_number(variation_raw.get("speed_min_pct"), field_name="speed_min_pct", minimum=0.001, maximum=100),
546
+ speed_max_pct=_optional_number(variation_raw.get("speed_max_pct"), field_name="speed_max_pct", minimum=0.001, maximum=100),
547
+ )
548
+
549
+ try:
550
+ purpose = RequestPurpose(str(payload.get("purpose", RequestPurpose.SIMULATE.value)).strip().lower())
551
+ objective = Objective(str(payload.get("objective", Objective.BALANCED.value)).strip().lower())
552
+ test_preset = TestPreset(str(payload.get("test_preset", TestPreset.ENGINEERING.value)).strip().lower())
553
+ except ValueError as exc:
554
+ raise EngineeringRequestError("request_enum_invalid") from exc
555
+
556
+ faults = _string_tuple(
557
+ payload.get("faults"),
558
+ field_name="faults",
559
+ allowed={item.value for item in FaultIntent},
560
+ )
561
+ uncertain_fields = _string_tuple(payload.get("uncertain_fields"), field_name="uncertain_fields")
562
+ notes = _string_tuple(payload.get("notes"), field_name="notes")
563
+
564
+ return EngineeringRequestDraft(
565
+ robot=_clean_text(payload.get("robot"), field_name="robot"),
566
+ operation=_clean_text(payload.get("operation"), field_name="operation"),
567
+ object_id=_clean_text(payload.get("object_id"), field_name="object_id"),
568
+ source=_clean_text(payload.get("source"), field_name="source"),
569
+ destination=_clean_text(payload.get("destination"), field_name="destination"),
570
+ tool=_clean_text(payload.get("tool"), field_name="tool"),
571
+ payload_kg=_optional_number(payload.get("payload_kg"), field_name="payload_kg", minimum=0),
572
+ purpose=purpose,
573
+ objective=objective,
574
+ test_preset=test_preset,
575
+ runs=_optional_int(payload.get("runs"), field_name="runs", minimum=1, maximum=MAX_RUNS),
576
+ variation=variation,
577
+ faults=faults,
578
+ minimum_clearance_mm=_optional_number(payload.get("minimum_clearance_mm"), field_name="minimum_clearance_mm", minimum=0.001),
579
+ max_cycle_time_s=_optional_number(payload.get("max_cycle_time_s"), field_name="max_cycle_time_s", minimum=0.001),
580
+ uncertain_fields=uncertain_fields,
581
+ notes=notes,
582
+ )
583
+
584
+
585
+ ENGINEERING_REQUEST_SCHEMA: dict[str, Any] = {
586
+ "type": "object",
587
+ "additionalProperties": False,
588
+ "required": [
589
+ "robot", "operation", "object_id", "source", "destination", "tool",
590
+ "payload_kg", "purpose", "objective", "test_preset", "runs",
591
+ "variation", "faults", "minimum_clearance_mm", "max_cycle_time_s",
592
+ "uncertain_fields", "notes",
593
+ ],
594
+ "properties": {
595
+ "robot": {"type": ["string", "null"], "maxLength": 512},
596
+ "operation": {"type": ["string", "null"], "maxLength": 512},
597
+ "object_id": {"type": ["string", "null"], "maxLength": 512},
598
+ "source": {"type": ["string", "null"], "maxLength": 512},
599
+ "destination": {"type": ["string", "null"], "maxLength": 512},
600
+ "tool": {"type": ["string", "null"], "maxLength": 512},
601
+ "payload_kg": {"type": ["number", "null"], "minimum": 0},
602
+ "purpose": {"enum": [item.value for item in RequestPurpose]},
603
+ "objective": {"enum": [item.value for item in Objective]},
604
+ "test_preset": {"enum": [item.value for item in TestPreset]},
605
+ "runs": {"type": ["integer", "null"], "minimum": 1, "maximum": MAX_RUNS},
606
+ "variation": {
607
+ "type": "object",
608
+ "additionalProperties": False,
609
+ "required": [
610
+ "object_x_mm", "object_y_mm", "object_z_mm", "yaw_deg",
611
+ "payload_min_kg", "payload_max_kg", "speed_min_pct", "speed_max_pct",
612
+ ],
613
+ "properties": {
614
+ "object_x_mm": {"type": ["number", "null"], "minimum": 0},
615
+ "object_y_mm": {"type": ["number", "null"], "minimum": 0},
616
+ "object_z_mm": {"type": ["number", "null"], "minimum": 0},
617
+ "yaw_deg": {"type": ["number", "null"], "minimum": 0},
618
+ "payload_min_kg": {"type": ["number", "null"], "minimum": 0},
619
+ "payload_max_kg": {"type": ["number", "null"], "minimum": 0},
620
+ "speed_min_pct": {"type": ["number", "null"], "exclusiveMinimum": 0, "maximum": 100},
621
+ "speed_max_pct": {"type": ["number", "null"], "exclusiveMinimum": 0, "maximum": 100},
622
+ },
623
+ },
624
+ "faults": {"type": "array", "maxItems": MAX_LIST_ITEMS, "uniqueItems": True, "items": {"enum": [item.value for item in FaultIntent]}},
625
+ "minimum_clearance_mm": {"type": ["number", "null"], "exclusiveMinimum": 0},
626
+ "max_cycle_time_s": {"type": ["number", "null"], "exclusiveMinimum": 0},
627
+ "uncertain_fields": {"type": "array", "maxItems": MAX_LIST_ITEMS, "uniqueItems": True, "items": {"type": "string", "maxLength": 128}},
628
+ "notes": {"type": "array", "maxItems": MAX_LIST_ITEMS, "uniqueItems": True, "items": {"type": "string", "maxLength": 512}},
629
+ },
630
+ }