@structupath/pi-steel 0.1.0

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.
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bash
2
+ # ═══════════════════════════════════════════════════════════════════
3
+ # AISC Member Lookup
4
+ # Usage: ./scripts/lookup-member.sh "W14X30"
5
+ # ./scripts/lookup-member.sh "HSS8X6X1/2"
6
+ # ./scripts/lookup-member.sh "L4X4X3/8"
7
+ # ./scripts/lookup-member.sh "C10X20"
8
+ # ./scripts/lookup-member.sh "PIPE6STD"
9
+ #
10
+ # Searches the AISC shapes database and returns all properties.
11
+ # Designation is case-insensitive, spaces are stripped.
12
+ # ═══════════════════════════════════════════════════════════════════
13
+ set -euo pipefail
14
+
15
+ SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
16
+ DB="$SKILL_DIR/assets/aisc-shapes-database.json"
17
+
18
+ if [ $# -eq 0 ]; then
19
+ echo "Usage: $0 <designation>"
20
+ echo ""
21
+ echo "Examples:"
22
+ echo " $0 W14X30"
23
+ echo " $0 HSS8X6X1/2"
24
+ echo " $0 L4X4X3/8"
25
+ echo " $0 C10X20"
26
+ echo " $0 PIPE6STD"
27
+ echo ""
28
+ echo "Search by type:"
29
+ echo " $0 --type W # List all W-shapes"
30
+ echo " $0 --type HSS-RECT # List all rectangular HSS"
31
+ echo " $0 --type L # List all angles"
32
+ exit 1
33
+ fi
34
+
35
+ if [ "$1" = "--type" ]; then
36
+ TYPE="${2:-W}"
37
+ jq -r --arg t "$TYPE" '
38
+ [.[] | select(.type == $t)] |
39
+ sort_by(.weight_per_ft) |
40
+ .[] |
41
+ "\(.designation)\t\(.weight_per_ft) plf\td=\(.d)\"\tA=\(.A) in²\tIx=\(.Ix) in⁴"
42
+ ' "$DB" | column -t -s $'\t'
43
+ exit 0
44
+ fi
45
+
46
+ # Normalize the query: uppercase, strip spaces
47
+ QUERY=$(echo "$1" | tr '[:lower:]' '[:upper:]' | tr -d ' ')
48
+
49
+ # Try exact match first
50
+ RESULT=$(jq -r --arg q "$QUERY" '
51
+ .[] | select(.designation == $q) |
52
+ if .type == "W" then
53
+ "\(.designation) | \(.weight_per_ft) plf | d=\(.d)\" | bf=\(.bf)\" | tf=\(.tf)\" | tw=\(.tw)\" | A=\(.A) in² | Ix=\(.Ix) in⁴ | Sx=\(.Sx) in³ | Iy=\(.Iy) in⁴ | Sy=\(.Sy) in³ | ry=\(.ry)\" | Zx=\(.Zx) in³ | Zy=\(.Zy) in³"
54
+ elif .type == "HSS-RECT" then
55
+ "\(.designation) | \(.weight_per_ft) plf | d=\(.d)\" | b=\(.b)\" | t=\(.t)\" | A=\(.A) in² | Ix=\(.Ix) in⁴ | Sx=\(.Sx) in³ | Iy=\(.Iy) in⁴ | Sy=\(.Sy) in³"
56
+ elif .type == "HSS-RND" then
57
+ "\(.designation) | \(.weight_per_ft) plf | d=\(.d)\" | t=\(.t)\" | A=\(.A) in² | Ix=\(.Ix) in⁴ | Sx=\(.Sx) in³"
58
+ elif .type == "C" then
59
+ "\(.designation) | \(.weight_per_ft) plf | d=\(.d)\" | bf=\(.bf)\" | A=\(.A) in² | Ix=\(.Ix) in⁴ | Sx=\(.Sx) in³ | Iy=\(.Iy) in⁴ | Sy=\(.Sy) in³"
60
+ elif .type == "L" then
61
+ "\(.designation) | \(.weight_per_ft) plf | d=\(.d)\" | b=\(.b)\" | t=\(.t)\" | A=\(.A) in² | Ix=\(.Ix) in⁴ | Sx=\(.Sx) in³ | ry=\(.ry)\""
62
+ elif .type == "PIPE" then
63
+ "\(.designation) | \(.weight_per_ft) plf | d=\(.d)\" | t=\(.t)\" | A=\(.A) in² | Ix=\(.Ix) in⁴ | Sx=\(.Sx) in³"
64
+ else
65
+ "\(.designation) | \(.weight_per_ft) plf | A=\(.A) in²"
66
+ end
67
+ ' "$DB")
68
+
69
+ if [ -n "$RESULT" ]; then
70
+ echo "$RESULT"
71
+ else
72
+ # Try partial/fuzzy match
73
+ echo "No exact match for '$QUERY'. Searching..."
74
+ MATCHES=$(jq -r --arg q "$QUERY" '
75
+ [.[] | select(.designation | test($q; "i"))] |
76
+ sort_by(.weight_per_ft) |
77
+ .[:10] |
78
+ .[] |
79
+ "\(.designation)\t\(.weight_per_ft) plf\t\(.type)"
80
+ ' "$DB")
81
+
82
+ if [ -n "$MATCHES" ]; then
83
+ echo "Possible matches:"
84
+ echo "$MATCHES" | column -t -s $'\t'
85
+ else
86
+ echo "No shapes found matching '$QUERY'"
87
+ echo "Available types: W, HSS-RECT, HSS-RND, C, L, PIPE"
88
+ fi
89
+ exit 1
90
+ fi
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ BOM Validator — Validates a steel bill of materials CSV.
4
+
5
+ Usage: python3 scripts/validate-bom.py path/to/bom.csv
6
+
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
17
+ import json
18
+ import os
19
+ import sys
20
+ from pathlib import Path
21
+
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
+
30
+ with open(db_path) as f:
31
+ shapes = json.load(f)
32
+
33
+ return {s["designation"]: s for s in shapes}
34
+
35
+
36
+ def normalize_designation(raw: str) -> str:
37
+ """Normalize a steel designation for lookup."""
38
+ return raw.upper().replace(" ", "").strip()
39
+
40
+
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()
45
+
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
+
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
62
+ )
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])}"
67
+ )
68
+
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")
212
+ else:
213
+ print(f"\n ✅ VALIDATION PASSED — no issues found")
214
+ print(f"{'='*60}\n")
215
+
216
+
217
+ if __name__ == "__main__":
218
+ main()