Files
engineering-skills/additive-manufacturing/scripts/postprocess_route.py
T
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

344 lines
14 KiB
Python

#!/usr/bin/env python3
"""
AM Post-Processing Route Planner
Given a process, material, and target Ra, generates the optimal post-processing sequence.
Usage: python3 postprocess_route.py --material AlSi10Mg --Ra 0.8 --use medical
"""
import json, argparse, sys
from pathlib import Path
DB_PATH = Path(__file__).parent.parent / "references" / "materials-db.json"
# Post-processing data by process — material-independent
POSTPROCESS_CATALOG = {
# --- Physical abrasive methods ---
"bead_blast": {
"label": "Bead/Sand Blasting",
"Ra_achievable": (3, 8),
"applies_to_processes": ["LPBF", "EBM", "SLS", "MJF", "BJT"],
"time_h": 0.1,
"cost_relative": 1,
"note": "Uniform matte finish. Typical prerequisite for other treatments.",
"sequence_priority": 1
},
"vibratory": {
"label": "Vibratory/Barrel Finishing",
"Ra_achievable": (1.5, 5),
"applies_to_processes": ["LPBF", "SLS", "MJF", "BJT"],
"time_h": 2,
"cost_relative": 2,
"note": "Good for batches. Watch thin features (<1mm).",
"sequence_priority": 2
},
"shot_peen": {
"label": "Shot Peening",
"Ra_achievable": (3, 8),
"applies_to_processes": ["LPBF", "EBM"],
"time_h": 0.5,
"cost_relative": 2,
"note": "Introduces compressive residual stress -> improves fatigue by 20-40%. AMS 2430 for aerospace.",
"sequence_priority": 2,
"functional_benefit": "fatigue_improvement"
},
"machining_CNC": {
"label": "CNC machining (milling/turning)",
"Ra_achievable": (0.4, 1.6),
"applies_to_processes": ["LPBF", "EBM", "SLS", "BJT", "FDM"],
"time_h": 1,
"cost_relative": 4,
"note": "For accessible specific surfaces. Machining stock must be planned in design (0.5-1.5mm).",
"sequence_priority": 3
},
"grinding": {
"label": "Grinding",
"Ra_achievable": (0.1, 0.4),
"applies_to_processes": ["LPBF", "EBM", "BJT"],
"applies_to_materials": ["17-4PH", "IN718", "CoCr", "316L"],
"time_h": 2,
"cost_relative": 5,
"note": "For flat sealing surfaces or precision fits.",
"sequence_priority": 4
},
"lapping_polishing": {
"label": "Lapping / Polishing",
"Ra_achievable": (0.025, 0.2),
"applies_to_processes": ["LPBF", "EBM", "SLA", "DLP"],
"time_h": 3,
"cost_relative": 6,
"note": "For Ra <0.2um. Surgical surfaces, precision seals, optics.",
"sequence_priority": 5
},
"electropolish": {
"label": "Electropolishing",
"Ra_achievable": (0.2, 1.5),
"applies_to_processes": ["LPBF", "BJT"],
"applies_to_materials": ["316L", "17-4PH", "Ti-6Al-4V", "IN625", "IN718"],
"time_h": 1,
"cost_relative": 3,
"note": "Excellent for 316L (food/medical). Variable results on Ti. Not recommended on Al.",
"sequence_priority": 3,
"functional_benefit": "corrosion_resistance"
},
"passivation": {
"label": "Passivation (nitric/citric acid)",
"Ra_achievable": None,
"applies_to_processes": ["LPBF", "BJT"],
"applies_to_materials": ["316L", "17-4PH", "15-5PH"],
"time_h": 0.5,
"cost_relative": 1,
"note": "Does not change Ra. Restores passive layer and improves corrosion resistance.",
"sequence_priority": 5,
"functional_benefit": "corrosion_resistance"
},
"anodizing": {
"label": "Anodizing",
"Ra_achievable": None,
"applies_to_processes": ["LPBF"],
"applies_to_materials": ["AlSi10Mg", "Scalmalloy"],
"time_h": 1,
"cost_relative": 2,
"note": "Does not change Ra. Protection, color, and surface hardness for Al.",
"sequence_priority": 5,
"functional_benefit": "surface_protection"
},
"sanding": {
"label": "Manual sanding (abrasive paper)",
"Ra_achievable": (0.4, 4),
"applies_to_processes": ["FDM", "SLA", "DLP"],
"time_h": 0.5,
"cost_relative": 1,
"grit_sequence": "120 -> 240 -> 400 -> 800 -> 1200 for Ra <1um",
"note": "Low cost. Labor-intensive. Not suitable for complex geometries.",
"sequence_priority": 2
},
"acetone_smoothing": {
"label": "Acetone smoothing (ABS only)",
"Ra_achievable": (1.5, 5),
"applies_to_processes": ["FDM"],
"applies_to_materials": ["ABS"],
"time_h": 0.3,
"cost_relative": 1,
"note": "ABS ONLY. Changes dimensions by +/-0.1-0.3mm; account for tolerances. Toxic vapors.",
"sequence_priority": 2
},
"IPA_wash_UV_cure": {
"label": "IPA Wash + UV Post-Curing",
"Ra_achievable": (1, 6),
"applies_to_processes": ["SLA", "DLP", "MSLA"],
"time_h": 0.5,
"cost_relative": 1,
"note": "MANDATORY for resins. IPA 10-15min + UV 900-1200 mJ/cm^2.",
"sequence_priority": 1,
"mandatory": True
},
"dyeing_SLS": {
"label": "Dyeing - SLS/MJF",
"Ra_achievable": None,
"applies_to_processes": ["SLS", "MJF"],
"time_h": 1,
"cost_relative": 1,
"note": "Does not change Ra. Uniform PA12 coloration. Hot process at 80-95C.",
"sequence_priority": 3,
"functional_benefit": "aesthetics"
},
"SLS_coating": {
"label": "SLS Coating (Ceracoat, Duracoat)",
"Ra_achievable": (3, 6),
"applies_to_processes": ["SLS"],
"time_h": 0.5,
"cost_relative": 2,
"note": "Sealing + uniform color for PA12 SLS.",
"sequence_priority": 4
},
"HIP": {
"label": "HIP (Hot Isostatic Pressing)",
"Ra_achievable": None,
"applies_to_processes": ["LPBF", "EBM", "BJT"],
"time_h": 8,
"cost_relative": 8,
"note": "Closes internal porosity (<0.1%). Mandatory for biomedical and fatigue-critical parts. 900C / 150MPa / 3h (Ti).",
"sequence_priority": 2,
"functional_benefit": "density_porosity"
}
}
def load_db():
with open(DB_PATH) as f:
return json.load(f)
def find_material(mat_id, db):
for m in db['polymers'] + db['metals']:
if m['id'].lower() == mat_id.lower():
return m
return None
def plan_route(mat_id, Ra_target, process, use_case=None):
db = load_db()
mat = find_material(mat_id, db)
if not mat:
avail = [m['id'] for m in db['polymers']+db['metals']]
return None, f"Material '{mat_id}' not found. Available: {', '.join(avail)}"
ra_data = mat.get('surface_roughness', {})
Ra_asbuilt = ra_data.get('Ra_asbuilt_typical', 50)
postproc_achievable = ra_data.get('postprocess_achievable', {})
mat_process = mat.get('processes', [process])
route = []
reasoning = []
# Step 1: mandatory process steps
if any(p in ['SLA','DLP','MSLA'] for p in mat_process):
route.append({
"step": 1, "operation": "IPA Wash + UV Post-Curing",
"conditions": "IPA 90%+ / 10-15 min agitation; UV 405nm / 900-1200 mJ/cm^2",
"Ra_after": Ra_asbuilt, "mandatory": True, "reason": "MANDATORY for resins -> final mechanical properties"
})
# Step 2: stress relief (metalli)
if any(p in ['LPBF','EBM','BJT'] for p in mat_process):
ht = mat.get('heat_treatment', {})
sr = ht.get('stress_relief', {})
if sr:
route.append({
"step": len(route)+1, "operation": "Stress Relief termico",
"conditions": f"{sr.get('temp_C','?')}°C / {sr.get('time_h','?')}h / {sr.get('atmosphere','?')}",
"Ra_after": Ra_asbuilt, "mandatory": sr.get('mandatory', True),
"reason": sr.get('timing', "Before removal from build plate") + " -> reduces residual stress and prevents distortion"
})
# Step 3: HIP if required by use case
if use_case in ['biomedical', 'fatigue_critical', 'pressure_vessel']:
if any(p in ['LPBF','EBM'] for p in mat_process):
route.append({
"step": len(route)+1, "operation": "HIP (Hot Isostatic Pressing)",
"conditions": "~900C / 150 MPa / 3h / Argon (Ti); consult supplier for other materials",
"Ra_after": Ra_asbuilt, "mandatory": True,
"reason": f"Mandatory for {use_case} -> closes porosity <0.1%, uniform properties"
})
# Step 4: specific heat treatment
ht = mat.get('heat_treatment', {})
for ht_key, ht_val in ht.items():
if isinstance(ht_val, dict) and ht_val.get('mandatory', False):
if ht_key not in ['stress_relief']:
route.append({
"step": len(route)+1, "operation": f"Heat Treatment: {ht_key.replace('_',' ').title()}",
"conditions": f"{ht_val.get('temp_C','?')}°C / {ht_val.get('time_h','?')}h / {ht_val.get('atmosphere','?')}",
"Ra_after": Ra_asbuilt, "mandatory": True,
"reason": ht_val.get('warning', ht_val.get('note', 'Required for final mechanical properties'))
})
# Step 5: support removal (if required)
if mat.get('flags', {}).get('supports_needed', False):
route.append({
"step": len(route)+1, "operation": "Support removal",
"conditions": "Mechanical (pliers, cutters) + milling for metal supports. EDM for hard-to-access areas.",
"Ra_after": Ra_asbuilt, "mandatory": True,
"reason": "After heat treatment for metals -> NOT before stress relief"
})
# Step 6: Ra route
Ra_current = Ra_asbuilt
if Ra_target and Ra_current > Ra_target:
reasoning.append(f"Ra as-built: {Ra_asbuilt}µm → target: {Ra_target}µm → delta: {Ra_asbuilt-Ra_target:.1f}µm")
# Choose optimal strategy
candidates = []
for method, vals in postproc_achievable.items():
if isinstance(vals, dict) and 'Ra_min' in vals:
if vals['Ra_min'] <= Ra_target:
candidates.append((method, vals['Ra_min'], vals))
# Sort by increasing cost
cost_map = {m: POSTPROCESS_CATALOG.get(m, {}).get('cost_relative', 3) for m, _, _ in candidates}
candidates.sort(key=lambda x: cost_map.get(x[0], 3))
if candidates:
best_method, best_Ra_min, best_vals = candidates[0]
catalog_entry = POSTPROCESS_CATALOG.get(best_method, {})
route.append({
"step": len(route)+1,
"operation": catalog_entry.get('label', best_method.replace('_',' ').title()),
"conditions": catalog_entry.get('grit_sequence', catalog_entry.get('note', '')),
"Ra_after": best_Ra_min,
"mandatory": False,
"reason": f"Required to reach target Ra {Ra_target}um (as-built: {Ra_asbuilt}um). Alternative: {candidates[1][0] if len(candidates)>1 else 'none'}"
})
Ra_current = best_Ra_min
else:
reasoning.append(f"WARNING: target Ra {Ra_target}um is not reachable with standard post-processing for {mat_id}")
# Step 7: additional functional treatments by use case
if use_case == 'food_contact' and mat_id in ['316L']:
route.append({
"step": len(route)+1, "operation": "Electropolishing + Passivation",
"conditions": "Electropolish in perchloric/acetic acid; nitric passivation 20-25%",
"Ra_after": 0.5, "mandatory": True,
"reason": "Mandatory for food-contact -> Ra <0.8um + intact passive layer (FDA/EHEDG)"
})
elif use_case == 'biomedical' and mat_id in ['316L', 'Ti-6Al-4V']:
route.append({
"step": len(route)+1, "operation": "Electropolishing + Biomedical passivation",
"conditions": "ASTM F86 or equivalent. Ra <0.8um for contact surfaces.",
"Ra_after": 0.4, "mandatory": True,
"reason": "Biomedical standard -> biofilm prevention, controlled cell adhesion"
})
# Final step: inspection
if use_case in ['biomedical', 'fatigue_critical', 'aerospace', 'pressure_vessel']:
route.append({
"step": len(route)+1, "operation": "Quality inspection",
"conditions": "CT scan (porosity) + CMM (dimensions) + hardness test" + (" + FPI (cracks)" if use_case in ['fatigue_critical','aerospace'] else ""),
"Ra_after": Ra_current, "mandatory": True,
"reason": f"Mandatory qualification for {use_case} application"
})
return route, reasoning
def print_route(mat_id, route, reasoning, Ra_target):
print("\n" + "="*70)
print(f" POST-PROCESSING ROUTE — {mat_id}")
if Ra_target:
print(f" Target Ra: {Ra_target} µm")
print("="*70)
if reasoning:
print("\n Notes:")
for r in reasoning:
print(f" {r}")
print(f"\n Sequence ({len(route)} steps):")
for step in route:
flag = " [MANDATORY]" if step.get('mandatory') else " [recommended]"
print(f"\n STEP {step['step']}: {step['operation']}{flag}")
if step.get('conditions'):
print(f" Conditions: {step['conditions']}")
if step.get('Ra_after') and Ra_target:
print(f" Ra after: ~{step['Ra_after']} µm")
print(f" Why: {step['reason']}")
print("\n" + "="*70 + "\n")
def main():
parser = argparse.ArgumentParser(description="AM Post-Processing Route Planner")
parser.add_argument('--material', required=True, help="Material ID (e.g. AlSi10Mg, Ti-6Al-4V, PA12-SLS)")
parser.add_argument('--Ra', type=float, help="Final target Ra (µm)")
parser.add_argument('--process', default='LPBF',help="AM process used")
parser.add_argument('--use', help="Application: biomedical, food_contact, aerospace, fatigue_critical, pressure_vessel")
parser.add_argument('--json', action='store_true', help="JSON output")
args = parser.parse_args()
route, reasoning = plan_route(args.material, args.Ra, args.process, args.use)
if route is None:
print(f"ERROR: {reasoning}")
sys.exit(1)
if args.json:
import json as _json
print(_json.dumps({'material': args.material, 'Ra_target': args.Ra, 'route': route, 'notes': reasoning}, indent=2))
else:
print_route(args.material, route, reasoning, args.Ra)
if __name__ == '__main__':
main()