Files
forgefirm/scripts/bench/arc_tolerance_sweep.py
T
2026-09-18 12:14:22 -04:00

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())