Prove the dose curve and the recorder; document S-means-light

The stream harness gains rule 19: with the bench-default curve in force
a ladder of S rungs renders the curve's densities exactly (half light
lands near 80 percent density), monotonic, floored and ceiled by
$35/$36; every other session pins laser_dose_curve = off so its
S-to-level arithmetic stays exact, and the arm-report checks carry the
curve name. The lifecycle harness asserts the published state file
names the curve in force. The catalog's forgectrl.panel-serves asserts
the curve field in the grbl report, /curve/status and the ladder
G-code, and its covers name the recorder.

BRINGUP describes S-commands-light through the measured curve and the
owner recorder; the MOTION table gains laser_dose_curve; LIGHTBURN
tells the operator power now means light and how to record their own
tube's curve from the panel.
This commit is contained in:
ScottW514
2026-08-30 20:19:24 -04:00
parent 557ed02590
commit 0b941fd743
6 changed files with 187 additions and 21 deletions
+20 -10
View File
@@ -238,16 +238,26 @@ spot. The arm report names it (`laser armed (density, floor 10 %)`), and
a floor of 0 is honored with a note (the ladders run that way). The a floor of 0 is honored with a note (the ladders run that way). The
cooling report carries the model with the job state. cooling report carries the model with the job state.
**Measured dose response (this bench, 2026-08-30, by the head thermopile, **S commands light, through the measured curve.** The tube's output is
the tube current and the operator's eye on Thick Draftboard and acrylic).** convex in pulse density (this bench, 2026-08-30, by the head thermopile,
Density delivers about half of the CW light at 80 % density, a third at the tube current and the operator's eye: 80 % density delivers about
60 %, a fifth at 45 % and a fourteenth at 30 %: the curve is the tube's half the CW light, 60 % a third, 45 % a fifth, 30 % a fourteenth - the
(pulsed against CW), not the sensor's, and it is the same physics behind same physics behind the factory's 18.9 to 79.5 % mapping with Full
the factory's 18.9 to 79.5 % mapping with Full Power kept apart. An S Power kept apart). So the driver maps the commanded fraction through
correction from that curve (E4 in the working file) is the open item that the measured curve's inverse onto the density that delivers it:
follows. Rasters hold their tonality down to ~14 pulse slots per pixel `laser_dose_curve` in the machine config holds density:light percent
(508 DPI at 6000 mm/min): the dither accumulator's cross-pixel averaging pairs, ships with the bench-measured default compiled in, accepts
recovers the levels, with no visible dither pattern. `off` for the identity, falls back loudly on a bad value, and is
reloaded at every precompute with the arm naming it (`laser armed
(density, floor 10 %, curve bench-default)`). `$35`/`$36` still floor
and ceil the result. An owner measures their own curve from the panel:
the dose-curve recorder hands them a ladder G-code to run from their
sender - every arm gate stands, forgectrl records the tube current and
the head thermopile passively, fits the rungs, and Apply writes the
result (`forgectrl/docs/SERVICES.md`). Rasters hold their tonality down
to ~14 pulse slots per pixel (508 DPI at 6000 mm/min): the dither
accumulator's cross-pixel averaging recovers the levels, with no
visible dither pattern.
An S word takes effect whether or not motion is in progress. Per-segment An S word takes effect whether or not motion is in progress. Per-segment
updates carry the level inside a laser block, but an S executed between updates carry the level inside a laser block, but an S executed between
+10 -6
View File
@@ -152,12 +152,16 @@ restart the controller with the head re-parked.)
The controller drives the tube the way the factory does: every pulse The controller drives the tube the way the factory does: every pulse
fires at full power, and the power setting decides how many ticks of fires at full power, and the power setting decides how many ticks of
each 710 us period fire. Every power level marks, low levels included, each 710 us period fire. Every power level marks, low levels included,
because no pulse is ever too weak to strike. The response is not linear: because no pulse is ever too weak to strike. The raw response is not
on this machine 80 % gives about half the light of 100 %, 60 % about a linear - 80 % of the pulses deliver about half the light - so the
third, 30 % about a fourteenth - so pick engrave power by test card, and controller maps your power setting through a measured dose curve: 50 %
prefer setting darkness with speed. Grayscale images fade cleanly into commands half the light, not half the pulses. The machine ships with
the shadows (a low level becomes sparse full-power pulses), and 254 to the bench-measured curve; record your own tube's from the control
508 DPI rasters hold their tonal steps. panel (GRBL tab, "Dose-curve recorder": download the ladder file, press
Record, run the file from LightBurn on scrap, press the button, Apply
the fit). Grayscale images fade cleanly into the shadows (a low level
becomes sparse full-power pulses), and 254 to 508 DPI rasters hold
their tonal steps.
`$35`, the power floor, is set by the controller from the machine config `$35`, the power floor, is set by the controller from the machine config
(the control panel's GRBL tab, "Laser dose"): do not type it, it is (the control panel's GRBL tab, "Laser dose"): do not type it, it is
+1
View File
@@ -427,6 +427,7 @@ only be changed while the machine is idle.
| `laser_button_timeout_s` | 300 | How long the machine waits at the button prompt. | | `laser_button_timeout_s` | 300 | How long the machine waits at the button prompt. |
| `laser_disarm_s` | 60 | Spindle-off grace before the armed window closes. | | `laser_disarm_s` | 60 | Spindle-off grace before the armed window closes. |
| `laser_floor_density` | 10 | The S-range floor, percent of full: the lowest pulse density that still marks. Loaded into `$35` at every spindle precompute; `$35` is derived, never typed. | | `laser_floor_density` | 10 | The S-range floor, percent of full: the lowest pulse density that still marks. Loaded into `$35` at every spindle precompute; `$35` is derived, never typed. |
| `laser_dose_curve` | (bench default) | The measured dose curve as density:light percent pairs; S commands a light fraction and the driver maps it onto the density that delivers it. `off` = identity; a bad value falls back to the default. The panel's recorder measures and applies a machine's own. |
| `laser_pulse_ticks` | 20 | Density base period in machine ticks (35.5 us each). | | `laser_pulse_ticks` | 20 | Density base period in machine ticks (35.5 us each). |
| `laser_pulse_min_ticks` | 3 | Shortest density pulse in ticks; below it a period is skipped and its debt carried. | | `laser_pulse_min_ticks` | 3 | Shortest density pulse in ticks; below it a period is skipped and its debt carried. |
| `rail_settle_s` | 2.5 | Motor-rail off period when a controller takes the device standalone. | | `rail_settle_s` | 2.5 | Motor-rail off period when a controller takes the device standalone. |
+14 -1
View File
@@ -222,7 +222,8 @@ def settings_bounds(ctx):
kind="auto", est_min=1, kind="auto", est_min=1,
covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"), covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"),
("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"),
("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c")], ("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"),
("forgectrl", "src/curverec.c")],
description="The panel page is served, /status carries the machine telemetry the panel and " description="The panel page is served, /status carries the machine telemetry the panel and "
"the acceptance tool read (including the sys block: CPU busy percent over the " "the acceptance tool read (including the sys block: CPU busy percent over the "
"interval since the previous read, memory used percent), and /cam/status " "interval since the previous read, memory used percent), and /cam/status "
@@ -289,9 +290,21 @@ def panel_serves(ctx):
ctx.check(key in rep, "/status grbl.report lacks %r", key) ctx.check(key in rep, "/status grbl.report lacks %r", key)
ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"), ctx.check((rep.get("laser") or {}).get("model") in ("density", "analog"),
"grbl.report.laser carries no model: %s", rep.get("laser")) "grbl.report.laser carries no model: %s", rep.get("laser"))
ctx.check((rep.get("laser") or {}).get("curve"),
"grbl.report.laser carries no dose curve: %s", rep.get("laser"))
st, text = fc.get("/grbl/settings", raw=True) st, text = fc.get("/grbl/settings", raw=True)
ev["grbl_settings_status"] = st ev["grbl_settings_status"] = st
ctx.check(st == 200 and b"$35=" in (text or b""), ctx.check(st == 200 and b"$35=" in (text or b""),
"GET /grbl/settings -> %s without the $$ view", st) "GET /grbl/settings -> %s without the $$ view", st)
else: else:
ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode) ctx.log("no live GRBL controller (%s); grbl block checks skipped", mode)
# The dose-curve recorder's read surface answers in any mode.
st, cs = fc.get("/curve/status")
ev["curve_status"] = cs
ctx.check(st == 200 and isinstance(cs, dict) and cs.get("state") in
("idle", "waiting", "recording", "done", "failed"),
"GET /curve/status -> %s %s", st, cs)
st, text = fc.get("/curve/ladder.gcode", raw=True)
ctx.check(st == 200 and b"S1000" in (text or b"") and b"M5" in (text or b""),
"GET /curve/ladder.gcode -> %s without the ladder", st)
+2
View File
@@ -296,6 +296,8 @@ def test_status_files():
assert '"armed":false' in st, "armed before any job: %r" % st assert '"armed":false' in st, "armed before any job: %r" % st
assert '"model":"density"' in st and '"floor_pct":10' in st, \ assert '"model":"density"' in st and '"floor_pct":10' in st, \
"model/floor missing (the floor must be derived from boot): %r" % st "model/floor missing (the floor must be derived from boot): %r" % st
assert '"curve":"bench-default"' in st, \
"the curve in force is not published: %r" % st
assert '"modals":"[GC:' in st, "modal report missing: %r" % st assert '"modals":"[GC:' in st, "modal report missing: %r" % st
ts0 = float(st.split('"ts_mono":')[1].split(',')[0]) ts0 = float(st.split('"ts_mono":')[1].split(',')[0])
+140 -4
View File
@@ -61,7 +61,13 @@ over TCP, then checks the dumps against the kernel feeder contract:
18. the floor is derived, never typed: $35 is loaded from the floor 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 key at every precompute, so a $35 typed by the sender is
overwritten - the ladder renders through the key's floor, and the overwritten - the ladder renders through the key's floor, and the
arm report names the model and the floor in force arm report names the model, the floor and the curve in force
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
The analog sessions select the reference mode through the config; on The analog sessions select the reference mode through the config; on
hardware the controller ignores it (density is the only product model - hardware the controller ignores it (density is the only product model -
@@ -169,6 +175,7 @@ JOB_LADDER.append("M5")
# tube's lasing duty (16), covered by the switch sessions below. # tube's lasing duty (16), covered by the switch sessions below.
ANALOG_FLOOR_DEFAULT_PCT = 16.0 ANALOG_FLOOR_DEFAULT_PCT = 16.0
ANALOG_CONF = ("laser_power_model = analog\n" ANALOG_CONF = ("laser_power_model = analog\n"
"laser_dose_curve = off\n"
"laser_floor_analog = %g\n" % PWM_MIN_PCT) "laser_floor_analog = %g\n" % PWM_MIN_PCT)
DENSITY_PERIOD = 20 DENSITY_PERIOD = 20
DENSITY_MIN_TICKS = 3 DENSITY_MIN_TICKS = 3
@@ -179,9 +186,43 @@ DENSITY_CONF_BASE = ("laser_pulse_ticks = %d\n"
# analog duty out of the tube's dead band, and here it would just clamp # analog duty out of the tube's dead band, and here it would just clamp
# the light end of the range. A floor of 0 is honored as written. # the light end of the range. A floor of 0 is honored as written.
DENSITY_CONF = ("laser_power_model = density\n" DENSITY_CONF = ("laser_power_model = density\n"
"laser_dose_curve = off\n"
"laser_floor_density = 0\n" + DENSITY_CONF_BASE) "laser_floor_density = 0\n" + DENSITY_CONF_BASE)
# The shipped density default: no floor key, so the board's floor applies. # The shipped density default: no floor key, so the board's floor applies.
DENSITY_CONF_FLOORED = "laser_power_model = density\n" + DENSITY_CONF_BASE DENSITY_CONF_FLOORED = ("laser_power_model = density\n"
"laser_dose_curve = off\n" + DENSITY_CONF_BASE)
# 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) 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
@@ -193,6 +234,73 @@ 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
# 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): def fail(msg):
print("FAIL: %s" % msg) print("FAIL: %s" % msg)
sys.exit(1) sys.exit(1)
@@ -636,7 +744,7 @@ def main():
rendered = check_density("density", dens, DENSITY_LEVEL, DENSITY_PERIOD, rendered = check_density("density", dens, DENSITY_LEVEL, DENSITY_PERIOD,
DENSITY_MIN_TICKS) DENSITY_MIN_TICKS)
check_termination("density", dens) check_termination("density", dens)
if "laser armed (density, floor 0 %)" not in run_session.text: if "laser armed (density, floor 0 %, curve off)" not in run_session.text:
fail("[density] the arm did not select the density model at floor 0") fail("[density] the arm did not select the density model at floor 0")
print("PASS [density]: %d bytes, %d power bytes all at full duty, " print("PASS [density]: %d bytes, %d power bytes all at full duty, "
"level->density %s" "level->density %s"
@@ -746,13 +854,41 @@ def main():
expect_levels = tuple(duty_for(x) for x in LADDER_S) expect_levels = tuple(duty_for(x) for x in LADDER_S)
check_density("floor-derived", floored, expect_levels, DENSITY_PERIOD, check_density("floor-derived", floored, expect_levels, DENSITY_PERIOD,
DENSITY_MIN_TICKS) DENSITY_MIN_TICKS)
if "laser armed (density, floor %g %%)" % PWM_MIN_PCT not in run_session.text: if "laser armed (density, floor %g %%, curve off)" % PWM_MIN_PCT not in run_session.text:
fail("[floor-derived] the arm report does not name the derived floor " fail("[floor-derived] the arm report does not name the derived floor "
"(text: %r)" % run_session.text[-400:]) "(text: %r)" % run_session.text[-400:])
print("PASS [floor-derived]: a typed $35=0 is overwritten at the arm; the " print("PASS [floor-derived]: a typed $35=0 is overwritten at the arm; the "
"ladder renders through the %g %% floor key, levels %s" "ladder renders through the %g %% floor key, levels %s"
% (PWM_MIN_PCT, list(expect_levels))) % (PWM_MIN_PCT, list(expect_levels)))
# --- 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))
print("PASS: all stream emission rules hold") print("PASS: all stream emission rules hold")