Files
forgefirm/scripts/bench/flow_characterize.py
T
ScottW514 cd8a01a3d9 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.
2026-08-16 16:28:39 -04:00

102 lines
3.6 KiB
Python

#!/usr/bin/env python3
"""Characterize the coolant flow signature using the FACTORY temperature
curve, and recommend flow-fault thresholds.
The loop heater sits between the two water sensors; flowing coolant
carries its heat away, so downstream-minus-upstream settles at a small
stable delta. Stopping the pump lets that heat pool, and the delta
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.
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 sys
import time
from gfbench import board, degc
def sample():
out = board('cat /sys/glowforge/pic/water_temp_1 /sys/glowforge/pic/water_temp_2').split()
if len(out) != 2:
return None
d, u = degc(int(out[0])), degc(int(out[1]))
return d, u, d - u
DOWN_ABORT_C = 45.0 # never cook the loop while characterizing
def phase(tag, seconds, interval=10, settle=0):
"""Log a phase; return the deltas after the settle period."""
t0 = time.time()
keep = []
while time.time() - t0 < seconds:
s = sample()
if s:
el = time.time() - t0
print(' %-7s t=%3.0fs down=%5.2f up=%5.2f dT=%+5.2f'
% (tag, el, s[0], s[1], s[2]), flush=True)
if el >= settle:
keep.append(s[2])
if s[0] >= DOWN_ABORT_C:
print(' ABORT: downstream %.1f C >= %.1f C safety limit'
% (s[0], DOWN_ABORT_C), flush=True)
board('echo 1 > /sys/glowforge/thermal/water_pump_on; '
'echo 0 > /sys/glowforge/thermal/heater_pwm')
break
time.sleep(interval)
return keep
def stats(name, ds):
if not ds:
print('%s: no samples' % name)
return None, None
print('%s: n=%d min=%+.2f max=%+.2f mean=%+.2f'
% (name, len(ds), min(ds), max(ds), sum(ds) / len(ds)))
return min(ds), max(ds)
heater_pct = int(sys.argv[1]) if len(sys.argv) > 1 else 10
heater_pwm = str(int(65535 * heater_pct / 100))
print('=== baseline: heater off, pump on (60 s)')
board('echo 1 > /sys/glowforge/thermal/water_pump_on; echo 0 > /sys/glowforge/thermal/heater_pwm')
base = phase('base', 60, 10, 30)
stats('baseline dT', base)
print('=== flow: heater %d%%, pump on (240 s; first 60 s ignored while dT establishes)' % heater_pct)
board('echo ' + heater_pwm + ' > /sys/glowforge/thermal/heater_pwm')
flow = phase('flow', 240, 10, 60)
fmin, fmax = stats('flow dT', flow)
print('=== no-flow: pump OFF, heater still on (150 s; first 20 s ignored)')
board('echo 0 > /sys/glowforge/thermal/water_pump_on')
noflow = phase('noflow', 150, 10, 20)
nmin, nmax = stats('no-flow dT', noflow)
print('=== recovery: pump on, heater off (90 s)')
board('echo 1 > /sys/glowforge/thermal/water_pump_on; echo 0 > /sys/glowforge/thermal/heater_pwm')
phase('recov', 90, 15)
print()
if fmax is not None and nmin is not None:
print('flow band: up to %+.2f C' % fmax)
print('no-flow band: from %+.2f C' % nmin)
if nmin > fmax:
fault = (fmax + nmin) / 2.0
print('separation: %.2f C -> suggested fault threshold %.2f C, re-arm %.2f C'
% (nmin - fmax, fault, fault - 0.4))
else:
print('BANDS OVERLAP - flow detection unreliable at %d%% heater; try a higher duty'
% heater_pct)
print('restored: pump on, heater off')