@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
|
@@ -21,14 +21,23 @@ import sys
|
|
|
21
21
|
import tempfile
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
class RecalculationError(RuntimeError):
|
|
25
|
+
"""Raised when recalculate-on-open cannot be recorded safely."""
|
|
26
|
+
|
|
27
|
+
|
|
24
28
|
def flag_recalc_on_load(path):
|
|
25
29
|
import openpyxl
|
|
26
|
-
|
|
30
|
+
|
|
27
31
|
try:
|
|
32
|
+
wb = openpyxl.load_workbook(path)
|
|
28
33
|
wb.calculation.fullCalcOnLoad = True
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
wb.calculation.forceFullCalc = True
|
|
35
|
+
wb.calculation.calcMode = "auto"
|
|
36
|
+
wb.save(path)
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
raise RecalculationError(
|
|
39
|
+
f"could not record recalculate-on-open state for {path}: {exc}"
|
|
40
|
+
) from exc
|
|
32
41
|
|
|
33
42
|
|
|
34
43
|
def libreoffice_bake(path):
|
|
@@ -52,22 +61,36 @@ def libreoffice_bake(path):
|
|
|
52
61
|
return False
|
|
53
62
|
|
|
54
63
|
|
|
64
|
+
def recalculate(path, *, bake=True):
|
|
65
|
+
"""Set recalc-on-open and return an honest cache status."""
|
|
66
|
+
flag_recalc_on_load(path)
|
|
67
|
+
if bake and libreoffice_bake(path):
|
|
68
|
+
return "baked_via_libreoffice"
|
|
69
|
+
return "deferred_recalculate_on_open"
|
|
70
|
+
|
|
71
|
+
|
|
55
72
|
def main():
|
|
56
73
|
if len(sys.argv) < 2:
|
|
57
|
-
|
|
74
|
+
print("usage: python3 recalc.py <workbook.xlsx>", file=sys.stderr)
|
|
75
|
+
return 1
|
|
58
76
|
path = sys.argv[1]
|
|
59
77
|
if not os.path.exists(path):
|
|
60
|
-
|
|
78
|
+
print(f"file not found: {path}", file=sys.stderr)
|
|
79
|
+
return 1
|
|
61
80
|
# Set recalc-on-open FIRST so LibreOffice honors it, then bake. Do NOT
|
|
62
81
|
# reload with openpyxl afterward — that would strip the cached values
|
|
63
82
|
# LibreOffice just computed.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
83
|
+
try:
|
|
84
|
+
status = recalculate(path)
|
|
85
|
+
except RecalculationError as exc:
|
|
86
|
+
print(f"recalc: failed — {exc}", file=sys.stderr)
|
|
87
|
+
return 1
|
|
88
|
+
if status == "baked_via_libreoffice":
|
|
67
89
|
print(f"recalc: values computed and baked via LibreOffice — {path}")
|
|
68
90
|
else:
|
|
69
91
|
print(f"recalc: recalc-on-open set (open in Excel to compute values) — {path}")
|
|
92
|
+
return 0
|
|
70
93
|
|
|
71
94
|
|
|
72
95
|
if __name__ == "__main__":
|
|
73
|
-
main()
|
|
96
|
+
raise SystemExit(main())
|
|
@@ -13,14 +13,14 @@ compatibility: Requires jq and python3. macOS or Linux.
|
|
|
13
13
|
metadata:
|
|
14
14
|
domain: structural-steel
|
|
15
15
|
version: "1.0.0"
|
|
16
|
-
|
|
17
|
-
shapes-count: "
|
|
16
|
+
dataset: "StructuPath Structural Shapes Database"
|
|
17
|
+
shapes-count: "477"
|
|
18
18
|
allowed-tools: Bash Read Edit Write
|
|
19
19
|
---
|
|
20
20
|
|
|
21
21
|
# Steel Takeoff Skill
|
|
22
22
|
|
|
23
|
-
Structural steel quantity takeoff and
|
|
23
|
+
Structural steel quantity takeoff and member data lookup using StructuPath's 477-shape database.
|
|
24
24
|
|
|
25
25
|
## Setup (run once per machine)
|
|
26
26
|
|
|
@@ -31,9 +31,9 @@ python3 -c "import json, csv, sys" || echo "ERROR: python3 required"
|
|
|
31
31
|
command -v jq >/dev/null || echo "ERROR: install jq (brew install jq / apt install jq)"
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
## Quick Reference:
|
|
34
|
+
## Quick Reference: Structural Member Lookup
|
|
35
35
|
|
|
36
|
-
Look up any
|
|
36
|
+
Look up any supported shape's full property set:
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
39
|
# Single member lookup — returns all properties
|
|
@@ -72,10 +72,12 @@ For each unique member mark, create a line item:
|
|
|
72
72
|
cat assets/bom-template.csv
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
Fields: Mark, Qty, Size, Grade, Length_ft, Unit_Wt_plf,
|
|
75
|
+
Fields: Source_ID, Mark, Qty, Size, Grade, Length_ft, Unit_Wt_plf,
|
|
76
|
+
Total_Wt_lbs, Connections, Notes, Source_Sheet, Source_Detail, Intent.
|
|
77
|
+
Use stable synthetic or source-system IDs; do not invent drawing evidence.
|
|
76
78
|
|
|
77
79
|
### Step 3 — Look Up Unit Weights
|
|
78
|
-
For every member size in the BOM, look up the unit weight from the
|
|
80
|
+
For every member size in the BOM, look up the unit weight from the bundled StructuPath database:
|
|
79
81
|
|
|
80
82
|
```bash
|
|
81
83
|
./scripts/lookup-member.sh "W14X30"
|
|
@@ -96,7 +98,9 @@ For every member size in the BOM, look up the unit weight from the AISC database
|
|
|
96
98
|
# Total weight in lbs and tons
|
|
97
99
|
# Connection allowance (12%)
|
|
98
100
|
# Misc steel allowance (5%)
|
|
99
|
-
# Grand total
|
|
101
|
+
# Grand total weight
|
|
102
|
+
# No pricing unless a separate project input supplies an explicit currency,
|
|
103
|
+
# unit basis, effective date, and source
|
|
100
104
|
```
|
|
101
105
|
|
|
102
106
|
### Step 5 — Add Connections & Misc Steel
|
|
@@ -1 +1 @@
|
|
|
1
|
-
Mark,Qty,Size,Grade,Length_ft,Unit_Wt_plf,Total_Wt_lbs,Connections,Notes
|
|
1
|
+
Source_ID,Mark,Qty,Size,Grade,Length_ft,Unit_Wt_plf,Total_Wt_lbs,Connections,Notes,Source_Sheet,Source_Detail,Intent
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
A steel takeoff quantifies every piece of structural steel on a project to determine
|
|
5
|
+
A steel takeoff quantifies every piece of structural steel on a project to determine
|
|
6
|
+
total tonnage for bidding. Accuracy directly affects quantities, schedule, and cost;
|
|
7
|
+
this public guide intentionally contains no market rates or company pricing.
|
|
6
8
|
|
|
7
9
|
## Takeoff Order (recommended)
|
|
8
10
|
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
# Usage: ./scripts/calculate-weight.sh path/to/bom.csv [connection_pct] [misc_pct]
|
|
5
5
|
#
|
|
6
6
|
# BOM CSV format:
|
|
7
|
-
# Mark,Qty,Size,Grade,Length_ft,Unit_Wt_plf
|
|
7
|
+
# Source_ID,Mark,Qty,Size,Grade,Length_ft,Unit_Wt_plf,...
|
|
8
8
|
#
|
|
9
9
|
# Optional arguments:
|
|
10
10
|
# connection_pct - Connection allowance percentage (default: 12)
|
|
11
11
|
# misc_pct - Misc steel allowance percentage (default: 5)
|
|
12
12
|
#
|
|
13
|
-
# Output: Line-by-line breakdown + totals
|
|
13
|
+
# Output: Line-by-line breakdown + weight totals. Pricing is supplied separately.
|
|
14
14
|
# ═══════════════════════════════════════════════════════════════════
|
|
15
15
|
set -euo pipefail
|
|
16
16
|
|
|
@@ -68,7 +68,7 @@ with open(bom_file) as f:
|
|
|
68
68
|
|
|
69
69
|
qty = int(row.get("Qty", 0))
|
|
70
70
|
size = row.get("Size", "?").strip()
|
|
71
|
-
grade = row.get("Grade", "
|
|
71
|
+
grade = row.get("Grade", "").strip() or "UNSPECIFIED"
|
|
72
72
|
length_str = row.get("Length_ft", "0").strip()
|
|
73
73
|
unit_wt_str = row.get("Unit_Wt_plf", "0").strip()
|
|
74
74
|
|
|
@@ -136,13 +136,8 @@ print(f" {'Misc Steel Allow ({:.0f}%):':<30} {misc_wt:>12,.0f} lb ({misc_wt/20
|
|
|
136
136
|
print(f" {'─'*60}")
|
|
137
137
|
print(f" {'GRAND TOTAL:':<30} {grand_total:>12,.0f} lb ({grand_total/2000:>8,.1f} tons)")
|
|
138
138
|
print(f" {'─'*60}")
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
print(f"\n COST ESTIMATES:")
|
|
142
|
-
tons = grand_total / 2000
|
|
143
|
-
for rate in [2800, 3200, 3600, 4000]:
|
|
144
|
-
cost = tons * rate
|
|
145
|
-
print(f" @ ${rate:,}/ton: ${cost:>14,.0f}")
|
|
139
|
+
print("\n No price was calculated. Pricing requires an explicit currency, unit basis,")
|
|
140
|
+
print(" effective date, and source supplied by the project input.")
|
|
146
141
|
|
|
147
142
|
print(f"\n{'='*78}\n")
|
|
148
143
|
PYTHON
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# ═══════════════════════════════════════════════════════════════════
|
|
3
|
-
#
|
|
3
|
+
# StructuPath Member Lookup
|
|
4
4
|
# Usage: ./scripts/lookup-member.sh "W14X30"
|
|
5
5
|
# ./scripts/lookup-member.sh "HSS8X6X1/2"
|
|
6
6
|
# ./scripts/lookup-member.sh "L4X4X3/8"
|
|
7
7
|
# ./scripts/lookup-member.sh "C10X20"
|
|
8
8
|
# ./scripts/lookup-member.sh "PIPE6STD"
|
|
9
9
|
#
|
|
10
|
-
# Searches the
|
|
10
|
+
# Searches the bundled StructuPath shapes database and returns all properties.
|
|
11
11
|
# Designation is case-insensitive, spaces are stripped.
|
|
12
12
|
# ═══════════════════════════════════════════════════════════════════
|
|
13
13
|
set -euo pipefail
|
|
@@ -1,218 +1,173 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
BOM Validator — Validates a steel bill of materials CSV.
|
|
2
|
+
"""Validate a legacy BOM through the canonical estimate-package contract."""
|
|
4
3
|
|
|
5
|
-
|
|
4
|
+
from __future__ import annotations
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
✓ Valid AISC designations (cross-references shapes database)
|
|
9
|
-
✓ Non-zero quantities and lengths
|
|
10
|
-
✓ Correct grade for shape type
|
|
11
|
-
✓ Reasonable weight-per-foot values
|
|
12
|
-
✓ No duplicate marks
|
|
13
|
-
✓ Required columns present
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
import csv
|
|
6
|
+
import argparse
|
|
17
7
|
import json
|
|
18
|
-
import os
|
|
19
8
|
import sys
|
|
20
9
|
from pathlib import Path
|
|
21
10
|
|
|
22
|
-
def load_shapes_db():
|
|
23
|
-
"""Load the AISC shapes database."""
|
|
24
|
-
skill_dir = Path(__file__).resolve().parent.parent
|
|
25
|
-
db_path = skill_dir / "assets" / "aisc-shapes-database.json"
|
|
26
|
-
if not db_path.exists():
|
|
27
|
-
print(f"WARNING: Shapes database not found at {db_path}")
|
|
28
|
-
return {}
|
|
29
11
|
|
|
30
|
-
|
|
31
|
-
|
|
12
|
+
SHARED_ROOT = Path(__file__).resolve().parents[2] / "_shared"
|
|
13
|
+
if str(SHARED_ROOT) not in sys.path:
|
|
14
|
+
sys.path.insert(0, str(SHARED_ROOT))
|
|
15
|
+
from bootstrap import bootstrap_shared # noqa: E402
|
|
32
16
|
|
|
33
|
-
|
|
17
|
+
bootstrap_shared(__file__)
|
|
18
|
+
from pi_steel.parsing import adapt_legacy_bom_csv # noqa: E402
|
|
19
|
+
from pi_steel.validation import validate_estimate_package # noqa: E402
|
|
34
20
|
|
|
35
21
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
|
|
22
|
+
def load_shapes_db() -> dict[str, dict]:
|
|
23
|
+
db_path = Path(__file__).resolve().parent.parent / "assets" / "aisc-shapes-database.json"
|
|
24
|
+
if not db_path.exists():
|
|
25
|
+
return {}
|
|
26
|
+
shapes = json.loads(db_path.read_text(encoding="utf-8"))
|
|
27
|
+
return {shape["designation"]: shape for shape in shapes}
|
|
39
28
|
|
|
40
29
|
|
|
41
|
-
def
|
|
42
|
-
""
|
|
43
|
-
warnings = []
|
|
44
|
-
grade_upper = grade.upper().strip()
|
|
30
|
+
def normalize_designation(raw: str) -> str:
|
|
31
|
+
return raw.upper().replace(" ", "").strip()
|
|
45
32
|
|
|
46
|
-
# Standard grade assignments
|
|
47
|
-
standard_grades = {
|
|
48
|
-
"W": ["A992", "A572", "A913", "A36"],
|
|
49
|
-
"HSS-RECT": ["A500", "A500 GR.C", "A500 GR. C", "A500GRC", "A500C"],
|
|
50
|
-
"HSS-RND": ["A500", "A500 GR.B", "A500 GR. B", "A500GRB", "A500B", "A53"],
|
|
51
|
-
"C": ["A36", "A572"],
|
|
52
|
-
"L": ["A36", "A572"],
|
|
53
|
-
"PIPE": ["A53", "A53 GR.B", "A53B", "A500"],
|
|
54
|
-
}
|
|
55
33
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
34
|
+
def grade_warnings(shape_type: str, grade: str) -> list[str]:
|
|
35
|
+
normalized = grade.upper().replace(" ", "").replace(".", "")
|
|
36
|
+
expected = {
|
|
37
|
+
"W": ("A992", "A572", "A913", "A36"),
|
|
38
|
+
"HSS-RECT": ("A500", "A500GRC"),
|
|
39
|
+
"HSS-RND": ("A500", "A500GRB", "A53"),
|
|
40
|
+
"C": ("A36", "A572"),
|
|
41
|
+
"L": ("A36", "A572"),
|
|
42
|
+
"PIPE": ("A53", "A500"),
|
|
43
|
+
}.get(shape_type, ())
|
|
44
|
+
if expected and not any(normalized.startswith(value) for value in expected):
|
|
45
|
+
return [f"Grade {grade!r} is unusual for shape type {shape_type}."]
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parser() -> argparse.ArgumentParser:
|
|
50
|
+
result = argparse.ArgumentParser(description=__doc__)
|
|
51
|
+
result.add_argument("bom", type=Path)
|
|
52
|
+
result.add_argument(
|
|
53
|
+
"--project-id",
|
|
54
|
+
default="SYNTHETIC-UNSPECIFIED",
|
|
55
|
+
help="Stable project identity used to derive canonical item IDs.",
|
|
56
|
+
)
|
|
57
|
+
result.add_argument(
|
|
58
|
+
"--revision-id",
|
|
59
|
+
default="SYNTHETIC-REV-UNSPECIFIED",
|
|
60
|
+
help="Source revision identity used to derive canonical item IDs.",
|
|
61
|
+
)
|
|
62
|
+
result.add_argument(
|
|
63
|
+
"--json",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="Print the validation result as JSON.",
|
|
66
|
+
)
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main(argv: list[str] | None = None) -> int:
|
|
71
|
+
args = parser().parse_args(argv)
|
|
72
|
+
if not args.bom.is_file():
|
|
73
|
+
print(f"ERROR: File not found: {args.bom}", file=sys.stderr)
|
|
74
|
+
return 1
|
|
75
|
+
try:
|
|
76
|
+
package = adapt_legacy_bom_csv(
|
|
77
|
+
args.bom,
|
|
78
|
+
project_id=args.project_id,
|
|
79
|
+
revision_id=args.revision_id,
|
|
62
80
|
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
81
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
82
|
+
print(f"ERROR: Could not parse BOM: {exc}", file=sys.stderr)
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
result = validate_estimate_package(package)
|
|
86
|
+
findings = list(result.active_findings)
|
|
87
|
+
shapes = load_shapes_db()
|
|
88
|
+
for index, item in enumerate(package["items"]):
|
|
89
|
+
designation = normalize_designation(item.get("designation", ""))
|
|
90
|
+
shape = shapes.get(designation)
|
|
91
|
+
if designation and shapes and shape is None and not designation.startswith(
|
|
92
|
+
("PL", "PLATE", "BU", "WT")
|
|
93
|
+
):
|
|
94
|
+
findings.append(
|
|
95
|
+
{
|
|
96
|
+
"finding_id": f"legacy-shape:{index}",
|
|
97
|
+
"code": "unverified_designation",
|
|
98
|
+
"severity": "warning",
|
|
99
|
+
"path": f"$.items[{index}].designation",
|
|
100
|
+
"message": f"{item.get('designation')!r} was not found in the bundled reference data.",
|
|
101
|
+
"relevant_hash": result.input_hash,
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
elif shape is not None:
|
|
105
|
+
unit_weight = item.get("unit_weight_plf")
|
|
106
|
+
if unit_weight and abs(unit_weight - shape["weight_per_ft"]) > 0.5:
|
|
107
|
+
findings.append(
|
|
108
|
+
{
|
|
109
|
+
"finding_id": f"legacy-weight:{index}",
|
|
110
|
+
"code": "unit_weight_mismatch",
|
|
111
|
+
"severity": "warning",
|
|
112
|
+
"path": f"$.items[{index}].unit_weight_plf",
|
|
113
|
+
"message": (
|
|
114
|
+
f"Unit weight {unit_weight:g} plf does not match the "
|
|
115
|
+
f"bundled reference value {shape['weight_per_ft']:g} plf."
|
|
116
|
+
),
|
|
117
|
+
"relevant_hash": result.input_hash,
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
for message in grade_warnings(shape["type"], item.get("grade", "")):
|
|
121
|
+
findings.append(
|
|
122
|
+
{
|
|
123
|
+
"finding_id": f"legacy-grade:{index}",
|
|
124
|
+
"code": "unusual_grade",
|
|
125
|
+
"severity": "warning",
|
|
126
|
+
"path": f"$.items[{index}].grade",
|
|
127
|
+
"message": message,
|
|
128
|
+
"relevant_hash": result.input_hash,
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
if item.get("length_ft", 0) > 80:
|
|
132
|
+
findings.append(
|
|
133
|
+
{
|
|
134
|
+
"finding_id": f"legacy-length:{index}",
|
|
135
|
+
"code": "unusual_member_length",
|
|
136
|
+
"severity": "warning",
|
|
137
|
+
"path": f"$.items[{index}].length_ft",
|
|
138
|
+
"message": "Member length exceeds 80 ft; verify the source.",
|
|
139
|
+
"relevant_hash": result.input_hash,
|
|
140
|
+
}
|
|
67
141
|
)
|
|
68
142
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
marks_seen = {}
|
|
86
|
-
total_weight = 0
|
|
87
|
-
line_num = 0
|
|
88
|
-
|
|
89
|
-
required_columns = {"Mark", "Qty", "Size", "Length_ft", "Unit_Wt_plf"}
|
|
90
|
-
|
|
91
|
-
print(f"\n{'='*60}")
|
|
92
|
-
print(f" BOM VALIDATION: {os.path.basename(bom_file)}")
|
|
93
|
-
print(f"{'='*60}\n")
|
|
94
|
-
|
|
95
|
-
with open(bom_file) as f:
|
|
96
|
-
reader = csv.DictReader(f)
|
|
97
|
-
|
|
98
|
-
# Check required columns
|
|
99
|
-
if reader.fieldnames:
|
|
100
|
-
actual_cols = set(reader.fieldnames)
|
|
101
|
-
missing_cols = required_columns - actual_cols
|
|
102
|
-
if missing_cols:
|
|
103
|
-
errors.append(f"Missing required columns: {', '.join(missing_cols)}")
|
|
104
|
-
|
|
105
|
-
for row in reader:
|
|
106
|
-
line_num += 1
|
|
107
|
-
mark = row.get("Mark", "").strip()
|
|
108
|
-
if not mark or mark.startswith("#"):
|
|
109
|
-
continue
|
|
110
|
-
|
|
111
|
-
size = row.get("Size", "").strip()
|
|
112
|
-
grade = row.get("Grade", "").strip()
|
|
113
|
-
qty_str = row.get("Qty", "0").strip()
|
|
114
|
-
length_str = row.get("Length_ft", "0").strip()
|
|
115
|
-
unit_wt_str = row.get("Unit_Wt_plf", "0").strip()
|
|
116
|
-
|
|
117
|
-
prefix = f"Line {line_num} ({mark})"
|
|
118
|
-
|
|
119
|
-
# Check for duplicate marks
|
|
120
|
-
if mark in marks_seen:
|
|
121
|
-
warnings.append(f"{prefix}: Duplicate mark (also on line {marks_seen[mark]})")
|
|
122
|
-
marks_seen[mark] = line_num
|
|
123
|
-
|
|
124
|
-
# Validate quantity
|
|
125
|
-
try:
|
|
126
|
-
qty = int(qty_str) if qty_str else 0
|
|
127
|
-
if qty <= 0:
|
|
128
|
-
errors.append(f"{prefix}: Quantity is {qty} — must be > 0")
|
|
129
|
-
except ValueError:
|
|
130
|
-
errors.append(f"{prefix}: Invalid quantity '{qty_str}'")
|
|
131
|
-
qty = 0
|
|
132
|
-
|
|
133
|
-
# Validate length
|
|
134
|
-
try:
|
|
135
|
-
if "'" in length_str:
|
|
136
|
-
parts = length_str.replace('"', '').split("'")
|
|
137
|
-
feet = float(parts[0].replace('-', '').strip())
|
|
138
|
-
inches = float(parts[1].replace('-', '').strip()) if len(parts) > 1 and parts[1].strip() else 0
|
|
139
|
-
length = feet + inches / 12.0
|
|
140
|
-
else:
|
|
141
|
-
length = float(length_str) if length_str else 0
|
|
142
|
-
if length <= 0:
|
|
143
|
-
errors.append(f"{prefix}: Length is {length} — must be > 0")
|
|
144
|
-
elif length > 80:
|
|
145
|
-
warnings.append(f"{prefix}: Length {length:.1f} ft exceeds typical max (80 ft) — verify")
|
|
146
|
-
except ValueError:
|
|
147
|
-
errors.append(f"{prefix}: Invalid length '{length_str}'")
|
|
148
|
-
length = 0
|
|
149
|
-
|
|
150
|
-
# Validate unit weight
|
|
151
|
-
try:
|
|
152
|
-
unit_wt = float(unit_wt_str) if unit_wt_str else 0
|
|
153
|
-
if unit_wt <= 0:
|
|
154
|
-
errors.append(f"{prefix}: Unit weight is {unit_wt} — must be > 0")
|
|
155
|
-
except ValueError:
|
|
156
|
-
errors.append(f"{prefix}: Invalid unit weight '{unit_wt_str}'")
|
|
157
|
-
unit_wt = 0
|
|
158
|
-
|
|
159
|
-
# Validate AISC designation
|
|
160
|
-
normalized = normalize_designation(size)
|
|
161
|
-
if shapes_db:
|
|
162
|
-
if normalized in shapes_db:
|
|
163
|
-
db_shape = shapes_db[normalized]
|
|
164
|
-
db_wt = db_shape["weight_per_ft"]
|
|
165
|
-
if unit_wt > 0 and abs(unit_wt - db_wt) > 0.5:
|
|
166
|
-
warnings.append(
|
|
167
|
-
f"{prefix}: Unit weight {unit_wt} plf doesn't match "
|
|
168
|
-
f"AISC database ({db_wt} plf) for {size}"
|
|
169
|
-
)
|
|
170
|
-
# Validate grade
|
|
171
|
-
if grade:
|
|
172
|
-
grade_warns = validate_grade(db_shape["type"], grade)
|
|
173
|
-
warnings.extend(f"{prefix}: {w}" for w in grade_warns)
|
|
174
|
-
else:
|
|
175
|
-
# Not a fatal error — could be a plate or built-up section
|
|
176
|
-
if not any(normalized.startswith(p) for p in ["PL", "PLATE", "BU", "WT"]):
|
|
177
|
-
warnings.append(f"{prefix}: '{size}' not found in AISC database — verify designation")
|
|
178
|
-
|
|
179
|
-
# Validate grade is present
|
|
180
|
-
if not grade:
|
|
181
|
-
warnings.append(f"{prefix}: No material grade specified")
|
|
182
|
-
|
|
183
|
-
total_weight += qty * length * unit_wt
|
|
184
|
-
|
|
185
|
-
# Print results
|
|
186
|
-
if errors:
|
|
187
|
-
print(" ❌ ERRORS (must fix):")
|
|
188
|
-
for e in errors:
|
|
189
|
-
print(f" • {e}")
|
|
190
|
-
print()
|
|
191
|
-
|
|
192
|
-
if warnings:
|
|
193
|
-
print(" ⚠️ WARNINGS (review):")
|
|
194
|
-
for w in warnings:
|
|
195
|
-
print(f" • {w}")
|
|
196
|
-
print()
|
|
197
|
-
|
|
198
|
-
print(f" 📊 SUMMARY:")
|
|
199
|
-
print(f" Lines validated: {line_num}")
|
|
200
|
-
print(f" Unique marks: {len(marks_seen)}")
|
|
201
|
-
print(f" Errors: {len(errors)}")
|
|
202
|
-
print(f" Warnings: {len(warnings)}")
|
|
203
|
-
print(f" Total weight: {total_weight:,.0f} lbs ({total_weight/2000:,.1f} tons)")
|
|
204
|
-
|
|
205
|
-
if errors:
|
|
206
|
-
print(f"\n ❌ VALIDATION FAILED — {len(errors)} error(s) found")
|
|
207
|
-
print(f"{'='*60}\n")
|
|
208
|
-
sys.exit(1)
|
|
209
|
-
elif warnings:
|
|
210
|
-
print(f"\n ⚠️ PASSED WITH WARNINGS — review {len(warnings)} warning(s)")
|
|
211
|
-
print(f"{'='*60}\n")
|
|
143
|
+
total_weight = sum(
|
|
144
|
+
item["quantity"] * item.get("length_ft", 0) * item.get("unit_weight_plf", 0)
|
|
145
|
+
for item in package["items"]
|
|
146
|
+
if item.get("intent") == "fabricated_part"
|
|
147
|
+
)
|
|
148
|
+
blockers = [finding for finding in findings if finding["severity"] == "blocker"]
|
|
149
|
+
warnings = [finding for finding in findings if finding["severity"] == "warning"]
|
|
150
|
+
output = {
|
|
151
|
+
"status": "invalid" if blockers else ("review_required" if warnings else "validated"),
|
|
152
|
+
"input_hash": result.input_hash,
|
|
153
|
+
"line_count": len(package["items"]),
|
|
154
|
+
"total_weight_lbs": total_weight,
|
|
155
|
+
"findings": findings,
|
|
156
|
+
}
|
|
157
|
+
if args.json:
|
|
158
|
+
print(json.dumps(output, indent=2, sort_keys=True))
|
|
212
159
|
else:
|
|
213
|
-
print(f"
|
|
214
|
-
|
|
160
|
+
print(f"BOM VALIDATION: {args.bom.name}")
|
|
161
|
+
for finding in findings:
|
|
162
|
+
label = "ERROR" if finding["severity"] == "blocker" else finding["severity"].upper()
|
|
163
|
+
print(f"{label}: {finding['path']}: {finding['message']}")
|
|
164
|
+
print(
|
|
165
|
+
f"SUMMARY: {len(package['items'])} lines; "
|
|
166
|
+
f"{total_weight:,.0f} lb; {len(blockers)} blockers; {len(warnings)} warnings"
|
|
167
|
+
)
|
|
168
|
+
print(f"STATUS: {output['status']}")
|
|
169
|
+
return 1 if blockers else 0
|
|
215
170
|
|
|
216
171
|
|
|
217
172
|
if __name__ == "__main__":
|
|
218
|
-
main()
|
|
173
|
+
raise SystemExit(main())
|
|
Binary file
|