mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
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:
@@ -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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user