Files
forgefirm/scripts/bench/puls_profile.py
T
ScottW514 4a6bd5a179 docs: record what the factory does about fans, temperature and coolant
Next-work item 19 carried three open questions about the factory's
operating envelope. All three are answered now, so the item states the
policy instead of the guess.

A fan tach alert during a cut pauses the print, taking the same transition
a user pause takes, and two of the factory's three tach monitors cannot
fire at all: they treat a zero limit as unconfigured and the limits arrive
zero anyway. So a stalled extraction fan is caught by the temperature it
causes, not by its tachometer. Every temperature alert pauses as well, and
a critical fails the machine outright, which is a different state and not a
pause. The scale question behind the header's ceilings is answered by the
coolant family, which the factory carries twice, once in raw ADC counts
where the tag named min is the hot limit, and once in millidegrees. And the
factory does not verify coolant flow at all, so the cooling engine's flow
check is ahead of the factory rather than behind it.

The crash, tilt and beam-detect bullets gain the same grading: an alert
threshold that pauses and a separate abort threshold that aborts.

Also drops references to paths that resolve outside this repo, in both
documents and in two bench scripts, naming the artifact instead. In
CAMPAIGN-LOG that is a wording substitution only; no date, claim or
measurement changes.

Documentation only, so no acceptance catalog consequence.
2026-08-19 17:23:02 -04:00

