From 2172ae4101b0d48870596ee5b104c476e15ccce7 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Fri, 16 Oct 2026 14:48:32 -0400 Subject: [PATCH] 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. --- forgetest/forgetest/suite/setup_burns.py | 62 ++++++++++++++++++------ forgetest/tests/test_setup_burns.py | 28 ++++++++++- 2 files changed, 74 insertions(+), 16 deletions(-) diff --git a/forgetest/forgetest/suite/setup_burns.py b/forgetest/forgetest/suite/setup_burns.py index cc5fd0f..e279c4f 100644 --- a/forgetest/forgetest/suite/setup_burns.py +++ b/forgetest/forgetest/suite/setup_burns.py @@ -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 # 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 at X0 Y0, within -# HOME_TOL_MM, when a manual home references X and Y. +# /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. HOME = "The head is home" HOME_TOL_MM = 0.05 +HOME_OFFSET_KEYS = ("manual_home_x", "manual_home_y") EDGE_KEY = "lens_hall_edge_z_mm" 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"] @@ -83,15 +85,36 @@ 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) + 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): """The head's place on record before the wizard starts, read from /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. Judged at - X0 Y0 within HOME_TOL_MM when X and Y are referenced by a manual - home, the one provider that declares X0 Y0 at the gantry stops. - Unreferenced, the head's place rests on the operator's word at the - ready gate, and the record says so.""" + 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.""" fc = ctx.forgectrl read = {} @@ -108,18 +131,27 @@ def head_at_home(ctx): axes = read.get("homed_axes") source = read.get("home_source") referenced = isinstance(axes, int) and (axes & 3) == 3 and source == "manual" - ctx.evidence["head_at_start"] = {"pos": pos, "homed_axes": axes, "home_source": source, - "judged": referenced} + rec = ctx.evidence["head_at_start"] = {"pos": pos, "homed_axes": axes, "home_source": source, + "judged": referenced} if not referenced: 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} x, y = pos.get("x"), pos.get("y") 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 " - "and Y referenced by a manual home; home it there before the burn", pos) - ctx.log("the head is home on record: pos %s, X and Y referenced by a manual home", 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, the stops at X%.2f Y%.2f", + pos, stops["x"], stops["y"]) 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 " "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 at X0 Y0 when a manual home " - "references X and Y, else left to the operator's word at the ready gate. The " + "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 " "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 " diff --git a/forgetest/tests/test_setup_burns.py b/forgetest/tests/test_setup_burns.py index 3dfdc2a..d4d69ea 100644 --- a/forgetest/tests/test_setup_burns.py +++ b/forgetest/tests/test_setup_burns.py @@ -336,10 +336,10 @@ class BurnRunTests(unittest.TestCase): self.assertNotIn("lens_hall_edge_z_mm", start) self.assertEqual((start["lens_stop_below_steps"], start["lens_stop_above_steps"]), ("14", "20")) 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"], {"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 seen = run.evidence["emission_seen"] 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.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): # A fresh controller's counters-only position places nothing: the # read is recorded, not judged, and the burn runs.