laser_stream_test: the analog copies, the in-run levels and the curve run leave

The analog dose model runs on no machine; the density sessions beside
these prove the same rules on the shipped model.

- Sessions J and K (M5 at idle, the next job's first cut) run under
  density only.
- Session C (churn, analog) goes; its pad budget (no runaway pad across
  cycle churn) moves onto session G, density-churn, which already held
  FIRE, termination and the gaps on the same job.
- Session H (a level change inside a run costs no byte) goes: every
  density power byte is already held at full duty by check_density;
  the byte count was a cost, not an energy rule.
- Rule 19's end-to-end curve run goes: laser_arm_test.c case P pins the
  curve's mapping. The rolloff sessions keep the curve in force.
- The constants only those sessions read go (CURVE_DEFAULT,
  curve_density_for, CURVE_S, JOB_CURVE, JOB_LEVELS); the header's rules
  11, 16 and 19 say what the harness now holds.

Proof: laser_stream_test passes whole on a null-sink build of the
grblHAL-glowforge working tree under Linux (23 sessions). pyflakes is
clean.

Acceptance: none. The harness runs in grblHAL-glowforge's CI.
This commit is contained in:
ScottW514
2026-10-13 19:10:00 -04:00
parent 09cf9242a9
commit 8536ae7076
+25 -125
View File
@@ -35,8 +35,7 @@ over TCP, then checks the dumps against the kernel feeder contract:
fire ticks divide evenly across the rungs, which is what fails if a fire ticks divide evenly across the rungs, which is what fails if a
rung's opening ticks carry the previous rung's duty rung's opening ticks carry the previous rung's duty
11. under the density dose model no level ever reaches PWMSAR: every 11. under the density dose model no level ever reaches PWMSAR: every
power byte carries full duty (one still leads each kernel run), and power byte carries full duty (one still leads each kernel run)
a level change inside a run costs no stream byte at all
12. density matches the level the core commanded, rung by rung, and no 12. density matches the level the core commanded, rung by rung, and no
burst is longer than the base period burst is longer than the base period
13. the model is a mask and never a source: run the same job under both 13. the model is a mask and never a source: run the same job under both
@@ -54,7 +53,7 @@ over TCP, then checks the dumps against the kernel feeder contract:
16. and the off transition survives the same way: an M5 executed with 16. and the off transition survives the same way: an M5 executed with
the planner drained and the kernel run over must darken the rapids the planner drained and the kernel run over must darken the rapids
that follow it, and a bare G0 sent with the spindle off must ship that follow it, and a bare G0 sent with the spindle off must ship
dark, under both dose models - the stream's wanted fire state is dark under the density dose model - the stream's wanted fire state is
the only thing those moves consult, and a stale true there lights the only thing those moves consult, and a stale true there lights
the next run at the last level (full duty under density) the next run at the last level (full duty under density)
17. and a job's first cut at the level the previous job ended at 17. and a job's first cut at the level the previous job ended at
@@ -76,12 +75,10 @@ over TCP, then checks the dumps against the kernel feeder contract:
read with $$ on the fresh controller and again after the arm, $31 read with $$ on the fresh controller and again after the arm, $31
is 0 (the floor, not $31, sets the bottom of the S range) and $35 is 0 (the floor, not $31, sets the bottom of the S range) and $35
is the board's floor, landing at PWMSAR_FLOOR_MIN or above is the board's floor, landing at PWMSAR_FLOOR_MIN or above
19. the dose curve bends S onto the density that delivers the 19. the dose curve's mapping of S onto delivered light is
commanded light fraction: with the bench-default curve in force a laser_arm_test.c case P's; every session here but the rolloff ones
ladder of S rungs renders the curve's densities (half light lands runs with laser_dose_curve = off so its S-to-level arithmetic stays
near 80 percent density), monotonic, floored and ceiled by exact
$35/$36; every other session runs with laser_dose_curve = off so
its S-to-level arithmetic stays exact
20. the corner rolloff starves the slow spots: under M4 with the curve 20. the corner rolloff starves the slow spots: under M4 with the curve
in force, the accelerate-in head of a line renders less density at in force, the accelerate-in head of a line renders less density at
the default gamma of 2 than at gamma 1, and the cruise middle the default gamma of 2 than at gamma 1, and the cruise middle
@@ -303,35 +300,6 @@ DENSITY_CONF_FLOORED = ("laser_power_model = density\n"
# The shipped default: the bench curve in force (no keys at all). # The shipped default: the bench curve in force (no keys at all).
DENSITY_CONF_CURVED = "laser_power_model = density\n" + DENSITY_CONF_BASE DENSITY_CONF_CURVED = "laser_power_model = density\n" + DENSITY_CONF_BASE
# The compiled bench-default curve (glowforge_laser.c curve_default),
# mirrored here the way the floor is: changing it changes rule 19.
CURVE_DEFAULT = ((10.0, 0.5), (20.0, 2.0), (30.0, 7.0), (45.0, 21.0),
(60.0, 37.0), (80.0, 50.0), (100.0, 100.0))
def curve_density_for(s_val):
"""The density fraction the bench-default curve maps an S onto,
before the $35/$36 clamp (mirrors curve_apply)."""
l = s_val / RPM_MAX * 100.0
pts = CURVE_DEFAULT
if l <= pts[0][1]:
return pts[0][0] * (l / pts[0][1]) / 100.0
i = 1
while i < len(pts) - 1 and l > pts[i][1]:
i += 1
d0, l0 = pts[i - 1]
d1, l1 = pts[i]
f = min(1.0, (l - l0) / (l1 - l0))
return (d0 + f * (d1 - d0)) / 100.0
CURVE_S = (100, 300, 500, 800, 1000)
JOB_CURVE = ["G91", "G21", "M3"]
for _s in CURVE_S:
JOB_CURVE.append("S%d" % _s)
JOB_CURVE.append("G1 X%g F300" % (LADDER_MM if _s % 2 == 0 else LADDER_MM))
JOB_CURVE.append("G0 Y1")
JOB_CURVE.append("M5")
DENSITY_LEVEL = tuple(int(x * PWM_PERIOD / RPM_MAX) for x in LADDER_S) DENSITY_LEVEL = tuple(int(x * PWM_PERIOD / RPM_MAX) for x in LADDER_S)
# A $35 typed ahead of the job: rule 18 says the arm overwrites it. # A $35 typed ahead of the job: rule 18 says the arm overwrites it.
JOB_DENSITY = ["$35=0"] + JOB_LADDER JOB_DENSITY = ["$35=0"] + JOB_LADDER
@@ -343,17 +311,6 @@ def duty_for_floor(s, floor_pct):
return int(s * (PWM_PERIOD - lo) / RPM_MAX) + lo return int(s * (PWM_PERIOD - lo) / RPM_MAX) + lo
# 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 # 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 # 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 # nothing streaming. The state has no event to ride and must be
@@ -1016,29 +973,6 @@ def main():
% (len(data), sum(1 for b in data if b & 0x80), len(fire_ticks), % (len(data), sum(1 for b in data if b & 0x80), len(fire_ticks),
powers, x_max, tail_steps, gap_a)) powers, x_max, tail_steps, gap_a))
# --- session C: cycle churn, rules 8-9 ------------------------------
data = run_session("churn", JOB_CHURN, conf=ANALOG_CONF)
if not count_fire(data):
fail("[churn] no FIRE bits in the stream")
check_termination("churn", data)
gap_c = check_fire_gaps("churn", data)
# Rule 17: the churn stream carries no runaway pad. Bytes are the time
# axis, one per machine tick, so the stream's length IS how long the
# machine plays it. A cycle that resumes while the kernel still drains
# re-bases production onto the wall cursor; if the producer's lead lets
# production stay ahead of that cursor across the gap, the re-base is
# skipped and the overshoot is inherited by every cycle after it. That
# is what GFSINK_LEAD_MS_MAX bounds, and this is what catches it.
churn_s = len(data) / MACHINE_TICK_HZ
if churn_s > CHURN_BUDGET_S:
fail("[churn] stream is %.0f ms of playout, over the %.0f ms budget: "
"the cycle re-base is leaving pad behind"
% (churn_s * 1e3, CHURN_BUDGET_S * 1e3))
print("PASS [churn]: %d bytes, %d fire ticks, max fire gap %d, "
"%.0f ms of playout (budget %.0f)"
% (len(data), count_fire(data), gap_c, churn_s * 1e3,
CHURN_BUDGET_S * 1e3))
# --- session D: power ladder, rule 10 ------------------------------- # --- session D: power ladder, rule 10 -------------------------------
data = run_session("ladder", JOB_LADDER, conf=ANALOG_CONF) data = run_session("ladder", JOB_LADDER, conf=ANALOG_CONF)
counts = check_power_ladder("ladder", data, LADDER_DUTY) counts = check_power_ladder("ladder", data, LADDER_DUTY)
@@ -1087,25 +1021,21 @@ def main():
fail("[density-churn] no FIRE bits in the stream") fail("[density-churn] no FIRE bits in the stream")
check_termination("density-churn", ch) check_termination("density-churn", ch)
gap_e = check_fire_gaps("density-churn", ch) gap_e = check_fire_gaps("density-churn", ch)
print("PASS [density-churn]: %d bytes, %d fire ticks, max fire gap %d" # Rule 17: the churn stream carries no runaway pad. Bytes are the time
% (len(ch), count_fire(ch), gap_e)) # axis, one per machine tick, so the stream's length IS how long the
# machine plays it. A cycle that resumes while the kernel still drains
# --- session H: a level change inside a run costs no byte ----------- # re-bases production onto the wall cursor; if the producer's lead lets
lv_a = run_session("levels-analog", JOB_LEVELS, conf=ANALOG_CONF) # production stay ahead of that cursor across the gap, the re-base is
lv_d = run_session("levels-density", JOB_LEVELS, conf=DENSITY_CONF) # skipped and the overshoot is inherited by every cycle after it. That
pa = [b & 0x7F for b in lv_a if b & 0x80] # is what GFSINK_LEAD_MS_MAX bounds, and this is what catches it.
pd = [b & 0x7F for b in lv_d if b & 0x80] churn_s = len(ch) / MACHINE_TICK_HZ
if len([d for d in set(pa) if d]) < 3: if churn_s > CHURN_BUDGET_S:
fail("[levels] the analog run shipped duties %s: fewer than the three " fail("[density-churn] stream is %.0f ms of playout, over the %.0f ms budget: "
"commanded levels, so the job is not exercising in-run changes" "the cycle re-base is leaving pad behind"
% sorted(set(pa))) % (churn_s * 1e3, CHURN_BUDGET_S * 1e3))
if set(pd) != {PWM_PERIOD}: print("PASS [density-churn]: %d bytes, %d fire ticks, max fire gap %d, "
fail("[levels] density shipped a level as duty: %s" % sorted(set(pd))) "%.0f ms of playout (budget %.0f)"
if len(pd) >= len(pa): % (len(ch), count_fire(ch), gap_e, churn_s * 1e3, CHURN_BUDGET_S * 1e3))
fail("[levels] density shipped %d power bytes against analog's %d - "
"the level changes are still costing stream bytes" % (len(pd), len(pa)))
print("PASS [levels]: analog %d power bytes %s, density %d at full duty"
% (len(pa), sorted(set(pa)), len(pd)))
# --- session I: a level set while idle still cuts (rule 14) --------- # --- session I: a level set while idle still cuts (rule 14) ---------
# Under the shipped density model the level rides the FIRE bits at full # Under the shipped density model the level rides the FIRE bits at full
@@ -1119,9 +1049,8 @@ def main():
% [d for _l, d in got]) % [d for _l, d in got])
# --- session J: M5 executed while idle darkens the next run (rule 16) --- # --- session J: M5 executed while idle darkens the next run (rule 16) ---
for model, conf in (("analog", ANALOG_CONF), ("density", DENSITY_CONF)): name = "m5-idle-density"
name = "m5-idle-" + model data = run_session(name, JOB_M5_IDLE, conf=DENSITY_CONF)
data = run_session(name, JOB_M5_IDLE, conf=conf)
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS, spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
"the two G1 moves: FIRE rode a rapid after M5, " "the two G1 moves: FIRE rode a rapid after M5, "
"or the bare G0 sent with the spindle off") "or the bare G0 sent with the spindle off")
@@ -1131,9 +1060,8 @@ def main():
% (name, [s1 - s0 for s0, s1 in spans])) % (name, [s1 - s0 for s0, s1 in spans]))
# --- session K: the next job, at the same level, fires (rule 17) --- # --- session K: the next job, at the same level, fires (rule 17) ---
for model, conf in (("analog", ANALOG_CONF), ("density", DENSITY_CONF)): name = "next-job-density"
name = "next-job-" + model data = run_session(name, JOB_NEXT + JOB_NEXT, conf=DENSITY_CONF)
data = run_session(name, JOB_NEXT + JOB_NEXT, conf=conf)
text = run_session.text text = run_session.text
if text.count("laser armed") != 2 or text.count("laser disarmed") != 2: if text.count("laser armed") != 2 or text.count("laser disarmed") != 2:
fail("[%s] expected two armed windows closed by M2 (armed %d, " fail("[%s] expected two armed windows closed by M2 (armed %d, "
@@ -1216,34 +1144,6 @@ def main():
"default 2; cruise %.3f alike" % (dens_head["g1"], dens_head["g2"], "default 2; cruise %.3f alike" % (dens_head["g1"], dens_head["g2"],
dens_mid["g1"])) dens_mid["g1"]))
# --- rule 19: the dose curve bends S onto delivered light -----------
cur = run_session("curve", JOB_CURVE, conf=DENSITY_CONF_CURVED)
if "curve bench-default" not in run_session.text:
fail("[curve] the arm does not name the bench-default curve (text: %r)"
% run_session.text[-300:])
floor_frac = PWM_MIN / float(PWM_PERIOD)
expect = []
for s_val in CURVE_S:
d = curve_density_for(s_val)
expect.append(min(1.0, max(d, floor_frac)))
ticks = tick_bytes(cur)
spans = fire_spans(ticks)
if len(spans) != len(CURVE_S):
fail("[curve] %d fire spans, expected %d" % (len(spans), len(CURVE_S)))
got = []
for (a, b) in spans:
seg = ticks[a:b]
got.append(sum(1 for t in seg if t & 0x10) / float(len(seg)))
for g, w, s_val in zip(got, expect, CURVE_S):
if abs(g - w) > max(0.012, w * 0.06):
fail("[curve] S%d rendered density %.4f, expected %.4f through the "
"bench-default curve" % (s_val, g, w))
if not all(b > a for a, b in zip(got, got[1:])):
fail("[curve] densities not monotonic: %s" % [round(g, 4) for g in got])
check_termination("curve", cur)
print("PASS [curve]: S %s -> densities %s through the bench-default curve "
"(floored at %.3f)" % (list(CURVE_S), [round(g, 3) for g in got], floor_frac))
# --- rule 21: a feed hold leaves no dark ground, in either mode ----- # --- rule 21: a feed hold leaves no dark ground, in either mode -----
# One long line at 100 mm/s, held mid-move and resumed. The planned # One long line at 100 mm/s, held mid-move and resumed. The planned
# deceleration runs lit (M4 velocity-scaled, M3 constant), the # deceleration runs lit (M4 velocity-scaled, M3 constant), the