Cover both dose models explicitly now that density is the default

grblHAL-glowforge 03d70e0, pinned here, makes density the shipped model
and $35 the density floor at 10. Every analog session in the stream
harness now selects its model rather than inheriting it: without that the
flip would have quietly turned them into density runs and taken the
analog fallback's coverage with them. laser.power-floor carries the new
floor and its PWMSAR minimum, and its description no longer describes
$35 as a duty floor.

All ten stream sessions pass on the new defaults, with the analog duties
shifting exactly as the floor predicts - min_value 12, gradient 0.115 -
along with both C harnesses and the lifecycle harness.

Recorded in BRINGUP and the campaign log, including what the defaults
rest on and what they do not: the seven ladders ran at F300 and F100 at
constant power, so production feeds, M4 into corners and the raster path
remain unproven on hardware.
This commit is contained in:
ScottW514
2026-08-17 22:03:56 -04:00
parent 1e855ab212
commit 0c2dd37b2f
5 changed files with 74 additions and 36 deletions
+21 -12
View File
@@ -203,9 +203,9 @@ spindle PWM is precomputed to a period of exactly 127, so computed values ARE
power bytes (`$30` default 1000 → S1000 = 127). power bytes (`$30` default 1000 → S1000 = 127).
**Dose model.** `laser_power_model` in the shared machine config selects how **Dose model.** `laser_power_model` in the shared machine config selects how
the shipper renders the per-segment value the core computes: `analog` (the the shipper renders the per-segment value the core computes: `density` (the
default) ships it as a power byte, `density` pins the duty at full and default) pins the duty at full and modulates the FIRE bit, `analog` ships the
modulates the FIRE bit instead - a base period of `laser_pulse_ticks` 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 (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 dithered between adjacent integers with the remainder carried, so densities
finer than one tick per period average out. The model is selected per arm finer than one tick per period average out. The model is selected per arm
@@ -221,7 +221,10 @@ 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 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 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 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. Structurally the model is a 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 mask on the core's fire state and never a source of one, so emission stays
exactly where the core commanded it. exactly where the core commanded it.
@@ -1138,14 +1141,20 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
onto that band, which is what the factory does and what `$35` is under onto that band, which is what the factory does and what `$35` is under
this model. this model.
Owed: the shipping defaults. The model, the minimum pulse and the **The defaults are flipped:** `laser_power_model` defaults to `density`
scale are all proven on hardware, but `laser_power_model` still and `$35` to 10, the density floor, so a stock machine runs the model
defaults to `analog`, so nothing of this reaches a machine until the and a commanded 1 % marks. The analog path remains as `laser_power_model
key is set. Flipping the default means `$35` must move with it - 16 is = analog`, and a machine switched to it must raise `$35` to ~16 or low
the analog duty floor, 10 is the density floor, and the wrong pairing S lands in the duty dead band; the arm warns on either mismatch.
is a dead band either way - and it wants one real job at a production
feed first: every ladder here ran at F300 or F100, where dose per Owed: validation at production feeds. Every ladder behind these
millimeter is generous, and no raster has run at all. defaults ran at F300 or F100, where dose per millimeter is generous and
the power is constant - none of them exercised M4's velocity scaling
into corners, a real sender's level changes, or the raster path, which
has not run at all. The arithmetic says dotting will not be the
problem (at 10 % density the pulse interval is 1.07 ms, which at
2000 mm/min is 35 um against a ~200 um spot), but that is reasoning,
not a cut.
For reference, the factory maps its whole 1-100 power For reference, the factory maps its whole 1-100 power
scale onto density 18.9-79.5 % (fit from the three captures; Full Power scale onto density 18.9-79.5 % (fit from the three captures; Full Power
+29 -6
View File
@@ -3042,12 +3042,35 @@ level real pulses, the minimum pulse to keep them strikeable, and the floor to
put the user's range on the band that works — the same three pieces the put the user's range on the band that works — the same three pieces the
factory uses, arrived at from this bench's own measurements. factory uses, arrived at from this bench's own measurements.
Owed: the shipping defaults. `laser_power_model` still defaults to `analog`, ### The defaults flipped
so none of this reaches a machine until the key is set, and `$35` must move
with the model (16 is the analog duty floor, 10 the density floor; the wrong `laser_power_model` now defaults to `density` and `$35` to 10, so a stock
pairing is a dead band either way). Both want one real job at a production machine runs the model and a commanded 1 % marks. The analog path stays as an
feed first — every ladder here ran at F300 or F100, where dose per millimeter explicit `laser_power_model = analog`.
is generous, and no raster has run at all.
The two settings are coupled and the pairing matters: `$35` is a **density**
floor under the shipped model and a **duty** floor under the fallback, wanting
~10 and ~16 respectively, and the wrong pairing is a dead band in either
direction. The arm warns on both mismatches — a zero floor under density,
where the bottom of the S range asks for pulses too far apart to re-strike,
and a sub-lasing floor under analog.
Test-side consequences worth noting, since the default reaches into the
harness: every analog session in `laser_stream_test.py` now selects its model
explicitly rather than inheriting it, or the flip would have silently turned
them into density runs and taken the analog fallback's coverage with them.
`laser_arm_test` asserts the inverse of what it used to — no config key now
means density — and `laser.power-floor` carries the new floor and its
PWMSAR minimum. All ten stream sessions, both C harnesses and the lifecycle
harness pass on the new defaults; the analog duties shift exactly as the new
floor predicts (min_value 12, gradient 0.115).
Owed: validation at a production feed. Every ladder behind these defaults ran
at F300 or F100 at constant power, so none of them exercised M4's velocity
scaling into corners, a real sender's mid-run level changes, or the raster
path, which has not run at all. The arithmetic says dotting will not be the
problem — at 10 % density the pulse interval is 1.07 ms, 35 µm at
2000 mm/min against a ~200 µm spot — but that is reasoning, not a cut.
## Superseded status notes ## Superseded status notes
+11 -8
View File
@@ -26,10 +26,13 @@ _LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "*
("forgectrl", "src/status.c"), ("forgectrl", "src/main.c")] ("forgectrl", "src/status.c"), ("forgectrl", "src/main.c")]
# boards/glowforge.h: DEFAULT_SPINDLE_PWM_MIN_VALUE against the hardware's # boards/glowforge.h: DEFAULT_SPINDLE_PWM_MIN_VALUE against the hardware's
# 127-count PWM period. The tube's measured lasing threshold is PWMSAR 20. # 127-count PWM period. Under the shipped FIRE-density dose model this is
POWER_FLOOR_PCT = 16.0 # a density floor: the bottom of the S range maps onto it, measured as
# the lowest level that still marks. (Under the analog fallback the same
# setting is a duty floor and wants ~16, the duty this tube lases at.)
POWER_FLOOR_PCT = 10.0
PWM_PERIOD = 127 PWM_PERIOD = 127
PWMSAR_FLOOR_MIN = 20 PWMSAR_FLOOR_MIN = 12
ARM_CUE = ("LIVE FIRE. Eye protection on, exhaust running, fire watch and extinguisher in reach, " ARM_CUE = ("LIVE FIRE. Eye protection on, exhaust running, fire watch and extinguisher in reach, "
"scrap under the head with room to move (%s), lid closed. When the job starts the " "scrap under the head with room to move (%s), lid closed. When the job starts the "
@@ -228,11 +231,11 @@ def wait_disarm(ctx, timeout):
@test("laser.power-floor", title="The shipped duty floor holds commanded power above the lasing threshold", @test("laser.power-floor", title="The shipped duty floor holds commanded power above the lasing threshold",
subsystem="laser", kind="auto", est_min=1, subsystem="laser", kind="auto", est_min=1,
covers=[("grblhal-glowforge", "src/**")], covers=[("grblhal-glowforge", "src/**")],
description="The tube lases only above ~16 % duty, so $35 must floor every nonzero S there: " description="$35 floors the bottom of the laser's output range, and unfloored the low end "
"unfloored, M4's velocity-scaled power falls into the dead band at every corner " "of S asks for pulses too far apart for the discharge to re-strike - a "
"and reversal and marks nothing. Reads $$ and checks the floor is the " "commanded 1 % would emit nothing. Reads $$ and checks the floor is the "
"commissioned percent, that $31 is 0 (the floor, not $31, sets the bottom of " "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. Reports the " "the range), and that it lands at or above the measured minimum. Reports the "
"stored setting, which is the one in force after any controller start.") "stored setting, which is the one in force after any controller start.")
def power_floor(ctx): def power_floor(ctx):
ev = ctx.evidence ev = ctx.evidence
@@ -263,7 +266,7 @@ def power_floor(ctx):
"controller restart, since the spindle mapping is precomputed at start", "controller restart, since the spindle mapping is precomputed at start",
floor_pct, POWER_FLOOR_PCT) floor_pct, POWER_FLOOR_PCT)
ctx.check(counts >= PWMSAR_FLOOR_MIN, ctx.check(counts >= PWMSAR_FLOOR_MIN,
"the floor lands at PWMSAR %d, below the %d the tube needs to lase", "the floor lands at PWMSAR %d, below the %d the tube needs",
counts, PWMSAR_FLOOR_MIN) counts, PWMSAR_FLOOR_MIN)
ctx.check(rpm_min == 0, "$31 is %s, not 0: the bottom of the S range is no longer the floor", ctx.check(rpm_min == 0, "$31 is %s, not 0: the bottom of the S range is no longer the floor",
rpm_min) rpm_min)
@@ -2,5 +2,5 @@
# changes; keep only SRCREV and PV here - the image manifest leaves # changes; keep only SRCREV and PV here - the image manifest leaves
# *-pin.inc out of the layer content hash because the component entry # *-pin.inc out of the layer content hash because the component entry
# already identifies the pinned source (forgefirm-image-manifest.bbclass). # already identifies the pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "f7e8c17bb3065a6b27cef4bb1099c9564359980e" SRCREV = "03d70e01f6abc66faeeed6304ab2de02e4cadedc"
PV = "0.1.0" PV = "0.1.0"
+12 -9
View File
@@ -64,13 +64,15 @@ BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build-native/grblHA
PORT = 2399 PORT = 2399
STEPS_PER_MM = 53.333 STEPS_PER_MM = 53.333
# The S -> duty mapping the board defaults produce: $30 = 1000, $31 = 0, # The S -> level mapping the board defaults produce: $30 = 1000, $31 = 0,
# and a $35 floor (boards/glowforge.h DEFAULT_SPINDLE_PWM_MIN_VALUE) # and a $35 floor (boards/glowforge.h DEFAULT_SPINDLE_PWM_MIN_VALUE)
# against the hardware's 127-count period. Changing the board's floor # against the hardware's 127-count period. The shipped floor is the
# density one; the analog sessions below select their model explicitly
# rather than inheriting the default, so both paths stay covered. Changing the board's floor
# changes every expectation below, which is why it is mirrored here # changes every expectation below, which is why it is mirrored here
# rather than inferred from the stream. # rather than inferred from the stream.
PWM_PERIOD = 127 PWM_PERIOD = 127
PWM_MIN_PCT = 16.0 PWM_MIN_PCT = 10.0
PWM_MIN = int(PWM_PERIOD * PWM_MIN_PCT / 100.0) PWM_MIN = int(PWM_PERIOD * PWM_MIN_PCT / 100.0)
RPM_MAX = 1000.0 RPM_MAX = 1000.0
@@ -139,6 +141,7 @@ JOB_LADDER.append("M5")
# the floor exists only to keep an analog duty out of the tube's dead # 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 # band - under density every pulse is full-power, and a floor would just
# clamp the light end of the range. # clamp the light end of the range.
ANALOG_CONF = "laser_power_model = analog\n"
DENSITY_PERIOD = 20 DENSITY_PERIOD = 20
DENSITY_MIN_TICKS = 3 DENSITY_MIN_TICKS = 3
DENSITY_CONF = ("laser_power_model = density\n" DENSITY_CONF = ("laser_power_model = density\n"
@@ -550,7 +553,7 @@ def count_fire(data):
def main(): def main():
# --- session A: M4 dynamic power, rules 1-6 + 7-8 ------------------- # --- session A: M4 dynamic power, rules 1-6 + 7-8 -------------------
data = run_session("m4", JOB_M4) data = run_session("m4", JOB_M4, conf=ANALOG_CONF)
fire_ticks, powers, x_max, tail_steps = check_m4_job(data) fire_ticks, powers, x_max, tail_steps = check_m4_job(data)
check_termination("m4", data) check_termination("m4", data)
gap_a = check_fire_gaps("m4", data) gap_a = check_fire_gaps("m4", data)
@@ -560,7 +563,7 @@ def main():
powers, x_max, tail_steps, gap_a)) powers, x_max, tail_steps, gap_a))
# --- session B: M3 constant power to stream end, rule 7 ------------- # --- session B: M3 constant power to stream end, rule 7 -------------
data = run_session("m3-term", JOB_M3_TERM) data = run_session("m3-term", JOB_M3_TERM, conf=ANALOG_CONF)
if not count_fire(data): if not count_fire(data):
fail("[m3-term] no FIRE bits in the stream") fail("[m3-term] no FIRE bits in the stream")
check_termination("m3-term", data) check_termination("m3-term", data)
@@ -569,7 +572,7 @@ def main():
% (len(data), count_fire(data), gap_b)) % (len(data), count_fire(data), gap_b))
# --- session C: cycle churn, rules 8-9 ------------------------------ # --- session C: cycle churn, rules 8-9 ------------------------------
data = run_session("churn", JOB_CHURN) data = run_session("churn", JOB_CHURN, conf=ANALOG_CONF)
if not count_fire(data): if not count_fire(data):
fail("[churn] no FIRE bits in the stream") fail("[churn] no FIRE bits in the stream")
check_termination("churn", data) check_termination("churn", data)
@@ -578,7 +581,7 @@ def main():
% (len(data), count_fire(data), gap_c)) % (len(data), count_fire(data), gap_c))
# --- session D: power ladder, rule 10 ------------------------------- # --- session D: power ladder, rule 10 -------------------------------
data = run_session("ladder", JOB_LADDER) data = run_session("ladder", JOB_LADDER, conf=ANALOG_CONF)
counts = check_power_ladder("ladder", data, LADDER_DUTY) counts = check_power_ladder("ladder", data, LADDER_DUTY)
check_termination("ladder", data) check_termination("ladder", data)
gap_d = check_fire_gaps("ladder", data) gap_d = check_fire_gaps("ladder", data)
@@ -632,7 +635,7 @@ def main():
% (len(ch), count_fire(ch), gap_e)) % (len(ch), count_fire(ch), gap_e))
# --- session H: a level change inside a run costs no byte ----------- # --- session H: a level change inside a run costs no byte -----------
lv_a = run_session("levels-analog", JOB_LEVELS) lv_a = run_session("levels-analog", JOB_LEVELS, conf=ANALOG_CONF)
lv_d = run_session("levels-density", JOB_LEVELS, conf=DENSITY_CONF) lv_d = run_session("levels-density", JOB_LEVELS, conf=DENSITY_CONF)
pa = [b & 0x7F for b in lv_a if b & 0x80] pa = [b & 0x7F for b in lv_a if b & 0x80]
pd = [b & 0x7F for b in lv_d if b & 0x80] pd = [b & 0x7F for b in lv_d if b & 0x80]
@@ -649,7 +652,7 @@ def main():
% (len(pa), sorted(set(pa)), len(pd))) % (len(pa), sorted(set(pa)), len(pd)))
# --- session I: a level set while idle still cuts (rule 14) --------- # --- session I: a level set while idle still cuts (rule 14) ---------
idle_s = run_session("idle-s", JOB_IDLE_S) idle_s = run_session("idle-s", JOB_IDLE_S, conf=ANALOG_CONF)
fire_by_duty = {} fire_by_duty = {}
cur = None cur = None
for b in idle_s: for b in idle_s: