mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
Add the dpatch depth-witness drill
live_fire_drills.py gains dpatch: two rows of small serpentine-filled patches on scrap. Row A is CW at feeds that give relative doses from 1.0 to 0.25. Row B is density 100, 80, 60, 45, and 30 percent at F600. The operator matches each row-B patch to the row-A patch of equal depth. That reads the light fraction of a density off the material, next to the prediction of the thermopile. The drill samples sysfs at 25 Hz, as pcurve does, and writes a JSON record to the bench data directory. Bench tool only, on the dev image; no catalog consequence.
This commit is contained in:
@@ -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, the rapids after an M5 executed at idle ship dark, and the next job in the same process fires at the level the previous one ended at). 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), `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), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `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), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch` (depth witness for the density curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25, row B density 100/80/60/45/30 % at F600; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `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. |
|
||||
|
||||
@@ -96,6 +96,15 @@ Drills (pass a name):
|
||||
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
|
||||
@@ -1280,6 +1289,231 @@ def drill_pcurve(g):
|
||||
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,
|
||||
@@ -1469,6 +1703,7 @@ def main():
|
||||
'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__)
|
||||
|
||||
Reference in New Issue
Block a user