225 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""Extract motion profiles from Glowforge pulse files.
Decodes a factory .puls byte stream (one byte per machine tick) and reports
the velocity/acceleration profile the factory planner actually produced:
peak axis/vector speeds, acceleration ramp slopes, and per-move segments.
Used to derive factory-true grblHAL settings ($110/$111 max rate, $120/$121
accel) for the ForgeFIRM step backend (milestone 2, motion quality).
Accepts either a raw header-stripped stream (a bare .puls body) or a
full download with the GF1 header (magic at [1:4], total header length at
[4:8], then 8-byte key/value records). Header settings (STfr, XSmm) override
the --rate/--mode defaults when present.
Byte layout (kernel-module-glowforge/UAPI.md, hardware-verified):
bit7 set -> laser power byte (low 7 bits = power, no steps this tick)
bit0 X_STEP, bit1 X_DIR (set = -X)
bit2 Y_STEP, bit3 Y_DIR (set = +Y)
bit5 Z_STEP, bit6 Z_DIR (set = +Z, lens up)
bit4 LASER_EN
"""
import argparse
import json
import math
import sys
MM_PER_FULL_STEP = 0.15 # X/Y, hardware-verified
MM_PER_HALF_STEP_Z = 0.3534 # Z, hardware-verified
def parse_header(data: bytes):
"""Return (settings dict, pulse-data offset). Empty dict if headerless."""
if len(data) > 8 and data[1:4] == b'GF1':
total = int.from_bytes(data[4:8], 'little')
hdr = {}
pos = 8
while pos + 8 <= total:
key = data[pos:pos + 4].decode('ascii', 'replace')
hdr[key] = int.from_bytes(data[pos + 4:pos + 8], 'little')
pos += 8
return hdr, total
return {}, 0
def decode(data: bytes, offset: int):
"""Per-tick signed step deltas and laser state."""
n = len(data) - offset
dx = [0] * n
dy = [0] * n
dz = [0] * n
laser = [0] * n
power = [] # (tick, power value)
for i in range(n):
b = data[offset + i]
if b & 0x80:
power.append((i, b & 0x7F))
continue
if b & 0x01:
dx[i] = -1 if b & 0x02 else 1
if b & 0x04:
dy[i] = 1 if b & 0x08 else -1
if b & 0x20:
dz[i] = 1 if b & 0x40 else -1
laser[i] = (b >> 4) & 1
return dx, dy, dz, laser, power
def velocity(deltas, rate: float, mm_per_step: float, win: int):
"""Signed mm/s via centered window difference of the cumulative count."""
n = len(deltas)
cum = [0] * (n + 1)
for i, d in enumerate(deltas):
cum[i + 1] = cum[i] + d
v = [0.0] * n
scale = mm_per_step * rate / (2 * win)
for i in range(n):
lo = max(0, i - win)
hi = min(n, i + win)
v[i] = (cum[hi] - cum[lo]) * mm_per_step * rate / (hi - lo)
return v, cum[n]
def ramps(speed, rate: float, win: int, vmax: float):
"""Find sustained accel/decel ramps; return list of fitted slopes (mm/s^2).
A ramp = a maximal run where speed changes monotonically (within window
noise) by at least 25% of vmax. Slope from least-squares fit over the run.
"""
if vmax <= 0:
return []
n = len(speed)
out = []
i = 0
step = max(1, win // 4) # sample coarsely; windows overlap anyway
idx = list(range(0, n, step))
k = 1
while k < len(idx):
j0 = k - 1
rising = speed[idx[k]] > speed[idx[k - 1]]
while k < len(idx) and (speed[idx[k]] > speed[idx[k - 1]]) == rising \
and abs(speed[idx[k]] - speed[idx[k - 1]]) > 1e-9:
k += 1
seg = idx[j0:k]
dv = speed[seg[-1]] - speed[seg[0]]
if len(seg) >= 3 and abs(dv) >= 0.25 * vmax:
ts = [s / rate for s in seg]
vs = [speed[s] for s in seg]
tm = sum(ts) / len(ts)
vm = sum(vs) / len(vs)
num = sum((t - tm) * (v - vm) for t, v in zip(ts, vs))
den = sum((t - tm) ** 2 for t in ts)
if den > 0:
out.append(num / den)
if k < len(idx) and idx[k] == seg[-1]:
continue
k += 1
return out
def segments(speed, rate: float, thresh: float = 0.5):
"""Split into moves separated by >=20 ms of near-zero speed."""
n = len(speed)
gap = int(0.02 * rate)
moves = []
i = 0
while i < n:
while i < n and speed[i] <= thresh:
i += 1
if i >= n:
break
start = i
quiet = 0
while i < n and quiet < gap:
quiet = quiet + 1 if speed[i] <= thresh else 0
i += 1
end = i - quiet
moves.append((start, end, max(speed[start:end])))
return moves
def analyze(path: str, rate: float, mode: int, win_ms: float, dump_csv: str):
data = open(path, 'rb').read()
hdr, offset = parse_header(data)
if hdr:
rate = hdr.get('STfr', rate)
mode = hdr.get('XSmm', mode)
mm_us = MM_PER_FULL_STEP / mode
win = max(8, int(win_ms / 1000 * rate))
dx, dy, dz, laser, power = decode(data, offset)
n = len(dx)
vx, netx = velocity(dx, rate, mm_us, win)
vy, nety = velocity(dy, rate, mm_us, win)
speed = [math.hypot(a, b) for a, b in zip(vx, vy)]
vmax = max(speed) if speed else 0.0
rx = ramps([abs(v) for v in vx], rate, win, max(abs(v) for v in vx) if vx else 0)
ry = ramps([abs(v) for v in vy], rate, win, max(abs(v) for v in vy) if vy else 0)
rv = ramps(speed, rate, win, vmax)
zticks = [i for i, d in enumerate(dz) if d]
zint = [(b - a) / rate * 1000 for a, b in zip(zticks, zticks[1:])]
moves = segments(speed, rate)
rep = {
'file': path,
'ticks': n,
'rate_hz': rate,
'duration_s': round(n / rate, 3),
'microstep_mode': mode,
'header': {k: hdr[k] for k in ('STfr', 'XSmm', 'HAxr', 'HAyr', 'HAar',
'XSrc', 'YSrc') if k in hdr} or None,
'net_travel_mm': {'x': round(netx * mm_us, 3), 'y': round(nety * mm_us, 3)},
'steps': {'x': sum(1 for d in dx if d), 'y': sum(1 for d in dy if d),
'z': len(zticks)},
'peak_speed_mm_s': {'x': round(max((abs(v) for v in vx), default=0), 2),
'y': round(max((abs(v) for v in vy), default=0), 2),
'vector': round(vmax, 2)},
'accel_mm_s2': {
'x_ramps': [round(a) for a in rx],
'y_ramps': [round(a) for a in ry],
'vector_ramps': [round(a) for a in rv],
},
'z_step_interval_ms': {
'min': round(min(zint), 2) if zint else None,
'median': round(sorted(zint)[len(zint) // 2], 2) if zint else None,
},
'laser_on_ticks': sum(laser),
'power_bytes': power[:8],
'moves': [{'t0_s': round(a / rate, 3), 't1_s': round(b / rate, 3),
'peak_mm_s': round(p, 2)} for a, b, p in moves[:20]],
'move_count': len(moves),
}
if dump_csv:
stride = max(1, win // 4)
with open(dump_csv, 'w') as f:
f.write('t_s,vx_mm_s,vy_mm_s,speed_mm_s\n')
for i in range(0, n, stride):
f.write('%.4f,%.2f,%.2f,%.2f\n' % (i / rate, vx[i], vy[i], speed[i]))
return rep
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument('files', nargs='+')
ap.add_argument('--rate', type=float, default=10000,
help='machine tick Hz for headerless files (default 10000)')
ap.add_argument('--mode', type=int, default=8,
help='X/Y microstep mode for headerless files (default 8)')
ap.add_argument('--win-ms', type=float, default=8.0,
help='velocity window half-width in ms (default 8)')
ap.add_argument('--csv', help='dump velocity profile CSV (single file only)')
args = ap.parse_args()
for path in args.files:
rep = analyze(path, args.rate, args.mode, args.win_ms,
args.csv if len(args.files) == 1 else None)
json.dump(rep, sys.stdout, indent=2)
print()
if __name__ == '__main__':
main()