diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index 114ac7b..8c8858c 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -651,6 +651,39 @@ not a release. - **Laser PWM**: 39.98 kHz register-verified (divider 13 × 127 counts), scope- confirmed at 25.0 µs period across the full duty range, clean at the low end (6.4 % measured vs 6.3 % commanded at PWMSAR=8). +- **Laser duty thresholds** (ladder on scrap at F300, constant power): the + tube has two thresholds, far apart. The discharge **strikes between 2 % and + 3 %** duty — 2 % (PWMSAR 2) draws no measurable `hv_current` and leaves + nothing at all, 3 % (PWMSAR 3) draws current — but it does **not lase + usefully until 16 %** (PWMSAR 20), the lowest duty leaving a continuous + mark. Between them (3–14 %) is a **dead band**: current flows and climbs, + and each line shows only a spot at its start (the strike transient) with a + dark line after it. So the usable analog range is ~16–100 %, and `$35` + (`DEFAULT_SPINDLE_PWM_MIN_VALUE`) ships at **16** to hold every nonzero S + above it. Raw `hv_current` counts are a presence/absence witness only: the + per-rung means are non-monotonic at the top of the ladder and the signal + has no characterized transfer function. +- **Factory power model** (three cloud cuts of one 1" square, same location, + material and speed, only the UI power setting changed; captures in + `_RESOURCES/power-settings-20260817/`): the **power byte is pinned at 127** + in all three runs — three occurrences each, one as the cut begins and a + refresh every ~27 000 ticks (~2.7 s). Analog duty is never a power control. + **Dose is FIRE-bit density on a fixed 7-tick period** (700 µs at + `STfr` = 10 000, ~1.43 kHz), the on-count dithered between adjacent integers + to reach a fractional duty: Precision Power 1 = 1.371 of 7 (density 0.1953, + runs of 1 and 2), PP 100 = 5.576 of 7 (0.7952, runs of 5 and 6), Full Power + = 7 of 7 (0.9965, continuous). The period was exactly 7 in all 570 measured + cycles of both dithered runs, and the mix of adjacent on-counts matches the + fractional part exactly (PP 1 wants 1.371; 2-runs are 212 of 571 = 0.371). + The three **headers are identical** — the power setting never reaches the + machine, so the whole model is service-side. Motion is identical too: 5420 + steps, 101.62 mm, 10.81 s at 9.44 mm/s. **Density tracks velocity through + corners**, by the same relative factor at every power setting (corner/cruise + 0.38, 0.38, 0.41), but only partly: fire ticks per step rise 3.89 → 7.00 as + speed falls 9.44 → 1.22 mm/s, so dose per unit length rises ~1.8× at a + corner instead of the ~7.7× it would rise with no compensation. On the UI + scale, PP 1→100 is linear in density (~0.006 per unit, intercept ~0.189) and + Full Power sits off that line, where PP ~134 would land. - **Cooling operating point**: 40 % heater duty, 50 s window, flow-rise threshold 14.4 °C, re-checks every 150 s. Below ~40 % duty the stagnant loop sheds the heater's output by convection well enough to mimic flow (at 30 %, @@ -974,33 +1007,43 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`. modes, 4.1× and 16.4× fewer bytes); frame rate only spaces the stalls out, and the existing `FORGECTRL_STREAM_FPS` cap skips demosaic and encode but still dequeues every frame. Shares the bench slot with item 8. -17. **Laser power model and the missing duty floor.** grblHAL maps S onto the +17. **Laser power model: dose by FIRE-bit density.** grblHAL maps S onto the analog PWM duty (`$30`/`$31` → `$35`/`$36`, written raw into PWMSAR against - the 127-count period), and ForgeFIRM overrides only `$32`, so a shipped - machine has `$35` = 0: duty runs linearly to zero with S and nothing stops - it falling below the tube's striking threshold. Under M4 the core scales S - by velocity, so every corner, every reversal, and every segment shorter - than the accelerate-in-and-out distance (~1.6 mm at 2000 mm/min with the - default 700 mm/s²) is commanded below the striking point and does not burn - at all. + the 127-count period). `$35` now ships at 16, the measured lasing + threshold (facts bank), which keeps M4's velocity-scaled power out of the + dead band at corners, reversals and segments shorter than the + accelerate-in-and-out distance (~1.6 mm at 2000 mm/min with the default + 700 mm/s²) — where an unfloored duty is commanded below the threshold and + does not burn at all. - The factory does not use duty as a power control. All five firing jobs in - the captured pulse files pin the power byte at 127 (one also uses 102) and - modulate dose entirely by dithering the FIRE bit at the 10 kHz tick, at - 6.5–18.8 % density. Two consequences: the captures cannot supply a `$35` - default, because nothing in them runs anywhere near the threshold; and the - duty → optical-power transfer function of this HV supply is unmeasured, - because nothing has ever depended on it. + The floor is a patch on a model this tube does not fit. Only 16–100 % of + the duty range does anything, so analog control has a ~6:1 span, and the + floor buys freedom from dropout by putting its full 16 % into corners + where velocity — and dose per unit length — goes the other way. The + factory does not use duty as a power control at all: all five firing jobs + in the captured pulse files pin the power byte at 127 (one also uses 102) + and modulate dose entirely by dithering the FIRE bit at the 10 kHz tick, + at 6.5–18.8 % density. The measured dead band is why. Dose set by pulse + density cannot fall below the lasing threshold by construction, which is + what the per-tick FIRE bit exists for, and it is the only power model this + tube and supply are known to work well with. The duty → optical-power + transfer function is still unmeasured — nothing has ever depended on it. - Owed, in order: run `live_fire_drills.py pthresh` on scrap with `$35` = 0 - to find the striking threshold, set `DEFAULT_SPINDLE_PWM_MIN_VALUE` (a - percent) in `grblHAL-glowforge/src/boards/glowforge.h` from it — the - marking rung's percent is the value — and record the number here. Then the - design question behind it: whether to follow the factory and modulate dose - by FIRE-bit density at a fixed high duty rather than by analog duty. That - is what the per-tick FIRE bit exists for, it cannot fall below the striking - threshold by construction, and it is the only power model this tube and - supply are known to work well with. + The factory's implementation is now measured rather than inferred (facts + bank): power byte pinned at 127, dose set by a fixed 7-tick period + (~1.43 kHz) whose on-count is dithered between adjacent integers, and a + velocity compensation that is real but partial. Two things follow for the + ForgeFIRM implementation. The base period is a free parameter — the + factory's 700 µs is 7 ticks at its 10 kHz print rate, and GRBL mode ships + the stream at 28 160 Hz, so the same PRF is ~20 ticks; the accumulator, not + the period, is what recovers fractional density. And velocity scaling + arrives for free: under M4 the core already scales S by velocity, so + mapping S onto density inherits compensation that is *more* complete than + the factory's, which still lets dose per unit length rise ~1.8× at a + corner. + + Owed: the density model itself. `$35` and the analog path stay as the + fallback until it lands. What that model means for image engraving, since it decides the design as much as cutting does. LightBurn has two image paths. Its 1-bit modes diff --git a/docs/CAMPAIGN-LOG.md b/docs/CAMPAIGN-LOG.md index 2fd1c70..0eacbaf 100644 --- a/docs/CAMPAIGN-LOG.md +++ b/docs/CAMPAIGN-LOG.md @@ -2599,6 +2599,182 @@ binned modes, 4.1× and 16.4× fewer bytes, which shortens the stall rather than merely spacing stalls out) and the churn re-base (which is what would allow a lead beyond 10 ms). Tracked in BRINGUP "Next work" item 16. +## 2026-08-17 — the laser duty threshold ladder + +The first owed step of "Next work" item 17: measure where the tube starts +lasing, so `$35` can stop M4's velocity-scaled power falling below it. + +### The run + +`live_fire_drills.py pthresh 1000 300` on wood scrap, operator-run on dev +image `20260817220126`, machine idle and homed, coolant 23.9/24.1 °C. The +precondition was read off the machine first: `$30`=1000, `$31`=0, `$32`=1, +**`$35`=0.0**, `$36`=100 — no floor in place to lift the rungs. + +Thirteen rungs, 2 %…30 % of full, 25 mm each at F300, constant power (M3), +3 mm of `+Y` between them. Before firing, the two conversions were checked +against each other: a rung of *P* % sends `S = 10·P`, which the core maps to +`floor(127·P/100)` counts, and `$35 = P` computes `min_value = +(uint)(127·P/100)` — the same integer, so the rung's percent *is* the `$35` +value exactly, not approximately. + +### What came back + +Material, counting from the first rung drawn: rung 1 (2 %) nothing at all; +rungs 2–9 (3–14 %) a tiny spot at the start of each line and a dark line +after it; rungs 10–13 (16–30 %) continuous marks. + +The `hv_current` trace agrees independently. It holds 0 for 19.5 s (arm +wait), then runs nonzero to 82.8 s, immediately before Idle. Within it the +laser-off `G0` between rungs reads 0, so the current runs count the rungs: +**12 segments of ~4.7 s at a ~5.27 s period, not 13.** The last segment ends +at the job end, so it is rung 13; counting back 12 puts the first current at +rung 2. Rung 1 drew no measurable discharge current — the same rung that +left no mark, from a completely separate witness. + +So the tube has **two thresholds, far apart**: + +| | rung | duty | witness | +|---|---|---|---| +| Discharge strikes | 2 (3 %) | PWMSAR 3 | current lifts off; spot only | +| Sustained lasing | 10 (16 %) | PWMSAR 20 | first continuous mark | + +Between them, 3–14 % is a **dead band**: current flows and climbs (per-rung +means 133 → 289 raw) with essentially no light out. Each line's opening spot +is the strike transient; the tube lights, drops below lasing gain, and coasts +dark for the remaining 25 mm. + +This falsifies the drill's own guidance, which said the current "lifts off +baseline at the same rung the material starts marking" — lift-off is rung 2, +marking is rung 10. The docstring and the printed read-the-material text were +corrected to name both thresholds and to tell the operator that a rung +showing only a start-of-line spot is *below* the threshold, not at it. + +Raw `hv_current` is a presence/absence witness only. Per-rung means are +non-monotonic at the top (429 at 20 %, then 311 and 302) and the variance +collapses on the top two rungs, which is what an aliased point-sample of a +pulsed current looks like; the signal has no characterized transfer function. + +### Ruling out the firmware explanation for the spots + +A start-of-line spot is also what a full-power leak would look like: a kernel +run start resets the hardware duty to ~100 %, so a fire bit reaching the +stream ahead of its power byte would burn at full power. The material already +argued against it — rung 1 is the first fire of the run, the likeliest place +for such a leak, and it is blank — but the stream is the record, so +`laser_stream_test.py` gained a fourth session (rule 10): a ladder in the same +shape, full power deliberately absent, asserting that every FIRE tick rides a +commanded duty and that the fire ticks divide evenly across rungs (a rung +opening at its neighbor's duty shows up as a surplus on one and a deficit on +the next). + +Result on the native build: duties under FIRE were exactly `[22, 23, 26, 32, +41, 52]`, nothing else, and **28296 fire ticks on every rung, identical to the +tick**. No full-power window, no stale-duty window. The spots are the tube and +supply, not the firmware. + +### What landed + +- `DEFAULT_SPINDLE_PWM_MIN_VALUE 16.0f` in `boards/glowforge.h` — the + measured lasing rung. Chosen over the next rung up (20 %) because the floor + is spent at corners, where velocity and dose per unit length already move + the wrong way, and because `$35` is a user setting anyone can raise. +- The harness now derives its expectations from that floor (`duty_for()`), so + the M4 session's S500 plateau moved 63 → 73 and its ramp `[44, 52, 63, 127]` + → `[57, 64, 73, 127]`, plus a new check that no duty under FIRE falls below + the floor. All four sessions pass, as do `switch_map_test`, `laser_arm_test` + and `laser_lifecycle_test`. +- `laser.power-floor`, an auto acceptance test (the suite's only non-firing + one): reads `$$` and checks the machine actually carries the commissioned + floor, since stored settings beat freshly baked defaults and a machine with + an older EEPROM needs `$RST=$` once. Coverage lint clean at 40 tests. + +### What it means for the model + +The usable analog range is 16–100 %, about 6:1, with the bottom sixth of the +control range physically dead — and the factory's captured pulse files pin the +power byte at 127 and modulate dose by dithering the FIRE bit at 6.5–18.8 % +density. The dead band is why. `$35` is a patch that buys freedom from dropout +by putting its full 16 % into every corner; dose set by pulse density cannot +fall below the lasing threshold by construction. Item 17 is now the density +model itself, with the analog path as the fallback. + +## 2026-08-17 — how the factory sets power + +Three cloud-mode cuts of the same 1" square, same location, same material, +same speed, changing only the Glowforge UI power setting: Precision Power 1, +Precision Power 100, then Full Power. Captures in +`_RESOURCES/power-settings-20260817/`. + +Pulse-file capture ships off (`LOGGING.SAVE_PULS`), and the machine's copy of +`/data/etc/gfhome.conf` predated the key, so it was enabled for this session +and turned off afterward. A first attempt appended the key past the last +section, where `get_cfg('LOGGING.SAVE_PULS')` would never have found it — it +belongs inside `[LOGGING]`, and was verified through the app's own parser +rather than by eye. + +### The measurement + +**Analog duty is not a power control.** All three runs carry the power byte +exactly three times, always 127: once as the cut begins, then a refresh every +~27 000 ticks (~2.7 s). Nothing modulates PWMSAR, at any setting. + +**Dose is FIRE-bit density on a fixed 7-tick period** — 700 µs at +`STfr` = 10 000, ~1.43 kHz — with the on-count dithered between adjacent +integers: + +| Setting | on-runs | mean of 7 | density | +|---|---|---|---| +| Precision Power 1 | 1 (×359), 2 (×212) | 1.371 | 0.1953 | +| Precision Power 100 | 5 (×236), 6 (×334) | 5.576 | 0.7952 | +| Full Power | continuous | 7 | 0.9965 | + +The period was exactly 7 in all 570 measured cycles of both dithered runs, and +the mix of adjacent on-counts matches the fractional part exactly: PP 1 wants +1.371 on-ticks, and 2-runs are 212 of 571 = 0.371. That is an error +accumulator, not a repeating pattern. + +**The power setting never reaches the machine.** The three headers are +identical — no key differs — so the model lives entirely in the service, which +bakes it into the FIRE bits. The motion is identical too: 5420 steps, +101.62 mm (4 × 25.4), 10.81 s at 9.44 mm/s. Full Power's file is longer only +in the lead-in before the cut. + +**Velocity compensation is real but partial.** Density falls as the head slows +into a corner, by the same relative factor at every power setting +(corner/cruise 0.38, 0.38, 0.41). Measured per step interval, though, fire +ticks per step *rise* from 3.89 at 9.44 mm/s to 7.00 at 1.22 mm/s, so dose per +unit length still climbs ~1.8× at a corner — against the ~7.7× it would climb +with no compensation at all. Only ~24 of 5420 step intervals are below cruise +speed, so the direction and rough magnitude are solid and the exact law is +not. + +On the UI scale, PP 1→100 is linear in density (~0.006 per unit, intercept +~0.189); Full Power sits off that line, where PP ~134 would land, which fits a +setting the UI presents as outside the normal range. + +### Two corrections to earlier readings + +The first pass at the dither sampled the mid-point of the cut, which for a +square is a corner, and truncated its distributions — it showed 8-on/4-off +bursts that are corner behavior, not the steady pattern. The first pass at the +dose law counted every tick as a step, because in this encoding bits 1 and 3 +are *direction*, held for the whole side, and only bits 0 and 2 are step +pulses; the tell was 2026 mm of travel on a 101.62 mm cut. + +### A defect found by using the feature + +Deleting the capture directory under a running gfcloud showed that with +capture enabled, a missing directory or a full disk makes `load_motion` raise +on the capture write and kills the print. A debug aid must never cost a job: +the capture open, the per-chunk write and the `.info` write are now each +non-fatal, dropping the capture with a warning and running the job on +(`gfutilities`, with a regression test in `tests/test_lifecycle.py`; verified +in three cases — missing directory still loads the job, a writable directory +still gets the copy, capture off writes nothing). No acceptance-catalog +consequence: the path is an off-by-default debug capture with no bearing on +emission, motion or the release surface. + ## Superseded status notes ### Shared machine services — remaining polish, as listed 2026-08-13 diff --git a/forgetest/forgetest/suite/laser.py b/forgetest/forgetest/suite/laser.py index 81af8ad..880e3bb 100644 --- a/forgetest/forgetest/suite/laser.py +++ b/forgetest/forgetest/suite/laser.py @@ -1,6 +1,7 @@ """laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`. -Every test here can emit. The page starts one only with the operator's +Every test here can emit, except `laser.power-floor`, which only reads +settings. 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 @@ -24,6 +25,12 @@ _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. The tube's measured lasing threshold is PWMSAR 20. +POWER_FLOOR_PCT = 16.0 +PWM_PERIOD = 127 +PWMSAR_FLOOR_MIN = 20 + 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 " "white button lights and the stream blocks until you press the physical arm button; " @@ -218,6 +225,48 @@ def wait_disarm(ctx, timeout): return None +@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 " + "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.") +def power_floor(ctx): + ev = ctx.evidence + with ctx.grbl() as g: + 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 + ctx.check(vals, "no $-settings in the response to $$") + + floor_pct = vals.get("$35") + rpm_min = vals.get("$31") + ev["settings"] = {k: vals.get(k) for k in ("$30", "$31", "$32", "$35", "$36")} + ctx.check(floor_pct is not None, "$35 missing from the settings report") + counts = int(PWM_PERIOD * floor_pct / 100.0) + ev["pwmsar_floor"] = counts + 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 `$RST=$` once", 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", + 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) + ctx.log("PASS: nonzero S never commands less than PWMSAR %d", counts) + + @test("laser.emission-witness", title="Live emission witness (S400 vector mark) and job-based disarm", subsystem="laser", kind="live", always=True, est_min=5, covers=_LASER_COVERS, 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 7c9fdf6..ef45477 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 = "fd059b3bf52de103f598cf94cb860916e5fffd00" +SRCREV = "9466f7650a4722cb42a18fe96a359b2a8b3a639f" PV = "0.1.0" diff --git a/scripts/bench/laser_stream_test.py b/scripts/bench/laser_stream_test.py index 8a03ee4..8a77f11 100644 --- a/scripts/bench/laser_stream_test.py +++ b/scripts/bench/laser_stream_test.py @@ -8,7 +8,9 @@ over TCP, then checks the dumps against the kernel feeder contract: 1. a power byte (bit 7) leads the stream, before any tick byte 2. no two consecutive power bytes (the SDMA script drops the second) 3. the first FIRE bit (0x10) comes after a nonzero power byte - 4. power values match the S words ($30=1000 -> S500 = 63, S1000 = 127) + 4. power values match the S words through the core's mapping, floor + included ($30=1000, $31=0, $35 = the board's floor), and no duty + under FIRE falls below that floor 5. FIRE only spans the cutting moves: none before the job, none during the G0 return, none at the tail 6. step accounting survives the insertions: X returns to net zero and @@ -22,6 +24,12 @@ over TCP, then checks the dumps against the kernel feeder contract: 9. rules 7-8 hold across rapid cycle stop/start churn (planner-starve shaped jobs), where the FIRE state of the previous cycle must not leak into the idle-gap pad bytes + 10. a power ladder fires every rung at the duty commanded for it: no + FIRE tick rides a duty that was never commanded (a run start resets + the hardware duty to ~100 %, so a fire bit reaching the stream + ahead of the rung's power byte would burn at full power), and the + fire ticks divide evenly across the rungs, which is what fails if a + rung's opening ticks carry the previous rung's duty Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge) """ @@ -40,6 +48,21 @@ 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, +# and a $35 floor (boards/glowforge.h DEFAULT_SPINDLE_PWM_MIN_VALUE) +# against the hardware's 127-count period. 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 = int(PWM_PERIOD * PWM_MIN_PCT / 100.0) +RPM_MAX = 1000.0 + + +def duty_for(s): + """Duty the core computes for an S word, floor included.""" + return int(s * (PWM_PERIOD - PWM_MIN) / RPM_MAX) + PWM_MIN + # Longest stepless run allowed to carry FIRE, in machine ticks. The # slowest legitimate between-step interval in these jobs is the first # step of an accel-from-rest: sqrt(2 * (1/53.333 mm) / 700 mm/s^2) @@ -81,6 +104,20 @@ for _ in range(30): JOB_CHURN.insert(0, "M4 S0") JOB_CHURN.append("M5") +# Session D: a power ladder in the shape the bench threshold drill uses - +# constant power (M3) so the commanded duty is the tested duty, rungs +# ascending, a dark G0 between them. Full power is deliberately absent +# from the ladder, so duty 127 under FIRE can only be a leak. +LADDER_S = (20, 30, 60, 120, 200, 300) +LADDER_DUTY = tuple(duty_for(s) for s in LADDER_S) +LADDER_MM = 5.0 +JOB_LADDER = ["G91", "G21", "M3"] +for _i, _s in enumerate(LADDER_S): + JOB_LADDER.append("S%d" % _s) + JOB_LADDER.append("G1 X%g F300" % (LADDER_MM if _i % 2 == 0 else -LADDER_MM)) + JOB_LADDER.append("G0 Y1") +JOB_LADDER.append("M5") + def fail(msg): print("FAIL: %s" % msg) @@ -282,10 +319,14 @@ def check_m4_job(data): fail("first FIRE bit rides duty 0 (power-before-fire violated)") powers = sorted(set(p for _, p in fire_ticks)) - if powers[-1] != 127: - fail("S1000 did not reach duty 127 (max %d)" % powers[-1]) - if not any(60 <= p <= 66 for p in powers): - fail("S500 plateau (~63) not seen (powers %s)" % powers[:20]) + if powers[-1] != PWM_PERIOD: + fail("S1000 did not reach duty %d (max %d)" % (PWM_PERIOD, powers[-1])) + want = duty_for(500) + if not any(abs(p - want) <= 2 for p in powers): + fail("S500 plateau (~%d) not seen (powers %s)" % (want, powers[:20])) + if powers[0] < PWM_MIN: + fail("duty %d under FIRE is below the $35 floor of %d: M4's ramp is " + "commanding power the tube cannot lase at" % (powers[0], PWM_MIN)) expect_peak = round(10 * STEPS_PER_MM) if abs(x_max - expect_peak) > 2: @@ -310,6 +351,41 @@ def check_m4_job(data): return fire_ticks, powers, x_max, tail_steps +def check_power_ladder(name, data, expect): + """Rule 10: every FIRE tick rides the duty commanded for its rung.""" + cur = None + order = [] # duties in the order they carry FIRE + counts = {} + for b in data: + if b & 0x80: + cur = b & 0x7F + continue + if b & 0x10: + if cur is None: + fail("[%s] FIRE bit ahead of any power byte" % name) + counts[cur] = counts.get(cur, 0) + 1 + if not order or order[-1] != cur: + order.append(cur) + + stray = sorted(d for d in counts if d not in expect) + if stray: + fail("[%s] FIRE rode uncommanded duty %s (commanded %s): power the " + "job never asked for is uncommanded energy" + % (name, stray, list(expect))) + if order != list(expect): + fail("[%s] duty sequence under FIRE was %s, expected %s" + % (name, order, list(expect))) + + # Equal-length rungs at one feed burn equal numbers of fire ticks. + # A rung whose opening ticks carry the previous rung's duty shows up + # here as a surplus on one duty and a deficit on the next. + lo, hi = min(counts.values()), max(counts.values()) + if hi > lo * 1.05: + fail("[%s] fire ticks per rung uneven (%d..%d, %s): a rung is " + "firing at its neighbor's duty" % (name, lo, hi, counts)) + return counts + + def count_fire(data): return sum(1 for b in tick_bytes(data) if b & 0x10) @@ -343,6 +419,15 @@ def main(): print("PASS [churn]: %d bytes, %d fire ticks, max fire gap %d" % (len(data), count_fire(data), gap_c)) + # --- session D: power ladder, rule 10 ------------------------------- + data = run_session("ladder", JOB_LADDER) + counts = check_power_ladder("ladder", data, LADDER_DUTY) + check_termination("ladder", data) + gap_d = check_fire_gaps("ladder", data) + print("PASS [ladder]: %d bytes, duties %s fire ticks %s, max fire gap %d" + % (len(data), list(LADDER_DUTY), + [counts[d] for d in LADDER_DUTY], gap_d)) + print("PASS: all stream emission rules hold") diff --git a/scripts/bench/live_fire_drills.py b/scripts/bench/live_fire_drills.py index 02e6237..500e94d 100644 --- a/scripts/bench/live_fire_drills.py +++ b/scripts/bench/live_fire_drills.py @@ -35,12 +35,17 @@ Drills (pass a name): ircut [S] [F] e.g. ircut 1000 300 pthresh Laser power-threshold ladder: one line per power level on scrap, climbing from 2 % to 30 % of full, at constant power - (M3) so nothing scales the duty with velocity. The lowest - rung that leaves a mark is the tube's striking threshold, - and because $35 is a percent of full duty and the rungs are - percents of $30 with $31 = 0, that rung's percent IS the - $35 value. Requires $35 = 0 for the run: a floor already in - place lifts every rung and hides the threshold. + (M3) so nothing scales the duty with velocity. The ladder + separates two thresholds that are far apart: the discharge + strikes at a duty well below the one the tube lases at, and + the rungs between them show only a spot at the line start + (the strike transient) with a dark line after it. The + lowest rung that leaves a CONTINUOUS mark is the lasing + threshold, and because $35 is a percent of full duty and + the rungs are percents of $30 with $31 = 0, that rung's + percent IS the $35 value. Requires $35 = 0 for the run: a + floor already in place lifts every rung and hides both + thresholds. pthresh [Smax] [F] e.g. pthresh 1000 300 expstop Armed kill on the EXPECTED-stop path: start a mark job, then mid-burn POST /controller/stop (the supervisor stops @@ -467,8 +472,10 @@ def drill_pthresh(g): if samples: t0 = samples[0]['t'] print('hv_current trace (t s : raw) - the discharge current is the') - print('electrical witness of striking; it lifts off baseline at the') - print('same rung the material starts marking:') + print('electrical witness of striking, and it lifts off baseline') + print('well below the rung that marks. Read it for the rung') + print('boundaries: the laser-off G0 between rungs reads 0, so the') + print('runs of nonzero current count the rungs that struck at all.') line = [] for s in samples: if s['hv'] is None: @@ -480,11 +487,12 @@ def drill_pthresh(g): if line: print(' ' + ' '.join(line)) print('\nRead the material: count rungs from the FIRST one drawn. The') - print('lowest rung that leaves any mark is the striking threshold; set') - print('$35 to that rung\'s percent (round up to the next rung for') - print('margin). Note the emission counter proves the safety chain') - print('asserted LASER_ON, not that the tube lased - only the mark and') - print('the discharge current say that.') + print('lowest rung carrying a CONTINUOUS mark is the lasing threshold;') + print('set $35 to that rung\'s percent. A rung showing only a spot at') + print('the start of its line struck but did not sustain - it is below') + print('the threshold, not at it. Note the emission counter proves the') + print('safety chain asserted LASER_ON, not that the tube lased - only') + print('the mark and the discharge current say that.') return samples