diff --git a/forgetest/forgetest/baseline.py b/forgetest/forgetest/baseline.py index d75c777..3621cec 100644 --- a/forgetest/forgetest/baseline.py +++ b/forgetest/forgetest/baseline.py @@ -182,12 +182,19 @@ def leds_root(): return r if r.endswith("/") else r + "/" -def read_led(name): - try: - with open(leds_root() + name + "/brightness") as f: - return f.read().strip() - except OSError: - return None +def read_led(name, attr="target"): + """A button LED's commanded level. The trigger fades brightness toward + target, so brightness is where the fade has reached and target is what + the machine was told: a run that left the button lit left a target, and + a run that ended a moment ago may still be fading from one. Falls back + to brightness where no target exists.""" + for a in (attr, "brightness"): + try: + with open(leds_root() + name + "/" + a) as f: + return f.read().strip() + except OSError: + continue + return None def write_led(name, value): @@ -545,7 +552,14 @@ class Baseline: before = (mode.get("controller"), mode.get("motion")) mode = self.wait_settled() or mode if mode.get("controller") == "running" and mode.get("motion") == "verified": - left.append(Leftover("controller", "%s/%s" % before, "running/verified", "waited")) + # The supervisor's own work: a takeover ends by starting + # forgectrl again, and the respawn runs the liveness probe + # and the lens reference before the controller is up and + # motion is verified. Waiting for that is right; calling + # it a leftover is not, because the run did put it back - + # the machine was still doing what it was asked. + self.log("controller: %s/%s settled to running/verified on its own; " + "the supervisor's own start, not a leftover" % before) else: left.append(Leftover("controller", "%s/%s" % before, "running/verified", "failed: %s/%s" % (mode.get("controller"), mode.get("motion")))) @@ -553,14 +567,23 @@ class Baseline: st, s = self.fc_get("/status") if st == 200 and isinstance(s, dict): if s.get("state") != "idle": - if s.get("state") == "underrun": + # An underrun is the run's: the engine ran out of bytes and + # the fault is acknowledged here. Any other state is the + # machine finishing what it was given - the ring draining + # to the end of a job - and it reaches idle on its own. + underrun = s.get("state") == "underrun" + if underrun: try: hw.sysfs_write("cnc/stop", "1") # ack except OSError: pass w = self._wait("idle", lambda: (self.fc_get("/status")[1] or {}).get("state") == "idle", IDLE_S) - left.append(Leftover("state", s.get("state"), "idle", - "waited" if w is not None else "failed: not idle")) + if w is not None and not underrun: + self.log("state: %s reached idle on its own after %.0f s; the machine " + "finishing, not a leftover" % (s.get("state"), w)) + else: + left.append(Leftover("state", s.get("state"), "idle", + "waited" if w is not None else "failed: not idle")) if s.get("laser_locked") is False: try: hw.sysfs_write("cnc/laser_latch", "1") @@ -575,18 +598,29 @@ class Baseline: found = "%s/armed=%s/hold=%s" % (c.get("phase"), c.get("armed"), c.get("hold")) idle = lambda: (lambda x: x.get("phase") == "idle" and not x.get("armed") # noqa: E731 and not x.get("hold"))(self.fc_get("/cool/status")[1] or {}) + # Armed, or holding, is the run's doing. A phase alone is + # not: the engine clears smoke at run duty after an armed + # session and cools down after a hot one, both timed and + # both ending on their own. Waiting for that is right; + # calling it a leftover is not, because nothing was left - + # the machine was still finishing. + dirt = bool(c.get("armed") or c.get("hold")) w = self._wait("cool idle", idle, COOL_IDLE_S) - # The engine holds for a job. A run that ended without - # ending its job leaves one alive, and no amount of - # waiting ends it: stand the machine down and let the - # supervisor bring the controller back clean. - act = "waited" - if w is None: - self.stand_down("the cooling engine is still %s" % found) - w = self._wait("cool idle", idle, COOL_IDLE_S) - act = "restored (stood the machine down)" - left.append(Leftover("cool", found, "idle/unarmed/no hold", - act if w is not None else "failed: still %s" % found)) + if w is not None and not dirt: + self.log("cool: %s ended on its own after %.0f s; the engine's own post-job " + "work, not a leftover" % (found, w)) + else: + # The engine holds for a job. A run that ended without + # ending its job leaves one alive, and no amount of + # waiting ends it: stand the machine down and let the + # supervisor bring the controller back clean. + act = "waited" + if w is None: + self.stand_down("the cooling engine is still %s" % found) + w = self._wait("cool idle", idle, COOL_IDLE_S) + act = "restored (stood the machine down)" + left.append(Leftover("cool", found, "idle/unarmed/no hold", + act if w is not None else "failed: still %s" % found)) def _lamp_side(self, left): """The lid lamp at forgectrl's idle level (the lid_lamp_idle setting). @@ -650,6 +684,9 @@ class Baseline: except OSError as e: act = "failed: %s" % e left.append(Leftover("leds/" + name, got, "0", act)) + elif (read_led(name, "brightness") or "0") != "0": + 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"))) def _return_head(self, was, now): """Jog the head back along its own path by the kernel-measured X/Y diff --git a/forgetest/tests/test_baseline.py b/forgetest/tests/test_baseline.py index b4914bd..d7b09da 100644 --- a/forgetest/tests/test_baseline.py +++ b/forgetest/tests/test_baseline.py @@ -52,6 +52,13 @@ class BaselineTests(unittest.TestCase): # the class interface writes 'target'; the fake mirrors it into brightness # only when the test asks (see _sync_leds) + def _led2(self, name, target, brightness): + """Both attributes of one button LED: the trigger's commanded + level and the fade that follows it.""" + for a, v in (("target", target), ("brightness", brightness)): + with open(self.leds + name + "/" + a, "w") as f: + f.write(v) + def _sync_leds(self): for name in baseline.BUTTON_LEDS: p = self.leds + name + "/target" @@ -370,6 +377,29 @@ if __name__ == "__main__": unittest.main() +class TransientNotLeftoverTests(BaselineTests): + """A leftover is what a run left behind, not the machine part-way + through its own work. Found on the bench reference, where + laser.arm-wait-lid passed every check it makes and failed its + hand-back on the post-job smoke clear.""" + + def test_a_fading_button_led_is_not_a_leftover(self): + # the trigger fades brightness toward target: target 0 with + # brightness still high is the fade from a level the machine ended + for name in baseline.BUTTON_LEDS: + self._led2(name, target="0", brightness="900") + left = self.bl().enforce("post", captured=None) + self.assertEqual([x for x in left if x.item.startswith("leds/")], []) + + def test_a_lit_button_led_is_a_leftover(self): + # a target the run left standing is dirt, whatever the fade reads + self._led2(baseline.BUTTON_LEDS[0], target="1014", brightness="0") + left = self.bl().enforce("post", captured=None) + items = [x for x in left if x.item.startswith("leds/")] + self.assertEqual(len(items), 1) + self.assertEqual(items[0].found, "1014") + + class PositionDeadbandTests(unittest.TestCase): """The counters count steps and a controller's own return lands within a few hundredths of a millimeter, not on the step: a difference that