mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
242 lines
9.8 KiB
Python
242 lines
9.8 KiB
Python
#!/usr/bin/env python3
|
|
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
# Written by Scott Wiederhold
|
|
# https://community.openglow.org
|
|
# SPDX-License-Identifier: MIT
|
|
"""How fine an arc the one core can plan: a $12 ladder on the 9 in circle (on the board).
|
|
|
|
Usage: arc_tolerance_sweep.py [--mode M] [tolerances...]
|
|
(default: --mode 16, the ladder 0.002 0.001 0.0005 0.00025 0.0001)
|
|
|
|
grblHAL traces an arc as chords whose sagitta is $12, so the chord length
|
|
is about 2 sqrt(2 r $12) and every chord is a planner block: on the 9 in
|
|
circle at F12000 the default 0.002 mm gives 1.35 mm chords and 148 block
|
|
boundaries a second, which is the tone the circle plays at every
|
|
microstep mode. A finer $12 moves the tone up and asks the single core
|
|
for more blocks a second. This drill finds where that stops holding:
|
|
with the machine silent (the engine's quiet hold, the fixed 10 s wait)
|
|
and the head accelerometer listening, it moves from home to (9, 9) in,
|
|
then for each $12 in the ladder runs the 9 in circle from its mid-bottom
|
|
at F12000 and reports the chords, the boundaries a second, the circle
|
|
time against the ideal, the lowest feed in the middle of the circle and
|
|
the fewest free planner blocks (a starved planner shows as both), the
|
|
controller CPU over the circle, any clamped-event line the driver logged,
|
|
cnc/underruns, and the accelerometer's cruise RMS. $12 goes back to what
|
|
it was, on every exit path, and the head returns home.
|
|
|
|
Needs the controller in GRBL mode, homed and standing at home, the lid
|
|
closed, no other Grbl client, and the bed clear across the circle.
|
|
"""
|
|
import json
|
|
import math
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
from gfbench import data_path
|
|
from xy_pattern_accel import (Accel, Sampler, Grbl, fail, preflight, quiet, set_mode, stats,
|
|
sysfs, window, FEED, IN, QUIET_S, TRIM_S)
|
|
|
|
LOG = "/data/log/forgefirm/grblhal/grblhal.log"
|
|
RADIUS = 4.5 * IN
|
|
CIRCLE = "G2 X0 Y0 I0 J%.3f F%d" % (-RADIUS, FEED)
|
|
DEFAULT_LADDER = [0.002, 0.001, 0.0005, 0.00025, 0.0001]
|
|
|
|
|
|
def parse_args(argv):
|
|
mode = 16
|
|
tols = []
|
|
it = iter(argv)
|
|
for a in it:
|
|
if a == "--mode":
|
|
mode = int(next(it))
|
|
else:
|
|
tols += [float(x) for x in a.split()]
|
|
return mode, tols or DEFAULT_LADDER
|
|
|
|
|
|
def controller_pid():
|
|
out = subprocess.run(["pidof", "grblHAL_glowforge"], capture_output=True, text=True).stdout.split()
|
|
return int(out[0]) if out else None
|
|
|
|
|
|
def cpu_ticks(pid):
|
|
with open("/proc/%d/stat" % pid) as f:
|
|
s = f.read().split()
|
|
return int(s[13]) + int(s[14])
|
|
|
|
|
|
def grbl_setting(g, key):
|
|
g.s.sendall((key + "\n").encode())
|
|
g.s.settimeout(1.0)
|
|
buf = ""
|
|
end = time.time() + 1.5
|
|
while time.time() < end and "ok" not in buf:
|
|
try:
|
|
buf += g.s.recv(4096).decode(errors="replace")
|
|
except OSError:
|
|
break
|
|
m = re.search(r"^%s=(\S+)" % re.escape(key), buf, re.M)
|
|
return m.group(1) if m else None
|
|
|
|
|
|
def chords(tol):
|
|
seg = math.floor(math.pi * RADIUS / math.sqrt(tol * (2 * RADIUS - tol)))
|
|
return seg, 2 * math.pi * RADIUS / seg
|
|
|
|
|
|
def run_circle(g, pid):
|
|
"""The circle, timed by the state transitions and watched at about
|
|
40 Hz for the feed and the planner's free blocks."""
|
|
k0 = int(sysfs("cnc/underruns"))
|
|
log_off = os.path.getsize(LOG) if os.path.exists(LOG) else 0
|
|
c0 = cpu_ticks(pid)
|
|
g.s.sendall((CIRCLE + "\n").encode())
|
|
t_run = t_idle = None
|
|
feeds, bfs = [], []
|
|
end = time.time() + 120
|
|
while time.time() < end:
|
|
g.s.sendall(b"?")
|
|
g.s.settimeout(1.0)
|
|
buf = ""
|
|
t_end = time.time() + 1.0
|
|
while time.time() < t_end and ">" not in buf:
|
|
try:
|
|
buf += g.s.recv(4096).decode(errors="replace")
|
|
except OSError:
|
|
break
|
|
if "error:" in buf or "ALARM" in buf:
|
|
fail("the controller answered %r" % buf.strip()[-200:])
|
|
m = re.search(r"<(\w+)[^>]*", buf)
|
|
st = m.group(1) if m else None
|
|
now = time.monotonic()
|
|
f = re.search(r"FS:([\d.]+),", buf)
|
|
b = re.search(r"Bf:(\d+),", buf)
|
|
if st and st.startswith("Run"):
|
|
if t_run is None:
|
|
t_run = now
|
|
if f:
|
|
feeds.append((now, float(f.group(1))))
|
|
if b:
|
|
bfs.append((now, int(b.group(1))))
|
|
elif st and st.startswith("Idle") and t_run is not None:
|
|
t_idle = now
|
|
break
|
|
elif st and st.startswith("Alarm"):
|
|
fail("alarm during the circle: %s" % st)
|
|
time.sleep(0.02)
|
|
if t_idle is None:
|
|
fail("the circle did not finish")
|
|
cpu = 100.0 * (cpu_ticks(pid) - c0) / (os.sysconf("SC_CLK_TCK") * (t_idle - t_run))
|
|
g.drain()
|
|
clamped = []
|
|
if os.path.exists(LOG):
|
|
with open(LOG, errors="replace") as fh:
|
|
fh.seek(log_off)
|
|
clamped = [l.strip() for l in fh if "late events clamped" in l or "underrun" in l]
|
|
# the middle of the circle: the ramps at each end left out
|
|
mid = [(t, v) for t, v in feeds if t_run + TRIM_S <= t <= t_idle - TRIM_S]
|
|
mid_bf = [v for t, v in bfs if t_run + TRIM_S <= t <= t_idle - TRIM_S]
|
|
return {"t_run": t_run, "t_idle": t_idle, "seconds": round(t_idle - t_run, 2), "cpu_pct": round(cpu, 1),
|
|
"peak_feed": max((v for _, v in feeds), default=0), "min_mid_feed": min((v for _, v in mid), default=0),
|
|
"min_free_blocks": min(mid_bf, default=None), "max_free_blocks": max(mid_bf, default=None),
|
|
"status_samples": len(feeds), "clamped": clamped, "underruns": [k0, int(sysfs("cnc/underruns"))]}
|
|
|
|
|
|
def main():
|
|
mode, ladder = parse_args(sys.argv[1:])
|
|
home = preflight()
|
|
set_mode(mode)
|
|
pid = controller_pid()
|
|
g = Grbl()
|
|
accel = sampler = None
|
|
orig = None
|
|
rows = []
|
|
stamp = time.strftime("%Y%m%d%H%M%S")
|
|
try:
|
|
st, pos, _ = g.status()
|
|
if not st or not st.startswith("Idle"):
|
|
fail("controller is %s, not Idle" % st)
|
|
if abs(pos[0] - home[0]) > 0.05 or abs(pos[1] - home[1]) > 0.05:
|
|
fail("the head is not at home (MPos %s): run $H first" % (pos,))
|
|
orig = grbl_setting(g, "$12")
|
|
if orig is None:
|
|
fail("could not read $12")
|
|
print("x%d: step_freq %s; $12 now %s; the ladder %s" % (mode, sysfs("cnc/step_freq"), orig, ladder))
|
|
print("quiet hold: %s" % quiet(True))
|
|
time.sleep(QUIET_S)
|
|
accel = Accel()
|
|
accel.start()
|
|
sampler = Sampler(accel)
|
|
sampler.start()
|
|
g.cmd("G21")
|
|
g.cmd("G91")
|
|
g.run_leg("G1 X%.3f Y%.3f F%d" % (9 * IN, 9 * IN, FEED))
|
|
ideal = 2 * math.pi * RADIUS * 60.0 / FEED
|
|
for tol in ladder:
|
|
# plain decimals only: the controller reads no exponent, so a
|
|
# "5e-05" would land as 5 mm
|
|
r = g.cmd("$12=%.6f" % tol)
|
|
got = grbl_setting(g, "$12")
|
|
if "ok" not in r or got is None:
|
|
fail("$12=%g refused: %r" % (tol, r.strip()))
|
|
# $$ shows three decimals; the stored float keeps what was typed
|
|
# (the core parses the value with strtof and rounds nothing).
|
|
if abs(float(got) - tol) > 0.0006:
|
|
fail("$12=%g reads back as %s" % (tol, got))
|
|
seg, chord = chords(tol)
|
|
time.sleep(1.5)
|
|
res = run_circle(g, pid)
|
|
xs, ys = window(sampler.samples, res["t_run"] + TRIM_S, res["t_idle"] - TRIM_S)
|
|
res.update({"tol": tol, "reported": got, "chords": seg, "chord_mm": round(chord, 3),
|
|
"boundaries_per_s": round(seg / ideal, 0), "accel_x": stats(xs), "accel_y": stats(ys)})
|
|
rows.append(res)
|
|
ok = (res["min_mid_feed"] >= 0.97 * FEED and not res["clamped"] and res["underruns"][0] == res["underruns"][1]
|
|
and res["seconds"] <= ideal + 0.6)
|
|
res["result"] = "PASS" if ok else "FAIL"
|
|
print("$12=%-8g %5d chords of %.3f mm, %4.0f/s | circle %.2f s (ideal %.2f) | mid feed min %5.0f | "
|
|
"planner free min %s max %s | CPU %5.1f %% | clamped %d | underruns %d->%d | accel x rms %7s y rms %7s | %s"
|
|
% (tol, seg, chord, res["boundaries_per_s"], res["seconds"], ideal, res["min_mid_feed"],
|
|
res["min_free_blocks"], res["max_free_blocks"], res["cpu_pct"], len(res["clamped"]),
|
|
res["underruns"][0], res["underruns"][1], res["accel_x"]["rms"], res["accel_y"]["rms"],
|
|
res["result"]))
|
|
for l in res["clamped"][:3]:
|
|
print(" ", l)
|
|
time.sleep(2.0)
|
|
finally:
|
|
try:
|
|
if orig is not None:
|
|
g.cmd("$12=%.6f" % float(orig))
|
|
print("$12 restored to %s" % grbl_setting(g, "$12"))
|
|
except Exception as e:
|
|
print("WARNING: could not restore $12: %s" % e)
|
|
try:
|
|
quiet(False)
|
|
except SystemExit:
|
|
print("WARNING: the quiet hold did not release; the engine drops it itself within 600 s")
|
|
try:
|
|
if sampler is not None:
|
|
sampler.stop()
|
|
finally:
|
|
if accel is not None:
|
|
accel.restore()
|
|
try:
|
|
st, pos, _ = g.status()
|
|
if pos and (abs(pos[0] - home[0]) > 0.05 or abs(pos[1] - home[1]) > 0.05):
|
|
g.run_leg("G1 X%.3f Y%.3f F%d" % (home[0] - pos[0], home[1] - pos[1], FEED))
|
|
print("head back at home: MPos %s" % (g.status()[1],))
|
|
g.cmd("G90")
|
|
finally:
|
|
g.close()
|
|
path = data_path("arc_tolerance_sweep_x%d_%s.json" % (mode, stamp))
|
|
with open(path, "w") as f:
|
|
json.dump({"mode": mode, "feed": FEED, "radius_mm": RADIUS, "rows": rows}, f)
|
|
print("record: %s" % path)
|
|
return 0 if rows and all(r["result"] == "PASS" for r in rows) else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|