@structupath/pi-steel 0.2.1 → 0.2.3
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.
- package/DATA_PROVENANCE.json +23 -0
- package/DATA_PROVENANCE.md +35 -0
- package/PUBLIC_DATA_POLICY.md +52 -0
- package/README.md +114 -33
- package/docs/assets/pi-steel-demo.gif +0 -0
- package/docs/assets/pi-steel-gallery.webp +0 -0
- package/package.json +35 -4
- package/pyproject.toml +28 -0
- package/requirements-dev.txt +5 -0
- package/requirements-render.txt +1 -0
- package/requirements-tested.txt +9 -0
- package/requirements.txt +7 -0
- package/scripts/check-data-provenance.py +140 -0
- package/scripts/check-public-data.py +401 -0
- package/scripts/doctor.py +127 -0
- package/skills/_shared/bootstrap.py +32 -0
- package/skills/_shared/pi_steel/__init__.py +47 -0
- package/skills/_shared/pi_steel/cli.py +167 -0
- package/skills/_shared/pi_steel/contracts.py +87 -0
- package/skills/_shared/pi_steel/geometry_verify.py +243 -0
- package/skills/_shared/pi_steel/parsing.py +205 -0
- package/skills/_shared/pi_steel/run_manifest.py +317 -0
- package/skills/_shared/pi_steel/validation.py +597 -0
- package/skills/_shared/schemas/estimate-package.schema.json +424 -0
- package/skills/_shared/schemas/nest-result.schema.json +443 -0
- package/skills/_shared/schemas/run-manifest.schema.json +145 -0
- package/skills/steel-estimate/SKILL.md +119 -0
- package/skills/steel-estimate/references/estimate-package-example.json +91 -0
- package/skills/steel-estimate/references/output-contract.md +47 -0
- package/skills/steel-estimate/scripts/acknowledge-finding.py +193 -0
- package/skills/steel-estimate/scripts/build-estimate-package.py +836 -0
- package/skills/steel-nest/SKILL.md +50 -23
- package/skills/steel-nest/references/FIXTURE_PROVENANCE.md +12 -0
- package/skills/steel-nest/references/example_job.json +21 -25
- package/skills/steel-nest/references/job_template.json +11 -9
- package/skills/steel-nest/scripts/nest.py +1276 -250
- package/skills/steel-rfq/SKILL.md +93 -175
- package/skills/steel-rfq/assets/company-profile.example.json +11 -5
- package/skills/steel-rfq/references/rfq-input.md +58 -0
- package/skills/steel-rfq/scripts/generate-rfq.py +1221 -0
- package/skills/steel-rfq/scripts/recalc.py +33 -10
- package/skills/steel-takeoff/SKILL.md +12 -8
- package/skills/steel-takeoff/assets/bom-template.csv +1 -1
- package/skills/steel-takeoff/references/takeoff-procedures.md +3 -1
- package/skills/steel-takeoff/scripts/calculate-weight.sh +5 -10
- package/skills/steel-takeoff/scripts/lookup-member.sh +2 -2
- package/skills/steel-takeoff/scripts/validate-bom.py +151 -196
- package/skills/steel-rfq/scripts/__pycache__/recalc.cpython-312.pyc +0 -0
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build one deterministic, review-gated steel estimate package."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import copy
|
|
8
|
+
import importlib.util
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
import tempfile
|
|
14
|
+
import zipfile
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from datetime import date
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SHARED_ROOT = Path(__file__).resolve().parents[2] / "_shared"
|
|
22
|
+
if str(SHARED_ROOT) not in sys.path:
|
|
23
|
+
sys.path.insert(0, str(SHARED_ROOT))
|
|
24
|
+
from bootstrap import bootstrap_shared # noqa: E402
|
|
25
|
+
|
|
26
|
+
bootstrap_shared(__file__)
|
|
27
|
+
from pi_steel import ( # noqa: E402
|
|
28
|
+
RunPublisher,
|
|
29
|
+
StageArgumentParser,
|
|
30
|
+
canonical_json_bytes,
|
|
31
|
+
outcome_exit_code,
|
|
32
|
+
package_version,
|
|
33
|
+
publish_failure_diagnostic,
|
|
34
|
+
sha256_bytes,
|
|
35
|
+
)
|
|
36
|
+
from pi_steel.contracts import ESTIMATE_PACKAGE_VERSION # noqa: E402
|
|
37
|
+
from pi_steel.validation import ( # noqa: E402
|
|
38
|
+
eligible_on_hand_stock,
|
|
39
|
+
validate_estimate_package,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
PIPELINE_VERSION = "1.0.0"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load_module(name: str, path: Path):
|
|
47
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
48
|
+
module = importlib.util.module_from_spec(spec)
|
|
49
|
+
sys.modules[spec.name] = module
|
|
50
|
+
spec.loader.exec_module(module)
|
|
51
|
+
return module
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
SKILLS_ROOT = Path(__file__).resolve().parents[2]
|
|
55
|
+
nest_engine = _load_module(
|
|
56
|
+
"pi_steel_estimate_nest",
|
|
57
|
+
SKILLS_ROOT / "steel-nest" / "scripts" / "nest.py",
|
|
58
|
+
)
|
|
59
|
+
rfq_compiler = _load_module(
|
|
60
|
+
"pi_steel_estimate_rfq",
|
|
61
|
+
SKILLS_ROOT / "steel-rfq" / "scripts" / "generate-rfq.py",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PipelineInputError(ValueError):
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def normalized_package(package: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
+
"""Return a stable canonical ordering without changing supplied facts."""
|
|
71
|
+
value = copy.deepcopy(package)
|
|
72
|
+
value["items"] = sorted(
|
|
73
|
+
value.get("items", []),
|
|
74
|
+
key=lambda item: (
|
|
75
|
+
item.get("item_id", "") if isinstance(item, dict) else ""
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
value["stock"] = sorted(
|
|
79
|
+
value.get("stock", []),
|
|
80
|
+
key=lambda stock: (
|
|
81
|
+
stock.get("inventory_id", "") if isinstance(stock, dict) else "",
|
|
82
|
+
stock.get("material", "") if isinstance(stock, dict) else "",
|
|
83
|
+
stock.get("grade", "") if isinstance(stock, dict) else "",
|
|
84
|
+
stock.get("thickness", 0) if isinstance(stock, dict) else 0,
|
|
85
|
+
stock.get("width", 0) if isinstance(stock, dict) else 0,
|
|
86
|
+
stock.get("height", 0) if isinstance(stock, dict) else 0,
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
review = value.get("review", {})
|
|
90
|
+
if isinstance(review, dict):
|
|
91
|
+
review["findings"] = sorted(
|
|
92
|
+
review.get("findings", []),
|
|
93
|
+
key=lambda finding: finding.get("finding_id", ""),
|
|
94
|
+
)
|
|
95
|
+
review["acknowledgements"] = sorted(
|
|
96
|
+
review.get("acknowledgements", []),
|
|
97
|
+
key=lambda acknowledgement: (
|
|
98
|
+
acknowledgement.get("finding_id", ""),
|
|
99
|
+
acknowledgement.get("timestamp", ""),
|
|
100
|
+
acknowledgement.get("actor", ""),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _legacy_hole(hole: dict[str, Any]) -> dict[str, Any]:
|
|
107
|
+
if hole["kind"] == "round":
|
|
108
|
+
return {
|
|
109
|
+
"dia": hole["diameter"],
|
|
110
|
+
"x": hole["x"],
|
|
111
|
+
"y": hole["y"],
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
"w": hole["width"],
|
|
115
|
+
"h": hole["height"],
|
|
116
|
+
"x": hole["x"],
|
|
117
|
+
"y": hole["y"],
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def nest_job_from_package(
|
|
122
|
+
package: dict[str, Any],
|
|
123
|
+
*,
|
|
124
|
+
estimate_input_hash: str,
|
|
125
|
+
eligible_inventory_ids: set[str],
|
|
126
|
+
kerf_in: float,
|
|
127
|
+
part_gap_in: float,
|
|
128
|
+
edge_margin_in: float,
|
|
129
|
+
density_lb_in3: float,
|
|
130
|
+
) -> dict[str, Any] | None:
|
|
131
|
+
plate_items = [
|
|
132
|
+
item
|
|
133
|
+
for item in package["items"]
|
|
134
|
+
if item["intent"] == "fabricated_part" and item.get("geometry")
|
|
135
|
+
]
|
|
136
|
+
if not plate_items:
|
|
137
|
+
return None
|
|
138
|
+
stock_rows = []
|
|
139
|
+
for index, stock in enumerate(package.get("stock", []), start=1):
|
|
140
|
+
if (
|
|
141
|
+
stock["stock_kind"] == "on_hand"
|
|
142
|
+
and stock.get("inventory_id") not in eligible_inventory_ids
|
|
143
|
+
):
|
|
144
|
+
continue
|
|
145
|
+
stock_rows.append(
|
|
146
|
+
{
|
|
147
|
+
"stock_id": stock.get("inventory_id")
|
|
148
|
+
or f"purchasable-stock:{index:04d}",
|
|
149
|
+
"name": stock.get("inventory_id") or f"Purchase Stock {index}",
|
|
150
|
+
"material": stock["material"],
|
|
151
|
+
"grade": stock["grade"],
|
|
152
|
+
"width": stock["width"],
|
|
153
|
+
"height": stock["height"],
|
|
154
|
+
"thickness": stock["thickness"],
|
|
155
|
+
"qty": stock["quantity"],
|
|
156
|
+
}
|
|
157
|
+
)
|
|
158
|
+
parts = []
|
|
159
|
+
for item in plate_items:
|
|
160
|
+
geometry = item["geometry"]
|
|
161
|
+
part = {
|
|
162
|
+
"source_id": item["source_id"],
|
|
163
|
+
"item_id": item["item_id"],
|
|
164
|
+
"name": item.get("mark") or item["item_id"],
|
|
165
|
+
"material": item["material"],
|
|
166
|
+
"grade": item["grade"],
|
|
167
|
+
"thickness": geometry["thickness"],
|
|
168
|
+
"width": geometry["width"],
|
|
169
|
+
"height": geometry["height"],
|
|
170
|
+
"qty": item["quantity"],
|
|
171
|
+
"shape": geometry["shape"],
|
|
172
|
+
"rotatable": geometry.get("rotatable", True),
|
|
173
|
+
"holes": [_legacy_hole(hole) for hole in geometry.get("holes", [])],
|
|
174
|
+
}
|
|
175
|
+
if geometry.get("area") is not None:
|
|
176
|
+
part["area"] = geometry["area"]
|
|
177
|
+
parts.append(part)
|
|
178
|
+
return {
|
|
179
|
+
"job_name": package["project"].get("name")
|
|
180
|
+
or package["project"]["project_id"],
|
|
181
|
+
"project_id": package["project"]["project_id"],
|
|
182
|
+
"revision_id": package["project"]["revision"]["revision_id"],
|
|
183
|
+
"estimate_input_hash": estimate_input_hash,
|
|
184
|
+
"unit_system": package["unit_system"],
|
|
185
|
+
"settings": {
|
|
186
|
+
"kerf_in": kerf_in,
|
|
187
|
+
"part_gap_in": part_gap_in,
|
|
188
|
+
"edge_margin_in": edge_margin_in,
|
|
189
|
+
"density_lb_in3": density_lb_in3,
|
|
190
|
+
},
|
|
191
|
+
"stock": stock_rows,
|
|
192
|
+
"parts": parts,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def build_bom_projection(
|
|
197
|
+
package: dict[str, Any], nest_result: dict[str, Any] | None
|
|
198
|
+
) -> dict[str, Any]:
|
|
199
|
+
items = []
|
|
200
|
+
known_member_weight = 0.0
|
|
201
|
+
for item in package["items"]:
|
|
202
|
+
row = {
|
|
203
|
+
"source_id": item["source_id"],
|
|
204
|
+
"item_id": item["item_id"],
|
|
205
|
+
"intent": item["intent"],
|
|
206
|
+
"quantity": item["quantity"],
|
|
207
|
+
"description": item.get("description", ""),
|
|
208
|
+
}
|
|
209
|
+
if item.get("mark") is not None:
|
|
210
|
+
row["mark"] = item["mark"]
|
|
211
|
+
if item.get("grade") is not None:
|
|
212
|
+
row["grade"] = item["grade"]
|
|
213
|
+
if item.get("designation") is not None:
|
|
214
|
+
row["designation"] = item["designation"]
|
|
215
|
+
if item.get("geometry") is not None:
|
|
216
|
+
row["geometry"] = item["geometry"]
|
|
217
|
+
if item.get("allowance_basis") is not None:
|
|
218
|
+
row["allowance_basis"] = item["allowance_basis"]
|
|
219
|
+
if item.get("reason") is not None:
|
|
220
|
+
row["reason"] = item["reason"]
|
|
221
|
+
weight = item.get("total_weight_lbs")
|
|
222
|
+
if (
|
|
223
|
+
weight is None
|
|
224
|
+
and item.get("length_ft") is not None
|
|
225
|
+
and item.get("unit_weight_plf") is not None
|
|
226
|
+
):
|
|
227
|
+
weight = item["quantity"] * item["length_ft"] * item["unit_weight_plf"]
|
|
228
|
+
if (
|
|
229
|
+
weight is not None
|
|
230
|
+
and item["intent"] == "fabricated_part"
|
|
231
|
+
and item.get("geometry") is None
|
|
232
|
+
):
|
|
233
|
+
row["calculated_weight_lbs"] = round(float(weight), 3)
|
|
234
|
+
known_member_weight += float(weight)
|
|
235
|
+
items.append(row)
|
|
236
|
+
|
|
237
|
+
nested_plate_weight = (
|
|
238
|
+
nest_result["total_part_weight_lb"] if nest_result is not None else 0.0
|
|
239
|
+
)
|
|
240
|
+
fabricated_weight = known_member_weight + nested_plate_weight
|
|
241
|
+
allowance_weight = 0.0
|
|
242
|
+
allowance_rows = []
|
|
243
|
+
for item in package["items"]:
|
|
244
|
+
if item["intent"] != "allowance":
|
|
245
|
+
continue
|
|
246
|
+
basis = item["allowance_basis"]
|
|
247
|
+
if basis["kind"] == "percent" and basis["applies_to"] == "fabricated_weight":
|
|
248
|
+
weight = fabricated_weight * basis["value"] / 100
|
|
249
|
+
elif basis["kind"] == "fixed_weight":
|
|
250
|
+
weight = basis["value"]
|
|
251
|
+
else:
|
|
252
|
+
weight = None
|
|
253
|
+
allowance_rows.append(
|
|
254
|
+
{
|
|
255
|
+
"item_id": item["item_id"],
|
|
256
|
+
"basis": basis,
|
|
257
|
+
"calculated_weight_lbs": (
|
|
258
|
+
None if weight is None else round(weight, 3)
|
|
259
|
+
),
|
|
260
|
+
"vendor_quantity": None,
|
|
261
|
+
}
|
|
262
|
+
)
|
|
263
|
+
if weight is not None:
|
|
264
|
+
allowance_weight += weight
|
|
265
|
+
complete = not (nest_result and nest_result["unplaced"])
|
|
266
|
+
return {
|
|
267
|
+
"schema_version": "1.0.0",
|
|
268
|
+
"project_id": package["project"]["project_id"],
|
|
269
|
+
"revision_id": package["project"]["revision"]["revision_id"],
|
|
270
|
+
"items": items,
|
|
271
|
+
"totals": {
|
|
272
|
+
"known_member_weight_lbs": round(known_member_weight, 3),
|
|
273
|
+
"nested_plate_weight_lbs": round(nested_plate_weight, 3),
|
|
274
|
+
"fabricated_weight_lbs": round(fabricated_weight, 3),
|
|
275
|
+
"allowance_weight_lbs": round(allowance_weight, 3),
|
|
276
|
+
"estimate_weight_lbs": round(fabricated_weight + allowance_weight, 3),
|
|
277
|
+
"status": "complete" if complete else "incomplete_unplaced",
|
|
278
|
+
},
|
|
279
|
+
"allowances": allowance_rows,
|
|
280
|
+
"pricing_status": "not_calculated_without_explicit_cost_basis",
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _annotated_handoff(nest_result: dict[str, Any] | None):
|
|
285
|
+
if nest_result is None:
|
|
286
|
+
return None
|
|
287
|
+
handoff = copy.deepcopy(nest_result["rfq_nesting"])
|
|
288
|
+
for row in handoff["rows"]:
|
|
289
|
+
row["geometry_readiness"] = nest_result["geometry_readiness"]
|
|
290
|
+
return handoff
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def apply_inventory_consumption(
|
|
294
|
+
package: dict[str, Any],
|
|
295
|
+
rfq_normalized: dict[str, Any],
|
|
296
|
+
nest_result: dict[str, Any] | None,
|
|
297
|
+
eligible_inventory_ids: set[str],
|
|
298
|
+
) -> list[dict[str, Any]]:
|
|
299
|
+
"""Reduce RFQ demand only for traceable on-hand sheets actually consumed."""
|
|
300
|
+
used = Counter(
|
|
301
|
+
report["stock_id"]
|
|
302
|
+
for report in (nest_result or {}).get("plate_reports", [])
|
|
303
|
+
if report["stock_id"] in eligible_inventory_ids
|
|
304
|
+
)
|
|
305
|
+
normalized_by_id = {
|
|
306
|
+
item["item_id"]: item for item in rfq_normalized.get("items", [])
|
|
307
|
+
}
|
|
308
|
+
purchase_items = sorted(
|
|
309
|
+
(
|
|
310
|
+
item
|
|
311
|
+
for item in package.get("items", [])
|
|
312
|
+
if item.get("intent") == "purchased_stock"
|
|
313
|
+
and (
|
|
314
|
+
(item.get("dimensions") or {}).get("inventory_id")
|
|
315
|
+
or (item.get("dimensions") or {}).get("stock_id")
|
|
316
|
+
)
|
|
317
|
+
),
|
|
318
|
+
key=lambda row: row["item_id"],
|
|
319
|
+
)
|
|
320
|
+
lineage = []
|
|
321
|
+
for inventory_id in sorted(used):
|
|
322
|
+
remaining = used[inventory_id]
|
|
323
|
+
for item in purchase_items:
|
|
324
|
+
if remaining <= 0:
|
|
325
|
+
break
|
|
326
|
+
dimensions = item.get("dimensions") or {}
|
|
327
|
+
linked_stock_id = (
|
|
328
|
+
dimensions.get("inventory_id") or dimensions.get("stock_id")
|
|
329
|
+
)
|
|
330
|
+
if linked_stock_id != inventory_id:
|
|
331
|
+
continue
|
|
332
|
+
rfq_item = normalized_by_id.get(item["item_id"])
|
|
333
|
+
if rfq_item is None:
|
|
334
|
+
continue
|
|
335
|
+
original_quantity = rfq_item["quantity"]
|
|
336
|
+
satisfied = min(original_quantity, remaining)
|
|
337
|
+
if satisfied <= 0:
|
|
338
|
+
continue
|
|
339
|
+
remaining -= satisfied
|
|
340
|
+
open_quantity = original_quantity - satisfied
|
|
341
|
+
if open_quantity:
|
|
342
|
+
rfq_item["quantity"] = open_quantity
|
|
343
|
+
if rfq_item.get("purchase_weight_lbs") is not None:
|
|
344
|
+
rfq_item["purchase_weight_lbs"] = round(
|
|
345
|
+
rfq_item["purchase_weight_lbs"]
|
|
346
|
+
* open_quantity
|
|
347
|
+
/ original_quantity,
|
|
348
|
+
3,
|
|
349
|
+
)
|
|
350
|
+
else:
|
|
351
|
+
rfq_normalized["items"].remove(rfq_item)
|
|
352
|
+
normalized_by_id.pop(item["item_id"], None)
|
|
353
|
+
lineage.append(
|
|
354
|
+
{
|
|
355
|
+
"inventory_id": inventory_id,
|
|
356
|
+
"purchase_item_id": item["item_id"],
|
|
357
|
+
"satisfied_quantity": satisfied,
|
|
358
|
+
"remaining_purchase_quantity": open_quantity,
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
return lineage
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _fixed_xlsx(path: Path, issued_date: str) -> None:
|
|
365
|
+
"""Normalize volatile XLSX metadata and ZIP timestamps for stable byte hashes."""
|
|
366
|
+
timestamp = f"{issued_date}T00:00:00Z".encode()
|
|
367
|
+
with zipfile.ZipFile(path, "r") as source:
|
|
368
|
+
entries = {
|
|
369
|
+
info.filename: (info, source.read(info.filename))
|
|
370
|
+
for info in source.infolist()
|
|
371
|
+
}
|
|
372
|
+
core_name = "docProps/core.xml"
|
|
373
|
+
if core_name in entries:
|
|
374
|
+
original, content = entries[core_name]
|
|
375
|
+
content = re.sub(
|
|
376
|
+
rb"(<dcterms:(?:created|modified)[^>]*>).*?(</dcterms:(?:created|modified)>)",
|
|
377
|
+
lambda match: match.group(1) + timestamp + match.group(2),
|
|
378
|
+
content,
|
|
379
|
+
)
|
|
380
|
+
entries[core_name] = (original, content)
|
|
381
|
+
with tempfile.NamedTemporaryFile(
|
|
382
|
+
prefix=path.stem + "-", suffix=".xlsx", dir=path.parent, delete=False
|
|
383
|
+
) as temporary:
|
|
384
|
+
temporary_path = Path(temporary.name)
|
|
385
|
+
try:
|
|
386
|
+
with zipfile.ZipFile(
|
|
387
|
+
temporary_path, "w", compression=zipfile.ZIP_DEFLATED
|
|
388
|
+
) as destination:
|
|
389
|
+
for name in sorted(entries):
|
|
390
|
+
original, content = entries[name]
|
|
391
|
+
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
|
392
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
393
|
+
info.external_attr = original.external_attr
|
|
394
|
+
info.create_system = original.create_system
|
|
395
|
+
destination.writestr(info, content)
|
|
396
|
+
os.replace(temporary_path, path)
|
|
397
|
+
finally:
|
|
398
|
+
if temporary_path.exists():
|
|
399
|
+
temporary_path.unlink()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _finding(code, severity, path, message):
|
|
403
|
+
return {
|
|
404
|
+
"code": code,
|
|
405
|
+
"severity": severity,
|
|
406
|
+
"path": path,
|
|
407
|
+
"message": message,
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _valid_date(value: str) -> bool:
|
|
412
|
+
try:
|
|
413
|
+
return date.fromisoformat(value).isoformat() == value
|
|
414
|
+
except (TypeError, ValueError):
|
|
415
|
+
return False
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def build_pipeline(args) -> tuple[dict[str, Any], Path]:
|
|
419
|
+
if not _valid_date(args.prepared_date):
|
|
420
|
+
raise PipelineInputError("--prepared-date must be an ISO date (YYYY-MM-DD)")
|
|
421
|
+
if not _valid_date(args.issued_date):
|
|
422
|
+
raise PipelineInputError("--issued-date must be an ISO date (YYYY-MM-DD)")
|
|
423
|
+
input_path = Path(args.input)
|
|
424
|
+
package = json.loads(input_path.read_text(encoding="utf-8"))
|
|
425
|
+
validation = validate_estimate_package(package)
|
|
426
|
+
normalized = (
|
|
427
|
+
normalized_package(package)
|
|
428
|
+
if isinstance(package, dict)
|
|
429
|
+
else {"invalid_input": package}
|
|
430
|
+
)
|
|
431
|
+
eligible_inventory_ids = (
|
|
432
|
+
{
|
|
433
|
+
stock["inventory_id"]
|
|
434
|
+
for stock in eligible_on_hand_stock(package)
|
|
435
|
+
}
|
|
436
|
+
if not validation.blockers
|
|
437
|
+
else set()
|
|
438
|
+
)
|
|
439
|
+
findings = [
|
|
440
|
+
{
|
|
441
|
+
"code": finding["code"],
|
|
442
|
+
"severity": finding["severity"],
|
|
443
|
+
"path": finding["path"],
|
|
444
|
+
"message": finding["message"],
|
|
445
|
+
}
|
|
446
|
+
for finding in validation.active_findings
|
|
447
|
+
]
|
|
448
|
+
validation_blocked = bool(validation.blockers)
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
profile, profile_source = rfq_compiler.resolve_company_profile(input_path)
|
|
452
|
+
profile_findings = rfq_compiler.validate_company_profile(
|
|
453
|
+
profile, profile.get("_profile_path") if profile else None
|
|
454
|
+
)
|
|
455
|
+
except rfq_compiler.RfqInputError as exc:
|
|
456
|
+
profile, profile_source = None, "invalid"
|
|
457
|
+
profile_findings = [
|
|
458
|
+
{
|
|
459
|
+
"code": "invalid_company_profile",
|
|
460
|
+
"severity": "error",
|
|
461
|
+
"message": str(exc),
|
|
462
|
+
}
|
|
463
|
+
]
|
|
464
|
+
findings.extend(
|
|
465
|
+
_finding(
|
|
466
|
+
finding["code"],
|
|
467
|
+
"blocker" if finding["severity"] == "error" else "warning",
|
|
468
|
+
"$.company_profile",
|
|
469
|
+
finding["message"],
|
|
470
|
+
)
|
|
471
|
+
for finding in profile_findings
|
|
472
|
+
)
|
|
473
|
+
profile_blocked = bool(profile_findings)
|
|
474
|
+
|
|
475
|
+
nest_result = None
|
|
476
|
+
if not validation_blocked:
|
|
477
|
+
nest_job = nest_job_from_package(
|
|
478
|
+
normalized,
|
|
479
|
+
estimate_input_hash=validation.input_hash,
|
|
480
|
+
eligible_inventory_ids=eligible_inventory_ids,
|
|
481
|
+
kerf_in=args.kerf_in,
|
|
482
|
+
part_gap_in=args.part_gap_in,
|
|
483
|
+
edge_margin_in=args.edge_margin_in,
|
|
484
|
+
density_lb_in3=args.density_lb_in3,
|
|
485
|
+
)
|
|
486
|
+
if nest_job is not None:
|
|
487
|
+
nest_result = nest_engine.run_job(nest_job)
|
|
488
|
+
for nest_finding in nest_result["validation_findings"]:
|
|
489
|
+
findings.append(
|
|
490
|
+
_finding(
|
|
491
|
+
nest_finding["code"],
|
|
492
|
+
"blocker"
|
|
493
|
+
if nest_finding["severity"] == "error"
|
|
494
|
+
else "warning",
|
|
495
|
+
nest_finding["path"],
|
|
496
|
+
nest_finding["message"],
|
|
497
|
+
)
|
|
498
|
+
)
|
|
499
|
+
for verifier_finding in nest_result["verification"]["findings"]:
|
|
500
|
+
findings.append(
|
|
501
|
+
_finding(
|
|
502
|
+
verifier_finding["code"],
|
|
503
|
+
"blocker",
|
|
504
|
+
verifier_finding["path"],
|
|
505
|
+
verifier_finding["message"],
|
|
506
|
+
)
|
|
507
|
+
)
|
|
508
|
+
if nest_result["unplaced"]:
|
|
509
|
+
findings.append(
|
|
510
|
+
_finding(
|
|
511
|
+
"unplaced_parts",
|
|
512
|
+
"blocker",
|
|
513
|
+
"$.nest.unplaced",
|
|
514
|
+
(
|
|
515
|
+
f"{sum(row['quantity'] for row in nest_result['unplaced'])} "
|
|
516
|
+
"required plate part(s) remain unplaced."
|
|
517
|
+
),
|
|
518
|
+
)
|
|
519
|
+
)
|
|
520
|
+
unplaced = bool(nest_result and nest_result["unplaced"])
|
|
521
|
+
nest_blocked = bool(
|
|
522
|
+
nest_result
|
|
523
|
+
and (
|
|
524
|
+
nest_result["outcome"] == "blocked"
|
|
525
|
+
or nest_result["verification"]["status"] != "verified"
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
reference_only = bool(
|
|
529
|
+
nest_result and nest_result["geometry_readiness"] == "reference_only"
|
|
530
|
+
)
|
|
531
|
+
approximations = []
|
|
532
|
+
if reference_only:
|
|
533
|
+
approximations.append(
|
|
534
|
+
{
|
|
535
|
+
"code": "BOUNDING_BOX_NESTING",
|
|
536
|
+
"message": "Irregular plate geometry is reference-only.",
|
|
537
|
+
}
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
render_missing = []
|
|
541
|
+
if not args.no_render and nest_result is not None:
|
|
542
|
+
render_missing = nest_engine.missing_render_dependencies()
|
|
543
|
+
if render_missing:
|
|
544
|
+
findings.append(
|
|
545
|
+
_finding(
|
|
546
|
+
"render_dependency_missing",
|
|
547
|
+
"blocker",
|
|
548
|
+
"$.render",
|
|
549
|
+
"Missing rendering modules: " + ", ".join(render_missing),
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
rfq_normalized = None
|
|
554
|
+
inventory_consumption = []
|
|
555
|
+
compiler_error = None
|
|
556
|
+
if not validation_blocked and not profile_blocked and not nest_blocked and not render_missing:
|
|
557
|
+
try:
|
|
558
|
+
rfq_normalized = rfq_compiler.normalize_canonical_package(package)
|
|
559
|
+
inventory_consumption = apply_inventory_consumption(
|
|
560
|
+
package,
|
|
561
|
+
rfq_normalized,
|
|
562
|
+
nest_result,
|
|
563
|
+
eligible_inventory_ids,
|
|
564
|
+
)
|
|
565
|
+
if not rfq_normalized["items"]:
|
|
566
|
+
raise rfq_compiler.RfqInputError(
|
|
567
|
+
"no open vendor-supply items remain after inventory consumption"
|
|
568
|
+
)
|
|
569
|
+
except rfq_compiler.RfqInputError as exc:
|
|
570
|
+
compiler_error = str(exc)
|
|
571
|
+
findings.append(
|
|
572
|
+
_finding("rfq_input_blocked", "blocker", "$.rfq", compiler_error)
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
blocked = (
|
|
576
|
+
validation_blocked
|
|
577
|
+
or profile_blocked
|
|
578
|
+
or nest_blocked
|
|
579
|
+
or unplaced
|
|
580
|
+
or bool(render_missing)
|
|
581
|
+
or compiler_error is not None
|
|
582
|
+
)
|
|
583
|
+
review_warnings = [
|
|
584
|
+
finding["message"]
|
|
585
|
+
for finding in findings
|
|
586
|
+
if finding["severity"] == "warning"
|
|
587
|
+
]
|
|
588
|
+
if blocked:
|
|
589
|
+
outcome = "dependency_missing" if render_missing else "blocked"
|
|
590
|
+
package_status = "nested_partial" if unplaced else "draft"
|
|
591
|
+
elif reference_only or review_warnings:
|
|
592
|
+
outcome = "review_required"
|
|
593
|
+
package_status = "rfq_draft_review_required"
|
|
594
|
+
else:
|
|
595
|
+
outcome = "ready"
|
|
596
|
+
package_status = "rfq_ready_for_review"
|
|
597
|
+
|
|
598
|
+
handoff = _annotated_handoff(nest_result)
|
|
599
|
+
configuration = {
|
|
600
|
+
"pipeline_version": PIPELINE_VERSION,
|
|
601
|
+
"nest_algorithm_version": nest_engine.NEST_ALGORITHM_VERSION,
|
|
602
|
+
"rfq_compiler_version": rfq_compiler.RFQ_COMPILER_VERSION,
|
|
603
|
+
"prepared_date": args.prepared_date,
|
|
604
|
+
"issued_date": args.issued_date,
|
|
605
|
+
"project_location": args.project_location,
|
|
606
|
+
"kerf_in": args.kerf_in,
|
|
607
|
+
"part_gap_in": args.part_gap_in,
|
|
608
|
+
"edge_margin_in": args.edge_margin_in,
|
|
609
|
+
"density_lb_in3": args.density_lb_in3,
|
|
610
|
+
"render": not args.no_render,
|
|
611
|
+
"bake": not args.no_bake,
|
|
612
|
+
"profile_hash": rfq_compiler.profile_semantic_hash(profile),
|
|
613
|
+
}
|
|
614
|
+
configuration_hash = sha256_bytes(canonical_json_bytes(configuration))
|
|
615
|
+
if validation_blocked:
|
|
616
|
+
project = normalized.get("project", {})
|
|
617
|
+
revision = project.get("revision", {}) if isinstance(project, dict) else {}
|
|
618
|
+
bom = {
|
|
619
|
+
"schema_version": "1.0.0",
|
|
620
|
+
"project_id": (
|
|
621
|
+
project.get("project_id") if isinstance(project, dict) else None
|
|
622
|
+
),
|
|
623
|
+
"revision_id": (
|
|
624
|
+
revision.get("revision_id") if isinstance(revision, dict) else None
|
|
625
|
+
),
|
|
626
|
+
"items": [],
|
|
627
|
+
"totals": {"status": "blocked_invalid_input"},
|
|
628
|
+
"allowances": [],
|
|
629
|
+
"pricing_status": "not_calculated_without_valid_input",
|
|
630
|
+
}
|
|
631
|
+
else:
|
|
632
|
+
bom = build_bom_projection(normalized, nest_result)
|
|
633
|
+
project = normalized.get("project", {})
|
|
634
|
+
project = project if isinstance(project, dict) else {}
|
|
635
|
+
revision = project.get("revision", {})
|
|
636
|
+
revision = revision if isinstance(revision, dict) else {}
|
|
637
|
+
qa_report = {
|
|
638
|
+
"schema_version": "1.0.0",
|
|
639
|
+
"stage": "steel-estimate",
|
|
640
|
+
"run_outcome": outcome,
|
|
641
|
+
"package_status": package_status,
|
|
642
|
+
"project_id": project.get("project_id"),
|
|
643
|
+
"revision_id": revision.get("revision_id"),
|
|
644
|
+
"input_hash": validation.input_hash,
|
|
645
|
+
"configuration_hash": configuration_hash,
|
|
646
|
+
"profile_source": profile_source,
|
|
647
|
+
"findings": findings,
|
|
648
|
+
"warnings": review_warnings,
|
|
649
|
+
"approximations": approximations,
|
|
650
|
+
"nest": (
|
|
651
|
+
None
|
|
652
|
+
if nest_result is None
|
|
653
|
+
else {
|
|
654
|
+
"outcome": nest_result["outcome"],
|
|
655
|
+
"geometry_readiness": nest_result["geometry_readiness"],
|
|
656
|
+
"unplaced": nest_result["unplaced"],
|
|
657
|
+
"verification": nest_result["verification"],
|
|
658
|
+
}
|
|
659
|
+
),
|
|
660
|
+
"rfq": {
|
|
661
|
+
"generated": not blocked,
|
|
662
|
+
"document_status": "DRAFT — NOT SENT OR AWARDED",
|
|
663
|
+
"recalculation_status": None,
|
|
664
|
+
},
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
with RunPublisher(
|
|
668
|
+
args.out,
|
|
669
|
+
stage="steel-estimate",
|
|
670
|
+
run_outcome=outcome,
|
|
671
|
+
package_status=package_status,
|
|
672
|
+
input_hash=validation.input_hash,
|
|
673
|
+
configuration_hash=configuration_hash,
|
|
674
|
+
schema_versions={
|
|
675
|
+
"run_manifest": "1.0.0",
|
|
676
|
+
"estimate_package": ESTIMATE_PACKAGE_VERSION,
|
|
677
|
+
"normalized_bom": "1.0.0",
|
|
678
|
+
"nest_result": nest_engine.NEST_RESULT_VERSION,
|
|
679
|
+
"rfq_nesting": rfq_compiler.NEST_HANDOFF_VERSION,
|
|
680
|
+
"rfq_workbook": rfq_compiler.RFQ_COMPILER_VERSION,
|
|
681
|
+
},
|
|
682
|
+
tool_versions={
|
|
683
|
+
"pi_steel": package_version(__file__),
|
|
684
|
+
"estimate_pipeline": PIPELINE_VERSION,
|
|
685
|
+
"nest_algorithm": nest_engine.NEST_ALGORITHM_VERSION,
|
|
686
|
+
"rfq_compiler": rfq_compiler.RFQ_COMPILER_VERSION,
|
|
687
|
+
},
|
|
688
|
+
explicit_dates={
|
|
689
|
+
"prepared_date": args.prepared_date,
|
|
690
|
+
"issued_date": args.issued_date,
|
|
691
|
+
},
|
|
692
|
+
warnings=review_warnings,
|
|
693
|
+
approximations=approximations,
|
|
694
|
+
run_id=args.run_id,
|
|
695
|
+
) as publisher:
|
|
696
|
+
publisher.write_json(
|
|
697
|
+
"estimate-package.json", normalized, readiness="draft"
|
|
698
|
+
)
|
|
699
|
+
publisher.write_json("normalized-bom.json", bom, readiness="diagnostic")
|
|
700
|
+
if nest_result is not None:
|
|
701
|
+
publisher.write_json(
|
|
702
|
+
"nest-result.json", nest_result, readiness="diagnostic"
|
|
703
|
+
)
|
|
704
|
+
publisher.write_json(
|
|
705
|
+
"rfq-nesting.json", handoff, readiness="diagnostic"
|
|
706
|
+
)
|
|
707
|
+
if inventory_consumption:
|
|
708
|
+
publisher.write_json(
|
|
709
|
+
"inventory-consumption.json",
|
|
710
|
+
inventory_consumption,
|
|
711
|
+
readiness="diagnostic",
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
if not blocked:
|
|
715
|
+
compile_result = rfq_compiler.compile_workbook(
|
|
716
|
+
rfq_normalized,
|
|
717
|
+
profile,
|
|
718
|
+
nest_handoff=handoff,
|
|
719
|
+
issued_date=args.issued_date,
|
|
720
|
+
project_location=args.project_location,
|
|
721
|
+
output_directory=publisher.staging_path,
|
|
722
|
+
profile_source=profile_source,
|
|
723
|
+
bake=not args.no_bake,
|
|
724
|
+
)
|
|
725
|
+
workbook_path = compile_result["workbook_path"]
|
|
726
|
+
_fixed_xlsx(workbook_path, args.issued_date)
|
|
727
|
+
qa_report["rfq"]["recalculation_status"] = compile_result[
|
|
728
|
+
"recalculation_status"
|
|
729
|
+
]
|
|
730
|
+
qa_report["rfq"]["logo_status"] = compile_result["logo_status"]
|
|
731
|
+
publisher.register_artifact(
|
|
732
|
+
workbook_path.name,
|
|
733
|
+
readiness="draft",
|
|
734
|
+
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
735
|
+
)
|
|
736
|
+
publisher.write_json(
|
|
737
|
+
"workbook-semantic.json",
|
|
738
|
+
rfq_compiler.workbook_semantic_projection(workbook_path),
|
|
739
|
+
readiness="diagnostic",
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
if not args.no_render and nest_result is not None and not render_missing:
|
|
743
|
+
nest_engine.render_layout(nest_result, publisher.staging_path)
|
|
744
|
+
publisher.register_artifact(
|
|
745
|
+
"layout.pdf",
|
|
746
|
+
readiness="reference_only",
|
|
747
|
+
media_type="application/pdf",
|
|
748
|
+
)
|
|
749
|
+
for plate in nest_result["plate_reports"]:
|
|
750
|
+
publisher.register_artifact(
|
|
751
|
+
f"plate_{plate['index']}.png",
|
|
752
|
+
readiness="reference_only",
|
|
753
|
+
media_type="image/png",
|
|
754
|
+
)
|
|
755
|
+
if nest_result["plate_reports"]:
|
|
756
|
+
nest_engine.render_dxf_overview(nest_result, publisher.staging_path)
|
|
757
|
+
nest_engine.render_reference_plate_dxfs(
|
|
758
|
+
nest_result, publisher.staging_path
|
|
759
|
+
)
|
|
760
|
+
publisher.register_artifact(
|
|
761
|
+
"reference_nest.dxf",
|
|
762
|
+
readiness="reference_only",
|
|
763
|
+
media_type="image/vnd.dxf",
|
|
764
|
+
)
|
|
765
|
+
for plate in nest_result["plate_reports"]:
|
|
766
|
+
publisher.register_artifact(
|
|
767
|
+
f"reference_plate_{plate['index']}.dxf",
|
|
768
|
+
readiness="reference_only",
|
|
769
|
+
media_type="image/vnd.dxf",
|
|
770
|
+
)
|
|
771
|
+
if outcome == "ready" and nest_result["burn_dxf_eligible"]:
|
|
772
|
+
nest_engine.render_burn_dxfs(nest_result, publisher.staging_path)
|
|
773
|
+
for plate in nest_result["plate_reports"]:
|
|
774
|
+
publisher.register_artifact(
|
|
775
|
+
f"burn_plate_{plate['index']}.dxf",
|
|
776
|
+
readiness="geometry_verified",
|
|
777
|
+
media_type="image/vnd.dxf",
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
publisher.write_qa_report(qa_report)
|
|
781
|
+
final_path = publisher.publish()
|
|
782
|
+
return qa_report, final_path
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def main(argv=None) -> int:
|
|
786
|
+
parser = StageArgumentParser(description=__doc__)
|
|
787
|
+
parser.configure_failure_diagnostics(
|
|
788
|
+
stage="steel-estimate",
|
|
789
|
+
entry_file=__file__,
|
|
790
|
+
input_option="--input",
|
|
791
|
+
date_options={
|
|
792
|
+
"--prepared-date": "prepared_date",
|
|
793
|
+
"--issued-date": "issued_date",
|
|
794
|
+
},
|
|
795
|
+
)
|
|
796
|
+
parser.add_argument("--input", required=True)
|
|
797
|
+
parser.add_argument("--out", default="outputs")
|
|
798
|
+
parser.add_argument("--prepared-date", required=True)
|
|
799
|
+
parser.add_argument("--issued-date", required=True)
|
|
800
|
+
parser.add_argument("--project-location", default="")
|
|
801
|
+
parser.add_argument("--kerf-in", type=float, default=0.06)
|
|
802
|
+
parser.add_argument("--part-gap-in", type=float, default=0.25)
|
|
803
|
+
parser.add_argument("--edge-margin-in", type=float, default=0.5)
|
|
804
|
+
parser.add_argument("--density-lb-in3", type=float, default=0.2836)
|
|
805
|
+
parser.add_argument("--no-render", action="store_true")
|
|
806
|
+
parser.add_argument("--no-bake", action="store_true")
|
|
807
|
+
parser.add_argument("--run-id", help=argparse.SUPPRESS)
|
|
808
|
+
args = parser.parse_args(argv)
|
|
809
|
+
try:
|
|
810
|
+
qa_report, final_path = build_pipeline(args)
|
|
811
|
+
except (OSError, json.JSONDecodeError, PipelineInputError) as exc:
|
|
812
|
+
diagnostic_path = publish_failure_diagnostic(
|
|
813
|
+
args.out,
|
|
814
|
+
stage="steel-estimate",
|
|
815
|
+
input_path=args.input,
|
|
816
|
+
error=exc,
|
|
817
|
+
tool_version=package_version(__file__),
|
|
818
|
+
run_id=args.run_id,
|
|
819
|
+
explicit_dates={
|
|
820
|
+
"prepared_date": args.prepared_date,
|
|
821
|
+
"issued_date": args.issued_date,
|
|
822
|
+
},
|
|
823
|
+
)
|
|
824
|
+
suffix = (
|
|
825
|
+
f"; diagnostic published: {diagnostic_path}"
|
|
826
|
+
if diagnostic_path is not None
|
|
827
|
+
else "; diagnostic publication unavailable"
|
|
828
|
+
)
|
|
829
|
+
print(f"Estimate package failed: {exc}{suffix}", file=sys.stderr)
|
|
830
|
+
return 1
|
|
831
|
+
print(f"Published {qa_report['run_outcome']} estimate package: {final_path}")
|
|
832
|
+
return outcome_exit_code(qa_report["run_outcome"])
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
if __name__ == "__main__":
|
|
836
|
+
raise SystemExit(main())
|