setup.focus: the head's place judged against the gantry stops, at minus the manual home offsets when they are set

head_at_home judged a head referenced by a manual home at X0 Y0, on
the premise that a manual home declares X0 Y0 at the gantry stops. That
holds only while manual_home_x and manual_home_y are blank or 0. With an
offset, the manual provider declares the head at the stops at minus the
offsets and then jogs it to X0 Y0, that far in front of them
(glowforge_homing.c manual_home; the Homing pages of the docs), and
/status reads pos X0 Y0 there. The wizard draws every card from the
head's home against the stops (wizlive.c head_home; Setup, "The head at
home"), and the test answers HOME to that prompt. So with an offset the
judge recorded judged True and "the head is home on record" for a head
the offsets in front of the stops, the burn ran from there, and its box
landed that much farther from the stops. The failure message (home it
there) rebuilt the same state.

The sources put the home of the burn at the stops, and the stops at
minus the offsets in the coordinates /status reports, so the judge now
judges the head there. head_at_home reads the two offsets from
/settings (blank is 0; a value that is not a number of 0 or more fails
the test before any start), records the stops it judges against in
head_at_start, and judges pos against them within HOME_TOL_MM. With no
offset that is X0 Y0, the rule as it was. With an offset, a head that a
manual home left at X0 Y0 fails before any wizard start, with no
controller lever touched, and the message names the offsets, where
they put the stops, and the remedy: release the motors and push the
head against the stops (the head's place then rests on the operator's
word, as for any unreferenced head, once the release ends with
Energize motors). Judging at X0 Y0 under an offset
would affirm a place the wizard does not ask for, and recording the
read without judging it would let the test answer HOME with a read in
hand that puts the head away from the stops; neither is taken. The
offsets are read as they stand at the burn: /status does not report
the offsets the home used, and the home anchor, which holds the stops
that home declared, is not read. The description and the comments say
what is judged.

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:
test_a_manual_home_offset_puts_the_stops_off_x0_y0 (manual_home_x 5 and
manual_home_y 3, homed_axes 7, home_source manual: at X0 Y0 the test
fails before any start, the stops on record at X-5 Y-3, no /controller/
lever posted, the settings as found; at X-5 Y-3 the burn runs, judged
True; manual_home_x "five" fails before any start), and the lit burn's
record carries the stops at X0 Y0. Negative controls on scratch copies:
setup_burns.py as it was, and the offsets taken as 0, each fail the new
case at its first run (the burn starts). pyflakes reports no undefined
name.

