#!/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 [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 (default 1000 = full) and F (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())