Files
forgefirm/scripts/bench/gate_a_kernel_drills.py
T
ScottW514 a2bc4233d5 bench: the stale-origin drill goes, drills report their verdict, the pgood gate follows the pin
The faultpos live-fire drill armed and commanded a cut at an origin it
called stale to test a refusal the design decided not to gate: its only
outcome was an emission at an unknown position. Removed from the script
and the bench page.

live_fire_drills.py discarded every drill's return value, so the bench
page recorded a failed live-fire drill as OK. The exit status is the
drill's.

laser_pgood is the supply's power-good, high on every healthy machine;
fire_test.py and the K3 drill aborted on it and pgood_probe.py inverted
it. The latch-unlock drills now gate on the safety chain holding HV off
(charge-pump watchdog dead, pulse engine idle), as the kernel suite
does, and the probe reports the pin as the kernel publishes it.

motion.deadman: the controller resumed from its hang recovers on $X and
moves again without a restart (the stream's fault acknowledgment).
2026-09-02 08:08:19 -04:00

282 lines
10 KiB
Python

#!/usr/bin/env python3
"""GATE A kernel drills - runs ON the board. Usage: gate_a_kernel_drills.py K1|K2|K3
The pulse device must be free: stop forgectrl first
(/etc/init.d/forgectrl stop), run the drill, then restart it. The
script exits with a clear message if the device is busy.
All drills: the first stream byte forces laser duty to zero, the
stream ends with laser-off bytes, motor_lock=15 (no axis moves), and
the latch is re-locked unconditionally on exit. K3 refuses to run if
laser_pgood reports the HV supply good. Software witnesses:
cnc/state, cnc/laser_enable, cnc/laser_on, cnc/laser_on_sampled, and
interlock_circuit bit 3 (the commanded latch). Physical witness:
scope on the PSU-connector LASER_ON pin, as in fire_test.py.
K1 - controlled-stop deceleration floor. Pads-only stream at the
default cloud tick (10 kHz, ramp 125000 Hz/s). A controlled stop
mid-run must ramp the step frequency down to the floor (tens of
milliseconds), never consume the tail as a max-rate burst
(sub-millisecond) or hang. PASS: stop-to-idle time inside the
controlled-decel band, no fault, no underrun.
K2 - resume waypoint honors the locked latch. Stream = pads, an
X-step section (motors locked), then a 2 s FIRE window. Run with
the latch LOCKED, controlled-stop inside the initial pads, then
resume with a positive waypoint - the branch that re-enables the
laser after the waypoint is reached. PASS: laser_enable stays 0
through the FIRE window; laser_on/laser_on_sampled stay 0.
K3 - mid-run latch unlock does not re-arm FIRE. Stream = FIRE bits
throughout. Run with the latch LOCKED (the run-start guard makes
the run laser-less), then write laser_latch=0 during the accel
ramp (ramp_rate lowered to 10000 Hz/s for a ~0.9 s window). The
unlock must drive the latch pin (interlock bit 3 reads 0) but
must NOT restore the FIRE output drive while the run or a ramp
is in flight. PASS: laser_enable stays 0 for the entire run.
"""
import errno, fcntl, os, struct, sys, time
TICK_HZ = 10000
FIRE = b'\x10'
PAD = b'\x00'
XSTEP = b'\x01'
POWER0 = bytes([0x80])
def wr(attr, val):
with open('/sys/glowforge/' + attr, 'w') as f:
f.write(str(val))
def rd(attr):
with open('/sys/glowforge/' + attr) as f:
return f.read().strip()
# The safety chain asserts HV_ENABLE only while a run feeds the charge-pump
# watchdog: a dead watchdog and an idle pulse engine mean HV off. That is
# the gate for a latch unlock; laser_pgood is the supply's power-good,
# high on every healthy machine, and says nothing about HV.
def hv_off_reason():
alive = rd('cnc/charge_pump_alive')
state = rd('cnc/state')
if alive != '0' or state != 'idle':
return 'charge_pump_alive=%s state=%s' % (alive, state)
return None
def rd_pos():
with open('/sys/glowforge/cnc/position', 'rb') as f:
raw = f.read(32)
return struct.unpack('<5i', raw[:20])
def snap(tag):
print('%s: state=%s laser_enable=%s laser_on=%s laser_on_sampled=%s interlock=%s'
% (tag, rd('cnc/state'), rd('cnc/laser_enable'), rd('cnc/laser_on'),
rd('cnc/laser_on_sampled'), rd('cnc/interlock_circuit')))
def open_pulsedev():
try:
fd = os.open('/dev/glowforge', os.O_WRONLY)
except OSError as e:
if e.errno == errno.EBUSY:
print('ABORT: /dev/glowforge is busy - stop forgectrl first '
'(/etc/init.d/forgectrl stop), then re-run')
sys.exit(1)
raise
fcntl.flock(fd, fcntl.LOCK_EX)
return fd
def wait_state(want, timeout, poll=0.05):
t0 = time.time()
while time.time() - t0 < timeout:
s = rd('cnc/state')
if s == want:
return s
time.sleep(poll)
return rd('cnc/state')
def watch_laser_until_idle(timeout):
"""Tight-loop laser_enable/laser_on watch; returns (hits, end_state)."""
hits = []
t0 = time.time()
state = 'running'
while time.time() - t0 < timeout:
en = rd('cnc/laser_enable')
on = rd('cnc/laser_on')
if en != '0' or on != '0':
hits.append((round(time.time() - t0, 4), en, on))
state = rd('cnc/state')
if state != 'running':
break
return hits, state
def drill_k1():
stream = POWER0 + PAD * (6 * TICK_HZ)
print('K1: %d bytes = %.1f s of pads at %d Hz, ramp 125000 Hz/s'
% (len(stream), len(stream) / TICK_HZ, TICK_HZ))
snap('pre ')
wr('cnc/motor_lock', 15)
wr('cnc/laser_latch', 1)
wr('cnc/ramp_rate', 125000)
wr('cnc/step_freq', TICK_HZ)
fd = open_pulsedev()
try:
os.lseek(fd, 1, os.SEEK_SET)
wr('cnc/enable', 1)
time.sleep(0.5)
os.write(fd, stream)
wr('cnc/run', 1)
time.sleep(1.5) # well past the accel ramp
if rd('cnc/state') != 'running':
print('FAIL: expected running before the stop, got %s' % rd('cnc/state'))
return 1
t0 = time.time()
wr('cnc/stop', 1)
while time.time() - t0 < 5:
if rd('cnc/state') != 'running':
break
dt = time.time() - t0
state = rd('cnc/state')
print('controlled stop: state=%s after %.4f s' % (state, dt))
ok = state == 'idle' and 0.02 <= dt <= 3.0 and rd('cnc/faults') == '0'
if state == 'idle' and dt < 0.02:
print('FAIL: stop consumed the tail as a burst (%.4f s) - decel floor broken' % dt)
elif not ok:
print('FAIL: state=%s dt=%.4f faults=%s' % (state, dt, rd('cnc/faults')))
else:
print('PASS: decelerating tail %.4f s, no burst, no fault' % dt)
# drain the paused remainder laser-less so the device ends clean
wr('cnc/resume', 0)
wait_state('running', 2, poll=0.005)
wait_state('idle', 10)
return 0 if ok else 1
finally:
wr('cnc/laser_latch', 1)
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
def drill_k2():
# "unmask-x" as a second argument runs with motor_lock=14 (X live,
# ~6 mm of X travel) for boards where the waypoint counter does not
# advance under a full motor_lock. Park the head mid-bed first.
lock = 14 if 'unmask-x' in [a.lower() for a in sys.argv[2:]] else 15
step_sec = (XSTEP + PAD * 4) * 1000 # 1000 X steps at 2 kHz
stream = (POWER0 + PAD * TICK_HZ + step_sec + PAD * (TICK_HZ // 2)
+ FIRE * (2 * TICK_HZ) + PAD * TICK_HZ)
print('K2: %d bytes = %.1f s; latch stays LOCKED; waypoint +200; motor_lock=%d'
% (len(stream), len(stream) / TICK_HZ, lock))
snap('pre ')
wr('cnc/motor_lock', lock)
wr('cnc/laser_latch', 1)
wr('cnc/ramp_rate', 125000)
wr('cnc/step_freq', TICK_HZ)
fd = open_pulsedev()
try:
os.lseek(fd, 1, os.SEEK_SET)
wr('cnc/enable', 1)
time.sleep(0.5)
os.write(fd, stream)
pos_before = rd_pos()
wr('cnc/run', 1)
time.sleep(0.4) # inside the initial pads
wr('cnc/stop', 1)
state = wait_state('idle', 5, poll=0.01)
if state != 'idle':
print('FAIL: controlled stop did not reach idle (state=%s)' % state)
return 1
print('paused inside the pads; resuming with waypoint +200 (latch LOCKED)')
wr('cnc/resume', 200)
wait_state('running', 2, poll=0.005)
hits, state = watch_laser_until_idle(20)
pos_after = rd_pos()
print('done: state=%s' % state)
print('pos before=%s after=%s (informational: does the waypoint counter '
'advance under motor_lock)' % (pos_before, pos_after))
snap('post')
print('laser_on_sampled=%s underruns=%s faults=%s'
% (rd('cnc/laser_on_sampled'), rd('cnc/underruns'), rd('cnc/faults')))
if hits:
print('FAIL: laser asserted with the latch locked: %s' % hits[:10])
return 1
if pos_before[:3] == pos_after[:3]:
print('NOTE: position did not advance under motor_lock, so the '
'waypoint may not have completed. Re-run as '
'"gate_a_kernel_drills.py K2 unmask-x" (X live, ~6 mm of '
'X travel) with the head parked mid-bed.')
print('PASS: FIRE window replayed after the resume waypoint with '
'laser_enable/laser_on at 0 throughout')
return 0
finally:
wr('cnc/laser_latch', 1)
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
def drill_k3():
why = hv_off_reason()
if why is not None:
print('ABORT: the safety chain is not holding HV off (%s) - refusing latch unlock' % why)
return 1
stream = POWER0 + FIRE * (3 * TICK_HZ) + PAD * (TICK_HZ // 2)
print('K3: %d bytes = %.1f s of FIRE bits; ramp_rate 10000 Hz/s '
'(~0.9 s accel window); unlock at t=+0.15 s'
% (len(stream), len(stream) / TICK_HZ))
snap('pre ')
wr('cnc/motor_lock', 15)
wr('cnc/laser_latch', 1)
wr('cnc/step_freq', TICK_HZ)
wr('cnc/ramp_rate', 10000)
fd = open_pulsedev()
try:
os.lseek(fd, 1, os.SEEK_SET)
wr('cnc/enable', 1)
time.sleep(0.5)
os.write(fd, stream)
wr('cnc/run', 1)
time.sleep(0.15) # inside the accel ramp
wr('cnc/laser_latch', 0)
print('latch UNLOCKED mid-ramp; interlock=%s (bit 3 should read 0)'
% rd('cnc/interlock_circuit'))
hits, state = watch_laser_until_idle(20)
print('done: state=%s' % state)
snap('post')
print('laser_on_sampled=%s underruns=%s faults=%s'
% (rd('cnc/laser_on_sampled'), rd('cnc/underruns'), rd('cnc/faults')))
if hits:
print('FAIL: FIRE drive re-armed by a mid-run unlock: %s' % hits[:10])
return 1
print('PASS: laser_enable stayed 0 for the entire run after the '
'mid-ramp unlock')
return 0
finally:
wr('cnc/laser_latch', 1)
try:
wr('cnc/ramp_rate', 125000)
except OSError:
print('WARNING: could not restore ramp_rate=125000 - restore it by hand')
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
def main():
mode = sys.argv[1].upper() if len(sys.argv) > 1 else ''
drills = {'K1': drill_k1, 'K2': drill_k2, 'K3': drill_k3}
if mode not in drills:
print(__doc__)
return 2
rc = drills[mode]()
wr('cnc/laser_latch', 1)
print('exit: state=%s latch=LOCKED' % rd('cnc/state'))
return rc
if __name__ == '__main__':
sys.exit(main())