diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index a5547a6..0aae465 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -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). **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` +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 @@ -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 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 +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. @@ -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 this model. - Owed: the shipping defaults. The model, the minimum pulse and the - scale are all proven on hardware, but `laser_power_model` still - defaults to `analog`, so nothing of this reaches a machine until the - key is set. Flipping the default means `$35` must move with it - 16 is - the analog duty floor, 10 is the density floor, and the wrong pairing - 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 - millimeter is generous, and no raster has run at all. + **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. + + Owed: validation at production feeds. Every ladder behind these + 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 scale onto density 18.9-79.5 % (fit from the three captures; Full Power diff --git a/docs/CAMPAIGN-LOG.md b/docs/CAMPAIGN-LOG.md index 7060a86..3397c21 100644 --- a/docs/CAMPAIGN-LOG.md +++ b/docs/CAMPAIGN-LOG.md @@ -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 factory uses, arrived at from this bench's own measurements. -Owed: the shipping defaults. `laser_power_model` still defaults to `analog`, -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 -pairing is a dead band either way). Both want one real job at a production -feed first — every ladder here ran at F300 or F100, where dose per millimeter -is generous, and no raster has run at all. +### The defaults flipped + +`laser_power_model` now defaults to `density` and `$35` to 10, so a stock +machine runs the model and a commanded 1 % marks. The analog path stays as an +explicit `laser_power_model = analog`. + +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 diff --git a/forgetest/forgetest/suite/laser.py b/forgetest/forgetest/suite/laser.py index 03a71d5..6e8532b 100644 --- a/forgetest/forgetest/suite/laser.py +++ b/forgetest/forgetest/suite/laser.py @@ -26,10 +26,13 @@ _LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "* ("forgectrl", "src/status.c"), ("forgectrl", "src/main.c")] # 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. -POWER_FLOOR_PCT = 16.0 +# 127-count PWM period. Under the shipped FIRE-density dose model this is +# 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 -PWMSAR_FLOOR_MIN = 20 +PWMSAR_FLOOR_MIN = 12 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 " @@ -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", subsystem="laser", kind="auto", est_min=1, covers=[("grblhal-glowforge", "src/**")], - description="The tube lases only above ~16 % duty, so $35 must floor every nonzero S there: " - "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 " + description="$35 floors the bottom of the laser's output range, and unfloored the low end " + "of S asks for pulses too far apart for the discharge to re-strike - a " + "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 " - "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.") def power_floor(ctx): ev = ctx.evidence @@ -263,7 +266,7 @@ def power_floor(ctx): "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", + "the floor lands at PWMSAR %d, below the %d the tube needs", 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", rpm_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 98c4833..f36f2b6 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 = "f7e8c17bb3065a6b27cef4bb1099c9564359980e" +SRCREV = "03d70e01f6abc66faeeed6304ab2de02e4cadedc" PV = "0.1.0" diff --git a/scripts/bench/laser_stream_test.py b/scripts/bench/laser_stream_test.py index 0544a2a..c3100e0 100644 --- a/scripts/bench/laser_stream_test.py +++ b/scripts/bench/laser_stream_test.py @@ -64,13 +64,15 @@ BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build-native/grblHA PORT = 2399 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) -# 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 # rather than inferred from the stream. PWM_PERIOD = 127 -PWM_MIN_PCT = 16.0 +PWM_MIN_PCT = 10.0 PWM_MIN = int(PWM_PERIOD * PWM_MIN_PCT / 100.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 # band - under density every pulse is full-power, and a floor would just # clamp the light end of the range. +ANALOG_CONF = "laser_power_model = analog\n" DENSITY_PERIOD = 20 DENSITY_MIN_TICKS = 3 DENSITY_CONF = ("laser_power_model = density\n" @@ -550,7 +553,7 @@ def count_fire(data): def main(): # --- 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) check_termination("m4", data) gap_a = check_fire_gaps("m4", data) @@ -560,7 +563,7 @@ def main(): powers, x_max, tail_steps, gap_a)) # --- 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): fail("[m3-term] no FIRE bits in the stream") check_termination("m3-term", data) @@ -569,7 +572,7 @@ def main(): % (len(data), count_fire(data), gap_b)) # --- 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): fail("[churn] no FIRE bits in the stream") check_termination("churn", data) @@ -578,7 +581,7 @@ def main(): % (len(data), count_fire(data), gap_c)) # --- 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) check_termination("ladder", data) gap_d = check_fire_gaps("ladder", data) @@ -632,7 +635,7 @@ def main(): % (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_a = run_session("levels-analog", JOB_LEVELS, conf=ANALOG_CONF) 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] @@ -649,7 +652,7 @@ def main(): % (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) + idle_s = run_session("idle-s", JOB_IDLE_S, conf=ANALOG_CONF) fire_by_duty = {} cur = None for b in idle_s: