diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 8c8858c..9e8884e 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -169,6 +169,14 @@ core mutex stands in for interrupt masking. `GFSINK` unset = null-sink mode `^X` mid-motion aborts via kernel `cnc/stop` (controlled decel) and raises an alarm; TCP disconnects never kill the process (the dead-man fd stays held). +**Spindle `$`-settings take effect at controller start.** The core +precomputes the S -> duty mapping once, when the spindle is enabled, and a +settings write does not re-run it: `$35=16` persists to the eeprom +immediately and `$$` reports it immediately, but the mapping in force is +still the one loaded at start until the controller restarts (a mode switch, +a `POST /controller/stop` + `start`, or a boot). Verified host-side: after a +runtime `$35=0` the shipped duties stay floored. + **Stored `$`-settings beat freshly baked defaults** — after changing `GLOWFORGE_DEFAULTS` values, run `$RST=$` once on the board (settings persist in the eeprom file in `/data`). @@ -194,6 +202,31 @@ of the first tick byte it covers, FIRE as bit 4 OR'd into tick bytes. The spindle PWM is precomputed to a period of exactly 127, so computed values ARE power bytes (`$30` default 1000 → S1000 = 127). +**Dose model.** `laser_power_model` in the shared machine config selects how +the shipper renders the per-segment value the core computes: `analog` (the +default) ships it as a power byte, `density` pins the duty at full and +modulates the FIRE bit instead - a base period of `laser_pulse_ticks` +(default 20 = 710 us at 28160 Hz, the factory's ~1.43 kHz) whose on-count is +dithered between adjacent integers with the remainder carried, so densities +finer than one tick per period average out. The model is selected per arm +and reported (`laser armed (density)`). Density is what the tube's dead band +below its lasing threshold requires: every pulse it emits is full-power, so +no commanded level lands in the band, and a level change inside a run costs +no stream byte at all. It wants `$35` = 0 - the floor exists only to keep an +analog duty out of the band, and under density it just clamps the light end +of the range; the arm warns when a floor is set. Structurally the model is a +mask on the core's fire state and never a source of one, so emission stays +exactly where the core commanded it. + +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 +blocks - with the planner drained, so nothing is streaming - arrives only +through the synchronous spindle path, which publishes the duty without +touching the fire state; and the next run re-asserts the laser state the +core last asked for at its first byte, fire only inside an armed window. +Without those two a standalone S from a sender slow enough to drain the +planner left the following moves cutting at a stale duty, or dark. + Contract rules enforced structurally: a power byte leads every kernel run before any fire bit (a run start resets duty to ~100 %), transitions are coalesced per tick so power bytes are never consecutive, and power bytes cost @@ -1042,8 +1075,24 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`. the factory's, which still lets dose per unit length rise ~1.8× at a corner. - Owed: the density model itself. `$35` and the analog path stay as the - fallback until it lands. + The model itself is implemented and host-proven, off by default + (`laser_power_model`, above). What the harness holds: density renders + the commanded level exactly (level/127 to four decimals at every rung), + no level ever reaches PWMSAR, a level change inside a run costs no + stream byte where analog pays one each, and - run against the same job + under both models - the motion grid is identical and every density FIRE + tick is one the analog run also fired, so the model only ever masks. + + Owed: one bench drill to choose the base period, which is the parameter + the host cannot answer. The factory never emits a pulse shorter than + 100 us; a tick here is 35.5 us, and every pulse restarts the discharge, + so each carries the strike transient the threshold ladder made visible + - dose per pulse is therefore probably not proportional to pulse length + and density -> dose may be superlinear at the low end. A density ladder + on scrap at two or three `laser_pulse_ticks` values answers both that + and the shortest pulse that marks reliably. grblHAL is userspace, so it + deploys by replacing the binary; no image flash. Then the raster path + below. What that model means for image engraving, since it decides the design as much as cutting does. LightBurn has two image paths. Its 1-bit modes diff --git a/docs/CAMPAIGN-LOG.md b/docs/CAMPAIGN-LOG.md index 0eacbaf..77031b7 100644 --- a/docs/CAMPAIGN-LOG.md +++ b/docs/CAMPAIGN-LOG.md @@ -2775,6 +2775,91 @@ still gets the copy, capture off writes nothing). No acceptance-catalog consequence: the path is an off-by-default debug capture with no bearing on emission, motion or the release surface. +## 2026-08-17 — the density dose model, phases 1 and 2 + +Implemented and host-proven; off by default, so nothing about a shipped +machine changes until `laser_power_model = density` is set. + +### The change + +The whole hot path is one predicate in the shipper: + + if(gf.cur_fire) -> if(gf.cur_fire && (!gf.dith_period || dither_tick())) + b |= 0x10; b |= 0x10; + +That `&&` is the safety property, structurally: the model masks the core's +fire state and can never be a source of one, so it stays out of the safety +argument entirely — the armed window, the latch, the coolant gates and the +hardware chain are all upstream and untouched. + +Around it: a fixed base period of `laser_pulse_ticks` (default 20 = 710 us +at 28160 Hz, the factory's ~1.43 kHz), on-count `level x period / 127` with +the remainder carried across periods so finer densities average out, the +on-ticks leading each period so a level renders as one burst rather than +isolated ticks. The accumulator resets only where the dose itself restarts — +run boundary, fire off, disarm, abort — never per segment. In density mode +the duty is pinned: a power byte still leads every kernel run, because the +run start resets the hardware duty, but it always carries full duty and a +level never reaches PWMSAR. Selected per arm from the shared machine config +and reported as `laser armed (density)`; the arm warns when `$35` is set, +since the floor only clamps the light end of a range that cannot fall into +the dead band anyway. + +### What the harness holds (rules 11-13) + +- Density renders the commanded level exactly: levels 2, 3, 7, 15, 25, 38 + came back as 0.0158, 0.0237, 0.0551, 0.1182, 0.1969, 0.2993 against + level/127 of 0.01575, 0.02362, 0.05512, 0.11811, 0.19685, 0.29921. +- S1000 renders density 1.0000 and still ends dark. +- Every power byte carries full duty; a level change inside a run costs no + stream byte, where analog ships one per level (4 bytes, duties 0/30/52/84, + against density's 1). +- The mask invariant, measured rather than argued: the same job run under + both models produced an identical motion grid tick for tick, and all + 20051 density FIRE ticks fell inside the 169776 the analog run fired. +- Churn (planner-starve run boundaries) still terminates dark under the + model, with no FIRE across a stepless gap. + +The analog path is byte-identical to before the change — same byte counts, +duties and fire ticks on every pre-existing session — so the fallback is +intact. + +### Two things the work turned up + +**Spindle `$`-settings take effect at controller start, not at the write.** +The core precomputes the S -> duty mapping once, when the spindle is +enabled; a settings write does not re-run it. After a runtime `$35=0` the +shipped duties stayed floored at 57/64/73/127. So `$35=16` set on the bench +earlier today persisted immediately and was reported by `$$` immediately, +but only entered force at the next controller restart — which the capture +work then supplied. The harness now models this the way an operator would: +one launch writes the setting, the next runs the job. + +**A laser state change made while the stream is idle was lost — found, +root-caused and fixed.** Reproduced in both dose models, so it was not the +density model's doing: with a line-at-a-time sender and moves long enough to +drain the planner, `S100 / G1 X5 / S300 / G1 X5 / S600 / G1 X5` fired only +the first move and shipped duty 30 three times. + +It was two faults wearing one symptom, and fixing the first exposed the +second. `gf_stream_laser()` dropped transitions while nothing was streaming, +so nothing re-asserted the state for the next run, which a run end leaves +dark — the stream engine now records the state the core last asked for +whether or not it is streaming, and re-asserts it at the first byte of the +next run, fire only inside an armed window (an abort clears it, so a closed +window can never be resurrected). With that in, all three moves fired, and +all three fired at duty 30: the level had never reached the driver at all, +because `spindleSetState` discarded its `rpm` argument. Per-segment updates +carry the level inside a laser block, but an S executed between blocks +arrives only through that synchronous path. It now publishes the duty, and +only the duty — fire stays where `spindleUpdatePWM` and its gates put it, +so the new path carries no consent to fire. + +Rule 14 in the harness is the regression: the same standalone-S job must +show each level firing its own move. It does — 28338 fire ticks each at +duties 30, 52 and 84, where before the fix duty 30 held all 85014 and the +two other levels never appeared. + ## Superseded status notes ### Shared machine services — remaining polish, as listed 2026-08-13 diff --git a/forgetest/forgetest/suite/laser.py b/forgetest/forgetest/suite/laser.py index 880e3bb..03a71d5 100644 --- a/forgetest/forgetest/suite/laser.py +++ b/forgetest/forgetest/suite/laser.py @@ -232,7 +232,8 @@ def wait_disarm(ctx, timeout): "unfloored, M4's velocity-scaled power falls into the dead band at every corner " "and reversal and marks nothing. Reads $$ and checks the floor is the " "commissioned percent, that $31 is 0 (the floor, not $31, sets the bottom of " - "the range), and that the floor lands at or above PWMSAR 20.") + "the range), and that the floor lands at or above PWMSAR 20. Reports the " + "stored setting, which is the one in force after any controller start.") def power_floor(ctx): ev = ctx.evidence with ctx.grbl() as g: @@ -258,7 +259,9 @@ def power_floor(ctx): ctx.check(abs(floor_pct - POWER_FLOOR_PCT) < 0.05, "$35 is %.1f %%, expected the commissioned %.1f %% - a machine carrying stored " - "settings from before the floor needs `$RST=$` once", floor_pct, POWER_FLOOR_PCT) + "settings from before the floor needs `$35=16` (or `$RST=$`) and then a " + "controller restart, since the spindle mapping is precomputed at start", + floor_pct, POWER_FLOOR_PCT) ctx.check(counts >= PWMSAR_FLOOR_MIN, "the floor lands at PWMSAR %d, below the %d the tube needs to lase", counts, PWMSAR_FLOOR_MIN) diff --git a/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge-pin.inc b/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge-pin.inc index ef45477..02ea7bf 100644 --- a/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge-pin.inc +++ b/meta-forgefirm/recipes-forgefirm/grblhal-glowforge/grblhal-glowforge-pin.inc @@ -2,5 +2,5 @@ # changes; keep only SRCREV and PV here - the image manifest leaves # *-pin.inc out of the layer content hash because the component entry # already identifies the pinned source (forgefirm-image-manifest.bbclass). -SRCREV = "9466f7650a4722cb42a18fe96a359b2a8b3a639f" +SRCREV = "2bca017854291f4084ca2bdf6f1ffaed88ff6d4f" PV = "0.1.0" diff --git a/scripts/bench/laser_stream_test.py b/scripts/bench/laser_stream_test.py index 8a77f11..2dccc25 100644 --- a/scripts/bench/laser_stream_test.py +++ b/scripts/bench/laser_stream_test.py @@ -30,6 +30,18 @@ over TCP, then checks the dumps against the kernel feeder contract: ahead of the rung's power byte would burn at full power), and the 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 + 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 + models and every FIRE tick of the density run is a FIRE tick of the + analog run, on an identical motion grid + 14. a laser state change made while the stream is idle survives to the + next run: a standalone S word between moves, from a sender slow + enough to drain the planner, must still cut at the level it asked + for rather than dark at a stale duty Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge) """ @@ -119,6 +131,42 @@ for _i, _s in enumerate(LADDER_S): JOB_LADDER.append("M5") +# Sessions E-G: the density dose model. $35 = 0 for the ladder because +# the floor exists only to keep an analog duty out of the tube's dead +# band - under density every pulse is full-power, and a floor would just +# clamp the light end of the range. +DENSITY_PERIOD = 20 +DENSITY_CONF = ("laser_power_model = density\n" + "laser_pulse_ticks = %d\n" % DENSITY_PERIOD) +DENSITY_LEVEL = tuple(int(x * PWM_PERIOD / RPM_MAX) for x in LADDER_S) +JOB_DENSITY = ["$35=0"] + JOB_LADDER + +# 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") + + def fail(msg): print("FAIL: %s" % msg) sys.exit(1) @@ -181,14 +229,26 @@ def publish_verdicts(path, stop): stop.wait(0.5) -def run_session(name, steps): - """Launch the controller, run the job steps, return the dump bytes.""" - workdir = tempfile.mkdtemp(prefix="laser-test-") +def run_session(name, steps, conf=None, workdir=None, keep=False, + arm_required=True): + """Launch the controller, run the job steps, return the dump bytes. + + Pass workdir + keep to chain launches over one settings file: the + core precomputes the spindle PWM mapping once, when the spindle is + enabled, so a $35 written at runtime only takes effect on the next + controller start.""" + if workdir is None: + workdir = tempfile.mkdtemp(prefix="laser-test-") dump = os.path.join(workdir, "stream.bin") verdict = os.path.join(workdir, "cooling.state") env = dict(os.environ, GFSINK_DUMP=dump, GF_VERDICT_FILE=verdict, FFLOG_STDERR="1") env.pop("GFSINK", None) + if conf is not None: + conf_path = os.path.join(workdir, "forgefirm.conf") + with open(conf_path, "w") as f: + f.write(conf) + env["GFHOME_CONF"] = conf_path stop = threading.Event() pub = threading.Thread(target=publish_verdicts, args=(verdict, stop), daemon=True) @@ -226,9 +286,10 @@ def run_session(name, steps): # is wall-paced), then for the Idle report. wait_idle(sock, log) time.sleep(1.0) # let the shipper drain the tail - text = "".join(log) - if "laser armed" not in text: + + run_session.text = text + if arm_required and "laser armed" not in text: fail("[%s] no 'laser armed' message (arming flow did not run)" % name) sock.close() @@ -242,9 +303,10 @@ def run_session(name, steps): pub.join(2) data = open(dump, "rb").read() - if not data: + if not data and arm_required: fail("[%s] empty stream dump" % name) - shutil.rmtree(workdir, ignore_errors=True) + if not keep: + shutil.rmtree(workdir, ignore_errors=True) return data @@ -386,6 +448,80 @@ def check_power_ladder(name, data, expect): return counts +def fire_spans(ticks, gap=500): + """Tick spans carrying fire, split on dark gaps (the G0 between + rungs). Within a rung the model's own dark stretches are at most a + couple of base periods, far below the split.""" + spans = [] + start = last = None + for i, b in enumerate(ticks): + if b & 0x10: + if start is None: + start = i + elif i - last > gap: + spans.append((start, last + 1)) + start = i + last = i + if start is not None: + spans.append((start, last + 1)) + return spans + + +def check_density(name, data, levels, period): + """Rules 11-12: pinned duty, and density per rung matching the level.""" + # A power byte still leads every kernel run - the run start resets the + # hardware duty - but under this model it only ever carries full duty: + # the level rides the FIRE bits, never PWMSAR. + powers = [b & 0x7F for b in data if b & 0x80] + if not powers or set(powers) != {PWM_PERIOD}: + fail("[%s] density mode shipped power bytes %s; every one must be " + "full duty, or a level reached PWMSAR" % (name, sorted(set(powers)))) + + ticks = tick_bytes(data) + spans = fire_spans(ticks) + if len(spans) != len(levels): + fail("[%s] %d fire spans, expected one per rung (%d): %s" + % (name, len(spans), len(levels), spans[:8])) + + out = [] + for (a, b), level in zip(spans, levels): + seg = ticks[a:b] + got = sum(1 for t in seg if t & 0x10) / float(len(seg)) + want = level / float(PWM_PERIOD) + out.append((level, round(got, 4))) + # A span is clipped to whole ticks, not whole periods, so allow a + # little slack at the edges; the accumulator carries the rest. + if abs(got - want) > max(0.01, want * 0.06): + fail("[%s] level %d rendered density %.4f, expected %.4f" + % (name, level, got, want)) + run = worst = 0 + for t in seg: + run = run + 1 if t & 0x10 else 0 + worst = max(worst, run) + if worst > period: + fail("[%s] level %d burst of %d ticks exceeds the %d-tick base " + "period" % (name, level, worst, period)) + return out + + +def check_mask(analog, density): + """Rule 13: same motion, and density fire is a subset of analog fire.""" + ta, td = tick_bytes(analog), tick_bytes(density) + if len(ta) != len(td): + fail("[mask] tick counts differ (analog %d, density %d): the two runs " + "are not the same motion" % (len(ta), len(td))) + for i, (a, b) in enumerate(zip(ta, td)): + if (a & ~0x10) != (b & ~0x10): + fail("[mask] motion differs at tick %d (analog 0x%02x, density " + "0x%02x)" % (i, a, b)) + stray = [i for i, (a, b) in enumerate(zip(ta, td)) if (b & 0x10) and not (a & 0x10)] + if stray: + fail("[mask] density fired %d tick(s) the core never commanded, first " + "at %d - the model is acting as a source of emission, not a mask" + % (len(stray), stray[0])) + return sum(1 for b in td if b & 0x10), sum(1 for a in ta if a & 0x10) + + def count_fire(data): return sum(1 for b in tick_bytes(data) if b & 0x10) @@ -428,6 +564,89 @@ def main(): % (len(data), list(LADDER_DUTY), [counts[d] for d in LADDER_DUTY], gap_d)) + # --- session E: the same ladder under the density model ------------- + # $35 is written by a first launch and takes effect on the second: + # the floor exists only to keep an analog duty out of the tube's + # dead band, and under density it would just clamp the light end. + wd = tempfile.mkdtemp(prefix="laser-test-") + run_session("density-setup", ["$35=0"], conf=DENSITY_CONF, workdir=wd, + keep=True, arm_required=False) + dens = run_session("density", JOB_DENSITY, conf=DENSITY_CONF, workdir=wd) + rendered = check_density("density", dens, DENSITY_LEVEL, DENSITY_PERIOD) + check_termination("density", dens) + if "laser armed (density)" not in run_session.text: + fail("[density] the arm did not select the density model") + print("PASS [density]: %d bytes, %d power bytes all at full duty, " + "level->density %s" + % (len(dens), sum(1 for b in dens if b & 0x80), rendered)) + + # --- rule 13: the model masks, it never sources --------------------- + d_fire, a_fire = check_mask(data, dens) + print("PASS [mask]: identical motion grid, %d density fire ticks all " + "inside the %d the core commanded" % (d_fire, a_fire)) + + # --- session F: full level under the model is continuous fire ------- + full = run_session("density-full", ["$35=0"] + JOB_M3_TERM, conf=DENSITY_CONF) + ticks = tick_bytes(full) + spans = fire_spans(ticks) + if not spans: + fail("[density-full] no FIRE bits in the stream") + a, b = spans[0] + got = sum(1 for t in ticks[a:b] if t & 0x10) / float(b - a) + if got != 1.0: + fail("[density-full] S1000 rendered density %.4f, expected 1.0" % got) + check_termination("density-full", full) + print("PASS [density-full]: S1000 -> density 1.0000 over %d ticks, ends dark" + % (b - a)) + + # --- session G: churn under the model (rules 7-9 still hold) -------- + ch = run_session("density-churn", ["$35=0"] + JOB_CHURN, conf=DENSITY_CONF) + if not count_fire(ch): + 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) + 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))) + + # --- session I: a level set while idle still cuts (rule 14) --------- + idle_s = run_session("idle-s", JOB_IDLE_S) + fire_by_duty = {} + cur = None + for b in idle_s: + if b & 0x80: + cur = b & 0x7F + elif b & 0x10: + fire_by_duty[cur] = fire_by_duty.get(cur, 0) + 1 + want_ticks = IDLE_S_MM / (IDLE_S_FEED / 60.0) * 28160 + for level in IDLE_S_LEVELS: + duty = duty_for(level) + got = fire_by_duty.get(duty, 0) + if got < want_ticks * 0.9: + fail("[idle-s] S%d (duty %d) fired %d ticks, expected ~%d: a level " + "set while the stream was idle was dropped and the move ran " + "dark or at a stale duty (all: %s)" + % (level, duty, got, want_ticks, fire_by_duty)) + check_termination("idle-s", idle_s) + print("PASS [idle-s]: standalone S across idle gaps -> fire ticks per duty %s" + % {duty_for(l): fire_by_duty[duty_for(l)] for l in IDLE_S_LEVELS}) + print("PASS: all stream emission rules hold")