Files
davide d62dfd13a8 feat(additive-manufacturing): add AM expert skill, references, and planning scripts
- 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
2026-03-23 14:32:47 +01:00

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()