@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
@@ -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
- wb = openpyxl.load_workbook(path)
30
+
27
31
  try:
32
+ wb = openpyxl.load_workbook(path)
28
33
  wb.calculation.fullCalcOnLoad = True
29
- except Exception:
30
- pass # older openpyxl — the LibreOffice bake below still fixes values
31
- wb.save(path)
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
- sys.exit("usage: python3 recalc.py <workbook.xlsx>")
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
- sys.exit(f"file not found: {path}")
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
- flag_recalc_on_load(path)
65
- baked = libreoffice_bake(path)
66
- if baked:
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
- aisc-edition: "16th"
17
- shapes-count: "400+"
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 AISC member data lookup using the 16th Edition shapes database.
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: AISC Member Lookup
34
+ ## Quick Reference: Structural Member Lookup
35
35
 
36
- Look up any AISC shape's full property set:
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, Total_Wt_lbs, Connections, Notes
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 AISC database:
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 with estimated cost
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 total tonnage for bidding. Accuracy directly impacts profit margin — a 5% error on a 200-ton job is 10 tons ($32,000+ at current rates).
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,Total_Wt_lbs,Connections,Notes
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 with cost estimate
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", "A992").strip()
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
- # Cost estimates at various $/ton rates
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
- # AISC Member Lookup
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 AISC shapes database and returns all properties.
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
- Usage: python3 scripts/validate-bom.py path/to/bom.csv
4
+ from __future__ import annotations
6
5
 
7
- Checks:
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
- with open(db_path) as f:
31
- shapes = json.load(f)
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
- return {s["designation"]: s for s in shapes}
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 normalize_designation(raw: str) -> str:
37
- """Normalize a steel designation for lookup."""
38
- return raw.upper().replace(" ", "").strip()
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 validate_grade(shape_type: str, grade: str) -> list[str]:
42
- """Check if the material grade is appropriate for the shape type."""
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
- if shape_type in standard_grades:
57
- valid = standard_grades[shape_type]
58
- # Check if grade matches any valid option
59
- matches = any(
60
- grade_upper.replace(" ", "").replace(".", "").startswith(v.replace(" ", "").replace(".", ""))
61
- for v in valid
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
- if not matches:
64
- warnings.append(
65
- f"Grade '{grade}' unusual for {shape_type} — "
66
- f"expected one of: {', '.join(standard_grades[shape_type][:3])}"
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
- return warnings
70
-
71
-
72
- def main():
73
- if len(sys.argv) < 2:
74
- print("Usage: python3 validate-bom.py <bom.csv>")
75
- sys.exit(1)
76
-
77
- bom_file = sys.argv[1]
78
- if not os.path.exists(bom_file):
79
- print(f"ERROR: File not found: {bom_file}")
80
- sys.exit(1)
81
-
82
- shapes_db = load_shapes_db()
83
- errors = []
84
- warnings = []
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"\n ✅ VALIDATION PASSED — no issues found")
214
- print(f"{'='*60}\n")
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())