File size: 8,732 Bytes
547ac30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
"""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