ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python状态机驱动CNC G代码解析与模拟校验

Python状态机驱动CNC G代码解析与模拟校验 简介一个用Python实现的简单CNC状态机项目面向自动化控制初学者、Python开发者及对G代码解析感兴趣的编程爱好者。项目通过解析G代码指令模拟机床在等待、移动、切割等状态间的流转帮助读者理解状态机模型在真实工业场景中的应用。压缩包共10个文件以Python源码为核心辅以配置文件、测试脚本、README说明及Makefile整体仅15KB轻量易读。其中G代码解析器负责将指令拆解为可操作元素状态引擎根据当前机床状态和输入更新状态仿真器计算刀具路径、速度与位置错误检测检查指令合法性从解析到仿真形成完整闭环。资源已有97人学习适合作为学习CNC控制、状态机设计及G代码标准的入门实践项目。1. 一个Python状态机怎么把G代码从文本变成可验证的执行序列CNC项目里最常见的翻车现场不在插补算法,而在状态没管住:程序还在退刀,操作员已经按了暂停,G代码解析器直接吐了个乱序坐标;换刀指令还没落位,下一段G01就压进来了。G代码本身是顺序文本,但机床行为是分状态的,逐行执行和机床状态是两回事。用Python写一个简单的CNC状态机,把G代码逐行喂进去,由状态机决定当前行能不能执行、执行完进入什么状态,这是做G代码模拟和处理最稳的骨架。下面把状态划分、转移表、解析和模态处理串起来,给出能跑的最小实现,以及模拟和校验时要调的参数。2. CNC状态机的状态划分与事件转移表2.1 先从机床行为拆状态,别从代码行数拆很多第一次写CNC模拟器的人,会把状态直接映射成执行到第几行,这等于没做状态机。状态机描述的是机床现在允许做什么,程序走到哪一行是另一个维度。一行G代码能不能执行,取决于机床当前状态,跟行号没有关系。把这两个维度分开,暂停、停止、报警才有清晰语义,UI上也才知道该显示哪个按钮。一个常规三轴铣床,七个状态足够:状态含义允许执行的动作IDLE上电未回零仅回零(HOME)和复位(RESET)HOMING正在回机械原点等HOMED反馈,不接任何程序指令READY已回零,机床空闲接受启动(START),可做手动进给RUNNING正在执行G代码处理当前行,可暂停、停止PAUSED程序暂停,进给与主轴停恢复(RESUME)或停止(STOP)STOPPED程序结束或被手动终止只能复位回IDLEALARM报警或急停后的软件态仅复位,复位前不执行任何运动这里有一个容易踩的坑:把暂停和停止当成同一个状态。暂停是RUNNING的可逆子集,恢复后继续执行下一行;停止是终态,剩下的行全部作废。两者在转移表里通向不同的目标状态,如果合并,暂停后想继续会直接变成非法转移。2.2 事件是外部输入和内部异常的统一点状态机只认事件。按键、网络指令、解析错误、插补越界,只要统一转成事件,转移表就只需要维护一个维度。常用八个事件:事件来源典型触发场景HOME外部命令执行G28回零或面板按回零HOMED内部反馈三轴到位信号全部有效START外部命令面板启动键或上位机下发PAUSE外部命令暂停键或断刀检测信号RESUME外部命令恢复键,继续执行下一行STOP外部命令或程序停止键或M30程序结束FAULT内部异常解析失败、驱动报警、软限位RESET外部命令清除报警,回到IDLE急停没有单列一个状态,它归到FAULT加RESET这条链路里。机械急停需要独立硬接线逻辑,软件状态机里给一个FAULT事件就够,否则状态矩阵会指数级膨胀,维护成本立刻上来。2.3 表驱动状态机:把一坨if-else压成一张字典用枚举定义状态和事件,字典做转移表,是Python里最自然的表驱动状态机写法,也是从单片机裸机状态机一直延伸到上位机的同一套思路:from enum import Enum, auto class CncState(Enum): IDLE auto() HOMING auto() READY auto() RUNNING auto() PAUSED auto() STOPPED auto() ALARM auto() class CncEvent(Enum): HOME auto() HOMED auto() START auto() PAUSE auto() RESUME auto() STOP auto() FAULT auto() RESET auto() TRANSITIONS { (CncState.IDLE, CncEvent.HOME): CncState.HOMING, (CncState.HOMING, CncEvent.HOMED): CncState.READY, (CncState.READY, CncEvent.START): CncState.RUNNING, (CncState.RUNNING, CncEvent.PAUSE): CncState.PAUSED, (CncState.PAUSED, CncEvent.RESUME): CncState.RUNNING, (CncState.RUNNING, CncEvent.STOP): CncState.STOPPED, (CncState.PAUSED, CncEvent.STOP): CncState.STOPPED, (CncState.RUNNING, CncEvent.FAULT): CncState.ALARM, (CncState.ALARM, CncEvent.RESET): CncState.IDLE, }键值对里缺哪个键,哪个转移就是非法的。比如STOPPED状态再来START,字典查不到,直接报错,不用靠一堆else兜底。表驱动状态机最大的收益是可测试:遍历这张表,每个键就是一个测试用例,状态机行为的覆盖率是100%,if-else堆出来的判断链做不到这一点。3. 用Python写一个最小可跑的CNC状态机骨架3.1 核心类只做三件事:保存状态、查表、分发这个类的职责边界要清楚:它不管G代码怎么解析,不管坐标怎么算,只负责当前状态是什么、收到事件后去哪里、要不要通知外部。通知机制用回调注册,模拟器可以在特定转移发生时插入动作:方法 / 属性作用典型挂接点send(event, ctx)提交事件,查表并切换状态按键回调、解析器异常分支on(state, event, handler)注册转移前的动作进READY前坐标清零,进ALARM前记录错误码state只读当前状态UI显示、主循环条件判断from typing import Callable, Dict class CncStateMachine: def __init__(self): self._state CncState.IDLE self._handlers: Dict[CncState, Dict[CncEvent, Callable]] {} property def state(self): return self._state def on(self, state: CncState, event: CncEvent, handler: Callable): # 注册转移动作,目标状态生效前执行 self._handlers.setdefault(state, {})[event] handler def send(self, event: CncEvent, ctxNone): key (self._state, event) if key not in TRANSITIONS: raise ValueError( fillegal transition: {self._state.name} {event.name}) handler self._handlers.get(self._state, {}).get(event) if handler is not None: handler(ctx) self._state TRANSITIONS[key]on()注册的回调在状态切换之前执行,这样可以在进入READY前做一次坐标系清零,在进入ALARM前把错误码写进日志。send()里的大部分逻辑就是一次字典查找,量级可以忽略,频繁调用不会成为模拟器的瓶颈。3.2 非法转移直接抛异常,而不是静默忽略send()里的ValueError是这个类最重要的设计决策。不少第一次写状态机的人倾向收到不认识的转移就忽略掉,这在CNC场景里是危险的:START事件在STOPPED状态被忽略,操作员看到的是没有任何反馈,以为程序已经启动。显式抛异常配合上层try/except,至少能留下日志和错误码,排查时一眼定位。把状态机接到模拟执行循环里,逐行先查状态再执行:sm CncStateMachine() sm.send(CncEvent.HOME) # IDLE - HOMING sm.send(CncEvent.HOMED) # HOMING - READY for line_no, line in enumerate(gcode_lines, 1): if sm.state CncState.STOPPED: print(faborted at line {line_no}) break if sm.state CncState.READY: sm.send(CncEvent.START) # 首行前进入RUNNING try: simulate_line(line) # 逐行模拟,见第4章 except GCodeError as exc: sm.send(CncEvent.FAULT) # RUNNING - ALARM raise sm.send(CncEvent.STOP)循环里先看状态、再决定是否继续是整台模拟器的核心节奏。解析异常用FAULT事件进ALARM,程序立即终止,不会带着错误坐标继续往下算。enumerate从1开始编号,报错信息里的行号直接对应文件里的真实行号,省去一行换算。3.3 把暂停恢复做成生成器,模拟器才能边跑边响应真实机床的暂停发生在两条指令之间,不是指令执行到一半。用Python模拟时,把执行循环改造成生成器,每处理一行向外yield一次,外部就能在这一刻插入PAUSE、RESUME、STOP:def run_program(lines): sm CncStateMachine() sm.send(CncEvent.HOME) sm.send(CncEvent.HOMED) sm.send(CncEvent.START) for line in lines: if sm.state CncState.ALARM: return if sm.state CncState.PAUSED: yield paused # 等待外部RESUME continue if sm.state CncState.STOPPED: return # 外部已停止,剩余行作废 yield simulate_line(line) sm.send(CncEvent.STOP) yield finished外部调用方用next()拉取,在任意两行之间设置断点、调整进给倍率、或者注入一个FAULT事件测试报警逻辑。这个模式比线程加锁简单得多,单线程内就完成了边跑边响应,也和屏幕UI的刷新循环天然配合。4. G代码解析与模态命令的状态化处理4.1 用一套正则把一行文本变成指令序列G代码一行可以同时出现多个同字母字,比如G00 G91 X10 Y20,所以解析结果用(letter, value)对的列表保存顺序,而不是字典。一个能处理大多数程序文件的解析函数:import re WORD_RE re.compile(r([A-Z])(-?\d*\.?\d*)) def parse_gcode_line(line: str): # 先剥掉注释: 圆括号注释和分号注释 raw line.split(()[0].split(;)[0].strip() if not raw: return [] items [] for m in WORD_RE.finditer(raw.upper()): letter m.group(1) num m.group(2) if num : items.append((letter, None)) # 有地址无数值,沿用当前模态 elif letter in GNMTS: items.append((letter, int(float(num)))) # G/M/T是整型模态码 else: items.append((letter, float(num))) # XYZIJKRF是浮点数值 return itemsint(float(num))先转float再转int,是因为G01.这种写法在部分后处理器里会出现,直接int()会抛ValueError。坐标轴字母和G/M分开处理,因为类型不同,G/M是模态码,XYZ是数值,混在一起强制转换会埋下隐性bug。4.2 模态命令本身就是一组状态,处理它们就是在做状态转移G代码最容易被新手忽略的特征是模态:一组运动指令里,G00/G01/G02/G03同属一个模态组,写了G02之后,后续行不加G字也默认走圆弧。这就是典型的状态保持,按状态机的思路分层:模态组可取值默认影响范围组1 运动G00 G01 G02 G03G01后续运动指令的插补方式组2 平面G17 G18 G19G17圆弧插补所在平面组3 坐标G90 G91G90坐标是绝对还是增量组4 单位G20 G21G21英寸/毫米,影响所有坐标数值组7 主轴M03 M04 M05M05主轴正转/反转/停止模态处理在解释器里就是对组内值的覆盖,上一行写的模态码要一直保持到被同组新值替换:class GCodeInterpreter: def __init__(self): self.motion G01 # 模态组1 self.plane G17 # 模态组2 self.abs_mode True # G90 self.units 1.0 # G21为1.0, G20为25.4 self.feed 0.0 self.pos [0.0, 0.0, 0.0] def apply_g(self, g: int): if g in (0, 1, 2, 3): self.motion fG0{g} elif g in (17, 18, 19): self.plane fG{g} elif g 20: self.units 25.4 elif g 21: self.units 1.0 elif g 90: self.abs_mode True elif g 91: self.abs_mode False def target(self, items): # 绝对/增量换算, 增量基于本行已移动后的坐标 t list(self.pos) for letter, val in items: if letter in XYZ: idx XYZ.index(letter) t[idx] val if self.abs_mode else t[idx] val return ttarget()里增量模式用的是t[idx]而不是self.pos[idx],因为同一行G91 X10 Y20的Y增量,要基于X已经移动后的位置计算。这个细节写错的人很多,现象是斜线变成阶梯线。注意:模态值的持久化只属于解释器,不要把模态值塞进状态机的CncState里。状态机管安全,解释器管语义,两者混在一起,转移表很快就会失控。4.3 直线和圆弧插补的模拟,参数化到可以调精度轨迹模拟的核心参数是弦高误差(chord error),不是固定步数。for i in range(100)的写法在零件尺寸变化时,要么顶点冗余要么轮廓不够平滑。按弦高误差反推步数,是数控系统里实际使用的做法:import math def line_to(self, target, tol0.01): if self.check_only: self.pos list(target) return start list(self.pos) dist math.dist(start, target) steps max(1, int(dist / tol)) for i in range(1, steps 1): t i / steps self.traj.append([a (b - a) * t for a, b in zip(start, target)]) self.pos list(target) def arc_to(self, target, center, clockwise, tol0.01): sx, sy self.pos[0], self.pos[1] cx, cy center[0], center[1] r math.hypot(sx - cx, sy - cy) if r tol: self.line_to(target, tol) # 半径趋近零, 退化成直线 return ang0 math.atan2(sy - cy, sx - cx) ang1 math.atan2(target[1] - cy, target[0] - cx) sweep ang1 - ang0 while sweep math.pi: sweep - 2 * math.pi while sweep -math.pi: sweep 2 * math.pi if clockwise and sweep 0: sweep - 2 * math.pi if not clockwise and sweep 0: sweep 2 * math.pi step_ang 2 * math.acos( min(1.0, max(-1.0, 1.0 - tol / r))) steps max(1, int(abs(sweep) / step_ang)) for i in range(1, steps 1): ang ang0 sweep * i / steps self.traj.append([cx r * math.cos(ang), cy r * math.sin(ang), self.pos[2]]) self.pos list(target)弦高误差固定为tol时,小圆弧自动少采样,大圆弧自动加密,同一份代码处理不同尺寸的零件都合理。atan2返回[-pi, pi],跨零点的圆弧(比如从170°转到-170°)如果不做while校正,sweep会差出接近一整圈,圆心角算错,整个圆弧形状都错。min/max夹紧是为了防止浮点精度把1 - tol/r推到(-1, 1)之外让acos返回nan。4.4 常见的解析和状态错误,以及一眼看穿它们的日志模拟器跑起来之后,最先遇到的不是插补bug,而是G代码本身的脏数据。见得多的几类:错误类型典型现象处理方式注释未过滤圆括号里的刀号被当成指令解析前先切分(和;G00带F值快速移动的速度写进进给G00下忽略F,保留原模态值圆弧半径为0圆心和起点重合r tol时按直线处理负半径R部分控制器允许,多数不允许按控制器规范决定报错或取绝对值半径接近零的处理在arc_to里已经体现:退化直线而不是抛异常。这更贴近实际数控系统的行为——刀尖轨迹在极小圆弧处本来就近似直线,直接报错会把一批后处理器生成的正常程序误判。5. 用状态机反过来校验G代码,以及两个调参技巧5.1 把转移表直接写成自动化测试转移表本身就是测试用例列表。遍历所有键值对逐个断言,几分钟写完整个状态图的回归测试:def test_all_transitions(): sm CncStateMachine() for (state, event), target in TRANSITIONS.items(): sm._state state # 注入状态,只测转移逻辑 sm.send(event) assert sm.state target def test_illegal_transition(): import pytest sm CncStateMachine() sm._state CncState.STOPPED with pytest.raises(ValueError): sm.send(CncEvent.START)直接注入状态、发事件、断言结果,不需要mock任何硬件。新增事件时把新键同时补进表里和测试里,行为变更立刻可见。5.2 加一个check_only开关,把解释器变成G代码体检工具模拟和校验共用解析与执行逻辑,区别只在是否写轨迹。check_onlyTrue时运动指令只更新坐标,不append轨迹点,几千行的程序也能在几十毫秒内完成全量校验:def execute(self, items, check_only: bool): self.check_only check_only # 运动指令内部用self.check_only判断是否跳过traj.appendCAM出的程序上线前先体检一遍,哪一行非法转移、哪一行圆弧越界,一次全部暴露。5.3 两个直接影响性能和内存的参数参数默认值调大/调小的影响tol 弦高误差0.01 mm调大顶点少、内存省,曲面轮廓变糙check_onlyFalse调True跳过轨迹记录,校验快几个数量级正则的预编译也在性能清单里:WORD_RE放在模块级,避免每行重复compile。轨迹点用list嵌套list即可,如果顶点量到了百万级,换array(f)按XYZ连续存放,再配合numpy的reshape直接喂可视化,省掉一次数据拷贝。给run_program生成器加一个返回(line_no, state)的yield,配合check_only,就是一套不需要任何界面框架的G代码预检管道,状态机本身不必为此多开一个事件。本文还有配套的精品资源点击获取
返回列表