Prove the density dose model host-side; close the idle-gap level loss

Four new sessions in the stream harness cover the model (grblHAL-glowforge
2bca017, pinned here). Density renders the commanded level exactly -
levels 2, 3, 7, 15, 25 and 38 came back as 0.0158, 0.0237, 0.0551,
0.1182, 0.1969 and 0.2993 against level/127 of 0.01575, 0.02362, 0.05512,
0.11811, 0.19685 and 0.29921 - S1000 renders 1.0000 and still ends dark,
every power byte carries full duty, and a level change inside a run costs
no stream byte where analog ships one per level.

Rule 13 is the one worth having: the same job run under both models
produces an identical motion grid tick for tick, and all 20051 density
FIRE ticks fall inside the 169776 the analog run fired. The model masks
the core's fire state and never sources one, measured rather than argued.

Rule 14 covers the idle-gap fix: a standalone S between moves, from a
sender slow enough to drain the planner, now fires each move at its own
level (28338 ticks each at duties 30, 52 and 84). Before the fix duty 30
held all 85014 and the other two levels never appeared. That closes
"Next work" item 18, which this work opened earlier today.

The harness now derives its expectations from the board's floor and
chains two launches over one settings file, because the core precomputes
the S to duty mapping once when the spindle is enabled: $35 written at
runtime persists and reports immediately but only enters force at the
next controller start. That is recorded in BRINGUP beside the existing
defaults note, and laser.power-floor's failure message now says so.

Acceptance: the density path shipping off by default is inert until
laser_power_model is set, and the laser tests' covers already name
grblhal-glowforge src/**; the model's own acceptance test waits on the
bench drill that picks the base period.
This commit is contained in:
ScottW514
2026-08-17 20:24:43 -04:00
parent cb41a6030c
commit c2c627d9b6
5 changed files with 368 additions and 12 deletions
+51 -2
View File
@@ -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
+85
View File
@@ -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