@structupath/pi-steel 0.2.2 → 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.
Files changed (47) hide show
  1. package/DATA_PROVENANCE.json +23 -0
  2. package/DATA_PROVENANCE.md +35 -0
  3. package/PUBLIC_DATA_POLICY.md +52 -0
  4. package/README.md +114 -33
  5. package/docs/assets/pi-steel-demo.gif +0 -0
  6. package/docs/assets/pi-steel-gallery.webp +0 -0
  7. package/package.json +35 -4
  8. package/pyproject.toml +28 -0
  9. package/requirements-dev.txt +5 -0
  10. package/requirements-render.txt +1 -0
  11. package/requirements-tested.txt +9 -0
  12. package/requirements.txt +7 -0
  13. package/scripts/check-data-provenance.py +140 -0
  14. package/scripts/check-public-data.py +401 -0
  15. package/scripts/doctor.py +127 -0
  16. package/skills/_shared/bootstrap.py +32 -0
  17. package/skills/_shared/pi_steel/__init__.py +47 -0
  18. package/skills/_shared/pi_steel/cli.py +167 -0
  19. package/skills/_shared/pi_steel/contracts.py +87 -0
  20. package/skills/_shared/pi_steel/geometry_verify.py +243 -0
  21. package/skills/_shared/pi_steel/parsing.py +205 -0
  22. package/skills/_shared/pi_steel/run_manifest.py +317 -0
  23. package/skills/_shared/pi_steel/validation.py +597 -0
  24. package/skills/_shared/schemas/estimate-package.schema.json +424 -0
  25. package/skills/_shared/schemas/nest-result.schema.json +443 -0
  26. package/skills/_shared/schemas/run-manifest.schema.json +145 -0
  27. package/skills/steel-estimate/SKILL.md +119 -0
  28. package/skills/steel-estimate/references/estimate-package-example.json +91 -0
  29. package/skills/steel-estimate/references/output-contract.md +47 -0
  30. package/skills/steel-estimate/scripts/acknowledge-finding.py +193 -0
  31. package/skills/steel-estimate/scripts/build-estimate-package.py +836 -0
  32. package/skills/steel-nest/SKILL.md +50 -23
  33. package/skills/steel-nest/references/FIXTURE_PROVENANCE.md +12 -0
  34. package/skills/steel-nest/references/example_job.json +21 -25
  35. package/skills/steel-nest/references/job_template.json +11 -9
  36. package/skills/steel-nest/scripts/nest.py +1276 -250
  37. package/skills/steel-rfq/SKILL.md +93 -175
  38. package/skills/steel-rfq/assets/company-profile.example.json +11 -5
  39. package/skills/steel-rfq/references/rfq-input.md +58 -0
  40. package/skills/steel-rfq/scripts/generate-rfq.py +1221 -0
  41. package/skills/steel-rfq/scripts/recalc.py +33 -10
  42. package/skills/steel-takeoff/SKILL.md +12 -8
  43. package/skills/steel-takeoff/assets/bom-template.csv +1 -1
  44. package/skills/steel-takeoff/references/takeoff-procedures.md +3 -1
  45. package/skills/steel-takeoff/scripts/calculate-weight.sh +5 -10
  46. package/skills/steel-takeoff/scripts/lookup-member.sh +2 -2
  47. package/skills/steel-takeoff/scripts/validate-bom.py +151 -196
@@ -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())