d62dfd13a8
- add skill package and SKILL.md with AM workflow, guardrails, and output structure - add technical reference corpus (DfAM, fatigue, defects, process parameters, compliance, cost) - add materials-db.json with polymer/metal data, roughness/post-processing ranges, and selection guides - add CLI tools: select_material.py and postprocess_route.py for material ranking and post-processing route generation
281 lines
11 KiB
Python
281 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
AM Material Selector — Additive Manufacturing Expert Skill
|
|
Filters and ranks materials from the database based on engineering requirements.
|
|
Usage: python3 select_material.py --help
|
|
"""
|
|
import json, argparse, sys
|
|
from pathlib import Path
|
|
|
|
DB_PATH = Path(__file__).parent.parent / "references" / "materials-db.json"
|
|
|
|
def load_db():
|
|
with open(DB_PATH) as f:
|
|
return json.load(f)
|
|
|
|
def parse_range(val):
|
|
"""Converts 'min-max' or a single number into (min, max)."""
|
|
if val is None:
|
|
return None, None
|
|
s = str(val)
|
|
if '-' in s:
|
|
parts = s.split('-')
|
|
return float(parts[0]), float(parts[1])
|
|
return float(s), float('inf')
|
|
|
|
def score_material(mat, req):
|
|
"""Computes a 0-100 score for a material against requirements. Returns (score, reasons, disqualified)."""
|
|
score = 100
|
|
reasons = []
|
|
disqualified = []
|
|
|
|
mech = mat.get('mechanical', {})
|
|
thermal = mat.get('thermal', {})
|
|
flags = mat.get('flags', {})
|
|
ra = mat.get('surface_roughness', {})
|
|
|
|
# --- ELIMINATION FILTERS ---
|
|
|
|
# Service temperature
|
|
if req.get('T_service'):
|
|
T = req['T_service']
|
|
T_max = thermal.get('T_max_service', 9999)
|
|
if isinstance(T_max, str):
|
|
T_max = float(T_max.replace('>','').replace('~',''))
|
|
if T_max < T:
|
|
disqualified.append(f"T_max_service={T_max}°C < required {T}°C")
|
|
|
|
# Process
|
|
if req.get('process'):
|
|
proc = req['process'].upper()
|
|
mat_procs = [p.upper() for p in mat.get('processes', [])]
|
|
if not any(proc in p or p in proc for p in mat_procs):
|
|
disqualified.append(f"process {req['process']} not available (processes: {mat.get('processes')})")
|
|
|
|
# Biocompatibility
|
|
if req.get('biocompatible'):
|
|
if not flags.get('biocompatible', False):
|
|
disqualified.append("biocompatibility required but not available")
|
|
|
|
# UV resistant
|
|
if req.get('uv_resistant'):
|
|
if not flags.get('uv_resistant', False):
|
|
disqualified.append("UV resistance required but not guaranteed")
|
|
|
|
# Minimum UTS
|
|
if req.get('UTS_min') and disqualified == []:
|
|
uts_min_req = req['UTS_min']
|
|
uts_max_mat = mech.get('UTS_max') or mech.get('UTS_asbuilt_max') or mech.get('UTS_LPBF_max') or 0
|
|
if uts_max_mat < uts_min_req:
|
|
disqualified.append(f"UTS_max={uts_max_mat} MPa < required {uts_min_req} MPa")
|
|
|
|
# Target roughness (Ra) — check if achievable
|
|
if req.get('Ra_target') and disqualified == []:
|
|
Ra_req = req['Ra_target']
|
|
Ra_asbuilt = ra.get('Ra_asbuilt_typical', 50)
|
|
postproc = ra.get('postprocess_achievable', {})
|
|
Ra_achievable_min = Ra_asbuilt
|
|
best_method = "as-built"
|
|
for method, vals in postproc.items():
|
|
if isinstance(vals, dict) and 'Ra_min' in vals:
|
|
if vals['Ra_min'] < Ra_achievable_min:
|
|
Ra_achievable_min = vals['Ra_min']
|
|
best_method = method
|
|
if Ra_achievable_min > Ra_req:
|
|
disqualified.append(f"achievable Ra_min={Ra_achievable_min}µm > target {Ra_req}µm even with post-processing")
|
|
else:
|
|
if Ra_asbuilt > Ra_req:
|
|
reasons.append(f"target Ra {Ra_req}µm achievable with {best_method} (as-built: {Ra_asbuilt}µm)")
|
|
|
|
if disqualified:
|
|
return 0, reasons, disqualified
|
|
|
|
# --- POSITIVE SCORING ---
|
|
|
|
# Temperature: more margin = better
|
|
if req.get('T_service'):
|
|
T = req['T_service']
|
|
T_max = thermal.get('T_max_service', 9999)
|
|
if isinstance(T_max, (int, float)):
|
|
margin = T_max - T
|
|
if margin > 100:
|
|
score += 10
|
|
reasons.append(f"excellent thermal margin (+{margin:.0f}°C)")
|
|
elif margin > 30:
|
|
score += 5
|
|
|
|
# UTS: margin against requirement
|
|
if req.get('UTS_min'):
|
|
uts_min_req = req['UTS_min']
|
|
uts_max_mat = mech.get('UTS_max') or mech.get('UTS_asbuilt_max') or mech.get('UTS_LPBF_max') or 0
|
|
if uts_max_mat > 0:
|
|
ratio = uts_max_mat / uts_min_req
|
|
if ratio > 2:
|
|
score += 10
|
|
elif ratio > 1.5:
|
|
score += 5
|
|
|
|
# As-built Ra: if it already meets the target without post-processing -> advantage
|
|
if req.get('Ra_target'):
|
|
Ra_asbuilt = ra.get('Ra_asbuilt_typical', 50)
|
|
if Ra_asbuilt <= req['Ra_target']:
|
|
score += 15
|
|
reasons.append(f"as-built Ra ({Ra_asbuilt}µm) already meets the target — no additional post-processing")
|
|
|
|
# Printability
|
|
difficulty_map = {
|
|
"easy": 10, "moderate": 5, "difficult": 0, "very difficult": -5, "extreme": -10
|
|
}
|
|
diff = mat.get('print_difficulty', '')
|
|
score += difficulty_map.get(diff, 0)
|
|
|
|
# Cost
|
|
cost = mat.get('cost_relative', 5)
|
|
if cost <= 2:
|
|
score += 8
|
|
reasons.append(f"low cost (index {cost})")
|
|
elif cost <= 5:
|
|
score += 4
|
|
elif cost > 15:
|
|
score -= 10
|
|
|
|
# Isotropy
|
|
aniso = mech.get('anisotropy_Z_factor', 1.0)
|
|
if aniso >= 0.9:
|
|
score += 5
|
|
reasons.append(f"good isotropy (Z/XY = {aniso})")
|
|
|
|
# Quantity
|
|
if req.get('quantity') and req['quantity'] > 100:
|
|
procs = mat.get('processes', [])
|
|
if any(p in ['SLS', 'MJF'] for p in procs):
|
|
score += 8
|
|
reasons.append("process well-suited for large series")
|
|
if any(p == 'LPBF' for p in procs) and req['quantity'] > 50:
|
|
score -= 5 # LPBF is expensive for large volumes
|
|
|
|
return min(score, 100), reasons, []
|
|
|
|
def recommend(req, top_n=5):
|
|
db = load_db()
|
|
all_mats = db['polymers'] + db['metals']
|
|
results = []
|
|
eliminated = []
|
|
|
|
for mat in all_mats:
|
|
score, reasons, disqualified = score_material(mat, req)
|
|
if disqualified:
|
|
eliminated.append({'id': mat['id'], 'name': mat['name'], 'why': disqualified})
|
|
else:
|
|
results.append({
|
|
'id': mat['id'],
|
|
'name': mat['name'],
|
|
'processes': mat.get('processes', []),
|
|
'score': score,
|
|
'reasons': reasons,
|
|
'cost_relative': mat.get('cost_relative', '?'),
|
|
'Ra_asbuilt': mat.get('surface_roughness', {}).get('Ra_asbuilt_typical', '?'),
|
|
'T_max': mat.get('thermal', {}).get('T_max_service', '?'),
|
|
'UTS_max': (mat.get('mechanical', {}).get('UTS_max') or
|
|
mat.get('mechanical', {}).get('UTS_asbuilt_max') or
|
|
mat.get('mechanical', {}).get('UTS_LPBF_max') or '?'),
|
|
'postprocess_for_Ra': _ra_strategy(mat, req.get('Ra_target'))
|
|
})
|
|
|
|
results.sort(key=lambda x: x['score'], reverse=True)
|
|
return results[:top_n], eliminated
|
|
|
|
def _ra_strategy(mat, Ra_target):
|
|
"""Returns the post-processing strategy to reach target Ra."""
|
|
if Ra_target is None:
|
|
return None
|
|
ra = mat.get('surface_roughness', {})
|
|
Ra_asbuilt = ra.get('Ra_asbuilt_typical', 50)
|
|
if Ra_asbuilt <= Ra_target:
|
|
return f"as-built is sufficient (Ra_typical={Ra_asbuilt}µm)"
|
|
postproc = ra.get('postprocess_achievable', {})
|
|
strategies = []
|
|
for method, vals in postproc.items():
|
|
if isinstance(vals, dict) and 'Ra_min' in vals:
|
|
if vals['Ra_min'] <= Ra_target:
|
|
note = vals.get('note', '')
|
|
strategies.append(f"{method} → Ra≥{vals['Ra_min']}µm" + (f" ({note})" if note else ""))
|
|
if strategies:
|
|
return "; or ".join(strategies)
|
|
return f"target Ra={Ra_target}µm NOT achievable (min={min((v['Ra_min'] for v in postproc.values() if isinstance(v,dict) and 'Ra_min' in v), default=Ra_asbuilt)}µm)"
|
|
|
|
def print_report(results, eliminated, req):
|
|
print("\n" + "="*70)
|
|
print(" AM MATERIAL SELECTOR — Results")
|
|
print("="*70)
|
|
print(f"\n Requirements analyzed:")
|
|
for k, v in req.items():
|
|
if v is not None:
|
|
print(f" {k}: {v}")
|
|
print(f"\n Materials evaluated: {len(results)+len(eliminated)}")
|
|
print(f" Eligible: {len(results)} | Eliminated: {len(eliminated)}")
|
|
|
|
print("\n" + "-"*70)
|
|
print(" TOP CANDIDATES (sorted by score)")
|
|
print("-"*70)
|
|
for i, r in enumerate(results):
|
|
print(f"\n [{i+1}] {r['name']} (score: {r['score']}/100)")
|
|
print(f" Processes: {', '.join(r['processes'])}")
|
|
print(f" UTS_max: {r['UTS_max']} MPa | T_max: {r['T_max']}°C | Ra_asbuilt: {r['Ra_asbuilt']}µm | Cost: {r['cost_relative']}/10")
|
|
if r['postprocess_for_Ra']:
|
|
print(f" Ra strategy: {r['postprocess_for_Ra']}")
|
|
if r['reasons']:
|
|
for reason in r['reasons'][:3]:
|
|
print(f" ✓ {reason}")
|
|
|
|
if eliminated:
|
|
print("\n" + "-"*70)
|
|
print(f" ELIMINATED ({len(eliminated)}) — main reasons")
|
|
print("-"*70)
|
|
shown = 0
|
|
for e in eliminated[:10]:
|
|
print(f" ✗ {e['name']}: {'; '.join(e['why'][:2])}")
|
|
shown += 1
|
|
if len(eliminated) > shown:
|
|
print(f" ... and {len(eliminated)-shown} more")
|
|
print("\n" + "="*70 + "\n")
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="AM Material Selector — filters materials by engineering requirements")
|
|
parser.add_argument('--T', type=float, help="Max service temperature (°C)")
|
|
parser.add_argument('--UTS', type=float, help="Minimum required UTS (MPa)")
|
|
parser.add_argument('--Ra', type=float, help="Target surface roughness Ra (µm)")
|
|
parser.add_argument('--process', type=str, help="Specific process (FDM, SLS, LPBF, etc.)")
|
|
parser.add_argument('--bio', action='store_true', help="Biocompatibility required")
|
|
parser.add_argument('--uv', action='store_true', help="UV resistance required")
|
|
parser.add_argument('--qty', type=int, help="Quantity (pcs)")
|
|
parser.add_argument('--top', type=int, default=5, help="Number of results (default: 5)")
|
|
parser.add_argument('--json', action='store_true', help="JSON output")
|
|
|
|
args = parser.parse_args()
|
|
|
|
req = {
|
|
'T_service': args.T,
|
|
'UTS_min': args.UTS,
|
|
'Ra_target': args.Ra,
|
|
'process': args.process,
|
|
'biocompatible':args.bio or None,
|
|
'uv_resistant': args.uv or None,
|
|
'quantity': args.qty
|
|
}
|
|
req = {k: v for k, v in req.items() if v}
|
|
|
|
if not req:
|
|
print("ERROR: specify at least one requirement. Use --help for parameters.")
|
|
sys.exit(1)
|
|
|
|
results, eliminated = recommend(req, top_n=args.top)
|
|
|
|
if args.json:
|
|
print(json.dumps({'results': results, 'eliminated': eliminated}, indent=2))
|
|
else:
|
|
print_report(results, eliminated, req)
|
|
|
|
if __name__ == '__main__':
|
|
main()
|