Flow-detection experimental record and bench tooling

Adds the design matrix and its supporting tools, and records in BRINGUP
what the 60-run matrix overturned: sub-40-percent duty mimics flow
(three of five dead-pump trials looked healthier than a working pump),
the operating point and threshold now rest on 25 pooled observations,
periodic re-checks are thermally free with the fans running, and the
settle gate closes a bench-proven miss. Also records what is NOT yet
validated - warm-loop baselines and behaviour under laser heating - as
first-light commissioning items.
This commit is contained in:
ScottW514
2026-08-02 23:51:31 -04:00
parent 8fa10f7b28
commit f92147093e
12 changed files with 5244 additions and 1 deletions
+4
View File
@@ -13,6 +13,10 @@ target board (dev image, python3 present) unless noted.
| `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. All gates PASSED with it 2026-08-02 (2.0000 s pulses exact, both paths). |
| `fan_test.py` | Fan/coolant bench (Windows-side): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. All-green 2026-08-02. |
| `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. This is what showed the 10 % scheme was unusable (0.04 °C gap) and sized the 30 % check. |
| `flow_matrix.py` | **The flow-detection design matrix** (with `flow_sampler.py`, which lives on the board at `/data/`): 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. 40 min ⇒ 0 false faults, loop *cooled* 2 °C. |
| `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; documents two dead ends (over-temp cannot see a stopped pump; passive warming trends are ambiguous). |
| `temp_calibrate.py` | Coolant temperature calibration helper (`watch` / `point <measured_C>` / `fit`) — pairs a measured temperature with averaged raw ADC readings and fits the line. Used to sanity-check the factory curve against a thermometer. |
| `build-glowforge.sh` | Cross-compiles **grblHAL-glowforge** (the canonical driver repo, `../../../grblHAL-glowforge`) in the forge-yocto WSL distro. Run: `wsl -d forge-yocto -- bash <path>/build-glowforge.sh` (from PowerShell; Git Bash mangles /mnt/c paths). This is the production controller build. |
| `build-feeder.sh` | Cross-compiles `feeder.c` the same way. |
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""Coolant flow-detection design matrix: heating cost AND detection
precision across heater duty cycles and check durations.
Method. For every (duty, case) the loop is first cooled with the
cut-profile fans back to a common baseline, so every run starts from the
same thermal state and the measured rises are comparable. Then the
heater runs at the given duty while both sensors are sampled at 1 Hz.
Because a single heating trace contains the rise at every elapsed time,
one run yields the metric for ALL candidate check durations at once.
Two questions answered from the same data:
1. COST - how much does the check heat the loop? (upstream/bulk rise
in the flow case, per duty per duration)
2. PRECISION - how well does it discriminate? (downstream rise, flow
vs no-flow: separation, worst-case margin, and d' =
separation / pooled standard deviation)
Repeats are interleaved (all conditions in round 1, then round 2, ...)
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.
Output: incremental JSON to flow_matrix_results.json (so partial runs
are still usable) and a summary table at the end.
"""
import json
import math
import os
import statistics
import subprocess
import sys
import time
HOST = '172.16.1.97'
HERE = os.path.dirname(os.path.abspath(__file__))
RESULTS = os.path.join(HERE, os.environ.get('FM_RESULTS', 'flow_matrix_results.json'))
# 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)
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'))
RUN_S = 75
SAMPLE_IV = 1.0
DURATIONS = [15, 20, 25, 30, 40, 50, 60, 75]
ABORT_C = 48.0 # below the factory 50 C idle ceiling
BASE_TOL_C = 0.5 # cooldown target: base + this
COOL_MAX_S = 300
FANS_RUN = ('echo 65535 > /sys/glowforge/thermal/exhaust_pwm; '
'echo 43278 > /sys/glowforge/thermal/intake_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(['wsl', '-d', 'forge-yocto', '--', '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])
def heater(pct):
board('echo %d > /sys/glowforge/thermal/heater_pwm' % int(65535 * pct / 100))
def pump(on):
board('echo %d > /sys/glowforge/thermal/water_pump_on' % (1 if on else 0))
def safe_state():
heater(0)
pump(True)
def cool_to(base, log):
"""Cool with cut-profile fans until upstream is back near base."""
safe_state()
board(FANS_RUN)
t0 = time.time()
while time.time() - t0 < COOL_MAX_S:
d, u = temps()
if u <= base + BASE_TOL_C:
return u, time.time() - t0
time.sleep(10)
d, u = temps()
log(' (cooldown timeout at %.2f C, target %.2f)' % (u, base + BASE_TOL_C))
return u, time.time() - t0
def run_case(duty, flow, log):
"""One heating run. Returns dict of rises at each candidate duration."""
pump(flow)
time.sleep(3)
d0, u0 = temps()
heater(duty)
raw = board('python3 /data/flow_sampler.py %d %.1f' % (RUN_S, SAMPLE_IV),
timeout=RUN_S + 60)
heater(0)
pump(True)
series = []
for line in raw.strip().splitlines():
try:
el, r1, r2 = line.split(',')
series.append((float(el), degc(r1), degc(r2)))
except ValueError:
continue
aborted_at = None
for el, d, u in series:
if d >= ABORT_C:
aborted_at = el
break
out = {'duty': duty, 'flow': flow, 'start_down': d0, 'start_up': u0,
'aborted_at': aborted_at, 'samples': len(series), 'at': {}}
for target in DURATIONS:
if aborted_at is not None and target > aborted_at:
continue
near = [s for s in series if s[0] <= target]
if not near:
continue
el, d, u = near[-1]
if abs(el - target) > 4: # no sample close enough
continue
out['at'][str(target)] = {'t': el, 'down_rise': d - d0, 'up_rise': u - u0,
'diff_rise': (d - d0) - (u - u0), 'down_abs': d}
return out
def summarize(runs, log):
log('')
log('=== COST: bulk (upstream) rise during a check, flow case, degrees C')
log(' duty ' + ''.join('%8s' % ('%ds' % t) for t in DURATIONS))
for duty in DUTIES:
cells = []
for t in DURATIONS:
vals = [r['at'][str(t)]['up_rise'] for r in runs
if r['duty'] == duty and r['flow'] and str(t) in r['at']]
cells.append('%8s' % ('%.2f' % statistics.mean(vals) if vals else '-'))
log(' %4d%%' % duty + ''.join(cells))
log('')
log('=== PRECISION: downstream-rise discrimination (flow vs no-flow)')
log(' duty dur flow mean+-sd noflow mean+-sd sep worst d-prime')
best = []
for duty in DUTIES:
for t in DURATIONS:
fv = [r['at'][str(t)]['down_rise'] for r in runs
if r['duty'] == duty and r['flow'] and str(t) in r['at']]
nv = [r['at'][str(t)]['down_rise'] for r in runs
if r['duty'] == duty and not r['flow'] and str(t) in r['at']]
if len(fv) < 2 or len(nv) < 2:
continue
fm, fsd = statistics.mean(fv), statistics.stdev(fv)
nm, nsd = statistics.mean(nv), statistics.stdev(nv)
sep = nm - fm
worst = min(nv) - max(fv)
pooled = math.sqrt((fsd ** 2 + nsd ** 2) / 2) or 1e-9
dprime = sep / pooled
cost = statistics.mean([r['at'][str(t)]['up_rise'] for r in runs
if r['duty'] == duty and r['flow'] and str(t) in r['at']])
best.append((dprime, worst, duty, t, fm, fsd, nm, nsd, sep, cost))
log(' %4d%% %4ds %6.2f+-%4.2f %6.2f+-%4.2f %5.2f %+5.2f %5.1f'
% (duty, t, fm, fsd, nm, nsd, sep, worst, dprime))
log('')
log('=== RANKED by d-prime (separation in pooled standard deviations)')
log(' rank duty dur d-prime worst-margin bulk-cost threshold')
for i, b in enumerate(sorted(best, reverse=True)[:12], 1):
dprime, worst, duty, t, fm, fsd, nm, nsd, sep, cost = b
log(' %4d %4d%% %4ds %7.1f %+11.2f %8.2f %8.2f'
% (i, duty, t, dprime, worst, cost, (fm + nm) / 2))
log('')
log(' (worst-margin = min(no-flow) - max(flow); positive means every')
log(' observed no-flow run exceeded every observed flow run.')
log(' bulk-cost = degrees C added to the loop per check.)')
def main():
t_start = time.time()
log_path = os.path.join(HERE, 'flow_matrix_log.txt')
logf = open(log_path, 'a')
def log(msg):
print(msg, flush=True)
logf.write(msg + '\n')
logf.flush()
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()
log('initial settle (180 s with fans)...')
time.sleep(180)
d, u = temps()
base = u
log('baseline: down=%.2f up=%.2f (target for every run: <= %.2f)'
% (d, u, base + BASE_TOL_C))
runs = []
if os.path.exists(RESULTS):
try:
runs = json.load(open(RESULTS)).get('runs', [])
log('resuming with %d existing runs' % len(runs))
except Exception:
runs = []
total = REPEATS * len(DUTIES) * 2
n = 0
try:
for rep in range(REPEATS):
for duty in DUTIES:
for flow in (True, False):
n += 1
start_t, cool_s = cool_to(base, log)
r = run_case(duty, flow, log)
r['rep'] = rep
r['cooldown_s'] = round(cool_s)
r['base_at_start'] = start_t
runs.append(r)
json.dump({'base': base, 'runs': runs}, open(RESULTS, 'w'), indent=1)
at30 = r['at'].get('30') or r['at'].get('25') or {}
log(' [%2d/%2d] rep%d duty%3d%% %-7s start=%.2f down_rise@%s=%s %s'
% (n, total, rep + 1, duty, 'FLOW' if flow else 'NO-FLOW',
start_t, '30s' if '30' in r['at'] else '25s',
('%.2f' % at30['down_rise']) if at30 else 'n/a',
('ABORT@%.0fs' % r['aborted_at']) if r['aborted_at'] else ''))
log(' elapsed %.1f h' % ((time.time() - t_start) / 3600))
except KeyboardInterrupt:
log('interrupted - summarizing what we have')
finally:
safe_state()
board('echo 0 > /sys/glowforge/thermal/exhaust_pwm; '
'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))
logf.close()
if __name__ == '__main__':
sys.exit(main())
+205
View File
@@ -0,0 +1,205 @@
=== flow matrix started 2026-08-02 20:20:59
duties=[10, 15, 20, 30, 40, 50] repeats=5 run=75s durations=[15, 20, 25, 30, 40, 50, 60, 75]
controller stopped for the experiment
initial settle (180 s with fans)...
baseline: down=22.62 up=22.46 (target for every run: <= 22.96)
[ 1/60] rep1 duty 10% FLOW start=22.77 down_rise@30s=3.93
elapsed 0.1 h
[ 2/60] rep1 duty 10% NO-FLOW start=22.38 down_rise@30s=3.10
elapsed 0.1 h
[ 3/60] rep1 duty 15% FLOW start=22.85 down_rise@30s=4.73
elapsed 0.1 h
[ 4/60] rep1 duty 15% NO-FLOW start=22.38 down_rise@30s=3.45
elapsed 0.2 h
[ 5/60] rep1 duty 20% FLOW start=22.77 down_rise@30s=3.64
elapsed 0.2 h
[ 6/60] rep1 duty 20% NO-FLOW start=22.46 down_rise@30s=6.82
elapsed 0.2 h
[ 7/60] rep1 duty 30% FLOW start=22.23 down_rise@30s=8.41
elapsed 0.2 h
[ 8/60] rep1 duty 30% NO-FLOW start=22.30 down_rise@30s=11.03
elapsed 0.3 h
[ 9/60] rep1 duty 40% FLOW start=22.93 down_rise@30s=11.46
elapsed 0.3 h
[10/60] rep1 duty 40% NO-FLOW start=22.46 down_rise@30s=15.27
elapsed 0.3 h
[11/60] rep1 duty 50% FLOW start=22.23 down_rise@30s=14.75
elapsed 0.3 h
[12/60] rep1 duty 50% NO-FLOW start=22.54 down_rise@30s=18.74
elapsed 0.4 h
[13/60] rep2 duty 10% FLOW start=21.84 down_rise@30s=3.14
elapsed 0.4 h
[14/60] rep2 duty 10% NO-FLOW start=22.30 down_rise@30s=2.29
elapsed 0.4 h
[15/60] rep2 duty 15% FLOW start=22.15 down_rise@30s=3.46
elapsed 0.5 h
[16/60] rep2 duty 15% NO-FLOW start=22.77 down_rise@30s=4.08
elapsed 0.5 h
[17/60] rep2 duty 20% FLOW start=22.15 down_rise@30s=4.69
elapsed 0.5 h
[18/60] rep2 duty 20% NO-FLOW start=22.77 down_rise@30s=5.51
elapsed 0.5 h
[19/60] rep2 duty 30% FLOW start=21.99 down_rise@30s=7.17
elapsed 0.6 h
[20/60] rep2 duty 30% NO-FLOW start=22.93 down_rise@30s=7.10
elapsed 0.6 h
[21/60] rep2 duty 40% FLOW start=22.62 down_rise@30s=8.82
elapsed 0.6 h
[22/60] rep2 duty 40% NO-FLOW start=22.62 down_rise@30s=13.46
elapsed 0.6 h
[23/60] rep2 duty 50% FLOW start=22.30 down_rise@30s=12.66
elapsed 0.7 h
[24/60] rep2 duty 50% NO-FLOW start=22.54 down_rise@30s=18.18
elapsed 0.7 h
[25/60] rep3 duty 10% FLOW start=22.07 down_rise@30s=2.43
elapsed 0.7 h
[26/60] rep3 duty 10% NO-FLOW start=22.07 down_rise@30s=3.40
elapsed 0.7 h
[27/60] rep3 duty 15% FLOW start=22.62 down_rise@30s=4.40
elapsed 0.8 h
[28/60] rep3 duty 15% NO-FLOW start=21.76 down_rise@30s=4.62
elapsed 0.8 h
[29/60] rep3 duty 20% FLOW start=22.85 down_rise@30s=4.35
elapsed 0.8 h
[30/60] rep3 duty 20% NO-FLOW start=21.68 down_rise@30s=4.79
elapsed 0.8 h
[31/60] rep3 duty 30% FLOW start=22.30 down_rise@30s=9.11
elapsed 0.9 h
[32/60] rep3 duty 30% NO-FLOW start=22.15 down_rise@30s=7.28
elapsed 0.9 h
[33/60] rep3 duty 40% FLOW start=21.91 down_rise@30s=10.00
elapsed 0.9 h
[34/60] rep3 duty 40% NO-FLOW start=22.85 down_rise@30s=14.55
elapsed 0.9 h
[35/60] rep3 duty 50% FLOW start=22.62 down_rise@30s=12.71
elapsed 1.0 h
[36/60] rep3 duty 50% NO-FLOW start=22.62 down_rise@30s=18.19
elapsed 1.0 h
[37/60] rep4 duty 10% FLOW start=22.54 down_rise@30s=2.75
elapsed 1.0 h
[38/60] rep4 duty 10% NO-FLOW start=20.75 down_rise@30s=2.92
elapsed 1.1 h
[39/60] rep4 duty 15% FLOW start=22.15 down_rise@30s=4.17
elapsed 1.1 h
[40/60] rep4 duty 15% NO-FLOW start=22.62 down_rise@30s=4.71
elapsed 1.1 h
[41/60] rep4 duty 20% FLOW start=21.37 down_rise@30s=5.48
elapsed 1.1 h
[42/60] rep4 duty 20% NO-FLOW start=22.23 down_rise@30s=4.77
elapsed 1.2 h
[43/60] rep4 duty 30% FLOW start=22.46 down_rise@30s=6.41
elapsed 1.2 h
[44/60] rep4 duty 30% NO-FLOW start=22.62 down_rise@30s=6.84
elapsed 1.2 h
[45/60] rep4 duty 40% FLOW start=22.54 down_rise@30s=10.50
elapsed 1.2 h
[46/60] rep4 duty 40% NO-FLOW start=22.69 down_rise@30s=14.56
elapsed 1.3 h
[47/60] rep4 duty 50% FLOW start=22.38 down_rise@30s=13.75
elapsed 1.3 h
[48/60] rep4 duty 50% NO-FLOW start=22.23 down_rise@30s=18.61
elapsed 1.3 h
[49/60] rep5 duty 10% FLOW start=22.38 down_rise@30s=2.67
elapsed 1.3 h
[50/60] rep5 duty 10% NO-FLOW start=22.07 down_rise@30s=4.10
elapsed 1.4 h
[51/60] rep5 duty 15% FLOW start=21.45 down_rise@30s=3.94
elapsed 1.4 h
[52/60] rep5 duty 15% NO-FLOW start=22.15 down_rise@30s=3.19
elapsed 1.4 h
[53/60] rep5 duty 20% FLOW start=21.91 down_rise@30s=4.26
elapsed 1.4 h
[54/60] rep5 duty 20% NO-FLOW start=22.46 down_rise@30s=5.25
elapsed 1.5 h
[55/60] rep5 duty 30% FLOW start=22.23 down_rise@30s=7.34
elapsed 1.5 h
[56/60] rep5 duty 30% NO-FLOW start=22.77 down_rise@30s=11.06
elapsed 1.5 h
[57/60] rep5 duty 40% FLOW start=22.93 down_rise@30s=9.65
elapsed 1.5 h
[58/60] rep5 duty 40% NO-FLOW start=22.38 down_rise@30s=13.48
elapsed 1.6 h
[59/60] rep5 duty 50% FLOW start=22.38 down_rise@30s=12.59
elapsed 1.6 h
[60/60] rep5 duty 50% NO-FLOW start=21.68 down_rise@30s=20.05
elapsed 1.6 h
=== COST: bulk (upstream) rise during a check, flow case, degrees C
duty 15s 20s 25s 30s 40s 50s 60s 75s
10% 0.34 0.62 0.67 0.33 0.30 0.58 0.09 0.34
15% 0.03 0.76 0.50 0.67 0.53 0.69 0.39 0.64
20% -0.08 -0.17 0.23 0.09 0.19 0.23 0.02 0.50
30% 0.79 0.73 1.39 0.83 1.11 1.17 1.42 1.22
40% 0.55 0.36 1.05 1.06 0.95 0.76 1.25 1.53
50% 1.26 1.83 1.72 1.59 2.03 2.03 2.19 2.69
=== PRECISION: downstream-rise discrimination (flow vs no-flow)
duty dur flow mean+-sd noflow mean+-sd sep worst d-prime
10% 15s 2.68+-0.45 2.03+-0.71 -0.65 -2.20 -1.1
10% 20s 2.90+-0.69 2.81+-0.37 -0.09 -1.65 -0.2
10% 25s 2.76+-0.69 3.10+-0.60 0.33 -1.80 0.5
10% 30s 2.98+-0.59 3.16+-0.66 0.18 -1.64 0.3
10% 40s 2.90+-0.86 3.30+-0.42 0.40 -1.08 0.6
10% 50s 3.05+-0.85 3.88+-0.44 0.83 -1.09 1.2
10% 60s 2.37+-0.54 3.61+-0.54 1.24 -0.21 2.3
10% 75s 2.60+-0.83 4.12+-0.54 1.52 -0.36 2.2
15% 15s 2.93+-0.63 2.93+-0.36 -0.01 -1.21 -0.0
15% 20s 3.97+-0.33 3.54+-0.35 -0.42 -1.31 -1.2
15% 25s 3.89+-0.86 3.95+-0.45 0.06 -1.46 0.1
15% 30s 4.14+-0.48 4.01+-0.68 -0.13 -1.55 -0.2
15% 40s 4.11+-0.79 4.43+-0.65 0.32 -1.54 0.4
15% 50s 4.17+-0.78 5.01+-0.34 0.83 -0.24 1.4
15% 60s 4.17+-0.52 5.09+-0.48 0.92 -0.21 1.8
15% 75s 4.21+-0.41 5.75+-0.45 1.54 +0.37 3.6
20% 15s 3.84+-0.78 3.57+-0.83 -0.27 -2.08 -0.3
20% 20s 4.18+-0.53 4.14+-0.83 -0.03 -1.36 -0.0
20% 25s 4.71+-0.92 4.90+-1.03 0.19 -1.91 0.2
20% 30s 4.48+-0.67 5.43+-0.84 0.95 -0.71 1.2
20% 40s 4.84+-0.58 5.79+-1.16 0.96 -0.61 1.0
20% 50s 4.98+-0.39 6.41+-0.79 1.43 +0.22 2.3
20% 60s 4.53+-0.66 6.67+-1.39 2.14 -0.44 2.0
20% 75s 4.95+-0.65 7.34+-1.02 2.39 +0.64 2.8
30% 15s 6.32+-0.81 5.63+-1.72 -0.69 -3.43 -0.5
30% 20s 7.13+-1.31 6.86+-2.24 -0.27 -3.67 -0.1
30% 25s 7.65+-0.92 7.73+-1.93 0.08 -2.63 0.1
30% 30s 7.69+-1.07 8.66+-2.18 0.97 -2.27 0.6
30% 40s 7.69+-0.90 9.96+-2.65 2.27 -1.00 1.1
30% 50s 8.15+-0.75 10.24+-2.37 2.09 -0.93 1.2
30% 60s 8.20+-0.70 10.77+-2.34 2.57 -0.35 1.5
30% 75s 8.27+-1.28 10.86+-2.26 2.59 -0.82 1.4
40% 15s 8.06+-0.87 10.14+-0.65 2.08 +0.37 2.7
40% 20s 8.93+-1.07 12.07+-0.48 3.13 +1.06 3.8
40% 25s 9.61+-1.22 13.47+-0.98 3.86 +0.96 3.5
40% 30s 10.09+-0.98 14.26+-0.78 4.18 +2.00 4.7
40% 40s 10.29+-1.41 16.01+-0.88 5.72 +2.77 4.9
40% 50s 10.14+-0.87 16.95+-0.74 6.81 +4.58 8.4
40% 60s 10.71+-1.22 17.27+-0.96 6.56 +3.72 6.0
40% 75s 10.46+-1.22 17.88+-0.77 7.42 +5.38 7.2
50% 15s 10.73+-0.65 13.02+-0.71 2.29 +0.22 3.3
50% 20s 12.71+-0.89 15.58+-0.85 2.87 +0.55 3.3
50% 25s 12.71+-0.96 17.10+-0.78 4.39 +1.66 5.0
50% 30s 13.29+-0.95 18.75+-0.76 5.46 +3.43 6.4
50% 40s 14.04+-0.80 20.38+-1.16 6.34 +3.48 6.3
50% 50s 14.38+-0.57 21.61+-0.93 7.23 +5.08 9.4
50% 60s 14.24+-0.48 22.14+-1.02 7.90 +5.94 9.9
50% 75s 14.18+-0.78 23.17+-1.10 8.99 +6.51 9.4
=== RANKED by d-prime (separation in pooled standard deviations)
rank duty dur d-prime worst-margin bulk-cost threshold
1 50% 60s 9.9 +5.94 2.19 18.19
2 50% 75s 9.4 +6.51 2.69 18.68
3 50% 50s 9.4 +5.08 2.03 18.00
4 40% 50s 8.4 +4.58 0.76 13.54
5 40% 75s 7.2 +5.38 1.53 14.17
6 50% 30s 6.4 +3.43 1.59 16.02
7 50% 40s 6.3 +3.48 2.03 17.21
8 40% 60s 6.0 +3.72 1.25 13.99
9 50% 25s 5.0 +1.66 1.72 14.91
10 40% 40s 4.9 +2.77 0.95 13.15
11 40% 30s 4.7 +2.00 1.06 12.18
12 40% 20s 3.8 +1.06 0.36 10.50
(worst-margin = min(no-flow) - max(flow); positive means every
observed no-flow run exceeded every observed flow run.
bulk-cost = degrees C added to the loop per check.)
controller restarted; total elapsed 1.63 h
File diff suppressed because it is too large Load Diff
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Characterize a SHORT periodic flow re-check for use DURING a job.
Why a separate design from the job-start check: with the laser firing
and coolant flowing, tube heat raises BOTH sensors, inflating a plain
downstream-rise metric toward a false fault. With the pump stopped that
heat never reaches the sensors at all (stagnant loop), so the no-flow
signature stays at its idle value. The two signatures therefore
converge during a cut. The differential rise
(downstream_end - downstream_start) - (upstream_end - upstream_start)
cancels that common-mode heating and is the metric this measures.
TWO DEAD ENDS, recorded so they are not re-invented:
1. Absolute over-temperature does NOT detect a failed pump. The
sensors read the water at their own location; with no circulation
the tube's heat stays in the tube and never reaches them. The loop
can read perfectly comfortable while the tube cooks. Over-temp
monitoring detects a hot CIRCULATING loop - a different failure.
2. "The coolant should warm up while cutting" is NOT a usable
indicator either. A low-duty engrave (say 5% duty at 30% power) can
put so little heat in that the cooling system absorbs it with no
measurable rise, so a flat trend is equally consistent with a light
load working correctly and with a dead pump. Ambiguous evidence is
worse than none - it invites false confidence.
Hence: periodic ACTIVE interrogation with the heater, which creates a
known stimulus instead of waiting for one, is the only valid method on
this hardware (there is no pump tach or pump current sense anywhere in
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.
"""
import math
import subprocess
import sys
import time
HOST = '172.16.1.97'
F = 1024.0 * 1.3
RD, BETA = 10000.0, 3380.0
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
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(['wsl', '-d', 'forge-yocto', '--', '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]))
def settle(seconds=100):
board('echo 1 > /sys/glowforge/thermal/water_pump_on; echo 0 > /sys/glowforge/thermal/heater_pwm')
time.sleep(seconds)
def check(tag, pump_on, pct, window):
board('echo %d > /sys/glowforge/thermal/water_pump_on' % (1 if pump_on else 0))
time.sleep(2)
d0, u0 = sample()
board('echo %d > /sys/glowforge/thermal/heater_pwm' % int(65535 * pct / 100))
t0 = time.time()
d, u = d0, u0
while time.time() - t0 < window:
time.sleep(5)
d, u = sample()
print(' %-8s t=%2.0fs down=%5.2f (%+5.2f) up=%5.2f (%+5.2f) diff=%+5.2f'
% (tag, time.time() - t0, d, d - d0, u, u - u0, (d - d0) - (u - u0)), flush=True)
if d >= DOWN_ABORT_C:
print(' abort: downstream at safety limit', flush=True)
break
board('echo 0 > /sys/glowforge/thermal/heater_pwm; echo 1 > /sys/glowforge/thermal/water_pump_on')
return (d - d0) - (u - u0), d - d0
pct = int(sys.argv[1]) if len(sys.argv) > 1 else 50
window = int(sys.argv[2]) if len(sys.argv) > 2 else 30
print('=== periodic re-check characterization: %d%% heater, %d s window' % (pct, window))
settle()
print(' flow case:')
flow_diff, flow_rise = check('flow', True, pct, window)
settle()
print(' no-flow case:')
noflow_diff, noflow_rise = check('noflow', False, pct, window)
settle(60)
print()
print('differential rise: flow %+.2f no-flow %+.2f separation %.2f C'
% (flow_diff, noflow_diff, noflow_diff - flow_diff))
print('plain down rise: flow %+.2f no-flow %+.2f separation %.2f C'
% (flow_rise, noflow_rise, noflow_rise - flow_rise))
print('suggested differential threshold: %.2f C' % ((flow_diff + noflow_diff) / 2))
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python3
"""Board-side coolant sampler: prints 'elapsed,raw_down,raw_up' lines.
Usage: flow_sampler.py <duration_s> <interval_s>
Kept on the board so sampling cadence is not at the mercy of ssh
round-trip latency."""
import sys
import time
dur = float(sys.argv[1])
iv = float(sys.argv[2])
t0 = time.time()
while True:
el = time.time() - t0
if el > dur:
break
with open('/sys/glowforge/pic/water_temp_1') as f:
r1 = f.read().strip()
with open('/sys/glowforge/pic/water_temp_2') as f:
r2 = f.read().strip()
print('%.2f,%s,%s' % (el, r1, r2), flush=True)
time.sleep(iv)
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Sustained-load test for periodic flow re-checks.
Question: what does repeated interrogation cost thermally over a long
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.
Usage: flow_sustained.py [minutes] (default 30)
"""
import math
import re
import socket
import subprocess
import sys
import time
HOST = '172.16.1.97'
F = 1024.0 * 1.3
RD, BETA = 10000.0, 3380.0
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
def degc(raw):
r = RD / (F / float(raw) - 1.0)
return BETA / math.log(r / RINF) - 273.15
def board(cmd):
r = subprocess.run(['wsl', '-d', 'forge-yocto', '--', 'ssh',
'-o', 'PreferredAuthentications=none',
'root@' + HOST, cmd],
capture_output=True, text=True, timeout=30)
return r.stdout.strip()
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])
minutes = float(sys.argv[1]) if len(sys.argv) > 1 else 30
s = socket.create_connection((HOST, 23), timeout=5)
s.settimeout(0.3)
time.sleep(0.5)
try:
while s.recv(4096):
pass
except socket.timeout:
pass
d0, u0 = temps()
print('start: down=%.2f up=%.2f running M8 for %.0f min' % (d0, u0, minutes))
s.sendall(b'M8\n')
t0 = time.time()
verdicts = []
peak_up = u0
buf = ''
while time.time() - t0 < minutes * 60:
try:
buf += s.recv(4096).decode('ascii', 'replace')
except socket.timeout:
pass
for line in buf.split('\n'):
if 'flow' in line and ('verified' in line or 'FAULT' in line):
m = re.search(r'rise ([\d.]+) C', line)
if m and (not verdicts or verdicts[-1][1] != float(m.group(1))):
el = (time.time() - t0) / 60
verdicts.append((el, float(m.group(1)), 'FAULT' in line))
d, u = temps()
peak_up = max(peak_up, u)
print(' %5.1f min rise=%5.2f %-8s loop down=%.2f up=%.2f (%+.2f from start)'
% (el, float(m.group(1)), 'FAULT' if 'FAULT' in line else 'ok', d, u, u - u0),
flush=True)
buf = buf[-2000:]
time.sleep(5)
s.sendall(b'M9\n')
time.sleep(1)
s.close()
d, u = temps()
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('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))
print('cadence: %.1f min between checks' % (minutes / max(1, len(verdicts))))
+96
View File
@@ -0,0 +1,96 @@
=== warm-baseline validation 2026-08-02 22:43:41 duty=40% window=50s threshold=13.7
cycle 1/3 FLOW
warmed to down=21.84 up=21.91 in 1.3 min
base_up=22.07 down_rise=12.18 -> ok CORRECT (peak down 34.4 C)
cycle 1/3 NO-FLOW
warmed to down=22.46 up=21.84 in 1.0 min
base_up=21.84 down_rise=18.16 -> FAULT CORRECT (peak down 40.4 C)
cycle 2/3 FLOW
warmed to down=21.84 up=22.07 in 1.0 min
base_up=21.29 down_rise=12.75 -> ok CORRECT (peak down 34.5 C)
cycle 2/3 NO-FLOW
warmed to down=22.07 up=21.91 in 1.0 min
base_up=21.45 down_rise=18.15 -> FAULT CORRECT (peak down 40.5 C)
cycle 3/3 FLOW
warmed to down=22.07 up=21.91 in 1.0 min
base_up=21.60 down_rise=10.95 -> ok CORRECT (peak down 34.3 C)
cycle 3/3 NO-FLOW
warmed to down=22.30 up=22.30 in 1.0 min
base_up=21.76 down_rise=17.85 -> FAULT CORRECT (peak down 40.4 C)
=== warm-baseline results (baselines 21.3 - 22.1 C)
flow n=3 mean 11.96 sd 0.92 max 12.75
no-flow n=3 mean 18.06 sd 0.17 min 17.85
worst-case margin: +5.11 C threshold 13.7 sits +0.95 above flow max, +4.15 below no-flow min
misclassified: 0 of 6
controller restarted
=== warm-baseline validation 2026-08-02 22:56:44 duty=40% window=50s threshold=13.7
cycle 1/3 FLOW
warmed to down=22.30 up=22.07 in 1.3 min
base_up=22.23 down_rise=12.62 -> ok CORRECT (peak down 34.0 C)
cycle 1/3 NO-FLOW
warmed to down=22.23 up=21.84 in 1.0 min
base_up=21.76 down_rise=18.71 -> FAULT CORRECT (peak down 40.6 C)
cycle 2/3 FLOW
warmed to down=22.30 up=22.07 in 1.0 min
base_up=21.76 down_rise=11.03 -> ok CORRECT (peak down 34.3 C)
cycle 2/3 NO-FLOW
warmed to down=22.15 up=21.84 in 1.0 min
base_up=21.37 down_rise=18.26 -> FAULT CORRECT (peak down 40.1 C)
cycle 3/3 FLOW
warmed to down=22.54 up=22.15 in 1.0 min
base_up=21.99 down_rise=11.08 -> ok CORRECT (peak down 34.3 C)
cycle 3/3 NO-FLOW
warmed to down=22.07 up=21.84 in 1.0 min
base_up=21.91 down_rise=18.42 -> FAULT CORRECT (peak down 40.5 C)
=== warm-baseline results (baselines 21.4 - 22.2 C)
flow n=3 mean 11.58 sd 0.91 max 12.62
no-flow n=3 mean 18.46 sd 0.23 min 18.26
worst-case margin: +5.63 C threshold 13.7 sits +1.08 above flow max, +4.56 below no-flow min
misclassified: 0 of 6
controller restarted
=== warm-baseline validation 2026-08-02 23:10:04 duty=40% window=50s threshold=13.7
cycle 1/2 FLOW
warm abort: downstream 51.95 at 0.5 min
warmed to down=22.15 up=21.84 in 1.3 min
=== warm-baseline validation 2026-08-02 23:11:51 duty=40% window=50s threshold=13.7
cycle 1/2 FLOW
base_up=22.23 down_rise=15.53 -> FAULT *** WRONG *** (peak down 37.7 C)
cycle 1/2 NO-FLOW
warm abort: downstream 49.34 at 0.3 min
warm target reached: up=25.84 at 0.8 min
warmed to down=21.84 up=21.60 in 1.0 min
warmed to down=22.15 up=22.07 in 1.6 min
base_up=21.37 down_rise=10.69 -> ok *** WRONG *** (peak down 34.1 C)
cycle 2/2 FLOW
base_up=20.91 down_rise=11.48 -> ok CORRECT (peak down 34.4 C)
cycle 1/2 NO-FLOW
warming: 0.8 min down=37.53 up=23.71
warming: 0.8 min down=38.17 up=24.65
warming: 1.8 min down=37.80 up=23.94
warming: 1.8 min down=37.99 up=24.50
(warming plateaued at 24.18 C after 2.6 min)
warming: 2.9 min down=26.56 up=23.47
(warming plateaued at 23.47 C after 2.9 min)
warmed to down=21.68 up=21.60 in 3.4 min
warmed to down=28.57 up=21.84 in 3.6 min
base_up=21.37 down_rise=12.18 -> ok *** WRONG *** (peak down 34.1 C)
cycle 2/2 FLOW
base_up=21.91 down_rise=3.76 -> ok CORRECT (peak down 35.8 C)
cycle 2/2 NO-FLOW
warm abort: downstream 48.90 at 0.3 min
warm target reached: up=25.68 at 0.5 min
warmed to down=22.23 up=21.84 in 1.0 min
warmed to down=21.29 up=21.68 in 1.3 min
base_up=21.45 down_rise=11.58 -> ok *** WRONG *** (peak down 34.4 C)
base_up=21.06 down_rise=11.30 -> ok CORRECT (peak down 34.2 C)
cycle 2/2 NO-FLOW
=== warm-baseline results (baselines 21.4 - 22.2 C)
flow n=2 mean 9.65 sd 8.32 max 15.53
no-flow n=2 mean 11.13 sd 0.63 min 10.69
worst-case margin: -4.84 C threshold 13.7 sits -1.83 above flow max, -3.01 below no-flow min
misclassified: 3 of 4
controller restarted
warming: 0.8 min down=37.71 up=24.50
+32
View File
@@ -0,0 +1,32 @@
[
{
"flow": true,
"base_up": 20.90741361785507,
"base_down": 21.837331209719537,
"down_rise": 11.480714695811116,
"up_rise": 2.254025184475836,
"peak_down": 34.43702357179029,
"t": 49.15,
"cycle": 0
},
{
"flow": false,
"base_up": 21.371932016053336,
"base_down": 21.914988917022924,
"down_rise": 12.176080123337613,
"up_rise": 1.5552007482007184,
"peak_down": 34.09106904036054,
"t": 49.17,
"cycle": 0
},
{
"flow": true,
"base_up": 21.06216007856807,
"base_down": 21.75970016295645,
"down_rise": 11.302272149723365,
"up_rise": 2.6471638813487743,
"peak_down": 34.1774170676689,
"t": 49.16,
"cycle": 1
}
]
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Validate the flow check at a WARM loop baseline - the condition a
real job actually presents.
Every design-matrix run started near ambient (21-23 C). During a cut the
loop sits warmer, where heat loss to ambient is larger, which shrinks
the measured rise in BOTH the flow and no-flow cases and could compress
the margin the threshold depends on. This warms the loop with its own
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.
Usage: flow_warm_validate.py [cycles_per_case] (default 3)
"""
import json
import math
import os
import statistics
import subprocess
import sys
import time
HOST = '172.16.1.97'
HERE = os.path.dirname(os.path.abspath(__file__))
RESULTS = os.path.join(HERE, 'flow_warm_results.json')
F = 1024.0 * 1.3
RD, BETA = 10000.0, 3380.0
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
DUTY = 40
CHECK_S = 50
THRESHOLD = 13.7
WARM_TARGET_C = 25.5 # what a ~19-20 C room permits at 50% duty
WARM_MAX_S = 780
ABORT_C = 48.0
FANS_RUN = ('echo 65535 > /sys/glowforge/thermal/exhaust_pwm; '
'echo 43278 > /sys/glowforge/thermal/intake_pwm')
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(['wsl', '-d', 'forge-yocto', '--', 'ssh',
'-o', 'PreferredAuthentications=none',
'root@' + HOST, cmd],
capture_output=True, text=True, timeout=timeout)
return r.stdout
_last_good = (22.0, 22.0)
def temps():
"""Sanity-guarded read. A single glitched sysfs value used to map to
~109 C and trip the safety abort, silently ending the warm phase
after one iteration - which wasted two whole validation runs."""
global _last_good
o = board('cat /sys/glowforge/pic/water_temp_1 /sys/glowforge/pic/water_temp_2').split()
if len(o) != 2:
return _last_good
d, u = degc(o[0]), degc(o[1])
if any(x != x or x < 5.0 or x > 60.0 for x in (d, u)):
return _last_good
_last_good = (d, u)
return d, u
def heater(pct):
board('echo %d > /sys/glowforge/thermal/heater_pwm' % int(65535 * pct / 100))
def pump(on):
board('echo %d > /sys/glowforge/thermal/water_pump_on' % (1 if on else 0))
def warm_to(target, log):
"""Warm with the loop heater, pump circulating, fans off."""
pump(True)
board(FANS_OFF)
# 50% is the practical ceiling: the downstream sensor sits AT the
# heater element, so 100% drives it past 50 C within 30 s while the
# bulk has barely moved. At 50% it plateaus near 41 C, leaving the
# bulk free to climb for as long as we let it.
heater(50)
t0 = time.time()
# Plateau detection compares against the reading two minutes back:
# with the pump circulating, the bulk climbs only ~0.5-0.8 C/min, so
# a per-sample threshold mistakes normal warming for a plateau (it
# did exactly that on the first attempt and the whole run executed
# at ambient).
hist = []
while time.time() - t0 < WARM_MAX_S:
d, u = temps()
el = (time.time() - t0) / 60
if u >= target:
log(' warm target reached: up=%.2f at %.1f min' % (u, el))
break
if d >= ABORT_C:
log(' warm abort: downstream %.2f at %.1f min' % (d, el))
break
hist.append(u)
if len(hist) % 4 == 0:
log(' warming: %.1f min down=%.2f up=%.2f' % (el, d, u))
if len(hist) > 8 and u - hist[-9] < 0.3:
log(' (warming plateaued at %.2f C after %.1f min)' % (u, el))
break
time.sleep(15)
heater(0)
pump(True)
time.sleep(45) # mix so the bulk is uniform before measuring
d, u = temps()
log(' warmed to down=%.2f up=%.2f in %.1f min' % (d, u, (time.time() - t0) / 60))
return u
def check(flow, log):
"""The real check: fans at cut profile, 40% heater, 50 s."""
board(FANS_RUN)
pump(flow)
time.sleep(3)
d0, u0 = temps()
heater(DUTY)
raw = board('python3 /data/flow_sampler.py %d 1.0' % CHECK_S, timeout=CHECK_S + 60)
heater(0)
pump(True)
series = []
for line in raw.strip().splitlines():
try:
el, r1, r2 = line.split(',')
series.append((float(el), degc(r1), degc(r2)))
except ValueError:
continue
if not series:
return None
el, d, u = series[-1]
return {'flow': flow, 'base_up': u0, 'base_down': d0, 'down_rise': d - d0,
'up_rise': u - u0, 'peak_down': max(s[1] for s in series), 't': el}
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')
def log(msg):
print(msg, flush=True)
logf.write(msg + '\n')
logf.flush()
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:
for c in range(cycles):
for flow in (True, False):
log(' cycle %d/%d %s' % (c + 1, cycles, 'FLOW' if flow else 'NO-FLOW'))
warm_to(WARM_TARGET_C, log)
r = check(flow, log)
if r:
r['cycle'] = c
runs.append(r)
verdict = 'FAULT' if r['down_rise'] > THRESHOLD else 'ok'
correct = (verdict == 'FAULT') == (not flow)
log(' base_up=%.2f down_rise=%.2f -> %-5s %s (peak down %.1f C)'
% (r['base_up'], r['down_rise'], verdict,
'CORRECT' if correct else '*** WRONG ***', r['peak_down']))
json.dump(runs, open(RESULTS, 'w'), indent=1)
finally:
heater(0)
pump(True)
board(FANS_OFF)
fv = [r['down_rise'] for r in runs if r['flow']]
nv = [r['down_rise'] for r in runs if not r['flow']]
log('')
log('=== warm-baseline results (baselines %.1f - %.1f C)'
% (min(r['base_up'] for r in runs), max(r['base_up'] for r in runs)))
if len(fv) >= 2 and len(nv) >= 2:
log(' flow n=%d mean %.2f sd %.2f max %.2f' % (len(fv), statistics.mean(fv), statistics.stdev(fv), max(fv)))
log(' no-flow n=%d mean %.2f sd %.2f min %.2f' % (len(nv), statistics.mean(nv), statistics.stdev(nv), min(nv)))
log(' worst-case margin: %+.2f C threshold %.1f sits %+.2f above flow max, %+.2f below no-flow min'
% (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')
logf.close()
if __name__ == '__main__':
sys.exit(main())
+11
View File
@@ -0,0 +1,11 @@
{
"points": [
{
"measured_c": 25.67,
"raw1": 672.4,
"raw2": 672.6,
"note": "IR on reservoir+hoses 78.2F, ambient 78.0F, idle 1h, pump on, heater off",
"when": "2026-08-02 18:13:41"
}
]
}