@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.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/package.json +37 -0
- package/skills/steel-rfq/SKILL.md +178 -0
- package/skills/steel-rfq/assets/company-profile.example.json +7 -0
- package/skills/steel-takeoff/SKILL.md +157 -0
- package/skills/steel-takeoff/assets/aisc-shapes-database.json +484 -0
- package/skills/steel-takeoff/assets/bom-template.csv +1 -0
- package/skills/steel-takeoff/assets/material-grades.json +153 -0
- package/skills/steel-takeoff/references/aisc-shapes-guide.md +96 -0
- package/skills/steel-takeoff/references/connection-types.md +124 -0
- package/skills/steel-takeoff/references/takeoff-procedures.md +163 -0
- package/skills/steel-takeoff/scripts/calculate-weight.sh +148 -0
- package/skills/steel-takeoff/scripts/lookup-member.sh +90 -0
- package/skills/steel-takeoff/scripts/validate-bom.py +218 -0
|
@@ -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()
|