forgetest: the operator proves presence at the machine, the bench presses

A live test asked a person to click Ready on a page and then make
timing-critical presses in the middle of a burning cut. That is how
tonight's pause test became unanswerable: the actuator had dropped off
the network, the harness fell back to the operator without saying so,
and afterwards nobody could tell a second press from the machine
resuming on its own.

Where an actuator is up and wired to the button, the ready gate now
takes a press on the machine's own button as the presence check, and the
actuator performs every press in that test. The button does nothing at
Idle, so the press is only a presence check, and the gate waits for the
release so it is never read as the arm press. With no actuator the
operator does the presses and answers on the page, as before.

An actuator lost after that takeover is now said out loud, in the log
and in the evidence, instead of quietly becoming a person's press.

The live-fire cue was four lines of machine-shaped prose. It is now what
a person needs: protection, exhaust, extinguisher, scrap, lid.
This commit is contained in:
ScottW514
2026-09-03 16:55:34 -04:00
parent da0c70703d
commit ba0bf41749
3 changed files with 686 additions and 556 deletions
+64 -6
View File
@@ -116,6 +116,10 @@ ACTION_TEXT = {
("button", "press"): "Press the button once.", ("button", "press"): "Press the button once.",
} }
ACT_TIMEOUT_S = 180 ACT_TIMEOUT_S = 180
# How long a live test waits for the operator to prove they are at the
# machine by pressing its button. Generous: they may be setting up the
# scrap, and nothing fires until it lands.
PRESENCE_TIMEOUT_S = 600
class Run: class Run:
@@ -131,6 +135,10 @@ class Run:
self.notice = None # {"id","text"}: a standing instruction, no button self.notice = None # {"id","text"}: a standing instruction, no button
self.answers = [] self.answers = []
self.unattended = False # a fixture-run test: no prompt can be answered self.unattended = False # a fixture-run test: no prompt can be answered
# The operator proved they are at the machine by pressing its
# button, so the bench actuator performs every press from there
# on. Set by Ctx.ready() when an actuator is up to take over.
self.fixture_takeover = False
self.evidence = {} self.evidence = {}
self.baseline_captured = None # preserved state the post pass hands back self.baseline_captured = None # preserved state the post pass hands back
self.aborted = threading.Event() self.aborted = threading.Event()
@@ -266,14 +274,45 @@ class Context:
raise Failed("operator could not: %s" % text) raise Failed("operator could not: %s" % text)
def ready(self, text): def ready(self, text):
"""Pre-announce a timed step: what happens when the operator """Pre-announce a timed step and wait for the operator to say the
clicks Ready and what they do during it. Returns on the click; machine is set up.
the test then starts the thing and watches the machine. With the
fixture performing the step (the run is unattended) there is Where an actuator is up and wired to the button, the operator
nobody to announce it to: the gate passes at once, logged.""" proves they are at the machine by pressing the machine's own
button, and the actuator then performs every press in the test.
A person doing timing-critical presses in the middle of a live
cut is how a press count becomes unknowable: a press that lands
late, or twice, cannot be told apart afterwards from the machine
misbehaving. The button does nothing at Idle, so this press is
only a presence check.
With no actuator the operator does the presses and answers on the
page, as before. With the run unattended there is nobody to
announce it to and the gate passes at once, logged."""
if self.run.unattended: if self.run.unattended:
self.log("READY (fixture performs the step): %s", text) self.log("READY (fixture performs the step): %s", text)
return return
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
if fixture is not None and fixture.covers("button"):
self.notice(text + " Then press the button on the machine to start.")
self.log("READY: waiting for the operator's press on the machine "
"(the bench then does every press in this test)")
pressed = self.wait_for(lambda: self.switch("button") is True, PRESENCE_TIMEOUT_S)
self.clear_notice()
if pressed is None:
raise Failed("no press on the machine within %d s: %s"
% (PRESENCE_TIMEOUT_S, text))
# Let the button come back up, so this press is never read as
# the arm press or a pause toggle.
self.wait_for(lambda: self.switch("button") is False, 10)
self.run.fixture_takeover = True
self.evidence.setdefault("actions", []).append(
{"channel": "button", "state": "presence", "by": "operator",
"took_s": round(pressed, 2), "ts": now_ts()})
self.log("READY: operator present (press after %.1f s); the bench takes the presses", pressed)
return
ans = self.prompt(text, ("Ready", "Cannot")) ans = self.prompt(text, ("Ready", "Cannot"))
if ans != "Ready": if ans != "Ready":
raise Failed("operator could not: %s" % text) raise Failed("operator could not: %s" % text)
@@ -332,6 +371,15 @@ class Context:
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
rec = {"channel": channel, "state": state, "by": "operator", "ts": now_ts()} rec = {"channel": channel, "state": state, "by": "operator", "ts": now_ts()}
self.evidence.setdefault("actions", []).append(rec) self.evidence.setdefault("actions", []).append(rec)
# The test began with the bench taking the presses. If it is gone
# now, say so loudly: falling back to the operator silently is
# what turns one dropped actuator into a press nobody can account
# for afterwards.
if self.run.fixture_takeover and not (fixture is not None and fixture.covers(channel)):
rec["fixture_lost"] = True
self.log("ACT %s %s: WARNING the bench actuator took this test's presses and is now "
"gone - asking the operator instead; a press from here on is a person's",
channel, state)
if fixture is not None and fixture.covers(channel): if fixture is not None and fixture.covers(channel):
self.log("ACT %s %s (fixture)", channel, state) self.log("ACT %s %s (fixture)", channel, state)
rec["by"] = "fixture" rec["by"] = "fixture"
@@ -377,7 +425,17 @@ class Context:
fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None fixture = getattr(self.runner, "fixture", None) if self.runner is not None else None
rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()} rec = {"channel": "button", "state": "arm", "by": "operator", "ts": now_ts()}
self.evidence.setdefault("actions", []).append(rec) self.evidence.setdefault("actions", []).append(rec)
if not (fixture is not None and fixture.arm_press and fixture.covers("button")): # A presence press at the ready gate hands this test's presses to
# the bench, whatever the config's standing opt-in says: the
# operator has already proved they are at the machine, which is
# the thing the opt-in exists to establish.
opted_in = fixture is not None and fixture.covers("button") and \
(fixture.arm_press or self.run.fixture_takeover)
if not opted_in:
if self.run.fixture_takeover:
rec["fixture_lost"] = True
self.log("ARM: WARNING the bench actuator took this test's presses and is now "
"gone - asking the operator for the arm press")
self.notice(text) self.notice(text)
return False return False
self.log("ARM: the fixture presses when the button lights (the bench's arm_press opt-in)") self.log("ARM: the fixture presses when the button lights (the bench's arm_press opt-in)")
+2 -4
View File
@@ -60,10 +60,8 @@ def _grbl_settings(lines):
pass pass
return vals return vals
ARM_CUE = ("LIVE FIRE. Eye protection on, exhaust running, fire watch and extinguisher in reach, " ARM_CUE = ("LIVE FIRE. Eye protection on. Exhaust on. Extinguisher in reach. "
"scrap under the head with room to move (%s), lid closed. On Ready the job starts: " "Put scrap under the head with %s clear, and close the lid.")
"the white button lights and the stream waits for your press of the physical arm "
"button; the machine fires only after that press.")
# The beam detector's emission signature: this much over its pre-fire # The beam detector's emission signature: this much over its pre-fire
# level while the beam is on (bench: ~1834 idle, 2600-2890 at S300/S400). # level while the beam is on (bench: ~1834 idle, 2600-2890 at S300/S400).
File diff suppressed because it is too large Load Diff