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
+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()