laser.emission-witness: the head is placed back only by a sample that read the window open

rapid_place searched the samples up to the first disarmed one, that one
included, and sample_to_disarm read each sample's X after its armed
flag. So the first sample to read the window closed could place the
head back with an X read after the close: a window that closed on the
return leg, up to about one sampling loop before the head was back,
placed the rapid inside the window with a disarm time of 0, and the
`0 <=` on that time could not fail.

rapid_place now searches the samples before the first disarmed one
alone, and sample_to_disarm reads the controller's report (its state
and X) before the sample, so a sample that reads the window open read
its X with the window open. The head stands back for 1 s before the M2,
not 0.5 s: the placement needs a sample whose X and armed flag both
come between the head's return and the close, and a slow sampling loop
needs that time. The judge only gets stricter. No energy or motion path
changes: the longer dwell runs dark after the M5 with the window open,
as the shorter one did.

Proof: tests/test_laser_dwell.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 13 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12:
test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid
gains the close at the first sample with the head back (2, None, 6),
and test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped
holds the order (each sample's X read before its armed flag).
test_cooling_suite.py (3) passes. Negative controls on scratch copies:
the search with the first disarmed sample included fails the first
case with (2, 6, 6); the report read after the sample fails the second.
emission_witness run whole on a fake clock with only its I/O stubbed
(the review's sweep: the armed flag served mid-GET, X read mid status
report, 40 phases of the rapid against sampling loops of 0.20 to
0.64 s): a window closing 0 to 2 s before the head is back fails the
placement at every phase and every loop, and the normal close passes
at 40 of 40 phases at every loop. The code before this change passed
early closes at some phases up to about one loop early (0.20 s at a
0.20 s loop, 0.5 s at a 0.64 s loop). With the new order and the 0.5 s
dwell, the normal close passed 39 of 40 at a 0.38 s loop and 12 of 40
at a 0.64 s loop. pyflakes reports no undefined name.

Acceptance: laser.emission-witness's fingerprint moves (its own text and
laser.py's shared text), laser.armed-kill and
laser.pause-resume-lid-cancel move through the shared text, and
cooling.flow-under-load and cooling.fail-tier-stop move because they
import from .laser in their own functions; the other 38 do not move
(source_sha compared before and after). Against the campaign this
series already needs, that adds no run: 850498f moved all 43 once.
laser.emission-witness is always run, and the next campaign judges the
placement on the bench.
This commit is contained in:
ScottW514
2026-10-16 22:56:33 -04:00
parent d57421c71d
commit 7b0f065a3f
2 changed files with 33 additions and 20 deletions
+24 -19
View File
@@ -313,15 +313,17 @@ def read_gaps(samples, key, t_end=None):
def rapid_place(stretch, x0): def rapid_place(stretch, x0):
"""Where the head's reports place the rapid after the M5, judged over """Where the head's reports place the rapid after the M5, judged over
the armed part of the dark stretch alone: its samples up to the first the armed part of the dark stretch alone: its samples before the first
disarmed one, that one included. Returns (i_out, i_back, i_closed): disarmed one. Returns (i_out, i_back, i_closed): the first sample with
the first sample with the head at least 19.5 mm out, the first after the head at least 19.5 mm out, the first after it with the head back
it with the head back at x0 (within 0.05 mm), and the first disarmed at x0 (within 0.05 mm), and the first disarmed sample; each None when
sample; each None when there is none. A window that closes before there is none. A window that closes before the head is back leaves
the head is back leaves i_back None: the samples after the close i_back None: only a sample that read the window open places the head
never place the rapid inside the window.""" back, and sample_to_disarm reads its X before its armed flag, so the
head was back with the window open. The first disarmed sample does
not tell whether the head came back before the close or after it."""
i_closed = next((i for i, s in enumerate(stretch) if not s["armed"]), None) i_closed = next((i for i, s in enumerate(stretch) if not s["armed"]), None)
part = stretch if i_closed is None else stretch[:i_closed + 1] part = stretch if i_closed is None else stretch[:i_closed]
i_out = next((i for i, s in enumerate(part) if s.get("x") is not None and s["x"] - x0 >= 19.5), None) i_out = next((i for i, s in enumerate(part) if s.get("x") is not None and s["x"] - x0 >= 19.5), None)
i_back = (next((i for i in range(i_out + 1, len(part)) i_back = (next((i for i in range(i_out + 1, len(part))
if part[i].get("x") is not None and abs(part[i]["x"] - x0) < 0.05), None) if part[i].get("x") is not None and abs(part[i]["x"] - x0) < 0.05), None)
@@ -331,14 +333,15 @@ def rapid_place(stretch, x0):
def sample_to_disarm(ctx, g, timeout=20.0, sample_hz=8): def sample_to_disarm(ctx, g, timeout=20.0, sample_hz=8):
"""Sample until the armed window closes, or the timeout, with the """Sample until the armed window closes, or the timeout, with the
controller's state and machine X beside each sample ("x"). A G4 dwell controller's state and machine X beside each sample ("x"), read just
reports Idle, so Idle cannot end this; the window closing at the before the sample: a sample that reads the window open read its X
program end does, after two more kernel windows and the read gap with the window open (rapid_place). A G4 dwell reports Idle, so Idle
allowed: the LASER_ON count for the window in flight at the close cannot end this; the window closing at the program end does, after
latches up to one window later and is held through the next, so the two more kernel windows and the read gap allowed: the LASER_ON count
sampling runs on until that count has been read. Returns the samples for the window in flight at the close latches up to one window later
served and the time the sampling stopped, the trail's end for the and is held through the next, so the sampling runs on until that
read rule.""" count has been read. Returns the samples served and the time the
sampling stopped, the trail's end for the read rule."""
samples = [] samples = []
period = 1.0 / sample_hz period = 1.0 / sample_hz
t0 = time.time() t0 = time.time()
@@ -346,8 +349,8 @@ def sample_to_disarm(ctx, g, timeout=20.0, sample_hz=8):
t_closed = None t_closed = None
while time.time() < end: while time.time() < end:
ctx.checkpoint() ctx.checkpoint()
smp = sample(ctx)
rep = g.status_report() rep = g.status_report()
smp = sample(ctx)
if smp: if smp:
smp["gstate"] = rep.get("state") smp["gstate"] = rep.get("state")
smp["x"] = (rep.get("MPos") or (None,))[0] smp["x"] = (rep.get("MPos") or (None,))[0]
@@ -474,8 +477,10 @@ def wait_disarm(ctx, timeout):
def emission_witness(ctx): def emission_witness(ctx):
ev = ctx.evidence ev = ctx.evidence
# The rapid after the M5 (G91 is modal from the square): out over the # The rapid after the M5 (G91 is modal from the square): out over the
# first side and back, then the program end. # first side and back, then the program end. The head stands back for
rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P0.5", "G90", "M2"] # 1 s before the M2, so that a sample of a slow sampling loop still
# reads it back with the window open (rapid_place).
rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P1", "G90", "M2"]
with ctx.grbl() as g, LiveJob(ctx, g): with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g) prepare(ctx, g)
+9 -1
View File
@@ -166,7 +166,8 @@ class RapidTailTests(unittest.TestCase):
class Grbl: class Grbl:
def status_report(self): def status_report(self):
return {"state": "Idle", "MPos": (0.0, 0.0, 0.0)} # the time of the read, as the head's X
return {"state": "Idle", "MPos": (clock.now, 0.0, 0.0)}
saved = laser.time, laser.sample saved = laser.time, laser.sample
self.addCleanup(setattr, laser, "time", saved[0]) self.addCleanup(setattr, laser, "time", saved[0])
self.addCleanup(setattr, laser, "sample", saved[1]) self.addCleanup(setattr, laser, "sample", saved[1])
@@ -177,6 +178,9 @@ class RapidTailTests(unittest.TestCase):
self.assertTrue(all(s["armed"] for s in tail[:-1])) self.assertTrue(all(s["armed"] for s in tail[:-1]))
self.assertGreaterEqual(t_end - closed["t"], 2 * laser.EMISSION_WINDOW_S + laser.EMISSION_READ_GAP_S) self.assertGreaterEqual(t_end - closed["t"], 2 * laser.EMISSION_WINDOW_S + laser.EMISSION_READ_GAP_S)
self.assertFalse(covered(laser.read_gaps(tail, "emission", t_end))) self.assertFalse(covered(laser.read_gaps(tail, "emission", t_end)))
# each sample's X is read before its armed flag: an X that places
# the head was read with the window open
self.assertTrue(all(s["x"] < s["t"] for s in tail))
def place_trail(xs, closes_at): def place_trail(xs, closes_at):
@@ -195,6 +199,10 @@ class RapidPlaceTests(unittest.TestCase):
# the window closes in the dwell at +20 mm and the head comes back # the window closes in the dwell at +20 mm and the head comes back
# after the close: the samples past the close do not place it # after the close: the samples past the close do not place it
self.assertEqual(laser.rapid_place(place_trail(self.XS, 4), 0.0), (2, None, 4)) self.assertEqual(laser.rapid_place(place_trail(self.XS, 4), 0.0), (2, None, 4))
# the window reads closed at the first sample with the head back:
# that sample does not tell whether the head was back before the
# close, so it does not place it
self.assertEqual(laser.rapid_place(place_trail(self.XS, 6), 0.0), (2, None, 6))
if __name__ == "__main__": if __name__ == "__main__":