Cover step timing under CPU contention; record the laser power model

Bump the grblHAL pin to the real-time producer change.

Add motion.step-timing-under-load: the catalog had nothing that
exercised step generation while userspace competed for the single core,
which is exactly the gap that let the condition go unnoticed - the ring
never runs dry, so cnc/underruns reads 0 through it. The test asserts
the producer and the shipper both hold SCHED_FIFO, then drives
2000 mm/min round trips against a deliberate nice-5 CPU hog and requires
the controller to report no clamped events.

Add the pthresh live-fire drill: a constant-power ladder from 2 % to
30 % of full on scrap. Because $35 is a percent of full duty and the
rungs are percents of $30 with $31 = 0, the lowest rung that marks reads
directly as the $35 value. It needs $35 = 0 for the run, or the floor
lifts every rung and hides the threshold.

Record both open items in BRINGUP. The laser one carries the finding
that the factory never uses duty as a power control - all five firing
jobs in the captured pulse files pin the power byte at 127 and modulate
dose by dithering the FIRE bit at 6.5-18.8 % density - so the captures
cannot supply a $35 default, and the duty to optical-power transfer
function of this supply has never been measured.
This commit is contained in:
ScottW514
2026-08-17 16:45:37 -04:00
parent 2fa5800519
commit faaa6cb40b
5 changed files with 256 additions and 5 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ page's takeover does that; from a host, stop them first.
| `gate_a_kernel_drills.py` | Kernel laser-safety drills (run on the board with forgectrl stopped so the pulse device is free): `K1` controlled-stop deceleration floor, `K2` resume waypoint honors the locked latch, `K3` a mid-ramp latch unlock never re-arms the FIRE drive. Software witnesses (`cnc/state`, `laser_enable`, `laser_on`, `laser_on_sampled`, interlock bit 3) plus the PSU-connector LASER_ON scope point; K3 refuses to run if HV reports good. |
| `laser_stream_test.py` | Host-side laser pulse-stream emission harness: runs the native null-sink controller with `GFSINK_DUMP`, drives small laser jobs over TCP, and checks the dumped bytes against the kernel feeder contract (leading power byte, no back-to-back power bytes, FIRE only inside cutting moves, every stream ends FIRE-clear, no FIRE on a stepless gap, no FIRE leak across cycle churn). Runs in the grblHAL repo's CI. |
| `laser_lifecycle_test.py` | Host-side operator-armed-window lifecycle harness (null-sink controller): arm once per job with M5/M3 persistence, the M2 close, sender-change re-consent, the disarm grace counting down in Hold, and arm refusal under a blocking cooling verdict. Runs in the grblHAL repo's CI. |
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
| `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. |
| `gfbench.py` | Not a tool: the helper the board/host tools share - `HOST`/`LOCAL` from `GF_HOST`, `board(cmd)` (local `sh -c` or ssh), the factory coolant conversion `degc()`, `data_path()` (`FORGETEST_BENCH_DATA` or next to the tool), forgectrl's HTTP API with the panel token, `setting(key)` (from forgectrl, or from `/data/forgefirm.conf` on the board while forgectrl is stopped). |
| `fan_test.py` | Fan/coolant bench (board or host; controller running): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. |
+76 -1
View File
@@ -7,7 +7,7 @@ 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> [S] [F] (S, F used by ircut)
Usage: live_fire_drills.py <drill> [S] [F] (S, F used by ircut, pthresh)
Drills (pass a name):
witness Phase 5 A-1/A-2/A-5: a short vector mark at S400. Samples
@@ -33,6 +33,15 @@ Drills (pass a name):
>= 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 lowest
rung that leaves a mark is the tube's striking 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 the threshold.
pthresh [Smax] [F] e.g. pthresh 1000 300
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
@@ -414,6 +423,71 @@ def drill_ircut(g):
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; it lifts off baseline at the')
print('same rung the material starts marking:')
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 that leaves any mark is the striking threshold; set')
print('$35 to that rung\'s percent (round up to the next rung for')
print('margin). Note the emission counter proves the safety chain')
print('asserted LASER_ON, not that the tube lased - only the mark and')
print('the discharge current say that.')
return samples
def post_ctrl(action):
# http.client preserves the header-name case exactly as given.
import http.client
@@ -499,6 +573,7 @@ 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,
'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart}
if drill not in drills:
print(__doc__)