autoassembler 0.3.1__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 (198) hide show
  1. autoassembler/.fernignore +7 -0
  2. autoassembler/__init__.py +537 -0
  3. autoassembler/_custom/__init__.py +56 -0
  4. autoassembler/_custom/assemblies.py +226 -0
  5. autoassembler/_custom/assemblies_api.py +188 -0
  6. autoassembler/_custom/credentials.py +248 -0
  7. autoassembler/_custom/http.py +42 -0
  8. autoassembler/_custom/operations.py +258 -0
  9. autoassembler/_custom/planning.py +598 -0
  10. autoassembler/_custom/planning_api.py +225 -0
  11. autoassembler/_default_clients.py +32 -0
  12. autoassembler/_version.py +8 -0
  13. autoassembler/alpha/__init__.py +63 -0
  14. autoassembler/alpha/client.py +82 -0
  15. autoassembler/alpha/jobs/__init__.py +39 -0
  16. autoassembler/alpha/jobs/client.py +338 -0
  17. autoassembler/alpha/jobs/raw_client.py +641 -0
  18. autoassembler/alpha/jobs/types/__init__.py +42 -0
  19. autoassembler/alpha/jobs/types/cancel_job_jobs_response.py +12 -0
  20. autoassembler/alpha/jobs/types/list_jobs_jobs_response.py +30 -0
  21. autoassembler/alpha/jobs/types/list_jobs_jobs_response_pagination.py +22 -0
  22. autoassembler/alpha/jobs/types/retry_job_jobs_response.py +16 -0
  23. autoassembler/alpha/planning/__init__.py +49 -0
  24. autoassembler/alpha/planning/client.py +201 -0
  25. autoassembler/alpha/planning/raw_client.py +268 -0
  26. autoassembler/alpha/planning/types/__init__.py +47 -0
  27. autoassembler/alpha/planning/types/apply_ops_planning_request_namespace.py +5 -0
  28. autoassembler/alpha/planning/types/apply_ops_planning_request_ops_item.py +13 -0
  29. autoassembler/alpha/planning/types/apply_ops_planning_request_ops_item_op.py +58 -0
  30. autoassembler/alpha/planning/types/apply_ops_planning_response.py +13 -0
  31. autoassembler/alpha/raw_client.py +13 -0
  32. autoassembler/assemblies/__init__.py +73 -0
  33. autoassembler/assemblies/client.py +1177 -0
  34. autoassembler/assemblies/raw_client.py +1557 -0
  35. autoassembler/assemblies/types/__init__.py +71 -0
  36. autoassembler/assemblies/types/delete_assembly_response.py +12 -0
  37. autoassembler/assemblies/types/download_assembly_cad_request_namespace.py +5 -0
  38. autoassembler/assemblies/types/get_assembly_info_request_namespace.py +5 -0
  39. autoassembler/assemblies/types/get_status_request_namespace.py +5 -0
  40. autoassembler/assemblies/types/get_status_response.py +16 -0
  41. autoassembler/assemblies/types/get_status_response_progress.py +13 -0
  42. autoassembler/assemblies/types/get_status_response_status.py +7 -0
  43. autoassembler/assemblies/types/get_structure_request_namespace.py +5 -0
  44. autoassembler/assemblies/types/list_assembly_versions_request_namespace.py +5 -0
  45. autoassembler/assemblies/types/list_connections_request_namespace.py +5 -0
  46. autoassembler/assemblies/types/process_request_namespace.py +5 -0
  47. autoassembler/assemblies/types/update_assembly_info_request_namespace.py +5 -0
  48. autoassembler/auth/__init__.py +34 -0
  49. autoassembler/auth/client.py +282 -0
  50. autoassembler/auth/raw_client.py +413 -0
  51. autoassembler/auth/types/__init__.py +36 -0
  52. autoassembler/auth/types/create_access_token_request_grant_type.py +7 -0
  53. autoassembler/base_client.py +328 -0
  54. autoassembler/client.py +262 -0
  55. autoassembler/core/__init__.py +127 -0
  56. autoassembler/core/api_error.py +23 -0
  57. autoassembler/core/client_wrapper.py +147 -0
  58. autoassembler/core/datetime_utils.py +70 -0
  59. autoassembler/core/file.py +67 -0
  60. autoassembler/core/force_multipart.py +18 -0
  61. autoassembler/core/http_client.py +965 -0
  62. autoassembler/core/http_response.py +63 -0
  63. autoassembler/core/http_sse/__init__.py +42 -0
  64. autoassembler/core/http_sse/_api.py +455 -0
  65. autoassembler/core/http_sse/_decoders.py +74 -0
  66. autoassembler/core/http_sse/_exceptions.py +7 -0
  67. autoassembler/core/http_sse/_models.py +17 -0
  68. autoassembler/core/jsonable_encoder.py +133 -0
  69. autoassembler/core/logging.py +107 -0
  70. autoassembler/core/parse_error.py +36 -0
  71. autoassembler/core/pydantic_utilities.py +486 -0
  72. autoassembler/core/query_encoder.py +58 -0
  73. autoassembler/core/remove_none_from_dict.py +11 -0
  74. autoassembler/core/request_options.py +40 -0
  75. autoassembler/core/serialization.py +347 -0
  76. autoassembler/environment.py +7 -0
  77. autoassembler/errors/__init__.py +56 -0
  78. autoassembler/errors/bad_request_error.py +10 -0
  79. autoassembler/errors/conflict_error.py +10 -0
  80. autoassembler/errors/forbidden_error.py +11 -0
  81. autoassembler/errors/internal_server_error.py +11 -0
  82. autoassembler/errors/not_found_error.py +11 -0
  83. autoassembler/errors/unauthorized_error.py +10 -0
  84. autoassembler/errors/unprocessable_entity_error.py +11 -0
  85. autoassembler/operation.py +196 -0
  86. autoassembler/operations/__init__.py +4 -0
  87. autoassembler/operations/client.py +209 -0
  88. autoassembler/operations/raw_client.py +343 -0
  89. autoassembler/planning/__init__.py +70 -0
  90. autoassembler/planning/client.py +1174 -0
  91. autoassembler/planning/raw_client.py +1349 -0
  92. autoassembler/planning/types/__init__.py +68 -0
  93. autoassembler/planning/types/adapt_request_namespace.py +5 -0
  94. autoassembler/planning/types/create_mpp_request_body.py +8 -0
  95. autoassembler/planning/types/create_mpp_request_namespace.py +5 -0
  96. autoassembler/planning/types/delete_mpp_request_namespace.py +5 -0
  97. autoassembler/planning/types/delete_mpp_response.py +12 -0
  98. autoassembler/planning/types/evaluate_dfa_request_namespace.py +5 -0
  99. autoassembler/planning/types/get_mpp_request_namespace.py +5 -0
  100. autoassembler/planning/types/get_mpp_response.py +27 -0
  101. autoassembler/planning/types/list_mpps_request_namespace.py +5 -0
  102. autoassembler/planning/types/plan_request_namespace.py +5 -0
  103. autoassembler/planning/types/validate_request_namespace.py +5 -0
  104. autoassembler/py.typed +0 -0
  105. autoassembler/sessions/__init__.py +4 -0
  106. autoassembler/sessions/client.py +102 -0
  107. autoassembler/sessions/raw_client.py +126 -0
  108. autoassembler/types/__init__.py +344 -0
  109. autoassembler/types/access_token_response.py +14 -0
  110. autoassembler/types/active_session.py +25 -0
  111. autoassembler/types/active_session_namespace.py +5 -0
  112. autoassembler/types/adapt_mpp_accepted.py +26 -0
  113. autoassembler/types/assembly.py +30 -0
  114. autoassembler/types/assembly_connection.py +21 -0
  115. autoassembler/types/assembly_connection_relationship.py +5 -0
  116. autoassembler/types/assembly_connections_response.py +13 -0
  117. autoassembler/types/assembly_diff_metadata.py +35 -0
  118. autoassembler/types/assembly_diff_response.py +34 -0
  119. autoassembler/types/assembly_info.py +36 -0
  120. autoassembler/types/assembly_json_response.py +15 -0
  121. autoassembler/types/assembly_process_metadata.py +32 -0
  122. autoassembler/types/assembly_process_response.py +18 -0
  123. autoassembler/types/assembly_search_hit.py +31 -0
  124. autoassembler/types/assembly_version_summary.py +27 -0
  125. autoassembler/types/conflict_error_body.py +19 -0
  126. autoassembler/types/conflict_error_body_error.py +5 -0
  127. autoassembler/types/create_mpp_request.py +13 -0
  128. autoassembler/types/device_authorization_response.py +17 -0
  129. autoassembler/types/diff_accepted.py +42 -0
  130. autoassembler/types/diff_context_node.py +20 -0
  131. autoassembler/types/diff_details.py +73 -0
  132. autoassembler/types/diff_error_response.py +38 -0
  133. autoassembler/types/diff_flags.py +74 -0
  134. autoassembler/types/diff_ml_semantics.py +55 -0
  135. autoassembler/types/diff_node.py +26 -0
  136. autoassembler/types/diff_node_pair_with_parts.py +90 -0
  137. autoassembler/types/diff_node_with_parent.py +15 -0
  138. autoassembler/types/diff_reference_target_nodes.py +25 -0
  139. autoassembler/types/diff_report.py +24 -0
  140. autoassembler/types/diff_rigid_alignment.py +30 -0
  141. autoassembler/types/diff_summary.py +121 -0
  142. autoassembler/types/diff_summary_assembly_labels.py +44 -0
  143. autoassembler/types/diff_summary_geometric_diffs.py +24 -0
  144. autoassembler/types/diff_summary_persistent_edges_congruence.py +24 -0
  145. autoassembler/types/diff_summary_persistent_edges_congruence_pose.py +24 -0
  146. autoassembler/types/diff_summary_persistent_nodes_congruence.py +24 -0
  147. autoassembler/types/diff_summary_persistent_nodes_congruence_pose.py +24 -0
  148. autoassembler/types/diff_summary_total_parts.py +24 -0
  149. autoassembler/types/diff_swap.py +65 -0
  150. autoassembler/types/diff_swap_confidence.py +5 -0
  151. autoassembler/types/diff_swap_group.py +92 -0
  152. autoassembler/types/diff_validation_issue.py +38 -0
  153. autoassembler/types/diff_validation_issue_severity.py +5 -0
  154. autoassembler/types/diff_validation_result.py +35 -0
  155. autoassembler/types/diff_warning.py +79 -0
  156. autoassembler/types/diff_warning_reason.py +18 -0
  157. autoassembler/types/diff_warning_severity.py +5 -0
  158. autoassembler/types/error_response.py +30 -0
  159. autoassembler/types/filter.py +19 -0
  160. autoassembler/types/filter_field.py +7 -0
  161. autoassembler/types/filter_operator.py +5 -0
  162. autoassembler/types/job.py +123 -0
  163. autoassembler/types/job_progress.py +24 -0
  164. autoassembler/types/job_status.py +5 -0
  165. autoassembler/types/list_operations_response.py +25 -0
  166. autoassembler/types/manufacturing_process_plan.py +57 -0
  167. autoassembler/types/mpp_adapt_metadata.py +48 -0
  168. autoassembler/types/mpp_adapt_response.py +22 -0
  169. autoassembler/types/mpp_evaluate_dfa_metadata.py +47 -0
  170. autoassembler/types/mpp_evaluate_dfa_response.py +22 -0
  171. autoassembler/types/mpp_plan_metadata.py +47 -0
  172. autoassembler/types/mpp_plan_response.py +22 -0
  173. autoassembler/types/mpp_summary.py +20 -0
  174. autoassembler/types/mpp_validate_metadata.py +47 -0
  175. autoassembler/types/mpp_validate_response.py +22 -0
  176. autoassembler/types/o_auth_error_response.py +14 -0
  177. autoassembler/types/o_auth_error_response_error.py +18 -0
  178. autoassembler/types/operation.py +27 -0
  179. autoassembler/types/operation_error.py +21 -0
  180. autoassembler/types/operation_metadata.py +148 -0
  181. autoassembler/types/operation_response.py +94 -0
  182. autoassembler/types/process_assembly_accepted.py +14 -0
  183. autoassembler/types/process_assembly_response.py +55 -0
  184. autoassembler/types/process_assembly_skipped.py +14 -0
  185. autoassembler/types/search_assemblies_response.py +14 -0
  186. autoassembler/types/seq_plan_accepted.py +24 -0
  187. autoassembler/types/sever.py +33 -0
  188. autoassembler/types/sever_kind.py +5 -0
  189. autoassembler/types/sever_source.py +5 -0
  190. autoassembler/types/sort.py +8 -0
  191. autoassembler/types/upload_response.py +19 -0
  192. autoassembler/types/user_info.py +19 -0
  193. autoassembler/types/user_info_response.py +23 -0
  194. autoassembler/types/user_info_response_providers.py +14 -0
  195. autoassembler-0.3.1.dist-info/METADATA +209 -0
  196. autoassembler-0.3.1.dist-info/RECORD +198 -0
  197. autoassembler-0.3.1.dist-info/WHEEL +4 -0
  198. autoassembler-0.3.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,598 @@
