diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index efe5e9b..4cd9997 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -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 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 diff --git a/docs/CAMPAIGN-LOG.md b/docs/CAMPAIGN-LOG.md index f6ff078..d1af650 100644 --- a/docs/CAMPAIGN-LOG.md +++ b/docs/CAMPAIGN-LOG.md @@ -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 diff --git a/docs/LIGHTBURN.md b/docs/LIGHTBURN.md index 5c44b6b..543d0f7 100644 --- a/docs/LIGHTBURN.md +++ b/docs/LIGHTBURN.md @@ -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. diff --git a/docs/MOTION.md b/docs/MOTION.md index 8ccc7f6..0b50860 100644 --- a/docs/MOTION.md +++ b/docs/MOTION.md @@ -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. | diff --git a/docs/SAFETY.md b/docs/SAFETY.md index 2190d8c..5cee1cf 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -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 diff --git a/forgetest/forgetest/suite/laser.py b/forgetest/forgetest/suite/laser.py index d8958a4..df04145 100644 --- a/forgetest/forgetest/suite/laser.py +++ b/forgetest/forgetest/suite/laser.py @@ -1,7 +1,8 @@ """laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`. -Every test here can emit, except `laser.power-floor`, which only reads -settings. The page starts a firing one only with the operator's +Every test here can emit, except `laser.power-floor` and +`laser.power-model-switch`, which only read and switch settings with the +spindle off. The page starts a firing one only with the operator's eye-protection / fire-watch / exhaust acknowledgment; the test then prompts for the scrap and the button, streams a small job through the controller, and the machine fires only after the operator presses the @@ -31,14 +32,41 @@ _LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "* ("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"), ("forgectrl", "src/status.c"), ("forgectrl", "src/main.c")] -# boards/glowforge.h: DEFAULT_SPINDLE_PWM_MIN_VALUE against the hardware's -# 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 +# The per-model S-range floors the controller loads into $35 at every +# arm and switch (glowforge_laser.c: laser_floor_density / +# laser_floor_analog, with these defaults when the config carries no +# key). Density: the lowest pulse density that still marks. Analog: the +# duty this tube lases at, against the hardware's 127-count PWM period. +FLOOR_DEFAULT = {"density": 10.0, "analog": 16.0} PWM_PERIOD = 127 -PWMSAR_FLOOR_MIN = 12 +PWMSAR_FLOOR_MIN = {"density": 12, "analog": 20} + + +def _model_floors(fc): + """(configured model, {model: floor percent}) from forgectrl's settings: + the config keys the controller derives $35 from, defaults applied.""" + cfg = fc.settings() + model = (cfg.get("laser_power_model") or "density").strip() or "density" + floors = {} + for m in ("density", "analog"): + raw = (cfg.get("laser_floor_%s" % m) or "").strip() + try: + floors[m] = float(raw) if raw else FLOOR_DEFAULT[m] + except ValueError: + floors[m] = FLOOR_DEFAULT[m] + return model, floors + + +def _grbl_settings(lines): + vals = {} + for ln in lines: + key, _, val = ln.partition("=") + if key.startswith("$"): + try: + vals[key] = float(val) + except ValueError: + pass + return vals 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. On Ready the job starts: " @@ -281,27 +309,35 @@ def wait_disarm(ctx, timeout): return None -@test("laser.power-floor", title="The shipped duty floor holds commanded power above the lasing threshold", +@test("laser.power-floor", title="The configured model's floor is the one in force", subsystem="laser", kind="auto", mode="grbl", est_min=1, - covers=[("grblhal-glowforge", "src/**")], - 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 it lands at or above the measured minimum. Reports the " - "stored setting, which is the one in force after any controller start.") + covers=[("grblhal-glowforge", "src/**"), ("forgectrl", "src/main.c")], + description="The controller derives $35 from the configured dose model's floor key " + "(laser_floor_density / laser_floor_analog) at every arm and switch, and " + "never from a typed value. Unfloored, the low end of S asks for pulses too " + "far apart to re-strike (density) or a duty in the tube's dead band " + "(analog), so a commanded 1 % would emit nothing. Reads the model and the " + "floors from forgectrl, sends an M101 for the configured model so the " + "derivation runs without a fire, then reads $$ and checks $35 is that " + "model's floor, that it lands at or above the measured minimum, and that " + "$31 is 0 (the floor, not $31, sets the bottom of the range).") def power_floor(ctx): ev = ctx.evidence + model, floors = _model_floors(ctx.forgectrl) + ev["configured"] = {"model": model, "floors": floors} + ctx.log("configured model %s, floors %s", model, floors) with ctx.grbl() as g: + # The switch for the model already selected: the derivation runs + # at the switch as at the arm, spindle off, nothing fires, and the + # program-scoped override is cleared by the M2 that follows. + reply = g.command("M101 P%d" % (1 if model == "density" else 0), timeout=5) + ev["m101"] = reply + ctx.check(any(ln.startswith("ok") for ln in reply) and + not any(ln.startswith("error") for ln in reply), + "M101 for the configured model was not accepted: %s", reply) lines = g.command("$$", timeout=5) - vals = {} - for ln in lines: - key, _, val = ln.partition("=") - if key.startswith("$"): - try: - vals[key] = float(val) - except ValueError: - pass + g.command("M2", timeout=5) + vals = _grbl_settings(lines) ctx.check(vals, "no $-settings in the response to $$") floor_pct = vals.get("$35") @@ -313,17 +349,57 @@ def power_floor(ctx): ctx.log("$35=%.1f%% -> PWMSAR %d of %d ($31=%s, $32=%s, $36=%s)", floor_pct, counts, PWM_PERIOD, rpm_min, vals.get("$32"), vals.get("$36")) - ctx.check(abs(floor_pct - POWER_FLOOR_PCT) < 0.05, - "$35 is %.1f %%, expected the commissioned %.1f %% - a machine carrying stored " - "settings from before the floor needs `$35=16` (or `$RST=$`) and then a " - "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", - counts, PWMSAR_FLOOR_MIN) + want = floors[model] + ctx.check(abs(floor_pct - want) < 0.05, + "$35 is %.1f %%, expected the %s floor %.1f %% from the machine config", + floor_pct, model, want) + ctx.check(counts >= PWMSAR_FLOOR_MIN[model], + "the %s floor lands at PWMSAR %d, below the %d the tube needs", + model, counts, PWMSAR_FLOOR_MIN[model]) ctx.check(rpm_min == 0, "$31 is %s, not 0: the bottom of the S range is no longer the floor", rpm_min) - ctx.log("PASS: nonzero S never commands less than PWMSAR %d", counts) + ctx.log("PASS: under %s, nonzero S never commands less than PWMSAR %d", model, counts) + + +@test("laser.power-model-switch", title="M101 switches the dose model and its floor, and reverts at M2", + subsystem="laser", kind="auto", mode="grbl", est_min=1, + covers=[("grblhal-glowforge", "src/glowforge_laser.c"), ("grblhal-glowforge", "src/driver.c"), + ("forgectrl", "src/main.c")], + description="A job selects its dose model with M101 P0 (analog) or M101 P1 (density) " + "after an M5; the controller loads that model's floor into $35 on the spot " + "and reverts to the configured default at program end unless the line " + "carried Q1. With the spindle off and nothing armed: switches to each model " + "and reads $35 back as that model's floor, checks the reported message names " + "the model and the floor, then sends M2 and checks the configured model's " + "floor is back. Nothing fires: the switch is refused with the spindle on, " + "and no M3 is sent.") +def power_model_switch(ctx): + ev = ctx.evidence + model, floors = _model_floors(ctx.forgectrl) + other = "analog" if model == "density" else "density" + ev["configured"] = {"model": model, "floors": floors} + with ctx.grbl() as g: + for m in (other, model, other): + reply = g.command("M101 P%d" % (1 if m == "density" else 0), timeout=5) + text = "\n".join(reply) + ctx.check(any(ln.startswith("ok") for ln in reply), "M101 -> %s not accepted: %s", m, reply) + ctx.check("laser power model set for this program (%s, floor %g %%)" % (m, floors[m]) in text, + "the switch to %s was not reported with its floor: %s", m, reply) + vals = _grbl_settings(g.command("$$", timeout=5)) + ev["after_%s" % m] = vals.get("$35") + ctx.check(vals.get("$35") is not None and abs(vals["$35"] - floors[m]) < 0.05, + "after M101 -> %s, $35 is %s, expected %.1f", m, vals.get("$35"), floors[m]) + reply = g.command("M2", timeout=5) + text = "\n".join(reply) + vals = _grbl_settings(g.command("$$", timeout=5)) + ev["after_m2"] = vals.get("$35") + ctx.check("laser power model reverted (%s, floor %g %%)" % (model, floors[model]) in text, + "M2 did not report the revert to %s: %s", model, reply) + ctx.check(vals.get("$35") is not None and abs(vals["$35"] - floors[model]) < 0.05, + "after M2, $35 is %s, expected the configured %s floor %.1f", + vals.get("$35"), model, floors[model]) + ctx.log("PASS: M101 loads %s's floor %g and %s's floor %g; M2 reverts to %s", + other, floors[other], model, floors[model], model) @test("laser.emission-witness", title="Live emission witness (S400 vector mark) and job-based disarm", diff --git a/scripts/bench/README.md b/scripts/bench/README.md index 5f8244a..05feedb 100644 --- a/scripts/bench/README.md +++ b/scripts/bench/README.md @@ -32,7 +32,7 @@ page's takeover does that; from a host, stop them first. | `gate_a_kernel_drills.py` | Kernel laser-safety drills (run on the board with forgectrl stopped so the pulse device is free): `K1` controlled-stop deceleration floor, `K2` resume waypoint honors the locked latch, `K3` a mid-ramp latch unlock never re-arms the FIRE drive. Software witnesses (`cnc/state`, `laser_enable`, `laser_on`, `laser_on_sampled`, interlock bit 3) plus the PSU-connector LASER_ON scope point; K3 refuses to run if HV reports good. | | `laser_stream_test.py` | Host-side laser pulse-stream emission harness: runs the native null-sink controller with `GFSINK_DUMP`, drives small laser jobs over TCP, and checks the dumped bytes against the kernel feeder contract (leading power byte, no back-to-back power bytes, FIRE only inside cutting moves, every stream ends FIRE-clear, no FIRE on a stepless gap, no FIRE leak across cycle churn, the rapids after an M5 executed at idle ship dark, and the next job in the same process fires at the level the previous one ended at). Runs in the grblHAL repo's CI. | | `laser_lifecycle_test.py` | Host-side operator-armed-window lifecycle harness (null-sink controller): arm once per job with M5/M3 persistence, the M2 close, sender-change re-consent, the disarm grace counting down in Hold, and arm refusal under a blocking cooling verdict. Runs in the grblHAL repo's CI. | -| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the dose curve of the configured `laser_power_model`: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density or duty; the operator matches each row-B patch to the row-A patch of equal depth, which reads the model's light fraction off the material beside the thermopile's prediction; JSON record), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. | +| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the dose curve of the configured `laser_power_model`: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density or duty; the operator matches each row-B patch to the row-A patch of equal depth, which reads the model's light fraction off the material beside the thermopile's prediction; JSON record), `mswitch` (the M101 dose-model switch on the machine, one armed run: a density line at S500, M5, M101 P0, the same line back under analog, M2; asserts the arm/switch/revert reports with each model's floor, $$ showing the floor in force, no re-prompt across the switch, exactly two discharge segments with a pulsed character under density and a steady one under analog, and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. | | `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. | | `gfbench.py` | Not a tool: the helper the board/host tools share - `HOST`/`LOCAL` from `GF_HOST`, `board(cmd)` (local `sh -c` or ssh), the factory coolant conversion `degc()`, `data_path()` (`FORGETEST_BENCH_DATA` or next to the tool), forgectrl's HTTP API with the panel token, `setting(key)` (from forgectrl, or from `/data/forgefirm.conf` on the board while forgectrl is stopped). | | `fan_test.py` | Fan/coolant bench (board or host; controller running): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. | diff --git a/scripts/bench/laser_stream_test.py b/scripts/bench/laser_stream_test.py index bbe1c08..b51f1d3 100644 --- a/scripts/bench/laser_stream_test.py +++ b/scripts/bench/laser_stream_test.py @@ -58,6 +58,21 @@ over TCP, then checks the dumps against the kernel feeder contract: the M3 that opens the next job is the only thing that can light its first move - set_state must push the whole state, fire included, never the duty alone + 18. the floor is derived, never typed: $35 is loaded from the selected + model's floor key (laser_floor_density / laser_floor_analog) at + the arm, so a $35 typed by the sender is overwritten - the ladder + renders through the key's floor, and the arm report names the + model and the floor in force + 19. M101 switches the model at a boundary and nowhere else: with the + spindle off and the controller idle, the rendering changes exactly + at the switch (analog duties before, pinned full duty and dithered + FIRE after, or the reverse) and there is no torn transition - no + window of continuous FIRE at full duty anywhere across it + 20. M101 is refused with the spindle on (error 253, the reason + reported), and the stream is unchanged by the refusal + 21. a switch is program-scoped: M2 reverts it to the boot default and + the next job renders under the default, while M101 ... Q1 sticks + across M2 Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge) """ @@ -153,16 +168,99 @@ 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" +# The floors are config keys, loaded into $35 at every arm (rule 18). +# The analog sessions pin theirs at the board's density floor so the +# duty expectations above hold unchanged; the analog default is the +# tube's lasing duty (16), covered by the switch sessions below. +ANALOG_FLOOR_DEFAULT_PCT = 16.0 +ANALOG_CONF = ("laser_power_model = analog\n" + "laser_floor_analog = %g\n" % PWM_MIN_PCT) DENSITY_PERIOD = 20 DENSITY_MIN_TICKS = 3 +DENSITY_CONF_BASE = ("laser_pulse_ticks = %d\n" + "laser_pulse_min_ticks = %d\n" + % (DENSITY_PERIOD, DENSITY_MIN_TICKS)) +# The density ladder runs unfloored: the floor exists only to keep an +# analog duty out of the tube's dead band, and here it would just clamp +# the light end of the range. A floor of 0 is honored as written. DENSITY_CONF = ("laser_power_model = density\n" - "laser_pulse_ticks = %d\n" - "laser_pulse_min_ticks = %d\n" - % (DENSITY_PERIOD, DENSITY_MIN_TICKS)) + "laser_floor_density = 0\n" + DENSITY_CONF_BASE) +# The shipped density default: no floor key, so the board's floor applies. +DENSITY_CONF_FLOORED = "laser_power_model = density\n" + DENSITY_CONF_BASE DENSITY_LEVEL = tuple(int(x * PWM_PERIOD / RPM_MAX) for x in LADDER_S) +# A $35 typed ahead of the job: rule 18 says the arm overwrites it. JOB_DENSITY = ["$35=0"] + JOB_LADDER + +def duty_for_floor(s, floor_pct): + """Duty the core computes for an S word against a given floor.""" + lo = int(PWM_PERIOD * floor_pct / 100.0) + return int(s * (PWM_PERIOD - lo) / RPM_MAX) + lo + + +# Sessions L-N: the M101 dose-model switch. One cut, the spindle off, +# the switch, a second cut at the same S. Each cut is a kernel run of +# its own (the planner drains at WAIT_IDLE), so the switch lands between +# runs, which is the only place it is allowed to. +SWITCH_S = 500 +SWITCH_MM = 5.0 +SWITCH_FEED = 600 +SWITCH_TICKS = SWITCH_MM / (SWITCH_FEED / 60.0) * 28160 + + +def job_switch(mcode): + return [ + "G91", "G21", + "M3 S%d" % SWITCH_S, + "G1 X%g F%d" % (SWITCH_MM, SWITCH_FEED), + WAIT_IDLE, ("sleep", 0.5), + "M5", + mcode, + "M3 S%d" % SWITCH_S, + "G1 X%g" % -SWITCH_MM, + WAIT_IDLE, ("sleep", 0.5), + "M5", + ] + + +JOB_SWITCH_A2D = job_switch("M101 P1") +JOB_SWITCH_D2A = job_switch("M101 P0") + +# The refusal: the switch arrives with the spindle still on. The stream +# must be what the job without the M101 would have produced. +JOB_SWITCH_REFUSED = [ + "G91", "G21", + "M3 S%d" % SWITCH_S, + "G1 X%g F%d" % (SWITCH_MM, SWITCH_FEED), + WAIT_IDLE, ("sleep", 0.5), + ("expect_error", "M101 P1"), + "G1 X%g" % -SWITCH_MM, + WAIT_IDLE, ("sleep", 0.5), + "M5", +] + + +def job_revert(mcode): + """Two programs: the first switches and ends in M2, the second cuts + at the same S with no switch of its own.""" + return [ + "G91", "G21", + mcode, + "M3 S%d" % SWITCH_S, + "G1 X%g F%d" % (SWITCH_MM, SWITCH_FEED), + WAIT_IDLE, ("sleep", 0.5), + "M5", "G90", "M2", ("sleep", 1.0), + "G91", + "M3 S%d" % SWITCH_S, + "G1 X%g" % -SWITCH_MM, + WAIT_IDLE, ("sleep", 0.5), + "M5", + ] + + +JOB_SWITCH_REVERT = job_revert("M101 P0") +JOB_SWITCH_STICKY = job_revert("M101 P0 Q1") + # Session H: three levels inside one kernel run. The moves are short and # fast so the planner never drains, and each carries its own S word, so # the level changes land mid-run. Analog pays a power byte per level; @@ -235,14 +333,14 @@ def fail(msg): sys.exit(1) -def send_line(sock, line, log): +def send_line(sock, line, log, expect_error=False): sock.sendall((line + "\n").encode()) while True: r = read_avail(sock, log, 5.0, until=("ok", "error")) if r is None: fail("no ok/error for %r" % line) - if r == "error": - fail("error response to %r" % line) + if (r == "error") != expect_error: + fail("%s response to %r" % (r, line)) return @@ -342,6 +440,11 @@ def run_session(name, steps, conf=None, workdir=None, keep=False, wait_idle(sock, log) elif isinstance(step, tuple) and step[0] == "sleep": time.sleep(step[1]) + elif isinstance(step, tuple) and step[0] == "expect_error": + send_line(sock, step[1], log, expect_error=True) + # The core skips every G-code line after an error until + # the sender resyncs with an empty line (or a $ command). + send_line(sock, "", log) else: send_line(sock, step, log) @@ -667,18 +770,14 @@ def main(): [counts[d] for d in LADDER_DUTY], gap_d)) # --- session E: the same ladder under the density model ------------- - # $35 is written by a first launch and takes effect on the second: - # the floor exists only to keep an analog duty out of the tube's - # dead band, and under density it would just clamp the light end. - wd = tempfile.mkdtemp(prefix="laser-test-") - 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) + # Unfloored through the config key (laser_floor_density = 0), which + # the arm loads into $35. + dens = run_session("density", JOB_LADDER, conf=DENSITY_CONF) 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") + if "laser armed (density, floor 0 %)" not in run_session.text: + fail("[density] the arm did not select the density model at floor 0") print("PASS [density]: %d bytes, %d power bytes all at full duty, " "level->density %s" % (len(dens), sum(1 for b in dens if b & 0x80), rendered)) @@ -689,7 +788,7 @@ def main(): "inside the %d the core commanded" % (d_fire, a_fire)) # --- session F: full level under the model is continuous fire ------- - full = run_session("density-full", ["$35=0"] + JOB_M3_TERM, conf=DENSITY_CONF) + full = run_session("density-full", JOB_M3_TERM, conf=DENSITY_CONF) ticks = tick_bytes(full) spans = fire_spans(ticks) if not spans: @@ -703,7 +802,7 @@ def main(): % (b - a)) # --- session G: churn under the model (rules 7-9 still hold) -------- - ch = run_session("density-churn", ["$35=0"] + JOB_CHURN, conf=DENSITY_CONF) + ch = run_session("density-churn", JOB_CHURN, conf=DENSITY_CONF) if not count_fire(ch): fail("[density-churn] no FIRE bits in the stream") check_termination("density-churn", ch) @@ -779,6 +878,171 @@ def main(): "G1; 2 fire spans of %s ticks" % (name, [s1 - s0 for s0, s1 in spans])) + # --- rule 18: the floor is derived from the key, never typed -------- + # The same ladder with a $35=0 typed ahead of it, under the shipped + # density default (no floor key): the arm loads the board's floor and + # every rung renders through it. + floored = run_session("floor-derived", JOB_DENSITY, conf=DENSITY_CONF_FLOORED) + expect_levels = tuple(duty_for(x) for x in LADDER_S) + check_density("floor-derived", floored, expect_levels, DENSITY_PERIOD, + DENSITY_MIN_TICKS) + if "laser armed (density, floor %g %%)" % PWM_MIN_PCT not in run_session.text: + fail("[floor-derived] the arm report does not name the derived floor " + "(text: %r)" % run_session.text[-400:]) + print("PASS [floor-derived]: a typed $35=0 is overwritten at the arm; the " + "ladder renders through the %g %% floor key, levels %s" + % (PWM_MIN_PCT, list(expect_levels))) + + # --- sessions L: the switch, both directions (rule 19) -------------- + def split_at_switch(data, marker): + """Byte offset of the first power byte satisfying marker(duty).""" + for i, b in enumerate(data): + if b & 0x80 and marker(b & 0x7F): + return i + return None + + def longest_burst(ticks): + run = worst = 0 + for t in ticks: + run = run + 1 if t & 0x10 else 0 + worst = max(worst, run) + return worst + + def fire_by_power(data): + """Fire ticks per power byte in force: the duties FIRE rode.""" + cur, out = None, {} + for b in data: + if b & 0x80: + cur = b & 0x7F + elif b & 0x10: + out[cur] = out.get(cur, 0) + 1 + return out + + # Analog -> density. Before the switch: analog duties, continuous + # FIRE. After: full duty only, FIRE dithered at the level, bursts no + # longer than the base period. + a2d = run_session("switch-a2d", JOB_SWITCH_A2D, conf=ANALOG_CONF) + text = run_session.text + if "laser power model set for this program (density, floor %g %%)" % PWM_MIN_PCT not in text: + fail("[switch-a2d] the switch was not reported (text: %r)" % text[-400:]) + cut = split_at_switch(a2d, lambda d: d == PWM_PERIOD) + if cut is None: + fail("[switch-a2d] no full-duty power byte after the switch: the density " + "model never took over") + before, after = a2d[:cut], a2d[cut:] + duty = duty_for(SWITCH_S) + # A run may lead with a dark duty-0 byte across the idle pads; what + # matters is the duty FIRE rides on each side of the switch. + if set(fire_by_power(before)) != {duty}: + fail("[switch-a2d] FIRE rode duties %s before the switch, expected only %d" + % (sorted(fire_by_power(before)), duty)) + if set(b & 0x7F for b in after if b & 0x80) != {PWM_PERIOD}: + fail("[switch-a2d] duties after the switch %s: a level reached PWMSAR " + "under density" % sorted(set(b & 0x7F for b in after if b & 0x80))) + tb, ta = tick_bytes(before), tick_bytes(after) + sb, sa = fire_spans(tb), fire_spans(ta) + if len(sb) != 1 or len(sa) != 1: + fail("[switch-a2d] fire spans before/after the switch %s / %s, expected " + "one each" % (sb, sa)) + if longest_burst(ta) > DENSITY_PERIOD: + fail("[switch-a2d] a %d-tick continuous FIRE burst at full duty after the " + "switch: a torn transition" % longest_burst(ta)) + dens_after = sum(1 for t in ta[sa[0][0]:sa[0][1]] if t & 0x10) / float(sa[0][1] - sa[0][0]) + if abs(dens_after - duty / float(PWM_PERIOD)) > 0.03: + fail("[switch-a2d] density after the switch %.3f, expected %.3f" + % (dens_after, duty / float(PWM_PERIOD))) + check_termination("switch-a2d", a2d) + print("PASS [switch-a2d]: analog duty %d before, full duty + density %.3f after, " + "longest burst %d <= %d" % (duty, dens_after, longest_burst(ta), DENSITY_PERIOD)) + + # Density -> analog. The analog default floor (16) applies after the + # switch, so the duty is the one that floor gives. + d2a = run_session("switch-d2a", JOB_SWITCH_D2A, conf=DENSITY_CONF_FLOORED) + text = run_session.text + if "laser power model set for this program (analog, floor %g %%)" % ANALOG_FLOOR_DEFAULT_PCT not in text: + fail("[switch-d2a] the switch was not reported with the analog floor (text: %r)" + % text[-400:]) + duty_a = duty_for_floor(SWITCH_S, ANALOG_FLOOR_DEFAULT_PCT) + cut = split_at_switch(d2a, lambda d: d == duty_a) + if cut is None: + fail("[switch-d2a] no analog power byte (%d) after the switch" % duty_a) + before, after = d2a[:cut], d2a[cut:] + if set(fire_by_power(before)) != {PWM_PERIOD}: + fail("[switch-d2a] FIRE rode duties %s before the switch, expected full only" + % sorted(fire_by_power(before))) + if set(b & 0x7F for b in before if b & 0x80) - {PWM_PERIOD, 0}: + fail("[switch-d2a] a density level shipped as a duty across the switch: " + "power bytes %s" % sorted(set(b & 0x7F for b in before if b & 0x80))) + if set(fire_by_power(after)) != {duty_a}: + fail("[switch-d2a] FIRE rode duties %s after the switch, expected only %d" + % (sorted(fire_by_power(after)), duty_a)) + ta = tick_bytes(after) + sa = fire_spans(ta) + if len(sa) != 1: + fail("[switch-d2a] fire spans after the switch %s, expected one" % sa) + got = sum(1 for t in ta[sa[0][0]:sa[0][1]] if t & 0x10) / float(sa[0][1] - sa[0][0]) + if got < 0.999: + fail("[switch-d2a] FIRE after the switch is not continuous (%.3f): the " + "dither is still masking under analog" % got) + check_termination("switch-d2a", d2a) + print("PASS [switch-d2a]: full duty + dither before, continuous FIRE at duty %d " + "(floor %g) after" % (duty_a, ANALOG_FLOOR_DEFAULT_PCT)) + + # --- session M: refused with the spindle on (rule 20) --------------- + ref = run_session("switch-refused", JOB_SWITCH_REFUSED, conf=ANALOG_CONF) + text = run_session.text + if "M5 first" not in text or "error:253" not in text: + fail("[switch-refused] the refusal was not reported as error 253 with its " + "reason (text: %r)" % text[-400:]) + if "laser power model set" in text: + fail("[switch-refused] the switch was applied despite the refusal") + if set(fire_by_power(ref)) != {duty}: + fail("[switch-refused] FIRE rode duties %s, expected only %d: the stream " + "changed under a refused switch" % (sorted(fire_by_power(ref)), duty)) + check_cut_spans("switch-refused", tick_bytes(ref), 2, SWITCH_TICKS, + "the two G1 moves, both analog") + check_termination("switch-refused", ref) + print("PASS [switch-refused]: M101 with the spindle on -> error:253, stream " + "unchanged (duty %d throughout)" % duty) + + # --- session N: program scope and Q1 (rule 21) ---------------------- + rev = run_session("switch-revert", JOB_SWITCH_REVERT, conf=DENSITY_CONF_FLOORED) + text = run_session.text + if "laser power model reverted (density, floor %g %%)" % PWM_MIN_PCT not in text: + fail("[switch-revert] M2 did not report the revert (text: %r)" % text[-600:]) + if text.count("laser armed (analog, floor %g %%)" % ANALOG_FLOOR_DEFAULT_PCT) != 1 or \ + text.count("laser armed (density, floor %g %%)" % PWM_MIN_PCT) != 1: + fail("[switch-revert] expected one analog arm then one density arm " + "(text: %r)" % text[-600:]) + cut = split_at_switch(rev, lambda d: d == PWM_PERIOD) + if cut is None: + fail("[switch-revert] the second job never rendered under density") + before, after = rev[:cut], rev[cut:] + if set(fire_by_power(before)) != {duty_a}: + fail("[switch-revert] first job FIRE rode duties %s, expected the analog %d" + % (sorted(fire_by_power(before)), duty_a)) + if longest_burst(tick_bytes(after)) > DENSITY_PERIOD: + fail("[switch-revert] continuous FIRE at full duty in the second job: the " + "revert did not restore the dither") + check_termination("switch-revert", rev) + print("PASS [switch-revert]: job 1 analog at duty %d, M2 reverts, job 2 density" + % duty_a) + + stk = run_session("switch-sticky", JOB_SWITCH_STICKY, conf=DENSITY_CONF_FLOORED) + text = run_session.text + if "reverted" in text: + fail("[switch-sticky] a Q1 switch was reverted at M2") + if text.count("laser armed (analog, floor %g %%)" % ANALOG_FLOOR_DEFAULT_PCT) != 2: + fail("[switch-sticky] expected both jobs to arm analog (text: %r)" % text[-600:]) + if set(fire_by_power(stk)) != {duty_a}: + fail("[switch-sticky] FIRE rode duties %s, expected the analog %d in both jobs" + % (sorted(fire_by_power(stk)), duty_a)) + check_cut_spans("switch-sticky", tick_bytes(stk), 2, SWITCH_TICKS, + "one G1 per job, both analog") + check_termination("switch-sticky", stk) + print("PASS [switch-sticky]: M101 P0 Q1 holds across M2; both jobs analog at " + "duty %d" % duty_a) + print("PASS: all stream emission rules hold") diff --git a/scripts/bench/live_fire_drills.py b/scripts/bench/live_fire_drills.py index 7878729..2301a6b 100644 --- a/scripts/bench/live_fire_drills.py +++ b/scripts/bench/live_fire_drills.py @@ -96,6 +96,16 @@ Drills (pass a name): nonzero again after its first zero past the line. Prints the 9 s after the line at 40 ms steps. The catalog's laser.m5-rapid-dark is its port. + mswitch The M101 dose-model switch on the machine, one armed run: + a 20 mm density line at S500, M5, M101 P0 (the reply and + the switch report are asserted), a 20 mm analog line back + at the same S, M5, M2. PASS when the arm names the model + and floor, the switch and the M2 revert are reported, $$ + shows each model's floor while it is in force, exactly two + discharge segments appear with nothing after them, the + window never re-prompts, and the current character flips + (pulsed spikes under density, steady under analog). + Requires laser_power_model = density (the default). dpatch Depth witness for the dose curve of the configured laser_power_model: two rows of small engraved patches (serpentine G1 fills) on the stock. Row A is CW (S1000) at @@ -808,8 +818,8 @@ def drill_dladder(g): line = [] if line: print(' ' + ' '.join(line)) - print('Check the controller said "laser armed (density)" - a plain') - print('"laser armed" means the analog path ran and this is a duty') + print('Check the controller said "laser armed (density, ...)" - an') + print('"(analog, ...)" arm means the analog path ran and this is a duty') print('ladder, not a density one.') print('\nRead the material: count rungs from the FIRST one drawn.') print('Two readings, and the second is the one only the bench can give:') @@ -2745,6 +2755,151 @@ def drill_m5dark(g): return 0 if ok else 1 +MSWITCH_S = 500 +MSWITCH_MM = 20.0 +MSWITCH_FEED = 600 + + +def drill_mswitch(g): + print('=== M101 on the machine: density line, switch, analog line, revert ===') + sampler = Sampler(PCURVE_SAMPLE_HZ) + if not sampler.local: + print('run this on the board: the witnesses are sysfs at 25 Hz') + return 2 + model = conf_get('laser_power_model') or 'density' + if model != 'density': + print('PRECONDITION FAILED: laser_power_model is %s, the drill asserts ' + 'the density default and its revert' % model) + return 2 + fails = [] + + def check(cond, msg): + print(' %s: %s' % ('ok' if cond else 'FAIL', msg)) + if not cond: + fails.append(msg) + + def logged(needle): + return any(needle in ln for _t, ln in g.log) + + def floor35(text): + for ln in text.splitlines(): + if ln.startswith('$35='): + try: + return float(ln[4:]) + except ValueError: + return None + return None + + print('connect: %s' % prepare(g)) + print('pre-fire: %s' % sample_forgectrl()) + arm_cue() + print('>>> %g mm of free +X travel at the head, scrap under it: one line' % MSWITCH_MM) + print('>>> out under density, the switch, the same line back under analog.\n') + sampler.start() + aborted = False + try: + for ln in ('G91', 'G21'): + g.cmd(ln) + # Section 1: density. The M3 blocks in the arm until the press. + g.s.sendall(b'M3 S%d\n' % MSWITCH_S) + g.s.sendall(('G1 X%g F%d\n' % (MSWITCH_MM, MSWITCH_FEED)).encode()) + st = g.wait_state('Run', 300) + if not st.startswith('Run'): + print('FAIL: the job never ran (state=%s)' % st) + g.rt(b'\x18') + return 1 + g.wait_state('Idle', 60) + time.sleep(0.5) + g.drain() + check(logged('laser armed (density, floor 10 %)'), + 'the arm names the density model and its floor') + # The switch, spindle off. + r = g.cmd('M5') + check('error' not in r, 'M5 accepted (%s)' % r.replace('\n', ' ')) + r = g.cmd('M101 P0', timeout=10) + check('ok' in r and 'error' not in r, 'M101 P0 accepted (%s)' % r.replace('\n', ' ')) + time.sleep(0.3) + g.drain() + check(logged('laser power model set for this program (analog, floor 16 %)'), + 'the switch is reported with the analog floor') + f = floor35(g.cmd('$$', timeout=10)) + check(f == 16.0, '$$ shows the analog floor in force ($35=%s)' % f) + # Section 2: analog, same S, no new press allowed. + presses = sum(1 for _t, ln in g.log if 'press the button' in ln) + g.s.sendall(b'M3 S%d\n' % MSWITCH_S) + g.s.sendall(('G1 X%g F%d\n' % (-MSWITCH_MM, MSWITCH_FEED)).encode()) + st = g.wait_state('Run', 60) + check(st.startswith('Run'), 'the analog section ran (state=%s)' % st) + g.wait_state('Idle', 60) + time.sleep(0.5) + g.drain() + check(sum(1 for _t, ln in g.log if 'press the button' in ln) == presses, + 'the open window carried across the switch: no re-prompt') + for ln in ('M5', 'G90'): + g.cmd(ln) + g.cmd('M2') + time.sleep(0.5) + g.drain() + check(logged('laser power model reverted (density, floor 10 %)'), + 'M2 reverts to the density default and reports it') + f = floor35(g.cmd('$$', timeout=10)) + check(f == 10.0, '$$ shows the density floor back ($35=%s)' % f) + # Let the disarm land before judging the trace. + t0 = time.time() + while time.time() - t0 < 90: + smp = sample_forgectrl() + if smp and not smp['armed']: + break + time.sleep(0.2) + time.sleep(1.5) + except Exception as e: + aborted = True + print('ABORTED: %s' % e) + g.rt(b'\x18') + finally: + sampler.stop() + if aborted: + return 1 + + tr = sampler.samples + segs, cur = [], None + for smp in tr: + on = smp['hv'] is not None and smp['hv'] > HV_DARK_MAX + if on and cur is None: + cur = [smp['t'], smp['t']] + elif on: + cur[1] = smp['t'] + elif cur is not None and smp['t'] - cur[1] > 1.0: + segs.append(cur) + cur = None + if cur: + segs.append(cur) + print('\n--- results (%d samples, %.1f Hz) ---' % (len(tr), sampler.rate())) + check(len(segs) == 2, '%d discharge segment(s), expected exactly 2 (one per line)' + % len(segs)) + if len(segs) == 2: + for name, (a, b) in zip(('density', 'analog'), segs): + hv = _stats(_window(tr, a + 0.3, b - 0.1, 'hv')) + print(' %s line: %.2f s, hv mean %.0f max %d (max-mean %.0f)' + % (name, b - a, hv['mean'], hv['max'], hv['max'] - hv['mean'])) + hv_d = _stats(_window(tr, segs[0][0] + 0.3, segs[0][1] - 0.1, 'hv')) + hv_a = _stats(_window(tr, segs[1][0] + 0.3, segs[1][1] - 0.1, 'hv')) + check(hv_a['max'] - hv_a['mean'] < 150, + 'the analog line is a steady discharge (max-mean %.0f)' + % (hv_a['max'] - hv_a['mean'])) + check(hv_d['max'] - hv_d['mean'] > 250, + 'the density line is a pulsed discharge (max-mean %.0f)' + % (hv_d['max'] - hv_d['mean'])) + t_end = segs[1][1] + hv_after = max((smp['hv'] for smp in tr if smp['t'] > t_end + 0.3 + and smp['hv'] is not None), default=0) + check(hv_after <= HV_DARK_MAX, 'dark after the second M5 (hv max %d)' % hv_after) + ok = not fails + print('MSWITCH %s' % ('PASS: the switch, the floors and the revert hold on the machine' + if ok else 'FAIL: %d check(s) failed' % len(fails))) + return 0 if ok else 1 + + def post_ctrl(action): # http.client preserves the header-name case exactly as given. import http.client @@ -2833,6 +2988,7 @@ def main(): 'pthresh': drill_pthresh, 'dladder': drill_dladder, 'pcurve': drill_pcurve, 'm5dark': drill_m5dark, 'dpatch': drill_dpatch, 'flowload': drill_flowload, + 'mswitch': drill_mswitch, 'senderchg': drill_senderchg, 'overrun': drill_overrun, 'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart} if drill not in drills: