mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
Add laser-safety and regulatory documentation; scrub bench identity
- LIGHTBURN.md: mandatory "Before you cut" safety section; the walkthrough now reflects the firing machine (dry runs need the layer output off or M5; live first-cut instructions); the homing entry documents homing_mode and the gfcloud method; the machine address is a placeholder. - README.md: condensed safety section linking the full text and the regulatory notes. - INSTALL.md: "Regulatory and legal" section ahead of the install steps; routine updates route through the panel updater rather than the installer. - BRINGUP.md: the release signing key is described as held offline (no on-disk path); bench address and credential notes removed; Next-work item 7 corrected (the installer embeds the production release key); status entry for audit remediation Phases 0-1; the GATE A kernel drills join the pending image-flash checklist. - bench scripts: the target host comes from GF_HOST (or argv) instead of a hardcoded address. - laser_stream_test.py: per-session controller runs with a hermetic cooling-verdict publisher; new assertions that every stream terminates with FIRE clear (including M3 held to stream end) and that no FIRE bit rides a zero-step gap; a cycle-churn session exercises the stop/start seams. Audit findings D-1, D-2, D-3, D-5, D-10, D-12, B-10, and the harness half of D-4/G-1.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"""Milestone-2 motion-quality bench: factory-true rates/accels over TCP.
|
||||
|
||||
Runs a bounded, return-to-start jog sequence against grblHAL on the board
|
||||
(default 172.16.1.97:23) and reports peak feed reached, state transitions,
|
||||
(argv[1] or GF_HOST, port 23) and reports peak feed reached, state transitions,
|
||||
and final position drift. Every move is relative and round-trip, so the
|
||||
head ends where it started; the laser stays latched (motion-only backend).
|
||||
|
||||
@@ -10,11 +10,14 @@ Sequence: sanity jogs (X, Y, 40 mm out/back at 2400 mm/min), max-rate X
|
||||
out/back (60 mm at F12000 - peaks ~200 mm/s mid-move), diagonal out/back,
|
||||
then a G1 move with a feed-hold/resume in the middle.
|
||||
"""
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = sys.argv[1] if len(sys.argv) > 1 else '172.16.1.97'
|
||||
HOST = sys.argv[1] if len(sys.argv) > 1 else os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('pass the machine IP as argv[1] or set GF_HOST')
|
||||
PORT = 23
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import socket, subprocess, time
|
||||
import os
|
||||
|
||||
HOST = '172.16.1.97'
|
||||
HOST = os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('set GF_HOST to the machine IP address')
|
||||
|
||||
def board(cmd):
|
||||
r = subprocess.run(['wsl', '-d', 'forge-yocto', '--', 'ssh',
|
||||
|
||||
@@ -15,11 +15,14 @@ driver only writes the heater on M8/M9 transitions, so an idle driver
|
||||
leaves this alone.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = '172.16.1.97'
|
||||
HOST = os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('set GF_HOST to the machine IP address')
|
||||
|
||||
# Factory B-equation conversion (see kernel-module-glowforge/UAPI.md).
|
||||
F = 1024.0 * 1.3
|
||||
|
||||
@@ -35,7 +35,9 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = '172.16.1.97'
|
||||
HOST = os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('set GF_HOST to the machine IP address')
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
RESULTS = os.path.join(HERE, os.environ.get('FM_RESULTS', 'flow_matrix_results.json'))
|
||||
|
||||
|
||||
@@ -37,11 +37,14 @@ Runs both flow and no-flow cases from a comparable loop state and
|
||||
prints the differential separation. Aborts if downstream passes 45 C.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = '172.16.1.97'
|
||||
HOST = os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('set GF_HOST to the machine IP address')
|
||||
F = 1024.0 * 1.3
|
||||
RD, BETA = 10000.0, 3380.0
|
||||
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
|
||||
|
||||
@@ -9,13 +9,16 @@ cut-profile fans, logging bulk coolant temperature and every verdict.
|
||||
Usage: flow_sustained.py [minutes] (default 30)
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = '172.16.1.97'
|
||||
HOST = os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('set GF_HOST to the machine IP address')
|
||||
F = 1024.0 * 1.3
|
||||
RD, BETA = 10000.0, 3380.0
|
||||
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
|
||||
|
||||
@@ -20,7 +20,9 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = '172.16.1.97'
|
||||
HOST = os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('set GF_HOST to the machine IP address')
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
RESULTS = os.path.join(HERE, 'flow_warm_results.json')
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
"""Host-side verification of the laser pulse-stream emission.
|
||||
|
||||
Runs the native grblHAL_glowforge binary in null-sink mode with
|
||||
GFSINK_DUMP capturing the shipped byte stream, drives a small M4 laser
|
||||
job over TCP, then checks the dump against the kernel feeder contract:
|
||||
GFSINK_DUMP capturing the shipped byte stream, drives small laser jobs
|
||||
over TCP, then checks the dumps against the kernel feeder contract:
|
||||
|
||||
1. a power byte (bit 7) leads the stream, before any tick byte
|
||||
2. no two consecutive power bytes (the SDMA script drops the second)
|
||||
@@ -13,6 +13,15 @@ job over TCP, then checks the dump against the kernel feeder contract:
|
||||
the G0 return, none at the tail
|
||||
6. step accounting survives the insertions: X returns to net zero and
|
||||
peaks at the programmed 10 mm
|
||||
7. termination: every stream ends with FIRE clear, including an M3
|
||||
(constant-power) job whose core never issues a laser-off update -
|
||||
the stream must never lean on the kernel's end-of-data backstop
|
||||
8. no FIRE bit ever rides a zero-step gap: a stepless run of stream
|
||||
bytes carrying FIRE longer than any legitimate between-step
|
||||
interval is a stationary dwell burn
|
||||
9. rules 7-8 hold across rapid cycle stop/start churn (planner-starve
|
||||
shaped jobs), where the FIRE state of the previous cycle must not
|
||||
leak into the idle-gap pad bytes
|
||||
|
||||
Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge)
|
||||
"""
|
||||
@@ -24,13 +33,24 @@ import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build-native/grblHAL_glowforge")
|
||||
PORT = 2399
|
||||
STEPS_PER_MM = 53.333
|
||||
|
||||
JOB = [
|
||||
# Longest stepless run allowed to carry FIRE, in machine ticks. The
|
||||
# slowest legitimate between-step interval in these jobs is the first
|
||||
# step of an accel-from-rest: sqrt(2 * (1/53.333 mm) / 700 mm/s^2)
|
||||
# = 7.3 ms = ~206 ticks at 28160 Hz. 500 gives >2x margin while staying
|
||||
# far below any idle-gap pad run.
|
||||
FIRE_GAP_LIMIT_TICKS = 500
|
||||
|
||||
WAIT_IDLE = ("wait_idle",)
|
||||
|
||||
# Session A: the original M4 dynamic-power job (rules 1-6).
|
||||
JOB_M4 = [
|
||||
"M4 S0",
|
||||
"G1 X5 F600 S500",
|
||||
"G1 X10 S1000",
|
||||
@@ -38,6 +58,29 @@ JOB = [
|
||||
"M5",
|
||||
]
|
||||
|
||||
# Session B: M3 constant power to the end of the stream. The core never
|
||||
# issues a laser-off update for M3, so the stream engine itself must
|
||||
# terminate the cycle dark (rule 7).
|
||||
JOB_M3_TERM = [
|
||||
"M3 S1000",
|
||||
"G1 X5 F600",
|
||||
WAIT_IDLE,
|
||||
("sleep", 1.0),
|
||||
"M5",
|
||||
]
|
||||
|
||||
# Session C: rapid cycle churn - many tiny laser moves sent one at a
|
||||
# time with small gaps, so cycles stop and restart the way a planner
|
||||
# starve produces them (rules 8-9).
|
||||
JOB_CHURN = []
|
||||
for _ in range(30):
|
||||
JOB_CHURN.append("G1 X0.2 F600 S800")
|
||||
JOB_CHURN.append(("sleep", 0.02))
|
||||
JOB_CHURN.append("G1 X0 S800")
|
||||
JOB_CHURN.append(("sleep", 0.02))
|
||||
JOB_CHURN.insert(0, "M4 S0")
|
||||
JOB_CHURN.append("M5")
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print("FAIL: %s" % msg)
|
||||
@@ -76,12 +119,43 @@ def read_avail(sock, log, timeout, until=None):
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
def wait_idle(sock, log):
|
||||
for _ in range(100):
|
||||
sock.sendall(b"?")
|
||||
read_avail(sock, log, 0.3)
|
||||
if re.search(r"<Idle", "".join(log[-3:])):
|
||||
return
|
||||
time.sleep(0.2)
|
||||
fail("controller never returned to Idle")
|
||||
|
||||
|
||||
def publish_verdicts(path, stop):
|
||||
"""Publish a fresh, clean cooling verdict every 0.5 s (the arm flow
|
||||
refuses without one; freshness window is 2 s). Same-host monotonic
|
||||
clock, atomic rename so the reader never sees a torn file."""
|
||||
while not stop.is_set():
|
||||
body = ('{"ts_mono":%.3f,"fire_ok":true,"hold":false,'
|
||||
'"resume_ok":true,"reason":""}'
|
||||
% time.clock_gettime(time.CLOCK_MONOTONIC))
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(body)
|
||||
os.replace(tmp, path)
|
||||
stop.wait(0.5)
|
||||
|
||||
|
||||
def run_session(name, steps):
|
||||
"""Launch the controller, run the job steps, return the dump bytes."""
|
||||
workdir = tempfile.mkdtemp(prefix="laser-test-")
|
||||
dump = os.path.join(workdir, "stream.bin")
|
||||
env = dict(os.environ, GFSINK_DUMP=dump)
|
||||
verdict = os.path.join(workdir, "cooling.state")
|
||||
env = dict(os.environ, GFSINK_DUMP=dump, GF_VERDICT_FILE=verdict)
|
||||
env.pop("GFSINK", None)
|
||||
|
||||
stop = threading.Event()
|
||||
pub = threading.Thread(target=publish_verdicts, args=(verdict, stop), daemon=True)
|
||||
pub.start()
|
||||
|
||||
proc = subprocess.Popen([BIN, "-p", str(PORT)], cwd=workdir, env=env,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
||||
try:
|
||||
@@ -93,31 +167,27 @@ def main():
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
if sock is None:
|
||||
fail("cannot connect to the controller")
|
||||
fail("[%s] cannot connect to the controller" % name)
|
||||
|
||||
log = []
|
||||
read_avail(sock, log, 0.5) # banner / hello
|
||||
|
||||
for line in JOB:
|
||||
send_line(sock, line, log)
|
||||
for step in steps:
|
||||
if step == WAIT_IDLE:
|
||||
wait_idle(sock, log)
|
||||
elif isinstance(step, tuple) and step[0] == "sleep":
|
||||
time.sleep(step[1])
|
||||
else:
|
||||
send_line(sock, step, log)
|
||||
|
||||
# Wait for the motion to play out on the wall clock (the shipper
|
||||
# is wall-paced), then for the Idle report.
|
||||
idle = False
|
||||
for _ in range(100):
|
||||
sock.sendall(b"?")
|
||||
read_avail(sock, log, 0.3)
|
||||
if re.search(r"<Idle", "".join(log[-3:])):
|
||||
idle = True
|
||||
break
|
||||
time.sleep(0.2)
|
||||
if not idle:
|
||||
fail("controller never returned to Idle")
|
||||
wait_idle(sock, log)
|
||||
time.sleep(1.0) # let the shipper drain the tail
|
||||
|
||||
text = "".join(log)
|
||||
if "laser armed" not in text:
|
||||
fail("no 'laser armed' message (arming flow did not run)")
|
||||
fail("[%s] no 'laser armed' message (arming flow did not run)" % name)
|
||||
|
||||
sock.close()
|
||||
finally:
|
||||
@@ -126,12 +196,51 @@ def main():
|
||||
proc.wait(5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
stop.set()
|
||||
pub.join(2)
|
||||
|
||||
data = open(dump, "rb").read()
|
||||
if not data:
|
||||
fail("empty stream dump")
|
||||
fail("[%s] empty stream dump" % name)
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
return data
|
||||
|
||||
# --- contract checks -------------------------------------------------
|
||||
|
||||
def tick_bytes(data):
|
||||
"""The stream with power bytes stripped (tick bytes only)."""
|
||||
return bytes(b for b in data if not b & 0x80)
|
||||
|
||||
|
||||
def check_fire_gaps(name, data):
|
||||
"""Rule 8: no stepless run carrying FIRE longer than the limit."""
|
||||
run = 0
|
||||
worst = 0
|
||||
for tick, b in enumerate(tick_bytes(data)):
|
||||
if b & 0x10 and not b & 0x25: # FIRE, no X/Y/Z step
|
||||
run += 1
|
||||
worst = max(worst, run)
|
||||
if run >= FIRE_GAP_LIMIT_TICKS:
|
||||
fail("[%s] FIRE carried across a %d-tick zero-step gap "
|
||||
"ending at tick %d (stationary dwell burn)"
|
||||
% (name, run, tick))
|
||||
else:
|
||||
run = 0
|
||||
return worst
|
||||
|
||||
|
||||
def check_termination(name, data):
|
||||
"""Rule 7: the stream's final tick byte must carry FIRE clear."""
|
||||
ticks = tick_bytes(data)
|
||||
if not ticks:
|
||||
fail("[%s] no tick bytes in the stream" % name)
|
||||
if ticks[-1] & 0x10:
|
||||
fail("[%s] stream ends with FIRE set (0x%02x) - termination "
|
||||
"rule violated, relies on the end-of-data backstop"
|
||||
% (name, ticks[-1]))
|
||||
|
||||
|
||||
def check_m4_job(data):
|
||||
"""Rules 1-6 on the original M4 job."""
|
||||
if not data[0] & 0x80:
|
||||
fail("stream does not lead with a power byte (first byte 0x%02x)" % data[0])
|
||||
|
||||
@@ -184,10 +293,8 @@ def main():
|
||||
last_fire = fire_ticks[-1][0]
|
||||
tail_steps = 0
|
||||
tick = 0
|
||||
prev_power = False
|
||||
for b in data:
|
||||
if b & 0x80:
|
||||
prev_power = True
|
||||
continue
|
||||
if tick > last_fire and b & 0x01:
|
||||
tail_steps += 1
|
||||
@@ -195,11 +302,43 @@ def main():
|
||||
if tail_steps < 400:
|
||||
fail("only %d fire-free steps after the last FIRE bit - G0 return not dark" % tail_steps)
|
||||
|
||||
print("PASS: %d bytes, %d power bytes, %d fire ticks, powers %s, "
|
||||
"X peak %d steps net 0, %d dark return steps"
|
||||
return fire_ticks, powers, x_max, tail_steps
|
||||
|
||||
|
||||
def count_fire(data):
|
||||
return sum(1 for b in tick_bytes(data) if b & 0x10)
|
||||
|
||||
|
||||
def main():
|
||||
# --- session A: M4 dynamic power, rules 1-6 + 7-8 -------------------
|
||||
data = run_session("m4", JOB_M4)
|
||||
fire_ticks, powers, x_max, tail_steps = check_m4_job(data)
|
||||
check_termination("m4", data)
|
||||
gap_a = check_fire_gaps("m4", data)
|
||||
print("PASS [m4]: %d bytes, %d power bytes, %d fire ticks, powers %s, "
|
||||
"X peak %d steps net 0, %d dark return steps, max fire gap %d"
|
||||
% (len(data), sum(1 for b in data if b & 0x80), len(fire_ticks),
|
||||
powers, x_max, tail_steps))
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
powers, x_max, tail_steps, gap_a))
|
||||
|
||||
# --- session B: M3 constant power to stream end, rule 7 -------------
|
||||
data = run_session("m3-term", JOB_M3_TERM)
|
||||
if not count_fire(data):
|
||||
fail("[m3-term] no FIRE bits in the stream")
|
||||
check_termination("m3-term", data)
|
||||
gap_b = check_fire_gaps("m3-term", data)
|
||||
print("PASS [m3-term]: %d bytes, %d fire ticks end dark, max fire gap %d"
|
||||
% (len(data), count_fire(data), gap_b))
|
||||
|
||||
# --- session C: cycle churn, rules 8-9 ------------------------------
|
||||
data = run_session("churn", JOB_CHURN)
|
||||
if not count_fire(data):
|
||||
fail("[churn] no FIRE bits in the stream")
|
||||
check_termination("churn", data)
|
||||
gap_c = check_fire_gaps("churn", data)
|
||||
print("PASS [churn]: %d bytes, %d fire ticks, max fire gap %d"
|
||||
% (len(data), count_fire(data), gap_c))
|
||||
|
||||
print("PASS: all stream emission rules hold")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -25,7 +25,9 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = '172.16.1.97'
|
||||
HOST = os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('set GF_HOST to the machine IP address')
|
||||
STORE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp_calibration.json')
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user