From 7b0f065a3f5c51f98d44f999d2b8bfa4358f9daf Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Fri, 16 Oct 2026 22:56:33 -0400 Subject: [PATCH] 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. --- forgetest/forgetest/suite/laser.py | 43 ++++++++++++++++------------- forgetest/tests/test_laser_dwell.py | 10 ++++++- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/forgetest/forgetest/suite/laser.py b/forgetest/forgetest/suite/laser.py index 08c438d..478dedb 100644 --- a/forgetest/forgetest/suite/laser.py +++ b/forgetest/forgetest/suite/laser.py @@ -313,15 +313,17 @@ def read_gaps(samples, key, t_end=None): def rapid_place(stretch, x0): """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 - disarmed one, that one included. Returns (i_out, i_back, i_closed): - the first sample with the head at least 19.5 mm out, the first after - it with the head back at x0 (within 0.05 mm), and the first disarmed - sample; each None when there is none. A window that closes before - the head is back leaves i_back None: the samples after the close - never place the rapid inside the window.""" + the armed part of the dark stretch alone: its samples before the first + disarmed one. Returns (i_out, i_back, i_closed): the first sample with + the head at least 19.5 mm out, the first after it with the head back + at x0 (within 0.05 mm), and the first disarmed sample; each None when + there is none. A window that closes before the head is back leaves + i_back None: only a sample that read the window open places the head + 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) - 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_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) @@ -331,14 +333,15 @@ def rapid_place(stretch, x0): def sample_to_disarm(ctx, g, timeout=20.0, sample_hz=8): """Sample until the armed window closes, or the timeout, with the - controller's state and machine X beside each sample ("x"). A G4 dwell - reports Idle, so Idle cannot end this; the window closing at the - program end does, after two more kernel windows and the read gap - allowed: the LASER_ON count for the window in flight at the close - latches up to one window later and is held through the next, so the - sampling runs on until that count has been read. Returns the samples - served and the time the sampling stopped, the trail's end for the - read rule.""" + controller's state and machine X beside each sample ("x"), read just + before the sample: a sample that reads the window open read its X + with the window open (rapid_place). A G4 dwell reports Idle, so Idle + cannot end this; the window closing at the program end does, after + two more kernel windows and the read gap allowed: the LASER_ON count + for the window in flight at the close latches up to one window later + and is held through the next, so the sampling runs on until that + count has been read. Returns the samples served and the time the + sampling stopped, the trail's end for the read rule.""" samples = [] period = 1.0 / sample_hz t0 = time.time() @@ -346,8 +349,8 @@ def sample_to_disarm(ctx, g, timeout=20.0, sample_hz=8): t_closed = None while time.time() < end: ctx.checkpoint() - smp = sample(ctx) rep = g.status_report() + smp = sample(ctx) if smp: smp["gstate"] = rep.get("state") smp["x"] = (rep.get("MPos") or (None,))[0] @@ -474,8 +477,10 @@ def wait_disarm(ctx, timeout): def emission_witness(ctx): ev = ctx.evidence # The rapid after the M5 (G91 is modal from the square): out over the - # first side and back, then the program end. - rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P0.5", "G90", "M2"] + # first side and back, then the program end. The head stands back for + # 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): prepare(ctx, g) diff --git a/forgetest/tests/test_laser_dwell.py b/forgetest/tests/test_laser_dwell.py index 48a48f8..e01d846 100644 --- a/forgetest/tests/test_laser_dwell.py +++ b/forgetest/tests/test_laser_dwell.py @@ -166,7 +166,8 @@ class RapidTailTests(unittest.TestCase): class Grbl: 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 self.addCleanup(setattr, laser, "time", saved[0]) 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.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))) + # 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): @@ -195,6 +199,10 @@ class RapidPlaceTests(unittest.TestCase): # 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 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__":