Manufacturing-MoE / src /drilling_physics.py
rasitcesur's picture
Sync from rasitcesur/Manufacturing-MoE via hub-sync
547ac30 verified
Raw History Blame Contribute Delete
8.73 kB
"""Restricted G17/G90/G94 LinuxCNC-style G81/G82/G83 interpreter surrogate.
This is NOT LinuxCNC, a firmware emulator, or a measured power simulator.
Only supports the explicit subset generated by generate_drilling.py. Fails closed
on unsupported G-codes, offset modes, coordinates or parameter combinations.
For each canned cycle a source G-code block maps to multiple internal motions.
"""
from __future__ import annotations
import math,re
from dataclasses import dataclass
WORDS=re.compile(r'([A-Z])\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))',re.I)
PAREN=re.compile(r'\([^)]*\)|;[^\n]*')
COMPONENTS=['electronics','axis_hold','axis_x','axis_y','axis_z','spindle_idle','cutting_load','coolant']
MATERIALS={'AL6061':(.00270,1.45),'C45':(.00785,3.70),'POM':(.00141,.70)}
def motion_time(length,mm_min,accel):
if length<1e-12:return 0.
v=mm_min/60.;d=v*v/accel
return 2*math.sqrt(length/accel) if length<d else 2*v/accel+(length-d)/v
@dataclass
class State:
x:float=0.
y:float=0.
z:float=5.
rate:float=400.
rpm:float=0.
spindle:bool=False
coolant:bool=False
motion:str='G0'
cycle:str|None=None
retract:str='G98'
r:float|None=None
bottom:float|None=None
q:float|None=None
p:float|None=None
old_z:float|None=None
def simulate(lines,profile,*,accel=620.,rapid=6000.,clearance=.254,
base_power=39.,hold_power=19.,spindle_base=48.,spindle_linear=.011,
cutting_factor=1.,coolant_power=80.,axis_factor=1.):
"""Return one event per (possibly modal) G-code block; no target CSV is read."""
material=profile['material'];rho,k=MATERIALS[material];diam=float(profile['tool_diameter_mm'])
assert 0<diam<50
ztop=float(profile.get('stock_top_z_mm',0.));safe_z=float(profile.get('initial_z_mm',5.))
state=State(z=safe_z)
result=[]
for line in lines:
stripped=PAREN.sub('',line.upper()).strip()
if not stripped:continue
pairs=[(letter,float(value)) for letter,value in WORDS.findall(stripped)]
if not pairs:continue
words={a:b for a,b in pairs};gs=[int(v) for a,v in pairs if a=='G'];ms=[int(v) for a,v in pairs if a=='M']
allowed_g={0,1,4,17,21,40,49,80,81,82,83,90,94,98,99}
if any(g not in allowed_g for g in gs):raise ValueError('Unsupported drilling G-code: '+line)
if any(m not in (2,3,4,5,8,9) for m in ms):raise ValueError('Unsupported drilling M-code: '+line)
if 'F' in words:state.rate=float(words['F'])
if 'S' in words:state.rpm=float(words['S'])
if state.rate<=0:raise ValueError('feed must be positive')
for m in ms:
if m in (3,4):state.spindle=True
elif m==5:state.spindle=False
elif m==8:state.coolant=True
elif m==9:state.coolant=False
for g in gs:
if g in (98,99):state.retract='G'+str(g)
elif g in (0,1):state.motion='G'+str(g);state.cycle=None
elif g==80:state.cycle=None
elif g in (81,82,83):
state.cycle='G'+str(g)
state.old_z=state.z
comps=dict.fromkeys(COMPONENTS,0.)
total_time=0.;length_total=0.;feed_length=0.;volume=0.;segments=0;pecks=0;cycle_dwell=0.
def seg(dist,axis,rapid_move=False,cut=False):
nonlocal total_time,length_total,feed_length,segments
if dist<1.e-9:return
v=rapid if rapid_move else state.rate
dt=motion_time(dist,v,accel)
total_time+=dt;length_total+=dist;segments+=1
if not rapid_move:feed_length+=dist
# Electronics and hold are included during all nonzero time spans.
comps['electronics']+=base_power*dt/3600.
comps['axis_hold']+=hold_power*dt/3600.
if state.spindle:
comps['spindle_idle']+=(spindle_base+spindle_linear*state.rpm+1.1e-6*state.rpm**2)*dt/3600.
if state.coolant:comps['coolant']+=coolant_power*dt/3600.
speed=dist/max(dt,.001)
comps['axis_'+axis]+=axis_factor*(.45+.24*speed+.002*speed**2)*dt/3600.
def dwell(dt):
nonlocal total_time,cycle_dwell
total_time+=dt;cycle_dwell+=dt
comps['electronics']+=base_power*dt/3600.
comps['axis_hold']+=hold_power*dt/3600.
if state.spindle:comps['spindle_idle']+=(spindle_base+spindle_linear*state.rpm+1.1e-6*state.rpm**2)*dt/3600.
if state.coolant:comps['coolant']+=coolant_power*dt/3600.
op='STATE'
if 4 in gs:
if 'P' not in words or words['P']<0:raise ValueError('G4 needs P>=0')
op='G4';dwell(float(words['P']))
elif state.cycle and (('X' in words) or ('Y' in words) or (any(g in (81,82,83) for g in gs))):
op=state.cycle
# G-code cycling: X/Y modal, R and Z modal. G98 uses old Z; G99 uses R.
xx=words.get('X',state.x);yy=words.get('Y',state.y)
if 'R' in words:state.r=float(words['R'])
if 'Z' in words:state.bottom=float(words['Z'])
if 'Q' in words:state.q=float(words['Q'])
if 'P' in words:state.p=float(words['P'])
if state.r is None or state.bottom is None or state.bottom>=state.r or state.bottom>=ztop:raise ValueError('invalid R/Z in drilling cycle')
if op=='G83' and (state.q is None or state.q<=0):raise ValueError('G83 requires Q>0')
if op=='G82' and (state.p is None or state.p<0):raise ValueError('G82 requires P>=0')
if not state.spindle:raise ValueError('Drilling without spindle')
# XY traverse at current Z; lift to R if needed.
if state.z<state.r:seg(state.r-state.z,'z',True);state.z=state.r
seg(math.hypot(xx-state.x,yy-state.y),'x',True)
state.x,state.y=xx,yy
if state.z>state.r:seg(state.z-state.r,'z',True);state.z=state.r
target=state.bottom
depth=ztop-target
volume=math.pi/4*diam*diam*depth
if op in ('G81','G82'):
seg(state.r-target,'z',False,True);state.z=target
if op=='G82':dwell(state.p)
else:
previous_depth=state.r
while previous_depth>target+1e-9:
next_z=max(target,previous_depth-state.q)
if previous_depth==state.r:
seg(state.r-next_z,'z',False,True)
else:
approach=min(state.r,previous_depth+clearance)
seg(state.r-approach,'z',True)
seg(approach-next_z,'z',False,True)
previous_depth=next_z;pecks+=1
if previous_depth>target+1e-9:
seg(state.r-previous_depth,'z',True)
state.z=target
end_z=state.old_z if state.retract=='G98' and state.old_z is not None and state.old_z>state.r else state.r
seg(end_z-state.z,'z',True);state.z=end_z
# Drill removes one cylinder per location; each hole is unique in this generated subset.
comps['cutting_load']+=k*cutting_factor*volume/3600.
elif state.cycle and ('Z' in words or 'R' in words):
raise ValueError('Modal cycle must provide hole position')
elif any(g in (0,1) for g in gs) or (any(a in words for a in 'XYZ') and state.cycle is None):
op=state.motion
x,y,z=(words.get('X',state.x),words.get('Y',state.y),words.get('Z',state.z))
seg(math.hypot(x-state.x,y-state.y),'x',state.motion=='G0')
seg(abs(z-state.z),'z',state.motion=='G0')
state.x,state.y,state.z=x,y,z
if 'M3' in stripped or 'M4' in stripped:op='M3'
if 'M5' in stripped:op='M5'
if 'M8' in stripped:op='M8'
if 'M9' in stripped:op='M9'
if 'M2' in stripped:op='M2'
result.append({'gcode':stripped,'command':op,'time_s':total_time,'energy_Wh':sum(comps.values()),
'material_removed_mm3':volume,'material_g':volume*rho,'energy_components':comps,
'motion_length_mm':length_total,'feed_motion_mm':feed_length,
'segments':segments,'peck_count':pecks,'dwell_s':cycle_dwell,
'spindle_rpm':state.rpm,'feed_mm_min':state.rate,
'tool_diameter_mm':diam,'depth_mm':(-state.bottom) if state.bottom is not None else 0.,
'x_after_mm':state.x,'y_after_mm':state.y,'z_after_mm':state.z,
'spindle_on':int(state.spindle),'coolant_on':int(state.coolant),
'cycle_type':op if op in ('G81','G82','G83') else ''})
return result