setup.focus: the head's place judged against the stops its manual home declared, read from the home anchor

head_at_home placed the gantry stops at minus manual_home_x and
manual_home_y as the settings read at the burn. A manual home reads
them once: it declares the head at the stops at minus the offsets, and
with an offset it moves the head to X0 Y0, that far in front of them. A
write of the offsets after the home restarts no controller and keeps
the X/Y reference. So a home made with 5 and 3, the offsets then
cleared, had the head at X0 Y0 judged home 5 and 3 mm in front of the
stops, and the burn started: the false PASS 2172ae4 set out to close.
The other way round, offsets set after a home made with none failed a
head that was at the stops. The home anchor holds the stops as the home
declared them: /status reports the anchor's X and Y plus the step
counters.

head_at_home now takes the stops from the X and Y of the anchor
(baseline.ANCHOR_PATH) when /status reports X and Y referenced by a
manual home, and judges the head against them within HOME_TOL_MM. An
anchor that is gone, or that is not a manual home of X and Y, fails the
test before any start. The settings are read for the remedy alone. With
an offset, the remedy names how the push ends: release the motors, push
the head against the stops, and end the release with Energize motors
($ME), not with Set home here, which moves the head off the stops to X0
Y0 again. The remedy before said only to release the motors and push
the head, and a release left standing makes the burn ask home-push,
which the test does not answer. Without an offset it stays "home it
there". The judge only refuses more: no energy or motion path changes.

Proof: tests/test_setup_burns.py, the Unit tests step of forgetest-ci.yml
(python -B -m unittest discover -s tests), 24 tests OK on Windows with
python 3.14 and on Ubuntu 24.04 with python 3.12. BurnRunTests plants
the anchor of a home with no offset beside the fake's /status, and
test_a_manual_home_offset_puts_the_stops_off_x0_y0 plants the anchor of
each home: a home made with 5 and 3 fails the head at X0 Y0 and passes
it at X-5 Y-3; the offsets cleared after that home still fail the head
at X0 Y0 before any start (no wizard start, no lever); the offsets set
after a home made with none pass the head at X0 Y0; an anchor that is
gone, or that reads as the startup's Z-only reference, fails before any
start. That last pair replaces the leg where an offset that did not
read as a number failed: the stops no longer come from the settings,
and the rule that leg held (no stops placed, nothing starts) holds on
the anchor. test_setup_dark.py (21) and test_manifest.py (39, with the
guard on the suite's package imports, which setup_burns.py's import of
baseline.py passes) pass. Negative controls on scratch copies:
head_at_home as it was fails the case at the offsets cleared after the
home (Failed not raised), and so do the stops read from the settings;
an anchor that cannot be read taken as X0 Y0 fails it at the anchor
that is gone; an anchor of any axes and any source fails it at the
startup anchor. A probe of the failure text: with the offsets 5 and 3
it names Energize motors ($ME), not Set home here; with none it says
"home it there". pyflakes reports no undefined name.

