Prove the M101 dose-model switch; record the judged dose curves

The stream harness gains rules 18 to 21: the floor is derived from the
selected model's config key at the arm and a typed $35 is overwritten;
M101 switches the rendering exactly at the boundary in both directions
with no continuous FIRE at full duty across it; a refused switch (the
spindle on) leaves the stream unchanged, and the harness resyncs with
an empty line because the core skips G-code after an error until the
sender resyncs; M2 reverts a program-scoped switch and Q1 holds. The
analog sessions pin laser_floor_analog at the density floor so the
existing duty expectations stand, and the density ladder's unfloored
run moves from a chained $35 write to the laser_floor_density key.

The catalog's laser.power-floor becomes model-aware: it reads the
configured model and the floor keys from forgectrl, switches to the
configured model with M101 so the derivation runs without a fire, and
expects $35 to be that model's floor. The new laser.power-model-switch
switches to each model with the spindle off, checks the reported
message and $35 after each switch, and checks the M2 revert. The new
mswitch bench drill runs the switch on the machine in one armed run.

Docs follow: BRINGUP's Laser control section describes the switch, the
derived floors and the measured dose response of both models; the
MOTION settings table gains the five keys; LIGHTBURN gains a Power
models section and drops the stale 30 percent floor advice; SAFETY
names the switch's refusal rule; the CAMPAIGN-LOG records the judged
depth-witness runs of 2026-08-30 and the switch's host and bench proof.
This commit is contained in:
ScottW514
2026-08-30 15:08:34 -04:00
parent f88c7784d4
commit 3025996c86
9 changed files with 684 additions and 87 deletions
+69 -29
View File
@@ -206,31 +206,69 @@ 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: `density` (the
default) pins the duty at full and modulates the FIRE bit, `analog` ships the
value as a power byte 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. `laser_pulse_min_ticks` (default 3 = 106 us) is the
shortest pulse it will emit: below it a period is skipped and its debt
carried, so a faint level arrives as fewer full-width pulses instead of
stubs the supply cannot strike - measured on the bench, a 36 us stub draws
no discharge at all, and the factory never emits below one of its 100 us
ticks. The debt is conserved, so the average density is unchanged: at level
2 the stream goes from 444 one-tick bursts to 147 three-tick bursts, same
density to four decimals. Under this model `$35` stops being a duty floor
and becomes a density floor - the control that maps S onto the band that
does useful work, which is what the factory does with its own scale. It
ships at 10, putting a commanded 1 % at 10.2 % density; **selecting `analog`
means raising it to ~16**, the duty this tube lases at, and the arm warns on
either mismatch (a zero floor under density, a sub-lasing one under analog). 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.
**Dose model.** Two models render the per-segment value the core computes:
`density` (the default) pins the duty at full and modulates the FIRE bit,
`analog` ships the value as a power byte instead. Density uses 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. 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. `laser_pulse_min_ticks` (default 3 = 106 us) is the shortest
pulse it will emit: below it a period is skipped and its debt carried, so a
faint level arrives as fewer full-width pulses instead of stubs the supply
cannot strike - measured on the bench, a 36 us stub draws no discharge at
all, and the factory never emits below one of its 100 us ticks. The debt is
conserved, so the average density is unchanged: at level 2 the stream goes
from 444 one-tick bursts to 147 three-tick bursts, same density to four
decimals. 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.
**Selecting the model.** `laser_power_model` in the shared machine config
(the control panel's GRBL tab) is the boot default. A job selects its own
with the driver M-code `M101 P0` (analog) or `M101 P1` (density), sent with
the spindle off: the switch is refused (`error:253`, reason reported) with
the spindle commanded on or the controller not idle, because a model change
under fire could pair density's pinned full duty with analog's continuous
FIRE; between kernel runs, which end dark and lead with a power byte, there
is no torn state to reach. The planner drains before the switch, the armed
window stays open across it, so `M5` / `M101` / `M3` inside a job switches
models between its sections with no new press. The switch is
program-scoped: it reverts to the boot default at `M2`/`M30` and on a soft
reset, so a job header can declare the model it needs without leaving the
machine in it; `M101 P<n> Q1` sticks until the next switch or a controller
restart. The stream leads the first run after a switch dark (duty 0) until
the commanded power lands, so neither model's number is ever shipped under
the other.
**The floor is derived, never typed.** Each model has an S-range floor as a
config key: `laser_floor_density` (default 10, the lowest density that
still marks, putting a commanded 1 % at 10.2 % density) and
`laser_floor_analog` (default 16, the duty this tube lases at; 3 to 14 % is
a dead band). At every arm and every switch the controller loads the
selected model's floor into `$35` in RAM and re-precomputes the PWM
mapping; the stored `$35` is never written, `$$` reports the floor in
force, and a `$35` typed by a sender is overwritten at the next arm. The
arm and switch reports name both (`laser armed (density, floor 10 %)`,
`laser power model set for this program (analog, floor 16 %)`), and a floor
of 0 is honored with a note (the ladders run that way). The cooling report
carries the model in force (`model=` on `POST /cool/state`) so the engine's
tube-heat share follows an `M101` as well as the default.
**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. Analog is
close to linear above 30 % duty (0.82 / 0.68 / 0.54 / 0.37 of CW at 80 /
60 / 45 / 30 % duty, the tube current equal to the duty) with the lasing
knee at 20 to 23 % duty below that. The finish on acrylic is the same under
both models, and the only visible pattern is mechanical (present under CW
too), so the models differ in their S scale, not their mark. A per-model S
correction (E4 in the working file) is the open item that follows.
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
@@ -1306,10 +1344,12 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
this model.
**The defaults are flipped:** `laser_power_model` defaults to `density`
and `$35` to 10, the density floor, so a stock machine runs the model
and a commanded 1 % marks. The analog path remains as `laser_power_model
= analog`, and a machine switched to it must raise `$35` to ~16 or low
S lands in the duty dead band; the arm warns on either mismatch.
with a 10 % floor (`laser_floor_density`), so a stock machine runs the
model and a commanded 1 % marks. The analog path remains as
`laser_power_model = analog` or `M101 P0` in a job, with its own floor
key (`laser_floor_analog`, 16); the controller derives `$35` from the
selected model's key at every arm and switch, so no floor is typed and
no mismatch exists ("Laser control (GRBL mode)" above).
Owed: validation at production feeds. Every ladder behind these
defaults ran at F300 or F100, where dose per millimeter is generous and
+14
View File
@@ -5986,6 +5986,20 @@ Closed: the arm is decided by the window alone (grblHAL-glowforge a7dcdca), an R
`serial.c`.
### The dose curves judged on material, and the model switch built, 2026-08-30
Bench, dev image 20260829220329, three `dpatch` depth-witness runs, each two rows of 30 x 4 mm serpentine fills (row A CW at six feeds for relative doses 1.0 to 0.25, row B at the reference feed at 100 / 80 / 60 / 45 / 30 % of the model's range), the operator matching each row-B patch to the row-A patch of equal depth by eye:
- Density on Thick Draftboard at F1500 (`bench-data/dpatch_20260830-172227.json`) and on acrylic at F1125 (`dpatch_20260830-173933.json`): the operator's verdict on both, "sensor predictions are accurate", B8 (80 % density) = A4 (CW at half the dose), B9 = A5, B10 = A6, B7 = A1. Thermopile 0.47 / 0.35 / 0.19 / 0.07 of CW at 80 / 60 / 45 / 30 % (0.45 / 0.34 / 0.19 / 0.06 on acrylic), tube current 0.56 / 0.45 / 0.39 / 0.35. Row A flat within 17 % and 16 %. The density curve is the tube's, not the sensor's, and the pending question from 2026-08-25 (B8 = A4 or A2) is closed on A4. By eye: every box deeper at both ends (the reversal slow-down under constant power), a slight line pattern from the 0.3 mm serpentine, no dot pattern.
- Analog on acrylic at F1125 (`dpatch_20260830-175759.json`), the drill extended to run under `laser_power_model = analog` (forgefirm f88c778): "they match the sensors", B8 (80 % duty) = A2, B9 between A2 and A3, B10 = A4, B11 = A5; light 0.82 / 0.68 / 0.54 / 0.37 of CW at 80 / 60 / 45 / 30 % duty, current 0.80 / 0.61 / 0.46 / 0.31. The 2026-08-25 ladder's prior (0.72 / 0.52 / 0.37 / 0.07) was low, worst at 30 % duty where its F600 lines sat in the unstable discharge band. The thermopile drifted inside this run (row A 1733 down to 1290, baseline 1835 up to 1896), so the ratios carry more uncertainty than the density runs.
- Finish, the two acrylic runs side by side: "finish is the same"; the only visible pattern is present at every power, under CW and under 100 % density (continuous fire) alike, so it is mechanical, not the dither. Operator's decision: the analog model stays and is developed as a second, near-linear model.
Each run: `cool_flow_recheck_s = 600` for the run and removed after, the `dpatch` record copied to the tree, `/tmp` cleaned, `laser_power_model` put back to `density`, the machine idle and disarmed.
Built the same day, host-proven, no fire: the dose-model switch. `M101 P0` (analog) / `M101 P1` (density) as a driver M-code, refused with the spindle commanded on (error 253, reason reported) or the controller not idle, program-scoped with `Q1` to stick; the per-model floors as config keys (`laser_floor_density` 10, `laser_floor_analog` 16) loaded into `$35` in RAM at every arm and switch with the PWM mapping re-precomputed, the stored setting never written; the stream leading the first run after a switch dark; the cooling report carrying the model in force (`model=` on `POST /cool/state`, the engine preferring it to the config key for the tube-heat share); the five `laser_*` keys in forgectrl's settings whitelist and the panel's GRBL tab with help text; `laser.power-floor` made model-aware and the new catalog test `laser.power-model-switch`. Proof: `tests/laser_arm_test.c` cases J to O (derived floors, validate and execute refusals, switch, revert, Q1, reset), `laser_stream_test.py` rules 18 to 21 (a typed `$35` overwritten at the arm; both switch directions rendering exactly at the boundary with no continuous FIRE at full duty across it; the refusal leaving the stream unchanged; `M2` reverting and `Q1` holding), forgectrl's host tests. Found on the way: the core skips every G-code line after an error until the sender resyncs with an empty line or a `$` command (`protocol.c`), which is how the harness now follows a refused switch. One flake, not a defect: rule 13's mask comparison slipped one tick at the tail while a build ran alongside in the same VM (the shipper is wall-paced); three runs alone were identical.
Bench-proven the same day, one armed run of the new `mswitch` drill on the hot-installed cross-built binaries (forgectrl md5 4dc4677a, grblHAL_glowforge 17e5502f, operator-run install): the arm reported "laser armed (density, floor 10 %)", `M5` then `M101 P0` answered ok and reported "laser power model set for this program (analog, floor 16 %)", `$$` read `$35=16` while analog was in force and `$35=10` again after `M2` reported the revert, the armed window carried across the switch with no re-prompt, and the 25 Hz trace showed exactly two discharge segments, the density line pulsed (hv mean 390, max-mean 547) and the analog line steady (mean 567, max-mean 28), dark after the second `M5` (hv max 0). Board cleaned; the hot-deployed binaries stay until the next flash, `/tmp/*.prev` is the rollback.
## Reference notes
### Head-IRQ source validation — the beam-emission hypothesis
+37 -3
View File
@@ -147,6 +147,39 @@ restart the controller with the head re-parked.)
- **Console tab**: raw grbl — `?` status, `$$` settings, `$X` unlock,
`$J=G91X10F1200` jog.
## Power models
The controller has two ways to turn a layer's power into light, and a job
can pick either.
- **Density** (the default): every pulse is full power, and the power
setting decides how many ticks of each 710 us period fire. This is what
the factory does. Every power level marks, low levels included, because
no pulse is ever too weak to strike; the trade is that the response is
not linear: on this machine 80 % gives about half the light of 100 %, 60 %
about a third, 30 % about a fourteenth.
- **Analog**: the beam runs continuously and the power setting sets the PWM
duty. Close to linear above 30 %, with a floor at the duty the tube lases
at (16 %), below which nothing marks. The finish on acrylic is the same
as density's.
The default model is set on the control panel (GRBL tab, "Laser power
model"). A job selects its own with a line in its G-code, with the laser
off:
M5 ; beam off (the switch is refused while the spindle is on)
M101 P0 ; analog for this program (M101 P1 = density)
M3 ...
Put it in the job's start G-code (Edit -> Device Settings -> GCode -> Start
G-Code in LightBurn) or between sections of a job. It reverts to the panel
default when the program ends (`M2`) or on Stop, so a job never leaves the
machine in a model you did not pick; `M101 P0 Q1` typed in the Console
sticks until the next `M101` or a controller restart. The console reports
every switch and every arm with the model and its floor, and `$35` (the
power floor) is set by the controller from the selected model: do not type
it, it is overwritten at the next job.
## Air assist / fans
Each cut/engrave layer has an **Air Assist** toggle (in the layer's cut
@@ -188,6 +221,7 @@ decay mode), and the head finishing per the job's return setting.
4. For the live pass: put scrap material on the bed (never an empty
honeycomb over the fan grill), re-enable the layer's **Output**, set
power to **30 %** or more (below ~30 % the tube barely marks), turn
the layer's **Air Assist** on, close the lid, **Frame**, **Start**,
and press the white button when it lights. Watch the whole job.
power to **20 %** (any nonzero power marks under the default power
model; 20 % is a light pass on scrap), turn the layer's **Air Assist**
on, close the lid, **Frame**, **Start**, and press the white button
when it lights. Watch the whole job.
+5
View File
@@ -426,6 +426,11 @@ only be changed while the machine is idle.
| `lid_policy` | `cancel` | `cancel` = factory behavior; `hold` = stock Grbl door parking. |
| `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_power_model` | `density` | The boot-default dose model: `density` (full-power pulses, dose by pulse density) or `analog` (continuous beam, dose by PWM duty). A job overrides it with `M101 P0` / `M101 P1`. |
| `laser_floor_density` | 10 | The S-range floor under density, percent of full; loaded into `$35` at every arm and switch. |
| `laser_floor_analog` | 16 | The S-range floor under analog, percent duty (the duty the tube lases at); loaded into `$35` the same way. |
| `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. |
| `cloud_pause_backtrack_ticks` | 2000 | Cloud pause: laser-off backtrack after the stop. |
| `cloud_resume_lead_ticks` | 1950 | Cloud resume: laser-off lead before firing again. |
+8
View File
@@ -203,6 +203,14 @@ would not allow.
under a job left standing, so a long pause ends with the machine disarmed
and the next emission needs a fresh press. The relock waits for the kernel to
finish the queue tail so a controlled stop can never leave FIRE driven.
- **Dose-model switch.** `M101` changes how a commanded power is rendered
(full-power pulses at a density, or a continuous beam at a duty). It is
refused with the spindle commanded on or the controller not idle, because
a change under fire could pair the density model's pinned full duty with
the analog model's continuous FIRE; between kernel runs, which end dark
and lead with a power byte, no torn state exists. The first run after a
switch leads dark (duty 0) until the commanded power lands. A switch
reverts at program end and on reset unless the operator made it stick.
- **Coolant fire gates.** The armed window requires a fresh `fire_ok` verdict
from the cooling engine (flow verification, over-temperature, the airflow
floors on every fan, lid-IR emission witness); a stale or failed verdict