Laser: the rapids after an M5 ship dark and the next job fires; the performance-curve drill

Stream harness rules 16 and 17 with their sessions: an M5 executed with
the planner drained and the kernel run over must darken the rapids that
follow it (m5-idle), and a job whose M3 runs at the level the previous
job ended at must still fire its first cut (next-job). The second rule
is the core's contract: set_state records the rpm and the per-segment
update is skipped while it is unchanged, so the driver's set_state is
the only thing that can light that move. Both sessions run under both
dose models; the bench build that went dark on its second job fails
next-job with one fire span.

Bench drills: pcurve (a per-level ladder of 100 mm lines read from the
HV current and the head thermopile at 25 Hz, with the instrument checks
and the JSON record) and m5dark (one line, M5, two rapids, judged on the
current trace and laser_on_sampled until the armed window closes).

Catalog: laser.m5-rapid-dark, a live test of the M5 case (46 tests; the
counts in BRINGUP follow). CAMPAIGN-LOG carries the day's record: the
two curve ladders, the defect pair, the root cause, the host and bench
proof.
This commit is contained in:
ScottW514
2026-08-25 17:41:45 -04:00
parent 0416986e52
commit fff0980079
6 changed files with 916 additions and 9 deletions
+6 -6
View File
@@ -48,7 +48,7 @@ hardware-validated.**
modes (cancel-and-return on a lid or interlock open, button pause/resume), modes (cancel-and-return on a lid or interlock open, button pause/resume),
bench-validated 2026-08-17. bench-validated 2026-08-17.
- **Releases are gated by the acceptance tool** (`forgetest`, dev image only): - **Releases are gated by the acceptance tool** (`forgetest`, dev image only):
a 45-test catalog, domain-scoped inheritance, an always-required safety core, a 46-test catalog, domain-scoped inheritance, an always-required safety core,
a bench actuator that works the lid, the interlock and the button so most of a bench actuator that works the lid, the interlock and the button so most of
the operator's part runs unattended, and a release gate that reads the the operator's part runs unattended, and a release gate that reads the
exported artifact. The latest full campaign, on dev image `20260824230512`, exported artifact. The latest full campaign, on dev image `20260824230512`,
@@ -588,7 +588,7 @@ the export reads "Release authorized: YES" for that image's manifest. That
authorizes a release; it is not one until `releases/v<version>/acceptance.json` authorizes a release; it is not one until `releases/v<version>/acceptance.json`
is committed. is committed.
- **Catalog: 45 tests** in `forgetest/forgetest/suite/`, every one a port of a - **Catalog: 46 tests** in `forgetest/forgetest/suite/`, every one a port of a
proven bench drill or a bench-verified check: the always-required core proven bench drill or a bench-verified check: the always-required core
(`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`, (`image.health`, `kernel.latch-locked-idle`, `kernel.k1-k2`,
`kernel.fire-line`), `forgectrl.*`, `logs.*`, `update.*`, `motion.*` `kernel.fire-line`), `forgectrl.*`, `logs.*`, `update.*`, `motion.*`
@@ -597,14 +597,14 @@ is committed.
quiet after motion, a gate setting tripping and off by value, a fan under quiet after motion, a gate setting tripping and off by value, a fan under
its floor), `camera.*`, its floor), `camera.*`,
`laser.*` (emission witness, arm-wait lid, disarm-in-hold, armed kill, `laser.*` (emission witness, arm-wait lid, disarm-in-hold, armed kill,
pause/resume/lid-cancel) and `cloud.*` (the service protocol answered by pause/resume/lid-cancel, the rapids after an M5 shipping dark) and `cloud.*` (the service protocol answered by
the emulator in this machine's identity, with only the app to drive; the the emulator in this machine's identity, with only the app to drive; the
mode round trip with the lid-open hunt and the web-service homing on it; mode round trip with the lid-open hunt and the web-service homing on it;
one real print; and the job-behavior tests under the offline service: one real print; and the job-behavior tests under the offline service:
the cloud client driven from a local socket with a synthesized the cloud client driven from a local socket with a synthesized
laser-free job, no account, no network, nothing on the bed). Tests that laser-free job, no account, no network, nothing on the bed). Tests that
share a setup are merged; the `auto` tests stay separate for failure share a setup are merged; the `auto` tests stay separate for failure
isolation. 28 are `auto`, 9 `operator`, 8 `live`; with the bench actuator up, isolation. 28 are `auto`, 9 `operator`, 9 `live`; with the bench actuator up,
eight of the operator tests run in the unattended queue. eight of the operator tests run in the unattended queue.
- **The operator's part is asked for by name, not by popup** - **The operator's part is asked for by name, not by popup**
(`docs/ACCEPTANCE.md` "The operator's part"): a Ready prompt before a (`docs/ACCEPTANCE.md` "The operator's part"): a Ready prompt before a
@@ -614,8 +614,8 @@ is committed.
emission witness's mark). The head accelerometer, the beam detector, the emission witness's mark). The head accelerometer, the beam detector, the
button LEDs, and a lid-lamp toggle between two snapshots replaced the button LEDs, and a lid-lamp toggle between two snapshots replaced the
other eyeball confirmations; `kernel.fire-line` and `camera.snapshot` are other eyeball confirmations; `kernel.fire-line` and `camera.snapshot` are
`auto`. With the bench actuator up the attended block is the nine tests `auto`. With the bench actuator up the attended block is the ten tests
that need a person (four laser live, five cloud): 12 minutes on dev image that need a person (five laser live, five cloud): 12 minutes on dev image
`20260824230512`. A test's implementation hash is its own function plus its `20260824230512`. A test's implementation hash is its own function plus its
module's shared code, so a fix inside one test re-requires that test module's shared code, so a fix inside one test re-requires that test
alone. alone.
+80
View File
@@ -4045,6 +4045,86 @@ open question, whether ForgeFIRM's load wants the heatsink the factory's
never did, closes with this entry: it does not. The per-job SoC range and never did, closes with this entry: it does not. The per-job SoC range and
the throttle log line stay as the running record. the throttle log line stay as the running record.
## 2026-08-25: the performance-curve ladders, and the rapids that fired after M5
The day opened with a new instrument. The head carries a thermopile that
reads scatter off the beam inside the head, upstream of the mirror that turns
it down to the work, so it sees the beam and not the material. A ladder of
100 mm lines at 10 mm/s, one per level, sampled from sysfs at 25 Hz along
with the HV current, is a performance curve for this tube and supply, and the
`pcurve` drill in `scripts/bench/live_fire_drills.py` runs it.
- **Analog ladder (E1), `$35` = 0, 13 rungs from 16 to 100 percent plus a
repeat of rung 7:** the current is proportional to duty above 30 percent
(slope 959 counts per 100 percent, r-squared 0.9999) and reaches 990 at
full, so this PSU's ADC does not clip; below 30 percent the discharge is
unstable. The thermopile is monotonic to 85 percent and puts the lasing
knee between 19.7 and 22.8 percent duty, not at 16, with the strike spot
showing as a first-second spike on the low rungs; its baseline holds within
50 counts over the ladder and the repeat rung reads 3.5 percent high. It
does not settle inside a line above about 50 percent (swings of 20 to 30
percent at constant current), so the top of the analog curve is not yet a
measurement. Record `pcurve_analog_20260825-195947.json`.
- **Density ladder (E3), `$35` = 0, period 20, minimum 3, 13 rungs from 1 to
100 percent:** the dose is strongly convex in density at a 710 us period
(80 percent of density reads 0.53 of full, 60 reads 0.37, 45 reads 0.21,
30 reads 0.07), while `laser_on_sampled` tracked the commanded on-fraction
exactly, so the drive delivered what was asked and the light did not
follow. Whether that is the per-pulse strike deficit or the sensor is the
next ladder's question. Lines were flat inside to within a few percent.
Record `pcurve_density_20260825-202317.json`.
**The rapids fired after `M5`.** Seen by the operator on the density block
and confirmed in both traces: the pulsed current ran on through the `G0`
back and the `G0` up after every line, at the rung's level, and through a
bare `G0` sent with no `M3` at all. Under density that is full-power light
where nothing was commanded. `M5` executed with the stream idle only stored
the off state; the stream re-asserts its wanted fire state at the first byte
of every run, and the wanted state was still the last cut's true. Live fire
stopped.
**The first fix made the second job dark.** Pushing `fire=false` on `M5`
darkened the rapids (bench run 1 passed: the current fell from 393 to 0
inside one 40 ms sample) and then every following job in the same controller
process shipped no fire at all (runs 2 and 3, HV 0..0, motion ran). That was
first read as hardware, with the `laser power-good degraded` warning as the
suspect. It was software, and it reproduces on the null sink with two jobs
in one process: the second G1 ships zero FIRE ticks under both models.
**The root cause is a core contract.** grblHAL's per-segment laser update is
edge-triggered on rpm: `set_state(on, rpm)` records the rpm, and a block at
that same rpm gets no `update_pwm`, because the core takes the driver's
`set_state` as having lit the laser. Our `spindleSetState` pushed the duty
only. A process's first job always fired because the parser starts in G0,
where the `M3` and `S` words run at rpm 0 and the first G1 differs; after
`M2` the motion mode is G1 and S is modal, so the next job's `M3` runs at the
old level, the core records it, and nothing lights the G1 except the stale
wanted state. The old build fired job 2 by that accident, the same stale flag
that lit the rapids; removing the accident exposed the hole.
**The fix, and its proof.** `spindleSetState` now computes the pwm for the
state it is given (the off value when off, refused, or rpm 0) and pushes it
through `spindleUpdatePWM`, the whole state through the same armed and
coolant gates; the duty-only stream call is gone. Harness rule 17 and the
`next-job` sessions (two jobs in one process, `M2` between, same S) join
rule 16 and the `m5-idle` sessions; the build that went dark fails the new
session with one fire span, and the fix passes all 14 stream sessions, the
13 lifecycle cases and the arm test. On the bench, with the corrected
controller hot-installed: `m5dark` run 4 (the process's first job) and run 5
(its second, the case that went dark) both passed, 2.00 s of discharge, the
`M5` taking the current to 0 inside one sample, both rapids and every dwell
dark over 11.4 s of sampling, the operator confirming by eye. The catalog
gains `laser.m5-rapid-dark` (46 tests).
**Power-good is not a witness of anything here.** The factory 2.6.0 binary
carries no power-good string at all; ForgeFIRM warns on it once per armed
window and reports it in `/status`, and nothing gates fire on it. On this PSU
it reads not-good at full tube current.
A bench note for the next hot install: a file copied to the board with `scp`
lands without its execute bit, and busybox `cp` keeps that, so the supervisor
loops on exit 127 until a `chmod 755`.
## Superseded status notes ## Superseded status notes
### Shared machine services — remaining polish, as listed 2026-08-13 ### Shared machine services — remaining polish, as listed 2026-08-13
+77
View File
@@ -396,6 +396,83 @@ def emission_witness(ctx):
"dark, mark confirmed", peak, ev["hv_min"], ev["hv_max"], beam["delta"], dt) "dark, mark confirmed", peak, ev["hv_min"], ev["hv_max"], beam["delta"], dt)
HV_DARK_MAX = 20 # hv_current_raw reads 0 with the tube off; hundreds under fire
@test("laser.m5-rapid-dark", title="The rapids after an M5 ship dark",
subsystem="laser", kind="live", mode="grbl", est_min=3,
covers=_LASER_COVERS,
requires=["laser.emission-witness"], actions=["button"],
steps=["Scrap under the head with 20 mm of free +X travel; lid closed; exhaust on.",
"Press the physical button when it lights white (the arm)."],
description="A 20 mm line at constant power (M3 S400/F600), then M5, a dwell, a rapid back "
"over the line, a dwell, a rapid forward, a dwell, and the program end. M5 "
"executes with the planner drained and the kernel run over, and the core "
"issues no per-segment laser update for moves made with the spindle off, so "
"only the stream's own wanted state decides whether those rapids fire; a stale "
"true there lights them at the last level, full duty under the density model. "
"The kernel's LASER_ON sample count must go to 0 after the M5 and stay 0 "
"through both rapids, and the HV current must stay at its idle reading.")
def m5_rapid_dark(ctx):
ev = ctx.evidence
with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g)
base = sample(ctx)
ctx.check(base, "forgectrl /status or /cool/status unavailable")
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
ctx.ready(ARM_CUE % "20 mm +X")
job = ["G91", "G21", "M3", "S400",
"G1 X20 F600",
"M5", "G4 P2.5",
"G0 X-20", "G4 P2.5",
"G0 X20", "G4 P2.5",
"G90", "M2"]
ctx.arm_press()
try:
samples = run_and_sample(ctx, g, job)
# The controller reports Idle inside a G4 dwell, so the sampler
# above can return before the rapids; the window closing at M2
# is the end of the job. Keep sampling until then.
t0 = time.time()
while time.time() - t0 < 30:
ctx.checkpoint()
smp = sample(ctx)
if smp:
samples.append(smp)
if not smp["armed"]:
break
time.sleep(0.125)
finally:
ctx.clear_notice()
emis = [(s["t"], s["emission"], s["hv"]) for s in samples if s["emission"] is not None]
peak = max((e for _t, e, _hv in emis), default=0)
ctx.check(peak > 0, "no emission witnessed on the G1 (emission_samples stayed 0)")
# The first zero after the peak is the dark window the M5 and its dwell
# produce; everything after it is the two rapids and their dwells.
i_peak = max(range(len(emis)), key=lambda i: emis[i][1])
after = [x for x in emis[i_peak:] if x[1] == 0]
ctx.check(after, "emission_samples never returned to 0 after the M5")
t_dark = after[0][0]
tail = [x for x in emis if x[0] >= t_dark]
relit = [x for x in tail if x[1]]
hv_tail = max((hv for _t, _e, hv in tail if hv is not None), default=0)
ev.update({"samples": len(samples), "emission_peak": peak, "tail_samples": len(tail),
"relit_samples": len(relit), "hv_tail_max": hv_tail,
"relit_first": relit[0] if relit else None})
ctx.log("emission peak %s; dark from +%.1f s; %d samples after it spanning %.1f s, %d with "
"emission, HV max after dark %s", peak, t_dark - emis[0][0], len(tail),
tail[-1][0] - t_dark, len(relit), hv_tail)
ctx.check(tail[-1][0] - t_dark >= 5.0, "sampling ended %.1f s after the dark point, before "
"both rapids and their dwells (~5.2 s) had run", tail[-1][0] - t_dark)
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%.1f s "
"(emission_samples %s) - a rapid after M5 fired at the last level",
len(relit), (relit[0][0] - t_dark) if relit else 0, relit[0][1] if relit else None)
ctx.check(hv_tail <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge "
"ran through a rapid", hv_tail)
ctx.log("PASS: emission peak %s on the G1, 0 through both rapids, HV %s after the M5",
peak, hv_tail)
@test("laser.disarm-in-hold", title="Disarm grace counts down in Hold", subsystem="laser", @test("laser.disarm-in-hold", title="Disarm grace counts down in Hold", subsystem="laser",
kind="live", mode="grbl", est_min=4, kind="live", mode="grbl", est_min=4,
covers=_LASER_COVERS, covers=_LASER_COVERS,
+2 -2
View File
@@ -30,9 +30,9 @@ page's takeover does that; from a host, stop them first.
| `fire_test.py` | FIRE drop-timing scope test (runs on the board): A = latch locked (expects nothing on FIRE/LASER_ON), B = latch unlocked / normal end-of-data, U = true underrun. Duty 0 throughout; refuses to unlock if HV reports good. | | `fire_test.py` | FIRE drop-timing scope test (runs on the board): A = latch locked (expects nothing on FIRE/LASER_ON), B = latch unlocked / normal end-of-data, U = true underrun. Duty 0 throughout; refuses to unlock if HV reports good. |
| `pwm_stream_test.py` | LASER_PWM stream-path scope test (runs on the board, controller and forgectrl stopped): streams power bytes only (no step bytes, no FIRE bits, `motor_lock=15`, latch locked) through `/dev/glowforge` so the scope verifies the real power path, including the run-start duty reset and the consecutive-power-byte drop; position counters compared before/after. Exit 0 = counters unmoved, idle at the end, no FIRE/emission read back. | | `pwm_stream_test.py` | LASER_PWM stream-path scope test (runs on the board, controller and forgectrl stopped): streams power bytes only (no step bytes, no FIRE bits, `motor_lock=15`, latch locked) through `/dev/glowforge` so the scope verifies the real power path, including the run-start duty reset and the consecutive-power-byte drop; position counters compared before/after. Exit 0 = counters unmoved, idle at the end, no FIRE/emission read back. |
| `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. | | `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). Runs in the grblHAL repo's CI. | | `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. | | `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 <drill> [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), `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 <drill> [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), `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), `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. | | `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). | | `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. | | `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. |
+106
View File
@@ -46,6 +46,18 @@ over TCP, then checks the dumps against the kernel feeder contract:
next run: a standalone S word between moves, from a sender slow next run: a standalone S word between moves, from a sender slow
enough to drain the planner, must still cut at the level it asked enough to drain the planner, must still cut at the level it asked
for rather than dark at a stale duty for rather than dark at a stale duty
16. and the off transition survives the same way: an M5 executed with
the planner drained and the kernel run over must darken the rapids
that follow it, and a bare G0 sent with the spindle off must ship
dark, under both dose models - the stream's wanted fire state is
the only thing those moves consult, and a stale true there lights
the next run at the last level (full duty under density)
17. and a job's first cut at the level the previous job ended at
fires: S is modal across M2, the core records the level a set_state
carries and skips the per-segment update while it is unchanged, so
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
Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge) Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge)
""" """
@@ -177,6 +189,47 @@ for _i, _s in enumerate(IDLE_S_LEVELS):
JOB_IDLE_S.append("M5") JOB_IDLE_S.append("M5")
# Session J: the bench ladder's shape. M5 executes with the planner
# drained and the kernel run over, and the rapids that follow start a
# new run; the core issues no per-segment laser update for moves made
# with the spindle off, so the stream's wanted state is all that decides
# whether those rapids fire. A bare G0 with no M3 since the M5 is the
# same case one step further.
M5_IDLE_MM = 5.0
M5_IDLE_FEED = 600
M5_IDLE_TICKS = M5_IDLE_MM / (M5_IDLE_FEED / 60.0) * 28160
JOB_M5_IDLE = [
"G91", "G21",
"M3 S500",
"G1 X%g F%d" % (M5_IDLE_MM, M5_IDLE_FEED),
WAIT_IDLE, ("sleep", 0.5),
"M5", ("sleep", 0.5),
"G0 X%g" % -M5_IDLE_MM, "G0 Y1",
WAIT_IDLE,
"G0 X%g" % M5_IDLE_MM,
WAIT_IDLE,
"M3 S500",
"G1 X%g" % -M5_IDLE_MM,
WAIT_IDLE, ("sleep", 0.5),
"M5",
]
# Session K: two jobs in one controller process, the second at the level
# the first ended at. M2 leaves S modal and resets the motion mode to G1,
# so the next job's M3 executes at that S; the core records it and issues
# no per-segment update for a G1 at the same level, so the set_state is
# the only thing that can light it. The parser starts in G0, which is why
# a process's FIRST job never shows this: its M3 runs at rpm 0.
JOB_NEXT = [
"G91", "G21", "M3", "S500",
"G1 X%g F%d" % (M5_IDLE_MM, M5_IDLE_FEED),
WAIT_IDLE, ("sleep", 0.5),
"M5", "G0 X%g" % -M5_IDLE_MM, "G0 Y1",
WAIT_IDLE, "G90", "M2", ("sleep", 1.0),
]
def fail(msg): def fail(msg):
print("FAIL: %s" % msg) print("FAIL: %s" % msg)
sys.exit(1) sys.exit(1)
@@ -551,6 +604,30 @@ def count_fire(data):
return sum(1 for b in tick_bytes(data) if b & 0x10) return sum(1 for b in tick_bytes(data) if b & 0x10)
def check_cut_spans(name, ticks, n, cut_ticks, what):
"""Exactly n fire spans, each one cutting move long, none stepping
at a rapid's rate: FIRE rode nothing but the G1s."""
spans = fire_spans(ticks)
if len(spans) != n:
fail("[%s] %d fire spans, expected exactly %d (%s) (spans %s)"
% (name, len(spans), n, what, spans))
for s0, s1 in spans:
if not 0.8 * cut_ticks <= s1 - s0 <= 1.25 * cut_ticks:
fail("[%s] fire span of %d ticks, expected ~%d (one G1): FIRE "
"carried into the move after it" % (name, s1 - s0, cut_ticks))
# A G1 at F600 steps once per ~53 ticks; a rapid at 200 mm/s
# steps every ~2.6. Any 100-tick window under FIRE with more
# than a handful of steps is a rapid being cut.
worst = 0
for i in range(s0, max(s0 + 1, s1 - 100), 50):
worst = max(worst, sum(1 for b in ticks[i:i + 100]
if (b & 0x10) and (b & 0x25)))
if worst > 8:
fail("[%s] %d steps in a 100-tick window under FIRE: a rapid "
"was cut" % (name, worst))
return spans
def main(): def main():
# --- session A: M4 dynamic power, rules 1-6 + 7-8 ------------------- # --- session A: M4 dynamic power, rules 1-6 + 7-8 -------------------
data = run_session("m4", JOB_M4, conf=ANALOG_CONF) data = run_session("m4", JOB_M4, conf=ANALOG_CONF)
@@ -673,6 +750,35 @@ def main():
print("PASS [idle-s]: standalone S across idle gaps -> fire ticks per duty %s" print("PASS [idle-s]: standalone S across idle gaps -> fire ticks per duty %s"
% {duty_for(l): fire_by_duty[duty_for(l)] for l in IDLE_S_LEVELS}) % {duty_for(l): fire_by_duty[duty_for(l)] for l in IDLE_S_LEVELS})
# --- session J: M5 executed while idle darkens the next run (rule 16) ---
for model, conf in (("analog", ANALOG_CONF), ("density", DENSITY_CONF)):
name = "m5-idle-" + model
data = run_session(name, JOB_M5_IDLE, conf=conf)
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
"the two G1 moves: FIRE rode a rapid after M5, "
"or the bare G0 sent with the spindle off")
check_termination(name, data)
print("PASS [%s]: M5 at idle -> the rapids after it and a bare G0 ship "
"dark; 2 fire spans of %s ticks"
% (name, [s1 - s0 for s0, s1 in spans]))
# --- session K: the next job, at the same level, fires (rule 17) ---
for model, conf in (("analog", ANALOG_CONF), ("density", DENSITY_CONF)):
name = "next-job-" + model
data = run_session(name, JOB_NEXT + JOB_NEXT, conf=conf)
text = run_session.text
if text.count("laser armed") != 2 or text.count("laser disarmed") != 2:
fail("[%s] expected two armed windows closed by M2 (armed %d, "
"disarmed %d)" % (name, text.count("laser armed"),
text.count("laser disarmed")))
spans = check_cut_spans(name, tick_bytes(data), 2, M5_IDLE_TICKS,
"one G1 per job: the second job's M3 at the "
"first job's S lit nothing, or a rapid fired")
check_termination(name, data)
print("PASS [%s]: the next job's M3 at the previous job's S fires its "
"G1; 2 fire spans of %s ticks"
% (name, [s1 - s0 for s0, s1 in spans]))
print("PASS: all stream emission rules hold") print("PASS: all stream emission rules hold")
+645 -1
View File
@@ -8,7 +8,8 @@ the operator to press the physical arm button before the machine fires;
nothing here defeats that gate. nothing here defeats that gate.
Usage: live_fire_drills.py <drill> [arg] [F] (ircut/pthresh take S, Usage: live_fire_drills.py <drill> [arg] [F] (ircut/pthresh take S,
dladder takes the base period in machine ticks) dladder takes the base period in machine ticks, pcurve takes
F, the line length and an optional rung list)
Drills (pass a name): Drills (pass a name):
witness Phase 5 A-1/A-2/A-5: a short vector mark at S400. Samples witness Phase 5 A-1/A-2/A-5: a short vector mark at S400. Samples
@@ -64,6 +65,37 @@ Drills (pass a name):
what a shipped machine would actually emit. Sets what a shipped machine would actually emit. Sets
laser_pulse_ticks itself when run on the board. laser_pulse_ticks itself when run on the board.
dladder [period] [F] e.g. dladder 20 300 dladder [period] [F] e.g. dladder 20 300
pcurve Laser performance-curve ladder, both instruments: one line
per level at constant feed (default F600, 10 mm/s, 100 mm),
M3 constant power, the laser off between rungs and a
mid-ladder rung repeated at the end as the drift witness. Reads
the HV current and the head thermopile (a scatter detector
in the beam path upstream of the final mirror, so it sees
the beam, not the material) straight from sysfs at ~25 Hz
while the machine runs, brackets each rung on the
controller's Run/Idle states, and reports per rung the
current (mean, spread, max, CLIPPED at 1023), the thermopile
delta over its laser-off baseline with its in-line drift,
the digital flag's duty and the coolant temperature; then
the normalized curve, a monotonicity check, a straight-line
fit with its x-intercept as the measured threshold, and the
repeat-rung comparison. Rungs follow laser_power_model:
analog 16..100 % of duty (dense at the knee), density
1..100 %; a comma list overrides. Records the actual level
each S lands on from $30/$31/$35/$36; measuring the curve
itself wants $35 = 0. JSON record (with the raw trace) in
FORGETEST_BENCH_DATA, else /tmp. Runs on the board for the
thermopile; from a host it falls back to /status (current
only, no curve). Reaches FULL power for 10 s per line.
pcurve [F] [len] [pcts] e.g. pcurve 600 100 16,20,30,50,100
m5dark The rapids after an M5 ship dark: one 20 mm line at M3 S400,
M5, a dwell, a rapid back over the line, a dwell, a rapid
forward, a dwell, M2. Samples sysfs at 25 Hz (board only):
PASS when the current shows exactly one discharge segment,
reads dark after the M5, and laser_on_sampled never goes
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.
expstop Armed kill on the EXPECTED-stop path: start a mark job, expstop Armed kill on the EXPECTED-stop path: start a mark job,
then mid-burn POST /controller/stop (the supervisor stops then mid-burn POST /controller/stop (the supervisor stops
the controller: SIGTERM, reap, exit safing). PASS: emission the controller: SIGTERM, reap, exit safing). PASS: emission
@@ -739,6 +771,617 @@ def drill_dladder(g):
return samples return samples
# --- performance curve ladder ---------------------------------------------
# One line per level at constant feed, two instruments read through each:
# the HV current (the supply's curve under analog; presence only under
# density, where every pulse is full current) and the head thermopile, a
# scatter detector in the beam path upstream of the final mirror, so it
# reads the beam and not the material. Rungs are percents of full. The
# analog list is dense at the knee where the tube starts to lase; the
# density list is weighted to the bottom, where the per-pulse strike
# deficit lives.
PCURVE_ANALOG_PCT = (16, 18, 20, 23, 26, 30, 35, 42, 50, 60, 72, 85, 100)
PCURVE_DENSITY_PCT = (1, 2, 3, 5, 7, 10, 15, 20, 30, 45, 60, 80, 100)
PCURVE_FEED = 600 # mm/min: 10 mm/s
PCURVE_LEN = 100.0 # mm of burn per rung
PCURVE_PITCH = 3.0 # mm between rungs (+X)
PCURVE_GAP_S = 4.0 # laser-off settle before each rung
PCURVE_GAP_SKIP_S = 1.5 # of which the first part still decays
PCURVE_SAMPLE_HZ = 25 # sysfs sampler target rate
PCURVE_TRIM_HEAD_S = 1.0 # dropped from the start of each line
PCURVE_TRIM_TAIL_S = 0.5 # dropped from its end
SYSFS = '/sys/glowforge'
HV_FULL_SCALE = 1023 # the PIC ADC's top count
# (key, sysfs attribute) per sampled channel.
PCURVE_CHANNELS = (
('hv', 'pic/hv_current'),
('tp', 'head/beam_detect_analog'),
('tpd', 'head/beam_detect_digital'),
('lon', 'cnc/laser_on_sampled'),
('wt1', 'pic/water_temp_1'),
('wt2', 'pic/water_temp_2'),
('pt', 'pic/pwr_temp'),
)
class Sampler:
"""Reads the pcurve channels in a thread. On the board they come
straight from sysfs, each read a live bus transaction, so the achieved
rate is whatever the PIC (SPI) and head (I2C) buses allow and it is
reported rather than assumed. From a host the only source is
forgectrl's /status at ~8 Hz, which carries the current and nothing
the thermopile needs."""
def __init__(self, hz):
import threading
self.local = os.path.isdir(SYSFS)
self.period = 1.0 / (hz if self.local else 8)
self.samples = []
self.errors = 0
self._stop = threading.Event()
self._thr = threading.Thread(target=self._run, daemon=True)
def start(self):
self._thr.start()
def stop(self):
self._stop.set()
self._thr.join(timeout=3)
def _read_sysfs(self):
smp = {'t': time.time()}
for key, attr in PCURVE_CHANNELS:
try:
with open(os.path.join(SYSFS, attr)) as f:
smp[key] = int(f.read().strip())
except (OSError, ValueError):
smp[key] = None
self.errors += 1
return smp
def _read_status(self):
st = sample_forgectrl()
smp = dict((key, None) for key, _attr in PCURVE_CHANNELS)
smp['t'] = time.time()
if st is None:
self.errors += 1
return smp
smp['hv'] = st['hv']
smp['lon'] = st['emission']
return smp
def _run(self):
read = self._read_sysfs if self.local else self._read_status
next_t = time.time()
while not self._stop.is_set():
self.samples.append(read())
next_t += self.period
delay = next_t - time.time()
if delay > 0:
time.sleep(delay)
else:
next_t = time.time()
def rate(self):
if len(self.samples) < 2:
return 0.0
span = self.samples[-1]['t'] - self.samples[0]['t']
return (len(self.samples) - 1) / span if span > 0 else 0.0
def _stats(vals):
n = len(vals)
if not n:
return {'n': 0, 'mean': None, 'sd': None, 'min': None, 'max': None}
mean = sum(vals) / float(n)
var = sum((v - mean) ** 2 for v in vals) / float(n)
return {'n': n, 'mean': mean, 'sd': var ** 0.5, 'min': min(vals),
'max': max(vals)}
def _window(samples, t0, t1, key):
return [s[key] for s in samples
if t0 <= s['t'] < t1 and s.get(key) is not None]
def _linfit(xs, ys):
"""Least squares y = a + b x; (a, b, r2), or None below two points."""
n = len(xs)
if n < 2:
return None
mx = sum(xs) / float(n)
my = sum(ys) / float(n)
sxx = sum((x - mx) ** 2 for x in xs)
if sxx == 0:
return None
b = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sxx
a = my - b * mx
ss_res = sum((y - (a + b * x)) ** 2 for x, y in zip(xs, ys))
ss_tot = sum((y - my) ** 2 for y in ys)
r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else 1.0
return a, b, r2
def _degc(raw):
"""The factory coolant conversion when gfbench is importable (on the
board, or with GF_HOST set), else None and the raw count is quoted.
The helper lives beside this file in the repo and under the bench
directory on the dev image; a copy staged elsewhere still finds it."""
for d in (os.path.dirname(os.path.abspath(__file__)),
'/usr/share/forgetest/bench'):
if d not in sys.path:
sys.path.append(d)
try:
from gfbench import degc
except (ImportError, SystemExit):
return None
return degc(raw)
def pcurve_levels(g, pcts):
"""(pct, S, level) per rung: the level is what the core maps S onto
with the settings in force, as a fraction of full (duty/127 under
analog, density under density). None when a setting cannot be read."""
floor = grbl_setting(g, '$35')
ceil = grbl_setting(g, '$36')
rpm_max = grbl_setting(g, '$30')
rpm_min = grbl_setting(g, '$31')
if None in (floor, ceil, rpm_max, rpm_min) or rpm_max <= rpm_min:
return None, (rpm_max, rpm_min, floor, ceil)
min_value = int(PWM_PERIOD * floor / 100.0)
max_value = int(PWM_PERIOD * ceil / 100.0)
gradient = (max_value - min_value) / (rpm_max - rpm_min)
levels = []
for pct in pcts:
sval = int(round(rpm_max * pct / 100.0))
if sval <= rpm_min:
level = 0
else:
level = min(int((sval - rpm_min) * gradient) + min_value, max_value)
levels.append((pct, sval, level / float(PWM_PERIOD)))
return levels, (rpm_max, rpm_min, floor, ceil)
def pcurve_analyze(samples, rungs, head_trim=PCURVE_TRIM_HEAD_S,
tail_trim=PCURVE_TRIM_TAIL_S):
"""Per-rung statistics over the trimmed steady window of each line,
then the curve: normalized thermopile delta against level, a
monotonicity count, straight-line fits with their x-intercepts, and
the repeat-rung comparison. Pure: takes the raw trace and the rung
brackets, returns a dict, so it can be checked without a machine."""
rows = []
for r in rungs:
t0, t1 = r['t_run0'] + head_trim, r['t_run1'] - tail_trim
if t1 - t0 < 1.0:
t0, t1 = r['t_run0'], r['t_run1']
hv = _stats(_window(samples, t0, t1, 'hv'))
tp = _stats(_window(samples, t0, t1, 'tp'))
# The thermopile falls back to baseline within about a second of a
# line ending (measured 2026-08-25), so the first part of the gap
# still carries the previous rung's tail; the baseline is the rest.
base = _stats(_window(samples, r['t_gap0'] + PCURVE_GAP_SKIP_S,
r['t_m3'], 'tp'))
first = _stats(_window(samples, t0, min(t0 + 2.0, t1), 'tp'))
last = _stats(_window(samples, max(t1 - 2.0, t0), t1, 'tp'))
tpd = _window(samples, t0, t1, 'tpd')
# laser_on_sampled is a once-per-second window count, so the
# last window of a line lands after Idle: look one second past.
lon = _window(samples, r['t_run0'], r['t_run1'] + 1.0, 'lon')
# Coolant from the UPSTREAM sensor: water_temp_1 sits downstream of
# the flow-check heater and swings with it during a run.
wt1 = _stats(_window(samples, r['t_gap0'], r['t_m3'], 'wt2'))
pt = _stats(_window(samples, r['t_gap0'], r['t_m3'], 'pt'))
delta = (tp['mean'] - base['mean']
if tp['mean'] is not None and base['mean'] is not None
else None)
drift = (last['mean'] - first['mean']
if last['mean'] is not None and first['mean'] is not None
else None)
rows.append({
'rung': r['rung'], 'repeat': r.get('repeat', False),
'pct': r['pct'], 's': r['s'], 'level': r['level'],
'seconds': round(r['t_run1'] - r['t_run0'], 2),
'hv_n': hv['n'], 'hv_mean': hv['mean'], 'hv_sd': hv['sd'],
'hv_max': hv['max'],
'hv_clipped': hv['max'] is not None and hv['max'] >= HV_FULL_SCALE,
'tp_n': tp['n'], 'tp_mean': tp['mean'], 'tp_sd': tp['sd'],
'tp_base': base['mean'], 'tp_base_sd': base['sd'],
'tp_delta': delta, 'tp_drift': drift,
'tpd_duty': (sum(1 for v in tpd if v) / float(len(tpd))
if tpd else None),
'lon_max': max(lon) if lon else None,
'fired': bool(lon) and max(lon) > 0,
'coolant_raw': wt1['mean'],
'coolant_c': _degc(wt1['mean']) if wt1['mean'] is not None else None,
'supply_raw': pt['mean'],
})
primary = [row for row in rows if not row['repeat']]
primary.sort(key=lambda row: row['level'])
fired = [row for row in primary if row['fired']]
curve = {'rows': len(rows), 'fired': len(fired)}
# Normalize the thermopile delta to the top of the ladder.
deltas = [row['tp_delta'] for row in fired if row['tp_delta'] is not None]
top = max(deltas) if deltas else None
for row in rows:
row['tp_norm'] = (row['tp_delta'] / top
if top and row['tp_delta'] is not None else None)
# Monotonicity: decreases of the delta with rising level, beyond noise.
dec = 0
prev = None
for row in fired:
if row['tp_delta'] is None:
continue
if prev is not None and row['tp_delta'] < prev['tp_delta'] - 2.0 * (row['tp_sd'] or 0):
dec += 1
prev = row
curve['tp_decreases'] = dec
dec = 0
prev = None
for row in fired:
if row['hv_mean'] is None or row['hv_clipped']:
continue
if prev is not None and row['hv_mean'] < prev['hv_mean'] - 2.0 * (row['hv_sd'] or 0):
dec += 1
prev = row
curve['hv_decreases'] = dec
# Signal rungs: delta clear of the baseline noise, for the fits.
sig = [row for row in fired if row['tp_delta'] is not None
and row['tp_delta'] > 3.0 * (row['tp_base_sd'] or 0)]
fit = _linfit([row['level'] for row in sig], [row['tp_delta'] for row in sig])
if fit:
a, b, r2 = fit
curve['tp_fit'] = {'points': len(sig), 'intercept': a, 'slope': b,
'r2': r2,
'x_intercept': (-a / b) if b else None}
unclipped = [row for row in fired if row['hv_mean'] is not None
and not row['hv_clipped']]
fit = _linfit([row['level'] for row in unclipped],
[row['hv_mean'] for row in unclipped])
if fit:
a, b, r2 = fit
curve['hv_fit'] = {'points': len(unclipped), 'intercept': a,
'slope': b, 'r2': r2,
'x_intercept': (-a / b) if b else None}
curve['hv_clipped_rungs'] = [row['rung'] for row in rows if row['hv_clipped']]
reps = [row for row in rows if row['repeat']]
firsts = [row for row in rows if not row['repeat'] and reps
and row['rung'] == reps[-1]['rung']]
if reps and firsts:
first, again = firsts[0], reps[-1]
rep = {'rung': first['rung']}
if first['tp_delta'] is not None and again['tp_delta'] is not None:
rep['tp_delta_first'] = first['tp_delta']
rep['tp_delta_again'] = again['tp_delta']
rep['tp_delta_change'] = again['tp_delta'] - first['tp_delta']
rep['tp_delta_change_pct'] = (100.0 * rep['tp_delta_change'] / first['tp_delta']
if first['tp_delta'] else None)
if first['hv_mean'] is not None and again['hv_mean'] is not None:
rep['hv_change'] = again['hv_mean'] - first['hv_mean']
bases = [row['tp_base'] for row in rows if row['tp_base'] is not None]
if len(bases) >= 2:
rep['baseline_walk'] = bases[-1] - bases[0]
curve['repeat'] = rep
return {'rungs': rows, 'curve': curve}
def _fmt(v, prec=1):
if v is None:
return '-'
if isinstance(v, float):
return '%.*f' % (prec, v)
return str(v)
def pcurve_report(res, model):
rows, curve = res['rungs'], res['curve']
print('\n--- per rung (steady window, first %gs and last %gs of each line dropped) ---'
% (PCURVE_TRIM_HEAD_S, PCURVE_TRIM_TAIL_S))
unit = 'density' if model == 'density' else 'duty'
print(' rung %% S %-8s hv mean sd max | tp delta sd base drift norm | dig lon cool'
% unit)
for row in rows:
tag = '%2d%s' % (row['rung'], 'r' if row['repeat'] else ' ')
print(' %s %3d %4d %6.2f%% %7s %5s %5s%s | %8s %5s %7s %6s %5s | %4s %4s %s'
% (tag, row['pct'], row['s'], 100.0 * row['level'],
_fmt(row['hv_mean']), _fmt(row['hv_sd']), _fmt(row['hv_max'], 0),
'!' if row['hv_clipped'] else ' ',
_fmt(row['tp_delta']), _fmt(row['tp_sd']), _fmt(row['tp_base']),
_fmt(row['tp_drift']), _fmt(row['tp_norm'], 3),
_fmt(row['tpd_duty'], 2), _fmt(row['lon_max'], 0),
_fmt(row['coolant_c']) if row['coolant_c'] is not None
else _fmt(row['coolant_raw'], 0) + 'raw'))
print(' (! = hv_current touched %d: the ADC is clipped there and the'
% HV_FULL_SCALE)
print(' current column is no longer a measurement on that rung)')
print('\n--- curve ---')
print('rungs fired (laser_on_sampled > 0): %d of %d' % (curve['fired'], curve['rows']))
print('thermopile delta decreases with rising level (beyond 2 sd): %s'
% curve['tp_decreases'])
print('hv_current decreases with rising level (beyond 2 sd, unclipped): %s'
% curve['hv_decreases'])
if curve.get('hv_clipped_rungs'):
print('hv_current CLIPPED on rungs %s' % curve['hv_clipped_rungs'])
for name, key in (('thermopile', 'tp_fit'), ('hv_current', 'hv_fit')):
f = curve.get(key)
if not f:
print('%s fit: not enough signal rungs' % name)
continue
print('%s vs level: %d points, slope %.1f per 100%%, r2 %.3f, '
'x-intercept %s%% (the measured threshold if the fit holds)'
% (name, f['points'], f['slope'], f['r2'],
_fmt(100.0 * f['x_intercept']) if f['x_intercept'] is not None else '-'))
rep = curve.get('repeat')
if rep:
print('repeat of rung %d: thermopile delta %s -> %s (%s, %s%%), '
'hv %s; baseline walked %s over the ladder'
% (rep['rung'], _fmt(rep.get('tp_delta_first')),
_fmt(rep.get('tp_delta_again')), _fmt(rep.get('tp_delta_change')),
_fmt(rep.get('tp_delta_change_pct')), _fmt(rep.get('hv_change')),
_fmt(rep.get('baseline_walk'))))
def drill_pcurve(g):
feed = int(sys.argv[2]) if len(sys.argv) > 2 else PCURVE_FEED
length = float(sys.argv[3]) if len(sys.argv) > 3 else PCURVE_LEN
model = conf_get('laser_power_model') or 'density'
if len(sys.argv) > 4:
pcts = tuple(int(x) for x in sys.argv[4].split(',') if x.strip())
else:
pcts = PCURVE_DENSITY_PCT if model == 'density' else PCURVE_ANALOG_PCT
if not pcts or min(pcts) < 1 or max(pcts) > 100:
print('rungs must be percents in 1..100')
return 2
print('=== laser performance curve: %s model, %d rungs + repeat, F%d, %g mm each ==='
% (model, len(pcts), feed, length))
print('constant power (M3): the commanded level is the tested level.')
levels, (rpm_max, rpm_min, floor, ceil) = pcurve_levels(g, pcts)
if levels is None:
print('PRECONDITION FAILED: cannot read $30/$31/$35/$36 (%s/%s/%s/%s)'
% (rpm_max, rpm_min, floor, ceil))
return 2
print('mapping: $30=%g $31=%g $35=%g $36=%g' % (rpm_max, rpm_min, floor, ceil))
if floor > 0.0:
print('a floor is set, so the low rungs land on it: this run records')
print('the shipping mapping. To measure the curve itself set $35=0')
print('and restart the controller first.')
unit = 'density' if model == 'density' else 'duty'
# The drift witness is a mid-ladder rung drawn again at the end: it
# has real signal (the bottom rung sits at the threshold and reads
# nothing twice) and it is not full power, so it adds little heat.
witness = len(levels) // 2
print('rungs (each a +X line from the block\'s X0, stepping +Y %g mm; rung'
% PCURVE_PITCH)
print('%d is drawn again at the end, running -X, as the drift witness;'
% (witness + 1))
print('the next run\'s block starts %g mm further along X):' % length)
for i, (pct, sval, level) in enumerate(levels):
print(' %2d: %3d%% -> S%-4d %s %.2f%%' % (i + 1, pct, sval, unit, 100.0 * level))
sampler = Sampler(PCURVE_SAMPLE_HZ)
if sampler.local:
print('sampling sysfs on the board at a target %d Hz: %s'
% (PCURVE_SAMPLE_HZ, ' '.join(attr for _k, attr in PCURVE_CHANNELS)))
else:
print('NOT on the board: sampling forgectrl /status at ~8 Hz instead.')
print('That carries the current and the emission witness only; the')
print('thermopile is not in /status, so this run yields no curve.')
print('connect: %s' % prepare(g))
print('pre-fire: %s' % sample_forgectrl())
arm_cue()
print('>>> This ladder reaches FULL power for %.0f s per line. Use'
% (length / feed * 60.0))
print('>>> something you are willing to cut through and that will not')
print('>>> flame: scrap tile, firebrick, thick draftboard on a')
print('>>> sacrificial layer. The thermopile is in the head, so the')
print('>>> material is not part of the measurement.\n')
print('G91/G21: %s / %s' % (g.cmd('G91'), g.cmd('G21')))
order = list(range(len(levels))) + [witness]
line_s = length / feed * 60.0
sampler.start()
rungs = []
aborted = None
try:
for n, idx in enumerate(order):
pct, sval, level = levels[idx]
repeat = n == len(order) - 1
# Every line runs +X from the block's X0 at one Y and the rungs
# step +Y, so a run occupies a block `length` wide by
# PCURVE_PITCH x rungs tall and the next run's block starts
# `length` further along X. The drift witness runs the other
# way, from the far end back to X0: a swing that reverses with
# direction is head position along the gantry; one that repeats
# is time.
if repeat:
g.s.sendall(('G0 X%g\n' % length).encode())
g.wait_state('Idle', 30)
t_gap0 = time.time()
time.sleep(PCURVE_GAP_S) # laser off: the baseline
t_m3 = time.time()
job = ['M3 S%d' % sval,
'G1 X%g F%d' % (-length if repeat else length, feed),
'M5']
for ln in job:
g.s.sendall(ln.encode() + b'\n')
st = g.wait_state('Run', 240 if n == 0 else 60)
if not st.startswith('Run'):
aborted = ('rung %d never ran (state=%s): arm refused, no '
'press, or the controller alarmed' % (idx + 1, st))
break
t_run0 = time.time()
st = g.wait_state('Idle', line_s + 30.0, poll=0.05)
t_run1 = time.time()
if not st.startswith('Idle'):
aborted = 'rung %d did not finish (state=%s)' % (idx + 1, st)
break
if t_run1 - t_run0 < line_s - 1.5:
# A line that ended early was cancelled by the operator or
# the controller, and a cancel may have moved the head
# (the controller returns to machine zero). From here every
# relative move is aimed from a position this drill no
# longer knows, so send nothing more.
aborted = ('rung %d ran %.1f s of %.1f: cancelled; no further '
'moves sent' % (idx + 1, t_run1 - t_run0, line_s))
break
rungs.append({'rung': idx + 1, 'repeat': repeat, 'pct': pct,
's': sval, 'level': level, 't_gap0': t_gap0,
't_m3': t_m3, 't_run0': t_run0, 't_run1': t_run1})
print(' rung %2d%s: S%-4d ran %.1f s' % (idx + 1, 'r' if repeat else ' ',
sval, t_run1 - t_run0))
back = '' if repeat else 'G0 X%g\n' % -length
g.s.sendall((back + 'G0 Y%g\n' % PCURVE_PITCH).encode())
g.wait_state('Idle', 30)
finally:
try:
g.cmd('M5', timeout=1)
except Exception:
pass
if aborted:
g.rt(b'\x18') # abort out of whatever it is in
else:
g.s.sendall(b'G90\nM2\n') # program end closes the window
time.sleep(1.5)
sampler.stop()
if aborted:
print('ABORTED: %s' % aborted)
print('\nsampler: %d samples, %.1f Hz achieved, %d read errors'
% (len(sampler.samples), sampler.rate(), sampler.errors))
if not rungs:
return 1
res = pcurve_analyze(sampler.samples, rungs)
pcurve_report(res, model)
record = {
'drill': 'pcurve', 'date': time.strftime('%Y-%m-%dT%H:%M:%S'),
'model': model, 'feed': feed, 'length_mm': length,
'gap_s': PCURVE_GAP_S, 'pitch_mm': PCURVE_PITCH,
'settings': {'$30': rpm_max, '$31': rpm_min, '$35': floor, '$36': ceil},
'sampler': {'local': sampler.local, 'hz': sampler.rate(),
'samples': len(sampler.samples), 'errors': sampler.errors},
'aborted': aborted, 'rungs': res['rungs'], 'curve': res['curve'],
'trace': sampler.samples,
}
ddir = os.environ.get('FORGETEST_BENCH_DATA') or ('/tmp' if sampler.local else os.getcwd())
path = os.path.join(ddir, 'pcurve_%s_%s.json' % (model, time.strftime('%Y%m%d-%H%M%S')))
try:
with open(path, 'w') as f:
json.dump(record, f, indent=1)
print('record: %s' % path)
except OSError as e:
print('record not written: %s' % e)
print('\nRead it in this order. First the instrument: the thermopile')
print('delta must rise with the level on every rung that fired, settle')
print('inside the line (small drift), return to its baseline between')
print('rungs, and the repeat rung must agree with its first run. Any')
print('miss there is a fact about the sensor, not the tube. Then the')
print('curve: under analog the current column is the supply and the')
print('thermopile is the tube; under density the current is only a')
print('presence witness and the thermopile is the whole story. A knee')
print('where the delta stops rising before 100%% is the ceiling S1000')
print('should map to. The material remains the witness that it lased.')
return res
# --- the rapids after an M5 ship dark ----------------------------------------
# One constant-power line, M5, then two rapids over it with dwells between,
# the shape every ladder above uses between rungs. M5 executes with the
# planner drained and the kernel run over, and the core issues no
# per-segment laser update for moves made with the spindle off, so only the
# stream's wanted fire state decides whether those rapids fire.
# S1000 is a certain strike and, under the density model, the worst case
# for the bug: full duty pinned, so a rapid that inherited fire would run
# at full power.
M5DARK_JOB = ['G91', 'G21', 'M3', 'S400',
'G1 X20 F600',
'M5', 'G4 P2.5',
'G0 X-20', 'G4 P2.5',
'G0 X20', 'G4 P2.5',
'G90', 'M2']
HV_DARK_MAX = 20 # hv_current reads 0 with the tube off
def drill_m5dark(g):
print('=== the rapids after an M5 ship dark: M3 S400, 20 mm line, M5, two rapids ===')
sampler = Sampler(PCURVE_SAMPLE_HZ)
if not sampler.local:
print('run this on the board: the witnesses are sysfs at 25 Hz')
return 2
print('connect: %s' % prepare(g))
print('pre-fire: %s' % sample_forgectrl())
arm_cue()
print('>>> 20 mm of free +X travel at the head. One 20 mm line at S400,')
print('>>> then the head rapids back over it and forward again, dark.\n')
sampler.start()
for ln in M5DARK_JOB:
g.s.sendall(ln.encode() + b'\n')
st = g.wait_state('Run', 240)
if not st.startswith('Run'):
print('FAIL: the job never ran (state=%s)' % st)
g.rt(b'\x18')
sampler.stop()
return 1
# The controller reports Idle inside a G4 dwell, so Idle is no sign the
# job is over; the armed window closing at M2 is.
t0 = time.time()
seen_armed = False
while time.time() - t0 < 90:
smp = sample_forgectrl()
if smp and smp['armed']:
seen_armed = True
elif smp and seen_armed and not smp['armed']:
break
time.sleep(0.2)
time.sleep(1.5)
sampler.stop()
tr = sampler.samples
# Discharge segments from the current, 1 s hysteresis: the line is one;
# a rapid that fired is another.
segs, cur = [], None
for s in tr:
on = s['hv'] is not None and s['hv'] > 30
if on and cur is None:
cur = [s['t'], s['t']]
elif on:
cur[1] = s['t']
elif cur is not None and s['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()))
if not segs:
print('FAIL: no discharge seen at all (arm refused, no press, or no fire)')
return 1
t_end = segs[0][1]
base = _stats(_window(tr, segs[0][0] - 2.0, segs[0][0] - 0.2, 'tp'))['mean']
print('line: %.2f s of discharge; %d discharge segment(s) in the run%s'
% (t_end - segs[0][0], len(segs),
'' if len(segs) == 1 else ': the extra ones are rapids that FIRED'))
hv_after = max((s['hv'] for s in tr if s['t'] > t_end + 0.3 and s['hv'] is not None),
default=0)
lon_after = [s for s in tr if s['t'] > t_end + 0.3 and s.get('lon')]
# laser_on_sampled lags a window: the first zero past the line is the
# dark point, and nothing after it may be nonzero.
zeros = [s['t'] for s in tr if s['t'] > t_end and s.get('lon') == 0]
relit = [s for s in tr if zeros and s['t'] > zeros[0] and s.get('lon')]
print('after the M5: hv max %d (dark <= %d); laser_on_sampled nonzero samples %d, '
'after its first zero %d' % (hv_after, HV_DARK_MAX, len(lon_after), len(relit)))
print('trace from 0.2 s before the line ended, 40 ms steps (hv / thermopile delta):')
row = [s for s in tr if t_end - 0.2 <= s['t'] <= t_end + 9.0]
for i in range(0, len(row), 25):
chunk = row[i:i + 25]
print(' +%4.1fs hv: %s' % (chunk[0]['t'] - t_end, ' '.join('%d' % (s['hv'] or 0) for s in chunk)))
print(' tp: %s' % ' '.join('%d' % ((s['tp'] or 0) - (base or 0)) for s in chunk))
covered = tr[-1]['t'] - t_end
if covered < 7.0:
print('M5DARK INCONCLUSIVE: the trace ends %.1f s after the line, before '
'the rapids (the job runs ~8 s past the M5)' % covered)
return 1
ok = len(segs) == 1 and hv_after <= HV_DARK_MAX and not relit
print('M5DARK %s' % ('PASS: the rapids after the M5 shipped dark (%.1f s sampled past the line)'
% covered if ok else 'FAIL: the laser fired after the M5'))
return 0 if ok else 1
def post_ctrl(action): def post_ctrl(action):
# http.client preserves the header-name case exactly as given. # http.client preserves the header-name case exactly as given.
import http.client import http.client
@@ -825,6 +1468,7 @@ def main():
drills = {'witness': drill_witness, 'hold': drill_hold, drills = {'witness': drill_witness, 'hold': drill_hold,
'faultpos': drill_faultpos, 'ircut': drill_ircut, 'faultpos': drill_faultpos, 'ircut': drill_ircut,
'pthresh': drill_pthresh, 'dladder': drill_dladder, 'pthresh': drill_pthresh, 'dladder': drill_dladder,
'pcurve': drill_pcurve, 'm5dark': drill_m5dark,
'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart} 'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart}
if drill not in drills: if drill not in drills:
print(__doc__) print(__doc__)