From 0b941fd74385cecc0d732dd197662861ee25d16c Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sun, 30 Aug 2026 20:19:24 -0400 Subject: [PATCH] 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. --- docs/BRINGUP.md | 30 ++++-- docs/LIGHTBURN.md | 16 +-- docs/MOTION.md | 1 + forgetest/forgetest/suite/forgectrl.py | 15 ++- scripts/bench/laser_lifecycle_test.py | 2 + scripts/bench/laser_stream_test.py | 144 ++++++++++++++++++++++++- 6 files changed, 187 insertions(+), 21 deletions(-) diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 85bd635..1cb35db 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -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 cooling report carries the model with the job state. -**Measured dose response (this bench, 2026-08-30, by the head thermopile, -the tube current and the operator's eye on Thick Draftboard and acrylic).** -Density delivers about half of the CW light at 80 % density, a third at -60 %, a fifth at 45 % and a fourteenth at 30 %: the curve is the tube's -(pulsed against CW), not the sensor's, and it is the same physics behind -the factory's 18.9 to 79.5 % mapping with Full Power kept apart. An S -correction from that curve (E4 in the working file) is the open item that -follows. 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. +**S commands light, through the measured curve.** The tube's output is +convex in pulse density (this bench, 2026-08-30, by the head thermopile, +the tube current and the operator's eye: 80 % density delivers about +half the CW light, 60 % a third, 45 % a fifth, 30 % a fourteenth - the +same physics behind the factory's 18.9 to 79.5 % mapping with Full +Power kept apart). So the driver maps the commanded fraction through +the measured curve's inverse onto the density that delivers it: +`laser_dose_curve` in the machine config holds density:light percent +pairs, ships with the bench-measured default compiled in, accepts +`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 updates carry the level inside a laser block, but an S executed between diff --git a/docs/LIGHTBURN.md b/docs/LIGHTBURN.md index 5f6e917..d28b9bb 100644 --- a/docs/LIGHTBURN.md +++ b/docs/LIGHTBURN.md @@ -152,12 +152,16 @@ restart the controller with the head re-parked.) 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 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: -on this machine 80 % gives about half the light of 100 %, 60 % about a -third, 30 % about a fourteenth - so pick engrave power by test card, and -prefer setting darkness with speed. 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. +because no pulse is ever too weak to strike. The raw response is not +linear - 80 % of the pulses deliver about half the light - so the +controller maps your power setting through a measured dose curve: 50 % +commands half the light, not half the pulses. The machine ships with +the bench-measured curve; record your own tube's from the control +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 (the control panel's GRBL tab, "Laser dose"): do not type it, it is diff --git a/docs/MOTION.md b/docs/MOTION.md index 43b7781..6c3a0e4 100644 --- a/docs/MOTION.md +++ b/docs/MOTION.md @@ -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_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_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_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. | diff --git a/forgetest/forgetest/suite/forgectrl.py b/forgetest/forgetest/suite/forgectrl.py index a4aa858..eb832cb 100644 --- a/forgetest/forgetest/suite/forgectrl.py +++ b/forgetest/forgetest/suite/forgectrl.py @@ -222,7 +222,8 @@ def settings_bounds(ctx): kind="auto", est_min=1, covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"), ("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 " "the acceptance tool read (including the sys block: CPU busy percent over the " "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((rep.get("laser") or {}).get("model") in ("density", "analog"), "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) ev["grbl_settings_status"] = st ctx.check(st == 200 and b"$35=" in (text or b""), "GET /grbl/settings -> %s without the $$ view", st) else: 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) diff --git a/scripts/bench/laser_lifecycle_test.py b/scripts/bench/laser_lifecycle_test.py index 79cca05..ac3ceec 100644 --- a/scripts/bench/laser_lifecycle_test.py +++ b/scripts/bench/laser_lifecycle_test.py @@ -296,6 +296,8 @@ def test_status_files(): assert '"armed":false' in st, "armed before any job: %r" % st assert '"model":"density"' in st and '"floor_pct":10' in 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 ts0 = float(st.split('"ts_mono":')[1].split(',')[0]) diff --git a/scripts/bench/laser_stream_test.py b/scripts/bench/laser_stream_test.py index 7e5d8ae..ab5940a 100644 --- a/scripts/bench/laser_stream_test.py +++ b/scripts/bench/laser_stream_test.py @@ -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 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 + 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 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. ANALOG_FLOOR_DEFAULT_PCT = 16.0 ANALOG_CONF = ("laser_power_model = analog\n" + "laser_dose_curve = off\n" "laser_floor_analog = %g\n" % PWM_MIN_PCT) DENSITY_PERIOD = 20 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 # the light end of the range. A floor of 0 is honored as written. DENSITY_CONF = ("laser_power_model = density\n" + "laser_dose_curve = off\n" "laser_floor_density = 0\n" + DENSITY_CONF_BASE) # 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) # A $35 typed ahead of the job: rule 18 says the arm overwrites it. 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 +# 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) @@ -636,7 +744,7 @@ def main(): rendered = check_density("density", dens, DENSITY_LEVEL, DENSITY_PERIOD, DENSITY_MIN_TICKS) 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") print("PASS [density]: %d bytes, %d power bytes all at full duty, " "level->density %s" @@ -746,13 +854,41 @@ def main(): expect_levels = tuple(duty_for(x) for x in LADDER_S) check_density("floor-derived", floored, expect_levels, DENSITY_PERIOD, 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 " "(text: %r)" % run_session.text[-400:]) print("PASS [floor-derived]: a typed $35=0 is overwritten at the arm; the " "ladder renders through the %g %% floor key, levels %s" % (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")