Prove the density-only model; record the rasters and the decision

The stream harness keeps the analog rendering as the host-test
conservatism reference (rule 13's mask, the duty ladders) and drops the
M101 switch sessions; rule 18 stays as the derived-floor proof, now
satisfied from boot by the precompute. The lifecycle harness's
state-files scenario asserts the derived floor is in $$ before any arm.
The catalog's laser.power-model-switch goes and laser.power-floor reads
the one floor key with no M-code needed. The mswitch drill goes; the
m4corner drill becomes a single density pass; the dpatch drill returns
to density only.

The CAMPAIGN-LOG records the first rasters (254 and 508 DPI grayscale
wedges: tonality held to ~14 pulse slots per pixel, no dither artifact,
one benign stale-verdict suppression under CPU starvation) and the
decision that ends the analog mode - the strike transient fires a spot
at every beam-on, and the finish comparison found no advantage. BRINGUP,
LIGHTBURN, MOTION and SAFETY describe the density-only present.
This commit is contained in:
ScottW514
2026-08-30 19:41:50 -04:00
parent 67da1ab9bc
commit d612c0099a
10 changed files with 355 additions and 615 deletions
+13 -303
View File
@@ -58,21 +58,16 @@ over TCP, then checks the dumps against the kernel feeder contract:
the M3 that opens the next job is the only thing that can light its
first move - set_state must push the whole state, fire included,
never the duty alone
18. the floor is derived, never typed: $35 is loaded from the selected
model's floor key (laser_floor_density / laser_floor_analog) at
the arm, so a $35 typed by the sender is overwritten - the ladder
renders through the key's floor, and the arm report names the
model and the floor in force
19. M101 switches the model at a boundary and nowhere else: with the
spindle off and the controller idle, the rendering changes exactly
at the switch (analog duties before, pinned full duty and dithered
FIRE after, or the reverse) and there is no torn transition - no
window of continuous FIRE at full duty anywhere across it
20. M101 is refused with the spindle on (error 253, the reason
reported), and the stream is unchanged by the refusal
21. a switch is program-scoped: M2 reverts it to the boot default and
the next job renders under the default, while M101 ... Q1 sticks
across M2
18. the floor is derived, never typed: $35 is loaded from the floor
key at every precompute, so a $35 typed by the sender is
overwritten - the ladder renders through the key's floor, and the
arm report names the model and the floor in force
The analog sessions select the reference mode through the config; on
hardware the controller ignores it (density is the only product model -
analog's strike transient puts a spot at every beam-on), but the
null-sink build honors it so these rules can hold the density model to
account against the continuous rendering (rule 13's mask above all).
Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge)
"""
@@ -198,149 +193,19 @@ def duty_for_floor(s, floor_pct):
return int(s * (PWM_PERIOD - lo) / RPM_MAX) + lo
# Sessions L-N: the M101 dose-model switch. One cut, the spindle off,
# the switch, a second cut at the same S. Each cut is a kernel run of
# its own (the planner drains at WAIT_IDLE), so the switch lands between
# runs, which is the only place it is allowed to.
SWITCH_S = 500
SWITCH_MM = 5.0
SWITCH_FEED = 600
SWITCH_TICKS = SWITCH_MM / (SWITCH_FEED / 60.0) * 28160
def job_switch(mcode):
return [
"G91", "G21",
"M3 S%d" % SWITCH_S,
"G1 X%g F%d" % (SWITCH_MM, SWITCH_FEED),
WAIT_IDLE, ("sleep", 0.5),
"M5",
mcode,
"M3 S%d" % SWITCH_S,
"G1 X%g" % -SWITCH_MM,
WAIT_IDLE, ("sleep", 0.5),
"M5",
]
JOB_SWITCH_A2D = job_switch("M101 P1")
JOB_SWITCH_D2A = job_switch("M101 P0")
# The refusal: the switch arrives with the spindle still on. The stream
# must be what the job without the M101 would have produced.
JOB_SWITCH_REFUSED = [
"G91", "G21",
"M3 S%d" % SWITCH_S,
"G1 X%g F%d" % (SWITCH_MM, SWITCH_FEED),
WAIT_IDLE, ("sleep", 0.5),
("expect_error", "M101 P1"),
"G1 X%g" % -SWITCH_MM,
WAIT_IDLE, ("sleep", 0.5),
"M5",
]
def job_revert(mcode):
"""Two programs: the first switches and ends in M2, the second cuts
at the same S with no switch of its own."""
return [
"G91", "G21",
mcode,
"M3 S%d" % SWITCH_S,
"G1 X%g F%d" % (SWITCH_MM, SWITCH_FEED),
WAIT_IDLE, ("sleep", 0.5),
"M5", "G90", "M2", ("sleep", 1.0),
"G91",
"M3 S%d" % SWITCH_S,
"G1 X%g" % -SWITCH_MM,
WAIT_IDLE, ("sleep", 0.5),
"M5",
]
JOB_SWITCH_REVERT = job_revert("M101 P0")
JOB_SWITCH_STICKY = job_revert("M101 P0 Q1")
# Session H: three levels inside one kernel run. The moves are short and
# fast so the planner never drains, and each carries its own S word, so
# the level changes land mid-run. Analog pays a power byte per level;
# density pays none, because the level rides the FIRE bits.
JOB_LEVELS = ["G91", "G21", "M3"]
for _s in (100, 300, 600):
for _ in range(20):
JOB_LEVELS.append("G1 X0.5 F3000 S%d" % _s)
JOB_LEVELS.append("M5")
# Session I: the levels arrive on their own lines, and the moves are long
# enough that the planner drains between them, so each S is executed with
# nothing streaming. The state has no event to ride and must be
# re-asserted at the next run's first byte.
IDLE_S_LEVELS = (100, 300, 600)
IDLE_S_MM = 5.0
IDLE_S_FEED = 300
JOB_IDLE_S = ["G91", "G21", "M3"]
for _i, _s in enumerate(IDLE_S_LEVELS):
JOB_IDLE_S.append("S%d" % _s)
JOB_IDLE_S.append("G1 X%g F%d" % (IDLE_S_MM if _i % 2 == 0 else -IDLE_S_MM,
IDLE_S_FEED))
JOB_IDLE_S.append("M5")
# Session J: the bench ladder's shape. M5 executes with the planner
# drained and the kernel run over, and the rapids that follow start a
# new run; the core issues no per-segment laser update for moves made
# with the spindle off, so the stream's wanted state is all that decides
# whether those rapids fire. A bare G0 with no M3 since the M5 is the
# same case one step further.
M5_IDLE_MM = 5.0
M5_IDLE_FEED = 600
M5_IDLE_TICKS = M5_IDLE_MM / (M5_IDLE_FEED / 60.0) * 28160
JOB_M5_IDLE = [
"G91", "G21",
"M3 S500",
"G1 X%g F%d" % (M5_IDLE_MM, M5_IDLE_FEED),
WAIT_IDLE, ("sleep", 0.5),
"M5", ("sleep", 0.5),
"G0 X%g" % -M5_IDLE_MM, "G0 Y1",
WAIT_IDLE,
"G0 X%g" % M5_IDLE_MM,
WAIT_IDLE,
"M3 S500",
"G1 X%g" % -M5_IDLE_MM,
WAIT_IDLE, ("sleep", 0.5),
"M5",
]
# Session K: two jobs in one controller process, the second at the level
# the first ended at. M2 leaves S modal and resets the motion mode to G1,
# so the next job's M3 executes at that S; the core records it and issues
# no per-segment update for a G1 at the same level, so the set_state is
# the only thing that can light it. The parser starts in G0, which is why
# a process's FIRST job never shows this: its M3 runs at rpm 0.
JOB_NEXT = [
"G91", "G21", "M3", "S500",
"G1 X%g F%d" % (M5_IDLE_MM, M5_IDLE_FEED),
WAIT_IDLE, ("sleep", 0.5),
"M5", "G0 X%g" % -M5_IDLE_MM, "G0 Y1",
WAIT_IDLE, "G90", "M2", ("sleep", 1.0),
]
def fail(msg):
print("FAIL: %s" % msg)
sys.exit(1)
def send_line(sock, line, log, expect_error=False):
def send_line(sock, line, log):
sock.sendall((line + "\n").encode())
while True:
r = read_avail(sock, log, 5.0, until=("ok", "error"))
if r is None:
fail("no ok/error for %r" % line)
if (r == "error") != expect_error:
fail("%s response to %r" % (r, line))
if r == "error":
fail("error response to %r" % line)
return
@@ -440,11 +305,6 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
wait_idle(sock, log)
elif isinstance(step, tuple) and step[0] == "sleep":
time.sleep(step[1])
elif isinstance(step, tuple) and step[0] == "expect_error":
send_line(sock, step[1], log, expect_error=True)
# The core skips every G-code line after an error until
# the sender resyncs with an empty line (or a $ command).
send_line(sock, "", log)
else:
send_line(sock, step, log)
@@ -893,156 +753,6 @@ def main():
"ladder renders through the %g %% floor key, levels %s"
% (PWM_MIN_PCT, list(expect_levels)))
# --- sessions L: the switch, both directions (rule 19) --------------
def split_at_switch(data, marker):
"""Byte offset of the first power byte satisfying marker(duty)."""
for i, b in enumerate(data):
if b & 0x80 and marker(b & 0x7F):
return i
return None
def longest_burst(ticks):
run = worst = 0
for t in ticks:
run = run + 1 if t & 0x10 else 0
worst = max(worst, run)
return worst
def fire_by_power(data):
"""Fire ticks per power byte in force: the duties FIRE rode."""
cur, out = None, {}
for b in data:
if b & 0x80:
cur = b & 0x7F
elif b & 0x10:
out[cur] = out.get(cur, 0) + 1
return out
# Analog -> density. Before the switch: analog duties, continuous
# FIRE. After: full duty only, FIRE dithered at the level, bursts no
# longer than the base period.
a2d = run_session("switch-a2d", JOB_SWITCH_A2D, conf=ANALOG_CONF)
text = run_session.text
if "laser power model set for this program (density, floor %g %%)" % PWM_MIN_PCT not in text:
fail("[switch-a2d] the switch was not reported (text: %r)" % text[-400:])
cut = split_at_switch(a2d, lambda d: d == PWM_PERIOD)
if cut is None:
fail("[switch-a2d] no full-duty power byte after the switch: the density "
"model never took over")
before, after = a2d[:cut], a2d[cut:]
duty = duty_for(SWITCH_S)
# A run may lead with a dark duty-0 byte across the idle pads; what
# matters is the duty FIRE rides on each side of the switch.
if set(fire_by_power(before)) != {duty}:
fail("[switch-a2d] FIRE rode duties %s before the switch, expected only %d"
% (sorted(fire_by_power(before)), duty))
if set(b & 0x7F for b in after if b & 0x80) != {PWM_PERIOD}:
fail("[switch-a2d] duties after the switch %s: a level reached PWMSAR "
"under density" % sorted(set(b & 0x7F for b in after if b & 0x80)))
tb, ta = tick_bytes(before), tick_bytes(after)
sb, sa = fire_spans(tb), fire_spans(ta)
if len(sb) != 1 or len(sa) != 1:
fail("[switch-a2d] fire spans before/after the switch %s / %s, expected "
"one each" % (sb, sa))
if longest_burst(ta) > DENSITY_PERIOD:
fail("[switch-a2d] a %d-tick continuous FIRE burst at full duty after the "
"switch: a torn transition" % longest_burst(ta))
dens_after = sum(1 for t in ta[sa[0][0]:sa[0][1]] if t & 0x10) / float(sa[0][1] - sa[0][0])
if abs(dens_after - duty / float(PWM_PERIOD)) > 0.03:
fail("[switch-a2d] density after the switch %.3f, expected %.3f"
% (dens_after, duty / float(PWM_PERIOD)))
check_termination("switch-a2d", a2d)
print("PASS [switch-a2d]: analog duty %d before, full duty + density %.3f after, "
"longest burst %d <= %d" % (duty, dens_after, longest_burst(ta), DENSITY_PERIOD))
# Density -> analog. The analog default floor (16) applies after the
# switch, so the duty is the one that floor gives.
d2a = run_session("switch-d2a", JOB_SWITCH_D2A, conf=DENSITY_CONF_FLOORED)
text = run_session.text
if "laser power model set for this program (analog, floor %g %%)" % ANALOG_FLOOR_DEFAULT_PCT not in text:
fail("[switch-d2a] the switch was not reported with the analog floor (text: %r)"
% text[-400:])
duty_a = duty_for_floor(SWITCH_S, ANALOG_FLOOR_DEFAULT_PCT)
cut = split_at_switch(d2a, lambda d: d == duty_a)
if cut is None:
fail("[switch-d2a] no analog power byte (%d) after the switch" % duty_a)
before, after = d2a[:cut], d2a[cut:]
if set(fire_by_power(before)) != {PWM_PERIOD}:
fail("[switch-d2a] FIRE rode duties %s before the switch, expected full only"
% sorted(fire_by_power(before)))
if set(b & 0x7F for b in before if b & 0x80) - {PWM_PERIOD, 0}:
fail("[switch-d2a] a density level shipped as a duty across the switch: "
"power bytes %s" % sorted(set(b & 0x7F for b in before if b & 0x80)))
if set(fire_by_power(after)) != {duty_a}:
fail("[switch-d2a] FIRE rode duties %s after the switch, expected only %d"
% (sorted(fire_by_power(after)), duty_a))
ta = tick_bytes(after)
sa = fire_spans(ta)
if len(sa) != 1:
fail("[switch-d2a] fire spans after the switch %s, expected one" % sa)
got = sum(1 for t in ta[sa[0][0]:sa[0][1]] if t & 0x10) / float(sa[0][1] - sa[0][0])
if got < 0.999:
fail("[switch-d2a] FIRE after the switch is not continuous (%.3f): the "
"dither is still masking under analog" % got)
check_termination("switch-d2a", d2a)
print("PASS [switch-d2a]: full duty + dither before, continuous FIRE at duty %d "
"(floor %g) after" % (duty_a, ANALOG_FLOOR_DEFAULT_PCT))
# --- session M: refused with the spindle on (rule 20) ---------------
ref = run_session("switch-refused", JOB_SWITCH_REFUSED, conf=ANALOG_CONF)
text = run_session.text
if "M5 first" not in text or "error:253" not in text:
fail("[switch-refused] the refusal was not reported as error 253 with its "
"reason (text: %r)" % text[-400:])
if "laser power model set" in text:
fail("[switch-refused] the switch was applied despite the refusal")
if set(fire_by_power(ref)) != {duty}:
fail("[switch-refused] FIRE rode duties %s, expected only %d: the stream "
"changed under a refused switch" % (sorted(fire_by_power(ref)), duty))
check_cut_spans("switch-refused", tick_bytes(ref), 2, SWITCH_TICKS,
"the two G1 moves, both analog")
check_termination("switch-refused", ref)
print("PASS [switch-refused]: M101 with the spindle on -> error:253, stream "
"unchanged (duty %d throughout)" % duty)
# --- session N: program scope and Q1 (rule 21) ----------------------
rev = run_session("switch-revert", JOB_SWITCH_REVERT, conf=DENSITY_CONF_FLOORED)
text = run_session.text
if "laser power model reverted (density, floor %g %%)" % PWM_MIN_PCT not in text:
fail("[switch-revert] M2 did not report the revert (text: %r)" % text[-600:])
if text.count("laser armed (analog, floor %g %%)" % ANALOG_FLOOR_DEFAULT_PCT) != 1 or \
text.count("laser armed (density, floor %g %%)" % PWM_MIN_PCT) != 1:
fail("[switch-revert] expected one analog arm then one density arm "
"(text: %r)" % text[-600:])
cut = split_at_switch(rev, lambda d: d == PWM_PERIOD)
if cut is None:
fail("[switch-revert] the second job never rendered under density")
before, after = rev[:cut], rev[cut:]
if set(fire_by_power(before)) != {duty_a}:
fail("[switch-revert] first job FIRE rode duties %s, expected the analog %d"
% (sorted(fire_by_power(before)), duty_a))
if longest_burst(tick_bytes(after)) > DENSITY_PERIOD:
fail("[switch-revert] continuous FIRE at full duty in the second job: the "
"revert did not restore the dither")
check_termination("switch-revert", rev)
print("PASS [switch-revert]: job 1 analog at duty %d, M2 reverts, job 2 density"
% duty_a)
stk = run_session("switch-sticky", JOB_SWITCH_STICKY, conf=DENSITY_CONF_FLOORED)
text = run_session.text
if "reverted" in text:
fail("[switch-sticky] a Q1 switch was reverted at M2")
if text.count("laser armed (analog, floor %g %%)" % ANALOG_FLOOR_DEFAULT_PCT) != 2:
fail("[switch-sticky] expected both jobs to arm analog (text: %r)" % text[-600:])
if set(fire_by_power(stk)) != {duty_a}:
fail("[switch-sticky] FIRE rode duties %s, expected the analog %d in both jobs"
% (sorted(fire_by_power(stk)), duty_a))
check_cut_spans("switch-sticky", tick_bytes(stk), 2, SWITCH_TICKS,
"one G1 per job, both analog")
check_termination("switch-sticky", stk)
print("PASS [switch-sticky]: M101 P0 Q1 holds across M2; both jobs analog at "
"duty %d" % duty_a)
print("PASS: all stream emission rules hold")