Add the senderchg and overrun bench drills; close BRINGUP item 20

Two live-fire drills in live_fire_drills.py: senderchg drops the sender
mid-line with the tube lit, reconnects and expects the next laser-on to
prompt again; overrun writes a 93-line fill at once into a running job
and expects the report, the alarm, the disarm and a fresh prompt after
$X. Both read hv_current and the thermopile for the instant emission
ends and open their "nothing lit" window 2.5 s after the event, since
laser_on_sampled is a one-second window count. Both passed on image
20260829190323; CAMPAIGN-LOG records the runs.

BRINGUP item 20 (the arm skipped on a stale spindle state) closes: fix on
the image, host and bench proven, covers in place. Its text moves to the
superseded notes; items 21 to 23 are now 20 to 22.

No catalog consequence: bench drills and documentation.
This commit is contained in:
ScottW514
2026-08-29 16:05:59 -04:00
parent 89731e654b
commit 5c282ce167
3 changed files with 354 additions and 21 deletions
+3 -21
View File
@@ -1434,25 +1434,7 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
(`KPROBES`, `PERF_EVENTS`, `BPF_SYSCALL`, `DEBUG_FS`, `DEVMEM`, (`KPROBES`, `PERF_EVENTS`, `BPF_SYSCALL`, `DEBUG_FS`, `DEVMEM`,
`MAGIC_SYSRQ`: no runtime cost unused, root-only exposure, and root can `MAGIC_SYSRQ`: no runtime cost unused, root-only exposure, and root can
load modules anyway). load modules anyway).
20. **Arm skipped on a stale spindle state (safety, fix before the next 20. **A sender change while a job runs: discussion.** Today a sender that
image).** `glowforge_laser.c` arms on the first laser-on of a job only
while its own record of the spindle state reads off
(`state.on && !cur.on && !laser_ok` in `spindleSetState`), and
`gflaser_disarm` does not clear that record. A job whose M5 never
executes leaves the record on, and the next job's M3 runs with no arm:
no button wait, no run report to forgectrl, no run airflow. Fire stays
suppressed at the stream, so no energy leaves the tube, but the head
runs the whole job without the operator's consent. Seen on the bench:
a sender wrote a 93-line job at once, the RX ring (1023 bytes)
overflowed, and the serial layer drops bytes on a full ring (`serial.c`,
the overflow flag is set and never read), so the job's M5 and M2 were
lost, the window stayed open until the sender disconnected, and the
following job ran unarmed. Owed: the driver fix on an image (the arm
decided by the window alone, an RX overrun dropping the overrunning
line whole and aborting the job), one bench drill of each scenario,
and the catalog's `covers` widened to `glowforge_laser.c` and
`serial.c`.
21. **A sender change while a job runs: discussion.** Today a sender that
disconnects mid-job leaves the motion running to the end of what the disconnects mid-job leaves the motion running to the end of what the
controller holds, with the window closed and fire suppressed (the controller holds, with the window closed and fire suppressed (the
consent belonged to the displaced session), so the job finishes dark consent belonged to the displaced session), so the job finishes dark
@@ -1471,7 +1453,7 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
running on leaves a clean stop position but wastes the piece. Decide running on leaves a clean stop position but wastes the piece. Decide
with the gapless pause and resume item (17), which owns the resume with the gapless pause and resume item (17), which owns the resume
mechanics. mechanics.
22. **The flow check while the tube is lit.** The arm-time heater check 21. **The flow check while the tube is lit.** The arm-time heater check
starts at the session open, so with a prompt press the tube is lit starts at the session open, so with a prompt press the tube is lit
for most of its window, and a lit CW window adds about 1.5 C to the for most of its window, and a lit CW window adds about 1.5 C to the
rise (0.5 C at 45 % density) against a 1.6 C margin; on top of that the rise (0.5 C at 45 % density) against a 1.6 C margin; on top of that the
@@ -1493,7 +1475,7 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
the ADC is the other candidate and a shared path could show both; a 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 scope on the two sensor lines through a session, fans and tube
switched separately, decides. switched separately, decides.
23. **Laser power-good: what the line means.** `cnc/laser_pgood` and its 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 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 ~3.9 ms), the facts bank records that the sampled count reads 0 through
real cutting, and the cooling engine warns real cutting, and the cooling engine warns
+53
View File
@@ -4357,6 +4357,37 @@ on and holds it better with the pump off, so `cool_flow_rise` needs no
warm-end value through 25 C; above that is not measured. Records: warm-end value through 25 C; above that is not measured. Records:
`bench-data/flow_warm_log_20260829.txt`, `flow_warm_results_20260829.json`. `bench-data/flow_warm_log_20260829.txt`, `flow_warm_results_20260829.json`.
## 2026-08-29: the arm-skip and RX-overrun fix on the bench
Two live-fire drills on image 20260829190323, both new in
`scripts/bench/live_fire_drills.py`, both passed.
**`senderchg`** (record `bench-data/senderchg_20260829-195933.json`): a
20 mm line at F60 lit on the press, the connection dropped 5.0 s in with
the tube lit, a new session, the move finishing dark, then a fresh M3 and
a 5 mm line. The engine's window closed 1.3 s after the drop; `hv_current`
read dark within one 40 ms sample of it and the thermopile fell to its
floor by 0.4 s; the head ran the remaining 15 mm without a sender (the
Grbl expectation, BRINGUP item 21); the fresh M3 prompted for the button
again, the second press armed, and the 5 mm line marked. Lit samples:
177 on the first line, 0 between the drop and the second press, 31 on the
second line. `cnc/laser_on_sampled` is a one-second window count and reads
nonzero for up to a second after the beam stops, so the drills open their
"nothing lit" window 2.5 s after the event and read `hv_current` and the
thermopile for the instant.
**`overrun`** (record `bench-data/overrun_20260829-200256.json`): the same
first line, and 3 s in a 93-line fill (1270 bytes) written at once against
the 1023-byte ring. `RX overrun: the sender ignored flow control (Bf:);
job aborted` 0.3 s after the blast, `ALARM:3`, the disarm and the reset
banner; `hv_current` dark 0.11 s after the blast; the engine's window
closed within the second. After `$X` the fresh M3 prompted again and the
second press cut the 5 mm line. Lit samples: 134 before the alarm, 0
between the alarm and the second press, 87 on the second line. The report
and the reset came twice, 0.1 s apart: the tail of the blast arrived after
the first reset had flushed the ring and overran it again. Harmless, the
job was already stopped.
## Superseded status notes ## Superseded status notes
### Shared machine services — remaining polish, as listed 2026-08-13 ### Shared machine services — remaining polish, as listed 2026-08-13
@@ -5761,6 +5792,28 @@ Modules (27): `glowforge`, `wl18xx`, `wlcore`, `wlcore_sdio`, `mac80211`, `cfg80
All of 1 through 4 ride one image flash (kernel/BSP changes batch), and every item here is All of 1 through 4 ride one image flash (kernel/BSP changes batch), and every item here is
a platform change under the acceptance model. a platform change under the acceptance model.
### Arm skipped on a stale spindle state (item 20), closed 2026-08-29
Closed: the arm is decided by the window alone (grblHAL-glowforge a7dcdca), an RX overrun drops the overrunning line whole and stops the job, both on image 20260829190323; proven by `tests/laser_arm_test.c` case H, `tests/serial_test.c`, the null-sink scenarios `sender-change-mid-job` and `rx-overrun`, and the bench drills `senderchg` and `overrun` (the 2026-08-29 entries above). The catalog's `laser.*` covers name `grblhal-glowforge/src/**`, which holds both files. The mid-job sender-change discussion is its own item.
20. **Arm skipped on a stale spindle state (safety, fix before the next
image).** `glowforge_laser.c` arms on the first laser-on of a job only
while its own record of the spindle state reads off
(`state.on && !cur.on && !laser_ok` in `spindleSetState`), and
`gflaser_disarm` does not clear that record. A job whose M5 never
executes leaves the record on, and the next job's M3 runs with no arm:
no button wait, no run report to forgectrl, no run airflow. Fire stays
suppressed at the stream, so no energy leaves the tube, but the head
runs the whole job without the operator's consent. Seen on the bench:
a sender wrote a 93-line job at once, the RX ring (1023 bytes)
overflowed, and the serial layer drops bytes on a full ring (`serial.c`,
the overflow flag is set and never read), so the job's M5 and M2 were
lost, the window stayed open until the sender disconnected, and the
following job ran unarmed. Owed: the driver fix on an image (the arm
decided by the window alone, an RX overrun dropping the overrunning
line whole and aborting the job), one bench drill of each scenario,
and the catalog's `covers` widened to `glowforge_laser.c` and
`serial.c`.
## Reference notes ## Reference notes
+298
View File
@@ -124,6 +124,18 @@ Drills (pass a name):
margin. `fit` reads every t2 record in the bench data dir margin. `fit` reads every t2 record in the bench data dir
and fits rise against dose. JSON records like dpatch. and fits rise against dose. JSON records like dpatch.
flowload t1 | flowload t2 <secs> [pct] | flowload fit flowload t1 | flowload t2 <secs> [pct] | flowload fit
senderchg A sender change mid-job: a 20 mm line at F60 lit on the
press, the connection dropped five seconds in, a reconnect,
the move finishing dark, then a fresh M3 and a 5 mm line that
must prompt for the button again. PASS: two prompts, two arms,
no lit sample between the drop and the second press, the
second line marks. Two presses; JSON record.
overrun An RX overrun mid-job: a 20 mm line at F60 lit on the press,
a 93-line fill written at once three seconds in (about 1270
bytes against the 1023-byte ring). PASS: the controller
reports the overrun, stops in alarm with the window closed
and emission ending at the alarm; after $X a fresh M3 prompts
for the button again and a 5 mm line marks. Two presses.
expstop Armed kill on the EXPECTED-stop path: start a mark job, expstop Armed kill on the EXPECTED-stop path: start a mark job,
then mid-burn POST /controller/stop (the supervisor stops then mid-burn POST /controller/stop (the supervisor stops
the controller: SIGTERM, reap, exit safing). PASS: emission the controller: SIGTERM, reap, exit safing). PASS: emission
@@ -2338,6 +2350,291 @@ def drill_flowload(g):
return 0 if rec['outcome'] == 'done' else 1 return 0 if rec['outcome'] == 'done' else 1
# --- a sender change mid-job closes the window; the next laser-on prompts ----
# The driver arms at a laser-on only while the window is closed, and a
# sender change closes it whatever the spindle state (the consent belonged
# to the displaced session). This drill lights a 20 mm line at F60, drops
# the connection five seconds in with the tube lit, reconnects, lets the
# move finish dark (the Grbl expectation: the controller runs what it
# holds), then sends a fresh M3 and a 5 mm line: the controller must prompt
# for the button again, and nothing may fire between the drop and the
# second press. The material is the witness: the first mark ends where the
# connection dropped, the second line marks whole.
SENDERCHG_S = 400
SENDERCHG_LINE1 = 'G1 X20 F60' # 20 s of motion
SENDERCHG_DROP_S = 5.0 # lit seconds before the drop
SENDERCHG_LINE2 = 'G1 X5 F600'
def senderchg_wait_reply(g, needle, timeout):
"""Poll the socket until a reply containing `needle` arrives."""
end = time.time() + timeout
seen = len(g.log)
while time.time() < end:
g.drain()
for _t, ln in g.log[seen:]:
if needle in ln:
return True
seen = len(g.log)
time.sleep(0.1)
return False
def drill_senderchg(g):
print('=== sender change mid-job: the next laser-on must prompt again ===')
print('connect: %s' % prepare(g))
print('spindle off: %s' % g.cmd('M5'))
pre = sample_forgectrl()
if pre is None or pre.get('armed'):
print('REFUSED: the armed window is still open (or forgectrl is unreachable)')
return 2
sampler = Sampler(PCURVE_SAMPLE_HZ)
poller = CoolPoller()
sampler.start()
poller.start()
arm_cue()
print('>>> Scrap with 25 mm of free +X travel from the head. TWO presses: one')
print('>>> for the first line, and a second one after the reconnect, when the')
print('>>> prompt comes again. The line goes dark at the drop and the head')
print('>>> finishes the move without a sender; that is expected.\n')
print('G91/G21: %s / %s' % (g.cmd('G91'), g.cmd('G21')))
t_m3 = time.time()
for ln in ('M3 S%d' % SENDERCHG_S, SENDERCHG_LINE1, 'M5'):
g.s.sendall(ln.encode() + b'\n')
prompt1 = senderchg_wait_reply(g, 'press the button', 10)
print(' first prompt: %s' % prompt1)
st = g.wait_state('Run', FLOWLOAD_PRESS_WAIT_S)
if not st.startswith('Run'):
print('ABORTED: the first job never ran (%s)' % st)
g.rt(b'\x18')
sampler.stop()
poller.stop()
return 1
t_run1 = time.time()
time.sleep(SENDERCHG_DROP_S)
t_drop = time.time()
replies_a = list(g.log)
g.s.close() # the sender goes away, mid-line, lit
print(' connection dropped %.1f s into the first line' % (t_drop - t_run1))
time.sleep(1.0)
g2 = Grbl(HOST, PORT) # a new session
t_reconnect = time.time()
st = g2.wait_state('Idle', 40)
t_idle1 = time.time()
print(' reconnected; the first move ended dark %.1f s after the drop (state %s)'
% (t_idle1 - t_drop, st))
cs = sample_forgectrl() or {}
print(' engine after the drop: armed=%s verdict=%s' % (cs.get('armed'), cs.get('verdict')))
outcome = 'done'
t_run2 = None
try:
for ln in ('M3 S%d' % SENDERCHG_S, SENDERCHG_LINE2, 'M5'):
g2.s.sendall(ln.encode() + b'\n')
prompt2 = senderchg_wait_reply(g2, 'press the button', 10)
print(' second prompt after the reconnect: %s' % prompt2)
if not prompt2:
outcome = 'no-prompt'
g2.rt(b'\x18')
else:
st = g2.wait_state('Run', FLOWLOAD_PRESS_WAIT_S)
if not st.startswith('Run'):
outcome = 'no-run'
g2.rt(b'\x18')
else:
t_run2 = time.time()
g2.wait_state('Idle', 30)
print(' M2: %s' % g2.cmd('G90\nM2', timeout=10))
finally:
try:
g2.cmd('M5', timeout=1)
except Exception:
pass
time.sleep(3.0)
sampler.stop()
poller.stop()
tr = sampler.samples
lit = [s['t'] for s in tr if (s.get('lon') or 0) > 0 or (s.get('hv') or 0) > HV_DARK_MAX]
# laser_on_sampled is a one-second window count: it reads nonzero for
# up to a second after the beam stops, so the gap opens 2.5 s after
# the drop; hv_current and the thermopile answer within a sample.
lit1 = [t for t in lit if t_run1 - 1 <= t <= t_drop + 2.5]
lit_gap = [t for t in lit if t_drop + 2.5 < t < (t_run2 or time.time()) - 0.5]
lit2 = [t for t in lit if t_run2 and t >= t_run2] if t_run2 else []
hv_off = [s['t'] - t_drop for s in tr if s['t'] > t_drop and (s.get('hv') or 0) <= HV_DARK_MAX]
print(' hv_current read dark %.2f s after the drop' % (hv_off[0] if hv_off else -1))
print('\n--- replies (t from the first M3) ---')
for t, ln in replies_a + g2.log:
if ln != 'ok' and not re.match(r'\$\d+=', ln):
print(' %+7.1f s %s' % (t - t_m3, ln))
print('--- engine armed transitions ---')
last = None
for p in poller.samples:
if 'armed' in p and p['armed'] != last:
print(' %+7.1f s armed=%s' % (p['t'] - t_m3, p['armed']))
last = p['armed']
print('--- emission (lit samples at 25 Hz) ---')
print(' first line, press to drop: %d samples (%.1f s)' % (len(lit1), len(lit1) / 25.0))
print(' drop to second press: %d samples <- must be 0' % len(lit_gap))
print(' second line: %d samples (%.1f s)' % (len(lit2), len(lit2) / 25.0))
ok = outcome == 'done' and prompt1 and len(lit1) > 25 and not lit_gap and len(lit2) > 5
print('\n%s: %s' % ('PASS' if ok else 'FAIL',
'the drop closed the window, nothing fired until the second press, '
'and the second laser-on prompted and armed' if ok else outcome))
rec = {'drill': 'senderchg', 'date': time.strftime('%Y-%m-%dT%H:%M:%S'),
't_m3': t_m3, 't_run1': t_run1, 't_drop': t_drop, 't_reconnect': t_reconnect,
't_run2': t_run2, 'outcome': outcome, 'pass': bool(ok),
'replies': replies_a + g2.log, 'poll': poller.samples, 'trace': tr}
ddir = os.environ.get('FORGETEST_BENCH_DATA') or ('/tmp' if sampler.local else os.getcwd())
path = os.path.join(ddir, 'senderchg_%s.json' % time.strftime('%Y%m%d-%H%M%S'))
try:
with open(path, 'w') as f:
json.dump(rec, f, indent=1)
print('record: %s' % path)
except OSError as e:
print('record not written: %s' % e)
return 0 if ok else 1
# --- an RX overrun mid-job stops the job and closes the window ---------------
# A sender that writes past the RX ring (Bf: is the contract) loses lines,
# and the driver now drops the overrunning line whole, reports the overrun
# and stops the job the way a ^X does: alarm, latch relocked, window
# closed. This drill lights a 20 mm line at F60 on the press, writes a
# 93-line fill at once three seconds in (about 1270 bytes against the
# 1023-byte ring), and expects the report, the alarm and the disarm, with
# emission ending at the alarm; then $X, a fresh M3 and a 5 mm line must
# prompt for the button again and mark.
OVERRUN_S = 400
OVERRUN_LINE1 = 'G1 X20 F60'
OVERRUN_BLAST_S = 3.0 # lit seconds before the blast
OVERRUN_LINE2 = 'G1 X5 F600'
def drill_overrun(g):
print('=== RX overrun mid-job: report, alarm, disarm; the next laser-on prompts ===')
print('connect: %s' % prepare(g))
print('spindle off: %s' % g.cmd('M5'))
pre = sample_forgectrl()
if pre is None or pre.get('armed'):
print('REFUSED: the armed window is still open (or forgectrl is unreachable)')
return 2
lines, _dx, _dy = dpatch_gcode(1500, 30.0, 0.3, 46)
blast = ''.join(ln + '\n' for ln in lines + ['M5']).encode()
sampler = Sampler(PCURVE_SAMPLE_HZ)
poller = CoolPoller()
sampler.start()
poller.start()
arm_cue()
print('>>> Scrap with 25 mm of free +X and 15 mm of +Y travel from the head.')
print('>>> TWO presses: one for the first line, and a second one after the')
print('>>> overrun alarm has been cleared, when the prompt comes again. The')
print('>>> blast is %d bytes written at once %.0f s into the line.\n'
% (len(blast), OVERRUN_BLAST_S))
print('G91/G21: %s / %s' % (g.cmd('G91'), g.cmd('G21')))
t_m3 = time.time()
for ln in ('M3 S%d' % OVERRUN_S, OVERRUN_LINE1):
g.s.sendall(ln.encode() + b'\n')
prompt1 = senderchg_wait_reply(g, 'press the button', 10)
print(' first prompt: %s' % prompt1)
st = g.wait_state('Run', FLOWLOAD_PRESS_WAIT_S)
if not st.startswith('Run'):
print('ABORTED: the first job never ran (%s)' % st)
g.rt(b'\x18')
sampler.stop()
poller.stop()
return 1
t_run1 = time.time()
time.sleep(OVERRUN_BLAST_S)
t_blast = time.time()
g.s.sendall(blast)
reported = senderchg_wait_reply(g, 'RX overrun', 10)
t_report = time.time()
st = g.wait_state('Alarm', 10)
t_alarm = time.time()
print(' overrun reported: %s (%.1f s after the blast); state %s' % (reported, t_report - t_blast, st))
outcome = 'done'
t_run2 = None
prompt2 = False
try:
if not reported or not st.startswith('Alarm'):
outcome = 'no-report' if not reported else 'no-alarm'
g.rt(b'\x18')
else:
time.sleep(2.0)
g.drain()
print(' unlock: %s' % g.cmd('$X'))
for ln in ('M3 S%d' % OVERRUN_S, OVERRUN_LINE2, 'M5'):
g.s.sendall(ln.encode() + b'\n')
prompt2 = senderchg_wait_reply(g, 'press the button', 10)
print(' second prompt after the alarm: %s' % prompt2)
if not prompt2:
outcome = 'no-prompt'
g.rt(b'\x18')
else:
st = g.wait_state('Run', FLOWLOAD_PRESS_WAIT_S)
if not st.startswith('Run'):
outcome = 'no-run'
g.rt(b'\x18')
else:
t_run2 = time.time()
g.wait_state('Idle', 30)
print(' M2: %s' % g.cmd('G90\nM2', timeout=10))
finally:
try:
g.cmd('M5', timeout=1)
except Exception:
pass
time.sleep(3.0)
sampler.stop()
poller.stop()
tr = sampler.samples
lit = [s['t'] for s in tr if (s.get('lon') or 0) > 0 or (s.get('hv') or 0) > HV_DARK_MAX]
# laser_on_sampled is a one-second window count (see senderchg): the
# gap opens 2.5 s after the alarm; hv_current answers within a sample.
lit1 = [t for t in lit if t_run1 - 1 <= t <= t_alarm + 2.5]
lit_gap = [t for t in lit if t_alarm + 2.5 < t < (t_run2 or time.time()) - 0.5]
lit2 = [t for t in lit if t_run2 and t >= t_run2] if t_run2 else []
hv_off = [s['t'] - t_blast for s in tr if s['t'] > t_blast and (s.get('hv') or 0) <= HV_DARK_MAX]
print(' hv_current read dark %.2f s after the blast' % (hv_off[0] if hv_off else -1))
last_lit_after_blast = max([t for t in lit if t <= (t_run2 or time.time())], default=t_blast)
print('\n--- replies (t from the first M3) ---')
for t, ln in g.log:
if ln != 'ok' and not re.match(r'\$\d+=', ln):
print(' %+7.1f s %s' % (t - t_m3, ln))
print('--- engine armed transitions ---')
last = None
for p in poller.samples:
if 'armed' in p and p['armed'] != last:
print(' %+7.1f s armed=%s' % (p['t'] - t_m3, p['armed']))
last = p['armed']
print('--- emission (lit samples at 25 Hz) ---')
print(' first line, press to alarm: %d samples (%.1f s); last lit sample %.2f s after the blast'
% (len(lit1), len(lit1) / 25.0, last_lit_after_blast - t_blast))
print(' alarm to second press: %d samples <- must be 0' % len(lit_gap))
print(' second line: %d samples (%.1f s)' % (len(lit2), len(lit2) / 25.0))
ok = outcome == 'done' and prompt1 and prompt2 and reported and not lit_gap and len(lit2) > 5
print('\n%s: %s' % ('PASS' if ok else 'FAIL',
'the overrun was reported, the job stopped in alarm with the window '
'closed, nothing fired until the second press, and the next laser-on '
'prompted and armed' if ok else outcome))
rec = {'drill': 'overrun', 'date': time.strftime('%Y-%m-%dT%H:%M:%S'),
't_m3': t_m3, 't_run1': t_run1, 't_blast': t_blast, 't_report': t_report,
't_alarm': t_alarm, 't_run2': t_run2, 'blast_bytes': len(blast),
'outcome': outcome, 'pass': bool(ok),
'replies': g.log, 'poll': poller.samples, 'trace': tr}
ddir = os.environ.get('FORGETEST_BENCH_DATA') or ('/tmp' if sampler.local else os.getcwd())
path = os.path.join(ddir, 'overrun_%s.json' % time.strftime('%Y%m%d-%H%M%S'))
try:
with open(path, 'w') as f:
json.dump(rec, f, indent=1)
print('record: %s' % path)
except OSError as e:
print('record not written: %s' % e)
return 0 if ok else 1
# --- the rapids after an M5 ship dark ---------------------------------------- # --- the rapids after an M5 ship dark ----------------------------------------
# One constant-power line, M5, then two rapids over it with dwells between, # One constant-power line, M5, then two rapids over it with dwells between,
@@ -2528,6 +2825,7 @@ def main():
'pthresh': drill_pthresh, 'dladder': drill_dladder, 'pthresh': drill_pthresh, 'dladder': drill_dladder,
'pcurve': drill_pcurve, 'm5dark': drill_m5dark, 'pcurve': drill_pcurve, 'm5dark': drill_m5dark,
'dpatch': drill_dpatch, 'flowload': drill_flowload, 'dpatch': drill_dpatch, 'flowload': drill_flowload,
'senderchg': drill_senderchg, 'overrun': drill_overrun,
'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart} 'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart}
if drill not in drills: if drill not in drills:
print(__doc__) print(__doc__)