mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
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:
@@ -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
|
||||
rung's opening ticks carry the previous rung's duty
|
||||
11. under the density dose model no level ever reaches PWMSAR: every
|
||||
power byte carries full duty (one still leads each kernel run), and
|
||||
a level change inside a run costs no stream byte at all
|
||||
power byte carries full duty (one still leads each kernel run)
|
||||
12. density matches the level the core commanded, rung by rung, and no
|
||||
burst is longer than the base period
|
||||
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
|
||||
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
|
||||
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 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
|
||||
@@ -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
|
||||
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
|
||||
19. the dose curve bends S onto the density that delivers the
|
||||
commanded light fraction: with the bench-default curve in force a
|
||||
ladder of S rungs renders the curve's densities (half light lands
|
||||
near 80 percent density), monotonic, floored and ceiled by
|
||||
$35/$36; every other session runs with laser_dose_curve = off so
|
||||
its S-to-level arithmetic stays exact
|
||||
19. the dose curve's mapping of S onto delivered light is
|
||||
laser_arm_test.c case P's; every session here but the rolloff ones
|
||||
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
|
||||
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
|
||||
@@ -303,35 +300,6 @@ DENSITY_CONF_FLOORED = ("laser_power_model = density\n"
|
||||
# The shipped default: the bench curve in force (no keys at all).
|
||||
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)
|
||||
# A $35 typed ahead of the job: rule 18 says the arm overwrites it.
|
||||
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
|
||||
|
||||
|
||||
# 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
|
||||
@@ -1016,29 +973,6 @@ def main():
|
||||
% (len(data), sum(1 for b in data if b & 0x80), len(fire_ticks),
|
||||
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 -------------------------------
|
||||
data = run_session("ladder", JOB_LADDER, conf=ANALOG_CONF)
|
||||
counts = check_power_ladder("ladder", data, LADDER_DUTY)
|
||||
@@ -1087,25 +1021,21 @@ def main():
|
||||
fail("[density-churn] no FIRE bits in the stream")
|
||||
check_termination("density-churn", ch)
|
||||
gap_e = check_fire_gaps("density-churn", ch)
|
||||
print("PASS [density-churn]: %d bytes, %d fire ticks, max fire gap %d"
|
||||
% (len(ch), count_fire(ch), gap_e))
|
||||
|
||||
# --- session H: a level change inside a run costs no byte -----------
|
||||
lv_a = run_session("levels-analog", JOB_LEVELS, conf=ANALOG_CONF)
|
||||
lv_d = run_session("levels-density", JOB_LEVELS, conf=DENSITY_CONF)
|
||||
pa = [b & 0x7F for b in lv_a if b & 0x80]
|
||||
pd = [b & 0x7F for b in lv_d if b & 0x80]
|
||||
if len([d for d in set(pa) if d]) < 3:
|
||||
fail("[levels] the analog run shipped duties %s: fewer than the three "
|
||||
"commanded levels, so the job is not exercising in-run changes"
|
||||
% sorted(set(pa)))
|
||||
if set(pd) != {PWM_PERIOD}:
|
||||
fail("[levels] density shipped a level as duty: %s" % sorted(set(pd)))
|
||||
if len(pd) >= len(pa):
|
||||
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)))
|
||||
# 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(ch) / MACHINE_TICK_HZ
|
||||
if churn_s > CHURN_BUDGET_S:
|
||||
fail("[density-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 [density-churn]: %d bytes, %d fire ticks, max fire gap %d, "
|
||||
"%.0f ms of playout (budget %.0f)"
|
||||
% (len(ch), count_fire(ch), gap_e, churn_s * 1e3, CHURN_BUDGET_S * 1e3))
|
||||
|
||||
# --- session I: a level set while idle still cuts (rule 14) ---------
|
||||
# Under the shipped density model the level rides the FIRE bits at full
|
||||
@@ -1119,33 +1049,31 @@ def main():
|
||||
% [d for _l, d in got])
|
||||
|
||||
# --- 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-" + model
|
||||
data = run_session(name, JOB_M5_IDLE, conf=conf)
|
||||
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
|
||||
"the two G1 moves: FIRE rode a rapid after M5, "
|
||||
"or the bare G0 sent with the spindle off")
|
||||
check_termination(name, data)
|
||||
print("PASS [%s]: M5 at idle -> the rapids after it and a bare G0 ship "
|
||||
"dark; 2 fire spans of %s ticks"
|
||||
% (name, [s1 - s0 for s0, s1 in spans]))
|
||||
name = "m5-idle-density"
|
||||
data = run_session(name, JOB_M5_IDLE, conf=DENSITY_CONF)
|
||||
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
|
||||
"the two G1 moves: FIRE rode a rapid after M5, "
|
||||
"or the bare G0 sent with the spindle off")
|
||||
check_termination(name, data)
|
||||
print("PASS [%s]: M5 at idle -> the rapids after it and a bare G0 ship "
|
||||
"dark; 2 fire spans of %s ticks"
|
||||
% (name, [s1 - s0 for s0, s1 in spans]))
|
||||
|
||||
# --- 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-" + model
|
||||
data = run_session(name, JOB_NEXT + JOB_NEXT, conf=conf)
|
||||
text = run_session.text
|
||||
if text.count("laser armed") != 2 or text.count("laser disarmed") != 2:
|
||||
fail("[%s] expected two armed windows closed by M2 (armed %d, "
|
||||
"disarmed %d)" % (name, text.count("laser armed"),
|
||||
text.count("laser disarmed")))
|
||||
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
|
||||
"one G1 per job: the second job's M3 at the "
|
||||
"first job's S lit nothing, or a rapid fired")
|
||||
check_termination(name, data)
|
||||
print("PASS [%s]: the next job's M3 at the previous job's S fires its "
|
||||
"G1; 2 fire spans of %s ticks"
|
||||
% (name, [s1 - s0 for s0, s1 in spans]))
|
||||
name = "next-job-density"
|
||||
data = run_session(name, JOB_NEXT + JOB_NEXT, conf=DENSITY_CONF)
|
||||
text = run_session.text
|
||||
if text.count("laser armed") != 2 or text.count("laser disarmed") != 2:
|
||||
fail("[%s] expected two armed windows closed by M2 (armed %d, "
|
||||
"disarmed %d)" % (name, text.count("laser armed"),
|
||||
text.count("laser disarmed")))
|
||||
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
|
||||
"one G1 per job: the second job's M3 at the "
|
||||
"first job's S lit nothing, or a rapid fired")
|
||||
check_termination(name, data)
|
||||
print("PASS [%s]: the next job's M3 at the previous job's S fires its "
|
||||
"G1; 2 fire spans of %s ticks"
|
||||
% (name, [s1 - s0 for s0, s1 in spans]))
|
||||
|
||||
# --- rule 18: the floor is derived from the key, never typed --------
|
||||
# The same ladder with a $35=0 typed ahead of it, under the shipped
|
||||
@@ -1216,34 +1144,6 @@ def main():
|
||||
"default 2; cruise %.3f alike" % (dens_head["g1"], dens_head["g2"],
|
||||
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 -----
|
||||
# One long line at 100 mm/s, held mid-move and resumed. The planned
|
||||
# deceleration runs lit (M4 velocity-scaled, M3 constant), the
|
||||
|
||||
Reference in New Issue
Block a user