mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-28 09:11:11 -07:00
Run the commissioning sheet as a fresh machine, and pin forgectrl 0.1.18 (the lens frame)
forgectrl f8ddb17 puts the lens frame in one place and writes the focus window before the card's controller starts, after two commissioning cards ended in ALARM:2 on a Z the wizard sent from one source while the controller's Z limit stood on another. The acceptance run had passed only because the bench's settings already held the stops from an earlier focus run, so the run is now a fresh machine's. commission.sheet clears the three lens settings inside its Restore before the cards, checks that the frame runs in the fallback window, and, after the focus card, that the settings hold the window the ladder ran in (the stops found or the fallback), that every ladder height lies in that window's reach, and that the program served now agrees with /status. Every served program's Z is checked against the reach /status reports before the card starts, so a stray Z fails the test with nothing burned. The test covers src/lens.*. The host mock in tests/test_commission_sheet.py mirrors the daemon (the /status lens block from the settings, the ladder served from the settings, the window written at the focus start); two regression tests reproduce the defects: a focus result naming a window the settings do not hold, and a served program with a Z beyond the reach. 10/10 green. scripts/bench/z_envelope_test.py, the grblHAL CI harness, adds the referenced-lens cases: with forgectrl's marker and the shared settings, the fallback window and a 14/20 window run to the ends of their reach and two half-steps past either end alarms, and a count of 41 falls back on its side alone. Passed on the null-sink build. The bench page's description of the harness follows. The forgectrl pin moves to f8ddb17 (0.1.18); every test covering forgectrl re-runs.
This commit is contained in:
@@ -247,8 +247,11 @@ TOOLS = [
|
|||||||
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
|
{"id": "z-envelope-test", "title": "Z envelope harness", "script": "z_envelope_test.py",
|
||||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||||
"desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the "
|
"desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the "
|
||||||
"lens stands, and neither a $20 nor a $132 write frees it. A CI harness (the grblHAL repo): needs "
|
"lens stands, and neither a $20 nor a $132 write frees it. A referenced lens (forgectrl's marker "
|
||||||
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
"and the lens settings) opens it to the window the settings hold, the fallback or the focus "
|
||||||
|
"card's stops: the ends of the reach run, two half-steps past either end alarms. A CI harness "
|
||||||
|
"(the grblHAL repo): needs the host-built null-sink controller, not the machine, so it is not a "
|
||||||
|
"bench-page tool."},
|
||||||
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
|
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
|
||||||
"safety": "dry", "where": "board", "ported": True,
|
"safety": "dry", "where": "board", "ported": True,
|
||||||
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
|
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from ..catalog import test
|
|||||||
from .commission_dark import run_check, Restore
|
from .commission_dark import run_check, Restore
|
||||||
|
|
||||||
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
SHEET_COVERS = [("forgectrl", "src/wizlive.*"), ("forgectrl", "src/wizrun.h"),
|
||||||
("forgectrl", "src/sheet.*"),
|
("forgectrl", "src/lens.*"), ("forgectrl", "src/sheet.*"),
|
||||||
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
("forgectrl", "src/font_hershey.*"), ("forgectrl", "src/jobstream.*"),
|
||||||
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
("forgectrl", "src/curverec.*"), ("forgectrl", "src/wizdark.*"),
|
||||||
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
("forgectrl", "src/wiz.*"), ("forgectrl", "src/commission.*"),
|
||||||
@@ -71,6 +71,22 @@ def preview_ok(ctx, wid):
|
|||||||
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
ctx.check(st == 200 and re.search(rb"^M[34] S\d", body or b"", re.M) is not None,
|
||||||
"no program for %s (%s)", wid, st)
|
"no program for %s (%s)", wid, st)
|
||||||
ctx.evidence["program_lines"] = body.count(b"\n")
|
ctx.evidence["program_lines"] = body.count(b"\n")
|
||||||
|
# Every Z the program commands is a height the head reaches in the
|
||||||
|
# window the machine holds now: the focus card's own numbers once it
|
||||||
|
# has them, and the conservative fallback on a head whose travel is
|
||||||
|
# not known yet. The controller refuses a Z outside it with ALARM:2,
|
||||||
|
# and it refuses it while the body is still burning, so the card dies
|
||||||
|
# where the parser is and not where the bad line is.
|
||||||
|
lens = (fc.status() or {}).get("lens") or {}
|
||||||
|
lo, hi = lens.get("reach_min"), lens.get("reach_max")
|
||||||
|
zs = [float(m) for m in re.findall(rb"^G0 Z(-?[0-9.]+)", body or b"", re.M)]
|
||||||
|
ctx.evidence.setdefault("program_z", {})[wid] = {
|
||||||
|
"z": zs, "reach": [lo, hi], "stops_found": lens.get("stops_found")}
|
||||||
|
ctx.check(lo is not None and hi is not None, "/status carries no lens reach: %s", lens)
|
||||||
|
if lo is not None and hi is not None:
|
||||||
|
out = [z for z in zs if z < lo - 0.01 or z > hi + 0.01]
|
||||||
|
ctx.check(not out, "%s commands Z outside the lens window %.2f..%.2f: %s",
|
||||||
|
wid, lo, hi, out)
|
||||||
|
|
||||||
|
|
||||||
def emission_ok(ctx, last):
|
def emission_ok(ctx, last):
|
||||||
@@ -89,6 +105,61 @@ def file_card(ctx, wid):
|
|||||||
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
ev.setdefault("cards", {})[wid] = {k: ev.pop(k) for k in CARD_EVIDENCE if k in ev}
|
||||||
|
|
||||||
|
|
||||||
|
# The lens settings: the head's numbers, which a fresh machine does not
|
||||||
|
# have. Cleared before the cards so the run is a fresh machine's.
|
||||||
|
LENS_SETTINGS = CARD_SETTINGS[:3]
|
||||||
|
FALLBACK_WINDOW = (10, 12)
|
||||||
|
|
||||||
|
|
||||||
|
def fresh_lens(ctx):
|
||||||
|
"""The lens as a fresh machine has it: the head's numbers unknown, so
|
||||||
|
the frame runs in the fallback window and the focus card has to find
|
||||||
|
the stops and write them before its ladder burns. The machine's own
|
||||||
|
numbers come back with the restore at the end of the run."""
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
for k in LENS_SETTINGS:
|
||||||
|
st, _ = fc.post("/settings", params={k: ""})
|
||||||
|
ctx.check(st == 200, "clearing %s -> %s", k, st)
|
||||||
|
lens = (fc.status() or {}).get("lens") or {}
|
||||||
|
ctx.evidence["lens_fresh"] = lens
|
||||||
|
ctx.check(lens.get("stops_found") is False and (lens.get("below"), lens.get("above")) == FALLBACK_WINDOW,
|
||||||
|
"the lens window is not the fallback after the clear: %s", lens)
|
||||||
|
|
||||||
|
|
||||||
|
def focus_window_ok(ctx):
|
||||||
|
"""The window the focus card burned its ladder in is the one it wrote
|
||||||
|
before its controller started, and the one the machine holds now: the
|
||||||
|
result's window is the stops it found (or the fallback), the settings
|
||||||
|
hold that window, every ladder height lies in its reach, and the
|
||||||
|
program served now agrees with /status."""
|
||||||
|
fc = ctx.forgectrl
|
||||||
|
r = ((ctx.evidence.get("cards") or {}).get("laser.focus") or {}).get("result") or {}
|
||||||
|
w, stops = r.get("window") or {}, r.get("stops") or {}
|
||||||
|
s = fc.settings() or {}
|
||||||
|
held = (str(s.get("lens_stop_below_steps")), str(s.get("lens_stop_above_steps")))
|
||||||
|
ctx.check(held == (str(w.get("below")), str(w.get("above"))),
|
||||||
|
"the settings hold the window %s, the ladder ran in %s/%s", held, w.get("below"), w.get("above"))
|
||||||
|
if stops.get("found"):
|
||||||
|
ctx.check((w.get("below"), w.get("above")) == (stops.get("below"), stops.get("above")),
|
||||||
|
"the ladder's window %s is not the stops found %s", w, stops)
|
||||||
|
else:
|
||||||
|
ctx.check((w.get("below"), w.get("above")) == FALLBACK_WINDOW,
|
||||||
|
"stops not found (%s), but the ladder's window is %s", stops.get("why"), w)
|
||||||
|
zs = r.get("ladder_z") or []
|
||||||
|
lo, hi = w.get("reach_min"), w.get("reach_max")
|
||||||
|
out = [z for z in zs if lo is None or hi is None or z < lo - 0.01 or z > hi + 0.01]
|
||||||
|
ctx.check(len(zs) == 12 and not out, "ladder heights outside the reach %s..%s: %s", lo, hi, out or zs)
|
||||||
|
ev = ctx.evidence
|
||||||
|
before = dict(ev.get("program_z") or {}).get("laser.focus")
|
||||||
|
preview_ok(ctx, "laser.focus")
|
||||||
|
ev["focus_window"] = {"window": w, "stops": stops, "ladder_z": zs,
|
||||||
|
"settings": {k: s.get(k) for k in LENS_SETTINGS},
|
||||||
|
"program_z_before": before,
|
||||||
|
"program_z_after": (ev.get("program_z") or {}).get("laser.focus"),
|
||||||
|
"preview_bytes": ev.pop("preview_bytes", None),
|
||||||
|
"program_lines": ev.pop("program_lines", None)}
|
||||||
|
|
||||||
|
|
||||||
def thickness_answer(ctx):
|
def thickness_answer(ctx):
|
||||||
"""The thickness the placement is answered with, in the machine's
|
"""The thickness the placement is answered with, in the machine's
|
||||||
units (the number prompts read ui_units), and the millimeters the
|
units (the number prompts read ui_units), and the millimeters the
|
||||||
@@ -167,15 +238,22 @@ def burn(ctx, wid, answers, want):
|
|||||||
"Nothing else: the test answers every prompt itself and puts back every setting the "
|
"Nothing else: the test answers every prompt itself and puts back every setting the "
|
||||||
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
|
"cards write. It runs about half an hour, mostly the coolant settle and the dark "
|
||||||
"tails."],
|
"tails."],
|
||||||
description="One run over the sheet's seven wizards on one piece of wood. "
|
description="One run over the sheet's seven wizards on one piece of wood, as a fresh "
|
||||||
|
"machine runs them: the three lens settings are cleared first, so the frame "
|
||||||
|
"runs in the fallback window and the focus card has to find the stops and write "
|
||||||
|
"them before its ladder burns. "
|
||||||
"POST /wiz/sheet.place/start references the lens, takes the controller in "
|
"POST /wiz/sheet.place/start references the lens, takes the controller in "
|
||||||
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
|
"loopback posture, jogs +X 10 and -X 10, and sets the origin at the head "
|
||||||
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
|
"(Full sheet; the thickness answered in the machine's units, 3.2 mm or "
|
||||||
"0.125 in). Then, each after the press and each with its preview "
|
"0.125 in). Then, each after the press and each with its preview "
|
||||||
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode: the frame "
|
"and program served by GET /wiz/sheet.svg and /wiz/sheet.gcode, every Z the "
|
||||||
|
"program commands inside the reach /status reports: the frame "
|
||||||
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
|
"(180 x 130 mm) and the header band at the mark dose; the focus card (the "
|
||||||
"lens homed on its bottom stop, the hall edge counted, twelve lines down the "
|
"lens on its hall edge, the stops found by the head accelerometer and written "
|
||||||
"travel; the pick is line 11, the thickness kept); the floor card (twelve "
|
"as the window before the controller starts, twelve lines from the top of the "
|
||||||
|
"window to the bottom; the pick is line 11, the thickness kept; after it the "
|
||||||
|
"settings, the result's window, and the served program must hold the window "
|
||||||
|
"the ladder ran in); the floor card (twelve "
|
||||||
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
|
"rungs from 2 to 24 percent with the floor and the curve off for the job; the "
|
||||||
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
|
"pick is 8, the floor written is 10); the dose card (seven rungs sampled at "
|
||||||
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
|
"25 Hz, the curve fit on 20 s of dark); the corner card (five gammas reloaded "
|
||||||
@@ -191,6 +269,9 @@ def sheet(ctx):
|
|||||||
"each. The bench presses when the button lights.")
|
"each. The bench presses when the button lights.")
|
||||||
place(ctx)
|
place(ctx)
|
||||||
with Restore(ctx, CARD_SETTINGS):
|
with Restore(ctx, CARD_SETTINGS):
|
||||||
|
fresh_lens(ctx)
|
||||||
for wid, answers, want in CARDS:
|
for wid, answers, want in CARDS:
|
||||||
burn(ctx, wid, answers, want)
|
burn(ctx, wid, answers, want)
|
||||||
ctx.log("PASS: the sheet's seven wizards ran on one piece; settings restored")
|
if wid == "laser.focus":
|
||||||
|
focus_window_ok(ctx)
|
||||||
|
ctx.log("PASS: the sheet's seven wizards ran on one piece as a fresh machine's; settings restored")
|
||||||
|
|||||||
@@ -17,7 +17,44 @@ from forgetest.runner import Context, Run # noqa: E402
|
|||||||
from forgetest.suite import commission_sheet # noqa: E402
|
from forgetest.suite import commission_sheet # noqa: E402
|
||||||
|
|
||||||
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
|
LIVE = ["sheet.frame", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner", "cooling.flow-load"]
|
||||||
|
# The focus card's program is read again after it ran: the served ladder
|
||||||
|
# must then lie in the window the card wrote.
|
||||||
|
PREVIEWS = ["sheet.frame", "laser.focus", "laser.focus", "laser.floor", "laser.dose-curve", "laser.corner",
|
||||||
|
"cooling.flow-load"]
|
||||||
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
|
EMISSION = {"hv_max": 610, "laser_on_samples": 900, "thermopile_delta": 600, "lit_s": 30.0}
|
||||||
|
SPM = 2.922 # the lens screw, half-steps per mm ($102)
|
||||||
|
EDGE_DEFAULT = 3.35
|
||||||
|
|
||||||
|
|
||||||
|
def lens_block(s):
|
||||||
|
"""The /status lens block as the daemon builds it from the settings: a
|
||||||
|
stop count from 1 to 40 stands, anything else is the fallback."""
|
||||||
|
def count(key, fallback):
|
||||||
|
try:
|
||||||
|
n = float(s.get(key, ""))
|
||||||
|
except ValueError:
|
||||||
|
return fallback, False
|
||||||
|
return (int(n), True) if 1 <= n <= 40 else (fallback, False)
|
||||||
|
try:
|
||||||
|
edge = float(s.get("lens_hall_edge_z_mm", ""))
|
||||||
|
except ValueError:
|
||||||
|
edge = EDGE_DEFAULT
|
||||||
|
below, a = count("lens_stop_below_steps", 10)
|
||||||
|
above, b = count("lens_stop_above_steps", 12)
|
||||||
|
return {"edge_z": round(edge, 2), "below": below, "above": above, "stops_found": a and b,
|
||||||
|
"reach_min": round(edge - below / SPM, 2), "reach_max": round(edge + above / SPM, 2)}
|
||||||
|
|
||||||
|
|
||||||
|
def ladder_z(edge, below, above):
|
||||||
|
"""The focus ladder the daemon builds: twelve whole half-steps from
|
||||||
|
the top of the window to the bottom."""
|
||||||
|
return [round(edge + round(above - (above + below) * i / 11) / SPM, 2) for i in range(12)]
|
||||||
|
|
||||||
|
|
||||||
|
# The window the focus card finds and writes before its controller starts.
|
||||||
|
STOPS = {"lens_stop_below_steps": "14", "lens_stop_above_steps": "20"}
|
||||||
|
FOCUS_WINDOW = {"below": 14, "above": 20, "reach_min": round(EDGE_DEFAULT - 14 / SPM, 2),
|
||||||
|
"reach_max": round(EDGE_DEFAULT + 20 / SPM, 2)}
|
||||||
|
|
||||||
# Each wizard as the daemon runs it: its prompts in order (id, kind,
|
# Each wizard as the daemon runs it: its prompts in order (id, kind,
|
||||||
# options) and the result it ends with. A "jog" prompt repeats until the
|
# options) and the result it ends with. A "jog" prompt repeats until the
|
||||||
@@ -37,7 +74,9 @@ SCRIPT = {
|
|||||||
"steps_per_mm": 2.922, "max_height_mm": 13.81,
|
"steps_per_mm": 2.922, "max_height_mm": 13.81,
|
||||||
"focus_range_mm": {"min": 2.17, "max": 13.81},
|
"focus_range_mm": {"min": 2.17, "max": 13.81},
|
||||||
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
|
"stops": {"found": True, "below": 14, "above": 20, "contact_below": 16,
|
||||||
"contact_above": 22, "why": ""}, "emission": EMISSION}),
|
"contact_above": 22, "why": ""},
|
||||||
|
"window": dict(FOCUS_WINDOW), "ladder_z": ladder_z(EDGE_DEFAULT, 14, 20),
|
||||||
|
"emission": EMISSION}),
|
||||||
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
|
"laser.floor": ([("floor-arm", "continue", ["Continue"]),
|
||||||
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
|
("floor-pick", "choice", ["2", "4", "6", "8", "None"])],
|
||||||
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
|
{"faintest_density": 8.0, "floor_density": 10.0, "emission": EMISSION}),
|
||||||
@@ -50,10 +89,11 @@ SCRIPT = {
|
|||||||
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
|
{"lit_s": 30.0, "dose_raw_s": 12000, "peak_c": 0.72, "k_density": 2.14e-5,
|
||||||
"k_cw": 2.78e-5, "emission": EMISSION}),
|
"k_cw": 2.78e-5, "emission": EMISSION}),
|
||||||
}
|
}
|
||||||
# What each card writes when it ends.
|
# What a card writes when it starts (the focus card's window, before its
|
||||||
|
# controller starts) and when it ends.
|
||||||
|
START_WRITES = {"laser.focus": dict(STOPS)}
|
||||||
WRITES = {
|
WRITES = {
|
||||||
"laser.focus": {"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14",
|
"laser.focus": {"lens_hall_edge_z_mm": "6.96"},
|
||||||
"lens_stop_above_steps": "20"},
|
|
||||||
"laser.floor": {"laser_floor_density": "10"},
|
"laser.floor": {"laser_floor_density": "10"},
|
||||||
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
|
"laser.dose-curve": {"laser_dose_curve": "10:0.44,100:100"},
|
||||||
"laser.corner": {"laser_corner_gamma": "1.5"},
|
"laser.corner": {"laser_corner_gamma": "1.5"},
|
||||||
@@ -115,9 +155,11 @@ class ScriptedDaemon:
|
|||||||
self.versions = {}
|
self.versions = {}
|
||||||
self.answers = {} # wid -> [values]
|
self.answers = {} # wid -> [values]
|
||||||
self.order = [] # the wizards as started
|
self.order = [] # the wizards as started
|
||||||
|
self.settings_at_start = {} # wid -> the settings as the wizard found them
|
||||||
self.previews = []
|
self.previews = []
|
||||||
self.queue = []
|
self.queue = []
|
||||||
self.seq = 0
|
self.seq = 0
|
||||||
|
self.stray_z = None # a Z the served program commands beyond the reach, when set
|
||||||
fake.on_get = self.on_get
|
fake.on_get = self.on_get
|
||||||
fake.on_post = self.on_post
|
fake.on_post = self.on_post
|
||||||
|
|
||||||
@@ -130,6 +172,10 @@ class ScriptedDaemon:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def on_get(self, path, q):
|
def on_get(self, path, q):
|
||||||
|
s = self.fake.state["settings"]
|
||||||
|
if path == "/status":
|
||||||
|
# the lens block follows the settings, as the daemon's does
|
||||||
|
return 200, dict(self.fake.state["status"], lens=lens_block(s))
|
||||||
if path == "/wiz/dark":
|
if path == "/wiz/dark":
|
||||||
return 200, dict(self.dark)
|
return 200, dict(self.dark)
|
||||||
if path == "/wiz":
|
if path == "/wiz":
|
||||||
@@ -140,8 +186,18 @@ class ScriptedDaemon:
|
|||||||
return 404, {"error": "no such card"}
|
return 404, {"error": "no such card"}
|
||||||
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
|
return 200, b"<svg xmlns='http://www.w3.org/2000/svg'></svg>", "image/svg+xml"
|
||||||
if path == "/wiz/sheet.gcode":
|
if path == "/wiz/sheet.gcode":
|
||||||
# the frame and the text are M4 at the mark dose, as the daemon serves them
|
# the frame and the text are M4 at the mark dose, as the daemon
|
||||||
return 200, b"; mock\nM4 S400\nG0 X60 Y42\nG1 X120 Y42 F3000\nM5\n", "text/plain"
|
# serves them; the head's Z is the edge, the tail goes back to
|
||||||
|
# it, and the focus card's ladder spans the window the settings
|
||||||
|
# hold now
|
||||||
|
lens = lens_block(s)
|
||||||
|
lines = ["; mock", "G0 Z%.2f" % lens["edge_z"], "M4 S400", "G0 X60 Y42", "G1 X120 Y42 F3000"]
|
||||||
|
if q.get("card") == "laser.focus":
|
||||||
|
lines += ["G0 Z%.2f" % z for z in ladder_z(lens["edge_z"], lens["below"], lens["above"])]
|
||||||
|
if self.stray_z is not None:
|
||||||
|
lines.append("G0 Z%.2f" % self.stray_z)
|
||||||
|
lines += ["M5", "G0 Z%.2f" % lens["edge_z"], "G0 X0 Y0", "M2"]
|
||||||
|
return 200, ("\n".join(lines) + "\n").encode(), "text/plain"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def on_post(self, path, form):
|
def on_post(self, path, form):
|
||||||
@@ -151,6 +207,8 @@ class ScriptedDaemon:
|
|||||||
if wid not in SCRIPT:
|
if wid not in SCRIPT:
|
||||||
return 404, {"error": "no such wizard"}
|
return 404, {"error": "no such wizard"}
|
||||||
self.order.append(wid)
|
self.order.append(wid)
|
||||||
|
self.settings_at_start[wid] = dict(s)
|
||||||
|
s.update(START_WRITES.get(wid, {}))
|
||||||
self.queue = list(SCRIPT[wid][0])
|
self.queue = list(SCRIPT[wid][0])
|
||||||
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
|
self.dark.update({"id": wid, "running": True, "phase": "starting", "result": None, "error": ""})
|
||||||
self.open_next()
|
self.open_next()
|
||||||
@@ -199,7 +257,7 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
|
self.assertEqual(d.answers["laser.focus"], ["Continue", "11", "Keep"])
|
||||||
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
|
self.assertEqual(d.answers["laser.floor"], ["Continue", "8"])
|
||||||
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
|
self.assertEqual(d.answers["laser.corner"], ["Continue", "1.50"])
|
||||||
self.assertEqual(d.previews, LIVE)
|
self.assertEqual(d.previews, PREVIEWS)
|
||||||
cards = run.evidence["cards"]
|
cards = run.evidence["cards"]
|
||||||
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
|
self.assertEqual(sorted(cards), sorted(["sheet.place"] + LIVE))
|
||||||
for wid in LIVE:
|
for wid in LIVE:
|
||||||
@@ -208,6 +266,24 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
self.assertNotIn("emission", run.evidence)
|
self.assertNotIn("emission", run.evidence)
|
||||||
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
|
self.assertEqual(cards["sheet.place"]["result"]["origin_x"], 100)
|
||||||
self.assertEqual(run.evidence["settings_before"], FOUND)
|
self.assertEqual(run.evidence["settings_before"], FOUND)
|
||||||
|
# A fresh machine's run: the lens settings were gone before the
|
||||||
|
# frame, the frame's program ran in the fallback window, the focus
|
||||||
|
# card's window was in the settings when the floor card started,
|
||||||
|
# and the window check filed what it compared.
|
||||||
|
for k in commission_sheet.LENS_SETTINGS:
|
||||||
|
self.assertNotIn(k, d.settings_at_start["sheet.frame"], k)
|
||||||
|
self.assertEqual(run.evidence["lens_fresh"]["stops_found"], False)
|
||||||
|
self.assertEqual(run.evidence["program_z"]["sheet.frame"]["reach"], [-0.07, 7.46])
|
||||||
|
floor_start = d.settings_at_start["laser.floor"]
|
||||||
|
self.assertEqual({k: floor_start[k] for k in commission_sheet.LENS_SETTINGS},
|
||||||
|
{"lens_hall_edge_z_mm": "6.96", "lens_stop_below_steps": "14", "lens_stop_above_steps": "20"})
|
||||||
|
fw = run.evidence["focus_window"]
|
||||||
|
self.assertEqual(fw["window"], FOCUS_WINDOW)
|
||||||
|
self.assertEqual(fw["settings"]["lens_stop_below_steps"], "14")
|
||||||
|
self.assertEqual(len(fw["ladder_z"]), 12)
|
||||||
|
self.assertEqual(fw["program_z_before"]["reach"], [-0.07, 7.46]) # the fallback ladder
|
||||||
|
self.assertEqual(fw["program_z_after"]["reach"], [2.17, 13.8]) # the written window at the new edge
|
||||||
|
self.assertEqual(len(fw["program_z_after"]["z"]), 14) # head, twelve lines, tail
|
||||||
for k, v in FOUND.items():
|
for k, v in FOUND.items():
|
||||||
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
|
self.assertEqual(self.fake.state["settings"][k], v, k) # restored
|
||||||
|
|
||||||
@@ -261,6 +337,44 @@ class SheetRunTests(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
|
SCRIPT["laser.floor"] = (SCRIPT["laser.floor"][0], dict(SCRIPT_DARK, emission=EMISSION))
|
||||||
|
|
||||||
|
def test_a_focus_window_the_settings_do_not_hold_fails_the_run(self):
|
||||||
|
# The defect the bench found: the focus card burned its ladder over
|
||||||
|
# the stops it found while the controller's Z limit stood on the
|
||||||
|
# settings, which did not hold them. A daemon whose focus result
|
||||||
|
# names a window the settings do not hold fails the run before the
|
||||||
|
# floor card, and the settings still go back.
|
||||||
|
from forgetest.runner import Failed
|
||||||
|
saved = dict(START_WRITES["laser.focus"])
|
||||||
|
START_WRITES["laser.focus"] = {}
|
||||||
|
try:
|
||||||
|
t = catalog.load_suite()["commission.sheet"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
run.unattended = True
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
self.assertIn("the settings hold", str(cm.exception))
|
||||||
|
self.assertEqual(self.daemon.order, ["sheet.place", "sheet.frame", "laser.focus"])
|
||||||
|
for k, v in FOUND.items():
|
||||||
|
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||||
|
finally:
|
||||||
|
START_WRITES["laser.focus"] = saved
|
||||||
|
|
||||||
|
def test_a_program_z_beyond_the_reach_fails_before_the_burn(self):
|
||||||
|
# A served program that commands a Z the lens does not reach is
|
||||||
|
# refused before the card starts: nothing burns.
|
||||||
|
from forgetest.runner import Failed
|
||||||
|
self.daemon.stray_z = 10.6
|
||||||
|
t = catalog.load_suite()["commission.sheet"]
|
||||||
|
run = Run("test", t.id, t.title)
|
||||||
|
run.unattended = True
|
||||||
|
with self.assertRaises(Failed) as cm:
|
||||||
|
t.fn(Context(run, None, t))
|
||||||
|
self.assertIn("outside the lens window", str(cm.exception))
|
||||||
|
self.assertIn("10.6", str(cm.exception))
|
||||||
|
self.assertEqual(self.daemon.order, ["sheet.place"])
|
||||||
|
for k, v in FOUND.items():
|
||||||
|
self.assertEqual(self.fake.state["settings"][k], v, k)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
|
||||||
# layer content hash because the component entry already identifies the
|
# layer content hash because the component entry already identifies the
|
||||||
# pinned source (forgefirm-image-manifest.bbclass).
|
# pinned source (forgefirm-image-manifest.bbclass).
|
||||||
SRCREV = "a9e7073b2325c8c9d89d490fe6d067abfac22da5"
|
SRCREV = "f8ddb17762ff3eb194c01e13579793d712ce70fc"
|
||||||
PV = "0.1.17"
|
PV = "0.1.18"
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ no root) and drives it over TCP:
|
|||||||
does not free Z either
|
does not free Z either
|
||||||
6. X and Y are still free after both writes
|
6. X and Y are still free after both writes
|
||||||
|
|
||||||
|
A referenced lens opens the envelope to the window the shared settings
|
||||||
|
hold, the same keys the daemon's wizards build their Z from. forgectrl
|
||||||
|
leaves the lens on its hall edge and a marker in the state directory
|
||||||
|
before the controller starts; the driver takes it as it loads its
|
||||||
|
settings and places the edge on its step grid:
|
||||||
|
|
||||||
|
7. the marker with the edge alone: Z sits at the edge's grid step, the
|
||||||
|
fallback window (10 below, 12 above) runs to its ends, and two
|
||||||
|
half-steps past either end alarms
|
||||||
|
8. the marker with the focus card's stops (14 below, 20 above): the
|
||||||
|
wider window runs to its ends, two half-steps past either alarms
|
||||||
|
9. a stop count out of range (41) falls back on its side alone
|
||||||
|
|
||||||
The binary keeps its settings in EEPROM.DAT in the working directory, so
|
The binary keeps its settings in EEPROM.DAT in the working directory, so
|
||||||
each run starts from defaults in a temporary directory and leaves
|
each run starts from defaults in a temporary directory and leaves
|
||||||
nothing behind.
|
nothing behind.
|
||||||
@@ -53,13 +66,24 @@ def fail(msg):
|
|||||||
|
|
||||||
|
|
||||||
class Session:
|
class Session:
|
||||||
"""One null-sink controller process with its own settings store."""
|
"""One null-sink controller process with its own settings store.
|
||||||
|
`conf` (key: value) is the shared config the driver reads; with
|
||||||
|
`referenced` the lens marker forgectrl leaves is in the state
|
||||||
|
directory, so the driver takes the lens reference at its start."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, conf=None, referenced=False):
|
||||||
self.workdir = tempfile.mkdtemp(prefix="z-envelope-")
|
self.workdir = tempfile.mkdtemp(prefix="z-envelope-")
|
||||||
env = dict(os.environ, GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
|
env = dict(os.environ, GF_STATE_DIR=self.workdir, FFLOG_STDERR="1")
|
||||||
for key in ("GFSINK", "GF_SWITCH_FILE", "GF_VERDICT_FILE"):
|
for key in ("GFSINK", "GF_SWITCH_FILE", "GF_VERDICT_FILE"):
|
||||||
env.pop(key, None)
|
env.pop(key, None)
|
||||||
|
conf_path = os.path.join(self.workdir, "forgefirm.conf")
|
||||||
|
with open(conf_path, "w") as f:
|
||||||
|
for k, v in (conf or {}).items():
|
||||||
|
f.write("%s = %s\n" % (k, v))
|
||||||
|
env["GFHOME_CONF"] = conf_path
|
||||||
|
if referenced:
|
||||||
|
with open(os.path.join(self.workdir, "lens.home"), "w") as f:
|
||||||
|
f.write("edge 0 3 3\n")
|
||||||
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
|
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
|
||||||
cwd=self.workdir, env=env,
|
cwd=self.workdir, env=env,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
@@ -113,6 +137,23 @@ class Session:
|
|||||||
m = re.search(r"<(\w+)", self.send("?"))
|
m = re.search(r"<(\w+)", self.send("?"))
|
||||||
return m.group(1) if m else "?"
|
return m.group(1) if m else "?"
|
||||||
|
|
||||||
|
def mpos_z(self):
|
||||||
|
"""The reported machine Z (no work offset is set in a session)."""
|
||||||
|
m = re.search(r"[MW]Pos:(-?[\d.]+),(-?[\d.]+),(-?[\d.]+)", self.send("?"))
|
||||||
|
if not m:
|
||||||
|
fail("no position in the status report")
|
||||||
|
return float(m.group(3))
|
||||||
|
|
||||||
|
def wait_idle(self, timeout=15.0):
|
||||||
|
"""A move that ran plays out before the next line is judged: the
|
||||||
|
soft limit on a moving machine holds first and alarms after."""
|
||||||
|
end = time.time() + timeout
|
||||||
|
while time.time() < end:
|
||||||
|
if self.state() == "Idle":
|
||||||
|
return
|
||||||
|
time.sleep(0.1)
|
||||||
|
fail("the controller did not return to Idle")
|
||||||
|
|
||||||
def move(self, gcode):
|
def move(self, gcode):
|
||||||
"""Run one move. Returns True when the soft limit blocked it.
|
"""Run one move. Returns True when the soft limit blocked it.
|
||||||
An alarm needs a reset and an unlock before the next move."""
|
An alarm needs a reset and an unlock before the next move."""
|
||||||
@@ -160,6 +201,60 @@ def free_in_xy(s, when):
|
|||||||
check(ran, "X and Y free %s" % when, "an X or Y move was blocked %s" % when)
|
check(ran, "X and Y free %s" % when, "an X or Y move was blocked %s" % when)
|
||||||
|
|
||||||
|
|
||||||
|
def window_holds(s, steps, below, above, what):
|
||||||
|
"""The envelope of a referenced lens whose edge sits on grid step
|
||||||
|
`steps`: the ends of the reach (`below` and `above` half-steps from
|
||||||
|
the edge) run, two half-steps past either end alarms. The controller
|
||||||
|
keeps a half-step of slack beyond the reach, so one past is not
|
||||||
|
judged here; two is outside on every head."""
|
||||||
|
spm = float(s.setting("$102"))
|
||||||
|
top, bottom = (steps + above) / spm, (steps - below) / spm
|
||||||
|
s.wait_idle()
|
||||||
|
check(not s.move("G0 Z%.3f" % top), "%s: the top of the reach, Z %.3f, runs" % (what, top),
|
||||||
|
"%s: Z %.3f, the top of the reach, was blocked" % (what, top))
|
||||||
|
s.wait_idle()
|
||||||
|
check(not s.move("G0 Z%.3f" % bottom), "%s: the bottom of the reach, Z %.3f, runs" % (what, bottom),
|
||||||
|
"%s: Z %.3f, the bottom of the reach, was blocked" % (what, bottom))
|
||||||
|
over, under = (steps + above + 2) / spm, (steps - below - 2) / spm
|
||||||
|
s.wait_idle()
|
||||||
|
check(s.move("G0 Z%.3f" % over), "%s: two half-steps over the top, Z %.3f, alarms" % (what, over),
|
||||||
|
"%s: Z %.3f, two half-steps over the top, ran" % (what, over))
|
||||||
|
s.wait_idle()
|
||||||
|
check(s.move("G0 Z%.3f" % under), "%s: two half-steps under the bottom, Z %.3f, alarms" % (what, under),
|
||||||
|
"%s: Z %.3f, two half-steps under the bottom, ran" % (what, under))
|
||||||
|
|
||||||
|
|
||||||
|
def referenced_cases():
|
||||||
|
"""The lens referenced by forgectrl's marker: the envelope is the
|
||||||
|
window the shared settings hold, as the daemon's wizards read it."""
|
||||||
|
edge = 3.35
|
||||||
|
conf = {"lens_hall_edge_z_mm": "%.2f" % edge}
|
||||||
|
s = Session(conf=conf, referenced=True)
|
||||||
|
try:
|
||||||
|
spm = float(s.setting("$102"))
|
||||||
|
steps = int(round(edge * spm))
|
||||||
|
z = s.mpos_z()
|
||||||
|
check(abs(z - steps / spm) < 0.002,
|
||||||
|
"referenced: Z sits at the edge's grid step, %.3f" % z,
|
||||||
|
"referenced: Z reads %.3f, not the edge's grid step %.3f" % (z, steps / spm))
|
||||||
|
window_holds(s, steps, 10, 12, "the fallback window")
|
||||||
|
free_in_xy(s, "with the lens referenced")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
s = Session(conf=dict(conf, lens_stop_below_steps="14", lens_stop_above_steps="20"), referenced=True)
|
||||||
|
try:
|
||||||
|
window_holds(s, steps, 14, 20, "the focus card's window")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
s = Session(conf=dict(conf, lens_stop_below_steps="41", lens_stop_above_steps="20"), referenced=True)
|
||||||
|
try:
|
||||||
|
window_holds(s, steps, 10, 20, "a below count out of range")
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
if not os.path.isfile(BIN):
|
if not os.path.isfile(BIN):
|
||||||
fail("no controller binary at %s" % BIN)
|
fail("no controller binary at %s" % BIN)
|
||||||
@@ -189,6 +284,7 @@ def main():
|
|||||||
free_in_xy(s, "after the settings writes")
|
free_in_xy(s, "after the settings writes")
|
||||||
finally:
|
finally:
|
||||||
s.close()
|
s.close()
|
||||||
|
referenced_cases()
|
||||||
print("PASS z_envelope_test")
|
print("PASS z_envelope_test")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user