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