mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-19 11:01:43 -07:00
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.
210 lines
9.1 KiB
Python
210 lines
9.1 KiB
Python
# Copyright 2026 514 LLC d/b/a OpenGlow
|
|
# Written by Scott Wiederhold
|
|
# https://community.openglow.org
|
|
# SPDX-License-Identifier: MIT
|
|
|
|
"""The dwell-gap verdict of laser.emission-witness over synthetic trails:
|
|
the button latch judged from the first emission through every sample
|
|
whose readback word shows the laser latch unlocked, both bits from that
|
|
word, so the relocked tail (where the relock itself sets the latch and
|
|
the emission counter still reads nonzero) is never judged. And its
|
|
read-gap verdict over the dark stretch after the M5: a sample whose
|
|
/status carried no laser block is a gap, never a dark sample, a tail the
|
|
daemon did not serve before the sampling stopped is a gap too, and a
|
|
stretch with a kernel window unread fails the rule; the sampling runs on
|
|
past the close; and the head's reports place the rapid inside the armed
|
|
window only."""
|
|
import unittest
|
|
|
|
from forgetest.suite import laser
|
|
|
|
|
|
def smp(emission, latch, locked=False, hv_enable=True, armed=True):
|
|
il = (laser.IL_BUTTON_LATCH if latch else 0) | (laser.IL_LASER_LATCH if locked else 0)
|
|
return {"emission": emission, "armed": armed, "il": il, "hv_enable": hv_enable}
|
|
|
|
|
|
class DwellGapTests(unittest.TestCase):
|
|
def test_the_relocked_tail_is_not_judged(self):
|
|
# the arm wait (unlocked, latch set until the press), fire, the
|
|
# gap, fire, then the relock sets the latch while the counter
|
|
# still reads nonzero
|
|
trail = [smp(0, 1), smp(0, 0), smp(10, 0), smp(20, 0, hv_enable=False),
|
|
smp(20, 0, hv_enable=False), smp(30, 0), smp(40, 0),
|
|
smp(40, 1, locked=True, armed=False), smp(40, 1, locked=True, armed=False),
|
|
smp(0, 1, locked=True, armed=False)]
|
|
g = laser.dwell_gap(trail)
|
|
self.assertEqual(g["button_latch_unlocked_max"], 0)
|
|
self.assertEqual(g["button_latch_unlocked_samples"], 5)
|
|
self.assertEqual(g["button_latch_set_at"], [])
|
|
self.assertTrue(g["hv_enable_dipped"])
|
|
self.assertTrue(g["hv_enable_back_lit"])
|
|
|
|
def test_latch_set_while_unlocked_is_the_defect(self):
|
|
trail = [smp(0, 0), smp(10, 0), smp(20, 1, hv_enable=False), smp(30, 0), smp(0, 1, locked=True)]
|
|
g = laser.dwell_gap(trail)
|
|
self.assertEqual(g["button_latch_unlocked_max"], 1)
|
|
self.assertEqual(g["button_latch_set_at"], [1])
|
|
|
|
def test_no_emission_judges_nothing(self):
|
|
g = laser.dwell_gap([smp(0, 0), smp(0, 1, locked=True)])
|
|
self.assertIsNone(g["button_latch_unlocked_max"])
|
|
self.assertEqual(g["button_latch_unlocked_samples"], 0)
|
|
self.assertFalse(g["hv_enable_dipped"])
|
|
|
|
|
|
def rapid_trail(n, dt=0.2, holes=()):
|
|
"""A dark stretch of n samples dt seconds apart, the emission count
|
|
and the HV current read on every sample but the ones in `holes`: a
|
|
/status served without its laser block, which sample() records as
|
|
None."""
|
|
out = []
|
|
for i in range(n):
|
|
s = {"t": 100.0 + i * dt, "emission": 0, "hv": 0, "armed": True}
|
|
if i in holes:
|
|
s["emission"] = None
|
|
s["hv"] = None
|
|
out.append(s)
|
|
return out
|
|
|
|
|
|
def covered(r):
|
|
"""The rule emission_witness applies to read_gaps' report."""
|
|
return (r["read"] >= laser.RAPID_MIN_READS and r["gap_max_s"] is not None
|
|
and r["gap_max_s"] <= laser.EMISSION_READ_GAP_S)
|
|
|
|
|
|
class RapidReadTests(unittest.TestCase):
|
|
def test_every_sample_read_is_inside_the_rule(self):
|
|
r = laser.read_gaps(rapid_trail(25), "emission")
|
|
self.assertEqual((r["read"], r["unread"]), (25, 0))
|
|
self.assertAlmostEqual(r["gap_max_s"], 0.2)
|
|
self.assertTrue(covered(r))
|
|
|
|
def test_one_transient_miss_is_inside_the_rule(self):
|
|
r = laser.read_gaps(rapid_trail(25, holes=(12,)), "emission")
|
|
self.assertEqual((r["read"], r["unread"]), (24, 1))
|
|
self.assertAlmostEqual(r["gap_max_s"], 0.4)
|
|
self.assertTrue(covered(r))
|
|
|
|
def test_a_hole_shorter_than_a_window_is_inside_the_rule(self):
|
|
r = laser.read_gaps(rapid_trail(25, holes=(10, 11, 12)), "emission")
|
|
self.assertAlmostEqual(r["gap_max_s"], 0.8)
|
|
self.assertTrue(covered(r))
|
|
|
|
def test_a_hole_longer_than_a_window_fails(self):
|
|
r = laser.read_gaps(rapid_trail(25, holes=(10, 11, 12, 13, 14)), "emission")
|
|
self.assertAlmostEqual(r["gap_max_s"], 1.2)
|
|
self.assertFalse(covered(r))
|
|
|
|
def test_the_block_gone_after_the_dark_point_fails(self):
|
|
r = laser.read_gaps(rapid_trail(25, holes=tuple(range(1, 25))), "emission")
|
|
self.assertEqual((r["read"], r["unread"]), (1, 24))
|
|
self.assertAlmostEqual(r["gap_max_s"], 4.8)
|
|
self.assertFalse(covered(r))
|
|
|
|
def test_the_block_gone_for_the_last_window_and_a_half_fails(self):
|
|
# the tail is an edge: the window that latches after the close
|
|
# goes unread
|
|
r = laser.read_gaps(rapid_trail(25, holes=tuple(range(17, 25))), "emission")
|
|
self.assertAlmostEqual(r["gap_max_s"], 1.6)
|
|
self.assertFalse(covered(r))
|
|
|
|
def test_a_tail_the_daemon_never_served_fails(self):
|
|
# every sample served was read, and the served samples end at the
|
|
# close: the sampling ran on past it with nothing served, so the
|
|
# stretch from the last reading to where the sampling stopped is a gap
|
|
trail = rapid_trail(25)
|
|
tail = 2 * laser.EMISSION_WINDOW_S + laser.EMISSION_READ_GAP_S
|
|
r = laser.read_gaps(trail, "emission", trail[-1]["t"] + tail)
|
|
self.assertAlmostEqual(r["gap_max_s"], tail)
|
|
self.assertFalse(covered(r))
|
|
# served through: the sampling stops one loop after the last sample
|
|
self.assertTrue(covered(laser.read_gaps(trail, "emission", trail[-1]["t"] + 0.125)))
|
|
|
|
def test_a_stretch_of_one_sample_fails(self):
|
|
r = laser.read_gaps(rapid_trail(1), "emission")
|
|
self.assertEqual(r, {"read": 1, "unread": 0, "gap_max_s": 0.0})
|
|
self.assertFalse(covered(r))
|
|
|
|
|
|
class Clock:
|
|
"""time.time and time.sleep on a clock the test moves."""
|
|
|
|
def __init__(self, now=1000.0):
|
|
self.now = now
|
|
|
|
def time(self):
|
|
return self.now
|
|
|
|
def sleep(self, s):
|
|
self.now += s
|
|
|
|
|
|
class RapidTailTests(unittest.TestCase):
|
|
def test_the_sampling_runs_on_past_the_close_and_ends_where_it_stopped(self):
|
|
# The window closes 3 s in, and the daemon serves the closed
|
|
# sample and nothing after it: the sampling still runs two kernel
|
|
# windows and the read gap allowed past the close, and the time it
|
|
# stopped is the trail's end, so the tail it never served fails the
|
|
# read rule.
|
|
clock = Clock()
|
|
close = clock.now + 3.0
|
|
served = []
|
|
|
|
def sample(_ctx):
|
|
clock.now += 0.06 # the two GETs
|
|
if served and not served[-1]["armed"]:
|
|
return None # nothing served after the close
|
|
s = {"t": clock.now, "armed": clock.now < close, "emission": 0, "hv": 0}
|
|
served.append(s)
|
|
return s
|
|
|
|
class Ctx:
|
|
def checkpoint(self):
|
|
pass
|
|
|
|
class Grbl:
|
|
def status_report(self):
|
|
# 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])
|
|
laser.time, laser.sample = clock, sample
|
|
tail, t_end = laser.sample_to_disarm(Ctx(), Grbl())
|
|
closed = tail[-1]
|
|
self.assertFalse(closed["armed"])
|
|
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):
|
|
"""A dark stretch with the head at machine X `xs` (one sample each,
|
|
0.2 s apart), the window closing at sample `closes_at`."""
|
|
return [{"t": 100.0 + i * 0.2, "x": float(x), "armed": i < closes_at} for i, x in enumerate(xs)]
|
|
|
|
|
|
class RapidPlaceTests(unittest.TestCase):
|
|
# out to +20 mm, the dwell there, back at the start, the dwell before the M2
|
|
XS = [0, 5, 20, 20, 20, 10, 0, 0, 0, 0]
|
|
|
|
def test_a_window_closed_before_the_head_is_back_does_not_place_the_rapid(self):
|
|
# the window closes after the head is back: out at 2, back at 6, closed at 8
|
|
self.assertEqual(laser.rapid_place(place_trail(self.XS, 8), 0.0), (2, 6, 8))
|
|
# 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__":
|
|
unittest.main()
|