Pin forgectrl 42cdb71; add the coolant offset check; record the compensation on the bench

The pin carries the calibrate tool's sampling fix (3 s at 8 Hz a side).
aa_offset_check.py proves the correction on a machine: M8 brings the fans
to the run profile, dark, while the raw coolant counts, /status and the
engine's readings are averaged before, during and after; it turns the
flow check off for its session and restores it. On the bench page as
aa-offset-check. CAMPAIGN-LOG records the tool's first run, the applied
value (16 counts) and the proof: the readings held within 0.1 C under
the run profile where they dropped 1.0 C uncorrected. BRINGUP item 21
names what remains.

No catalog consequence: a bench tool and records.
This commit is contained in:
ScottW514
2026-08-29 18:02:46 -04:00
parent 5920d9bc77
commit e32c86fb10
6 changed files with 155 additions and 4 deletions
+4 -3
View File
@@ -1476,9 +1476,10 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
gates read the coolant about 1.2 C cooler than it is while the air gates read the coolant about 1.2 C cooler than it is while the air
assist runs unless `cool_aa_offset_counts` carries the machine's value assist runs unless `cool_aa_offset_counts` carries the machine's value
(the `aa-offset-calibrate` diagnostic measures it, the panel's Apply (the `aa-offset-calibrate` diagnostic measures it, the panel's Apply
writes it; zero is the factory's uncorrected reading). Owed: the writes it; zero is the factory's uncorrected reading; the bench
calibrate run on this machine and its value applied, a second machine carries 16). Owed: the calibrate tool's recommendation
machine's value when one is on the bench; and the mid-run accepted on an image (its first sampling refused its own result), a
second machine's value when one is on the bench; and the mid-run
toggling between two levels (0.6 to 1.1 C, both sensors together), toggling between two levels (0.6 to 1.1 C, both sensors together),
which comes only with the tube lit: not with the fans alone, not under which comes only with the tube lit: not with the fans alone, not under
motion, not in an armed dark window. The HV supply's input current on motion, not in an armed dark window. The HV supply's input current on
+32
View File
@@ -4492,6 +4492,38 @@ two sensor lines during a cut is the next instrument. Its size is 0.6 to
1.1 C either way, inside the over-temperature ceiling's 2 C hysteresis, 1.1 C either way, inside the over-temperature ceiling's 2 C hysteresis,
and the flow check reads means. and the flow check reads means.
## 2026-08-29: the air-assist offset taken off the coolant readings, on the bench
forgectrl `cool_aa_offset_counts` (f9b4893, the status link fix 25cf969),
image 20260829214735 flashed by the operator. The correction is in ADC
counts, keyed to the air-assist duty the engine commands, taken off both
raw readings in the engine and in `/status` (more counts read colder, so
the fan's ground lift reads as a drop and the correction subtracts; the
host test caught the first cut adding it).
**The calibrate tool** (`aa-offset-calibrate`, the panel's "Calibrate
coolant offset") ran end to end on the bench: three idle-to-run cycles,
six edges reading 12.7/18.3, -15.8/-18.5, 16.3/16.8, -12.5/-10.3,
15.7/17.8, -15.7/-21.0 counts (down/up), mean 16.0. It refused its own
result on the spread (10.7 counts against its 8-count limit): 1.5 s at
4 Hz is six samples a side against about 5 counts of single-sample noise.
The tool now reads 3 s at 8 Hz a side (forgectrl 42cdb71, the next
image); the value was applied directly, `cool_aa_offset_counts = 16`, this
machine's number.
**The proof** (`scripts/bench/aa_offset_check.py`, dark, no press: M8
brings the fans to the run profile, the raw counts, `/status` and the
engine's readings averaged before, during and after). Uncorrected, the
upstream reading dropped 1.02 C under the run profile (raw +15.2 counts).
Corrected, with the flow check off for the session so its heater stayed
out of the downstream sensor: the raw counts stepped +15.4 / +13.3 and
`/status` read 23.96 / 23.94 against 23.87 / 23.96 before, +0.09 and
-0.02 C; the engine's own readings +0.31 / +0.25 inside the same window.
The readings hold still while the fan runs. A first run of the check had
left `cool_flow_check_s` at 0 (the script's restore posted an empty value
and got a 400; fixed); the setting was put back to 50 and the engine
re-read it at the next session.
## Superseded status notes ## Superseded status notes
### Shared machine services — remaining polish, as listed 2026-08-13 ### Shared machine services — remaining polish, as listed 2026-08-13
+7
View File
@@ -138,6 +138,13 @@ TOOLS = [
"with both sensors at 25 Hz, and scores the common-mode step at every edge and the level " "with both sensors at 25 Hz, and scores the common-mode step at every edge and the level "
"toggling inside every dwell; every value is restored on exit. The pump stops once for " "toggling inside every dwell; every value is restored on exit. The pump stops once for "
"8 s with the tube dark and the heater off. About five minutes."}, "8 s with the tube dark and the heater off. About five minutes."},
{"id": "aa-offset-check", "title": "Coolant offset correction under the run airflow", "script": "aa_offset_check.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("dwell", "float", 20.0, "seconds to read under the run profile")],
"desc": "M8 brings the fans to the run profile (no laser-on, no press) while the raw coolant "
"counts, /status and the engine's readings are averaged before, during and after: with "
"cool_aa_offset_counts at the machine's value the readings hold still while the raw counts "
"step; at zero they drop by about a degree. About a minute."},
{"id": "critical-tier", "title": "Coolant critical-tier warm-loop drill", "script": "critical_tier_drill.py", {"id": "critical-tier", "title": "Coolant critical-tier warm-loop drill", "script": "critical_tier_drill.py",
"safety": "dry", "where": "board", "ported": True, "safety": "dry", "where": "board", "ported": True,
"args": [_arg("max_seconds", "int", 1200, "give up after this long without CRITICAL", flag="--max-seconds")], "args": [_arg("max_seconds", "int", 1200, "give up after this long without CRITICAL", flag="--max-seconds")],
@@ -2,5 +2,5 @@
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the # only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the # layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass). # pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "25cf969cf149bc539ff90f875fcc7edaa6c52ae2" SRCREV = "42cdb717b9fe30ed0e051636fee6fe5ea4d68299"
PV = "0.1.0" PV = "0.1.0"
+1
View File
@@ -42,6 +42,7 @@ page's takeover does that; from a host, stop them first.
| `flow_sustained.py` | Long-run test of the real re-check cadence via M8 (board or host; controller running): counts verdicts/false faults against the configured `cool_flow_rise` and tracks whether the loop accumulates heat. `flow_sustained.py [minutes]`. | | `flow_sustained.py` | Long-run test of the real re-check cadence via M8 (board or host; controller running): counts verdicts/false faults against the configured `cool_flow_rise` and tracks whether the loop accumulates heat. `flow_sustained.py [minutes]`. |
| `temp_calibrate.py` (`supply-*` modes) | The power supply's sensor (`pic/pwr_temp`, raw) against a thermometer on its heatsink: `supply-watch`, `supply-point <C>`, `supply-fit`; the fit is printed beside `UAPI.md`'s unverified guess. Three points during a long cut settle it. | | `temp_calibrate.py` (`supply-*` modes) | The power supply's sensor (`pic/pwr_temp`, raw) against a thermometer on its heatsink: `supply-watch`, `supply-point <C>`, `supply-fit`; the fit is printed beside `UAPI.md`'s unverified guess. Three points during a long cut settle it. |
| `critical_tier_drill.py` | The coolant critical tier on a rising temperature (board or host): sets the ceiling, the resume gate and the critical line a few tenths above the live upstream reading and lets the engine's own flow-check heater warm the loop through them inside one `M8` session, expecting `OVERTEMP` at the ceiling and then `CRITICAL` (fire blocked, hold, no resume) with the fault ending at `M9`; restores the settings and cycles a session so the engine re-reads them. Results as JSON in the bench data directory. | | `critical_tier_drill.py` | The coolant critical tier on a rising temperature (board or host): sets the ceiling, the resume gate and the critical line a few tenths above the live upstream reading and lets the engine's own flow-check heater warm the loop through them inside one `M8` session, expecting `OVERTEMP` at the ceiling and then `CRITICAL` (fire blocked, hold, no resume) with the fault ending at `M9`; restores the settings and cycles a session so the engine re-reads them. Results as JSON in the bench data directory. |
| `aa_offset_check.py` | Coolant offset correction under the run airflow (board; controller running; dark, no press): M8 brings the fans to the run profile while the raw coolant counts, `/status` and the engine's readings are averaged before, during and after; with `cool_aa_offset_counts` at the machine's value the readings hold still while the raw counts step, at zero they drop by about a degree. `aa_offset_check.py [dwell_s]`. |
| `offset_probe.py` | Coolant-sensor offset probe (board; forgectrl idle; dark, no press): switches one actuator at a time (exhaust at 100/50/25 %, intakes, air assist, purge, heater, pump, TEC, lid lamp, then all run fans) with both thermistors sampled at 25 Hz and scores the common-mode step at every edge and the level toggling inside every dwell; `offset_probe.py ladder` runs the air-assist duty ladder alone. Every value is restored on exit. JSON record in the bench data directory. | | `offset_probe.py` | Coolant-sensor offset probe (board; forgectrl idle; dark, no press): switches one actuator at a time (exhaust at 100/50/25 %, intakes, air assist, purge, heater, pump, TEC, lid lamp, then all run fans) with both thermistors sampled at 25 Hz and scores the common-mode step at every edge and the level toggling inside every dwell; `offset_probe.py ladder` runs the air-assist duty ladder alone. Every value is restored on exit. JSON record in the bench data directory. |
| `flow_warm_validate.py` | Runs the real check from a heater-warmed baseline (board or host; forgectrl and controller stopped; `flow_warm_validate.py [cycles_per_case]`; results/log in the bench data directory; exit 1 if any run is misclassified). 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_warm_validate.py` | Runs the real check from a heater-warmed baseline (board or host; forgectrl and controller stopped; `flow_warm_validate.py [cycles_per_case]`; results/log in the bench data directory; exit 1 if any run is misclassified). 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 (board or host; forgectrl and controller stopped; `flow_recheck_char.py [heater_pct] [window_s]`); shows why over-temp cannot see a stopped pump and why passive warming trends are ambiguous. | | `flow_recheck_char.py` | Characterizes short in-run re-checks and the differential metric (board or host; forgectrl and controller stopped; `flow_recheck_char.py [heater_pct] [window_s]`); shows why over-temp cannot see a stopped pump and why passive warming trends are ambiguous. |
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Does the coolant reading hold still when the run airflow comes on?
With cool_aa_offset_counts at the machine's value the engine takes the
air-assist fan's ground shift off both coolant readings, so /status and
/cool/status should read the same coolant before and after M8 brings the
fans to the run profile, while the raw counts step by the offset. With
the setting at zero the readings drop by about a degree instead. Dark,
no press: M8 opens a run session without a laser-on. Runs on the board
(the bench directory on PYTHONPATH for the Grbl client).
Usage: aa_offset_check.py [dwell_s]
"""
import json
import sys
import time
import urllib.request
from live_fire_drills import Grbl, HOST, PORT
def raw():
out = []
for a in ('pic/water_temp_1', 'pic/water_temp_2'):
with open('/sys/glowforge/' + a) as f:
out.append(int(f.read().strip()))
return out
def status():
with urllib.request.urlopen('http://127.0.0.1:8080/status', timeout=2) as r:
s = json.load(r)
with urllib.request.urlopen('http://127.0.0.1:8080/cool/status', timeout=2) as r:
c = json.load(r)
with urllib.request.urlopen('http://127.0.0.1:8080/settings', timeout=2) as r:
st = json.load(r)
return (s['coolant']['down_c'], s['coolant']['up_c'], c['down_c'], c['up_c'], c['phase'],
st.get('cool_aa_offset_counts'))
def mean_over(secs):
rs, ss = [], []
end = time.time() + secs
while time.time() < end:
rs.append(raw())
ss.append(status()[:4])
time.sleep(0.5)
n = len(rs)
return ([sum(x[i] for x in rs) / n for i in range(2)],
[sum(x[i] for x in ss) / n for i in range(4)])
def post_settings(**kv):
"""POST /settings with the panel token; returns the reply dict."""
import urllib.parse
try:
with open('/data/forgefirm/panel.token') as f:
tok = f.read().strip()
except OSError:
tok = ''
req = urllib.request.Request('http://127.0.0.1:8080/settings',
data=urllib.parse.urlencode(kv).encode(),
headers={'X-ForgeFIRM-Token': tok})
with urllib.request.urlopen(req, timeout=4) as r:
return json.load(r)
def main():
dwell = float(sys.argv[1]) if len(sys.argv) > 1 else 20.0
g = Grbl(HOST, PORT)
if 'Idle' not in g.status():
print('REFUSED: controller not Idle')
return 2
setting = status()[5]
print('cool_aa_offset_counts = %s' % setting)
# A run session starts the flow check, whose heater warms the
# downstream sensor inside this check's dwell: off for this session,
# back afterward.
with urllib.request.urlopen('http://127.0.0.1:8080/settings', timeout=2) as r:
was = json.load(r).get('cool_flow_check_s')
if was in (None, ''):
was = '50' # unset reads as empty: the shipped default
print('flow check off for this session (cool_flow_check_s was %s): %s'
% (was, post_settings(cool_flow_check_s='0').get('cool_flow_check_s')))
print('before M8: settling 8 s')
r0, s0 = mean_over(8.0)
print(' raw %.1f/%.1f /status %.2f/%.2f engine %.2f/%.2f' % (r0[0], r0[1], *s0))
print('M8: %s' % g.cmd('M8'))
time.sleep(6.0) # the fans to run duty, the offset in
r1, s1 = mean_over(dwell)
print(' raw %.1f/%.1f /status %.2f/%.2f engine %.2f/%.2f (phase %s)'
% (r1[0], r1[1], *s1, status()[4]))
print('M9: %s' % g.cmd('M9'))
if was is not None:
post_settings(cool_flow_check_s=str(was))
else:
post_settings(cool_flow_check_s='50')
with urllib.request.urlopen('http://127.0.0.1:8080/settings', timeout=2) as r:
print('flow check restored: cool_flow_check_s = %s' % json.load(r).get('cool_flow_check_s'))
time.sleep(25.0) # the session closes, the fans idle
r2, s2 = mean_over(8.0)
print(' raw %.1f/%.1f /status %.2f/%.2f engine %.2f/%.2f' % (r2[0], r2[1], *s2))
print('\nraw counts stepped %+.1f / %+.1f under the run profile; /status moved %+.2f / %+.2f C; '
'the engine moved %+.2f / %+.2f C'
% (r1[0] - r0[0], r1[1] - r0[1], s1[0] - s0[0], s1[1] - s0[1], s1[2] - s0[2], s1[3] - s0[3]))
return 0
if __name__ == '__main__':
sys.exit(main())