bench: every board-runnable tool ported to the bench page

The remaining bench diagnostics run from forgetest's #bench tab. The
tools that also run from a LAN host share scripts/bench/gfbench.py:
GF_HOST names a remote machine (host mode, sysfs through ssh, Grbl and
forgectrl over the LAN); unset, the tool runs on the board itself
(local mode, sysfs directly, everything on 127.0.0.1), which is how the
page runs them - with GF_HOST=127.0.0.1, the panel token in GF_TOKEN
and their data files under <data>/bench/ (FORGETEST_BENCH_DATA). The
helper also reads a machine setting from forgectrl, or from the settings
file on the board while forgectrl is stopped.

Ported: pwm_sweep / pwm_hold (scope = a takeover; the latch relocked,
the write refused if FIRE or LASER_ON reads active), pwm_stream_test
(PASS/FAIL exit), flow_characterize, flow_recheck_char,
flow_warm_validate and flow_matrix (takeovers: forgectrl owns the
thermal hardware, so the page's takeover replaces the tools' own
controller stop/restart, whose command line predated the supervisor;
results and logs in the bench data directory), flow_sustained,
fan_test, temp_calibrate (dry; watch bounded in seconds; the threshold
and the coolant conversion from the shared code), flow_escalate_drill
(cool_confirm_max_s shortened through forgectrl's settings for the
drill and restored; the setting's minimum is the default budget), and
live_fire_drills (<drill> [S] [F], all six drills, host from GF_HOST,
token from the board). flow_matrix joins the registry. What stays
unported cannot run against the machine at all: the two null-sink CI
harnesses and the .puls decoder.

Runner: a scope tool runs inside the takeover wrapper; the bench
environment above is passed to every tool. Tests: test_bench_registry
(registry <-> scripts/bench consistency, every ported tool builds its
command line, every script compiles, gfbench host/local modes) and the
server test (scope tool takeover, the environment reaching the tool).
Local mode smoke-run on the bench (temp_calibrate watch, setting, token)
from /tmp, removed after.

No catalog consequence: bench tools are not image components (dev-only
forgetest); the acceptance catalog is unchanged.
This commit is contained in:
ScottW514
2026-08-16 16:28:39 -04:00
parent 03947cd94c
commit cd8a01a3d9
21 changed files with 691 additions and 316 deletions
+16 -4
View File
@@ -203,10 +203,21 @@ change author's judgment (rule 1).
The same daemon serves `#bench`: the registry of the bench tools
(`scripts/bench`, installed under `/usr/share/forgetest/bench/`), each with
its safety class (`dry`, `takeover`, `live`, `scope`), argument form, and
last run. A ported tool runs as a subprocess with the output on the page;
unported tools are listed with Start disabled. Bench runs are recorded in
`/data/forgetest/bench.jsonl` and never enter a campaign.
its safety class, argument form, and last run. The classes: `dry` (reads
or dry motion, forgectrl stays up), `takeover` (forgectrl and the
controller stopped for the run, the pulse device free, the same wrapper
the takeover tests use), `scope` (a takeover whose result only means
something with the named instrument on the bench), `live` (emission
possible; the operator acknowledgment and the physical arm press). A tool
runs as a subprocess with the output on the page and, on the board, the
machine as `GF_HOST=127.0.0.1`, the panel token in `GF_TOKEN`, and its data
files under `/data/forgetest/bench/` (`FORGETEST_BENCH_DATA`); the same
scripts run from a LAN host with `GF_HOST` set (`scripts/bench/gfbench.py`,
`scripts/bench/README.md`). Every board-runnable tool is ported; the entries
that stay unported are the CI harnesses of the null-sink controller and the
factory `.puls` decoder, which do not run against the machine at all - they
are listed so the catalog of what exists is complete. Bench runs are
recorded in `/data/forgetest/bench.jsonl` and never enter a campaign.
## Layout
@@ -221,6 +232,7 @@ unported tools are listed with Start disabled. Bench runs are recorded in
bench.py / coverage.py bench registry + subprocess runner; the lint
suite/ the catalog, one module per subsystem
forgetest/tests/ host unit tests (python3 -m unittest discover -s tests)
scripts/bench/ the bench tools (+ gfbench.py, the board/host helper)
scripts/acceptance-gate.py the gate
scripts/manifest-from-tree.py manifest from the recipe pins (CI, workstation)
releases/v<version>/ the committed artifacts
+33 -4
View File
@@ -1630,6 +1630,33 @@ that image's campaign is a full one, unavoidably; from then on a
component pin bump re-requires only the tests covering that component.
Run the full campaign on the first pin-file image, not on `191951`.**
**2026-08-16, bench-tab ports complete (item 15b).** Every tool that can
run against the machine is now runnable from the bench page: the scope
tools (`pwm_sweep`, `pwm_hold` - now a takeover with a locked-state guard:
the latch relocked, refused if FIRE or LASER_ON reads active;
`pwm_stream_test` with a PASS/FAIL exit), the flow characterization
family (`flow_characterize`, `flow_recheck_char`, `flow_warm_validate`,
`flow_matrix` as takeovers - forgectrl owns the thermal hardware, so the
page's takeover replaces the tools' own controller stop/restart, whose
command line predated the supervisor; `flow_sustained`, `fan_test` and
`temp_calibrate` stay dry), the escalation drill (`cool_confirm_max_s`
shortened through forgectrl's settings and restored; the setting's
minimum, 60 s, is the default budget) and the live drills (`<drill> [S]
[F]`, all six, the token from the board). The host tools keep working
from a workstation: `scripts/bench/gfbench.py` resolves `GF_HOST` (host
mode, ssh) or the board itself (local mode; the page runs them that way
with `GF_HOST=127.0.0.1`, `GF_TOKEN`, and their data files under
`/data/forgetest/bench/`). Not ported, by nature: the two null-sink CI
harnesses and the `.puls` decoder. Proof: `forgetest/tests/
test_bench_registry.py` (registry <-> `scripts/bench` consistency, every
ported tool builds its command line, every script compiles, gfbench host
and local modes), the server test (a scope tool runs inside the takeover
wrapper; the bench environment reaches the tool), and a local-mode smoke
run on the bench (`temp_calibrate.py watch`, `gfbench.setting`, the
token) staged in `/tmp` and removed. **The ported tools themselves have
not been exercised from the page on the bench yet - that rides the next
dev image (the confirmation campaign's image).**
## Hardware facts bank (measured)
- **DRV8825 stepper drivers wedge on 40 V rail glitches** (factory board;
@@ -2966,9 +2993,11 @@ Run the full campaign on the first pin-file image, not on `191951`.**
built with the pin files** (that build is a platform change against
every result so far; after it, a component pin bump re-requires only
the tests covering that component) - the tool on the bench is the
tree, but a hot-patched image is not the image that ships; (b) the remaining
bench-tab ports (scope tools, host-side flow characterization, the
live drills - the catalog carries their acceptance forms); (c) the
first release runs the full campaign and commits
tree, but a hot-patched image is not the image that ships; (b) the
bench-tab ports are **code-complete 2026-08-16** (every board-runnable
tool is ported: the scope tools, the flow characterization family,
the escalation drill, the live drills; record in "Release acceptance"
above) - their bench validation rides the same next dev image; (c)
the first release runs the full campaign and commits
`releases/v<version>/acceptance.json` - **not yet: no release is
cut.**
+1
View File
@@ -29,6 +29,7 @@ pins; the coverage lint is `python3 -m forgetest.coverage --manifest ...`.
| `FORGETEST_MANIFEST` | `/etc/forgefirm-manifest.json` | the image manifest |
| `FORGETEST_PORT`, `FORGETEST_HOST` | 8090, 0.0.0.0 | listener |
| `FORGETEST_BENCH_DIR` | `/usr/share/forgetest/bench` | the installed bench scripts |
| `FORGETEST_BENCH_DATA` | `<FORGETEST_DATA>/bench` | passed to bench tools: where they keep their data files (with `GF_HOST=127.0.0.1` and the panel token in `GF_TOKEN`) |
| `FORGETEST_MARKER` | `/run/forgetest.active` | takeover marker |
| `FORGECTRL_URL`, `FORGECTRL_TOKEN_FILE` | `http://127.0.0.1:8080`, `/data/forgefirm/panel.token` | forgectrl client |
| `GF_SYSFS_ROOT` | `/sys/glowforge/` | kernel module sysfs |
+63 -29
View File
@@ -12,10 +12,14 @@ so the catalog of what exists is complete, with Start disabled.
Safety classes:
dry reads or dry motion, no emission, forgectrl stays up
takeover needs forgectrl stopped and the pulse device free
scope a takeover whose result only means something with the named
instrument on the bench (a scope on LASER_PWM / LASER_ON)
live laser emission possible (operator acknowledgment required)
scope needs bench instrumentation on top of the class before it
Bench runs are recorded in <data>/bench.jsonl and never enter a campaign.
The tools that also run from a LAN host (gfbench.py: GF_HOST) run on the
board here with GF_HOST=127.0.0.1, the panel token in GF_TOKEN, and
their data files under FORGETEST_BENCH_DATA (<data>/bench/). Bench runs
are recorded in <data>/bench.jsonl and never enter a campaign.
"""
import json
import os
@@ -69,20 +73,23 @@ TOOLS = [
"desc": "Creeps toward a rail in bounded jog segments, detects the contact jolt, jog-cancels and backs off."},
# -- board-side, takeover / scope ------------------------------------------
{"id": "pwm-sweep", "title": "LASER_PWM scope sweep", "script": "pwm_sweep.py",
"safety": "scope", "where": "board", "ported": False,
"safety": "scope", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "check", "check = read-only, sweep = duty staircase", ["check", "sweep"])],
"desc": "check: readbacks + PWM2 dump; sweep: PWMSAR through 50/25/75/6/100 percent with 4 s holds. Locked state only."},
"desc": "check: readbacks + PWM2 dump; sweep: PWMSAR through 50/25/75/6/100 percent with 4 s holds. "
"Locked state (the takeover): latch relocked, refuses if FIRE or LASER_ON reads active."},
{"id": "pwm-hold", "title": "LASER_PWM scope hold", "script": "pwm_hold.py",
"safety": "scope", "where": "board", "ported": False,
"safety": "scope", "where": "board", "ported": True,
"args": [_arg("sar", "int", 64, "PWMSAR value"), _arg("seconds", "int", 10, "hold time")],
"desc": "Holds one PWMSAR value for a scope window, then restores. Locked state only."},
"desc": "Holds one PWMSAR value for a scope window, then restores. Locked state (the takeover): "
"latch relocked, refuses if FIRE or LASER_ON reads active."},
{"id": "fire-test", "title": "FIRE drop-timing test (A/B/U)", "script": "fire_test.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "A", "A latch locked, B unlocked/unarmed, U true underrun", ["A", "B", "U"])],
"desc": "Duty 0 throughout; refuses to unlock if HV reports good. Software witnesses + the PSU-connector LASER_ON scope point."},
{"id": "pwm-stream", "title": "LASER_PWM stream-path test", "script": "pwm_stream_test.py",
"safety": "takeover", "where": "board", "ported": False, "args": [],
"desc": "Streams power bytes only (no steps, no FIRE, motor_lock=15, latch locked) through /dev/glowforge."},
"safety": "takeover", "where": "board", "ported": True, "args": [],
"desc": "Streams power bytes only (no steps, no FIRE, motor_lock=15, latch locked) through /dev/glowforge; "
"PASS = counters unmoved, idle at the end, no FIRE/emission read back. The scope on LASER_PWM sees the duty steps."},
{"id": "gate-a-kernel", "title": "Kernel laser-safety drills K1/K2/K3", "script": "gate_a_kernel_drills.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("drill", "choice", "K1", "K1 stop floor, K2 resume honors latch, K3 mid-ramp unlock", ["K1", "K2", "K3"])],
@@ -97,51 +104,78 @@ TOOLS = [
"safety": "dry", "where": "board", "ported": True, "args": [],
"desc": "One M8 session walks the verdict state machine through real pump-off transients; PASS/FAIL per transition."},
{"id": "flow-escalate", "title": "Coolant starved re-check escalation drill", "script": "flow_escalate_drill.py",
"safety": "dry", "where": "board", "ported": False, "args": [],
"desc": "With the pump off the job-start check reads SUSPECT and the driver must escalate to FAULT."},
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("budget_s", "int", 60, "cool_confirm_max_s for the drill (60-3600), restored after")],
"desc": "With the pump off the job-start check reads SUSPECT and the engine must escalate to FAULT when "
"the confirmation budget expires; the budget setting is shortened for the drill and restored."},
{"id": "flow-characterize", "title": "Coolant flow characterization", "script": "flow_characterize.py",
"safety": "dry", "where": "host", "ported": False,
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("duty", "int", 30, "heater duty percent")],
"desc": "Baseline -> flow -> no-flow -> recovery with the factory temperature curve; aborts past 45 C downstream."},
"desc": "Baseline -> flow -> no-flow -> recovery with the factory temperature curve; aborts past 45 C "
"downstream. Drives the heater and pump directly (about 9 minutes)."},
{"id": "flow-sustained", "title": "Coolant sustained re-check run", "script": "flow_sustained.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("minutes", "float", 30.0, "how long to hold M8")],
"desc": "Long run of the real re-check cadence via M8: verdicts, false faults, loop heat accumulation."},
{"id": "flow-warm", "title": "Coolant warm-baseline validation", "script": "flow_warm_validate.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Runs the real check from a heater-warmed baseline."},
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("cycles", "int", 3, "cycles per case (flow / no-flow)")],
"desc": "Runs the real check (40 percent / 50 s, cut-profile fans) from a heater-warmed baseline, alternating "
"flow and no-flow; results to the bench data directory. Slow: about 15 minutes per cycle."},
{"id": "flow-recheck", "title": "Coolant re-check characterization", "script": "flow_recheck_char.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Short in-run re-checks and the differential metric."},
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("heater_pct", "int", 50, "heater duty percent"), _arg("window_s", "int", 30, "re-check window")],
"desc": "Short in-run re-checks and the differential metric, flow vs no-flow from a settled loop; "
"aborts past 45 C downstream (about 5 minutes)."},
{"id": "flow-matrix", "title": "Coolant flow-detection design matrix", "script": "flow_matrix.py",
"safety": "takeover", "where": "board", "ported": True,
"args": [_arg("duties", "str", "10,15,20,30,40,50", "heater duties, percent, comma-separated"),
_arg("repeats", "int", 5, "interleaved repeats per case")],
"desc": "duty x flow/no-flow x repeats from a common cooled baseline; cost and precision tables and a "
"ranked shortlist (the derivation of cool_flow_rise). Very slow: about 1.6 h for the full matrix; "
"resumable from the results file in the bench data directory."},
{"id": "flow-sampler", "title": "Coolant sampler", "script": "flow_sampler.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("duration_s", "int", 30, "capture length"), _arg("interval_s", "float", 1.0, "sample interval")],
"desc": "Prints elapsed,raw_down,raw_up at the interval; the sampler behind the flow tools."},
{"id": "temp-calibrate", "title": "Coolant temperature spot-check", "script": "temp_calibrate.py",
"safety": "dry", "where": "host", "ported": False,
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "watch", "watch / point / fit", ["watch", "point", "fit"]),
_arg("measured_c", "float", None, "thermometer reading for point")],
"desc": "Pairs a measured temperature with averaged raw readings; fits a per-machine line."},
_arg("value", "str", None, "point: the thermometer reading in C; watch: seconds (default 60)")],
"desc": "Pairs a measured temperature with averaged raw readings; fits a per-machine line. Points "
"accumulate in the bench data directory."},
{"id": "fan-test", "title": "Fan/coolant bench", "script": "fan_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Snapshots fan PWMs/tachs/temps, drives M8 -> cut fans, M9 -> cooldown -> idle. Host-side; port pending."},
"safety": "dry", "where": "board", "ported": True, "args": [],
"desc": "Snapshots fan PWMs/tachs/temps, drives M8 -> cut fans, M9 -> cooldown -> idle; the tach "
"readbacks in each snapshot are the evidence."},
# -- laser (live) --------------------------------------------------------------
{"id": "live-fire", "title": "LIVE laser drills", "script": "live_fire_drills.py",
"safety": "live", "where": "board", "ported": False,
"args": [_arg("drill", "choice", "witness", "witness / hold / faultpos", ["witness", "hold", "faultpos"])],
"argv_fixed_after": ["127.0.0.1"],
"desc": "Emission witness, disarm grace in Hold, stale-origin refusal. The operator's arm press is required for every drill."},
"safety": "live", "where": "board", "ported": True,
"args": [_arg("drill", "choice", "witness", "witness / hold / faultpos / ircut / expstop / ctrlstart",
["witness", "hold", "faultpos", "ircut", "expstop", "ctrlstart"]),
_arg("power", "int", 1000, "ircut: S value"), _arg("feed", "int", 300, "ircut: F value")],
"desc": "Emission witness, disarm grace in Hold, stale-origin refusal, lid-IR characterization cut, armed "
"kill on the expected-stop path (+ the separate controller restart). The operator's arm press is "
"required for every drill; eye protection, fire watch, extinguisher, exhaust."},
# -- host-side harnesses (CI) ------------------------------------------------------
{"id": "laser-stream-test", "title": "Laser pulse-stream emission harness", "script": "laser_stream_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Null-sink controller stream capture against the feeder contract. Runs in the grblHAL repo's CI."},
"desc": "Null-sink controller stream capture against the feeder contract. A CI harness (the grblHAL repo): "
"it needs the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "laser-lifecycle-test", "title": "Armed-window lifecycle harness", "script": "laser_lifecycle_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Arm/disarm lifecycle on the null-sink controller. Runs in the grblHAL repo's CI."},
"desc": "Arm/disarm lifecycle on the null-sink controller. 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": "puls-profile", "title": "Factory .puls profile decoder", "script": "puls_profile.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Decodes factory pulse streams into velocity/accel profiles. Runs anywhere; needs a .puls file."},
"desc": "Decodes factory pulse streams into velocity/accel profiles. Runs anywhere; needs a .puls file "
"(the reference captures live off the machine), so it is not a bench-page tool."},
]
# Files in scripts/bench that are not tools of their own: the helper module
# the host/board tools share, and the C feeder + build scripts (not python).
NOT_TOOLS = ("gfbench.py",)
class Bench:
def __init__(self, tools=None, tool_dir=None, index_path=None):
+11 -1
View File
@@ -503,7 +503,17 @@ class Runner:
captured = self._baseline_pre(run)
env = dict(os.environ)
env.setdefault("PYTHONUNBUFFERED", "1")
takeover = Takeover(run.log, "bench:" + tool["id"]) if tool.get("safety") == "takeover" else None
# The tools that also run from a LAN host (gfbench.py) run on
# the board here: the machine is local, the panel token is at
# hand, and their data files go under <data>/bench/.
env.setdefault("GF_HOST", "127.0.0.1")
env.setdefault("FORGETEST_BENCH_DATA", os.path.join(data_dir(), "bench"))
if not env.get("GF_TOKEN"):
tok = hw.Forgectrl().token
if tok:
env["GF_TOKEN"] = tok
takeover = (Takeover(run.log, "bench:" + tool["id"])
if tool.get("safety") in ("takeover", "scope") else None)
if takeover is not None:
takeover.__enter__()
try:
+114
View File
@@ -0,0 +1,114 @@
"""The bench registry against scripts/bench: every python tool is
registered (or named as not-a-tool), every registered script exists,
every argument spec builds a command line, every script compiles, and
the shared helper resolves host and local mode as documented."""
import glob
import os
import py_compile
import subprocess
import sys
import unittest
import helpers # noqa: F401 (sys.path)
from forgetest import bench as bench_mod
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
BENCH = os.path.join(REPO, "scripts", "bench")
class RegistryTests(unittest.TestCase):
def setUp(self):
self.bench = bench_mod.Bench(tool_dir=BENCH, index_path=os.devnull)
self.scripts = sorted(os.path.basename(p) for p in glob.glob(os.path.join(BENCH, "*.py")))
def test_every_python_tool_is_registered(self):
registered = {t["script"] for t in bench_mod.TOOLS}
missing = [s for s in self.scripts if s not in registered and s not in bench_mod.NOT_TOOLS]
self.assertEqual(missing, [], "scripts/bench python files missing from the registry")
def test_every_registered_script_exists(self):
for t in bench_mod.TOOLS:
self.assertTrue(os.path.exists(os.path.join(BENCH, t["script"])), t["script"])
def test_ids_unique_and_fields_valid(self):
ids = [t["id"] for t in bench_mod.TOOLS]
self.assertEqual(len(ids), len(set(ids)))
for t in bench_mod.TOOLS:
self.assertIn(t["safety"], ("dry", "takeover", "scope", "live"), t["id"])
self.assertIn(t["where"], ("board", "host"), t["id"])
self.assertIsInstance(t["ported"], bool, t["id"])
self.assertTrue(t["desc"], t["id"])
for a in t.get("args", []):
self.assertIn(a["type"], ("str", "int", "float", "choice"), (t["id"], a["name"]))
if a["type"] == "choice":
self.assertIn(a["default"], a["choices"], (t["id"], a["name"]))
def test_ported_tools_build_a_command_with_defaults(self):
for t in bench_mod.TOOLS:
if not t["ported"]:
continue
ok, argv, err = self.bench.command(t, {})
self.assertTrue(ok, "%s: %s" % (t["id"], err))
self.assertEqual(argv[1], os.path.join(BENCH, t["script"]))
def test_unported_tools_are_host_only(self):
# what stays unported is what cannot run on the machine at all
for t in bench_mod.TOOLS:
if not t["ported"]:
self.assertEqual(t["where"], "host", t["id"])
self.assertIn("not a bench-page tool", t["desc"], t["id"])
def test_scripts_compile(self):
for s in self.scripts:
py_compile.compile(os.path.join(BENCH, s), doraise=True)
class GfbenchTests(unittest.TestCase):
"""gfbench.py in a subprocess (module-level host resolution)."""
def run_snippet(self, code, env_extra):
env = dict(os.environ)
env.pop("GF_HOST", None)
env.pop("FORGETEST_BENCH_DATA", None)
env.update(env_extra)
r = subprocess.run([sys.executable, "-c", code], cwd=BENCH, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
return r.returncode, r.stdout.strip()
def test_host_mode(self):
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL, gfbench.SSH[0])",
{"GF_HOST": "192.0.2.7", "GF_SSH": "fakessh -x"})
self.assertEqual(rc, 0, out)
self.assertEqual(out, "192.0.2.7 False fakessh")
def test_local_when_named_local(self):
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL); "
"print(gfbench.board('echo hi').strip())",
{"GF_HOST": "127.0.0.1"})
self.assertEqual(rc, 0, out)
self.assertEqual(out.splitlines(), ["127.0.0.1 True", "hi"])
def test_refuses_without_a_machine(self):
if os.path.isdir("/sys/glowforge"):
self.skipTest("running on the machine")
rc, out = self.run_snippet("import gfbench", {})
self.assertNotEqual(rc, 0)
self.assertIn("GF_HOST", out)
def test_degc_and_data_dir(self):
import tempfile
d = tempfile.mkdtemp(prefix="forgetest-bench-")
rc, out = self.run_snippet(
"import gfbench, os; print(round(gfbench.degc(700), 2), gfbench.degc('x') != gfbench.degc('x'), "
"os.path.dirname(gfbench.data_path('f.json')) == os.environ['FORGETEST_BENCH_DATA'])",
{"GF_HOST": "127.0.0.1", "FORGETEST_BENCH_DATA": d})
self.assertEqual(rc, 0, out)
val, nan, indir = out.split()
# a plausible coolant reading (700 counts is room temperature territory)
self.assertTrue(15.0 < float(val) < 35.0, val)
self.assertEqual(nan, "True")
self.assertEqual(indir, "True")
if __name__ == "__main__":
unittest.main()
+16 -2
View File
@@ -62,7 +62,9 @@ class ServerTests(unittest.TestCase):
cls.tooldir = os.path.join(cls.tmp, "bench")
os.makedirs(cls.tooldir)
with open(os.path.join(cls.tooldir, "echo_tool.py"), "w") as f:
f.write("import sys, time\nprint('args', sys.argv[1:])\nsys.stdout.flush()\n"
f.write("import os, sys, time\nprint('args', sys.argv[1:])\n"
"print('env', os.environ.get('GF_HOST'), os.environ.get('FORGETEST_BENCH_DATA'))\n"
"sys.stdout.flush()\n"
"if 'slow' in sys.argv: time.sleep(30)\nsys.exit(0 if 'fail' not in sys.argv else 3)\n")
tools = [{"id": "echo", "title": "Echo", "script": "echo_tool.py", "safety": "dry", "where": "board",
"ported": True, "desc": "echo",
@@ -73,6 +75,8 @@ class ServerTests(unittest.TestCase):
{"id": "hot", "title": "H", "script": "echo_tool.py", "safety": "live", "where": "board",
"ported": True, "desc": "", "args": []},
{"id": "tk", "title": "T", "script": "echo_tool.py", "safety": "takeover", "where": "board",
"ported": True, "desc": "", "args": []},
{"id": "sc", "title": "S", "script": "echo_tool.py", "safety": "scope", "where": "board",
"ported": True, "desc": "", "args": []}]
cls.bench = bench_mod.Bench(tools, tool_dir=cls.tooldir,
index_path=os.path.join(cls.tmp, "bench.jsonl"))
@@ -261,7 +265,7 @@ class ServerTests(unittest.TestCase):
st, d = self.call("GET", "/bench")
self.assertEqual(st, 200)
ids = [t["id"] for t in d["tools"]]
self.assertEqual(ids, ["echo", "unported", "hot", "tk"])
self.assertEqual(ids, ["echo", "unported", "hot", "tk", "sc"])
st, d = self.call("POST", "/bench/start", {"tool": "unported"})
self.assertEqual(st, 409)
st, d = self.call("POST", "/bench/start", {"tool": "hot"})
@@ -274,6 +278,9 @@ class ServerTests(unittest.TestCase):
self.assertEqual(state["last_run"]["kind"], "bench")
self.assertEqual(state["last_run"]["finished"]["result"], "OK")
self.assertTrue(any("['yo', '5']" in l for l in state["last_run"]["log"]))
# the bench environment: the machine is local, data under <data>/bench
self.assertTrue(any(" env 127.0.0.1 " in l and l.endswith("bench")
for l in state["last_run"]["log"]), state["last_run"]["log"])
st, d = self.call("GET", "/bench")
self.assertEqual(d["tools"][0]["last"]["result"]["result"], "OK")
# a failing tool and an aborted one
@@ -295,6 +302,13 @@ class ServerTests(unittest.TestCase):
self.assertIn("takeover: forgectrl start", log)
self.assertIn("baseline: forgectrl unreachable for 10 s", log)
self.assertFalse(os.path.exists(os.environ["FORGETEST_MARKER"]))
# a scope tool is a takeover too
st, d = self.call("POST", "/bench/start", {"tool": "sc"})
self.assertEqual(st, 200, d)
state = self.wait_idle(timeout=40)
log = "\n".join(state["last_run"]["log"])
self.assertIn("takeover: pulse device free", log)
self.assertIn("takeover: forgectrl start", log)
# bench runs never touched the acceptance log
recs = self.log.read()
self.assertFalse(any(r.get("t") == "result" and r.get("test") == "echo" for r in recs))
+33 -25
View File
@@ -4,39 +4,47 @@ Hardware-verification tools for the ForgeFIRM bench. All run ON the
target board (dev image, python3 present) unless noted. The dev image
installs them under `/usr/share/forgetest/bench/`, and the acceptance
tool's **Bench diagnostics** tab (`http://<machine>:8090/#bench`,
`docs/ACCEPTANCE.md`) runs the board-side ones with their arguments and
the output on the page (takeover tools get forgectrl stopped and started
around the run); the acceptance catalog itself is built from ports of
these drills. Host-side tools
take the machine address from `GF_HOST` (or `argv`, where stated); the
ones that shell into the board over ssh use the `ssh` on `PATH`, or the
client named by `GF_SSH` (for example `GF_SSH='wsl -d <distro> -- ssh'`
to go through a WSL distro from Windows).
`docs/ACCEPTANCE.md`) runs them with their arguments and the output on
the page - takeover and scope tools get forgectrl and the controller
stopped and started around the run, live tools need the operator
acknowledgment; the acceptance catalog itself is built from ports of
these drills. The tools that also run from a LAN host use `gfbench.py`:
`GF_HOST` names the machine (sysfs through ssh - the `ssh` on `PATH`, or
the client named by `GF_SSH`, for example `GF_SSH='wsl -d <distro> --
ssh'` from Windows - Grbl and forgectrl over the LAN); with `GF_HOST`
unset they run on the board itself (sysfs directly, everything on
127.0.0.1), which is how the bench page runs them, with their data files
under `/data/forgetest/bench/` (`FORGETEST_BENCH_DATA`; next to the tool
otherwise) and the panel token in `GF_TOKEN`. Tools that drive the
thermal hardware directly (the flow characterization family) run with
forgectrl - the thermal-hardware owner - and the controller stopped: the
page's takeover does that; from a host, stop them first.
| Tool | Purpose |
|---|---|
| `feeder.c` | Underrun proof: streams NOP pulse bytes to `/dev/glowforge` with wall-clock pacing, bounded queue depth, deadman flock, SCHED_FIFO. Usage: `feeder <hz> <seconds> <depth_ms>`. Proven envelope: 100 kHz × 120 s under full load, 0.2 ms worst write latency. Cross-compile with `build-feeder.sh` (WSL). |
| `bench_phase2.py` | End-of-data protocol bench: underrun detection/ack, parked no-replay guard, resume(0), continuous-feed stability, 20× run/underrun cycles. Motion-safe (motors locked, laser latched). |
| `check_pwm.py` | Laser PWM register check: reads PWM2 PWMCR/PWMPR via /dev/mem, expects divider 13 × ~127 counts ≈ 40 kHz. |
| `pwm_sweep.py` | LASER_PWM scope test (runs on the board): `check` = read-only safety readbacks + PWM2 dump; `sweep` = steps PWMSAR through 50/25/75/6/100 % duty with 4 s holds, then restores. Run only in the locked state (controller stopped, cnc disabled, latch locked). |
| `pwm_hold.py` | Holds one PWMSAR value for a scope-measurement window (`pwm_hold.py <sar> <seconds>`), then restores. Same locked-state rule. |
| `pwm_sweep.py` | LASER_PWM scope test (runs on the board): `check` = read-only safety readbacks + PWM2 dump; `sweep` = steps PWMSAR through 50/25/75/6/100 % duty with 4 s holds, then restores. Locked state only (controller and forgectrl stopped, the pulse device closed - the page's takeover): the sweep relocks the latch itself and refuses to write if FIRE reads driven or LASER_ON reads active. |
| `pwm_hold.py` | Holds one PWMSAR value for a scope-measurement window (`pwm_hold.py <sar> <seconds>`), then restores. Same locked-state rule and guard. |
| `fire_test.py` | FIRE drop-timing scope test (runs on the board): A = latch locked (expects nothing on FIRE/LASER_ON), B = latch unlocked / normal end-of-data, U = true underrun. Duty 0 throughout; refuses to unlock if HV reports good. |
| `pwm_stream_test.py` | LASER_PWM stream-path scope test (runs on the board): streams power bytes only (no step bytes, no FIRE bits, `motor_lock=15`, latch locked) through `/dev/glowforge` so the scope verifies the real power path, including the run-start duty reset and the consecutive-power-byte drop; position counters compared before/after. |
| `pwm_stream_test.py` | LASER_PWM stream-path scope test (runs on the board, controller and forgectrl stopped): streams power bytes only (no step bytes, no FIRE bits, `motor_lock=15`, latch locked) through `/dev/glowforge` so the scope verifies the real power path, including the run-start duty reset and the consecutive-power-byte drop; position counters compared before/after. Exit 0 = counters unmoved, idle at the end, no FIRE/emission read back. |
| `gate_a_kernel_drills.py` | Kernel laser-safety drills (run on the board with forgectrl stopped so the pulse device is free): `K1` controlled-stop deceleration floor, `K2` resume waypoint honors the locked latch, `K3` a mid-ramp latch unlock never re-arms the FIRE drive. Software witnesses (`cnc/state`, `laser_enable`, `laser_on`, `laser_on_sampled`, interlock bit 3) plus the PSU-connector LASER_ON scope point; K3 refuses to run if HV reports good. |
| `laser_stream_test.py` | Host-side laser pulse-stream emission harness: runs the native null-sink controller with `GFSINK_DUMP`, drives small laser jobs over TCP, and checks the dumped bytes against the kernel feeder contract (leading power byte, no back-to-back power bytes, FIRE only inside cutting moves, every stream ends FIRE-clear, no FIRE on a stepless gap, no FIRE leak across cycle churn). Runs in the grblHAL repo's CI. |
| `laser_lifecycle_test.py` | Host-side operator-armed-window lifecycle harness (null-sink controller): arm once per job with M5/M3 persistence, the M2 close, sender-change re-consent, the disarm grace counting down in Hold, and arm refusal under a blocking cooling verdict. Runs in the grblHAL repo's CI. |
| `live_fire_drills.py` | **LIVE LASER** drills from a LAN host (`live_fire_drills.py <drill> [host]`, or `GF_HOST`): `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
| `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. |
| `fan_test.py` | Fan/coolant bench (Windows-side): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. |
| `flow_characterize.py` | Coolant flow characterization using the factory temperature curve: baseline → flow → no-flow → recovery, printing the ΔT bands and their separation. Takes the heater duty as an argument (`flow_characterize.py 30`); aborts if downstream passes 45 °C. |
| `flow_matrix.py` | **The flow-detection design matrix** (with `flow_sampler.py`, run on the board from `/usr/share/forgetest/bench/`): duty × duration × flow/no-flow, every run from a common cooled baseline, interleaved repeats. One heating trace yields the metric at every candidate duration, so cost and precision come from the same 60 runs. Prints a cost table, a precision table (mean±sd, worst-case margin, d′) and a ranked shortlist. Env: `FM_DUTIES`, `FM_REPEATS`, `FM_RESULTS`. |
| `flow_sustained.py` | Long-run test of the real re-check cadence via M8: counts verdicts/false faults and tracks whether the loop accumulates heat. |
| `flow_warm_validate.py` | Runs the real check from a heater-warmed baseline. Note the ceiling: 100 % duty pushes the downstream sensor past 50 °C in 30 s while the bulk barely moves, so warm-loop validation above ~23 °C needs the laser, not the heater. |
| `flow_recheck_char.py` | Characterizes short in-run re-checks and the differential metric; shows why over-temp cannot see a stopped pump and why passive warming trends are ambiguous. |
| `gfbench.py` | Not a tool: the helper the board/host tools share - `HOST`/`LOCAL` from `GF_HOST`, `board(cmd)` (local `sh -c` or ssh), the factory coolant conversion `degc()`, `data_path()` (`FORGETEST_BENCH_DATA` or next to the tool), forgectrl's HTTP API with the panel token, `setting(key)` (from forgectrl, or from `/data/forgefirm.conf` on the board while forgectrl is stopped). |
| `fan_test.py` | Fan/coolant bench (board or host; controller running): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. |
| `flow_characterize.py` | Coolant flow characterization using the factory temperature curve (board or host; forgectrl and controller stopped): baseline → flow → no-flow → recovery, printing the ΔT bands and their separation. Takes the heater duty as an argument (`flow_characterize.py 30`); aborts if downstream passes 45 °C. |
| `flow_matrix.py` | **The flow-detection design matrix** (board or host; forgectrl and controller stopped; with `flow_sampler.py` from `/usr/share/forgetest/bench/`): duty × duration × flow/no-flow, every run from a common cooled baseline, interleaved repeats. One heating trace yields the metric at every candidate duration, so cost and precision come from the same 60 runs. Prints a cost table, a precision table (mean±sd, worst-case margin, d′) and a ranked shortlist. `flow_matrix.py [duties] [repeats]` (or env `FM_DUTIES`, `FM_REPEATS`, `FM_RESULTS`); results/log in the bench data directory, resumable. |
| `flow_sustained.py` | Long-run test of the real re-check cadence via M8 (board or host; controller running): counts verdicts/false faults against the configured `cool_flow_rise` and tracks whether the loop accumulates heat. `flow_sustained.py [minutes]`. |
| `flow_warm_validate.py` | Runs the real check from a heater-warmed baseline (board or host; forgectrl and controller stopped; `flow_warm_validate.py [cycles_per_case]`; results/log in the bench data directory; exit 1 if any run is misclassified). Note the ceiling: 100 % duty pushes the downstream sensor past 50 °C in 30 s while the bulk barely moves, so warm-loop validation above ~23 °C needs the laser, not the heater. |
| `flow_recheck_char.py` | Characterizes short in-run re-checks and the differential metric (board or host; forgectrl and controller stopped; `flow_recheck_char.py [heater_pct] [window_s]`); shows why over-temp cannot see a stopped pump and why passive warming trends are ambiguous. |
| `flow_confirm_drill.py` | Coolant flow suspicion/confirmation drill (runs on the board): one continuous M8 session walks the verdict state machine through real pump-off transients — verified → SUSPECT (+ immediate re-check) → cleared → SUSPECT → FAULT (consecutive) → recovered — printing PASS/FAIL per transition. Leaves the machine idle (M9, pump on, heater off). |
| `flow_escalate_drill.py` | Coolant starved-re-check escalation drill (runs on the board against a controller started with a short confirmation budget): with the pump off the job-start check reads SUSPECT, the stagnant loop cannot pass the settle gate inside the budget, and the driver must escalate to FAULT. PASS/FAIL, leaves the machine idle. |
| `flow_escalate_drill.py` | Coolant starved-re-check escalation drill (runs on the board, controller running): sets the engine's confirmation budget `cool_confirm_max_s` to a short value through forgectrl's settings (`flow_escalate_drill.py [budget_s]`, default 60, the setting's minimum) and restores it after; with the pump off the job-start check reads SUSPECT, the stagnant loop cannot pass the settle gate inside the budget, and the engine must escalate to FAULT. PASS/FAIL (exit status), leaves the machine idle. |
| `flow_sampler.py` | Board-side coolant sampler used by the flow tools (`flow_sampler.py <duration_s> <interval_s>`, prints `elapsed,raw_down,raw_up`); run on the board (dev image: `/usr/share/forgetest/bench/`) so cadence does not depend on ssh latency. |
| `temp_calibrate.py` | Coolant temperature spot-check helper (`watch` / `point <measured_C>` / `fit`) — pairs a measured temperature with averaged raw ADC readings and fits a per-machine line to sanity-check the factory curve against a thermometer. |
| `temp_calibrate.py` | Coolant temperature spot-check helper (board or host): `watch [seconds]` / `point <measured_C> [note]` / `fit` — pairs a measured temperature with averaged raw ADC readings and fits a per-machine line to sanity-check the factory curve against a thermometer. Points accumulate in `temp_calibration.json` in the bench data directory. |
| `build-glowforge.sh` | Cross-compiles **grblHAL-glowforge** (the canonical driver repo, `../../../grblHAL-glowforge`) in the Yocto build environment, borrowing the recipe toolchain. Run: `bash <path>/build-glowforge.sh` (from Windows, launch it through the WSL distro from PowerShell; Git Bash mangles /mnt/c paths). Env: `FF_SRC_TOP`, `FF_BUILD_TOP`. This is the production controller build. |
| `build-forgectrl.sh` | Cross-compiles **forgectrl** (the canonical control-daemon repo, `../../../forgectrl`) the same way, borrowing the toolchain from the forgectrl recipe workdir (regenerate with `bitbake forgectrl` after a clean). |
| `accel_fast.py` | Direct-I2C sampler for the two head-bus LIS2HH12s (runs on the board; unbinds/rebinds st-accel around the capture, 800 Hz ODR, ~270 Hz per device polled): optional mid-capture jogs via local grblHAL TCP. CSV to /tmp/accel.csv. The head accel is i2c-3 0x1e. |
@@ -82,11 +90,11 @@ the threshold set at the balanced midpoint of the two bands (14.4 °C).
`flow_warm_validate.py` then re-ran the real check from heater-warmed
baselines (`flow_warm_results.json`).
To reproduce on another machine: set `GF_HOST` (and `GF_SSH` if ssh needs a
wrapper), boot the dev image (it installs `flow_sampler.py` under
`/usr/share/forgetest/bench/`), and run `flow_matrix.py` (env `FM_DUTIES`,
`FM_REPEATS`, `FM_RESULTS`; ~1.6 h for the full matrix, the controller is
stopped for the duration). The same
To reproduce on another machine: boot the dev image and run the
**flow-matrix** tool from the bench page (`#bench`; a takeover, ~1.6 h for
the full matrix; the results land under `/data/forgetest/bench/`), or from a
host with `GF_HOST` (and `GF_SSH` if ssh needs a wrapper), forgectrl and the
controller stopped, `flow_matrix.py [duties] [repeats]`. The same
derivation is also built into forgectrl as the panel's Diagnostics →
**flow-calibrate** tool (3 trials per case at the operating point, reports
both bands and a recommended threshold; the bench value it recommends
+13 -13
View File
@@ -1,18 +1,18 @@
import shlex, socket, subprocess, time
import os
#!/usr/bin/env python3
"""Fan/coolant bench: snapshots the fan PWMs, tachs and coolant readings,
drives M8 (cut-profile fans), then M9 (cooldown -> idle), and prints
each snapshot for the tach readbacks to be judged. Needs the controller
running with forgectrl's cooling engine. Runs on the board or from a
host (gfbench: GF_HOST)."""
import socket
import time
from gfbench import HOST, board as _board, degc
HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
# ssh client used to reach the board; override for a wrapper, e.g.
# GF_SSH='wsl -d <distro> -- ssh'.
SSH = shlex.split(os.environ.get('GF_SSH', 'ssh'))
def board(cmd):
r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none',
'root@' + HOST, cmd],
capture_output=True, text=True, timeout=30)
return r.stdout.strip().replace('\n', ' ')
return _board(cmd).strip().replace('\n', ' ')
ATTRS = ('head/air_assist_pwm head/air_assist_tach head/purge_air '
'thermal/exhaust_pwm thermal/tach_exhaust '
@@ -44,7 +44,7 @@ time.sleep(0.5); drain()
snap('--- baseline (driver idle profile)')
w2 = board('cat /sys/glowforge/pic/water_temp_2')
print('water_temp_2 = %s -> %.1f C' % (w2, int(w2) * -0.09653 + 94))
print('water_temp_2 = %s -> %.1f C' % (w2, degc(w2)))
print()
print('=== M8: cut-profile fans ON (loud) ===')
+7 -33
View File
@@ -10,43 +10,17 @@ climbs. This measures both signatures and prints the separation.
Phases: baseline (heater off) -> flow (heater on, pump on) -> no-flow
(pump off) -> recovery (pump on). Restores heater off / pump on.
Run with the controller stopped, or accept that it will fight you: the
driver only writes the heater on M8/M9 transitions, so an idle driver
leaves this alone.
Drives the heater and pump directly, so it runs with forgectrl (the
thermal-hardware owner) and the controller stopped: the bench page's
takeover does that; from a host, stop them first. Runs on the board or
from a host (gfbench: GF_HOST).
Usage: flow_characterize.py [heater_pct] (default 10)
"""
import math
import os
import shlex
import subprocess
import sys
import time
HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
# ssh client used to reach the board; override for a wrapper, e.g.
# GF_SSH='wsl -d <distro> -- ssh'.
SSH = shlex.split(os.environ.get('GF_SSH', 'ssh'))
# Factory B-equation conversion (see kernel-module-glowforge/UAPI.md).
F = 1024.0 * 1.3
RD = 10000.0
BETA = 3380.0
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
def degc(raw):
if raw <= 0 or raw >= F:
return float('nan')
r = RD / (F / raw - 1.0)
return BETA / math.log(r / RINF) - 273.15
def board(cmd):
r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none',
'root@' + HOST, cmd],
capture_output=True, text=True, timeout=30)
return r.stdout.strip()
from gfbench import board, degc
def sample():
+32 -8
View File
@@ -1,20 +1,26 @@
#!/usr/bin/env python3
"""Bench drill for the starved-re-check escalation path.
Runs ON THE BOARD against a controller started with a short
confirmation budget (GFCOOL_CONFIRM_MAX_S=45; see the runbook for the
manual start line). With the pump off, the job-start check reads
Runs ON THE BOARD with the controller running. The cooling engine's
confirmation budget (`cool_confirm_max_s`, re-read at every run start)
is set to a short value for the drill through forgectrl's settings and
restored afterward. With the pump off, the job-start check reads
over-limit -> SUSPECT; the cooked stagnant loop then cannot pass the
settle gate within 45 s, so the budget expires and the driver must
escalate: "COOLANT FLOW FAULT: no clean re-check within 45 s".
settle gate within the budget, so it expires and the engine must
escalate: "COOLANT FLOW FAULT: no clean re-check within N s".
Usage: flow_escalate_drill.py [budget_s] (default 60, the setting's minimum)
Prints PASS/FAIL and leaves the machine idle: M9 sent, pump on,
heater off.
heater off, the budget setting as it was.
"""
import select
import socket
import sys
import time
from gfbench import forgectrl_post, setting
BUDGET_S = int(sys.argv[1]) if len(sys.argv) > 1 else 60
T0 = time.monotonic()
@@ -57,6 +63,14 @@ def wait_msg(substrs, deadline):
return None
budget_was = setting('cool_confirm_max_s') # None = not set (compiled default)
st, body = forgectrl_post('/settings', data={'cool_confirm_max_s': str(BUDGET_S)})
if st != 200:
log('!! could not set cool_confirm_max_s=%d (POST /settings -> %s %s)' % (BUDGET_S, st, body))
s.close()
sys.exit(2)
log('cool_confirm_max_s=%d for the drill (was %s)' % (BUDGET_S, budget_was or 'unset'))
ok = True
try:
time.sleep(0.5)
@@ -67,10 +81,19 @@ try:
if wait_msg(['FLOW SUSPECT'], el() + 180) is None:
log('!! no SUSPECT within 180 s')
ok = False
elif wait_msg(['no clean re-check within'], el() + 120) is None:
log('!! no escalation FAULT within 120 s of the suspect')
elif wait_msg(['no clean re-check within'], el() + BUDGET_S + 75) is None:
log('!! no escalation FAULT within %d s of the suspect' % (BUDGET_S + 75))
ok = False
finally:
try:
if budget_was is None:
st, _ = forgectrl_post('/settings', params={'cool_confirm_max_s': ''})
else:
st, _ = forgectrl_post('/settings', data={'cool_confirm_max_s': str(budget_was)})
log('cool_confirm_max_s restored to %s (-> %s)' % (budget_was or 'unset', st))
except OSError as e:
log('!! could not restore cool_confirm_max_s: %s' % e)
ok = False
try:
s.sendall(b'M9\n')
time.sleep(1.0)
@@ -83,3 +106,4 @@ finally:
log('--- M9 sent, pump on, heater off')
print('ESCALATION DRILL %s' % ('COMPLETE' if ok else 'FAILED'), flush=True)
sys.exit(0 if ok else 1)
+17 -42
View File
@@ -21,37 +21,32 @@ so slow ambient drift spreads across conditions instead of confounding
any single one.
Safety: aborts a run if downstream passes ABORT_C; heater off and pump
on at every exit path. The controller is stopped for the duration so it
cannot touch the fans or heater, and restarted at the end.
on at every exit path. Drives the heater, pump and fans directly: run
with forgectrl and the controller stopped for the duration (the bench
page's takeover does that; from a host, stop them first). Runs on the
board or from a host (gfbench: GF_HOST).
Output: incremental JSON to flow_matrix_results.json (so partial runs
are still usable) and a summary table at the end.
Output: incremental JSON to flow_matrix_results.json in the bench data
directory (gfbench.data_path; so partial runs are still usable and
resumable) and a summary table at the end.
Usage: flow_matrix.py [duties] [repeats] e.g. flow_matrix.py 10,20,40 5
(or env FM_DUTIES, FM_REPEATS, FM_RESULTS)
"""
import json
import math
import os
import shlex
import statistics
import subprocess
import sys
import time
HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
# ssh client used to reach the board; override for a wrapper, e.g.
# GF_SSH='wsl -d <distro> -- ssh'.
SSH = shlex.split(os.environ.get('GF_SSH', 'ssh'))
HERE = os.path.dirname(os.path.abspath(__file__))
RESULTS = os.path.join(HERE, os.environ.get('FM_RESULTS', 'flow_matrix_results.json'))
from gfbench import board, degc, data_path
# Factory B-equation conversion (kernel-module-glowforge/UAPI.md).
F = 1024.0 * 1.3
RD, BETA = 10000.0, 3380.0
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
RESULTS = data_path(os.environ.get('FM_RESULTS', 'flow_matrix_results.json'))
DUTIES = [int(x) for x in os.environ.get('FM_DUTIES', '10,15,20,30,40,50').split(',')]
REPEATS = int(os.environ.get('FM_REPEATS', '5'))
DUTIES = [int(x) for x in (sys.argv[1] if len(sys.argv) > 1 else
os.environ.get('FM_DUTIES', '10,15,20,30,40,50')).split(',')]
REPEATS = int(sys.argv[2]) if len(sys.argv) > 2 else int(os.environ.get('FM_REPEATS', '5'))
RUN_S = 75
SAMPLE_IV = 1.0
DURATIONS = [15, 20, 25, 30, 40, 50, 60, 75]
@@ -64,21 +59,6 @@ FANS_RUN = ('echo 65535 > /sys/glowforge/thermal/exhaust_pwm; '
'echo 204 > /sys/glowforge/head/air_assist_pwm')
def degc(raw):
raw = float(raw)
if raw <= 0 or raw >= F:
return float('nan')
r = RD / (F / raw - 1.0)
return BETA / math.log(r / RINF) - 273.15
def board(cmd, timeout=120):
r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none',
'root@' + HOST, cmd],
capture_output=True, text=True, timeout=timeout)
return r.stdout
def temps():
o = board('cat /sys/glowforge/pic/water_temp_1 /sys/glowforge/pic/water_temp_2').split()
return degc(o[0]), degc(o[1])
@@ -204,7 +184,7 @@ def summarize(runs, log):
def main():
t_start = time.time()
log_path = os.path.join(HERE, 'flow_matrix_log.txt')
log_path = data_path('flow_matrix_log.txt')
logf = open(log_path, 'a')
def log(msg):
@@ -215,8 +195,6 @@ def main():
log('=== flow matrix started %s' % time.strftime('%Y-%m-%d %H:%M:%S'))
log('duties=%s repeats=%d run=%ds durations=%s' % (DUTIES, REPEATS, RUN_S, DURATIONS))
board('pkill -x grblHAL_glowfor; sleep 1; true')
log('controller stopped for the experiment')
board(FANS_RUN)
safe_state()
@@ -264,10 +242,7 @@ def main():
'echo 0 > /sys/glowforge/thermal/intake_pwm')
summarize(runs, log)
board('cd /data && GFSINK=/dev/glowforge nohup grblHAL_glowforge -p 23 '
'-e /data/EEPROM-glowforge.DAT > /data/glowforge.log 2>&1 & sleep 2; true')
log('controller restarted; total elapsed %.2f h' % ((time.time() - t_start) / 3600))
log('results: %s; total elapsed %.2f h' % (RESULTS, (time.time() - t_start) / 3600))
logf.close()
+5 -25
View File
@@ -35,38 +35,18 @@ the machine).
Usage: flow_recheck_char.py [heater_pct] [window_s] (default 50 30)
Runs both flow and no-flow cases from a comparable loop state and
prints the differential separation. Aborts if downstream passes 45 C.
Drives the heater and pump directly: run with forgectrl and the
controller stopped (the bench page's takeover does that). Runs on the
board or from a host (gfbench: GF_HOST).
"""
import math
import os
import shlex
import subprocess
import sys
import time
HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
# ssh client used to reach the board; override for a wrapper, e.g.
# GF_SSH='wsl -d <distro> -- ssh'.
SSH = shlex.split(os.environ.get('GF_SSH', 'ssh'))
F = 1024.0 * 1.3
RD, BETA = 10000.0, 3380.0
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
from gfbench import board, degc
DOWN_ABORT_C = 45.0
def degc(raw):
r = RD / (F / raw - 1.0)
return BETA / math.log(r / RINF) - 273.15
def board(cmd):
r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none',
'root@' + HOST, cmd],
capture_output=True, text=True, timeout=30)
return r.stdout.strip()
def sample():
o = board('cat /sys/glowforge/pic/water_temp_1 /sys/glowforge/pic/water_temp_2').split()
return degc(int(o[0])), degc(int(o[1]))
+8 -26
View File
@@ -6,38 +6,20 @@ job, and does the loop reach equilibrium or climb without bound? Runs
the driver's real check cadence (M8 held for the duration) with the
cut-profile fans, logging bulk coolant temperature and every verdict.
Needs the controller running with forgectrl's cooling engine (the
verdict lines arrive on the Grbl socket). Runs on the board or from a
host (gfbench: GF_HOST).
Usage: flow_sustained.py [minutes] (default 30)
"""
import math
import os
import shlex
import re
import socket
import subprocess
import sys
import time
HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
# ssh client used to reach the board; override for a wrapper, e.g.
# GF_SSH='wsl -d <distro> -- ssh'.
SSH = shlex.split(os.environ.get('GF_SSH', 'ssh'))
F = 1024.0 * 1.3
RD, BETA = 10000.0, 3380.0
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
from gfbench import HOST, board, degc, setting
def degc(raw):
r = RD / (F / float(raw) - 1.0)
return BETA / math.log(r / RINF) - 273.15
def board(cmd):
r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none',
'root@' + HOST, cmd],
capture_output=True, text=True, timeout=30)
return r.stdout.strip()
THRESHOLD = float(setting('cool_flow_rise', 14.4)) # forgectrl's configured threshold
def temps():
@@ -92,8 +74,8 @@ print()
print('checks run: %d over %.0f min' % (len(verdicts), minutes))
if verdicts:
rises = [v[1] for v in verdicts]
print('rise values: min=%.2f max=%.2f mean=%.2f (threshold 13.7)'
% (min(rises), max(rises), sum(rises) / len(rises)))
print('rise values: min=%.2f max=%.2f mean=%.2f (threshold %.1f)'
% (min(rises), max(rises), sum(rises) / len(rises), THRESHOLD))
print('false faults: %d' % sum(1 for v in verdicts if v[2]))
print('loop temperature: start %.2f -> end %.2f (%+.2f C), peak %.2f'
% (u0, u, u - u0, peak_up))
+11 -37
View File
@@ -10,33 +10,25 @@ heater (which plateaus near 28-29 C - the most it can reach unaided),
then runs the real check at 40% / 50 s with the cut-profile fans, and
alternates flow / no-flow so both cases see the same conditions.
Drives the heater, pump and fans directly: run with forgectrl and the
controller stopped (the bench page's takeover does that; from a host,
stop them first). Runs on the board or from a host (gfbench: GF_HOST).
Results and the log go to the bench data directory (gfbench.data_path).
Usage: flow_warm_validate.py [cycles_per_case] (default 3)
"""
import json
import math
import os
import shlex
import statistics
import subprocess
import sys
import time
HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
# ssh client used to reach the board; override for a wrapper, e.g.
# GF_SSH='wsl -d <distro> -- ssh'.
SSH = shlex.split(os.environ.get('GF_SSH', 'ssh'))
HERE = os.path.dirname(os.path.abspath(__file__))
RESULTS = os.path.join(HERE, 'flow_warm_results.json')
from gfbench import board, degc, data_path, setting
F = 1024.0 * 1.3
RD, BETA = 10000.0, 3380.0
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
RESULTS = data_path('flow_warm_results.json')
DUTY = 40
CHECK_S = 50
THRESHOLD = 13.7
THRESHOLD = float(setting('cool_flow_rise', 14.4)) # forgectrl's configured threshold
WARM_TARGET_C = 25.5 # what a ~19-20 C room permits at 50% duty
WARM_MAX_S = 780
ABORT_C = 48.0
@@ -47,21 +39,6 @@ FANS_OFF = ('echo 0 > /sys/glowforge/thermal/exhaust_pwm; '
'echo 0 > /sys/glowforge/thermal/intake_pwm')
def degc(raw):
raw = float(raw)
if raw <= 1.0 or raw >= F:
return float('nan')
r = RD / (F / raw - 1.0)
return BETA / math.log(r / RINF) - 273.15
def board(cmd, timeout=120):
r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none',
'root@' + HOST, cmd],
capture_output=True, text=True, timeout=timeout)
return r.stdout
_last_good = (22.0, 22.0)
@@ -155,7 +132,7 @@ def check(flow, log):
def main():
cycles = int(sys.argv[1]) if len(sys.argv) > 1 else 3
logf = open(os.path.join(HERE, 'flow_warm_log.txt'), 'a')
logf = open(data_path('flow_warm_log.txt'), 'a')
def log(msg):
print(msg, flush=True)
@@ -164,7 +141,6 @@ def main():
log('=== warm-baseline validation %s duty=%d%% window=%ds threshold=%.1f'
% (time.strftime('%Y-%m-%d %H:%M:%S'), DUTY, CHECK_S, THRESHOLD))
board('pkill -x grblHAL_glowfor; sleep 1; true')
runs = []
try:
@@ -199,11 +175,9 @@ def main():
% (min(nv) - max(fv), THRESHOLD, THRESHOLD - max(fv), min(nv) - THRESHOLD))
wrong = [r for r in runs if ((r['down_rise'] > THRESHOLD) != (not r['flow']))]
log(' misclassified: %d of %d' % (len(wrong), len(runs)))
board('cd /data && GFSINK=/dev/glowforge nohup grblHAL_glowforge -p 23 '
'-e /data/EEPROM-glowforge.DAT > /data/glowforge.log 2>&1 & sleep 2; true')
log('controller restarted')
log('results: %s' % RESULTS)
logf.close()
return 1 if wrong else 0
if __name__ == '__main__':
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""Shared helpers for the bench tools that can run either ON the board or
from a LAN host.
from gfbench import HOST, LOCAL, board, degc, data_path
HOST / LOCAL the machine address and whether this process runs on it.
GF_HOST names a remote machine (host mode: sysfs through
ssh, Grbl and forgectrl over the LAN). Unset, the tool
runs on the board itself when /sys/glowforge exists (local
mode: sysfs directly, Grbl and forgectrl on 127.0.0.1) -
the acceptance tool's bench page runs them that way.
board(cmd) run a shell command on the machine and return its stdout
(local: sh -c; host: ssh, or the client named by GF_SSH,
e.g. GF_SSH='wsl -d <distro> -- ssh').
degc(raw) the factory B-equation coolant conversion
(kernel-module-glowforge/UAPI.md).
data_path(f) where a tool keeps its data files: FORGETEST_BENCH_DATA
when set (the bench page passes <data>/bench/), else next
to the tool.
forgectrl_* the machine-services HTTP API (:8080) with the panel token
from GF_TOKEN or, on the board, /data/forgefirm/panel.token.
"""
import json
import math
import os
import shlex
import subprocess
import urllib.error
import urllib.parse
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
_LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1")
def _resolve():
host = os.environ.get("GF_HOST")
if host:
return host, host in _LOCAL_HOSTS
if os.path.isdir("/sys/glowforge"):
return "127.0.0.1", True
raise SystemExit("set GF_HOST to the machine IP address (or run this on the board)")
HOST, LOCAL = _resolve()
SSH = shlex.split(os.environ.get("GF_SSH", "ssh"))
def board(cmd, timeout=30):
"""stdout of a shell command run on the machine (not stripped)."""
if LOCAL:
argv = ["sh", "-c", cmd]
else:
argv = SSH + ["-o", "PreferredAuthentications=none", "root@" + HOST, cmd]
r = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
return r.stdout
# Factory B-equation conversion: 10k B3380 NTC in a 10k divider behind a
# 1.3x gain stage, 10-bit ADC.
F = 1024.0 * 1.3
RD, BETA = 10000.0, 3380.0
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
def degc(raw):
try:
raw = float(raw)
except (TypeError, ValueError):
return float("nan")
if raw <= 1.0 or raw >= F:
return float("nan")
r = RD / (F / raw - 1.0)
return BETA / math.log(r / RINF) - 273.15
def data_dir():
d = os.environ.get("FORGETEST_BENCH_DATA")
if d:
os.makedirs(d, exist_ok=True)
return d
return HERE
def data_path(name):
return os.path.join(data_dir(), name)
# ------------------------------------------------------------- forgectrl
def forgectrl_base():
return os.environ.get("FORGECTRL_URL") or "http://%s:8080" % HOST
def token():
tok = os.environ.get("GF_TOKEN")
if tok:
return tok
if LOCAL:
try:
with open("/data/forgefirm/panel.token", "r", encoding="utf-8") as f:
return f.read().strip()
except OSError:
pass
return ""
def forgectrl_request(method, path, params=None, data=None, timeout=8.0):
"""(status, body): body is parsed JSON when the response is JSON, else
text. Raises OSError-derived errors when the daemon is unreachable."""
url = forgectrl_base() + path
if params:
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
body = None
hdrs = {"Host": urllib.parse.urlsplit(forgectrl_base()).netloc}
if data is not None:
body = urllib.parse.urlencode(data).encode()
hdrs["Content-Type"] = "application/x-www-form-urlencoded"
tok = token()
if tok:
hdrs["X-ForgeFIRM-Token"] = tok
req = urllib.request.Request(url, data=body, method=method, headers=hdrs)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
status, content, ctype = resp.status, resp.read(), resp.headers.get("Content-Type", "")
except urllib.error.HTTPError as e:
status, content = e.code, e.read()
ctype = e.headers.get("Content-Type", "") if e.headers else ""
text = content.decode("utf-8", "replace")
if "json" in ctype:
try:
return status, json.loads(text)
except ValueError:
pass
return status, text
def forgectrl_get(path, **kw):
return forgectrl_request("GET", path, **kw)
def forgectrl_post(path, **kw):
return forgectrl_request("POST", path, **kw)
SETTINGS_FILE = "/data/forgefirm.conf"
def setting(key, default=None):
"""One shared machine setting (forgectrl's /settings; on the board with
forgectrl stopped - a takeover - the settings file itself), or default
when unset or unreadable."""
try:
st, body = forgectrl_get("/settings")
if st == 200 and isinstance(body, dict):
val = body.get(key)
return default if val in (None, "") else val
except OSError:
pass
if LOCAL:
try:
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
if k.strip() == key:
v = v.strip()
return default if v == "" else v
except OSError:
pass
return default
+29 -17
View File
@@ -1,10 +1,13 @@
#!/usr/bin/env python3
"""Live-fire bench drills - Phases 4, 5, 6. Runs from a LAN host against
grblHAL over TCP (argv[1] or GF_HOST, port 23) and forgectrl over HTTP
(:8080). LIVE LASER: the operator must be armed with eye protection, a
fire watch, an extinguisher, and the exhaust running. Every drill waits
for the operator to press the physical arm button before the machine
fires; nothing here defeats that gate.
"""Live-fire bench drills - Phases 4, 5, 6. Runs on the board (the bench
page) or from a LAN host, against grblHAL over TCP (port 23) and
forgectrl over HTTP (:8080); the machine is GF_HOST, default 127.0.0.1.
LIVE LASER: the operator must be armed with eye protection, a fire
watch, an extinguisher, and the exhaust running. Every drill waits for
the operator to press the physical arm button before the machine fires;
nothing here defeats that gate.
Usage: live_fire_drills.py <drill> [S] [F] (S, F used by ircut)
Drills (pass a name):
witness Phase 5 A-1/A-2/A-5: a short vector mark at S400. Samples
@@ -29,15 +32,15 @@ Drills (pass a name):
engine's own "run telemetry" line is the record. Run it
>= 3 times on representative material; the highest peak
delta sizes cool_fire_ir_delta.
ircut [host] [S] [F] e.g. ircut 192.0.2.1 1000 300
ircut [S] [F] e.g. ircut 1000 300
expstop Armed kill on the EXPECTED-stop path: start a mark job,
then mid-burn POST /controller/stop (the supervisor stops
the controller: SIGTERM, reap, exit safing). PASS: emission
drops to 0 within a few samples of the stop and stays 0,
the kernel is not running, and POST /controller/start
is a SEPARATE step (`ctrlstart`, run after the operator has
judged the stop). Needs the panel token in GF_TOKEN
(cat /data/forgefirm/panel.token on the board).
judged the stop). Needs the panel token: GF_TOKEN, or
/data/forgefirm/panel.token when running on the board.
ctrlstart POST /controller/start after an expstop; no motion, no laser.
The G-4 arm-refuses-when-a-fire-gate-is-active drill is operator-manual
@@ -50,12 +53,21 @@ import sys
import time
import urllib.request
HOST = sys.argv[2] if len(sys.argv) > 2 else os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('usage: live_fire_drills.py <drill> [host] (or set GF_HOST)')
HOST = os.environ.get('GF_HOST') or '127.0.0.1'
PORT = 23
BASE = 'http://%s:8080' % HOST
def panel_token():
tok = os.environ.get('GF_TOKEN', '')
if tok:
return tok
try:
with open('/data/forgefirm/panel.token') as f:
return f.read().strip()
except OSError:
return ''
# Ambient lid-IR baseline (2026-08-14, lid closed, idle): per-channel means.
IR_BASELINE = [37.3, 36.3, 39.5, 40.0]
@@ -357,8 +369,8 @@ def drill_faultpos(g):
def drill_ircut(g):
power = int(sys.argv[3]) if len(sys.argv) > 3 else 1000
feed = int(sys.argv[4]) if len(sys.argv) > 4 else 300
power = int(sys.argv[2]) if len(sys.argv) > 2 else 1000
feed = int(sys.argv[3]) if len(sys.argv) > 3 else 300
print('=== lid-IR characterization: S%d F%d 30 mm square ===' % (power, feed))
print('connect: %s' % prepare(g))
base = sample_forgectrl()
@@ -405,7 +417,7 @@ def drill_ircut(g):
def post_ctrl(action):
# http.client preserves the header-name case exactly as given.
import http.client
tok = os.environ.get('GF_TOKEN', '')
tok = panel_token()
c = http.client.HTTPConnection(HOST, 8080, timeout=8)
c.putrequest('POST', '/controller/' + action)
c.putheader('X-ForgeFIRM-Token', tok)
@@ -419,8 +431,8 @@ def post_ctrl(action):
def drill_expstop(g):
print('=== armed kill on the expected-stop path (POST /controller/stop) ===')
if not os.environ.get('GF_TOKEN'):
raise SystemExit('set GF_TOKEN to the panel token first')
if not panel_token():
raise SystemExit('set GF_TOKEN to the panel token first (or run on the board)')
print('connect: %s' % prepare(g))
arm_cue()
job = ['G91', 'G21', 'M4', 'S400',
+35 -7
View File
@@ -1,6 +1,12 @@
#!/usr/bin/env python3
"""Hold LASER_PWM at one duty value for a measurement window, then restore.
Usage: pwm_hold.py [sar] [seconds] (defaults: 8, 30)"""
Usage: pwm_hold.py [sar] [seconds] (defaults: 8, 30)
The duty register (PWMSAR) is the laser power SETPOINT only. Locked
state only: run with the controller and forgectrl stopped (the bench
page's takeover), the pulse device closed. The latch is relocked here
as well, and the hold refuses to write if the FIRE line reads driven or
LASER_ON reads active."""
import mmap, struct, sys, time
PWM2_BASE = 0x02084000
@@ -9,6 +15,26 @@ SAR_OFF = 0x0C
sar = int(sys.argv[1]) if len(sys.argv) > 1 else 8
secs = int(sys.argv[2]) if len(sys.argv) > 2 else 30
def rd(name):
with open('/sys/glowforge/' + name) as f:
return f.read().strip()
def wr(name, val):
with open('/sys/glowforge/' + name, 'w') as f:
f.write(str(val))
try:
wr('cnc/laser_latch', 1)
except OSError as e:
raise SystemExit('could not lock the laser latch: %s' % e)
time.sleep(0.2)
en, on = rd('cnc/laser_enable'), rd('cnc/laser_on')
if en != '0' or on != '0':
raise SystemExit('not the locked state: laser_enable=%s laser_on=%s' % (en, on))
with open('/dev/mem', 'r+b') as f:
m = mmap.mmap(f.fileno(), 4096, mmap.MAP_SHARED,
mmap.PROT_READ | mmap.PROT_WRITE, offset=PWM2_BASE)
@@ -16,9 +42,11 @@ with open('/dev/mem', 'r+b') as f:
m[SAR_OFF:SAR_OFF + 4] = struct.pack('<I', sar)
print('PWMSAR=%d held for %d s (was %d)...' % (sar, secs, sar0))
sys.stdout.flush()
time.sleep(secs)
m[SAR_OFF:SAR_OFF + 4] = struct.pack('<I', sar0)
print('restored PWMSAR=%d' % sar0)
with open('/sys/glowforge/cnc/laser_on_sampled') as s:
print('laser_on_sampled =', s.read().strip())
m.close()
try:
time.sleep(secs)
finally:
m[SAR_OFF:SAR_OFF + 4] = struct.pack('<I', sar0)
print('restored PWMSAR=%d' % sar0)
print('laser_enable=%s laser_on=%s laser_on_sampled=%s'
% (rd('cnc/laser_enable'), rd('cnc/laser_on'), rd('cnc/laser_on_sampled')))
m.close()
+12 -5
View File
@@ -12,8 +12,12 @@ Safety posture:
- laser_latch re-asserted locked; lid closed; HV watchdog untouched.
- Position counters compared before/after - must be identical.
- streaming stays 0: end-of-data is a normal completion.
Runs with the controller and forgectrl stopped (the bench page's
takeover). Exit status 0 when the counters did not move, the run ended
in an idle state and no FIRE/emission was read back; 1 otherwise.
"""
import fcntl, mmap, os, struct, time
import fcntl, mmap, os, struct, sys, time
TICK_HZ = 10000
PWM2_BASE = 0x02084000
@@ -88,15 +92,18 @@ try:
dt = time.time() - t0
print('done: state=%s after %.1f s' % (state, dt))
pos_after = rd_pos()
print('post: pos before=%s after=%s MOVED=%s'
% (pos_before, pos_after, pos_before[:3] != pos_after[:3]))
moved = pos_before[:3] != pos_after[:3]
print('post: pos before=%s after=%s MOVED=%s' % (pos_before, pos_after, moved))
en, on, sampled = rd('cnc/laser_enable'), rd('cnc/laser_on'), rd('cnc/laser_on_sampled')
print('post: laser_enable=%s laser_on=%s laser_on_sampled=%s faults=%s underruns=%s'
% (rd('cnc/laser_enable'), rd('cnc/laser_on'),
rd('cnc/laser_on_sampled'), rd('cnc/faults'), rd('cnc/underruns')))
% (en, on, sampled, rd('cnc/faults'), rd('cnc/underruns')))
print('post: PWMSAR=%d (duty after end-of-data)' % pwmsar())
ok = (not moved) and state == 'idle' and en == '0' and on == '0' and sampled == '0'
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
wr('cnc/disable', 1)
print('safe state restored: state=%s' % rd('cnc/state'))
print('RESULT %s' % ('PASS' if ok else 'FAIL'))
sys.exit(0 if ok else 1)
+28
View File
@@ -8,6 +8,11 @@ sweep: step the PWM duty register through known values with pauses so
The duty register (PWMSAR) is the laser power SETPOINT only. No motion
subsystem is touched (steppers are disabled before this runs), no
stream runs, the laser latch is locked, FIRE is never asserted.
Locked state only: run with the controller and forgectrl stopped (the
bench page's takeover), the pulse device closed. The latch is relocked
here as well, and the sweep refuses to write if the FIRE line reads
driven or LASER_ON reads active. Usage: pwm_sweep.py [check|sweep]
"""
import mmap, struct, sys, time
@@ -34,6 +39,26 @@ def dump_regs(m):
return sar, pr
def wr(name, val):
with open('/sys/glowforge/' + name, 'w') as f:
f.write(str(val))
def locked_state():
"""The latch commanded locked, FIRE not driven, no emission."""
try:
wr('cnc/laser_latch', 1)
except OSError as e:
print('!! could not lock the laser latch: %s' % e)
return False
time.sleep(0.2)
en, on = rd('cnc/laser_enable'), rd('cnc/laser_on')
if en != '0' or on != '0':
print('!! not the locked state: laser_enable=%s laser_on=%s' % (en, on))
return False
return True
def safety_readback():
print('cnc/state =', rd('cnc/state'))
print('cnc/laser_on =', rd('cnc/laser_on'))
@@ -56,6 +81,9 @@ with open('/dev/mem', 'r+b') as f:
sar0, pr = dump_regs(m)
if mode == 'sweep':
if not locked_state():
m.close()
sys.exit(2)
period = pr + 2
steps = [(64, '50%'), (32, '25%'), (96, '75%'), (8, '6%'), (127, '100%')]
print('--- duty sweep: 4 s per step, watch the scope')
+33 -38
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""Coolant temperature spot-check helper (runs on Windows, reads the
board over ssh).
"""Coolant temperature spot-check helper (runs on the board or from a
host; gfbench: GF_HOST).
The raw->Celsius conversion in UAPI.md is the factory B-equation (10k
B3380 NTC in a 10k divider behind a 1.3x gain stage, 10-bit ADC). This
@@ -9,47 +9,30 @@ the machine's raw ADC readings - and fits a per-machine line to
cross-check that curve against a thermometer.
Usage:
temp_calibrate.py watch live raw + current-formula C
temp_calibrate.py point <measured_C> record a calibration point
temp_calibrate.py fit fit and print the calibration
temp_calibrate.py watch [seconds] live raw + current-formula C
(default 60 s)
temp_calibrate.py point <measured_C> [note] record a calibration point
temp_calibrate.py fit fit and print the calibration
Points accumulate in temp_calibration.json next to this script. Take at
least two points as far apart in temperature as practical (e.g. cold
Points accumulate in temp_calibration.json in the bench data directory
(gfbench.data_path: next to this script, or FORGETEST_BENCH_DATA). Take
at least two points as far apart in temperature as practical (e.g. cold
machine in the morning, and warm after a fan-off soak with the flow
heater on).
"""
import json
import math
import os
import shlex
import subprocess
import sys
import time
HOST = os.environ.get('GF_HOST')
if not HOST:
raise SystemExit('set GF_HOST to the machine IP address')
# ssh client used to reach the board; override for a wrapper, e.g.
# GF_SSH='wsl -d <distro> -- ssh'.
SSH = shlex.split(os.environ.get('GF_SSH', 'ssh'))
STORE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp_calibration.json')
from gfbench import board, degc, data_path
STORE = data_path('temp_calibration.json')
def uapi_c(raw):
"""The UAPI.md factory conversion (B-equation NTC behind divider + gain)."""
adc_f = 1024.0 * 1.3
if raw <= 0 or raw >= adc_f:
return float('nan')
rinf = 10000.0 * math.exp(-3380.0 / 298.15)
r = 10000.0 / (adc_f / raw - 1.0)
return 3380.0 / math.log(r / rinf) - 273.15
def board(cmd):
r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none',
'root@' + HOST, cmd],
capture_output=True, text=True, timeout=30)
return r.stdout.strip()
return degc(raw)
def raws(samples=5, delay=1.0):
@@ -93,24 +76,36 @@ def main():
mode = sys.argv[1] if len(sys.argv) > 1 else 'watch'
if mode == 'watch':
print('raw1(down) raw2(up) uapi-C down/up (ctrl-C to stop)')
while True:
seconds = float(sys.argv[2]) if len(sys.argv) > 2 else 60.0
print('raw1(down) raw2(up) uapi-C down/up (%.0f s)' % seconds)
t0 = time.time()
while time.time() - t0 < seconds:
r1, r2 = raws(1, 0)
print(' %6.1f %6.1f %.2f / %.2f'
% (r1, r2, uapi_c(r1), uapi_c(r2)))
if r1 is None:
print(' (no reading)')
else:
print(' %6.1f %6.1f %.2f / %.2f'
% (r1, r2, uapi_c(r1), uapi_c(r2)), flush=True)
time.sleep(2)
elif mode == 'point':
measured = float(sys.argv[2])
try:
measured = float(sys.argv[2])
except (IndexError, ValueError):
print('point needs the thermometer reading in C (value)')
return 2
note = sys.argv[3] if len(sys.argv) > 3 else ''
print('sampling raws (10 s)...')
print('sampling raws (10 s)...', flush=True)
r1, r2 = raws()
if r1 is None:
print('no readings from the machine')
return 1
data = load()
data['points'].append({'measured_c': measured, 'raw1': r1, 'raw2': r2,
'note': note, 'when': time.strftime('%Y-%m-%d %H:%M:%S')})
save(data)
print('recorded: measured %.2f C raw1=%.1f raw2=%.1f (%d points total)'
% (measured, r1, r2, len(data['points'])))
print('recorded: measured %.2f C raw1=%.1f raw2=%.1f (%d points total in %s)'
% (measured, r1, r2, len(data['points']), STORE))
elif mode == 'fit':
data = load()