diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 1cb35db..eb5bc86 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -250,11 +250,18 @@ pairs, ships with the bench-measured default compiled in, accepts `off` for the identity, falls back loudly on a bad value, and is reloaded at every precompute with the arm naming it (`laser armed (density, floor 10 %, curve bench-default)`). `$35`/`$36` still floor -and ceil the result. An owner measures their own curve from the panel: -the dose-curve recorder hands them a ladder G-code to run from their -sender - every arm gate stands, forgectrl records the tube current and -the head thermopile passively, fits the rungs, and Apply writes the -result (`forgectrl/docs/SERVICES.md`). Rasters hold their tonality down +and ceil the result. Under M4 the curve makes delivered light exactly +proportional to velocity - and heat still accumulates where the head +slows, so corners over-burned on the bench; `laser_corner_gamma` +(default 2, range 0.25 to 4) bends the rolloff to light proportional to +(v/v_programmed)^gamma, starving the slow spots the way the raw convex +mapping used to by accident, while the programmed level at speed stays +exactly the curve's. An owner measures their own curve from the panel: +the dose-curve recorder streams the ladder job itself from one Record +press (absolute from X0 Y0, refused while a sender is connected; the +operator's button press starts the fire with every arm gate standing), +records the tube current and the head thermopile, fits the rungs, and +Apply writes the result (`forgectrl/docs/SERVICES.md`). Rasters hold their tonality down to ~14 pulse slots per pixel (508 DPI at 6000 mm/min): the dither accumulator's cross-pixel averaging recovers the levels, with no visible dither pattern. diff --git a/docs/MOTION.md b/docs/MOTION.md index 6c3a0e4..9c36b7a 100644 --- a/docs/MOTION.md +++ b/docs/MOTION.md @@ -428,6 +428,7 @@ only be changed while the machine is idle. | `laser_disarm_s` | 60 | Spindle-off grace before the armed window closes. | | `laser_floor_density` | 10 | The S-range floor, percent of full: the lowest pulse density that still marks. Loaded into `$35` at every spindle precompute; `$35` is derived, never typed. | | `laser_dose_curve` | (bench default) | The measured dose curve as density:light percent pairs; S commands a light fraction and the driver maps it onto the density that delivers it. `off` = identity; a bad value falls back to the default. The panel's recorder measures and applies a machine's own. | +| `laser_corner_gamma` | 2 | The corner rolloff under M4: delivered light follows (v/v_programmed)^gamma, so 1 is plain proportionality and higher values starve the slow spots where heat accumulates. Rides the curve. | | `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. | diff --git a/forgetest/forgetest/suite/forgectrl.py b/forgetest/forgetest/suite/forgectrl.py index eb832cb..d1c8044 100644 --- a/forgetest/forgetest/suite/forgectrl.py +++ b/forgetest/forgetest/suite/forgectrl.py @@ -223,7 +223,7 @@ def settings_bounds(ctx): covers=[("forgectrl", "src/ui.*"), ("forgectrl", "src/ui/**"), ("forgectrl", "src/status.*"), ("forgectrl", "src/cam.c"), ("forgectrl", "src/main.c"), ("forgectrl", "src/super.c"), ("grblhal-glowforge", "src/glowforge_status.c"), ("grblhal-glowforge", "src/serial.c"), - ("forgectrl", "src/curverec.c")], + ("forgectrl", "src/curverec.*")], description="The panel page is served, /status carries the machine telemetry the panel and " "the acceptance tool read (including the sys block: CPU busy percent over the " "interval since the previous read, memory used percent), and /cam/status " diff --git a/scripts/bench/laser_stream_test.py b/scripts/bench/laser_stream_test.py index ab5940a..94cf61e 100644 --- a/scripts/bench/laser_stream_test.py +++ b/scripts/bench/laser_stream_test.py @@ -68,6 +68,11 @@ over TCP, then checks the dumps against the kernel feeder contract: near 80 percent density), monotonic, floored and ceiled by $35/$36; every other session runs with laser_dose_curve = off so its S-to-level arithmetic stays exact + 20. the corner rolloff starves the slow spots: under M4 with the curve + in force, the accelerate-in head of a line renders less density at + the default gamma of 2 than at gamma 1, and the cruise middle + renders the same - the exponent shapes only the velocity-scaled + rolloff, never the programmed level The analog sessions select the reference mode through the config; on hardware the controller ignores it (density is the only product model - @@ -861,6 +866,41 @@ def main(): "ladder renders through the %g %% floor key, levels %s" % (PWM_MIN_PCT, list(expect_levels))) + # --- rule 20: the corner rolloff starves the accel head ------------- + # One long M4 line from rest under the curve, at gamma 1 and the + # default 2. The accelerate-in head runs velocity-scaled; its + # rendered density must drop with the exponent while the cruise + # middle stays put. + # F6000 = 100 mm/s: the accel from rest lasts ~143 ms (~4000 ticks), + # so the first 2000 ticks are genuinely velocity-scaled. + JOB_M4_LONG = ["G91", "G21", "M4 S1000", "G1 X60 F6000", "M5"] + head_ticks = 2000 + dens_head = {} + dens_mid = {} + for gname, gconf in (("g1", "laser_corner_gamma = 1\n"), ("g2", "")): + data = run_session("rolloff-" + gname, JOB_M4_LONG, + conf=DENSITY_CONF_CURVED + gconf) + ticks = tick_bytes(data) + spans = fire_spans(ticks) + if len(spans) != 1: + fail("[rolloff-%s] %d fire spans, expected 1" % (gname, len(spans))) + a, b = spans[0] + seg = ticks[a:b] + head = seg[:head_ticks] + mid_a = len(seg) // 2 - 2000 + mid = seg[mid_a:mid_a + 4000] + dens_head[gname] = sum(1 for t in head if t & 0x10) / float(len(head)) + dens_mid[gname] = sum(1 for t in mid if t & 0x10) / float(len(mid)) + if not dens_head["g2"] < dens_head["g1"] - 0.02: + fail("[rolloff] gamma 2 does not starve the accel head (g1 %.3f, g2 %.3f)" + % (dens_head["g1"], dens_head["g2"])) + if abs(dens_mid["g2"] - dens_mid["g1"]) > 0.02: + fail("[rolloff] gamma changed the cruise density (g1 %.3f, g2 %.3f): it " + "must shape only the rolloff" % (dens_mid["g1"], dens_mid["g2"])) + print("PASS [rolloff]: accel-head density %.3f at gamma 1 -> %.3f at the " + "default 2; cruise %.3f alike" % (dens_head["g1"], dens_head["g2"], + dens_mid["g1"])) + # --- rule 19: the dose curve bends S onto delivered light ----------- cur = run_session("curve", JOB_CURVE, conf=DENSITY_CONF_CURVED) if "curve bench-default" not in run_session.text: