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.
- autoassembler/.fernignore +7 -0
- autoassembler/__init__.py +537 -0
- autoassembler/_custom/__init__.py +56 -0
- autoassembler/_custom/assemblies.py +226 -0
- autoassembler/_custom/assemblies_api.py +188 -0
- autoassembler/_custom/credentials.py +248 -0
- autoassembler/_custom/http.py +42 -0
- autoassembler/_custom/operations.py +258 -0
- autoassembler/_custom/planning.py +598 -0
- autoassembler/_custom/planning_api.py +225 -0
- autoassembler/_default_clients.py +32 -0
- autoassembler/_version.py +8 -0
- autoassembler/alpha/__init__.py +63 -0
- autoassembler/alpha/client.py +82 -0
- autoassembler/alpha/jobs/__init__.py +39 -0
- autoassembler/alpha/jobs/client.py +338 -0
- autoassembler/alpha/jobs/raw_client.py +641 -0
- autoassembler/alpha/jobs/types/__init__.py +42 -0
- autoassembler/alpha/jobs/types/cancel_job_jobs_response.py +12 -0
- autoassembler/alpha/jobs/types/list_jobs_jobs_response.py +30 -0
- autoassembler/alpha/jobs/types/list_jobs_jobs_response_pagination.py +22 -0
- autoassembler/alpha/jobs/types/retry_job_jobs_response.py +16 -0
- autoassembler/alpha/planning/__init__.py +49 -0
- autoassembler/alpha/planning/client.py +201 -0
- autoassembler/alpha/planning/raw_client.py +268 -0
- autoassembler/alpha/planning/types/__init__.py +47 -0
- autoassembler/alpha/planning/types/apply_ops_planning_request_namespace.py +5 -0
- autoassembler/alpha/planning/types/apply_ops_planning_request_ops_item.py +13 -0
- autoassembler/alpha/planning/types/apply_ops_planning_request_ops_item_op.py +58 -0
- autoassembler/alpha/planning/types/apply_ops_planning_response.py +13 -0
- autoassembler/alpha/raw_client.py +13 -0
- autoassembler/assemblies/__init__.py +73 -0
- autoassembler/assemblies/client.py +1177 -0
- autoassembler/assemblies/raw_client.py +1557 -0
- autoassembler/assemblies/types/__init__.py +71 -0
- autoassembler/assemblies/types/delete_assembly_response.py +12 -0
- autoassembler/assemblies/types/download_assembly_cad_request_namespace.py +5 -0
- autoassembler/assemblies/types/get_assembly_info_request_namespace.py +5 -0
- autoassembler/assemblies/types/get_status_request_namespace.py +5 -0
- autoassembler/assemblies/types/get_status_response.py +16 -0
- autoassembler/assemblies/types/get_status_response_progress.py +13 -0
- autoassembler/assemblies/types/get_status_response_status.py +7 -0
- autoassembler/assemblies/types/get_structure_request_namespace.py +5 -0
- autoassembler/assemblies/types/list_assembly_versions_request_namespace.py +5 -0
- autoassembler/assemblies/types/list_connections_request_namespace.py +5 -0
- autoassembler/assemblies/types/process_request_namespace.py +5 -0
- autoassembler/assemblies/types/update_assembly_info_request_namespace.py +5 -0
- autoassembler/auth/__init__.py +34 -0
- autoassembler/auth/client.py +282 -0
- autoassembler/auth/raw_client.py +413 -0
- autoassembler/auth/types/__init__.py +36 -0
- autoassembler/auth/types/create_access_token_request_grant_type.py +7 -0
- autoassembler/base_client.py +328 -0
- autoassembler/client.py +262 -0
- autoassembler/core/__init__.py +127 -0
- autoassembler/core/api_error.py +23 -0
- autoassembler/core/client_wrapper.py +147 -0
- autoassembler/core/datetime_utils.py +70 -0
- autoassembler/core/file.py +67 -0
- autoassembler/core/force_multipart.py +18 -0
- autoassembler/core/http_client.py +965 -0
- autoassembler/core/http_response.py +63 -0
- autoassembler/core/http_sse/__init__.py +42 -0
- autoassembler/core/http_sse/_api.py +455 -0
- autoassembler/core/http_sse/_decoders.py +74 -0
- autoassembler/core/http_sse/_exceptions.py +7 -0
- autoassembler/core/http_sse/_models.py +17 -0
- autoassembler/core/jsonable_encoder.py +133 -0
- autoassembler/core/logging.py +107 -0
- autoassembler/core/parse_error.py +36 -0
- autoassembler/core/pydantic_utilities.py +486 -0
- autoassembler/core/query_encoder.py +58 -0
- autoassembler/core/remove_none_from_dict.py +11 -0
- autoassembler/core/request_options.py +40 -0
- autoassembler/core/serialization.py +347 -0
- autoassembler/environment.py +7 -0
- autoassembler/errors/__init__.py +56 -0
- autoassembler/errors/bad_request_error.py +10 -0
- autoassembler/errors/conflict_error.py +10 -0
- autoassembler/errors/forbidden_error.py +11 -0
- autoassembler/errors/internal_server_error.py +11 -0
- autoassembler/errors/not_found_error.py +11 -0
- autoassembler/errors/unauthorized_error.py +10 -0
- autoassembler/errors/unprocessable_entity_error.py +11 -0
- autoassembler/operation.py +196 -0
- autoassembler/operations/__init__.py +4 -0
- autoassembler/operations/client.py +209 -0
- autoassembler/operations/raw_client.py +343 -0
- autoassembler/planning/__init__.py +70 -0
- autoassembler/planning/client.py +1174 -0
- autoassembler/planning/raw_client.py +1349 -0
- autoassembler/planning/types/__init__.py +68 -0
- autoassembler/planning/types/adapt_request_namespace.py +5 -0
- autoassembler/planning/types/create_mpp_request_body.py +8 -0
- autoassembler/planning/types/create_mpp_request_namespace.py +5 -0
- autoassembler/planning/types/delete_mpp_request_namespace.py +5 -0
- autoassembler/planning/types/delete_mpp_response.py +12 -0
- autoassembler/planning/types/evaluate_dfa_request_namespace.py +5 -0
- autoassembler/planning/types/get_mpp_request_namespace.py +5 -0
- autoassembler/planning/types/get_mpp_response.py +27 -0
- autoassembler/planning/types/list_mpps_request_namespace.py +5 -0
- autoassembler/planning/types/plan_request_namespace.py +5 -0
- autoassembler/planning/types/validate_request_namespace.py +5 -0
- autoassembler/py.typed +0 -0
- autoassembler/sessions/__init__.py +4 -0
- autoassembler/sessions/client.py +102 -0
- autoassembler/sessions/raw_client.py +126 -0
- autoassembler/types/__init__.py +344 -0
- autoassembler/types/access_token_response.py +14 -0
- autoassembler/types/active_session.py +25 -0
- autoassembler/types/active_session_namespace.py +5 -0
- autoassembler/types/adapt_mpp_accepted.py +26 -0
- autoassembler/types/assembly.py +30 -0
- autoassembler/types/assembly_connection.py +21 -0
- autoassembler/types/assembly_connection_relationship.py +5 -0
- autoassembler/types/assembly_connections_response.py +13 -0
- autoassembler/types/assembly_diff_metadata.py +35 -0
- autoassembler/types/assembly_diff_response.py +34 -0
- autoassembler/types/assembly_info.py +36 -0
- autoassembler/types/assembly_json_response.py +15 -0
- autoassembler/types/assembly_process_metadata.py +32 -0
- autoassembler/types/assembly_process_response.py +18 -0
- autoassembler/types/assembly_search_hit.py +31 -0
- autoassembler/types/assembly_version_summary.py +27 -0
- autoassembler/types/conflict_error_body.py +19 -0
- autoassembler/types/conflict_error_body_error.py +5 -0
- autoassembler/types/create_mpp_request.py +13 -0
- autoassembler/types/device_authorization_response.py +17 -0
- autoassembler/types/diff_accepted.py +42 -0
- autoassembler/types/diff_context_node.py +20 -0
- autoassembler/types/diff_details.py +73 -0
- autoassembler/types/diff_error_response.py +38 -0
- autoassembler/types/diff_flags.py +74 -0
- autoassembler/types/diff_ml_semantics.py +55 -0
- autoassembler/types/diff_node.py +26 -0
- autoassembler/types/diff_node_pair_with_parts.py +90 -0
- autoassembler/types/diff_node_with_parent.py +15 -0
- autoassembler/types/diff_reference_target_nodes.py +25 -0
- autoassembler/types/diff_report.py +24 -0
- autoassembler/types/diff_rigid_alignment.py +30 -0
- autoassembler/types/diff_summary.py +121 -0
- autoassembler/types/diff_summary_assembly_labels.py +44 -0
- autoassembler/types/diff_summary_geometric_diffs.py +24 -0
- autoassembler/types/diff_summary_persistent_edges_congruence.py +24 -0
- autoassembler/types/diff_summary_persistent_edges_congruence_pose.py +24 -0
- autoassembler/types/diff_summary_persistent_nodes_congruence.py +24 -0
- autoassembler/types/diff_summary_persistent_nodes_congruence_pose.py +24 -0
- autoassembler/types/diff_summary_total_parts.py +24 -0
- autoassembler/types/diff_swap.py +65 -0
- autoassembler/types/diff_swap_confidence.py +5 -0
- autoassembler/types/diff_swap_group.py +92 -0
- autoassembler/types/diff_validation_issue.py +38 -0
- autoassembler/types/diff_validation_issue_severity.py +5 -0
- autoassembler/types/diff_validation_result.py +35 -0
- autoassembler/types/diff_warning.py +79 -0
- autoassembler/types/diff_warning_reason.py +18 -0
- autoassembler/types/diff_warning_severity.py +5 -0
- autoassembler/types/error_response.py +30 -0
- autoassembler/types/filter.py +19 -0
- autoassembler/types/filter_field.py +7 -0
- autoassembler/types/filter_operator.py +5 -0
- autoassembler/types/job.py +123 -0
- autoassembler/types/job_progress.py +24 -0
- autoassembler/types/job_status.py +5 -0
- autoassembler/types/list_operations_response.py +25 -0
- autoassembler/types/manufacturing_process_plan.py +57 -0
- autoassembler/types/mpp_adapt_metadata.py +48 -0
- autoassembler/types/mpp_adapt_response.py +22 -0
- autoassembler/types/mpp_evaluate_dfa_metadata.py +47 -0
- autoassembler/types/mpp_evaluate_dfa_response.py +22 -0
- autoassembler/types/mpp_plan_metadata.py +47 -0
- autoassembler/types/mpp_plan_response.py +22 -0
- autoassembler/types/mpp_summary.py +20 -0
- autoassembler/types/mpp_validate_metadata.py +47 -0
- autoassembler/types/mpp_validate_response.py +22 -0
- autoassembler/types/o_auth_error_response.py +14 -0
- autoassembler/types/o_auth_error_response_error.py +18 -0
- autoassembler/types/operation.py +27 -0
- autoassembler/types/operation_error.py +21 -0
- autoassembler/types/operation_metadata.py +148 -0
- autoassembler/types/operation_response.py +94 -0
- autoassembler/types/process_assembly_accepted.py +14 -0
- autoassembler/types/process_assembly_response.py +55 -0
- autoassembler/types/process_assembly_skipped.py +14 -0
- autoassembler/types/search_assemblies_response.py +14 -0
- autoassembler/types/seq_plan_accepted.py +24 -0
- autoassembler/types/sever.py +33 -0
- autoassembler/types/sever_kind.py +5 -0
- autoassembler/types/sever_source.py +5 -0
- autoassembler/types/sort.py +8 -0
- autoassembler/types/upload_response.py +19 -0
- autoassembler/types/user_info.py +19 -0
- autoassembler/types/user_info_response.py +23 -0
- autoassembler/types/user_info_response_providers.py +14 -0
- autoassembler-0.3.1.dist-info/METADATA +209 -0
- autoassembler-0.3.1.dist-info/RECORD +198 -0
- autoassembler-0.3.1.dist-info/WHEEL +4 -0
- 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
|
+
|