Files
forgefirm/scripts/bench/live_fire_drills.py
T
ScottW514 d17c348e91 Add the dpatch depth-witness drill
live_fire_drills.py gains dpatch: two rows of small serpentine-filled
patches on scrap. Row A is CW at feeds that give relative doses from 1.0
to 0.25. Row B is density 100, 80, 60, 45, and 30 percent at F600. The
operator matches each row-B patch to the row-A patch of equal depth. That
reads the light fraction of a density off the material, next to the
prediction of the thermopile. The drill samples sysfs at 25 Hz, as pcurve
does, and writes a JSON record to the bench data directory.

Bench tool only, on the dev image; no catalog consequence.
2026-08-26 10:00:42 -04:00

1728 lines
77 KiB
Python

#!/usr/bin/env python3
"""Live-fire bench drills - Phases 4, 5, 6. Runs on the board (the bench
page) or from a LAN host, against grblHAL over TCP (port 23) and
forgectrl over HTTP (:8080); the machine is GF_HOST, default 127.0.0.1.
LIVE LASER: the operator must be armed with eye protection, a fire
watch, an extinguisher, and the exhaust running. Every drill waits for
the operator to press the physical arm button before the machine fires;
nothing here defeats that gate.
Usage: live_fire_drills.py <drill> [arg] [F] (ircut/pthresh take S,
dladder takes the base period in machine ticks, pcurve takes
F, the line length and an optional rung list)
Drills (pass a name):
witness Phase 5 A-1/A-2/A-5: a short vector mark at S400. Samples
forgectrl /status (emission_samples, lid_ir[], hv_current)
and /cool/status (armed, fire_watch) at ~8 Hz through the
job. PASS: emission_samples goes nonzero during the fire
window and returns to 0; lid_ir peak recorded per channel
vs the ambient baseline; hv_current range logged. Also
asserts X-3: armed drops at Idle with the job close, not at
+60 s.
hold Phase 4 G-10: arm + start a longer job, feed-hold mid-run,
then hold. PASS: the disarm grace counts down in Hold and
the window closes (armed -> false) without the job resuming.
faultpos Phase 6 G-2/G-3: after a run that was stopped by an
underrun (position no longer trusted), a subsequent armed
job must refuse to cut at the stale origin - the sender
alarms and re-home is required. Reads homed via /status.
ircut Lid-IR fire characterization at cutting power: a 30 mm
square at S<power> (default 1000 = full) and F<feed>
(default 300) on scrap, sampled like `witness`. Prints the
per-channel peak delta over the ambient baseline and the
engine's own "run telemetry" line is the record. Run it
>= 3 times on representative material; the highest peak
delta sizes cool_fire_ir_delta.
ircut [S] [F] e.g. ircut 1000 300
pthresh Laser power-threshold ladder: one line per power level on
scrap, climbing from 2 % to 30 % of full, at constant power
(M3) so nothing scales the duty with velocity. The ladder
separates two thresholds that are far apart: the discharge
strikes at a duty well below the one the tube lases at, and
the rungs between them show only a spot at the line start
(the strike transient) with a dark line after it. The
lowest rung that leaves a CONTINUOUS mark is the lasing
threshold, and because $35 is a percent of full duty and
the rungs are percents of $30 with $31 = 0, that rung's
percent IS the $35 value. Requires $35 = 0 for the run: a
floor already in place lifts every rung and hides both
thresholds.
pthresh [Smax] [F] e.g. pthresh 1000 300
dladder Density ladder for the FIRE-bit dose model: one line per
dose level on scrap, 5 % to 100 %, at constant power (M3)
so nothing scales the dose with velocity. Under this model
the duty is pinned at full and the level is carried by how
many ticks of each base period fire, so a rung's burst is
density x period - and the base period is the parameter the
host cannot choose for you. Run it at 20, then 40, then 10
on the same material: 20 ticks is 710 us, the factory's
~1.43 kHz, and 40 and 10 bracket it. Two questions the
material answers: does mark depth track density linearly,
and how short a burst still marks. Requires
laser_power_model = density; reads $30/$31/$35/$36 and
reports the mapping, so a run with a density floor set shows
what a shipped machine would actually emit. Sets
laser_pulse_ticks itself when run on the board.
dladder [period] [F] e.g. dladder 20 300
pcurve Laser performance-curve ladder, both instruments: one line
per level at constant feed (default F600, 10 mm/s, 100 mm),
M3 constant power, the laser off between rungs and a
mid-ladder rung repeated at the end as the drift witness. Reads
the HV current and the head thermopile (a scatter detector
in the beam path upstream of the final mirror, so it sees
the beam, not the material) straight from sysfs at ~25 Hz
while the machine runs, brackets each rung on the
controller's Run/Idle states, and reports per rung the
current (mean, spread, max, CLIPPED at 1023), the thermopile
delta over its laser-off baseline with its in-line drift,
the digital flag's duty and the coolant temperature; then
the normalized curve, a monotonicity check, a straight-line
fit with its x-intercept as the measured threshold, and the
repeat-rung comparison. Rungs follow laser_power_model:
analog 16..100 % of duty (dense at the knee), density
1..100 %; a comma list overrides. Records the actual level
each S lands on from $30/$31/$35/$36; measuring the curve
itself wants $35 = 0. JSON record (with the raw trace) in
FORGETEST_BENCH_DATA, else /tmp. Runs on the board for the
thermopile; from a host it falls back to /status (current
only, no curve). Reaches FULL power for 10 s per line.
pcurve [F] [len] [pcts] e.g. pcurve 600 100 16,20,30,50,100
m5dark The rapids after an M5 ship dark: one 20 mm line at M3 S400,
M5, a dwell, a rapid back over the line, a dwell, a rapid
forward, a dwell, M2. Samples sysfs at 25 Hz (board only):
PASS when the current shows exactly one discharge segment,
reads dark after the M5, and laser_on_sampled never goes
nonzero again after its first zero past the line. Prints the
9 s after the line at 40 ms steps. The catalog's
laser.m5-rapid-dark is its port.
dpatch Depth witness for the density curve: two rows of small
engraved patches (serpentine G1 fills) on the stock. Row A
is CW (S1000) at feeds giving relative doses 1.0 to 0.25;
row B is density 100/80/60/45/30 %% at F600. Match each
row-B patch to the row-A patch of equal depth by eye: that
reads the density's light fraction off the material, next
to what the thermopile says it should be. Samples sysfs at
25 Hz like pcurve; JSON record in the bench data directory.
dpatch [F] [pitch] [length] e.g. dpatch 1500 0.3 30
expstop Armed kill on the EXPECTED-stop path: start a mark job,
then mid-burn POST /controller/stop (the supervisor stops
the controller: SIGTERM, reap, exit safing). PASS: emission
drops to 0 within a few samples of the stop and stays 0,
the kernel is not running, and POST /controller/start
is a SEPARATE step (`ctrlstart`, run after the operator has
judged the stop). Needs the panel token: GF_TOKEN, or
/data/forgefirm/panel.token when running on the board.
ctrlstart POST /controller/start after an expstop; no motion, no laser.
The G-4 arm-refuses-when-a-fire-gate-is-active drill is operator-manual
(kill the pump during the button wait); this harness prints the cue.
"""
import json
import os
import socket
import sys
import time
import urllib.request
HOST = os.environ.get('GF_HOST') or '127.0.0.1'
PORT = 23
BASE = 'http://%s:8080' % HOST
def panel_token():
tok = os.environ.get('GF_TOKEN', '')
if tok:
return tok
try:
with open('/data/forgefirm/panel.token') as f:
return f.read().strip()
except OSError:
return ''
# Ambient lid-IR baseline (2026-08-14, lid closed, idle): per-channel means.
IR_BASELINE = [37.3, 36.3, 39.5, 40.0]
def get_json(path):
with urllib.request.urlopen(BASE + path, timeout=4) as r:
return json.load(r)
class Grbl:
def __init__(self, host, port):
self.s = socket.create_connection((host, port), timeout=5)
self.s.settimeout(0.2)
self.buf = b''
time.sleep(0.5)
self.drain()
def drain(self):
try:
while True:
d = self.s.recv(4096)
if not d:
break
self.buf += d
except socket.timeout:
pass
out, self.buf = self.buf, b''
return out.decode('ascii', 'replace')
def cmd(self, line, timeout=5.0):
self.s.sendall(line.encode() + b'\n')
deadline = time.time() + timeout
text = ''
while time.time() < deadline:
text += self.drain()
if 'ok' in text or 'error' in text or 'ALARM' in text:
return text.strip()
time.sleep(0.02)
return '(timeout) ' + text.strip()
def status(self):
self.s.sendall(b'?')
t = time.time() + 1.0
text = ''
while time.time() < t:
text += self.drain()
if '>' in text:
break
time.sleep(0.02)
if '<' in text and '>' in text:
return text[text.rfind('<'):text.rfind('>') + 1]
return ''
def state(self):
st = self.status()
return st[1:].split('|')[0] if st else ''
def rt(self, ch):
self.s.sendall(ch)
def wait_state(self, want, timeout=60.0, poll=0.1):
deadline = time.time() + timeout
while time.time() < deadline:
s = self.state()
if s.startswith(want):
return s
time.sleep(poll)
return self.state()
def sample_forgectrl():
"""One combined /status + /cool/status sample, or None on error."""
try:
st = get_json('/status')
cs = get_json('/cool/status')
except Exception:
return None
return {
't': time.time(),
'kstate': st.get('state'),
'emission': st.get('laser', {}).get('emission_samples'),
'pgood': st.get('laser', {}).get('pgood_samples'),
'faults': st.get('faults'),
'hv': st.get('hv_current_raw'),
'ir': st.get('lid_ir'),
'armed': cs.get('armed'),
'fire_watch': cs.get('fire_watch'),
'verdict': cs.get('verdict'),
'phase': cs.get('phase'),
'reason': cs.get('reason'),
}
def arm_cue():
print('\n>>> OPERATOR: eye protection on, exhaust running, fire watch,')
print('>>> extinguisher in reach, scrap under the head with room to move.')
print('>>> The job is starting. The white button will light and the')
print('>>> stream will BLOCK until you press the physical arm button.')
print('>>> The machine fires only after your press.\n')
def run_and_sample(g, gcode_lines, sample_hz=8, overall_timeout=200):
"""Stream gcode; sample forgectrl through the whole arm -> fire ->
disarm lifecycle. The arm phase (fan run + flow interrogation) plus
the operator button wait present grblHAL as Idle, so completion must
NOT trigger on an early Idle. Complete on one of:
- real fire captured: emission was seen > 0, then grbl Idle > 3 s;
- no-fire disarm: armed went True then False, grbl Idle, > 15 s in;
- overall timeout.
"""
samples = []
period = 1.0 / sample_hz
s0 = sample_forgectrl()
if s0:
samples.append(s0)
for ln in gcode_lines:
g.s.sendall(ln.encode() + b'\n')
t_start = time.time()
next_t = t_start
seen_emission = False
seen_armed = False
disarmed_now = False
idle_since = None
while time.time() - t_start < overall_timeout:
now = time.time()
if now >= next_t:
smp = sample_forgectrl()
if smp:
samples.append(smp)
if smp['emission'] and smp['emission'] > 0:
seen_emission = True
if smp['armed']:
seen_armed = True
disarmed_now = seen_armed and not smp['armed']
next_t = now + period
st = g.state()
if st.startswith('Idle'):
if idle_since is None:
idle_since = now
idle_for = now - idle_since
if seen_emission and idle_for > 3.0:
break # captured the burn
if disarmed_now and (now - t_start) > 15 and idle_for > 3.0:
break # armed then disarmed, no fire
else:
idle_since = None
time.sleep(0.05)
return samples
def prepare(g):
"""Guarantee a clean Idle start: clear a latched Door hold (lid was
opened to inspect) or an Alarm before the run."""
st = g.status()
if 'Door' in st or 'Hold' in st:
g.rt(b'\x18') # soft reset clears the hold
time.sleep(2)
g.drain()
st = g.status()
if 'Alarm' in st:
print('unlock: %s' % g.cmd('$X'))
st = g.status()
return st
def drill_witness(g):
print('=== Phase 5 witness drill: S400 vector mark ===')
print('connect: %s' % prepare(g))
base = sample_forgectrl()
print('pre-fire: %s' % base)
arm_cue()
# A small square outline at S400, motion-only feed so the fire window
# is unambiguous. Absolute-relative: use G91 so no homing is needed.
job = [
'G91', 'G21', # relative, mm
'M4', # dynamic laser mode, spindle enable
'S400',
'G1 X20 F600',
'G1 Y20 F600',
'G1 X-20 F600',
'G1 Y-20 F600',
'M5', # laser off
'G90',
'M2', # program end: X-3 job-based disarm trigger
]
samples = run_and_sample(g, job)
# Analysis.
emis = [s['emission'] for s in samples if s['emission'] is not None]
peak_emis = max(emis) if emis else 0
end_emis = emis[-1] if emis else None
ir_peak = [0, 0, 0, 0]
hv_vals = []
for s in samples:
if s['ir'] and len(s['ir']) == 4:
for i in range(4):
ir_peak[i] = max(ir_peak[i], s['ir'][i])
if s['hv'] is not None:
hv_vals.append(s['hv'])
armed_seen = any(s['armed'] for s in samples)
pgood_vals = [s['pgood'] for s in samples if s['pgood'] is not None]
print('\n--- results ---')
print('emission_samples: peak=%s end=%s (PASS if peak>0 and end==0)'
% (peak_emis, end_emis))
print('pgood_samples during job: peak=%s (>=128 = power-good)'
% (max(pgood_vals) if pgood_vals else '-'))
print('lid_ir peak=%s vs baseline=%s delta=%s'
% (ir_peak, IR_BASELINE,
[round(ir_peak[i] - IR_BASELINE[i], 1) for i in range(4)]))
print('hv_current range: %s..%s' % (min(hv_vals) if hv_vals else '-',
max(hv_vals) if hv_vals else '-'))
print('armed observed during job: %s' % armed_seen)
# X-3: measure time-to-disarm after the job completes at Idle. The
# job-based window (Phase 4) should disarm promptly at Idle entry,
# not wait out the ~60 s laser_disarm_s grace.
t0 = time.time()
disarm_dt = None
while time.time() - t0 < 75:
s = sample_forgectrl()
if s and not s['armed']:
disarm_dt = time.time() - t0
break
time.sleep(1)
print('X-3 time-to-disarm after Idle: %s s (job-based PASS if prompt, '
'not ~60 s grace)'
% (round(disarm_dt, 1) if disarm_dt is not None else '>75 (REVIEW)'))
ok = peak_emis > 0 and end_emis == 0
print('WITNESS emission %s' % ('PASS' if ok else 'REVIEW - see values above'))
# Emit the recommended cool_fire_ir_delta floor.
worst = max(ir_peak[i] - IR_BASELINE[i] for i in range(4))
print('suggest cool_fire_ir_delta >= max(15, %.0f) once several jobs '
'confirm the peak delta' % (2 * worst if worst > 0 else 15))
return samples
def drill_hold(g):
print('=== Phase 4 G-10 drill: disarm grace counts down in Hold ===')
print('connect: %s' % prepare(g))
arm_cue()
# +X move at F300 (~5 mm/s). Held after ~2 s of motion (~10 mm),
# so ~30 mm of +X clearance is plenty.
job = ['G91', 'G21', 'M4', 'S400', 'G1 X40 F300']
for ln in job:
g.s.sendall(ln.encode() + b'\n')
print('armed; waiting for motion to start (arm + your button press)...')
st = g.wait_state('Run', 180) # arming + button wait, then motion
if not st.startswith('Run'):
print('FAIL: motion never started (state=%s) - arm refused or no press'
% st)
g.cmd('M5', timeout=1)
g.rt(b'\x18')
return
print('moving under laser: %s; feed-hold in 2 s' % st)
time.sleep(2)
g.rt(b'!') # feed hold mid-move
st = g.wait_state('Hold', 5)
print('feed-held mid-move: %s' % st)
print('watching the disarm grace count down IN HOLD (not resuming)...')
t0 = time.time()
disarmed_at = None
while time.time() - t0 < 120:
s = sample_forgectrl()
held = g.state().startswith('Hold')
if s and not s['armed']:
disarmed_at = time.time() - t0
break
if not held:
print('note: left Hold (state=%s) before disarm' % g.state())
time.sleep(1)
# Recover: laser off, abort out of hold.
g.cmd('M5', timeout=1)
g.rt(b'\x18') # soft reset / abort out of hold
time.sleep(1)
print('G-10 disarmed in Hold after %s s (PASS if it disarms while held; '
'the bug left it armed for hours)'
% (round(disarmed_at, 1) if disarmed_at else 'NOT WITHIN 120 - REVIEW'))
if 'Alarm' in g.status():
g.cmd('$X')
def drill_faultpos(g):
print('=== Phase 6 G-2/G-3 drill: stale origin refused after underrun ===')
s = get_json('/status')
print('homed=%s (an underrun should have cleared this)' % s.get('homed'))
if s.get('homed'):
print('NOTE: homed is still true - run the SIGSTOP/underrun drill '
'first, then re-run this to confirm the refusal.')
return
print('attempting an armed cut at the stale origin - it must refuse/alarm')
prepare(g)
arm_cue()
r = g.cmd('M4 S400', timeout=2)
r2 = g.cmd('G1 X10 F300', timeout=3)
st = g.state()
print('controller response: %s / %s state=%s' % (r, r2, st))
print('FAULTPOS %s' % ('PASS (refused/alarm at stale origin)'
if ('error' in (r + r2).lower() or 'Alarm' in st) else
'REVIEW - cut was accepted; check G-3 anchor invalidation'))
g.cmd('M5', timeout=1)
def drill_ircut(g):
power = int(sys.argv[2]) if len(sys.argv) > 2 else 1000
feed = int(sys.argv[3]) if len(sys.argv) > 3 else 300
print('=== lid-IR characterization: S%d F%d 30 mm square ===' % (power, feed))
print('connect: %s' % prepare(g))
base = sample_forgectrl()
print('pre-fire: %s' % base)
arm_cue()
job = [
'G91', 'G21', 'M4', 'S%d' % power,
'G1 X30 F%d' % feed, 'G1 Y30 F%d' % feed,
'G1 X-30 F%d' % feed, 'G1 Y-30 F%d' % feed,
'M5', 'G90', 'M2',
]
samples = run_and_sample(g, job, overall_timeout=400)
ir_peak = [0, 0, 0, 0]
ir_min = [10 ** 6] * 4
hv_vals = []
emis = []
fw = set()
for s in samples:
if s['ir'] and len(s['ir']) == 4:
for i in range(4):
ir_peak[i] = max(ir_peak[i], s['ir'][i])
ir_min[i] = min(ir_min[i], s['ir'][i])
if s['hv'] is not None:
hv_vals.append(s['hv'])
if s['emission'] is not None:
emis.append(s['emission'])
if s['fire_watch']:
fw.add(s['fire_watch'])
print('\n--- results ---')
print('samples: %d emission peak=%s fire_watch states=%s'
% (len(samples), max(emis) if emis else '-', sorted(fw)))
delta = [round(ir_peak[i] - IR_BASELINE[i], 1) for i in range(4)]
print('lid_ir min=%s peak=%s baseline=%s peak delta=%s'
% (ir_min, ir_peak, IR_BASELINE, delta))
print('hv_current range: %s..%s' % (min(hv_vals) if hv_vals else '-',
max(hv_vals) if hv_vals else '-'))
worst = max(delta)
print('worst peak delta this job: %s counts -> cool_fire_ir_delta must sit '
'above the worst across ALL jobs (>= 2x it, never < 15)' % worst)
print('the engine logged its own "run telemetry: lid IR ..." line for this job')
return samples
# Power ladder for `pthresh`, in percent of full duty. The spacing is fine
# at the bottom because that is where the tube stops striking.
PTHRESH_PCT = (2, 3, 4, 5, 6, 8, 10, 12, 14, 16, 20, 25, 30)
PTHRESH_LEN = 25.0 # mm of burn per rung
PTHRESH_PITCH = 3.0 # mm between rungs
def drill_pthresh(g):
smax = int(sys.argv[2]) if len(sys.argv) > 2 else 1000
feed = int(sys.argv[3]) if len(sys.argv) > 3 else 300
levels = [(p, max(1, int(round(smax * p / 100.0)))) for p in PTHRESH_PCT]
print('=== laser power threshold ladder: %d rungs, F%d, %g mm each ==='
% (len(levels), feed, PTHRESH_LEN))
print('constant power (M3): the commanded duty is the tested duty.')
print('PRECONDITION: $35 must be 0 for this run. A floor already in')
print('place lifts every rung and the threshold cannot be read.')
print('rungs (drawn in order, alternating direction, +Y between):')
for i, (pct, s) in enumerate(levels):
print(' %2d: %2d%% -> S%d' % (i + 1, pct, s))
print('connect: %s' % prepare(g))
base = sample_forgectrl()
print('pre-fire: %s' % base)
arm_cue()
print('>>> This ladder reaches %d%% of full power - use scrap you are'
% PTHRESH_PCT[-1])
print('>>> willing to cut through.\n')
job = ['G91', 'G21', 'M3']
for i, (_pct, s) in enumerate(levels):
job.append('S%d' % s)
job.append('G1 X%g F%d' % (PTHRESH_LEN if i % 2 == 0 else -PTHRESH_LEN,
feed))
job.append('G0 Y%g' % PTHRESH_PITCH)
job += ['M5', 'G90', 'M2']
samples = run_and_sample(g, job, overall_timeout=600)
hv_vals = [s['hv'] for s in samples if s['hv'] is not None]
emis = [s['emission'] for s in samples if s['emission'] is not None]
print('\n--- results ---')
print('samples: %d emission peak=%s' % (len(samples),
max(emis) if emis else '-'))
print('hv_current range: %s..%s' % (min(hv_vals) if hv_vals else '-',
max(hv_vals) if hv_vals else '-'))
if samples:
t0 = samples[0]['t']
print('hv_current trace (t s : raw) - the discharge current is the')
print('electrical witness of striking, and it lifts off baseline')
print('well below the rung that marks. Read it for the rung')
print('boundaries: the laser-off G0 between rungs reads 0, so the')
print('runs of nonzero current count the rungs that struck at all.')
line = []
for s in samples:
if s['hv'] is None:
continue
line.append('%5.1f:%s' % (s['t'] - t0, s['hv']))
if len(line) == 8:
print(' ' + ' '.join(line))
line = []
if line:
print(' ' + ' '.join(line))
print('\nRead the material: count rungs from the FIRST one drawn. The')
print('lowest rung carrying a CONTINUOUS mark is the lasing threshold;')
print('set $35 to that rung\'s percent. A rung showing only a spot at')
print('the start of its line struck but did not sustain - it is below')
print('the threshold, not at it. Note the emission counter proves the')
print('safety chain asserted LASER_ON, not that the tube lased - only')
print('the mark and the discharge current say that.')
return samples
# --- density ladder -------------------------------------------------------
# Dose levels in percent of full, weighted to the bottom: with a density
# floor set, what matters is whether the lowest levels a user can dial in
# still mark, not how the top half behaves.
DLADDER_PCT = (1, 2, 5, 10, 20, 40, 70, 100)
DLADDER_LEN = 25.0 # mm of burn per rung
DLADDER_PITCH = 3.0 # mm between rungs
STREAM_RATE_HZ = 28160 # machine tick (GFSINK_RATE default)
PWM_PERIOD = 127 # 7-bit power byte against PWMSAR
CONF = os.environ.get('GFHOME_CONF') or '/data/forgefirm.conf'
PULSE_MIN_KEY = 'laser_pulse_min_ticks'
PULSE_MIN_DEFAULT = 3 # glowforge_laser.c PULSE_MIN_TICKS_DEFAULT
def conf_get(key):
"""One key from the shared machine config, or None."""
try:
with open(CONF) as f:
for line in f:
head = line.split('#', 1)[0]
if '=' in head:
k, v = head.split('=', 1)
if k.strip() == key:
return v.strip()
except OSError:
return None
return None
def conf_set(key, val):
"""Set one key, preserving every other line. False when the config is
not ours to write - running from a LAN host, say."""
try:
with open(CONF) as f:
lines = f.read().splitlines()
except OSError:
return False
out, done = [], False
for line in lines:
head = line.split('#', 1)[0]
if '=' in head and head.split('=', 1)[0].strip() == key:
if done:
continue
out.append('%s = %s' % (key, val))
done = True
else:
out.append(line)
if not done:
out.append('%s = %s' % (key, val))
try:
mode = os.stat(CONF).st_mode & 0o777
with open(CONF + '.tmp', 'w') as f:
f.write('\n'.join(out) + '\n')
os.chmod(CONF + '.tmp', mode)
os.replace(CONF + '.tmp', CONF)
except OSError:
return False
return True
def grbl_setting(g, key):
"""One $-setting as a float, read from the controller."""
for line in g.cmd('$$', timeout=8.0).splitlines():
line = line.strip()
if line.startswith(key + '='):
try:
return float(line.split('=', 1)[1])
except ValueError:
return None
return None
def drill_dladder(g):
period = int(sys.argv[2]) if len(sys.argv) > 2 else 20
feed = int(sys.argv[3]) if len(sys.argv) > 3 else 300
if period < 1:
print('period must be at least 1 machine tick')
return 2
print('=== density ladder: %d rungs, base period %d ticks, F%d, %g mm each ==='
% (len(DLADDER_PCT), period, feed, DLADDER_LEN))
# Preconditions. The model is read at each arm, so it must already be
# selected; the floor must be gone, or every rung is lifted off the
# bottom of the range this drill exists to explore.
# Density is the shipped default, so an absent key selects it; only an
# explicit analog selection is a refusal.
model = conf_get('laser_power_model') or 'density'
if model != 'density':
print('PRECONDITION FAILED: laser_power_model is %r, need "density"'
% (model,))
print('(or the key absent, which selects it). The model is read at')
print('each arm, so this key needs no controller restart.')
return 2
print('dose model: density (%s)'
% ('set in ' + CONF if conf_get('laser_power_model') else 'driver default'))
# The core maps S onto the level this model renders as density, and
# $35/$36 are its floor and ceiling. Read them rather than assuming:
# with a floor set, the ladder is testing the shipping mapping, and
# every rung sits higher than its commanded percent.
floor = grbl_setting(g, '$35')
ceil = grbl_setting(g, '$36')
rpm_max = grbl_setting(g, '$30')
rpm_min = grbl_setting(g, '$31')
if None in (floor, ceil, rpm_max, rpm_min) or rpm_max <= rpm_min:
print('PRECONDITION FAILED: cannot read $30/$31/$35/$36 (%s/%s/%s/%s)'
% (rpm_max, rpm_min, floor, ceil))
return 2
min_value = int(PWM_PERIOD * floor / 100.0)
max_value = int(PWM_PERIOD * ceil / 100.0)
gradient = (max_value - min_value) / (rpm_max - rpm_min)
print('mapping: $30=%g $31=%g $35=%g $36=%g -> density %.1f%%..%.1f%%'
% (rpm_max, rpm_min, floor, ceil,
100.0 * min_value / PWM_PERIOD, 100.0 * max_value / PWM_PERIOD))
if floor > 0.0:
print('a floor is set, so the rungs below it all land on it - that is')
print('the shipping mapping, not the raw range.')
if conf_set('laser_pulse_ticks', str(period)):
print('laser_pulse_ticks = %d (written to %s)' % (period, CONF))
else:
have = conf_get('laser_pulse_ticks')
if have != str(period):
print('PRECONDITION FAILED: cannot write %s from here, and' % CONF)
print('laser_pulse_ticks is %r, not %d. Set it on the board.'
% (have, period))
return 2
print('laser_pulse_ticks already %d' % period)
# What each rung actually emits. The on-count is dithered between
# adjacent integers, so the burst below is the mean.
min_ticks = int(conf_get(PULSE_MIN_KEY) or PULSE_MIN_DEFAULT)
if min_ticks < 1:
min_ticks = 1
print('period %d ticks = %.0f us at %d Hz -> %.0f Hz pulse rate'
% (period, period * 1e6 / STREAM_RATE_HZ, STREAM_RATE_HZ,
STREAM_RATE_HZ / float(period)))
print('minimum pulse %d ticks = %.0f us (%s): below it the model skips'
% (min_ticks, min_ticks * 1e6 / STREAM_RATE_HZ,
PULSE_MIN_KEY if conf_get(PULSE_MIN_KEY) else 'driver default'))
print('periods and carries the debt, so the pulse never falls under it.')
print('rungs (drawn in order, alternating direction, +Y between):')
levels = []
for pct in DLADDER_PCT:
sval = int(round(rpm_max * pct / 100.0))
level = int((sval - rpm_min) * gradient) + min_value
level = min(level, max_value)
dens = level / float(PWM_PERIOD)
on = dens * period
levels.append((pct, sval, dens))
# The on-count is a whole number of ticks, dithered between the
# two nearest, so quote the pulse the tube actually sees. Below
# one tick per period the pulse stays one tick and periods are
# skipped instead - that is the short end this drill is for.
lo = int(on)
tick_us = 1e6 / STREAM_RATE_HZ
if on < min_ticks:
# Below the minimum the model skips periods and carries the
# debt, so the pulse holds at the minimum and the rate drops.
burst = '%d ticks (%.0f us) every %.1f periods (%.0f Hz)' % (
min_ticks, min_ticks * tick_us, min_ticks / on,
STREAM_RATE_HZ / float(period) * on / min_ticks)
elif on == lo:
burst = '%d ticks (%.0f us)' % (lo, lo * tick_us)
else:
burst = '%d-%d ticks (%.0f-%.0f us)' % (lo, lo + 1, lo * tick_us,
(lo + 1) * tick_us)
print(' %3d%% -> S%-4d density %.4f %5.2f on-ticks pulse %s'
% (pct, sval, dens, on, burst))
print('connect: %s' % prepare(g))
base = sample_forgectrl()
print('pre-fire: %s' % base)
arm_cue()
print('>>> This ladder reaches FULL dose - use scrap you are willing')
print('>>> to cut through.\n')
job = ['G91', 'G21', 'M3']
for i, (_pct, sval, _d) in enumerate(levels):
job.append('S%d' % sval)
job.append('G1 X%g F%d' % (DLADDER_LEN if i % 2 == 0 else -DLADDER_LEN,
feed))
job.append('G0 Y%g' % DLADDER_PITCH)
job += ['M5', 'G90', 'M2']
samples = run_and_sample(g, job, overall_timeout=600)
emis = [s['emission'] for s in samples if s['emission'] is not None]
hv_vals = [s['hv'] for s in samples if s['hv'] is not None]
print('\n--- results ---')
print('samples: %d emission peak=%s' % (len(samples),
max(emis) if emis else '-'))
print('hv_current range: %s..%s' % (min(hv_vals) if hv_vals else '-',
max(hv_vals) if hv_vals else '-'))
if samples:
t0 = samples[0]['t']
print('hv_current trace (t s : raw) - the laser-off G0 between rungs')
print('reads 0, so the runs of nonzero current count the rungs that')
print('struck, and their level tracks the dose of each:')
line = []
for smp in samples:
if smp['hv'] is None:
continue
line.append('%5.1f:%s' % (smp['t'] - t0, smp['hv']))
if len(line) == 8:
print(' ' + ' '.join(line))
line = []
if line:
print(' ' + ' '.join(line))
print('Check the controller said "laser armed (density)" - a plain')
print('"laser armed" means the analog path ran and this is a duty')
print('ladder, not a density one.')
print('\nRead the material: count rungs from the FIRST one drawn.')
print('Two readings, and the second is the one only the bench can give:')
print(' 1. LINEARITY - does depth/darkness track the density column')
print(' above, or does the low end mark harder than its share? Every')
print(' burst restarts the discharge, so each carries the strike')
print(' transient, and dose per burst need not scale with its length.')
print(' 2. THE SHORT END - the lowest rung that still marks cleanly. Its')
print(' burst length in us is the number to keep; a rung that stops')
print(' marking sets the floor this base period can reach.')
print('The base period does not decide the low end: below the minimum')
print('the pulse interval is min_ticks x tick / density, which the')
print('period cancels out of. What sets the bottom is the density')
print('floor ($35), so a rung that fails is telling you to raise it.')
return samples
# --- performance curve ladder ---------------------------------------------
# One line per level at constant feed, two instruments read through each:
# the HV current (the supply's curve under analog; presence only under
# density, where every pulse is full current) and the head thermopile, a
# scatter detector in the beam path upstream of the final mirror, so it
# reads the beam and not the material. Rungs are percents of full. The
# analog list is dense at the knee where the tube starts to lase; the
# density list is weighted to the bottom, where the per-pulse strike
# deficit lives.
PCURVE_ANALOG_PCT = (16, 18, 20, 23, 26, 30, 35, 42, 50, 60, 72, 85, 100)
PCURVE_DENSITY_PCT = (1, 2, 3, 5, 7, 10, 15, 20, 30, 45, 60, 80, 100)
PCURVE_FEED = 600 # mm/min: 10 mm/s
PCURVE_LEN = 100.0 # mm of burn per rung
PCURVE_PITCH = 3.0 # mm between rungs (+X)
PCURVE_GAP_S = 4.0 # laser-off settle before each rung
PCURVE_GAP_SKIP_S = 1.5 # of which the first part still decays
PCURVE_SAMPLE_HZ = 25 # sysfs sampler target rate
PCURVE_TRIM_HEAD_S = 1.0 # dropped from the start of each line
PCURVE_TRIM_TAIL_S = 0.5 # dropped from its end
SYSFS = '/sys/glowforge'
HV_FULL_SCALE = 1023 # the PIC ADC's top count
# (key, sysfs attribute) per sampled channel.
PCURVE_CHANNELS = (
('hv', 'pic/hv_current'),
('tp', 'head/beam_detect_analog'),
('tpd', 'head/beam_detect_digital'),
('lon', 'cnc/laser_on_sampled'),
('wt1', 'pic/water_temp_1'),
('wt2', 'pic/water_temp_2'),
('pt', 'pic/pwr_temp'),
)
class Sampler:
"""Reads the pcurve channels in a thread. On the board they come
straight from sysfs, each read a live bus transaction, so the achieved
rate is whatever the PIC (SPI) and head (I2C) buses allow and it is
reported rather than assumed. From a host the only source is
forgectrl's /status at ~8 Hz, which carries the current and nothing
the thermopile needs."""
def __init__(self, hz):
import threading
self.local = os.path.isdir(SYSFS)
self.period = 1.0 / (hz if self.local else 8)
self.samples = []
self.errors = 0
self._stop = threading.Event()
self._thr = threading.Thread(target=self._run, daemon=True)
def start(self):
self._thr.start()
def stop(self):
self._stop.set()
self._thr.join(timeout=3)
def _read_sysfs(self):
smp = {'t': time.time()}
for key, attr in PCURVE_CHANNELS:
try:
with open(os.path.join(SYSFS, attr)) as f:
smp[key] = int(f.read().strip())
except (OSError, ValueError):
smp[key] = None
self.errors += 1
return smp
def _read_status(self):
st = sample_forgectrl()
smp = dict((key, None) for key, _attr in PCURVE_CHANNELS)
smp['t'] = time.time()
if st is None:
self.errors += 1
return smp
smp['hv'] = st['hv']
smp['lon'] = st['emission']
return smp
def _run(self):
read = self._read_sysfs if self.local else self._read_status
next_t = time.time()
while not self._stop.is_set():
self.samples.append(read())
next_t += self.period
delay = next_t - time.time()
if delay > 0:
time.sleep(delay)
else:
next_t = time.time()
def rate(self):
if len(self.samples) < 2:
return 0.0
span = self.samples[-1]['t'] - self.samples[0]['t']
return (len(self.samples) - 1) / span if span > 0 else 0.0
def _stats(vals):
n = len(vals)
if not n:
return {'n': 0, 'mean': None, 'sd': None, 'min': None, 'max': None}
mean = sum(vals) / float(n)
var = sum((v - mean) ** 2 for v in vals) / float(n)
return {'n': n, 'mean': mean, 'sd': var ** 0.5, 'min': min(vals),
'max': max(vals)}
def _window(samples, t0, t1, key):
return [s[key] for s in samples
if t0 <= s['t'] < t1 and s.get(key) is not None]
def _linfit(xs, ys):
"""Least squares y = a + b x; (a, b, r2), or None below two points."""
n = len(xs)
if n < 2:
return None
mx = sum(xs) / float(n)
my = sum(ys) / float(n)
sxx = sum((x - mx) ** 2 for x in xs)
if sxx == 0:
return None
b = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sxx
a = my - b * mx
ss_res = sum((y - (a + b * x)) ** 2 for x, y in zip(xs, ys))
ss_tot = sum((y - my) ** 2 for y in ys)
r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 1.0
return a, b, r2
def _degc(raw):
"""The factory coolant conversion when gfbench is importable (on the
board, or with GF_HOST set), else None and the raw count is quoted.
The helper lives beside this file in the repo and under the bench
directory on the dev image; a copy staged elsewhere still finds it."""
for d in (os.path.dirname(os.path.abspath(__file__)),
'/usr/share/forgetest/bench'):
if d not in sys.path:
sys.path.append(d)
try:
from gfbench import degc
except (ImportError, SystemExit):
return None
return degc(raw)
def pcurve_levels(g, pcts):
"""(pct, S, level) per rung: the level is what the core maps S onto
with the settings in force, as a fraction of full (duty/127 under
analog, density under density). None when a setting cannot be read."""
floor = grbl_setting(g, '$35')
ceil = grbl_setting(g, '$36')
rpm_max = grbl_setting(g, '$30')
rpm_min = grbl_setting(g, '$31')
if None in (floor, ceil, rpm_max, rpm_min) or rpm_max <= rpm_min:
return None, (rpm_max, rpm_min, floor, ceil)
min_value = int(PWM_PERIOD * floor / 100.0)
max_value = int(PWM_PERIOD * ceil / 100.0)
gradient = (max_value - min_value) / (rpm_max - rpm_min)
levels = []
for pct in pcts:
sval = int(round(rpm_max * pct / 100.0))
if sval <= rpm_min:
level = 0
else:
level = min(int((sval - rpm_min) * gradient) + min_value, max_value)
levels.append((pct, sval, level / float(PWM_PERIOD)))
return levels, (rpm_max, rpm_min, floor, ceil)
def pcurve_analyze(samples, rungs, head_trim=PCURVE_TRIM_HEAD_S,
tail_trim=PCURVE_TRIM_TAIL_S):
"""Per-rung statistics over the trimmed steady window of each line,
then the curve: normalized thermopile delta against level, a
monotonicity count, straight-line fits with their x-intercepts, and
the repeat-rung comparison. Pure: takes the raw trace and the rung
brackets, returns a dict, so it can be checked without a machine."""
rows = []
for r in rungs:
t0, t1 = r['t_run0'] + head_trim, r['t_run1'] - tail_trim
if t1 - t0 < 1.0:
t0, t1 = r['t_run0'], r['t_run1']
hv = _stats(_window(samples, t0, t1, 'hv'))
tp = _stats(_window(samples, t0, t1, 'tp'))
# The thermopile falls back to baseline within about a second of a
# line ending (measured 2026-08-25), so the first part of the gap
# still carries the previous rung's tail; the baseline is the rest.
base = _stats(_window(samples, r['t_gap0'] + PCURVE_GAP_SKIP_S,
r['t_m3'], 'tp'))
first = _stats(_window(samples, t0, min(t0 + 2.0, t1), 'tp'))
last = _stats(_window(samples, max(t1 - 2.0, t0), t1, 'tp'))
tpd = _window(samples, t0, t1, 'tpd')
# laser_on_sampled is a once-per-second window count, so the
# last window of a line lands after Idle: look one second past.
lon = _window(samples, r['t_run0'], r['t_run1'] + 1.0, 'lon')
# Coolant from the UPSTREAM sensor: water_temp_1 sits downstream of
# the flow-check heater and swings with it during a run.
wt1 = _stats(_window(samples, r['t_gap0'], r['t_m3'], 'wt2'))
pt = _stats(_window(samples, r['t_gap0'], r['t_m3'], 'pt'))
delta = (tp['mean'] - base['mean']
if tp['mean'] is not None and base['mean'] is not None
else None)
drift = (last['mean'] - first['mean']
if last['mean'] is not None and first['mean'] is not None
else None)
rows.append({
'rung': r['rung'], 'repeat': r.get('repeat', False),
'pct': r['pct'], 's': r['s'], 'level': r['level'],
'seconds': round(r['t_run1'] - r['t_run0'], 2),
'hv_n': hv['n'], 'hv_mean': hv['mean'], 'hv_sd': hv['sd'],
'hv_max': hv['max'],
'hv_clipped': hv['max'] is not None and hv['max'] >= HV_FULL_SCALE,
'tp_n': tp['n'], 'tp_mean': tp['mean'], 'tp_sd': tp['sd'],
'tp_base': base['mean'], 'tp_base_sd': base['sd'],
'tp_delta': delta, 'tp_drift': drift,
'tpd_duty': (sum(1 for v in tpd if v) / float(len(tpd))
if tpd else None),
'lon_max': max(lon) if lon else None,
'fired': bool(lon) and max(lon) > 0,
'coolant_raw': wt1['mean'],
'coolant_c': _degc(wt1['mean']) if wt1['mean'] is not None else None,
'supply_raw': pt['mean'],
})
primary = [row for row in rows if not row['repeat']]
primary.sort(key=lambda row: row['level'])
fired = [row for row in primary if row['fired']]
curve = {'rows': len(rows), 'fired': len(fired)}
# Normalize the thermopile delta to the top of the ladder.
deltas = [row['tp_delta'] for row in fired if row['tp_delta'] is not None]
top = max(deltas) if deltas else None
for row in rows:
row['tp_norm'] = (row['tp_delta'] / top
if top and row['tp_delta'] is not None else None)
# Monotonicity: decreases of the delta with rising level, beyond noise.
dec = 0
prev = None
for row in fired:
if row['tp_delta'] is None:
continue
if prev is not None and row['tp_delta'] < prev['tp_delta'] - 2.0 * (row['tp_sd'] or 0):
dec += 1
prev = row
curve['tp_decreases'] = dec
dec = 0
prev = None
for row in fired:
if row['hv_mean'] is None or row['hv_clipped']:
continue
if prev is not None and row['hv_mean'] < prev['hv_mean'] - 2.0 * (row['hv_sd'] or 0):
dec += 1
prev = row
curve['hv_decreases'] = dec
# Signal rungs: delta clear of the baseline noise, for the fits.
sig = [row for row in fired if row['tp_delta'] is not None
and row['tp_delta'] > 3.0 * (row['tp_base_sd'] or 0)]
fit = _linfit([row['level'] for row in sig], [row['tp_delta'] for row in sig])
if fit:
a, b, r2 = fit
curve['tp_fit'] = {'points': len(sig), 'intercept': a, 'slope': b,
'r2': r2,
'x_intercept': (-a / b) if b else None}
unclipped = [row for row in fired if row['hv_mean'] is not None
and not row['hv_clipped']]
fit = _linfit([row['level'] for row in unclipped],
[row['hv_mean'] for row in unclipped])
if fit:
a, b, r2 = fit
curve['hv_fit'] = {'points': len(unclipped), 'intercept': a,
'slope': b, 'r2': r2,
'x_intercept': (-a / b) if b else None}
curve['hv_clipped_rungs'] = [row['rung'] for row in rows if row['hv_clipped']]
reps = [row for row in rows if row['repeat']]
firsts = [row for row in rows if not row['repeat'] and reps
and row['rung'] == reps[-1]['rung']]
if reps and firsts:
first, again = firsts[0], reps[-1]
rep = {'rung': first['rung']}
if first['tp_delta'] is not None and again['tp_delta'] is not None:
rep['tp_delta_first'] = first['tp_delta']
rep['tp_delta_again'] = again['tp_delta']
rep['tp_delta_change'] = again['tp_delta'] - first['tp_delta']
rep['tp_delta_change_pct'] = (100.0 * rep['tp_delta_change'] / first['tp_delta']
if first['tp_delta'] else None)
if first['hv_mean'] is not None and again['hv_mean'] is not None:
rep['hv_change'] = again['hv_mean'] - first['hv_mean']
bases = [row['tp_base'] for row in rows if row['tp_base'] is not None]
if len(bases) >= 2:
rep['baseline_walk'] = bases[-1] - bases[0]
curve['repeat'] = rep
return {'rungs': rows, 'curve': curve}
def _fmt(v, prec=1):
if v is None:
return '-'
if isinstance(v, float):
return '%.*f' % (prec, v)
return str(v)
def pcurve_report(res, model):
rows, curve = res['rungs'], res['curve']
print('\n--- per rung (steady window, first %gs and last %gs of each line dropped) ---'
% (PCURVE_TRIM_HEAD_S, PCURVE_TRIM_TAIL_S))
unit = 'density' if model == 'density' else 'duty'
print(' rung %% S %-8s hv mean sd max | tp delta sd base drift norm | dig lon cool'
% unit)
for row in rows:
tag = '%2d%s' % (row['rung'], 'r' if row['repeat'] else ' ')
print(' %s %3d %4d %6.2f%% %7s %5s %5s%s | %8s %5s %7s %6s %5s | %4s %4s %s'
% (tag, row['pct'], row['s'], 100.0 * row['level'],
_fmt(row['hv_mean']), _fmt(row['hv_sd']), _fmt(row['hv_max'], 0),
'!' if row['hv_clipped'] else ' ',
_fmt(row['tp_delta']), _fmt(row['tp_sd']), _fmt(row['tp_base']),
_fmt(row['tp_drift']), _fmt(row['tp_norm'], 3),
_fmt(row['tpd_duty'], 2), _fmt(row['lon_max'], 0),
_fmt(row['coolant_c']) if row['coolant_c'] is not None
else _fmt(row['coolant_raw'], 0) + 'raw'))
print(' (! = hv_current touched %d: the ADC is clipped there and the'
% HV_FULL_SCALE)
print(' current column is no longer a measurement on that rung)')
print('\n--- curve ---')
print('rungs fired (laser_on_sampled > 0): %d of %d' % (curve['fired'], curve['rows']))
print('thermopile delta decreases with rising level (beyond 2 sd): %s'
% curve['tp_decreases'])
print('hv_current decreases with rising level (beyond 2 sd, unclipped): %s'
% curve['hv_decreases'])
if curve.get('hv_clipped_rungs'):
print('hv_current CLIPPED on rungs %s' % curve['hv_clipped_rungs'])
for name, key in (('thermopile', 'tp_fit'), ('hv_current', 'hv_fit')):
f = curve.get(key)
if not f:
print('%s fit: not enough signal rungs' % name)
continue
print('%s vs level: %d points, slope %.1f per 100%%, r2 %.3f, '
'x-intercept %s%% (the measured threshold if the fit holds)'
% (name, f['points'], f['slope'], f['r2'],
_fmt(100.0 * f['x_intercept']) if f['x_intercept'] is not None else '-'))
rep = curve.get('repeat')
if rep:
print('repeat of rung %d: thermopile delta %s -> %s (%s, %s%%), '
'hv %s; baseline walked %s over the ladder'
% (rep['rung'], _fmt(rep.get('tp_delta_first')),
_fmt(rep.get('tp_delta_again')), _fmt(rep.get('tp_delta_change')),
_fmt(rep.get('tp_delta_change_pct')), _fmt(rep.get('hv_change')),
_fmt(rep.get('baseline_walk'))))
def drill_pcurve(g):
feed = int(sys.argv[2]) if len(sys.argv) > 2 else PCURVE_FEED
length = float(sys.argv[3]) if len(sys.argv) > 3 else PCURVE_LEN
model = conf_get('laser_power_model') or 'density'
if len(sys.argv) > 4:
pcts = tuple(int(x) for x in sys.argv[4].split(',') if x.strip())
else:
pcts = PCURVE_DENSITY_PCT if model == 'density' else PCURVE_ANALOG_PCT
if not pcts or min(pcts) < 1 or max(pcts) > 100:
print('rungs must be percents in 1..100')
return 2
print('=== laser performance curve: %s model, %d rungs + repeat, F%d, %g mm each ==='
% (model, len(pcts), feed, length))
print('constant power (M3): the commanded level is the tested level.')
levels, (rpm_max, rpm_min, floor, ceil) = pcurve_levels(g, pcts)
if levels is None:
print('PRECONDITION FAILED: cannot read $30/$31/$35/$36 (%s/%s/%s/%s)'
% (rpm_max, rpm_min, floor, ceil))
return 2
print('mapping: $30=%g $31=%g $35=%g $36=%g' % (rpm_max, rpm_min, floor, ceil))
if floor > 0.0:
print('a floor is set, so the low rungs land on it: this run records')
print('the shipping mapping. To measure the curve itself set $35=0')
print('and restart the controller first.')
unit = 'density' if model == 'density' else 'duty'
# The drift witness is a mid-ladder rung drawn again at the end: it
# has real signal (the bottom rung sits at the threshold and reads
# nothing twice) and it is not full power, so it adds little heat.
witness = len(levels) // 2
print('rungs (each a +X line from the block\'s X0, stepping +Y %g mm; rung'
% PCURVE_PITCH)
print('%d is drawn again at the end, running -X, as the drift witness;'
% (witness + 1))
print('the next run\'s block starts %g mm further along X):' % length)
for i, (pct, sval, level) in enumerate(levels):
print(' %2d: %3d%% -> S%-4d %s %.2f%%' % (i + 1, pct, sval, unit, 100.0 * level))
sampler = Sampler(PCURVE_SAMPLE_HZ)
if sampler.local:
print('sampling sysfs on the board at a target %d Hz: %s'
% (PCURVE_SAMPLE_HZ, ' '.join(attr for _k, attr in PCURVE_CHANNELS)))
else:
print('NOT on the board: sampling forgectrl /status at ~8 Hz instead.')
print('That carries the current and the emission witness only; the')
print('thermopile is not in /status, so this run yields no curve.')
print('connect: %s' % prepare(g))
print('pre-fire: %s' % sample_forgectrl())
arm_cue()
print('>>> This ladder reaches FULL power for %.0f s per line. Use'
% (length / feed * 60.0))
print('>>> something you are willing to cut through and that will not')
print('>>> flame: scrap tile, firebrick, thick draftboard on a')
print('>>> sacrificial layer. The thermopile is in the head, so the')
print('>>> material is not part of the measurement.\n')
print('G91/G21: %s / %s' % (g.cmd('G91'), g.cmd('G21')))
order = list(range(len(levels))) + [witness]
line_s = length / feed * 60.0
sampler.start()
rungs = []
aborted = None
try:
for n, idx in enumerate(order):
pct, sval, level = levels[idx]
repeat = n == len(order) - 1
# Every line runs +X from the block's X0 at one Y and the rungs
# step +Y, so a run occupies a block `length` wide by
# PCURVE_PITCH x rungs tall and the next run's block starts
# `length` further along X. The drift witness runs the other
# way, from the far end back to X0: a swing that reverses with
# direction is head position along the gantry; one that repeats
# is time.
if repeat:
g.s.sendall(('G0 X%g\n' % length).encode())
g.wait_state('Idle', 30)
t_gap0 = time.time()
time.sleep(PCURVE_GAP_S) # laser off: the baseline
t_m3 = time.time()
job = ['M3 S%d' % sval,
'G1 X%g F%d' % (-length if repeat else length, feed),
'M5']
for ln in job:
g.s.sendall(ln.encode() + b'\n')
st = g.wait_state('Run', 240 if n == 0 else 60)
if not st.startswith('Run'):
aborted = ('rung %d never ran (state=%s): arm refused, no '
'press, or the controller alarmed' % (idx + 1, st))
break
t_run0 = time.time()
st = g.wait_state('Idle', line_s + 30.0, poll=0.05)
t_run1 = time.time()
if not st.startswith('Idle'):
aborted = 'rung %d did not finish (state=%s)' % (idx + 1, st)
break
if t_run1 - t_run0 < line_s - 1.5:
# A line that ended early was cancelled by the operator or
# the controller, and a cancel may have moved the head
# (the controller returns to machine zero). From here every
# relative move is aimed from a position this drill no
# longer knows, so send nothing more.
aborted = ('rung %d ran %.1f s of %.1f: cancelled; no further '
'moves sent' % (idx + 1, t_run1 - t_run0, line_s))
break
rungs.append({'rung': idx + 1, 'repeat': repeat, 'pct': pct,
's': sval, 'level': level, 't_gap0': t_gap0,
't_m3': t_m3, 't_run0': t_run0, 't_run1': t_run1})
print(' rung %2d%s: S%-4d ran %.1f s' % (idx + 1, 'r' if repeat else ' ',
sval, t_run1 - t_run0))
back = '' if repeat else 'G0 X%g\n' % -length
g.s.sendall((back + 'G0 Y%g\n' % PCURVE_PITCH).encode())
g.wait_state('Idle', 30)
finally:
try:
g.cmd('M5', timeout=1)
except Exception:
pass
if aborted:
g.rt(b'\x18') # abort out of whatever it is in
else:
g.s.sendall(b'G90\nM2\n') # program end closes the window
time.sleep(1.5)
sampler.stop()
if aborted:
print('ABORTED: %s' % aborted)
print('\nsampler: %d samples, %.1f Hz achieved, %d read errors'
% (len(sampler.samples), sampler.rate(), sampler.errors))
if not rungs:
return 1
res = pcurve_analyze(sampler.samples, rungs)
pcurve_report(res, model)
record = {
'drill': 'pcurve', 'date': time.strftime('%Y-%m-%dT%H:%M:%S'),
'model': model, 'feed': feed, 'length_mm': length,
'gap_s': PCURVE_GAP_S, 'pitch_mm': PCURVE_PITCH,
'settings': {'$30': rpm_max, '$31': rpm_min, '$35': floor, '$36': ceil},
'sampler': {'local': sampler.local, 'hz': sampler.rate(),
'samples': len(sampler.samples), 'errors': sampler.errors},
'aborted': aborted, 'rungs': res['rungs'], 'curve': res['curve'],
'trace': sampler.samples,
}
ddir = os.environ.get('FORGETEST_BENCH_DATA') or ('/tmp' if sampler.local else os.getcwd())
path = os.path.join(ddir, 'pcurve_%s_%s.json' % (model, time.strftime('%Y%m%d-%H%M%S')))
try:
with open(path, 'w') as f:
json.dump(record, f, indent=1)
print('record: %s' % path)
except OSError as e:
print('record not written: %s' % e)
print('\nRead it in this order. First the instrument: the thermopile')
print('delta must rise with the level on every rung that fired, settle')
print('inside the line (small drift), return to its baseline between')
print('rungs, and the repeat rung must agree with its first run. Any')
print('miss there is a fact about the sensor, not the tube. Then the')
print('curve: under analog the current column is the supply and the')
print('thermopile is the tube; under density the current is only a')
print('presence witness and the thermopile is the whole story. A knee')
print('where the delta stops rising before 100%% is the ceiling S1000')
print('should map to. The material remains the witness that it lased.')
return res
# --- the depth witness: CW patches at speed against density patches ---------
# The thermopile reads 80 % density as half the light of CW, and the
# material decides whether that is the tube or the sensor. A patch is a
# serpentine of G1 lines at one level and one feed; under constant power
# (M3) the dose per unit area is light x time, so a row of CW patches
# (S1000: the discharge continuous) at feeds F600/dose is a reference
# ladder of known relative dose, and a row of density patches at F600 is
# matched to it by engrave depth. The CW patch a density patch matches
# reads that density's light fraction straight off the stock, with the
# thermopile's prediction printed beside it. The lines alternate
# direction so no rapid crosses a fill; judge the middle of each patch,
# since under constant power the ends carry the acceleration and read
# darker at the fast feeds.
# Defaults, all overridable on the command line (dpatch [F] [pitch] [length]).
# The dose has to be an engrave, not a burn: CW at 10 mm/s with 0.2 mm lines
# turned thick draftboard to charcoal that no longer cuts (2026-08-25), so the
# reference is 25 mm/s at 0.3 mm, about a quarter of that energy density,
# and the lines are long enough that the fastest CW patch (100 mm/s) still
# has a plateau in its middle after the 7 mm acceleration at each end.
DPATCH_W = 30.0 # mm, the line length (X)
DPATCH_H = 4.0 # mm, the fill height (Y)
DPATCH_PITCH = 0.3 # mm between lines
DPATCH_GAP_X = 3.0 # mm between patches along X
DPATCH_ROW_GAP = 3.0 # mm between the two rows
DPATCH_FEED = 1500 # the reference feed, 25 mm/s
DPATCH_CW_DOSES = (1.0, 0.8, 0.6, 0.5, 0.35, 0.25) # row A: S1000 at F600/dose
DPATCH_DENSITY_PCT = (100, 80, 60, 45, 30) # row B: at F600
DPATCH_SETTLE_S = 2.0 # laser off before each patch
# The cooling engine interrogates the flow for cool_flow_check_s (50 s)
# from the moment the window arms, on a heater pulse judged by its rise;
# a CW patch under that window puts the tube's heat into the same loop
# and reads as a blocked flow (15.1 C against a 14.4 limit, 2026-08-25),
# which holds the job. The first patch therefore waits, spindle on and
# dark, until the check has finished on the heater alone. The mid-run
# re-check (cool_flow_recheck_s, 150 s by default) has to be pushed past
# the run's length for the session, or it lands on a patch the same way.
DPATCH_ARM_DWELL_S = 60.0
# The period-20 thermopile curve, light as a fraction of CW, for the labels.
DPATCH_TP_P20 = {100: 1.0, 80: 0.53, 60: 0.37, 45: 0.21, 30: 0.07}
def dpatch_gcode(feed, width, pitch, n):
"""One patch's cut moves and where they leave the head relative to
the patch origin: serpentine G1 lines, the Y steps cut at the edge."""
lines = []
x = y = 0.0
for i in range(n):
dx = width if i % 2 == 0 else -width
lines.append('G1 X%g F%d' % (dx, feed))
x += dx
if i < n - 1:
lines.append('G1 Y%g F%d' % (pitch, feed))
y += pitch
return lines, x, y
def drill_dpatch(g):
feed_ref = int(sys.argv[2]) if len(sys.argv) > 2 else DPATCH_FEED
pitch = float(sys.argv[3]) if len(sys.argv) > 3 else DPATCH_PITCH
width = float(sys.argv[4]) if len(sys.argv) > 4 else DPATCH_W
if feed_ref < 60 or not 0.05 <= pitch <= 2.0 or not 5.0 <= width <= 200.0:
print('usage: dpatch [F mm/min >= 60] [pitch 0.05..2 mm] [length 5..200 mm]')
return 2
model = conf_get('laser_power_model') or 'density'
if model != 'density':
print('this witness is for the density model (laser_power_model is %s)' % model)
return 2
levels, (rpm_max, rpm_min, floor, ceil) = pcurve_levels(g, DPATCH_DENSITY_PCT)
if levels is None:
print('PRECONDITION FAILED: cannot read $30/$31/$35/$36 (%s/%s/%s/%s)'
% (rpm_max, rpm_min, floor, ceil))
return 2
n_lines = int(round(DPATCH_H / pitch))
period = conf_get('laser_pulse_ticks') or 'driver default'
print('=== depth witness: CW patches at speed against density patches at F%d ==='
% feed_ref)
print('mapping: $30=%g $31=%g $35=%g $36=%g; density base period %s ticks'
% (rpm_max, rpm_min, floor, ceil, period))
patches = []
for dose in DPATCH_CW_DOSES:
feed = int(round(feed_ref / dose))
patches.append({'row': 'A', 'pct': 100, 's': int(rpm_max), 'feed': feed,
'dose': dose, 'tp_says': None,
'label': 'CW, dose %.2f' % dose})
for pct, sval, level in levels:
patches.append({'row': 'B', 'pct': pct, 's': sval, 'feed': feed_ref,
'dose': None, 'tp_says': DPATCH_TP_P20.get(pct),
'label': '%d%% density (level %.2f%%), thermopile says %.2f of CW'
% (pct, 100.0 * level, DPATCH_TP_P20.get(pct, float('nan')))})
n_a = len(DPATCH_CW_DOSES)
print('patches %g x %g mm, %d lines at %g mm pitch, %g mm apart along +X;'
% (width, DPATCH_H, n_lines, pitch, DPATCH_GAP_X))
print('row A starts at the head, row B %g mm above it:' % (DPATCH_H + DPATCH_ROW_GAP))
for i, p in enumerate(patches):
print(' %s%d: S%-4d F%-5d %s' % (p['row'], i + 1, p['s'], p['feed'], p['label']))
sampler = Sampler(PCURVE_SAMPLE_HZ)
print('connect: %s' % prepare(g))
print('pre-fire: %s' % sample_forgectrl())
arm_cue()
print('>>> A fresh area of the stock: %g mm along +X by %g mm along +Y'
% (n_a * (width + DPATCH_GAP_X), 2 * DPATCH_H + DPATCH_ROW_GAP))
print('>>> from the head. After your press the head waits %g s dark while the'
% DPATCH_ARM_DWELL_S)
print('>>> flow check runs. Row A is CW at full power, up to %.0f s per patch.\n'
% (n_lines * (width / feed_ref * 60.0 + 0.1)))
print('G91/G21: %s / %s' % (g.cmd('G91'), g.cmd('G21')))
sampler.start()
done = []
aborted = None
row_x = 0.0
try:
for i, p in enumerate(patches):
if i == n_a:
# Row B starts above row A's first patch.
g.s.sendall(('G0 X%g Y%g\n' % (-row_x, DPATCH_H + DPATCH_ROW_GAP)).encode())
g.wait_state('Idle', 30)
row_x = 0.0
lines, dx, dy = dpatch_gcode(p['feed'], width, pitch, n_lines)
est_s = n_lines * (width / p['feed'] * 60.0 + 0.25) + 2.0
time.sleep(DPATCH_SETTLE_S)
t_m3 = time.time()
dwell = ['G4 P%g' % DPATCH_ARM_DWELL_S] if i == 0 else []
for ln in ['M3 S%d' % p['s']] + dwell + lines + ['M5']:
g.s.sendall(ln.encode() + b'\n')
# The controller reports Idle inside the dwell, so the first Run
# seen after the press is the first line of the first patch, and
# the wait covers the button timeout plus the dwell.
if i == 0:
print(' waiting for the press, then %g s dark for the flow check'
% DPATCH_ARM_DWELL_S)
st = g.wait_state('Run', 300 + DPATCH_ARM_DWELL_S if i == 0 else 60)
if not st.startswith('Run'):
aborted = ('patch %d never ran (state=%s): arm refused, no press, '
'or the controller alarmed' % (i + 1, st))
break
t_run0 = time.time()
st = g.wait_state('Idle', est_s + 60.0, poll=0.05)
t_run1 = time.time()
if not st.startswith('Idle'):
cs = sample_forgectrl() or {}
aborted = ('patch %d did not finish (state=%s; cooling verdict %s, %r)'
% (i + 1, st, cs.get('verdict'), cs.get('reason')))
break
if t_run1 - t_run0 < 0.5 * est_s:
# Cut short: cancelled, and a cancel may have moved the
# head, so every relative move from here is aimed blind.
aborted = ('patch %d ran %.1f s of ~%.0f: cancelled; no further '
'moves sent' % (i + 1, t_run1 - t_run0, est_s))
break
p.update({'t_m3': t_m3, 't_run0': t_run0, 't_run1': t_run1})
done.append(p)
print(' %s%d: S%-4d F%-5d ran %.1f s' % (p['row'], i + 1, p['s'], p['feed'],
t_run1 - t_run0))
# Back to the patch origin, then along to the next one.
g.s.sendall(('G0 X%g Y%g\n' % (-dx, -dy)).encode())
g.s.sendall(('G0 X%g\n' % (width + DPATCH_GAP_X)).encode())
row_x += width + DPATCH_GAP_X
g.wait_state('Idle', 30)
finally:
try:
g.cmd('M5', timeout=1)
except Exception:
pass
if aborted:
g.rt(b'\x18') # abort out of whatever it is in
else:
g.s.sendall(b'G90\nM2\n') # program end closes the window
time.sleep(1.5)
sampler.stop()
if aborted:
print('ABORTED: %s' % aborted)
tr = sampler.samples
print('\nsampler: %d samples, %.1f Hz achieved, %d read errors'
% (len(tr), sampler.rate(), sampler.errors))
if not done:
return 1
print('\n--- per patch (steady window: first 1 s and last 0.5 s dropped) ---')
print(' patch S F hv mean max | tp delta base | lon')
for i, p in enumerate(done):
base = _stats(_window(tr, p['t_m3'] - DPATCH_SETTLE_S + 0.5, p['t_m3'], 'tp'))['mean']
hv = _stats(_window(tr, p['t_run0'] + 1.0, p['t_run1'] - 0.5, 'hv'))
tp = _stats(_window(tr, p['t_run0'] + 1.0, p['t_run1'] - 0.5, 'tp'))
lon = max(_window(tr, p['t_run0'], p['t_run1'], 'lon') or [0])
p['hv_mean'], p['hv_max'], p['tp_base'] = hv['mean'], hv['max'], base
p['tp_delta'] = None if (tp['mean'] is None or base is None) else tp['mean'] - base
p['lon_peak'] = lon
print(' %s%-2d %-5d %-6d %7s %5s | %8s %7s | %3d'
% (p['row'], i + 1, p['s'], p['feed'], _fmt(hv['mean']), _fmt(hv['max'], 0),
_fmt(p['tp_delta']), _fmt(base), lon))
row_a = [p['tp_delta'] for p in done if p['row'] == 'A' and p['tp_delta'] is not None]
if len(row_a) >= 2:
print('row A thermopile (CW at six feeds, the beam does not know the speed): '
'min %.0f max %.0f, spread %.1f%% of the mean'
% (min(row_a), max(row_a), 100.0 * (max(row_a) - min(row_a)) / (sum(row_a) / len(row_a))))
print('\n--- the match to make by eye, in the middle of each patch ---')
doses = [(p['dose'], i + 1) for i, p in enumerate(done) if p['row'] == 'A']
for i, p in enumerate(done):
if p['row'] != 'B' or p['tp_says'] is None:
continue
nearest = min(doses, key=lambda d: abs(d[0] - p['tp_says'])) if doses else None
print(' B%d (%d%% density): the thermopile says it should match A%d (dose %.2f); '
'deeper than that and the tube gives more than the sensor reads'
% (i + 1, p['pct'], nearest[1], nearest[0]) if nearest else
' B%d (%d%% density): no row A to match' % (i + 1, p['pct']))
record = {
'drill': 'dpatch', 'date': time.strftime('%Y-%m-%dT%H:%M:%S'),
'model': model, 'period': period, 'feed_ref': feed_ref,
'patch_mm': [width, DPATCH_H], 'pitch_mm': pitch,
'settings': {'$30': rpm_max, '$31': rpm_min, '$35': floor, '$36': ceil},
'sampler': {'local': sampler.local, 'hz': sampler.rate(),
'samples': len(tr), 'errors': sampler.errors},
'aborted': aborted, 'patches': done, 'trace': tr,
}
ddir = os.environ.get('FORGETEST_BENCH_DATA') or ('/tmp' if sampler.local else os.getcwd())
path = os.path.join(ddir, 'dpatch_%s.json' % time.strftime('%Y%m%d-%H%M%S'))
try:
with open(path, 'w') as f:
json.dump(record, f, indent=1)
print('record: %s' % path)
except OSError as e:
print('record not written: %s' % e)
return 0
# --- the rapids after an M5 ship dark ----------------------------------------
# One constant-power line, M5, then two rapids over it with dwells between,
# the shape every ladder above uses between rungs. M5 executes with the
# planner drained and the kernel run over, and the core issues no
# per-segment laser update for moves made with the spindle off, so only the
# stream's wanted fire state decides whether those rapids fire.
# S1000 is a certain strike and, under the density model, the worst case
# for the bug: full duty pinned, so a rapid that inherited fire would run
# at full power.
M5DARK_JOB = ['G91', 'G21', 'M3', 'S400',
'G1 X20 F600',
'M5', 'G4 P2.5',
'G0 X-20', 'G4 P2.5',
'G0 X20', 'G4 P2.5',
'G90', 'M2']
HV_DARK_MAX = 20 # hv_current reads 0 with the tube off
def drill_m5dark(g):
print('=== the rapids after an M5 ship dark: M3 S400, 20 mm line, M5, two rapids ===')
sampler = Sampler(PCURVE_SAMPLE_HZ)
if not sampler.local:
print('run this on the board: the witnesses are sysfs at 25 Hz')
return 2
print('connect: %s' % prepare(g))
print('pre-fire: %s' % sample_forgectrl())
arm_cue()
print('>>> 20 mm of free +X travel at the head. One 20 mm line at S400,')
print('>>> then the head rapids back over it and forward again, dark.\n')
sampler.start()
for ln in M5DARK_JOB:
g.s.sendall(ln.encode() + b'\n')
st = g.wait_state('Run', 240)
if not st.startswith('Run'):
print('FAIL: the job never ran (state=%s)' % st)
g.rt(b'\x18')
sampler.stop()
return 1
# The controller reports Idle inside a G4 dwell, so Idle is no sign the
# job is over; the armed window closing at M2 is.
t0 = time.time()
seen_armed = False
while time.time() - t0 < 90:
smp = sample_forgectrl()
if smp and smp['armed']:
seen_armed = True
elif smp and seen_armed and not smp['armed']:
break
time.sleep(0.2)
time.sleep(1.5)
sampler.stop()
tr = sampler.samples
# Discharge segments from the current, 1 s hysteresis: the line is one;
# a rapid that fired is another.
segs, cur = [], None
for s in tr:
on = s['hv'] is not None and s['hv'] > 30
if on and cur is None:
cur = [s['t'], s['t']]
elif on:
cur[1] = s['t']
elif cur is not None and s['t'] - cur[1] > 1.0:
segs.append(cur)
cur = None
if cur:
segs.append(cur)
print('\n--- results (%d samples, %.1f Hz) ---' % (len(tr), sampler.rate()))
if not segs:
print('FAIL: no discharge seen at all (arm refused, no press, or no fire)')
return 1
t_end = segs[0][1]
base = _stats(_window(tr, segs[0][0] - 2.0, segs[0][0] - 0.2, 'tp'))['mean']
print('line: %.2f s of discharge; %d discharge segment(s) in the run%s'
% (t_end - segs[0][0], len(segs),
'' if len(segs) == 1 else ': the extra ones are rapids that FIRED'))
hv_after = max((s['hv'] for s in tr if s['t'] > t_end + 0.3 and s['hv'] is not None),
default=0)
lon_after = [s for s in tr if s['t'] > t_end + 0.3 and s.get('lon')]
# laser_on_sampled lags a window: the first zero past the line is the
# dark point, and nothing after it may be nonzero.
zeros = [s['t'] for s in tr if s['t'] > t_end and s.get('lon') == 0]
relit = [s for s in tr if zeros and s['t'] > zeros[0] and s.get('lon')]
print('after the M5: hv max %d (dark <= %d); laser_on_sampled nonzero samples %d, '
'after its first zero %d' % (hv_after, HV_DARK_MAX, len(lon_after), len(relit)))
print('trace from 0.2 s before the line ended, 40 ms steps (hv / thermopile delta):')
row = [s for s in tr if t_end - 0.2 <= s['t'] <= t_end + 9.0]
for i in range(0, len(row), 25):
chunk = row[i:i + 25]
print(' +%4.1fs hv: %s' % (chunk[0]['t'] - t_end, ' '.join('%d' % (s['hv'] or 0) for s in chunk)))
print(' tp: %s' % ' '.join('%d' % ((s['tp'] or 0) - (base or 0)) for s in chunk))
covered = tr[-1]['t'] - t_end
if covered < 7.0:
print('M5DARK INCONCLUSIVE: the trace ends %.1f s after the line, before '
'the rapids (the job runs ~8 s past the M5)' % covered)
return 1
ok = len(segs) == 1 and hv_after <= HV_DARK_MAX and not relit
print('M5DARK %s' % ('PASS: the rapids after the M5 shipped dark (%.1f s sampled past the line)'
% covered if ok else 'FAIL: the laser fired after the M5'))
return 0 if ok else 1
def post_ctrl(action):
# http.client preserves the header-name case exactly as given.
import http.client
tok = panel_token()
c = http.client.HTTPConnection(HOST, 8080, timeout=8)
c.putrequest('POST', '/controller/' + action)
c.putheader('X-ForgeFIRM-Token', tok)
c.putheader('Content-Length', '0')
c.endheaders()
r = c.getresponse()
body = r.read().decode()
c.close()
return r.status, body
def drill_expstop(g):
print('=== armed kill on the expected-stop path (POST /controller/stop) ===')
if not panel_token():
raise SystemExit('set GF_TOKEN to the panel token first (or run on the board)')
print('connect: %s' % prepare(g))
arm_cue()
job = ['G91', 'G21', 'M4', 'S400',
'G1 X40 F200', 'G1 Y40 F200', 'G1 X-40 F200', 'G1 Y-40 F200',
'M5', 'G90', 'M2']
for ln in job:
g.s.sendall(ln.encode() + b'\n')
# Wait for the burn to be under way (emission > 0), then stop.
t0 = time.time()
seen = False
while time.time() - t0 < 240:
smp = sample_forgectrl()
if smp and smp['emission'] and smp['emission'] > 0:
seen = True
break
time.sleep(0.15)
if not seen:
print('no emission seen within the wait - operator did not arm? ABORT')
return []
print('emission live (%s) - stopping the controller NOW' % smp['emission'])
t_stop = time.time()
code, body = post_ctrl('stop')
print('POST /controller/stop -> %s %s (%.2f s)' % (code, body.strip(), time.time() - t_stop))
trail = []
for _ in range(40): # ~5 s at 8 Hz
smp = sample_forgectrl()
if smp:
trail.append((round(time.time() - t_stop, 2), smp['emission'], smp['kstate'], smp['armed']))
time.sleep(0.12)
print('post-stop trail (t, emission_samples, kstate, armed):')
for t in trail:
print(' %s' % (t,))
zero_at = next((t for t, e, _, _ in trail if e == 0), None)
tail_zero = all(e == 0 for _, e, _, _ in trail[-16:])
not_running = all(k != 'running' for _, _, k, _ in trail[-16:])
print('emission first 0 at +%s s; last 2 s all zero: %s; kernel not running: %s'
% (zero_at, tail_zero, not_running))
try:
mode = get_json('/mode')
except Exception as e:
mode = str(e)
print('/mode after stop: %s' % mode)
ok = zero_at is not None and zero_at < 2.5 and tail_zero and not_running
print('EXPSTOP %s' % ('PASS' if ok else 'REVIEW'))
print('the controller is left STOPPED (supervision held); resume it with '
'the ctrlstart step once the operator has judged the stop')
return trail
def drill_ctrlstart(g):
"""Resume supervision after expstop: POST /controller/start, then
report /mode. No motion, no laser."""
code, body = post_ctrl('start')
print('POST /controller/start -> %s %s' % (code, body.strip()))
time.sleep(6)
try:
print('/mode after start: %s' % get_json('/mode'))
except Exception as e:
print('/mode after start: %s' % e)
return []
def main():
drill = sys.argv[1] if len(sys.argv) > 1 else ''
drills = {'witness': drill_witness, 'hold': drill_hold,
'faultpos': drill_faultpos, 'ircut': drill_ircut,
'pthresh': drill_pthresh, 'dladder': drill_dladder,
'pcurve': drill_pcurve, 'm5dark': drill_m5dark,
'dpatch': drill_dpatch,
'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart}
if drill not in drills:
print(__doc__)
return 2
if drill == 'ctrlstart':
drills[drill](None)
return 0
g = Grbl(HOST, PORT)
try:
drills[drill](g)
finally:
# Always leave the laser commanded off.
try:
g.cmd('M5', timeout=1)
except Exception:
pass
return 0
if __name__ == '__main__':
sys.exit(main())