Acceptance: setup.focus's fingerprint moves (setup_burns.py's shared
text and the test's own description); the other 42 do not move
(source_sha compared before and after). baseline.py is a shared judge
already, in every hash. Against the campaign this series already
needs, that adds no run: 850498f moved all 43 once.
This commit is contained in:
ScottW514
2026-10-16 23:28:00 -04:00
parent ce73dbb052
commit 36c1b75bb8
2 changed files with 102 additions and 46 deletions
+58 -37
View File
@@ -22,6 +22,7 @@ the real numbers go in.
import re
import time
from .. import baseline
from .. import hw
from ..catalog import test
from .setup_dark import run_check, Restore, FALLBACK_WINDOW, LENS_WINDOW_KEYS, dark
@@ -44,9 +45,10 @@ BURN_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
# Every burn asks first that the gantry stops are installed and the head
# is at its home against them; the bench's head stands there, and every
# burn's tail brings it back. head_at_home reads the head's place from
# /status before the wizard starts and judges it against the stops, within
# HOME_TOL_MM, when a manual home references X and Y: at X0 Y0 with no
# manual home offset, at minus manual_home_x and manual_home_y with one.
# /status before the wizard starts and judges it, within HOME_TOL_MM,
# against the stops as the manual home that references X and Y declared
# them (its home anchor): X0 Y0 for a home with no manual home offset,
# minus manual_home_x and manual_home_y for a home made with them.
HOME = "The head is home"
HOME_TOL_MM = 0.05
HOME_OFFSET_KEYS = ("manual_home_x", "manual_home_y")
@@ -85,22 +87,44 @@ class Witnesses:
return {"laser_on_max": self.laser_on_max, "hv_max": self.hv_max, "reads": self.reads}
def home_offsets(ctx):
"""manual_home_x and manual_home_y as the settings hold them, in mm:
how far in front of the gantry stops a manual home puts X0 Y0 (blank
is 0). A value that does not read as a number of 0 or more fails the
test: the stops cannot be placed without it."""
s = ctx.forgectrl.settings()
out = []
for key in HOME_OFFSET_KEYS:
raw = s.get(key)
def anchor_stops():
"""Where the manual home that references X and Y declared the gantry
stops, in /status coordinates: the X and Y of the controller's home
anchor (baseline.ANCHOR_PATH), to which /status adds the step
counters. None when the anchor does not read as a manual home of X
and Y. The settings do not place the stops: a manual home reads
manual_home_x and manual_home_y once, and a change to them after the
home leaves its reference as it is."""
try:
v = float(raw) if raw not in (None, "") else 0.0
except (TypeError, ValueError):
v = None
ctx.check(v is not None and v >= 0, "%s reads %r: the gantry stops cannot be placed from it", key, raw)
out.append(v)
return tuple(out)
with open(baseline.ANCHOR_PATH, encoding="ascii", errors="replace") as f:
fields = f.read().split()
x, y, axes, source = float(fields[0]), float(fields[1]), int(fields[3]), fields[4]
except (OSError, ValueError, IndexError):
return None
if (axes & 3) != 3 or source != "manual":
return None
return x, y
def home_offset_remedy(ctx):
"""What brings the head to its home against the stops, from what the
next manual home reads: with no manual home offset, a home there;
with one, Set home here moves the head off the stops to X0 Y0, so the
head is pushed there with the motors released, and the release ends
with Energize motors."""
s = ctx.forgectrl.settings() or {}
raw = [str(s.get(key) or "").strip() for key in HOME_OFFSET_KEYS]
def no_offset(v):
try:
return v == "" or float(v) == 0
except ValueError:
return False
if no_offset(raw[0]) and no_offset(raw[1]):
return "home it there before the burn"
return ("with manual_home_x %s and manual_home_y %s, Set home here moves the head off the stops to X0 Y0; "
"release the motors, push the head against the stops, and end the release with Energize motors "
"($ME), not with Set home here, before the burn" % (raw[0] or "blank", raw[1] or "blank"))
def head_at_home(ctx):
@@ -108,13 +132,13 @@ def head_at_home(ctx):
/status under the campaign's own controller: the wizard restarts the
controller before its home prompt, and a fresh controller drops the
X/Y reference, so a read at that prompt places nothing. When X and Y
are referenced by a manual home, the head is judged against the
gantry stops, where the wizard draws from, within HOME_TOL_MM: a
manual home declares the head at the stops at minus manual_home_x
and manual_home_y, X0 Y0 with no offset, and with an offset it moves
the head to X0 Y0, that far in front of them. Unreferenced, the
head's place rests on the operator's word at the ready gate, and the
record says so."""
are referenced by a manual home, the head is judged within
HOME_TOL_MM against the gantry stops, where the wizard draws from,
as that home declared them (anchor_stops): X0 Y0 for a home with no
offset, and minus manual_home_x and manual_home_y for a home made
with them, which moves the head to X0 Y0, that far in front of the
stops. Unreferenced, the head's place rests on the operator's word
at the ready gate, and the record says so."""
fc = ctx.forgectrl
read = {}
@@ -137,19 +161,16 @@ def head_at_home(ctx):
ctx.log("the head's place rests on the operator's word at the ready gate (X and Y unreferenced: "
"homed_axes %s, home_source %s, pos %s)", axes, source, pos)
return
ox, oy = home_offsets(ctx)
stops = rec["stops"] = {"x": 0.0 - ox, "y": 0.0 - oy}
found = anchor_stops()
ctx.check(found is not None, "the home anchor (%s) does not read as the manual home /status reports: the "
"gantry stops cannot be placed before the burn", baseline.ANCHOR_PATH)
stops = rec["stops"] = {"x": found[0], "y": found[1]}
x, y = pos.get("x"), pos.get("y")
at_home = (isinstance(x, (int, float)) and isinstance(y, (int, float))
and abs(x - stops["x"]) <= HOME_TOL_MM and abs(y - stops["y"]) <= HOME_TOL_MM)
if ox or oy:
remedy = ("manual_home_x %g and manual_home_y %g put the stops at X%.2f Y%.2f, and a manual home "
"leaves the head at X0 Y0, that far in front of them; release the motors and push the "
"head against the stops before the burn" % (ox, oy, stops["x"], stops["y"]))
else:
remedy = "home it there before the burn"
ctx.check(at_home, "the head is not at its home against the gantry stops: /status reads pos %s with X "
"and Y referenced by a manual home; %s", pos, remedy)
"and Y referenced by a manual home that declared the stops at X%.2f Y%.2f; %s", pos, stops["x"],
stops["y"], "" if at_home else home_offset_remedy(ctx))
ctx.log("the head is home on record: pos %s, X and Y referenced by a manual home, the stops at X%.2f Y%.2f",
pos, stops["x"], stops["y"])
@@ -346,9 +367,9 @@ def focus_window_ok(ctx, r, before):
"burn writes."],
description="The focus burn as a fresh head runs it. Before anything starts, the head's "
"place is read from /status and recorded: judged against the gantry stops when "
"a manual home references X and Y (X0 Y0, or minus manual_home_x and "
"manual_home_y when they are set), else left to the operator's word at the "
"ready gate. The "
"a manual home references X and Y (where that home declared them: X0 Y0, or "
"minus the manual_home_x and manual_home_y it read), else left to the "
"operator's word at the ready gate. The "
"edge setting is cleared, so the burn starts from the bench reference's edge, "
"while the lens window stays the one the Lens travel check wrote (or the "
"fallback). POST "
+44 -9
View File
@@ -21,7 +21,7 @@ import unittest
sys.path.insert(0, os.path.dirname(__file__))
import helpers # noqa: E402
from forgetest import catalog # noqa: E402
from forgetest import baseline, catalog # noqa: E402
from forgetest.runner import Aborted, Context, Failed, Run # noqa: E402
from forgetest.suite import setup_burns, setup_dark # noqa: E402
@@ -291,14 +291,19 @@ class BurnRunTests(unittest.TestCase):
os.environ["GF_SYSFS_ROOT"] = self.sysfs + "/"
self.fake = helpers.FakeForgectrl().start()
# the head at its home against the gantry stops, X and Y referenced
# by a manual home (the fake's pos is X0 Y0 Z0)
# by a manual home with no offset (the fake's pos is X0 Y0 Z0), its
# anchor declaring the stops at X0 Y0
self.fake.state["status"].update({"homed": True, "homed_axes": 7, "home_source": "manual"})
self.saved_anchor = baseline.ANCHOR_PATH
baseline.ANCHOR_PATH = os.path.join(self.sysfs, "grblhal.homed")
self.anchor("0.000 0.000 0.000 7 manual")
self.daemon = ScriptedDaemon(self.fake)
self.poll = setup_dark.POLL_S
setup_dark.POLL_S = 0.01
def tearDown(self):
setup_dark.POLL_S = self.poll
baseline.ANCHOR_PATH = self.saved_anchor
self.fake.stop()
if self.saved_root is None:
os.environ.pop("GF_SYSFS_ROOT", None)
@@ -312,6 +317,12 @@ class BurnRunTests(unittest.TestCase):
with open(path, "w") as f:
f.write(value + "\n")
def anchor(self, line):
"""The controller's home anchor as a home writes it: X Y Z, the
axes it references, and what set it."""
with open(baseline.ANCHOR_PATH, "w") as f:
f.write(line + "\n")
def run_test(self, tid):
t = catalog.load_suite()[tid]
run = self.run = Run("test", t.id, t.title) # kept for a run that fails
@@ -451,11 +462,13 @@ class BurnRunTests(unittest.TestCase):
self.assert_found()
def test_a_manual_home_offset_puts_the_stops_off_x0_y0(self):
# manual_home_x 5 and manual_home_y 3: a manual home declares the
# stops X-5 Y-3 and leaves the head at X0 Y0, in front of them,
# where the wizard does not draw from. X0 Y0 fails before any start,
# with no controller lever touched; the head at the stops is home.
# A manual home made with manual_home_x 5 and manual_home_y 3
# declares the stops X-5 Y-3 (its anchor) and leaves the head at
# X0 Y0, in front of them, where the wizard does not draw from. X0
# Y0 fails before any start, with no controller lever touched; the
# head at the stops is home.
self.fake.state["settings"].update({"manual_home_x": "5", "manual_home_y": "3"})
self.anchor("-5.000 -3.000 0.000 7 manual")
with self.assertRaises(Failed) as cm:
self.run_test("setup.focus")
self.assertIn("not at its home", str(cm.exception))
@@ -467,12 +480,34 @@ class BurnRunTests(unittest.TestCase):
run = self.run_test("setup.focus")
self.assertTrue(run.evidence["head_at_start"]["judged"])
self.assertEqual(self.daemon.order, ["laser.focus"])
# an offset that does not read as a number places no stops: nothing starts
self.fake.state["settings"]["manual_home_x"] = "five"
# The offsets cleared after that home leave its reference as it is:
# the stops are still X-5 Y-3, and the head at X0 Y0 still fails
# before any start.
for k in ("manual_home_x", "manual_home_y"):
self.fake.state["settings"].pop(k)
self.fake.state["status"]["pos"] = {"x": 0.0, "y": 0.0, "z": 0.0}
with self.assertRaises(Failed) as cm:
self.run_test("setup.focus")
self.assertIn("manual_home_x reads 'five'", str(cm.exception))
self.assertIn("not at its home", str(cm.exception))
self.assertEqual(self.daemon.order, ["laser.focus"])
# The offsets set after a home made with none: the head at X0 Y0 is
# at the stops that home declared, and the burn runs.
self.fake.state["settings"].update({"manual_home_x": "5", "manual_home_y": "3"})
self.anchor("0.000 0.000 0.000 7 manual")
run = self.run_test("setup.focus")
self.assertEqual(run.evidence["head_at_start"]["stops"], {"x": 0.0, "y": 0.0})
self.assertEqual(self.daemon.order, ["laser.focus", "laser.focus"])
# An anchor that is gone, or that is not the manual home /status
# reports, places no stops: nothing starts.
os.unlink(baseline.ANCHOR_PATH)
with self.assertRaises(Failed) as cm:
self.run_test("setup.focus")
self.assertIn("home anchor", str(cm.exception))
self.anchor("0.000 0.000 0.000 4 startup")
with self.assertRaises(Failed) as cm:
self.run_test("setup.focus")
self.assertIn("home anchor", str(cm.exception))
self.assertEqual(self.daemon.order, ["laser.focus", "laser.focus"])
def test_an_unreferenced_head_runs_on_the_operators_word(self):
# A fresh controller's counters-only position places nothing: the