forgetest: the hand-back never moves the head across a lost counter frame

Seen on the bench reference, twice in one session: at the end of a passing
run the hand-back jogged the head 30 mm into the back-left stop blocks,
from a head that had not moved.

The baseline compares the kernel's step counters at the end of a run with
the start and jogs the head back by the difference. The GRBL controller
zeroes those counters at every start (the lens's startup reference) and at
every home (home_completed()), and rewrites its anchor, /run/grblhal.homed,
each time. Across either event the difference between two counter readings
is not a distance the head traveled. It stayed hidden because the counters
normally read zero between tests. homing.manual broke that: a manual home
zeroes the counters 30 mm out from where the test began, so after the
test's own correct return they read -6400, and the next test that restarts
the controller (setup.check-flow-verify, then motion.release) ended at 0,
"expected -6400", and was "returned" by 30 mm. An operator who jogs the
head from a Grbl client and then starts any takeover test from the page
would have met the same thing, by whatever distance they had jogged.

The baseline's capture() now records the counters' frame, the anchor's
inode and mtime. If the frame changed during the run and the test did not
vouch for the new one, the hand-back logs that the two readings share no
frame, and moves nothing. ctx.counters_rezeroed() is how a test vouches: it
now sets rezero_declared beside the position it expects. The start_reads
argument it briefly took is gone, since it made the baseline accept
counters that nothing after it could live with.

homing.manual restarts the controller once more after returning the head,
so it ends with the counters at zero where it began, and declares that.

events.stream waits for its three places. A stream an earlier test closed
keeps its place until the daemon's next write to it (its keep-alive), as
documented, so run straight after forgectrl.lease the third stream drew
503. The test now opens the three once they can be opened, and says so in
its log.

Proven. test_baseline gains test_a_lost_counter_frame_never_moves_the_head:
counters at -6400, a new anchor, counters at 0: no jog, no leftover, and the
log says why; the same counters with the frame intact are still a displaced
head; a declared re-zero is held to the position it declared. With the
frame check unable to see the change (the first cut of the test reused an
inode inside one clock tick) the case fails with ['position'], which is the
old behavior. The unit suite passes. On the bench reference, arranged so a
failure would move the head away from the stop: the head jogged to +60 mm
(counters 12800), motion.release run, PASS, "the controller re-zeroed its
counters during the run ... the head is not moved", and nothing moved.
forgectrl.lease then events.stream: two logged waits, PASS. homing.manual
then setup.check-flow-verify, the sequence that drove the head into the
stop: both PASS with a clean hand-back.
This commit is contained in:
ScottW514
2026-09-20 09:00:55 -04:00
parent 520bf6023d
commit 888b47abe8
5 changed files with 112 additions and 21 deletions
+43
View File
@@ -139,6 +139,49 @@ class BaselineTests(unittest.TestCase):
self.assertTrue(items["position"].action.startswith("unrestorable"), items["position"].action)
self.assertEqual(items["position"].found, [1000, 0, 0])
def test_a_lost_counter_frame_never_moves_the_head(self):
# The controller zeroes the step counters at every start and at
# every home, and rewrites its anchor then. A run that began at
# -6400 steps and restarted the controller ends at 0 with the head
# where it was: the difference is not a distance, and the hand-back
# must neither jog the head nor call it a leftover.
anchor = os.path.join(self.tmp, "grblhal.homed")
old_path, baseline.ANCHOR_PATH = baseline.ANCHOR_PATH, anchor
try:
with open(anchor, "w") as f:
f.write("0 0 0 4 startup")
self._pos(-6400, 0, 0)
b = self.bl()
cap = b.capture()
self.assertIsNotNone(cap["frame"])
os.unlink(anchor) # a controller start: a new anchor file
with open(anchor + ".new", "w") as f:
f.write("0 0 0 4 startup")
os.replace(anchor + ".new", anchor)
os.utime(anchor, ns=(5, 5)) # a freed inode can come straight back, inside one clock tick
self._pos(0, 0, 0)
left = b.enforce("post", captured=cap)
self.assertEqual([x.item for x in left], [])
self.assertTrue(any("re-zeroed its counters during the run" in l for l in self.lines), self.lines)
# the same counters with the frame intact are a displaced head, as before
self.lines.clear()
self._pos(-6400, 0, 0)
cap = b.capture()
self._pos(0, 0, 0)
left = b.enforce("post", captured=cap)
self.assertEqual([x.item for x in left], ["position"])
# and a test that vouches for the new frame is held to it
self._pos(-6400, 0, 0)
cap = b.capture()
os.utime(anchor, ns=(1, 1)) # re-zeroed, and declared (ctx.counters_rezeroed)
cap["position"], cap["rezero_declared"] = [0, 0, 0], True
self._pos(0, 0, 0)
self.assertEqual(b.enforce("post", captured=cap), [])
self._pos(1000, 0, 0)
self.assertEqual([x.item for x in b.enforce("post", captured=cap)], ["position"])
finally:
baseline.ANCHOR_PATH = old_path
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