From 8fa10f7b284753eaa6e0a1c1c65e17113b63a44f Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sun, 2 Aug 2026 19:12:00 -0400 Subject: [PATCH] Docs: corrected coolant curve and the rebuilt flow check Records why the temperature formula changed (and that everything derived from the old one had to be re-derived), the flow characterization data at both heater duties, the false negative that killed the dT-threshold design, and the downstream-rise check that replaced it. Adds flow_characterize.py to the bench kit. --- docs/BRINGUP.md | 37 +++++++-- scripts/bench/README.md | 2 + scripts/bench/flow_characterize.py | 121 +++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 scripts/bench/flow_characterize.py diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 96826a4..5baaead 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -232,14 +232,35 @@ hardware I/O — host testing). it to a hard fire gate). Verified via tach readbacks: air tach period 4439→699 under M8, exhaust stopped→full, intakes ~3×, cooldown hold, clean return to idle; coolant temp visibly - dropped during the blast. **Coolant flow verification also DONE** - (the factory heater trick: loop heater at 10 % between the two - water-temp sensors; measured on this machine — flow ΔT settles - +2.5..4.0 °C and never sustains >4.1, pump-stopped ΔT hits - +4.6..5.7 within 20 s; fault = ΔT>4.5 °C for 20 s, live-verified: - pump stopped → sender warning in 40 s → recovery re-arms). - Absolute ceiling 33 °C (job-header CMrx; heater adds ~1.5 °C to - the loop). **v2 (same day): heater job-scoped** (M8..M9 only — an + dropped during the blast. Absolute ceiling 33 °C (job-header + CMrx). + + **Coolant temperature conversion CORRECTED 2026-08-02** — the + UAPI "best guess" `raw*-0.09653+94` was wrong (3–5 °C high, wrong + slope); the real one is the factory B-equation recovered from the + v2.6.0 binary (10 k B3380 NTC, 10 k divider, ×1.3 gain, 10-bit + ADC), proven by reproducing this machine's `WT*` cloud settings + exactly, and thermometer-checked to ~1 °C. Full derivation now in + `kernel-module-glowforge/UAPI.md`. Consequence: the 33 °C ceiling + had been firing at a real ~29 °C, and **anything derived from the + old formula had to be re-derived** — which is how the flow check + below got rebuilt. + + **Coolant flow verification (rebuilt, live-verified both ways).** + 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 + of sensor noise. At 30 % the ΔT bands separate (≤9.32 / ≥10.99) + but a ΔT threshold still **failed a live pump-off drill** (8.8 °C + vs a 10.2 °C limit), because a check starting from a cold heater + never reaches the steady-state delta. Final design: a **one-shot + check at job start (M8)** — heater to 30 % for 50 s — with the + discriminator being **downstream temperature RISE** (flow ≈10.3 °C + vs no-flow ≈15.1 °C, ~6 °C separation; threshold 12.7 °C, + `GFCOOL_FLOW_RISE`). Heater goes off afterwards, so the loop is + not warmed for the rest of the job, and absolute over-temp + monitoring carries protection from there (a pump failure mid-cut + shows as a temperature climb far faster than any heater delta). + Verified twice each way from a cooled loop. **v2 (same day): heater job-scoped** (M8..M9 only — an always-on heater eats headroom below the 31 °C start gate at idle; flow faulting arms 30 s after heater-on), **two-phase cooldown** (15 s smoke clear at run duty, then half-duty airflow diff --git a/scripts/bench/README.md b/scripts/bench/README.md index 58df8e5..d0dbe6e 100644 --- a/scripts/bench/README.md +++ b/scripts/bench/README.md @@ -12,6 +12,8 @@ target board (dev image, python3 present) unless noted. | `pwm_hold.py` | Holds one PWMSAR value for a scope-measurement window (`pwm_hold.py `), then restores. Same locked-state rule. | | `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. | +| `temp_calibrate.py` | Coolant temperature calibration helper (`watch` / `point ` / `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 /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. | | `puls_profile.py` | Decodes factory `.puls` streams (raw or GF1-headered) into velocity/accel profiles: peak speeds, ramp-slope fits, per-move segments, Z cadence. Runs anywhere (stdlib only). Source of the factory-true grblHAL defaults (milestone 2): 700/590 mm/s² accel, 200 mm/s max rate, 28160 Hz travel tick. | diff --git a/scripts/bench/flow_characterize.py b/scripts/bench/flow_characterize.py new file mode 100644 index 0000000..8b9801e --- /dev/null +++ b/scripts/bench/flow_characterize.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Characterize the coolant flow signature using the FACTORY temperature +curve, and recommend flow-fault thresholds. + +The loop heater sits between the two water sensors; flowing coolant +carries its heat away, so downstream-minus-upstream settles at a small +stable delta. Stopping the pump lets that heat pool, and the delta +climbs. This measures both signatures and prints the separation. + +Phases: baseline (heater off) -> flow (heater on, pump on) -> no-flow +(pump off) -> recovery (pump on). Restores heater off / pump on. + +Run with the controller stopped, or accept that it will fight you: the +driver only writes the heater on M8/M9 transitions, so an idle driver +leaves this alone. +""" +import math +import subprocess +import sys +import time + +HOST = '172.16.1.97' + +# Factory B-equation conversion (see kernel-module-glowforge/UAPI.md). +F = 1024.0 * 1.3 +RD = 10000.0 +BETA = 3380.0 +RINF = 10000.0 * math.exp(-3380.0 / 298.15) + + +def degc(raw): + if raw <= 0 or raw >= F: + return float('nan') + r = RD / (F / raw - 1.0) + return BETA / math.log(r / RINF) - 273.15 + + +def board(cmd): + r = subprocess.run(['wsl', '-d', 'forge-yocto', '--', 'ssh', + '-o', 'PreferredAuthentications=none', + 'root@' + HOST, cmd], + capture_output=True, text=True, timeout=30) + return r.stdout.strip() + + +def sample(): + out = board('cat /sys/glowforge/pic/water_temp_1 /sys/glowforge/pic/water_temp_2').split() + if len(out) != 2: + return None + d, u = degc(int(out[0])), degc(int(out[1])) + return d, u, d - u + + +DOWN_ABORT_C = 45.0 # never cook the loop while characterizing + + +def phase(tag, seconds, interval=10, settle=0): + """Log a phase; return the deltas after the settle period.""" + t0 = time.time() + keep = [] + while time.time() - t0 < seconds: + s = sample() + if s: + el = time.time() - t0 + print(' %-7s t=%3.0fs down=%5.2f up=%5.2f dT=%+5.2f' + % (tag, el, s[0], s[1], s[2]), flush=True) + if el >= settle: + keep.append(s[2]) + if s[0] >= DOWN_ABORT_C: + print(' ABORT: downstream %.1f C >= %.1f C safety limit' + % (s[0], DOWN_ABORT_C), flush=True) + board('echo 1 > /sys/glowforge/thermal/water_pump_on; ' + 'echo 0 > /sys/glowforge/thermal/heater_pwm') + break + time.sleep(interval) + return keep + + +def stats(name, ds): + if not ds: + print('%s: no samples' % name) + return None, None + print('%s: n=%d min=%+.2f max=%+.2f mean=%+.2f' + % (name, len(ds), min(ds), max(ds), sum(ds) / len(ds))) + return min(ds), max(ds) + + +heater_pct = int(sys.argv[1]) if len(sys.argv) > 1 else 10 +heater_pwm = str(int(65535 * heater_pct / 100)) + +print('=== baseline: heater off, pump on (60 s)') +board('echo 1 > /sys/glowforge/thermal/water_pump_on; echo 0 > /sys/glowforge/thermal/heater_pwm') +base = phase('base', 60, 10, 30) +stats('baseline dT', base) + +print('=== flow: heater %d%%, pump on (240 s; first 60 s ignored while dT establishes)' % heater_pct) +board('echo ' + heater_pwm + ' > /sys/glowforge/thermal/heater_pwm') +flow = phase('flow', 240, 10, 60) +fmin, fmax = stats('flow dT', flow) + +print('=== no-flow: pump OFF, heater still on (150 s; first 20 s ignored)') +board('echo 0 > /sys/glowforge/thermal/water_pump_on') +noflow = phase('noflow', 150, 10, 20) +nmin, nmax = stats('no-flow dT', noflow) + +print('=== recovery: pump on, heater off (90 s)') +board('echo 1 > /sys/glowforge/thermal/water_pump_on; echo 0 > /sys/glowforge/thermal/heater_pwm') +phase('recov', 90, 15) + +print() +if fmax is not None and nmin is not None: + print('flow band: up to %+.2f C' % fmax) + print('no-flow band: from %+.2f C' % nmin) + if nmin > fmax: + fault = (fmax + nmin) / 2.0 + print('separation: %.2f C -> suggested fault threshold %.2f C, re-arm %.2f C' + % (nmin - fmax, fault, fault - 0.4)) + else: + print('BANDS OVERLAP - flow detection unreliable at %d%% heater; try a higher duty' + % heater_pct) +print('restored: pump on, heater off')