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:
ScottW514
2026-09-14 17:28:28 -04:00
parent ea458850f0
commit 2f2a4160af
6 changed files with 333 additions and 20 deletions
+25 -12
View File
@@ -217,17 +217,25 @@ def read_position():
return None 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 """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 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 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 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 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]: if was is None or now is None or now[2] != was[2]:
return False return False
return (abs(now[0] - was[0]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM and return (abs(now[0] - was[0]) / spm <= POSITION_DEADBAND_MM and
abs(now[1] - was[1]) / XY_STEPS_PER_MM <= POSITION_DEADBAND_MM) abs(now[1] - was[1]) / spm <= POSITION_DEADBAND_MM)
def read_ring_residue(): def read_ring_residue():
@@ -450,6 +458,9 @@ class Baseline:
preserved state to hand back (post) - None compares nothing.""" preserved state to hand back (post) - None compares nothing."""
left = [] left = []
self.mode = None 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._forgectrl_side(left, captured)
self._kernel_side(left) self._kernel_side(left)
self._lamp_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 " 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"))) "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 """Jog the head back along its own path by the kernel-measured X/Y
delta (Z is never touched), through the GRBL controller. Bounded: delta (Z is never touched), through the GRBL controller. spm is
beyond RETURN_MAX_MM per axis, or without a running GRBL the counters' scale (the mode's). Bounded: beyond RETURN_MAX_MM
controller, the counters are reported and left.""" per axis, or without a running GRBL controller, the counters are
dx = (now[0] - was[0]) / XY_STEPS_PER_MM reported and left."""
dy = (now[1] - was[1]) / XY_STEPS_PER_MM dx = (now[0] - was[0]) / spm
dy = (now[1] - was[1]) / spm
if abs(dx) < 0.02 and abs(dy) < 0.02: if abs(dx) < 0.02 and abs(dy) < 0.02:
return "unrestorable (Z only)" if now[2] != was[2] else "restored" return "unrestorable (Z only)" if now[2] != was[2] else "restored"
if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM: if abs(dx) > RETURN_MAX_MM or abs(dy) > RETURN_MAX_MM:
@@ -787,8 +799,9 @@ class Baseline:
was = captured.get("position") was = captured.get("position")
now = read_position() now = read_position()
if was is not None and now is not None and now != was and not self.cloud_mode(): if was is not None and now is not None and now != was and not self.cloud_mode():
act = self._return_head(was, now) spm = self._counter_spm
if position_quantized(was, now): 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, " self.log("position: %s (expected %s) -> %s; inside the %.2f mm dead band, "
"not a leftover" % (now, was, act, POSITION_DEADBAND_MM)) "not a leftover" % (now, was, act, POSITION_DEADBAND_MM))
else: else:
+5
View File
@@ -227,6 +227,11 @@ def gfhome_homing(ctx, ev, g):
# homed: the counters are re-anchored at the corner, where the head stays # homed: the counters are re-anchored at the corner, where the head stays
ctx.counters_rezeroed() ctx.counters_rezeroed()
ctx.check(ctx.forgectrl.wait_idle(15, abort=ctx.aborted), "machine not idle after homing") 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 " @test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl, the connect-time hunt "
+121 -6
View File
@@ -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 " "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 " "(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 " "fault to the stream and clears the stale ring) and moves again without a "
"restart. forgectrl " "restart. A short SIGSTOP (100 ms, inside the kernel's 200 ms queue) mid-move: "
"restart mid-move: the busy controller finishes the move unmanaged and the new " "no underrun, but the step producer comes back later than its lead and its "
"daemon retakes supervision at idle. After each drill the head is jogged back " "late events clamp; unarmed that is a warning in the controller's log and the "
"by the kernel-measured distance.") "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): def deadman(ctx):
import os as _os import os as _os
import signal as _signal import signal as _signal
@@ -897,6 +900,47 @@ def deadman(ctx):
x1 = _kernel_x_mm(ctx) x1 = _kernel_x_mm(ctx)
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None) _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 # ---- 3. forgectrl restart mid-move: the move finishes, supervision retaken at idle
m2 = wait_running(10) m2 = wait_running(10)
pid2 = m2["pid"] pid2 = m2["pid"]
@@ -934,8 +978,79 @@ def deadman(ctx):
x1 = _kernel_x_mm(ctx) x1 = _kernel_x_mm(ctx)
_return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None) _return_x(ctx, (x1 - x0) if (x0 is not None and x1 is not None) else None)
machine_idle(ctx) machine_idle(ctx)
ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, restart retook supervision (pid %s)", ctx.log("PASS: kill respawned in %s s, hang -> underrun in %s s, short stall warned and completed, "
respawn_s, halt_s, m3.get("pid")) "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) # ------------------------------------------------- lid / button (the factory's)
+22
View File
@@ -126,6 +126,21 @@ class BaselineTests(unittest.TestCase):
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action) self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
self.assertEqual(items["position"].found, [1000, 0, 0]) self.assertEqual(items["position"].found, [1000, 0, 0])
def test_the_counters_are_read_at_the_modes_scale(self):
# 6400 steps is 30 mm at x32 (a return within the bound; on the
# host it stops at the missing controller) and 120 mm at x8
# (beyond the bound). Read at the x8 scale, an x32 machine's
# displaced head is never jogged back.
b = self.bl()
cap = b.capture()
self._attr("cnc/x_mode", "32")
self._pos(6400, 0, 0)
items = {x.item: x for x in b.enforce("post", captured=cap)}
self.assertEqual(items["position"].action, "unrestorable: no running GRBL controller")
self._attr("cnc/x_mode", "8")
items = {x.item: x for x in b.enforce("post", captured=cap)}
self.assertEqual(items["position"].action, "unrestorable: 120.0/0.0 mm exceeds 100 mm")
def test_a_held_controller_is_reset_before_the_return_jog(self): def test_a_held_controller_is_reset_before_the_return_jog(self):
# a pause test that failed while held leaves the controller in # a pause test that failed while held leaves the controller in
# Hold, which refuses a jog: the baseline resets out of it first # Hold, which refuses a jog: the baseline resets out of it first
@@ -434,6 +449,13 @@ class PositionDeadbandTests(unittest.TestCase):
self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0])) self.assertFalse(baseline.position_quantized([0, 0, 0], [over, 0, 0]))
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0])) self.assertFalse(baseline.position_quantized([0, 0, 0], [0, over, 0]))
def test_the_dead_band_scales_with_the_counters(self):
# the same step count is a quarter of the distance at x32
over = int(baseline.POSITION_DEADBAND_MM * baseline.XY_STEPS_PER_MM) + 2
x32 = baseline.XY_STEPS_PER_MM_OF[32]
self.assertTrue(baseline.position_quantized([0, 0, 0], [over, 0, 0], x32))
self.assertFalse(baseline.position_quantized([0, 0, 0], [4 * over, 0, 0], x32))
def test_z_is_exact_because_the_return_never_moves_it(self): def test_z_is_exact_because_the_return_never_moves_it(self):
self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1])) self.assertFalse(baseline.position_quantized([0, 0, 0], [0, 0, 1]))
+103 -2
View File
@@ -107,6 +107,17 @@ over TCP, then checks the dumps against the kernel feeder contract:
clock between two of its file reads, lit to the stop like rule 24: clock between two of its file reads, lit to the stop like rule 24:
no dark cut runs out while the client waits for its next read; the no dark cut runs out while the client waits for its next read; the
engine's return resumes the cut lit engine's return resumes the cut lit
28. a scheduling stall of the step producer (GFSINK_STALL_MS: the
producer is kept off the CPU longer than its lead) maps events
behind the ship cursor. Inside an armed window that is a fault:
ALARM:17, the window closed, the stream ended dark, and no step
burst shipped (the clamped events are never compressed onto later
bytes). Unarmed it is a warning in the log and the move completes
with every step in the stream, compressed
29. a stall of the kernel write (GFSINK_WRITE_STALL_MS: the sink holds
the write the way a full ring holds it) stalls nothing but the
shipper: the producer keeps mapping at wall pace behind it, so the
job completes lit with no clamp, no burst, and a dark end
The analog sessions select the reference mode through the config; on The analog sessions select the reference mode through the config; on
hardware the controller ignores it (density is the only product model - hardware the controller ignores it (density is the only product model -
@@ -462,13 +473,15 @@ def publish_verdicts(path, stop):
def run_session(name, steps, conf=None, workdir=None, keep=False, def run_session(name, steps, conf=None, workdir=None, keep=False,
arm_required=True): arm_required=True, env_extra=None):
"""Launch the controller, run the job steps, return the dump bytes. """Launch the controller, run the job steps, return the dump bytes.
Pass workdir + keep to chain launches over one settings file: the Pass workdir + keep to chain launches over one settings file: the
core precomputes the spindle PWM mapping once, when the spindle is core precomputes the spindle PWM mapping once, when the spindle is
enabled, so a $35 written at runtime only takes effect on the next enabled, so a $35 written at runtime only takes effect on the next
controller start.""" controller start. env_extra adds to the controller's environment
(the host stall knobs). The controller's log (stderr) is kept in
run_session.err."""
if workdir is None: if workdir is None:
workdir = tempfile.mkdtemp(prefix="laser-test-") workdir = tempfile.mkdtemp(prefix="laser-test-")
dump = os.path.join(workdir, "stream.bin") dump = os.path.join(workdir, "stream.bin")
@@ -476,6 +489,7 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
latch_log = os.path.join(workdir, "latch.log") latch_log = os.path.join(workdir, "latch.log")
env = dict(os.environ, GFSINK_DUMP=dump, GF_VERDICT_FILE=verdict, env = dict(os.environ, GFSINK_DUMP=dump, GF_VERDICT_FILE=verdict,
GFSINK_LATCH_LOG=latch_log, FFLOG_STDERR="1") GFSINK_LATCH_LOG=latch_log, FFLOG_STDERR="1")
env.update(env_extra or {})
# The lens reference the daemon leaves before a controller starts: # The lens reference the daemon leaves before a controller starts:
# forgectrl sweeps the carriage onto the hall edge and marks it, and # forgectrl sweeps the carriage onto the hall edge and marks it, and
# the controller opens the Z envelope on that mark. Without one Z # the controller opens the Z envelope on that mark. Without one Z
@@ -558,6 +572,10 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
proc.wait(5) proc.wait(5)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
proc.kill() proc.kill()
try:
run_session.err = (proc.stderr.read() or b"").decode(errors="replace")
except (OSError, ValueError):
run_session.err = ""
stop.set() stop.set()
pub.join(2) pub.join(2)
VERDICT_MODE["mode"] = "clean" VERDICT_MODE["mode"] = "clean"
@@ -1363,6 +1381,89 @@ def main():
"before the stop, lit after the return (%d fire ticks), latch %s" "before the stop, lit after the return (%d fire ticks), latch %s"
% (lit_lead, lit_lead * 1e3 / MACHINE_TICK_HZ, lit_after, seq)) % (lit_lead, lit_lead * 1e3 / MACHINE_TICK_HZ, lit_after, seq))
# --- rules 28 and 29: the stalls -------------------------------------
# The step density the job plans: F3000 at 53.333 steps/mm is 2667
# steps/s, 9.5 per 100 ticks. A clamp compresses the late events onto
# consecutive bytes, which is what the window count catches.
STALL_JOB = ["G90", "G21", "G1 X150 F3000"]
ARMED_STALL_JOB = ["G90", "G21", "M3 S500", "G1 X150 F3000"]
PLANNED_PER_100 = 3000.0 / 60.0 * STEPS_PER_MM / MACHINE_TICK_HZ * 100.0
BURST_LIMIT = int(PLANNED_PER_100 * 1.3) + 1
def max_steps_per_window(ticks, win=100):
worst = 0
for i in range(0, max(1, len(ticks) - win), win // 2):
worst = max(worst, sum(1 for t in ticks[i:i + win] if t & 0x01))
return worst
# 28a: the armed stall is a fault. The job ends in ALARM:17; a reset
# and an unlock bring the controller back so the session can end.
steps = ARMED_STALL_JOB + [("wait_state", "Alarm", 6.0), ("expect_text", "laser disarmed", 3.0),
("rt", b"\x18"), ("sleep", 1.0), "$X"]
data = run_session("stall-armed", steps, conf=DENSITY_CONF_FLOORED,
env_extra={"GFSINK_STALL_MS": "300"})
text, err = run_session.text, run_session.err
if "ALARM:17" not in text:
fail("[stall-armed] a producer stall inside the armed window did not fault (no ALARM:17)")
if "late events while the laser is armed" not in err:
fail("[stall-armed] the fault was not named in the log")
ticks = tick_bytes(data)
burst = max_steps_per_window(ticks)
if burst > BURST_LIMIT:
fail("[stall-armed] %d steps in a 100-tick window (planned %.1f): the clamped events "
"were shipped as a burst" % (burst, PLANNED_PER_100))
check_termination("stall-armed", data)
seq = latch_transitions(run_session.latch)
if seq != ["unlock", "lock"]:
fail("[stall-armed] latch ownership sequence %s, expected unlock (arm), lock (the fault)" % seq)
print("PASS [stall-armed]: a 300 ms producer stall while armed faulted with ALARM:17, "
"%d steps per 100 ticks at most (planned %.1f), ends dark, latch %s"
% (burst, PLANNED_PER_100, seq))
# 28b: unarmed, the same stall is a warning and the move completes with
# every step, compressed.
data = run_session("stall-unarmed", STALL_JOB, conf=DENSITY_CONF_FLOORED,
arm_required=False, env_extra={"GFSINK_STALL_MS": "300"})
text, err = run_session.text, run_session.err
if "ALARM" in text:
fail("[stall-unarmed] an unarmed stall raised an alarm")
if "late events clamped" not in err:
fail("[stall-unarmed] the clamp was not warned about in the log")
ticks = tick_bytes(data)
total = sum(1 for t in ticks if t & 0x01)
want = round(150 * STEPS_PER_MM)
if abs(total - want) > 2:
fail("[stall-unarmed] %d X steps in the stream, expected %d: steps were lost" % (total, want))
burst = max_steps_per_window(ticks)
if burst <= BURST_LIMIT:
fail("[stall-unarmed] no step burst in the stream (%d per 100 ticks): the stall knob "
"did not stall the producer" % burst)
print("PASS [stall-unarmed]: the same stall unarmed was warned, the move completed with "
"all %d steps, the clamp visible as %d steps per 100 ticks" % (total, burst))
# 29: a write stall stalls only the shipper. The producer keeps its
# pace behind it, so nothing clamps and nothing bursts.
data = run_session("write-stall", ARMED_STALL_JOB + [WAIT_IDLE, "M5"], conf=DENSITY_CONF_FLOORED,
env_extra={"GFSINK_WRITE_STALL_MS": "300"})
text, err = run_session.text, run_session.err
if "ALARM" in text:
fail("[write-stall] a write stall raised an alarm")
if "clamped" in err:
fail("[write-stall] a write stall clamped producer events: the back-off held the lock")
ticks = tick_bytes(data)
total = sum(1 for t in ticks if t & 0x01)
if abs(total - want) > 2:
fail("[write-stall] %d X steps in the stream, expected %d" % (total, want))
burst = max_steps_per_window(ticks)
if burst > BURST_LIMIT:
fail("[write-stall] %d steps in a 100-tick window (planned %.1f): a burst" % (burst, PLANNED_PER_100))
lit = count_fire(data)
if lit < 0.8 * want:
fail("[write-stall] the cut ran dark through the stall (%d fire ticks)" % lit)
check_termination("write-stall", data)
print("PASS [write-stall]: a 300 ms write stall left the producer on pace: all %d steps, "
"%d per 100 ticks at most, %d fire ticks, ends dark" % (total, burst, lit))
# --- rule 22: a jog never fires, whatever the modal spindle says ---- # --- rule 22: a jog never fires, whatever the modal spindle says ----
# The arm flow runs on the M3 (window open), the modal spindle is on # The arm flow runs on the M3 (window open), the modal spindle is on
# at S1000, and the jogs come from Idle: exactly what a sender's Fire # at S1000, and the jogs come from Idle: exactly what a sender's Fire
+57
View File
@@ -38,6 +38,17 @@ settings and places the edge on its step grid:
wider window runs to its ends, two half-steps past either alarms wider window runs to its ends, two half-steps past either alarms
9. a stop count out of range (41) falls back on its side alone 9. a stop count out of range (41) falls back on its side alone
The X and Y soft limits are the driver's as well: off while the
position is not trusted (the core's $20 cannot be turned on with $22
off), on after a home, when the envelope is the bed. A scripted home
(homing_mode = gfcloud with a runner that exits 0, the host hook) sets
them:
10. after the home a program move past X or Y max raises ALARM:2
before any motion, a jog past it is refused with error 15, and a
move inside the bed runs; a stream fault or a controller start
drops them again with the reference
The binary keeps its settings in EEPROM.DAT in the working directory, so The binary keeps its settings in EEPROM.DAT in the working directory, so
each run starts from defaults in a temporary directory and leaves each run starts from defaults in a temporary directory and leaves
nothing behind. nothing behind.
@@ -255,6 +266,51 @@ def referenced_cases():
s.close() s.close()
def homed_cases():
"""After a scripted gfcloud home the bed is the X/Y envelope."""
conf = {"lens_hall_edge_z_mm": "3.35", "homing_mode": "gfcloud", "gfcloud_home_cmd": "true"}
s = Session(conf=conf, referenced=True)
try:
free_in_xy(s, "before the home")
x_travel = float(s.setting("$130"))
y_travel = float(s.setting("$131"))
reply = s.send("$H", 5.0)
if "ok" not in reply:
fail("the scripted home was refused: %r" % reply)
s.wait_idle()
st = s.send("?")
m = re.search(r"MPos:(-?[\d.]+),(-?[\d.]+)", st)
check(m and abs(float(m.group(1))) < 0.01 and abs(float(m.group(2))) < 0.01,
"homed: X and Y sit at the origin", "homed: X/Y are not at the origin: %r" % st)
check(s.move("G90 G0 X%.1f" % (x_travel + 5)), "homed: X past the bed alarms",
"homed: a move past X max ran")
s.wait_idle()
check(s.move("G90 G0 Y%.1f" % (y_travel + 5)), "homed: Y past the bed alarms",
"homed: a move past Y max ran")
s.wait_idle()
check(s.move("G90 G0 X-1"), "homed: X past the near edge alarms",
"homed: a move past X min ran")
s.wait_idle()
reply = s.send("$J=G91X%.1fF1200" % (x_travel + 5), 1.5)
check("error:15" in reply, "homed: a jog past the bed is refused with error 15",
"homed: a jog past the bed was not refused with error 15: %r" % reply)
# The core answers the line after an error with that error again
# until an empty line (or a $ command) clears it: the sender's
# acknowledgment at compatibility level 0.
s.send("", 1.0)
check(not s.move("G90 G0 X10 Y10"), "homed: a move inside the bed runs",
"homed: a move inside the bed was blocked")
s.wait_idle()
# A settings write does not drop them: the driver re-applies the
# mask after the core clears it.
s.write_setting("$20=0")
check(s.move("G90 G0 X%.1f" % (x_travel + 5)), "homed: X past the bed still alarms after a $20 write",
"homed: a $20 write freed X")
s.wait_idle()
finally:
s.close()
def main(): def main():
if not os.path.isfile(BIN): if not os.path.isfile(BIN):
fail("no controller binary at %s" % BIN) fail("no controller binary at %s" % BIN)
@@ -285,6 +341,7 @@ def main():
finally: finally:
s.close() s.close()
referenced_cases() referenced_cases()
homed_cases()
print("PASS z_envelope_test") print("PASS z_envelope_test")