mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-28 01:01:12 -07:00
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:
+50
-1
@@ -246,7 +246,56 @@ hardware I/O — host testing).
|
|||||||
old formula had to be re-derived** — which is how the flow check
|
old formula had to be re-derived** — which is how the flow check
|
||||||
below got rebuilt.
|
below got rebuilt.
|
||||||
|
|
||||||
**Coolant flow verification (rebuilt, live-verified both ways).**
|
**Coolant flow verification — REBUILT ON A 60-RUN DESIGN MATRIX
|
||||||
|
(2026-08-02 overnight).** Everything below supersedes the earlier
|
||||||
|
ΔT-based designs; the tools are `scripts/bench/flow_matrix.py`
|
||||||
|
(+`flow_sampler.py` on the board), `flow_sustained.py`,
|
||||||
|
`flow_warm_validate.py`, `flow_recheck_char.py`.
|
||||||
|
- **Duty is the decisive parameter.** Below ~40 % the stagnant
|
||||||
|
loop sheds the heater's output by natural convection well enough
|
||||||
|
to **mimic flow**: at 30 %/50 s the five pump-stopped trials read
|
||||||
|
8.15, 8.69, 8.78, 12.25, 13.33 °C while flow never exceeded 9.08
|
||||||
|
— three of five dead-pump cases looked *healthier* than a working
|
||||||
|
pump. At 40 % heat input outruns convection (flow ≤11.46,
|
||||||
|
no-flow ≥16.04, d′ 8.4) and it is also the cheapest viable
|
||||||
|
option (~0.8 °C of loop heating per check vs ~2.0 °C at 50 %).
|
||||||
|
- **Operating point: 40 % duty, 50 s window, threshold 14.4 °C**
|
||||||
|
(balanced midpoint of 17 flow observations peaking at 12.75 and
|
||||||
|
8 no-flow observations bottoming at 16.04).
|
||||||
|
- **Periodic re-checks every 150 s** (`GFCOOL_RECHECK_S`), because
|
||||||
|
a stopped pump is undetectable any other way — absolute
|
||||||
|
temperature only tracks a *circulating* loop, and "coolant
|
||||||
|
should warm while cutting" is ambiguous (a light engrave may add
|
||||||
|
no measurable heat). Sustained 40-minute run: zero false faults,
|
||||||
|
and **no thermal accumulation** — with cut-profile fans the loop
|
||||||
|
*cooled* 2 °C while being interrogated throughout.
|
||||||
|
- **Settle gate (safety-critical).** The check measures a rise
|
||||||
|
from a baseline; capturing that baseline while the loop is still
|
||||||
|
cooling from earlier heat produces garbage and was bench-proven
|
||||||
|
to **miss** (reported flow with the pump stopped). Checks are now
|
||||||
|
requested, and start only once the sensors agree **and** the
|
||||||
|
downstream reading is stationary. Stationarity uses a
|
||||||
|
**split-half mean difference**, not peak-to-peak: measured noise
|
||||||
|
on a settled loop is 0.52 °C p-p (0.70 worst) but only 0.11 °C
|
||||||
|
split-half (0.21 worst), so any p-p threshold tight enough to
|
||||||
|
catch drift sits *below the noise floor* and the gate never
|
||||||
|
opens.
|
||||||
|
- **Record: 25/25 correct classifications at 40 %**, plus all three
|
||||||
|
settle cases (settled/flow, settled/no-flow, and the unsettled
|
||||||
|
no-flow case that previously missed → now defers, then faults).
|
||||||
|
- **NOT YET VALIDATED (first-light commissioning items):** all
|
||||||
|
baselines were 19–23 °C (an overnight-cool room; the loop
|
||||||
|
equilibrates near ambient and the heater cannot reach a
|
||||||
|
cutting-session loop temperature — 100 % duty drives the
|
||||||
|
downstream sensor past 50 °C in 30 s while the bulk barely
|
||||||
|
moves). Behaviour at 27–32 °C baselines, and under real laser
|
||||||
|
heating, must be characterized at first light. Physics argues
|
||||||
|
the dependence is weak — with forced flow ΔT = P/(ṁ·c), which
|
||||||
|
carries no absolute-temperature term — but that is reasoning,
|
||||||
|
not measurement.
|
||||||
|
|
||||||
|
*(Superseded earlier text kept below for context.)*
|
||||||
|
**Coolant flow verification (first attempt, live-verified both ways).**
|
||||||
Continuous 10 % heating was never viable on the corrected curve:
|
Continuous 10 % heating was never viable on the corrected curve:
|
||||||
flow ΔT ≤3.69 vs no-flow ΔT ≥3.74 — a 0.04 °C gap against ~0.9 °C
|
flow ΔT ≤3.69 vs no-flow ΔT ≥3.74 — a 0.04 °C gap against ~0.9 °C
|
||||||
of sensor noise. At 30 % the ΔT bands separate (≤9.32 / ≥10.99)
|
of sensor noise. At 30 % the ΔT bands separate (≤9.32 / ≥10.99)
|
||||||
|
|||||||
@@ -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). |
|
| `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. |
|
| `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_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. |
|
| `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-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. |
|
| `build-feeder.sh` | Cross-compiles `feeder.c` the same way. |
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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))))
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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())
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user