Pin forgectrl f9b4893: the air-assist ground shift off the coolant readings

The pin carries the compensation (cool_aa_offset_counts, the
aa-offset-calibrate diagnostic, the panel's Apply); verified with
bitbake -c fetch. The catalog gains cooling.aa-offset-calibrate, which
runs the tool and checks its recommendation and spread.

scripts/bench/offset_probe.py is the differential probe that found the
source: one actuator switched at a time with both sensors at 25 Hz
(survey), the air-assist duty ladder, the gantry jogging under the fan,
and an armed dark dwell. CAMPAIGN-LOG records the four probes and the
result; BRINGUP item 21 names the setting and the calibrate as what
remains on this machine, and item 23 opens the initial commissioning
procedure.

No catalog consequence beyond the new case: a bench tool and records.
This commit is contained in:
ScottW514
2026-08-29 17:36:02 -04:00
parent 61b68c387c
commit 8831d9b20e
6 changed files with 626 additions and 8 deletions
+34 -7
View File
@@ -1468,13 +1468,24 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
second machine is on the bench (one tube, one supply so far); and if a
lit check still trips, the void-on-emission design with the tube as
its own flow tracer.
Open question, the offset's source: the timing points at the airflow
drive (the step lands one sample after the fans go to run duty, before
any HV, and lifts when they go idle, long after the tube is dark), but
high-voltage energy coupling into the lines between the sensors and
the ADC is the other candidate and a shared path could show both; a
scope on the two sensor lines through a session, fans and tube
switched separately, decides.
The offset's source is the air-assist fan's return current on a ground
path the thermistor reference shares (about 1.2 C at the run duty,
proportional to the fan's current, both sensors alike; not crosstalk
on the sensor cable and not HV). The check cancels it now that its
baseline is taken under the run profile, but the over-temperature
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
(the `aa-offset-calibrate` diagnostic measures it, the panel's Apply
writes it; zero is the factory's uncorrected reading). Owed: the
calibrate run on this machine and its value applied, 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),
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
a return the thermistor reference shares, or its switching, is what
remains; a scope on the two sensor lines during a cut is the next
instrument. It sits inside the ceiling's 2 C hysteresis and the flow
check reads means, so it is a measurement item, not a gate item.
22. **Laser power-good: what the line means.** `cnc/laser_pgood` and its
sampled count are defined in the UAPI (active low, one sample every
~3.9 ms), the facts bank records that the sampled count reads 0 through
@@ -1487,6 +1498,22 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
scope against `hv_current` through an armed cut, its meaning written
into the facts bank and the UAPI, and then either a warning that means
something or no warning.
23. **Initial commissioning: measure and set the machine's own numbers
methodically.** Every tunable that was measured on the bench machine
and shipped as a default varies from machine to machine: the flow
check's bands and `cool_flow_rise`, the tube's heat coefficients
(`cool_laser_heat_cw`, `cool_laser_heat_density`), the air-assist
ground offset on the coolant readings, the laser's striking and lasing
thresholds and the duty floor, the fan floors, the thermistor curve
itself. Owed: one commissioning procedure, run once on a new machine
from the panel or the bench page, that measures each of these in
order with the tube dark wherever it can be, fires only where it
must, and writes the results as that machine's settings with a
record; and a reading of what the cloud sets for the same machine,
taken from cloud cuts (the pulse header carries the factory's
per-machine values), so the commissioning can start from the
factory's own numbers where they exist and note where they differ
from the measured ones.
**Deliberately not gated:** an armed GRBL job after an underrun cuts at the
stale origin unless homing is required (GRBL mode permits unhomed cutting; the
+72
View File
@@ -4420,6 +4420,78 @@ head jogged back by the baseline; the case now brings the head back
itself. The case rides the next image; its campaign standing comes with
that image's campaign.
## 2026-08-29: the coolant ADC offset is the air-assist fan's return current
The common-mode offset on the two coolant sensors (about 1 C low while the
run airflow profile is on, BRINGUP item 21) was run down differentially,
dark, with `scripts/bench/offset_probe.py`: forgectrl idle, one actuator
switched at a time, both sensors at 25 Hz, the step at every edge scored
as the 1.5 s means after minus before. Records
`bench-data/offset_probe_20260829-205928.json` (the survey) and
`offset_probe_20260829-210233.json` (the ladder).
The wiring first, from the OpenGlow board's netlist (pin-compatible with
the factory board): the thermistors enter on J2 pins 3 and 4 with the pump
enable (2), the TEC enable (1), the TEC thermistor (6), the heater PWM (7),
the exhaust tach (8) and the exhaust PWM (9) beside them, then 12 V, the
beam-detect lines, Z step and direction, the head I2C and the head camera
lanes; the intake fans, the HV lines, `LASER_ON` and `HV_EN` are on J1;
the air assist is driven on the head. The run profile drives the exhaust
at 65535 (100 %, no PWM edges) and the intakes at 43278.
The survey: exhaust at 100 %, 50 % and 25 %, the intakes at their run
duty, purge, the TEC enable and the lid lamp each move both sensors by
0.1 C or less; the heater and the pump edges move the downstream sensor
only (thermal). The air assist from its idle 204 to its run 1023 steps
both sensors together, -1.37 and -1.25 C in one sample, and back +1.18 and
+1.13; "all run fans" gives the same -1.28 and -1.23. The ladder: 256
-0.03, 512 -0.27, 768 -0.6, 1023 -1.2 C cumulative, both sensors alike,
each step reversed on the way down, and -1.2 to -1.3 C on two full on and
off repeats. The offset is proportional to the air-assist fan's current: a
ground-return drop on a path the thermistor reference shares, not
crosstalk on J2 and not HV (the `flowload` traces already show the step
before any HV and no further step at emission). Both sensors read low by
the same amount whenever the air assist runs, so the flow check's rise is
untouched now that its baseline is taken under the run profile, and the
over-temperature gates read the coolant about 1.2 C cooler than it is
during a job. The mid-run toggling between two levels is not reproduced by
a steady fan (0 toggles in every dwell); the fan's own current variation
under motion is the remaining candidate.
## 2026-08-29: the toggling is not motion
`offset_probe.py jog` (record `bench-data/offset_jog_20260829-211710.json`),
dark, no press: the air assist steady at its run duty while the gantry
jogged 30 mm in X and 8 mm in Y for 40 s, then the same jog with the fan
idle. Zero level toggles in every phase; with the fan on the readings sat
1.1 C low and as quiet while jogging (sd 0.26 C) as while still (0.32),
and the fan's tach held 677 to 678 under motion. Motion and the head's
pogo contacts under vibration are out. The toggling seen in the day's
`flowload t2` traces sits inside the armed windows only (from the moment
`armed` went true, past the burst's end, gone when the fans went idle), and
the dark runs with the fans at run duty show none, which leaves the HV
supply's enable, asserted from the press to the disarm, as the candidate;
an armed dark dwell (`M3 S0`, the window open, no emission) is the test.
## 2026-08-29: the toggling needs the tube lit
`offset_probe.py armed` (record `bench-data/offset_armed_20260829-212245.json`):
`M3 S0`, the press 0.3 s after the M3, the window open 73 s with the head
still, the flow check's heater running inside it, no emission (0 lit
samples on the LASER_ON witness and the tube current), then M2. Zero level
toggles on both sensors through the armed window; the one step after M2
is the fans returning to idle. The `hv_enable` switch read false
throughout, so it does not follow the arm. Every toggle in the day's
`flowload t2` traces sits inside a lit period (seven between +7 and +34 s
around a burst lit from +7 to +27; thirteen under a 41 s burst;
twenty-two under a 48 s burst), and none appear dark, with the fans
alone, under motion, or in an armed dark window. The jitter comes with
tube current: the HV supply's input current on a return the thermistor
reference shares, or its switching, is what remains, and a scope on the
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,
and the flow check reads means.
## Superseded status notes
### Shared machine services — remaining polish, as listed 2026-08-13
+52
View File
@@ -71,6 +71,58 @@ def flow_verify(ctx):
ctx.check(fc.wait_idle(120, abort=ctx.aborted), "machine did not return to idle after the diagnostic")
@test("cooling.aa-offset-calibrate", title="The air-assist ground shift on the coolant readings measures cleanly",
subsystem="cooling", kind="auto", est_min=2,
covers=_COOL_COVERS, requires=["kernel.latch-locked-idle"],
steps=["Coolant loop normal (pump on); the machine idle. The controller is suspended by "
"forgectrl for the duration (about a minute); the air assist cycles three times."],
description="forgectrl's aa-offset-calibrate diagnostic: the air-assist fan stepped idle to "
"run and back three times with the tube dark and the heater off, both coolant "
"sensors read at every edge. The fan's return current shifts both readings by "
"a number of ADC counts the engine adds back (cool_aa_offset_counts). PASS = the "
"tool reports a recommendation, the six edges agree within its spread limit, and "
"the value sits inside the setting's legal range. The setting is not written.")
def aa_offset_calibrate(ctx):
fc = ctx.forgectrl
ev = ctx.evidence
st, body = fc.get("/diag/status")
ctx.check(st == 200 and isinstance(body, dict), "GET /diag/status -> %s", st)
ctx.check(not body.get("running"), "a diagnostic is already running (%s)", body.get("tool"))
st, body = fc.post("/diag/aa-offset-calibrate")
ctx.log("POST /diag/aa-offset-calibrate -> %s %s", st, body if isinstance(body, dict) else "")
ctx.check(st == 202 and isinstance(body, dict) and body.get("started") is True,
"could not start aa-offset-calibrate (%s %s)", st, body)
result = None
last_phase = None
t0 = time.time()
try:
while time.time() - t0 < 300:
ctx.checkpoint()
st, d = fc.get("/diag/status")
if st == 200 and isinstance(d, dict):
if d.get("phase") != last_phase:
last_phase = d.get("phase")
ctx.log("phase: %s", last_phase)
if not d.get("running") and d.get("result") is not None:
result = d.get("result")
for line in d.get("log", [])[-10:]:
ctx.log(" diag: %s", line)
break
time.sleep(2)
except BaseException:
fc.post("/diag/abort")
raise
ctx.check(result is not None, "aa-offset-calibrate did not finish within 5 minutes")
ev["result"] = result
ctx.check("error" not in result, "aa-offset-calibrate error: %s", result.get("error"))
ctx.log("offset %s counts, spread %s, recommend %s, edges %s", result.get("offset_counts"),
result.get("spread_counts"), result.get("recommend"), result.get("steps"))
ctx.check("recommend" in result, "no recommendation in %s", result)
ctx.check(0 <= float(result["recommend"]) <= 60,
"recommendation %s outside the setting's range 0..60", result["recommend"])
ctx.check(fc.wait_idle(120, abort=ctx.aborted), "machine did not return to idle after the diagnostic")
IDLE_DUTY = {"thermal/exhaust_pwm": 0, "thermal/intake_pwm": 0} # forgectrl's idle posture
TACH_KEYS = ("exhaust", "intake_1", "intake_2")
SAMPLE_S = 5 # tach sampling period (the big exhaust fan coasts for tens of seconds)
@@ -2,5 +2,5 @@
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "2f18b16fe583a278220d765606c18a2a78ad320f"
SRCREV = "f9b48934b32278140510401bd066dac1e1437b7e"
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]`. |
| `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. |
| `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_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_confirm_drill.py` | Coolant flow suspicion/confirmation drill (runs on the board): one continuous M8 session walks the verdict state machine through real pump-off transients — verified → SUSPECT (+ immediate re-check) → cleared → SUSPECT → FAULT (consecutive) → recovered — printing PASS/FAIL per transition. Leaves the machine idle (M9, pump on, heater off). |
+466
View File
@@ -0,0 +1,466 @@
#!/usr/bin/env python3
"""Coolant-sensor offset probe: which actuator moves both thermistors?
The coolant ADC reads about 1 C low on both sensors while the run airflow
profile is on, stepping in one sample after the fans start, out when they
stop, and toggling between two levels in between. The two thermistor
lines ride J2 pins 3 and 4 beside the pump enable (2), the TEC enable (1),
the heater PWM (7), the exhaust tach (8) and the exhaust PWM (9); the
intake fans and the HV lines are on J1, the air assist on the head. This
probe switches one actuator at a time, dark (no laser, no press), with
both sensors sampled at 25 Hz, and scores the common-mode step at every
edge and the toggling inside every dwell. Runs on the board with
forgectrl idle (the engine writes the fans only at session transitions,
so a write here stands until the next session); every value is restored
on the way out, whatever happens. The pump is stopped for one short dwell
with the tube dark and the heater off, the one pump-off state allowed.
Usage: offset_probe.py [repeat] (repeats of the exhaust-run edge, default 2)
offset_probe.py ladder (the air-assist duty ladder alone: the step
against duty tells a current-proportional
ground drop from a threshold)
offset_probe.py jog (the air assist steady at its run duty while
the gantry jogs, dark: do the readings toggle
under motion? controller idle in GRBL mode,
35 mm +X and 10 mm +Y free; no laser, no press)
Record: FORGETEST_BENCH_DATA or /tmp, offset_probe_<stamp>.json (jog: offset_jog_<stamp>.json)
"""
import json
import math
import os
import sys
import threading
import time
SYSFS = '/sys/glowforge/'
LEDS = '/sys/class/leds/'
HZ = 25
PRE, POST, GAP = 1.5, 1.5, 0.3 # seconds around an edge for the step means
# B-equation from status.c: 10k B3380 NTC in a 10k divider behind a 1.3x
# reference.
ADC_F = 1024.0 * 1.3
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
def degc(raw):
if raw is None or raw <= 0 or raw >= ADC_F:
return float('nan')
r = 10000.0 / (ADC_F / raw - 1.0)
return 3380.0 / math.log(r / RINF) - 273.15
def rd(path):
try:
with open(path) as f:
return f.read().strip()
except OSError:
return None
def wr(path, val):
with open(path, 'w') as f:
f.write(str(val))
class Sampler(threading.Thread):
def __init__(self):
super().__init__(daemon=True)
self.samples = []
self.stop = threading.Event()
def run(self):
period = 1.0 / HZ
nxt = time.time()
while not self.stop.is_set():
t = time.time()
a, b = rd(SYSFS + 'pic/water_temp_1'), rd(SYSFS + 'pic/water_temp_2')
self.samples.append((t, int(a) if a and a.isdigit() else None,
int(b) if b and b.isdigit() else None))
nxt += period
d = nxt - time.time()
if d > 0:
time.sleep(d)
else:
nxt = time.time()
def mean_c(samples, t0, t1, idx):
v = [degc(s[idx]) for s in samples if t0 <= s[0] < t1 and s[idx] is not None]
v = [x for x in v if x == x]
return sum(v) / len(v) if v else None
def toggles(samples, t0, t1):
"""Level steps inside a dwell: both sensors' half-second means moving
0.45 C or more the same way, agreeing within 0.4 C (the flowload
detector)."""
pts = [(s[0], degc(s[1]), degc(s[2])) for s in samples
if t0 <= s[0] < t1 and s[1] is not None and s[2] is not None]
pts = [p for p in pts if p[1] == p[1] and p[2] == p[2]]
w, gap, n = 12, 2, 0
i = w + gap
while i < len(pts) - w - gap:
pre = pts[i - gap - w:i - gap]
post = pts[i + gap:i + gap + w]
dd = sum(p[1] for p in post) / w - sum(p[1] for p in pre) / w
du = sum(p[2] for p in post) / w - sum(p[2] for p in pre) / w
if abs(dd) >= 0.45 and abs(du) >= 0.45 and (dd > 0) == (du > 0) and abs(dd - du) <= 0.4:
n += 1
i += w + 2 * gap
else:
i += 1
return n
def run_jog():
"""The air assist steady at its run duty while the gantry jogs, dark:
does motion make the readings toggle between two levels? Phases: still
/ fan on still / fan on jogging / fan on still / fan off jogging / fan
off still. Needs the controller idle in GRBL mode and 35 mm of free +X
and 10 mm of +Y travel; no laser, no press. The Grbl client comes from
live_fire_drills (the bench directory on PYTHONPATH)."""
from live_fire_drills import Grbl, HOST, PORT
aa = SYSFS + 'head/air_assist_pwm'
orig = rd(aa)
g = Grbl(HOST, PORT)
st = g.status()
if 'Idle' not in st:
print('REFUSED: controller is %s, expected Idle' % st)
return 2
print('G91/G21: %s / %s' % (g.cmd('G91'), g.cmd('G21')))
sampler = Sampler()
sampler.start()
tach = []
phases = []
stop_motion = threading.Event()
def mover():
while not stop_motion.is_set():
for ln in ('G0 X30 F3000', 'G0 Y8 F3000', 'G0 X-30 F3000', 'G0 Y-8 F3000'):
g.s.sendall(ln.encode() + b'\n')
g.wait_state('Idle', 20)
if stop_motion.is_set():
break
time.sleep(0.2)
def phase(label, fan, motion, dwell):
t0 = time.time()
if fan is not None:
wr(aa, fan)
th = None
if motion:
stop_motion.clear()
th = threading.Thread(target=mover, daemon=True)
th.start()
end = t0 + dwell
while time.time() < end:
v = rd(SYSFS + 'head/air_assist_tach')
tach.append((time.time(), int(v) if v and v.isdigit() else None))
time.sleep(1.0)
if th:
stop_motion.set()
th.join(25)
g.wait_state('Idle', 20)
phases.append((label, t0, time.time()))
print(' %+7.1f s %s done' % (time.time() - phases[0][1], label), flush=True)
try:
phase('still, fan idle', None, False, 10)
phase('fan run, still', 1023, False, 15)
phase('fan run, jogging', None, True, 40)
phase('fan run, still again', None, False, 10)
phase('fan idle, jogging', 204, True, 30)
phase('fan idle, still', None, False, 10)
finally:
stop_motion.set()
try:
g.wait_state('Idle', 20)
g.cmd('G90')
except Exception:
pass
if orig is not None:
wr(aa, orig)
time.sleep(2)
sampler.stop.set()
sampler.join(2)
tr = sampler.samples
print('\n%d samples, %.1f Hz' % (len(tr), (len(tr) - 1) / (tr[-1][0] - tr[0][0])))
print('--- per phase: level toggles (both sensors, >= 0.45 C), the readings and the air-assist tach ---')
print(' phase toggles down mean/sd up mean/sd tach mean/sd')
rows = []
for label, t0, t1 in phases:
d = [degc(s[1]) for s in tr if t0 + 1 <= s[0] < t1 and s[1] is not None]
u = [degc(s[2]) for s in tr if t0 + 1 <= s[0] < t1 and s[2] is not None]
tk = [v for t, v in tach if t0 <= t < t1 and v is not None]
sd = lambda v: (sum((x - sum(v) / len(v)) ** 2 for x in v) / len(v)) ** 0.5 if len(v) > 1 else 0.0
tg = toggles(tr, t0 + 1.0, t1)
rows.append({'phase': label, 'toggles': tg, 'down_mean': sum(d) / len(d) if d else None,
'down_sd': sd(d), 'up_mean': sum(u) / len(u) if u else None, 'up_sd': sd(u),
'tach_mean': sum(tk) / len(tk) if tk else None, 'tach_sd': sd(tk), 'dwell_s': t1 - t0})
print(' %-26s %5d %6.2f/%.2f %6.2f/%.2f %s'
% (label, tg, rows[-1]['down_mean'] or 0, sd(d), rows[-1]['up_mean'] or 0, sd(u),
'-' if not tk else '%.0f/%.0f' % (rows[-1]['tach_mean'], sd(tk))))
rec = {'drill': 'offset_probe_jog', 'date': time.strftime('%Y-%m-%dT%H:%M:%S'),
'phases': rows, 'tach': tach, 'trace': tr}
ddir = os.environ.get('FORGETEST_BENCH_DATA') or '/tmp'
path = os.path.join(ddir, 'offset_jog_%s.json' % time.strftime('%Y%m%d-%H%M%S'))
with open(path, 'w') as f:
json.dump(rec, f)
print('record: %s' % path)
return 0
def run_armed():
"""An armed window with the tube dark: M3 S0 opens the window on the
press (the fire level is zero, nothing is ever requested), a 60 s
dwell, then M2. Phases: before the session / session open, waiting
for the press / armed (the HV enable asserted) / after M2. Any lit
sample (LASER_ON witness or tube current) soft-resets at once."""
import json as _json
import urllib.request
from live_fire_drills import Grbl, HOST, PORT
g = Grbl(HOST, PORT)
st = g.status()
if 'Idle' not in st:
print('REFUSED: controller is %s, expected Idle' % st)
return 2
print('spindle off: %s' % g.cmd('M5'))
def status():
try:
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)
return {'t': time.time(), 'armed': c.get('armed'), 'phase': c.get('phase'),
'hv_enable': (s.get('switches') or {}).get('hv_enable'),
'emission': (s.get('laser') or {}).get('emission_samples'),
'hv': s.get('hv_current_raw')}
except Exception:
return None
pre = status()
if not pre or pre['armed']:
print('REFUSED: forgectrl unreachable or the window is already armed (%s)' % pre)
return 2
print('>>> ARMED DARK DWELL: the button lights on the M3; press it. The fire level is')
print('>>> zero and nothing is commanded; any lit sample soft-resets the job.')
sampler = Sampler()
sampler.start()
poll = []
lit = []
stop = threading.Event()
def poller():
while not stop.is_set():
s = status()
if s:
poll.append(s)
if (s['emission'] or 0) > 0 or (s['hv'] or 0) > 20:
lit.append(s)
v = rd(SYSFS + 'cnc/laser_on_sampled')
if v and v.isdigit() and int(v) > 0:
lit.append({'t': time.time(), 'lon': int(v)})
time.sleep(0.5)
th = threading.Thread(target=poller, daemon=True)
th.start()
phases = []
t_m3 = None
try:
time.sleep(8)
phases.append(('before the session', sampler.samples[0][0], time.time()))
print('G91/G21: %s / %s' % (g.cmd('G91'), g.cmd('G21')))
t_m3 = time.time()
for ln in ('M3 S0', 'G4 P60', 'M5', 'G90', 'M2'):
g.s.sendall(ln.encode() + b'\n')
# wait for the press: the engine's armed goes true
t_armed = None
deadline = time.time() + 300
while time.time() < deadline and not lit:
g.drain()
if poll and poll[-1]['armed']:
t_armed = poll[-1]['t']
break
time.sleep(0.2)
if lit:
raise RuntimeError('lit sample before the press: %s' % lit[:2])
if t_armed is None:
raise RuntimeError('no press within 300 s')
phases.append(('session open, waiting for the press', t_m3, t_armed))
print(' armed %.1f s after the M3' % (t_armed - t_m3), flush=True)
# the dwell: armed, dark
t_end = None
while time.time() < t_armed + 75 and not lit:
g.drain()
if poll and not poll[-1]['armed'] and time.time() > t_armed + 5:
t_end = poll[-1]['t']
break
time.sleep(0.2)
if lit:
raise RuntimeError('LIT SAMPLE DURING THE ARMED DWELL: %s' % lit[:2])
t_end = t_end or time.time()
phases.append(('armed, dark dwell', t_armed, t_end))
print(' window closed %.1f s after the arm' % (t_end - t_armed), flush=True)
time.sleep(20)
phases.append(('after M2, fans still at run', t_end, time.time()))
except Exception as e:
print('ABORT: %s' % e)
g.rt(b'\x18')
time.sleep(2)
finally:
try:
g.cmd('M5', timeout=1)
except Exception:
pass
stop.set()
sampler.stop.set()
sampler.join(2)
th.join(2)
tr = sampler.samples
print('\n%d samples, %.1f Hz; lit samples: %d' % (len(tr), (len(tr) - 1) / (tr[-1][0] - tr[0][0]), len(lit)))
print('--- replies ---')
for t, ln in g.log:
if ln != 'ok':
print(' %+7.1f s %s' % (t - (t_m3 or t), ln))
print('--- hv_enable / armed transitions ---')
last = None
for p in poll:
k = (p['hv_enable'], p['armed'])
if k != last:
print(' %+7.1f s hv_enable=%s armed=%s phase=%s' % (p['t'] - (t_m3 or p['t']), p['hv_enable'], p['armed'], p['phase']))
last = k
print('--- per phase: toggles, readings ---')
print(' phase toggles down mean/sd up mean/sd')
rows = []
for label, t0, t1 in phases:
d = [degc(s[1]) for s in tr if t0 + 1 <= s[0] < t1 and s[1] is not None]
u = [degc(s[2]) for s in tr if t0 + 1 <= s[0] < t1 and s[2] is not None]
sd = lambda v: (sum((x - sum(v) / len(v)) ** 2 for x in v) / len(v)) ** 0.5 if len(v) > 1 else 0.0
tg = toggles(tr, t0 + 1.0, t1)
rows.append({'phase': label, 'toggles': tg, 'down_mean': sum(d) / len(d) if d else None,
'down_sd': sd(d), 'up_mean': sum(u) / len(u) if u else None, 'up_sd': sd(u), 'dwell_s': t1 - t0})
print(' %-38s %5d %6.2f/%.2f %6.2f/%.2f'
% (label, tg, rows[-1]['down_mean'] or 0, sd(d), rows[-1]['up_mean'] or 0, sd(u)))
rec = {'drill': 'offset_probe_armed', 'date': time.strftime('%Y-%m-%dT%H:%M:%S'), 't_m3': t_m3,
'phases': rows, 'poll': poll, 'lit': lit, 'replies': g.log, 'trace': tr}
ddir = os.environ.get('FORGETEST_BENCH_DATA') or '/tmp'
path = os.path.join(ddir, 'offset_armed_%s.json' % time.strftime('%Y%m%d-%H%M%S'))
with open(path, 'w') as f:
json.dump(rec, f)
print('record: %s' % path)
return 0 if not lit else 1
def main():
if len(sys.argv) > 1 and sys.argv[1] == 'jog':
return run_jog()
if len(sys.argv) > 1 and sys.argv[1] == 'armed':
return run_armed()
ladder = len(sys.argv) > 1 and sys.argv[1] == 'ladder'
repeat = int(sys.argv[1]) if len(sys.argv) > 1 and not ladder else 2
attrs = {
'exhaust': SYSFS + 'thermal/exhaust_pwm',
'intake': SYSFS + 'thermal/intake_pwm',
'air_assist': SYSFS + 'head/air_assist_pwm',
'purge': SYSFS + 'head/purge_air',
'heater': SYSFS + 'thermal/heater_pwm',
'pump': SYSFS + 'thermal/water_pump_on',
'tec': SYSFS + 'thermal/tec_on',
'lid_led': LEDS + 'lid_led/brightness',
}
orig = {k: rd(p) for k, p in attrs.items()}
print('start values: %s' % orig)
if orig['pump'] != '1' or orig['heater'] not in ('0', None):
print('REFUSED: the pump is not on or the heater is not off (%s, %s)' % (orig['pump'], orig['heater']))
return 2
# (label, key, value, dwell)
sched = [('baseline', None, None, 10)]
if ladder:
for duty in (256, 512, 768, 1023, 768, 512, 256):
sched.append(('air assist %d/1023' % duty, 'air_assist', duty, 10))
sched.append(('air assist idle', 'air_assist', 204, 10))
for duty in (1023, 204, 1023, 204):
sched.append(('air assist %d' % duty, 'air_assist', duty, 8))
for _ in range(0 if ladder else repeat):
sched += [('exhaust 100% (run, DC)', 'exhaust', 65535, 12), ('exhaust off', 'exhaust', 0, 12)]
if not ladder:
sched += [('exhaust 50% (PWM edges)', 'exhaust', 32768, 12), ('exhaust off', 'exhaust', 0, 12),
('exhaust 25%', 'exhaust', 16384, 12), ('exhaust off', 'exhaust', 0, 12),
('intake 66% (run)', 'intake', 43278, 12), ('intake off', 'intake', 0, 12),
('air assist run', 'air_assist', 1023, 12), ('air assist idle', 'air_assist', 204, 12),
('purge off', 'purge', 0, 8), ('purge on', 'purge', 1, 8),
('heater 40%', 'heater', 26214, 8), ('heater off', 'heater', 0, 14),
('pump off (dark, heater off)', 'pump', 0, 8), ('pump on', 'pump', 1, 12),
('tec on', 'tec', 1, 6), ('tec off', 'tec', 0, 6),
('lid lamp full', 'lid_led', 1023, 6), ('lid lamp back', 'lid_led', orig['lid_led'] or 0, 6),
('all run fans', None, None, 0)]
sampler = Sampler()
sampler.start()
edges = []
try:
for label, key, val, dwell in sched:
if label == 'all run fans':
t = time.time()
wr(attrs['exhaust'], 65535)
wr(attrs['intake'], 43278)
wr(attrs['air_assist'], 1023)
edges.append((t, label))
time.sleep(15)
t = time.time()
wr(attrs['exhaust'], 0)
wr(attrs['intake'], 0)
wr(attrs['air_assist'], 204)
edges.append((t, 'all fans off'))
time.sleep(12)
continue
if key:
t = time.time()
wr(attrs[key], val)
edges.append((t, label))
print(' %+7.1f s %s' % (t - edges[0][0], label), flush=True)
else:
edges.append((time.time(), label))
time.sleep(dwell)
finally:
for k, p in attrs.items():
if orig[k] is not None:
try:
wr(p, orig[k])
except OSError as e:
print('RESTORE FAILED %s: %s' % (k, e))
time.sleep(3)
sampler.stop.set()
sampler.join(2)
tr = sampler.samples
t0 = edges[0][0]
print('\n%d samples, %.1f Hz' % (len(tr), (len(tr) - 1) / (tr[-1][0] - tr[0][0])))
print('--- edges: common-mode step (1.5 s means after minus before), then toggles inside the dwell ---')
print(' t(s) edge down step up step | common | toggles')
rows = []
for i, (t, label) in enumerate(edges):
t_end = edges[i + 1][0] if i + 1 < len(edges) else tr[-1][0]
b1, a1 = mean_c(tr, t - PRE - GAP, t - GAP, 1), mean_c(tr, t + GAP, t + GAP + POST, 1)
b2, a2 = mean_c(tr, t - PRE - GAP, t - GAP, 2), mean_c(tr, t + GAP, t + GAP + POST, 2)
d1 = None if None in (a1, b1) else a1 - b1
d2 = None if None in (a2, b2) else a2 - b2
common = (d1 is not None and d2 is not None and abs(d1) >= 0.4 and abs(d2) >= 0.4
and (d1 > 0) == (d2 > 0))
tg = toggles(tr, t + 2.0, t_end)
rows.append({'t': t - t0, 'edge': label, 'down_step_c': d1, 'up_step_c': d2,
'common': common, 'toggles': tg, 'dwell_s': t_end - t})
print(' %6.1f %-28s %8s %8s | %-6s | %d'
% (t - t0, label, '-' if d1 is None else '%+.2f' % d1,
'-' if d2 is None else '%+.2f' % d2, 'YES' if common else '', tg))
rec = {'drill': 'offset_probe', 'date': time.strftime('%Y-%m-%dT%H:%M:%S'), 'orig': orig,
'edges': rows, 'trace': tr}
ddir = os.environ.get('FORGETEST_BENCH_DATA') or '/tmp'
path = os.path.join(ddir, 'offset_probe_%s.json' % time.strftime('%Y%m%d-%H%M%S'))
with open(path, 'w') as f:
json.dump(rec, f)
print('record: %s' % path)
print('end values: %s' % {k: rd(p) for k, p in attrs.items()})
return 0
if __name__ == '__main__':
sys.exit(main())