Acceptance: setup.focus's fingerprint moves (its own text and
setup_burns.py's shared text); no other test moves (source_sha compared
before and after). 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 14:48:32 -04:00
parent 9451587728
commit 2172ae4101
2 changed files with 74 additions and 16 deletions
+47 -13
View File
@@ -44,10 +44,12 @@ BURN_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
# Every burn asks first that the gantry stops are installed and the head # 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 # 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 # burn's tail brings it back. head_at_home reads the head's place from
# /status before the wizard starts and judges it at X0 Y0, within # /status before the wizard starts and judges it against the stops, within
# HOME_TOL_MM, when a manual home references X and Y. # 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.
HOME = "The head is home" HOME = "The head is home"
HOME_TOL_MM = 0.05 HOME_TOL_MM = 0.05
HOME_OFFSET_KEYS = ("manual_home_x", "manual_home_y")
EDGE_KEY = "lens_hall_edge_z_mm" EDGE_KEY = "lens_hall_edge_z_mm"
FOCUS_KEYS = ["thickness_mm", "z_passes", "ladder_z", "window", "emission", "pick", "picked_lines", FOCUS_KEYS = ["thickness_mm", "z_passes", "ladder_z", "window", "emission", "pick", "picked_lines",
"pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm", "focus_range_mm", "summary"] "pick_half_steps", "edge_z_mm", "steps_per_mm", "max_height_mm", "focus_range_mm", "summary"]
@@ -83,15 +85,36 @@ class Witnesses:
return {"laser_on_max": self.laser_on_max, "hv_max": self.hv_max, "reads": self.reads} 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)
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)
def head_at_home(ctx): def head_at_home(ctx):
"""The head's place on record before the wizard starts, read from """The head's place on record before the wizard starts, read from
/status under the campaign's own controller: the wizard restarts the /status under the campaign's own controller: the wizard restarts the
controller before its home prompt, and a fresh controller drops the controller before its home prompt, and a fresh controller drops the
X/Y reference, so a read at that prompt places nothing. Judged at X/Y reference, so a read at that prompt places nothing. When X and Y
X0 Y0 within HOME_TOL_MM when X and Y are referenced by a manual are referenced by a manual home, the head is judged against the
home, the one provider that declares X0 Y0 at the gantry stops. gantry stops, where the wizard draws from, within HOME_TOL_MM: a
Unreferenced, the head's place rests on the operator's word at the manual home declares the head at the stops at minus manual_home_x
ready gate, and the record says so.""" 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."""
fc = ctx.forgectrl fc = ctx.forgectrl
read = {} read = {}
@@ -108,18 +131,27 @@ def head_at_home(ctx):
axes = read.get("homed_axes") axes = read.get("homed_axes")
source = read.get("home_source") source = read.get("home_source")
referenced = isinstance(axes, int) and (axes & 3) == 3 and source == "manual" referenced = isinstance(axes, int) and (axes & 3) == 3 and source == "manual"
ctx.evidence["head_at_start"] = {"pos": pos, "homed_axes": axes, "home_source": source, rec = ctx.evidence["head_at_start"] = {"pos": pos, "homed_axes": axes, "home_source": source,
"judged": referenced} "judged": referenced}
if not referenced: if not referenced:
ctx.log("the head's place rests on the operator's word at the ready gate (X and Y unreferenced: " 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) "homed_axes %s, home_source %s, pos %s)", axes, source, pos)
return return
ox, oy = home_offsets(ctx)
stops = rec["stops"] = {"x": 0.0 - ox, "y": 0.0 - oy}
x, y = pos.get("x"), pos.get("y") x, y = pos.get("x"), pos.get("y")
at_home = (isinstance(x, (int, float)) and isinstance(y, (int, float)) at_home = (isinstance(x, (int, float)) and isinstance(y, (int, float))
and abs(x) <= HOME_TOL_MM and abs(y) <= HOME_TOL_MM) 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 " 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; home it there before the burn", pos) "and Y referenced by a manual home; %s", pos, remedy)
ctx.log("the head is home on record: pos %s, X and Y referenced by a manual home", pos) 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"])
def preview_ok(ctx, wid): def preview_ok(ctx, wid):
@@ -313,8 +345,10 @@ def focus_window_ok(ctx, r, before):
"Nothing else: the test answers every prompt itself and puts back the setting the " "Nothing else: the test answers every prompt itself and puts back the setting the "
"burn writes."], "burn writes."],
description="The focus burn as a fresh head runs it. Before anything starts, the head's " description="The focus burn as a fresh head runs it. Before anything starts, the head's "
"place is read from /status and recorded: judged at X0 Y0 when a manual home " "place is read from /status and recorded: judged against the gantry stops when "
"references X and Y, else left to the operator's word at the ready gate. The " "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 "
"edge setting is cleared, so the burn starts from the bench reference's edge, " "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 " "while the lens window stays the one the Lens travel check wrote (or the "
"fallback). POST " "fallback). POST "
+26 -2
View File
@@ -336,10 +336,10 @@ class BurnRunTests(unittest.TestCase):
self.assertNotIn("lens_hall_edge_z_mm", start) self.assertNotIn("lens_hall_edge_z_mm", start)
self.assertEqual((start["lens_stop_below_steps"], start["lens_stop_above_steps"]), ("14", "20")) self.assertEqual((start["lens_stop_below_steps"], start["lens_stop_above_steps"]), ("14", "20"))
self.assertEqual(run.evidence["result"]["emission"]["hv_max"], 610) self.assertEqual(run.evidence["result"]["emission"]["hv_max"], 610)
# the head's place on record before the start, judged at X0 Y0 # the head's place on record before the start, judged at the stops: X0 Y0 with no offset
self.assertEqual(run.evidence["head_at_start"], self.assertEqual(run.evidence["head_at_start"],
{"pos": {"x": 0.0, "y": 0.0, "z": 0.0}, "homed_axes": 7, "home_source": "manual", {"pos": {"x": 0.0, "y": 0.0, "z": 0.0}, "homed_axes": 7, "home_source": "manual",
"judged": True}) "judged": True, "stops": {"x": 0.0, "y": 0.0}})
# the witnesses read from here at every poll: each at its peak # the witnesses read from here at every poll: each at its peak
seen = run.evidence["emission_seen"] seen = run.evidence["emission_seen"]
self.assertEqual((seen["laser_on_max"], seen["hv_max"]), (118, 431)) self.assertEqual((seen["laser_on_max"], seen["hv_max"]), (118, 431))
@@ -450,6 +450,30 @@ class BurnRunTests(unittest.TestCase):
self.assertEqual(self.daemon.lever_posts(), []) self.assertEqual(self.daemon.lever_posts(), [])
self.assert_found() 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.
self.fake.state["settings"].update({"manual_home_x": "5", "manual_home_y": "3"})
with self.assertRaises(Failed) as cm:
self.run_test("setup.focus")
self.assertIn("not at its home", str(cm.exception))
self.assertEqual(self.run.evidence["head_at_start"]["stops"], {"x": -5.0, "y": -3.0})
self.assertEqual(self.daemon.order, [])
self.assertEqual(self.daemon.lever_posts(), [])
self.assert_found()
self.fake.state["status"]["pos"] = {"x": -5.0, "y": -3.0, "z": 0.0}
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"
with self.assertRaises(Failed) as cm:
self.run_test("setup.focus")
self.assertIn("manual_home_x reads 'five'", str(cm.exception))
self.assertEqual(self.daemon.order, ["laser.focus"])
def test_an_unreferenced_head_runs_on_the_operators_word(self): def test_an_unreferenced_head_runs_on_the_operators_word(self):
# A fresh controller's counters-only position places nothing: the # A fresh controller's counters-only position places nothing: the
# read is recorded, not judged, and the burn runs. # read is recorded, not judged, and the burn runs.