mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-28 09:11:11 -07:00
Stream and motion robustness: harness rules, catalog tests, the hand-back's counter scale
The GRBL driver's stream engine and its motion envelope change (grblHAL-glowforge: the shipper writes outside the lock, a clamp inside an armed window faults, the X/Y soft limits follow the home, the machine's settings are pinned, the homing keys are clamped). This commit carries the host rules and the catalog tests that hold them; the driver commit follows, because its CI fetches these harnesses unpinned. scripts/bench/laser_stream_test.py: - Rule 28: a 300 ms producer stall while armed faults the stream with ALARM:17, the kernel sees no step burst (at most the planned steps per 100 ticks), the stream ends dark, the latch sideband ends on the lock. The same stall unarmed is a warning: the move completes with every step, the clamp visible as the burst the kernel counts. - Rule 29: a 300 ms stall of the sink's write leaves the producer on pace: no clamp, every step, lit through, dark at the end. - The stand-in engine takes GFSINK_STALL_MS and GFSINK_WRITE_STALL_MS, null-sink only. scripts/bench/z_envelope_test.py: - Rule 10: homed (a gfcloud home), a program move past X max, Y max or the near edge alarms with ALARM:2 before any motion, a jog past the bed is refused with error 15, a move inside the bed runs, and a $20 write keeps the limits. The core repeats the last error for the line after a refused jog until an empty line clears it, so the rule sends one. forgetest/forgetest/suite/motion.py: - motion.soft-limits (kind auto, no emission): homes through the cloud suite's gfhome homing when the machine is not homed, then the three refusals (ALARM:2, the kernel counters still), the refused jog, the inside move, and the return to the corner read at rest. - motion.deadman phase 2b: a 100 ms SIGSTOP mid-move, inside the kernel's queue: no underrun, the controller's log warns of the clamped late events, the move completes with every step (read at rest), the latch stays locked. The armed clamp is proven on the host (rule 28). forgetest/forgetest/suite/cloud.py: - gfhome_homing drains the driver's answer to $H once the session ends: it sits behind the status reports and passed for the reply to the caller's next command (a setting read as None). forgetest/forgetest/baseline.py: - The hand-back reads the position counters at the kernel's own microstep mode (cnc/x_mode, read before the sysfs restore puts the settings' mode back). At the x8 constant, an x32 machine's 30 mm read as 120 mm, beyond the return bound, and the displaced head was left in place. The dead band scales the same way. tests/test_baseline.py holds both. Proof. Host: stream rules 1 to 29 and z_envelope rules 1 to 10 against the null-sink driver, the forgetest unit tests. Bench reference: motion.soft-limits passed (X 495 and Y 279 refused with ALARM:2 and 0.000 mm of kernel motion, X-1 the same, the jog error:15, the inside move ran, back at the corner 0.0/0.0 mm); motion.deadman passed with the new phase (92 late events clamped, max behind 92.3 ms, no underrun, 30.0 mm counted, latch locked) and the hand-back jogged the head back under the x32 scale.
This commit is contained in:
@@ -217,17 +217,25 @@ def read_position():
|
||||
return None
|
||||
|
||||
|
||||
def position_quantized(was, now):
|
||||
def counter_steps_per_mm():
|
||||
"""The scale of the X/Y position counters: the kernel counts the
|
||||
microsteps of the mode it runs (cnc/x_mode), so x16 and x32 count two
|
||||
and four times the x8 steps for the same millimeter. The x8 scale when
|
||||
the attribute is unreadable."""
|
||||
return XY_STEPS_PER_MM_OF.get(hw.sysfs_int("cnc/x_mode"), XY_STEPS_PER_MM)
|
||||
|
||||
|
||||
def position_quantized(was, now, spm=XY_STEPS_PER_MM):
|
||||
"""True when two step-counter readings differ by no more than the
|
||||
dead band on X and Y and not at all on Z: the step quantization of a
|
||||
move that landed where it meant to, rather than a leftover. A cancel
|
||||
that returns the head to the job start lands within a few hundredths
|
||||
of a millimeter, which is a step or four, and whether that rounds to
|
||||
the same integer is chance."""
|
||||
the same integer is chance. spm is the counters' scale (the mode's)."""
|
||||
if was is None or now is None or now[2] != was[2]:
|
||||
return False
|
||||
return (abs(now[0] - was[0]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM and
|
||||
abs(now[1] - was[1]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM)
|
||||
return (abs(now[0] - was[0]) / spm <= POSITION_DEADBAND_MM and
|
||||
abs(now[1] - was[1]) / spm <= POSITION_DEADBAND_MM)
|
||||
|
||||
|
||||
def read_ring_residue():
|
||||
@@ -450,6 +458,9 @@ class Baseline:
|
||||
preserved state to hand back (post) - None compares nothing."""
|
||||
left = []
|
||||
self.mode = None
|
||||
# the counters' scale is the mode the run counted under, read
|
||||
# before the kernel side puts the settings' mode back
|
||||
self._counter_spm = counter_steps_per_mm()
|
||||
self._forgectrl_side(left, captured)
|
||||
self._kernel_side(left)
|
||||
self._lamp_side(left)
|
||||
@@ -693,13 +704,14 @@ class Baseline:
|
||||
self.log("leds/%s: target 0, brightness %s; the fade from a level the machine "
|
||||
"itself ended, not a leftover" % (name, read_led(name, "brightness")))
|
||||
|
||||
def _return_head(self, was, now):
|
||||
def _return_head(self, was, now, spm=XY_STEPS_PER_MM):
|
||||
"""Jog the head back along its own path by the kernel-measured X/Y
|
||||
delta (Z is never touched), through the GRBL controller. Bounded:
|
||||
beyond RETURN_MAX_MM per axis, or without a running GRBL
|
||||
controller, the counters are reported and left."""
|
||||
dx = (now[0] - was[0]) / XY_STEPS_PER_MM
|
||||
dy = (now[1] - was[1]) / XY_STEPS_PER_MM
|
||||
delta (Z is never touched), through the GRBL controller. spm is
|
||||
the counters' scale (the mode's). Bounded: beyond RETURN_MAX_MM
|
||||
per axis, or without a running GRBL controller, the counters are
|
||||
reported and left."""
|
||||
dx = (now[0] - was[0]) / spm
|
||||
dy = (now[1] - was[1]) / spm
|
||||
if abs(dx) < 0.02 and abs(dy) < 0.02:
|
||||
return "unrestorable (Z only)" if now[2] != was[2] else "restored"
|
||||
if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM:
|
||||
@@ -787,8 +799,9 @@ class Baseline:
|
||||
was = captured.get("position")
|
||||
now = read_position()
|
||||
if was is not None and now is not None and now != was and not self.cloud_mode():
|
||||
act = self._return_head(was, now)
|
||||
if position_quantized(was, now):
|
||||
spm = self._counter_spm
|
||||
act = self._return_head(was, now, spm)
|
||||
if position_quantized(was, now, spm):
|
||||
self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, "
|
||||
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
|
||||
else:
|
||||
|
||||
@@ -227,6 +227,11 @@ def gfhome_homing(ctx, ev, g):
|
||||
# homed: the counters are re-anchored at the corner, where the head stays
|
||||
ctx.counters_rezeroed()
|
||||
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing")
|
||||
# The driver answers $H when the session ends; that ok (and the
|
||||
# session's messages) sit in the buffer behind the status reports and
|
||||
# would pass for the reply to the caller's next command.
|
||||
ack = " | ".join(ln.strip() for ln in g.drain().splitlines() if ln.strip())
|
||||
ctx.log("$H acknowledged: %s", ack[:160] or "(nothing buffered)")
|
||||
|
||||
|
||||
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt "
|
||||
|
||||
@@ -764,10 +764,13 @@ def _return_x(ctx, delta_mm):
|
||||
"locked); the process resumed from the hang recovers on a soft reset and an unlock "
|
||||
"(the alarm is critical, so $X alone is refused; the reset acknowledges the "
|
||||
"fault to the stream and clears the stale ring) and moves again without a "
|
||||
"restart. forgectrl "
|
||||
"restart mid-move: the busy controller finishes the move unmanaged and the new "
|
||||
"daemon retakes supervision at idle. After each drill the head is jogged back "
|
||||
"by the kernel-measured distance.")
|
||||
"restart. A short SIGSTOP (100 ms, inside the kernel's 200 ms queue) mid-move: "
|
||||
"no underrun, but the step producer comes back later than its lead and its "
|
||||
"late events clamp; unarmed that is a warning in the controller's log and the "
|
||||
"move completes with every step (the armed case faults, proven on the host). "
|
||||
"forgectrl restart mid-move: the busy controller finishes the move unmanaged "
|
||||
"and the new daemon retakes supervision at idle. After each drill the head is "
|
||||
"jogged back by the kernel-measured distance.")
|
||||
def deadman(ctx):
|
||||
import os as _os
|
||||
import signal as _signal
|
||||
@@ -897,6 +900,47 @@ def deadman(ctx):
|
||||
x1 = _kernel_x_mm(ctx)
|
||||
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||
|
||||
# ---- 2b. a short hang (inside the kernel queue): late events clamp, a warning, the move completes
|
||||
m2 = wait_running(10)
|
||||
pid2 = m2["pid"]
|
||||
x0 = _kernel_x_mm(ctx)
|
||||
underruns0 = hw.sysfs_int("cnc/underruns", 0)
|
||||
log0 = _log_offset(GRBLHAL_LOG)
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
g.command("G91")
|
||||
g.command("G1X30F300", timeout=0.5) # ~6 s of motion
|
||||
ctx.sleep(1.0)
|
||||
_os.kill(pid2, _signal.SIGSTOP)
|
||||
time.sleep(0.1)
|
||||
_os.kill(pid2, _signal.SIGCONT)
|
||||
ctx.log("SIGSTOP 100 ms sent to controller pid %d mid-move", pid2)
|
||||
peak, states, st = wait_idle(ctx, g, 30)
|
||||
g.command("G90")
|
||||
machine_idle(ctx) # the kernel's tail plays out behind grbl's Idle
|
||||
warned = None
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 5 and not warned:
|
||||
lines = _log_lines(GRBLHAL_LOG, log0, "late events clamped")
|
||||
warned = lines[-1] if lines else None
|
||||
if not warned:
|
||||
ctx.sleep(0.5)
|
||||
x1 = _kernel_x_mm(ctx)
|
||||
ev["short_stall"] = {"states": states, "underruns": hw.sysfs_int("cnc/underruns", 0) - underruns0,
|
||||
"warning": warned, "kernel_dx_mm": round((x1 - x0), 3) if (x0 is not None and x1 is not None) else None,
|
||||
"state_after": (st or {}).get("state")}
|
||||
ctx.log("short stall: states %s, underruns +%s, warning %r, kernel dx %s mm", states,
|
||||
ev["short_stall"]["underruns"], warned, ev["short_stall"]["kernel_dx_mm"])
|
||||
ctx.check("TIMEOUT" not in states and str((st or {}).get("state", "")).startswith("Idle"),
|
||||
"the move did not complete after the short stall (states %s)", states)
|
||||
ctx.check(ev["short_stall"]["underruns"] == 0, "the short stall drained the ring (underruns +%s)",
|
||||
ev["short_stall"]["underruns"])
|
||||
ctx.check(warned, "the controller did not warn about the clamped events after the short stall")
|
||||
ctx.check(ev["short_stall"]["kernel_dx_mm"] is not None and abs(ev["short_stall"]["kernel_dx_mm"] - 30.0) < 0.3,
|
||||
"the kernel counted %s mm for a 30 mm move after the short stall", ev["short_stall"]["kernel_dx_mm"])
|
||||
ctx.check(latch_locked(), "latch unlocked after the short stall")
|
||||
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||
|
||||
# ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle
|
||||
m2 = wait_running(10)
|
||||
pid2 = m2["pid"]
|
||||
@@ -934,8 +978,79 @@ def deadman(ctx):
|
||||
x1 = _kernel_x_mm(ctx)
|
||||
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
|
||||
machine_idle(ctx)
|
||||
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, restart retook supervision (pid %s)",
|
||||
respawn_s, halt_s, m3.get("pid"))
|
||||
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, short stall warned and completed, "
|
||||
"restart retook supervision (pid %s)", respawn_s, halt_s, m3.get("pid"))
|
||||
|
||||
|
||||
@test("motion.soft-limits", title="After a home the bed is the X/Y envelope",
|
||||
subsystem="motion", kind="auto", mode="grbl", est_min=3,
|
||||
covers=_MOTION_COVERS, requires=["motion.jog-roundtrip", "cloud.mode-switch"],
|
||||
steps=["Bed clear, lid closed. The test homes the machine through the web service if it is "
|
||||
"not homed (about a minute), then sends moves the controller must refuse."],
|
||||
description="The machine has no limit switches, so the core's $20 cannot be turned on and "
|
||||
"the X/Y soft limits are the driver's: off while the position is not trusted, on "
|
||||
"after a home, when the envelope is the bed ($130 by $131 from the home corner). "
|
||||
"Homed, a program move 5 mm past X max or Y max, or past the near edge, raises "
|
||||
"ALARM:2 before any motion (the kernel counters do not move), a jog past the bed "
|
||||
"is refused with error 15, and a move inside the bed runs. The Z envelope is the "
|
||||
"lens window, as before.")
|
||||
def soft_limits(ctx):
|
||||
from .cloud import gfhome_homing
|
||||
fc = ctx.forgectrl
|
||||
ev = ctx.evidence
|
||||
with ctx.grbl() as g:
|
||||
clean_slate(ctx, g)
|
||||
if not fc.status().get("homed"):
|
||||
gfhome_homing(ctx, ev, g)
|
||||
ctx.check(fc.status().get("homed"), "the machine is not homed")
|
||||
x_travel = float(grbl_setting(g, "$130"))
|
||||
y_travel = float(grbl_setting(g, "$131"))
|
||||
ev["travel"] = {"x": x_travel, "y": y_travel}
|
||||
k0 = kernel_xy_mm(ctx)
|
||||
|
||||
def refused(cmd):
|
||||
lines = g.command(cmd, timeout=2)
|
||||
ctx.sleep(0.5)
|
||||
text = "\n".join(lines) + g.drain()
|
||||
k1 = kernel_xy_mm(ctx)
|
||||
moved = max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1]))
|
||||
st = g.status_report()["state"]
|
||||
rec = {"cmd": cmd, "reply": lines, "alarm": "ALARM:2" in text, "moved_mm": round(moved, 3), "state": st}
|
||||
ctx.log("%s", rec)
|
||||
ctx.check(rec["alarm"], "%s was not refused with ALARM:2: %s", cmd, lines)
|
||||
ctx.check(moved < 0.05, "%s moved the kernel %.3f mm before the alarm", cmd, moved)
|
||||
g.realtime(0x18)
|
||||
ctx.sleep(1.5)
|
||||
g.drain()
|
||||
unlock = g.command("$X")
|
||||
ctx.check(unlock and unlock[-1] == "ok", "$X after the soft-limit alarm: %s", unlock)
|
||||
st = g.status_report()["state"]
|
||||
ctx.check(st.startswith("Idle"), "controller is %s after the recovery", st)
|
||||
return rec
|
||||
|
||||
ev["refused"] = [refused("G90 G1 X%.1f F600" % (x_travel + 5)),
|
||||
refused("G90 G1 Y%.1f F600" % (y_travel + 5)),
|
||||
refused("G90 G1 X-1 F600")]
|
||||
# Homed still: the soft-limit alarm and its reset keep the reference.
|
||||
ctx.check(fc.status().get("homed"), "the soft-limit alarm and the reset un-homed the machine")
|
||||
jog = g.command("$J=G91X%.1fF1200" % (x_travel + 5))
|
||||
ev["jog"] = jog
|
||||
ctx.check(any(l.startswith("error:15") for l in jog), "a jog past the bed was not refused with error 15: %s", jog)
|
||||
# The core answers the line after an error with that error again
|
||||
# until an empty line clears it (the sender's acknowledgment).
|
||||
g.command("")
|
||||
g.command("G90 G1 X10 Y10 F600", timeout=0.5)
|
||||
peak, states, st = wait_idle(ctx, g, 20)
|
||||
ev["inside"] = states
|
||||
ctx.check("TIMEOUT" not in states, "a move inside the bed did not run")
|
||||
g.command("G90 G1 X0 Y0 F600", timeout=0.5)
|
||||
wait_idle(ctx, g, 20)
|
||||
k1 = kernel_start(ctx) # at rest: grbl's Idle leads the kernel's tail
|
||||
ev["kernel_back_mm"] = [round(k1[0] - k0[0], 3), round(k1[1] - k0[1], 3)]
|
||||
ctx.check(max(abs(k1[0] - k0[0]), abs(k1[1] - k0[1])) < 0.1, "the head did not come back to the corner: %s",
|
||||
ev["kernel_back_mm"])
|
||||
ctx.log("PASS: X max, Y max and X min refused with ALARM:2 and no motion, the jog refused with "
|
||||
"error 15, a move inside the bed ran")
|
||||
|
||||
|
||||
# ------------------------------------------------- lid / button (the factory's)
|
||||
|
||||
Reference in New Issue
Block a user