1
+ """The `ProcessPlan` handle and the curated `client.planning` namespace.
2
+
3
+ Hand-written, protected via `.fernignore`. Built on the generated client, which
4
+ stays reachable as ``client.api.planning`` for anything not covered here (the full
5
+ PATCH op vocabulary, replace, ...).
6
+
7
+ Vocabulary: a `ProcessPlan` is a manufacturing process plan (MPP) for one assembly,
8
+ made of `Station`s; ``plan()``, ``validate()`` and ``evaluate_dfa()`` return an
9
+ ``Operation`` whose result is one record per requested station, in the document's
10
+ own words (``complete | partial | failed``, ``feasible | infeasible``).
11
+ ``client.planning.adapt()`` carries a plan over to a revised assembly.
12
+
13
+ Everything the handle shows is **derived from the document**, never stored on the
14
+ client: `Part`s are the source state-of-assembly nodes of the station graph,
15
+ ``unassigned_parts`` are those with no station edge in either direction (the server's
16
+ ``computeUnassigned``), a station's ``inputs`` are its incoming boundary, and its
17
+ ``status`` / ``feasibility`` read ``plan.status[s]`` / ``plan.feasibility[s]``.
18
+
19
+ A run's `Operation` is the server's: it is done once the run's results are saved, and
20
+ its result is then read from the document. A station is authored with one PATCH of two
21
+ public ops; the server expands the move into edges and the station's output node.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import secrets
27
+ import typing
28
+ from collections.abc import Iterable, Iterator
29
+ from dataclasses import dataclass
30
+
31
+ from ..alpha.planning.types.apply_ops_planning_request_ops_item import (
32
+ ApplyOpsPlanningRequestOpsItem,
33
+ )
34
+ from ..core.request_options import RequestOptions
35
+ from ..errors.conflict_error import ConflictError
36
+ from ..operation import Operation
37
+ from ..types.diff_report import DiffReport
38
+ from ..types.manufacturing_process_plan import ManufacturingProcessPlan
39
+ from ..types.operation import Operation as OperationResource
40
+ from ..types.operation_metadata import (
41
+ OperationMetadata_MppEvaluateDfa,
42
+ OperationMetadata_MppPlan,
43
+ OperationMetadata_MppValidate,
44
+ )
45
+ from ..types.operation_response import OperationResponse_MppAdapt
46
+
47
+ if typing.TYPE_CHECKING:
48
+ from ..client import AutoAssembler
49
+ from .assemblies import Assembly
50
+
51
+ PlanningStatus = typing.Literal["pending", "complete", "partial", "failed"]
52
+ """How far a station's refinement got. ``partial`` is a usable plan, not an error."""
53
+
54
+ FeasibilityStatus = typing.Literal["feasible", "infeasible"]
55
+ """Whether a station's planned steps can be physically carried out."""
56
+
57
+ _STATION_RUN_METADATA = (
58
+ OperationMetadata_MppPlan,
59
+ OperationMetadata_MppValidate,
60
+ OperationMetadata_MppEvaluateDfa,
61
+ )
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Derived records
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Part:
70
+ """A unit of material in the station graph: one occurrence, or a preassembled group.
71
+
72
+ ``id`` is the state-of-assembly node id, which is what every planning op names.
73
+ """
74
+
75
+ id: str
76
+ occurrences: tuple[str, ...]
77
+ name: str | None = None
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class StationPlanStatus:
82
+ """A station's planning outcome, as persisted at ``plan.status[station_id]``."""
83
+
84
+ station_id: str
85
+ status: PlanningStatus
86
+ messages: tuple[str, ...] = ()
87
+ findings: tuple[dict[str, typing.Any], ...] = ()
88
+ assumptions: tuple[dict[str, typing.Any], ...] = ()
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class StationFeasibility:
93
+ """A station's validation verdict, as persisted at ``plan.feasibility[station_id]``."""
94
+
95
+ station_id: str
96
+ status: FeasibilityStatus
97
+ messages: tuple[str, ...] = ()
98
+ issues: tuple[dict[str, typing.Any], ...] = ()
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class StationDfaReport:
103
+ """A station's DFA scores, as persisted at ``plan.dfa[station_id]`` (raw report)."""
104
+
105
+ station_id: str
106
+ report: dict[str, typing.Any]
107
+
108
+ @property
109
+ def total_score(self) -> float | None:
110
+ return self.report.get("total_score")
111
+
112
+ @property
113
+ def average_score(self) -> float | None:
114
+ return self.report.get("average_score")
115
+
116
+
117
+ class StationOutcomes[R]:
118
+ """One record per requested station, indexable by `Station` or id.
119
+
120
+ ``skipped`` lists requested stations the server accepted but did not run (nothing
121
+ to do for them); they have no record here.
122
+ """
123
+
124
+ def __init__(self, records: dict[str, R], *, skipped: Iterable[str] = ()):
125
+ self._records = dict(records)
126
+ self.skipped: tuple[str, ...] = tuple(skipped)
127
+
128
+ def __getitem__(self, station: Station | str) -> R:
129
+ return self._records[station.id if isinstance(station, Station) else station]
130
+
131
+ def __contains__(self, station: object) -> bool:
132
+ key = station.id if isinstance(station, Station) else station
133
+ return key in self._records
134
+
135
+ def __iter__(self) -> Iterator[str]:
136
+ return iter(self._records)
137
+
138
+ def __len__(self) -> int:
139
+ return len(self._records)
140
+
141
+ def items(self) -> typing.ItemsView[str, R]:
142
+ return self._records.items()
143
+
144
+ def values(self) -> typing.ValuesView[R]:
145
+ return self._records.values()
146
+
147
+ def __repr__(self) -> str:
148
+ return f"{type(self).__name__}({self._records!r}, skipped={self.skipped!r})"
149
+
150
+
151
+ class PlanOutcome(StationOutcomes[StationPlanStatus]):
152
+ """Result of `ProcessPlan.plan`. ``ok`` iff every station came out ``complete``."""
153
+
154
+ @property
155
+ def ok(self) -> bool:
156
+ return all(record.status == "complete" for record in self.values())
157
+
158
+
159
+ class FeasibilityOutcome(StationOutcomes[StationFeasibility]):
160
+ """Result of `ProcessPlan.validate`. ``ok`` iff every station is ``feasible``."""
161
+
162
+ @property
163
+ def ok(self) -> bool:
164
+ return all(record.status == "feasible" for record in self.values())
165
+
166
+
167
+ class DfaOutcome(StationOutcomes[StationDfaReport]):
168
+ """Result of `ProcessPlan.evaluate_dfa`: one scored report per station."""
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # Handles
173
+ # ---------------------------------------------------------------------------
174
+
175
+
176
+ class Station:
177
+ """A station of a `ProcessPlan`: a manufacturing stage that takes some parts in.
178
+
179
+ Everything on it is read from the plan's latest fetched document.
180
+ """
181
+
182
+ def __init__(self, plan: ProcessPlan, id: str):
183
+ self.plan = plan
184
+ self.id = id
185
+
186
+ def __repr__(self) -> str:
187
+ return f"Station({self.id!r}, name={self.name!r}, status={self.status!r})"
188
+
189
+ def __eq__(self, other: object) -> bool:
190
+ return isinstance(other, Station) and (other.plan.id, other.id) == (self.plan.id, self.id)
191
+
192
+ def __hash__(self) -> int:
193
+ return hash((self.plan.id, self.id))
194
+
195
+ @property
196
+ def name(self) -> str:
197
+ return str(_stations(self.plan.document).get(self.id, {}).get("name", self.id))
198
+
199
+ @property
200
+ def inputs(self) -> list[Part]:
201
+ """The parts this station takes in (its incoming boundary)."""
202
+ doc = self.plan.document
203
+ soas = _soas(doc)
204
+ return [
205
+ _part(soas[edge["from"]])
206
+ for edge in _edges(doc).values()
207
+ if edge.get("to") == self.id and edge.get("from") in soas
208
+ ]
209
+
210
+ @property
211
+ def status(self) -> PlanningStatus:
212
+ """``pending`` until a planning run has written a record for this station."""
213
+ return _plan_status(self.plan.document, self.id).status
214
+
215
+ @property
216
+ def feasibility(self) -> FeasibilityStatus | None:
217
+ """``None`` until a validation run has written a verdict for this station."""
218
+ record = _feasibility(self.plan.document, self.id)
219
+ return record.status if record else None
220
+
221
+
222
+ class ProcessPlan:
223
+ """A manufacturing process plan (MPP) for one assembly: the handle planning verbs hang off.
224
+
225
+ Holds its addressing (assembly, id), the last fetched document and nothing else:
226
+ every mutation goes to the server first. Obtained from ``client.planning.create_mpp``,
227
+ ``get_mpp`` or ``list_mpps``; never constructed directly.
228
+ """
229
+
230
+ def __init__(
231
+ self,
232
+ client: AutoAssembler,
233
+ assembly: Assembly,
234
+ *,
235
+ id: str,
236
+ document: ManufacturingProcessPlan | None = None,
237
+ name: str | None = None,
238
+ version: int | None = None,
239
+ ):
240
+ self._client = client
241
+ self.assembly = assembly
242
+ self.id = id
243
+ self._document = document
244
+ self._name = name
245
+ self._version = version
246
+
247
+ def __repr__(self) -> str:
248
+ return f"ProcessPlan({self.id!r}, name={self.name!r}, assembly={self.assembly.id!r})"
249
+
250
+ # -- document -----------------------------------------------------------------
251
+
252
+ @property
253
+ def document(self) -> ManufacturingProcessPlan:
254
+ """The last fetched document (fetched on first access)."""
255
+ if self._document is None:
256
+ self.refresh()
257
+ assert self._document is not None
258
+ return self._document
259
+
260
+ def refresh(self) -> ProcessPlan:
261
+ """Re-read the document from the server."""
262
+ response = self._client.api.planning.get_mpp(
263
+ self.assembly.namespace, self.assembly.id, self.id
264
+ )
265
+ self._document = response.doc
266
+ self._name = response.doc.name
267
+ self._version = response.doc.version
268
+ return self
269
+
270
+ @property
271
+ def name(self) -> str:
272
+ return self._name if self._name is not None else self.document.name
273
+
274
+ @property
275
+ def version(self) -> int:
276
+ """The document version last seen; sent with every mutation to detect concurrent edits."""
277
+ return self._version if self._version is not None else self.document.version
278
+
279
+ @property
280
+ def viewer_url(self) -> str:
281
+ """The plan in the UI."""
282
+ base = self._client.base_url
283
+ return f"{base}/assemblies-mpp/{self.assembly.namespace}/{self.assembly.id}?mpp={self.id}"
284
+
285
+ # -- derived views --------------------------------------------------------------
286
+
287
+ @property
288
+ def stations(self) -> list[Station]:
289
+ return [Station(self, station_id) for station_id in _stations(self.document)]
290
+
291
+ def station(self, id: str) -> Station:
292
+ if id not in _stations(self.document):
293
+ raise KeyError(f"process plan {self.id} has no station {id!r}")
294
+ return Station(self, id)
295
+
296
+ @property
297
+ def parts(self) -> list[Part]:
298
+ """Every source part the plan knows (one per eBOM occurrence, or a preassembled group)."""
299
+ return [_part(soa) for soa in _soas(self.document).values()]
300
+
301
+ @property
302
+ def unassigned_parts(self) -> list[Part]:
303
+ """Parts no station takes in or produces yet (the server's ``computeUnassigned``)."""
304
+ doc = self.document
305
+ touched = set()
306
+ for edge in _edges(doc).values():
307
+ touched.add(edge.get("from"))
308
+ touched.add(edge.get("to"))
309
+ return [_part(soa) for soa_id, soa in _soas(doc).items() if soa_id not in touched]
310
+
311
+ # -- authoring ------------------------------------------------------------------
312
+
313
+ def add_station(self, name: str, *, parts: Iterable[Part | str] | None = None) -> Station:
314
+ """Create a station and assign parts to it; every unassigned part by default.
315
+
316
+ One PATCH: the station node plus a move of the parts onto it. The server
317
+ expands the move into the station's input edges and its output node, so the
318
+ station is ready to plan when this returns.
319
+ """
320
+ station_id = f"st-{secrets.token_hex(4)}"
321
+ part_ids = (
322
+ [part.id for part in self.unassigned_parts]
323
+ if parts is None
324
+ else [part.id if isinstance(part, Part) else part for part in parts]
325
+ )
326
+ ops: list[dict[str, typing.Any]] = [
327
+ {"op": "addStationNode", "station": {"id": station_id, "name": name}},
328
+ ]
329
+ if part_ids:
330
+ ops.append(
331
+ {
332
+ "op": "moveSourceSoasToStation",
333
+ "sourceSoaIds": part_ids,
334
+ "toStationId": station_id,
335
+ }
336
+ )
337
+ self._apply(ops)
338
+ return Station(self, station_id)
339
+
340
+ def _apply(self, ops: list[dict[str, typing.Any]]) -> None:
341
+ """Send one op batch at the current version; on a stale version, refresh and retry once.
342
+
343
+ The generated transport would retry a 409 by itself, with backoff; a version
344
+ conflict can never be fixed by resending the same version, so that is turned off.
345
+ """
346
+ items = [ApplyOpsPlanningRequestOpsItem.model_validate(op) for op in ops]
347
+ for attempt in (1, 2):
348
+ try:
349
+ self._client.api.alpha.planning.apply_ops(
350
+ self.assembly.namespace,
351
+ self.assembly.id,
352
+ self.id,
353
+ version=self.version,
354
+ ops=items,
355
+ request_options=RequestOptions(max_retries=0),
356
+ )
357
+ break
358
+ except ConflictError:
359
+ if attempt == 2:
360
+ raise
361
+ self.refresh()
362
+ self.refresh()
363
+
364
+ def delete(self) -> None:
365
+ self._client.api.planning.delete_mpp(self.assembly.namespace, self.assembly.id, self.id)
366
+
367
+ # -- long-running verbs -----------------------------------------------------------
368
+
369
+ def plan(
370
+ self, station: Station | str, *more: Station | str, overwrite: bool = True
371
+ ) -> Operation[PlanOutcome]:
372
+ """Have the planner work out the assembly steps of the given stations.
373
+
374
+ Every station must be ready to plan (inputs assigned, one supported output), or
375
+ the whole request is refused (400 ``stations_not_ready_to_plan``, listing them).
376
+ Returns immediately with an ``Operation[PlanOutcome]``; ``result(timeout=...)``
377
+ returns one `StationPlanStatus` per requested station: ``complete``, ``partial``
378
+ (a usable partial plan, with messages saying what it could not place) or
379
+ ``failed``. A station that already has a plan is re-planned unless
380
+ ``overwrite=False``, which refuses the whole request (409) instead.
381
+ """
382
+ accepted = self._client.api.planning.plan(
383
+ self.assembly.namespace,
384
+ self.assembly.id,
385
+ self.id,
386
+ station_ids=self._station_ids((station, *more)),
387
+ overwrite=overwrite,
388
+ )
389
+
390
+ def outcome(doc: ManufacturingProcessPlan, queued: list[str], skipped: list[str]):
391
+ return PlanOutcome({s: _plan_status(doc, s) for s in queued}, skipped=skipped)
392
+
393
+ return self._outcome_operation(accepted.operation, outcome)
394
+
395
+ def validate(
396
+ self, *stations: Station | str, fix_directions: bool = False
397
+ ) -> Operation[FeasibilityOutcome]:
398
+ """Check that the planned steps of the given stations (all, if none given) are feasible.
399
+
400
+ Returns immediately with an ``Operation[FeasibilityOutcome]``; ``result()``
401
+ returns one `StationFeasibility` per station that was checked: ``feasible`` or
402
+ ``infeasible``, with the issues behind the verdict. Stations without planned
403
+ steps are reported in ``skipped``. ``fix_directions`` lets the server repair a
404
+ blocked step's direction when a free one exists.
405
+ """
406
+ kwargs: dict[str, typing.Any] = {}
407
+ if fix_directions:
408
+ kwargs["fix_directions"] = True
409
+ accepted = self._client.api.planning.validate(
410
+ self.assembly.namespace,
411
+ self.assembly.id,
412
+ self.id,
413
+ station_ids=self._station_ids(stations),
414
+ **kwargs,
415
+ )
416
+
417
+ def outcome(doc: ManufacturingProcessPlan, queued: list[str], skipped: list[str]):
418
+ records = {s: r for s in queued if (r := _feasibility(doc, s)) is not None}
419
+ return FeasibilityOutcome(records, skipped=skipped)
420
+
421
+ return self._outcome_operation(accepted.operation, outcome)
422
+
423
+ def evaluate_dfa(self, *stations: Station | str) -> Operation[DfaOutcome]:
424
+ """Score the given stations' planned steps for design-for-assembly.
425
+
426
+ With no stations named, every scorable station is scored.
427
+
428
+ A named station must be planned and ``feasible``, or the request is refused
429
+ (409). Returns immediately with an ``Operation[DfaOutcome]``; ``result()``
430
+ returns one `StationDfaReport` per scored station.
431
+ """
432
+ kwargs: dict[str, typing.Any] = {}
433
+ if stations:
434
+ kwargs["station_ids"] = self._station_ids(stations)
435
+ accepted = self._client.api.planning.evaluate_dfa(
436
+ self.assembly.namespace, self.assembly.id, self.id, **kwargs
437
+ )
438
+
439
+ def outcome(doc: ManufacturingProcessPlan, queued: list[str], skipped: list[str]):
440
+ records = {
441
+ s: StationDfaReport(s, report)
442
+ for s in queued
443
+ if (report := _dfa(doc, s)) is not None
444
+ }
445
+ return DfaOutcome(records, skipped=skipped)
446
+
447
+ return self._outcome_operation(accepted.operation, outcome)
448
+
449
+ def _station_ids(self, stations: tuple[Station | str, ...]) -> list[str]:
450
+ if not stations:
451
+ return list(_stations(self.document))
452
+ return [s.id if isinstance(s, Station) else s for s in stations]
453
+
454
+ def _outcome_operation[R](
455
+ self,
456
+ operation: OperationResource,
457
+ outcome: typing.Callable[[ManufacturingProcessPlan, list[str], list[str]], R],
458
+ ) -> Operation[R]:
459
+ """The run's operation, resolving to ``outcome`` of the document once it is done.
460
+
461
+ The stations come from the operation's metadata: those it ran and those it skipped.
462
+ """
463
+
464
+ def resolve(finished: OperationResource) -> R:
465
+ metadata = finished.metadata
466
+ if not isinstance(metadata, _STATION_RUN_METADATA):
467
+ raise TypeError(f"{finished.name} is not a station run: {metadata.type}")
468
+ self.refresh()
469
+ return outcome(self.document, list(metadata.station_ids), list(metadata.skipped))
470
+
471
+ return self._client.operations.handle(operation, resolve=resolve)
472
+
473
+
474
+ class Planning:
475
+ """``client.planning``: process plans for an assembly."""
476
+
477
+ def __init__(self, client: AutoAssembler):
478
+ self._client = client
479
+
480
+ def create_mpp(
481
+ self, assembly: Assembly, *, name: str, description: str | None = None
482
+ ) -> ProcessPlan:
483
+ """Create an empty process plan for a ``ready`` assembly.
484
+
485
+ The new plan knows the assembly's parts and has no stations; ``add_station``
486
+ is the next step.
487
+ """
488
+ from ..types.create_mpp_request import CreateMppRequest
489
+
490
+ # Unset, not None: the request model serializes None as null, which the server refuses.
491
+ request = (
492
+ CreateMppRequest(name=name)
493
+ if description is None
494
+ else CreateMppRequest(name=name, description=description)
495
+ )
496
+ doc = self._client.api.planning.create_mpp(
497
+ assembly.namespace, assembly.id, request=request
498
+ )
499
+ return ProcessPlan(
500
+ self._client, assembly, id=doc.id, document=doc, name=doc.name, version=doc.version
501
+ )
502
+
503
+ def adapt(
504
+ self,
505
+ reference: ProcessPlan,
506
+ target: Assembly,
507
+ *,
508
+ diff: DiffReport,
509
+ name: str | None = None,
510
+ ) -> Operation[ProcessPlan]:
511
+ """A process plan for ``target``, adapted from ``reference``, a plan of an earlier revision.
512
+
513
+ ``diff`` compares ``reference``'s assembly with ``target``:
514
+ ``client.assemblies.diff(reference.assembly, target).result()``. Returns the
515
+ adaptation's operation; ``result()`` returns the new `ProcessPlan` once saved.
516
+ """
517
+ kwargs: dict[str, typing.Any] = {}
518
+ if name is not None:
519
+ kwargs["name"] = name
520
+ adapting = self._client.api.planning.adapt(
521
+ target.namespace, target.id, reference_mpp_id=reference.id, diff=diff, **kwargs
522
+ )
523
+
524
+ def resolve(finished: OperationResource) -> ProcessPlan:
525
+ if not isinstance(finished.response, OperationResponse_MppAdapt):
526
+ raise TypeError(f"{finished.name} is not an adaptation: {finished.metadata.type}")
527
+ return ProcessPlan(self._client, target, id=finished.response.mpp_id).refresh()
528
+
529
+ return self._client.operations.handle(adapting.operation, resolve=resolve)
530
+
531
+ def get_mpp(self, assembly: Assembly, mpp_id: str) -> ProcessPlan:
532
+ """A handle on an existing process plan (one request)."""
533
+ return ProcessPlan(self._client, assembly, id=mpp_id).refresh()
534
+
535
+ def list_mpps(self, assembly: Assembly) -> list[ProcessPlan]:
536
+ """Handles on every process plan of the assembly (documents fetched on first use)."""
537
+ summaries = self._client.api.planning.list_mpps(assembly.namespace, assembly.id)
538
+ return [
539
+ ProcessPlan(self._client, assembly, id=s.id, name=s.name, version=s.version)
540
+ for s in summaries
541
+ ]
542
+
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # Document readers (the derivations, in one place)
546
+ # ---------------------------------------------------------------------------
547
+
548
+
549
+ def _stations(doc: ManufacturingProcessPlan) -> dict[str, dict[str, typing.Any]]:
550
+ return doc.station_graph.get("stations") or {}
551
+
552
+
553
+ def _soas(doc: ManufacturingProcessPlan) -> dict[str, dict[str, typing.Any]]:
554
+ return doc.station_graph.get("statesOfAssembly") or {}
555
+
556
+
557
+ def _edges(doc: ManufacturingProcessPlan) -> dict[str, dict[str, typing.Any]]:
558
+ return doc.station_graph.get("edges") or {}
559
+
560
+
561
+ def _part(soa: dict[str, typing.Any]) -> Part:
562
+ return Part(
563
+ id=str(soa["id"]), occurrences=tuple(soa.get("subassembly") or ()), name=soa.get("name")
564
+ )
565
+
566
+
567
+ def _plan_section(doc: ManufacturingProcessPlan, key: str) -> dict[str, typing.Any]:
568
+ return (doc.plan or {}).get(key) or {}
569
+
570
+
571
+ def _plan_status(doc: ManufacturingProcessPlan, station_id: str) -> StationPlanStatus:
572
+ record = _plan_section(doc, "status").get(station_id)
573
+ if not record:
574
+ return StationPlanStatus(station_id, "pending")
575
+ return StationPlanStatus(
576
+ station_id,
577
+ record.get("status", "pending"),
578
+ messages=tuple(record.get("messages") or ()),
579
+ findings=tuple(record.get("findings") or ()),
580
+ assumptions=tuple(record.get("assumptions") or ()),
581
+ )
582
+
583
+
584
+ def _feasibility(doc: ManufacturingProcessPlan, station_id: str) -> StationFeasibility | None:
585
+ record = _plan_section(doc, "feasibility").get(station_id)
586
+ if not record:
587
+ return None
588
+ return StationFeasibility(
589
+ station_id,
590
+ record["status"],
591
+ messages=tuple(record.get("messages") or ()),
592
+ issues=tuple(record.get("issues") or ()),
593
+ )
594
+
595
+
596
+ def _dfa(doc: ManufacturingProcessPlan, station_id: str) -> dict[str, typing.Any] | None:
597
+ return _plan_section(doc, "dfa").get(station_id)
598
+