diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 9e8884e..58bb55b 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -212,9 +212,16 @@ 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 +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. 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. @@ -1083,8 +1090,35 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`. 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 + The base period is settled: four bench ladders (F300 at periods 20, 40 + and 10, then F100 at 20) put the same six rungs on the material every + time, and the matched pairs across periods separate the variables - at + identical pulse length, halving density killed the mark; at identical + density, varying pulse length 3x changed nothing. Feed did not move it + either: 10 % at F100 carries 44 % more energy per millimeter than 20 % + at F300, which marks, and still left nothing. So the low-end marking + limit is average power, not dose per length and not pulse length, and + the period can be chosen on other grounds. It stays at 20. + + What the ladders did expose is a floor of our own making, since fixed: + at 5 % the model emitted 36 us stubs and the `hv_current` trace shows + **no discharge at all** for that rung, while 10 % drew current for its + full 15 s and simply marked nothing. The factory never emits below one + 100 us tick, and reaches low density by skipping windows instead - + which is now what `laser_pulse_min_ticks` does. + + Owed: the user-facing scale. The factory maps its whole 1-100 power + scale onto density 18.9-79.5 % (fit from the three captures; Full Power + is off that line at ~99.7 %), so its "1 %" is the bottom of the band + that does useful work rather than 1 % of the physical range. Under the + density model `$35` and `$36` are exactly that control - a density + floor and ceiling - so the scale is a settings choice, not new code. + The floor's value wants one more ladder: the step from 10 % to 20 % is + coarse, and the factory's own answer is 18.9 %. Note the captures also + run 6.5-18.8 % density on other jobs, so that intercept is a product + decision about cutting, not a physical limit - which is why the + minimum-pulse fix matters for the raster low end regardless of where + the cut scale starts. 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 diff --git a/docs/CAMPAIGN-LOG.md b/docs/CAMPAIGN-LOG.md index 77031b7..e27d2f2 100644 --- a/docs/CAMPAIGN-LOG.md +++ b/docs/CAMPAIGN-LOG.md @@ -2860,6 +2860,100 @@ 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. +## 2026-08-17 — the density ladders, and the minimum pulse + +Four live ladders on one piece of scrap, 8 rungs each from 5 % to 100 % of +dose at constant power: base period 20, 40 and 10 ticks at F300, then period +20 again at F100. The same six rungs marked every time — 20 % and up. 5 % and +10 % never marked in any of the four. + +### Pulse length is not the variable; average power is + +Because the same pulse length occurs at different densities across the +periods, the runs contain matched pairs: + +| pulse | density | period | marked | +|---|---|---|---| +| 107–142 µs | 20 % | 20 | yes | +| 107–142 µs | 10 % | 40 | no | +| 36–71 µs | 20 % | 10 | yes | +| 36–71 µs | 10 % | 20 | no | + +Hold the pulse and halve the density: the mark goes. Hold the density and +vary the pulse 3×: nothing changes. Feed does not move it either — 10 % at +F100 carries 0.0567 dose/mm against 20 % at F300's 0.0394, **44 % more energy +per millimeter than a rung that marks**, and it still left nothing. Two +independent variables moved without shifting the boundary. What sets the +low-end marking limit is average power reaching a quasi-steady surface +temperature; going slower does not help, because the heat conducts away +between pulses. + +So the base period can be chosen on other grounds, and stays at 20. + +### The trace separates two different failures + +The F100 run carried the `hv_current` trace (`pthresh` printed one, `dladder` +did not until this run — the omission cost the three F300 ladders their +per-rung witness). It shows **seven** current segments for eight rungs: +boundaries at 36.6, ~52.0, 67.3, 82.5, 98.0 and 113.2 s, each segment +14.4–14.9 s, one 25 mm rung at F100. The fire window is 106.8 s where eight +rungs would need 121.6 s. + +The final segment anchors the count: 113.5–128.4 s reads 943–986 dead flat, +the saturated steady current of continuous fire, which can only be 100 %. +Counting back, the segment means rise monotonically — 182, 320, 330, 390, +450, 540, 967 — for rungs 10 % through 100 %. Rung 1 has no segment at all: +its fifteen seconds are the zeros before 21.6 s, indistinguishable from the +arm wait because nothing happened in them. + +| rung | outcome | +|---|---| +| 5 % | **no discharge at all** | +| 10 % | discharge for the full 15 s, no mark | +| 20 %+ | discharge and mark | + +Supply current is not light — `pthresh` already showed this tube drawing +current across a whole band while emitting nothing — so "10 % struck" is not +"10 % lased". But 5 % not striking is unambiguous, and it is ours to fix. + +### The factory's own numbers, for scale + +Precision Power 1 runs the power byte at 127 (PWM duty 100 %) and a **FIRE +duty cycle of 19.53 %** — 1.371 on-ticks of every 7-tick window. Fitting the +three captures, the factory maps its entire 1–100 scale onto density +18.9–79.5 %, with Full Power off that line at ~99.7 %. Its "1 %" is the +bottom of the band that does useful work, not 1 % of the physical range — +which is why no user ever meets the dead zone. The older `_RESOURCES` +captures run 6.5–18.8 % density on other jobs, so 18.9 % is a product +decision about cutting, not a physical floor. + +### The fix: a minimum pulse width + +At 5 % the model emitted one-tick stubs, 36 µs, and the supply did not +strike. The factory never emits below one 100 us tick and reaches low density +by skipping windows instead. `laser_pulse_min_ticks` (default 3 = 106 µs) +does the same: when the computed on-count falls below the minimum the period +is skipped and the **whole** debt carried, rather than a stub emitted. The +debt is conserved, so the average density is untouched. + +Measured on the stream, level 2 (density 0.0159): + +| | bursts | density | +|---|---|---| +| minimum 1 tick | 444 × 36 µs | 0.0158 | +| minimum 3 ticks | **147 × 106 µs** | 0.0159 | + +147 × 3 = 441 against 444 — the same energy as fewer, longer pulses, and +every level already above the minimum is bit-identical, so the change touches +only what it must. Rule 15 in the stream harness holds both halves: no burst +below the minimum (excepting one clipped by fire going off mid-burst), and +the rendered density still exact. + +Owed next: the user-facing scale. Under this model `$35` and `$36` are a +density floor and ceiling, so mapping S onto the usable band is a settings +choice rather than new code — but the floor's value wants a finer ladder than +the 10 %→20 % step these four runs give. + ## Superseded status notes ### Shared machine services — remaining polish, as listed 2026-08-13 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 02ea7bf..98c4833 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 = "2bca017854291f4084ca2bdf6f1ffaed88ff6d4f" +SRCREV = "f7e8c17bb3065a6b27cef4bb1099c9564359980e" PV = "0.1.0" diff --git a/scripts/bench/laser_stream_test.py b/scripts/bench/laser_stream_test.py index 2dccc25..0544a2a 100644 --- a/scripts/bench/laser_stream_test.py +++ b/scripts/bench/laser_stream_test.py @@ -38,6 +38,10 @@ over TCP, then checks the dumps against the kernel feeder contract: 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 + 15. the minimum pulse width holds: no emitted burst is shorter than + laser_pulse_min_ticks, and the levels too faint to fill it still + render their exact average density - the debt is carried, so a low + level becomes fewer full-width pulses rather than stubs 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 @@ -136,8 +140,11 @@ JOB_LADDER.append("M5") # band - under density every pulse is full-power, and a floor would just # clamp the light end of the range. DENSITY_PERIOD = 20 +DENSITY_MIN_TICKS = 3 DENSITY_CONF = ("laser_power_model = density\n" - "laser_pulse_ticks = %d\n" % DENSITY_PERIOD) + "laser_pulse_ticks = %d\n" + "laser_pulse_min_ticks = %d\n" + % (DENSITY_PERIOD, DENSITY_MIN_TICKS)) DENSITY_LEVEL = tuple(int(x * PWM_PERIOD / RPM_MAX) for x in LADDER_S) JOB_DENSITY = ["$35=0"] + JOB_LADDER @@ -467,7 +474,7 @@ def fire_spans(ticks, gap=500): return spans -def check_density(name, data, levels, period): +def check_density(name, data, levels, period, min_ticks): """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: @@ -494,13 +501,28 @@ def check_density(name, data, levels, period): 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 + # Burst lengths inside the span. The last one can be clipped by + # the core turning fire off mid-burst, so it is not held to the + # minimum; every other burst is a whole pulse the model chose. + runs, run = [], 0 for t in seg: - run = run + 1 if t & 0x10 else 0 - worst = max(worst, run) - if worst > period: + if t & 0x10: + run += 1 + elif run: + runs.append(run) + run = 0 + if run: + runs.append(run) + if not runs: + fail("[%s] level %d produced no bursts at all" % (name, level)) + if max(runs) > period: fail("[%s] level %d burst of %d ticks exceeds the %d-tick base " - "period" % (name, level, worst, period)) + "period" % (name, level, max(runs), period)) + short = [r for r in runs[:-1] if r < min_ticks] + if short: + fail("[%s] level %d emitted %d burst(s) below the %d-tick minimum " + "(shortest %d): a stub too brief for the supply to strike" + % (name, level, len(short), min_ticks, min(short))) return out @@ -572,7 +594,8 @@ def main(): 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) + rendered = check_density("density", dens, DENSITY_LEVEL, DENSITY_PERIOD, + DENSITY_MIN_TICKS) check_termination("density", dens) if "laser armed (density)" not in run_session.text: fail("[density] the arm did not select the density model")