forgetest: the emission witness samples a rapid after its M5; m5-rapid-dark leaves

The rule that a rapid after an M5 ships no FIRE is the host's (laser
stream rule 16). What only the machine shows is LASER_ON staying off
with FIRE clear while the chain can fire; kernel.fire-line's dark
sample runs with the chain unarmed. laser.m5-rapid-dark held that
sample in a test of its own, with its own arm and press. The emission
witness, which every campaign runs, now holds it.

- The square ends at M5 with no program end, so the window stays open.
- The dark point: the LASER_ON count reads 0 after the M5, the window
  still armed, and the SoC lock line released (laser_locked false: the
  engine's armed flag can read true after the latch has locked, and a
  dark reading with the latch locked is the unarmed case). All three
  are checked before anything else is sent.
- Only then is the rapid sent: G0 X20, a 2.5 s dwell, G0 X-20, a 0.5 s
  dwell, the program end. Sending it after the dark point keeps the
  mid-square dwell's own zero from being taken for the dark point.
- The test samples until the window closes (a dwell reports Idle, so
  Idle cannot end it), with the controller's machine X beside each
  sample.
- Every sample from the dark point until the window closes reads no
  emission, and the HV current stays at its idle reading (HV_DARK_MAX);
  the reports show the head out at +20 mm and back at the start inside
  that stretch, and the latch reads released on the sample at +20 mm.
- The square still has to show emission and an HV rise, judged on the
  samples before the M5; the beam detector is judged there too.
- The job-based disarm is timed from the head being back: under 10 s,
  where the idle grace is 60 s.
- The rapid's list and its sampler live inside the test, and the
  deletion takes only m5-rapid-dark's own lines (decorator through
  body), so laser.py's shared text is byte-for-byte unchanged.
- live_fire_drills.py's m5dark note and the bench README say the
  emission witness now carries the drill's check.

Proof: the fingerprint parts of laser.py before and after: the shared
text unchanged, every other test's own text unchanged. In the
controller, glowforge_laser.c keeps the latch unlocked from the job's
first laser-on through M5/M3 toggles and relocks it at program end or
after the laser_disarm_s spindle-off grace (60 s by default), so the
dark point and the rapid fall inside the window. The coverage lint
reports 0 uncovered paths over 110 tests (its one failure is
setup.check-lens's src/lensfind.* entry, which the forgectrl pin bump
clears); pyflakes adds no finding; the forgetest host suite passes
under Linux.

Acceptance: only laser.emission-witness moves, and as an always-required
test it must pass on the bench in the next campaign before a release
can inherit its PASS.
This commit is contained in:
ScottW514
2026-10-13 16:30:45 -04:00
parent 6a434bbf77
commit 571f702113
3 changed files with 112 additions and 103 deletions
+109 -101
View File
@@ -457,15 +457,45 @@ def power_floor(ctx):
"(G4) between its second and third sides: emission_samples (the kernel's " "(G4) between its second and third sides: emission_samples (the kernel's "
"LASER_ON sample count) goes nonzero during the fire window and returns to 0 " "LASER_ON sample count) goes nonzero during the fire window and returns to 0 "
"at Idle, HV current rises during the burn, the head's beam detector sees the " "at Idle, HV current rises during the burn, the head's beam detector sees the "
"beam, the armed window is observed, the M2 program end disarms promptly at " "beam, and the armed window is observed. Across the dwell the kernel run ends "
"Idle (job-based, not the 60 s idle grace) and the button goes dark. Across " "and restarts: HV_ENABLE drops with the charge-pump watchdog and is back for "
"the dwell the kernel run ends and restarts: HV_ENABLE drops with the " "the third side, while the hardware button latch stays clear (cnc/button_latch "
"charge-pump watchdog and is back for the third side, while the hardware " "0 in every armed sample), so the second half of the square marks without a "
"button latch stays clear (cnc/button_latch 0 in every armed sample), so the " "second press. The square ends at M5 with the window still open; once the "
"second half of the square marks without a second press. The operator " "LASER_ON count reads 0 (the dark point), a rapid runs 20 mm out over the "
"confirms all four sides.") "first side and back, then the program ends. From the dark point until the "
"window closes, every sample reads no emission and the HV current at its idle "
"reading, and the head's reports place the rapid inside that stretch: LASER_ON "
"stays off with FIRE clear while the chain can fire. The M2 program end "
"disarms promptly (job-based, not the 60 s idle grace) and the button goes "
"dark. The operator confirms all four sides.")
def emission_witness(ctx): def emission_witness(ctx):
ev = ctx.evidence ev = ctx.evidence
# The rapid after the M5 (G91 is modal from the square): out over the
# first side and back, then the program end.
rapid_tail = ["G0 X20", "G4 P2.5", "G0 X-20", "G4 P0.5", "G90", "M2"]
def sample_to_disarm(g, timeout=20.0, sample_hz=8):
"""Sample until the armed window closes, or the timeout, with the
controller's state and machine X beside each sample ("x"). A G4
dwell reports Idle, so Idle cannot end this; the window closing at
the program end does."""
samples = []
period = 1.0 / sample_hz
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
smp = sample(ctx)
rep = g.status_report()
if smp:
smp["gstate"] = rep.get("state")
smp["x"] = (rep.get("MPos") or (None,))[0]
samples.append(smp)
if not smp["armed"]:
break
time.sleep(period)
return samples
with ctx.grbl() as g, LiveJob(ctx, g): with ctx.grbl() as g, LiveJob(ctx, g):
prepare(ctx, g) prepare(ctx, g)
base = sample(ctx) base = sample(ctx)
@@ -475,21 +505,46 @@ def emission_witness(ctx):
base["armed"], base["verdict"]) base["armed"], base["verdict"])
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"]) ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
ctx.ready(ARM_CUE % "20 mm +X and +Y") ctx.ready(ARM_CUE % "20 mm +X and +Y")
# The square ends at M5 with no program end: the window stays open
# for the rapid that follows.
job = ["G91", "G21", "M4", "S400", job = ["G91", "G21", "M4", "S400",
"G1 X20 F600", "G1 Y20 F600", "G4 P2", "G1 X-20 F600", "G1 Y-20 F600", "G1 X20 F600", "G1 Y20 F600", "G4 P2", "G1 X-20 F600", "G1 Y-20 F600",
"M5", "G90", "M2"] "M5"]
ctx.arm_press() ctx.arm_press()
try: try:
samples = run_and_sample(ctx, g, job) cut = run_and_sample(ctx, g, job)
# The dark point: the M5 has run, the LASER_ON count reads 0, and
# the window is still open. The rapid is sent only after it, so
# the dark stretch covers the rapid by construction, and the
# mid-square dwell's own zero cannot be taken for it.
ctx.wait_for(lambda: (sample(ctx) or {}).get("emission") == 0, 10, poll=0.125)
dark = sample(ctx)
ctx.check(dark and dark["emission"] == 0,
"the LASER_ON count did not return to 0 after the M5 (%s)", dark and dark["emission"])
ctx.check(dark["armed"], "the armed window closed after the M5, before the rapid "
"(phase %s): the rapid would not run under it", dark["phase"])
# The engine's armed flag can still read true after the latch has
# locked, and a dark reading with the latch locked is the unarmed
# case this sample is here to exclude: the SoC lock line must read
# released.
ctx.check(dark["locked"] is False, "the laser latch is locked at the dark point "
"(laser_locked %s): the rapid would run unarmed", dark["locked"])
rep = g.status_report()
dark["gstate"] = rep.get("state")
dark["x"] = (rep.get("MPos") or (None,))[0]
ctx.check(dark["x"] is not None, "the controller reports no machine position: %s", rep)
stream(g, rapid_tail)
tail = sample_to_disarm(g)
finally: finally:
ctx.clear_notice() ctx.clear_notice()
samples = cut + [dark] + tail
ev["trail_fields"] = list(TRAIL_FIELDS) ev["trail_fields"] = list(TRAIL_FIELDS)
ev["trail"] = trail(samples) ev["trail"] = trail(samples)
beam = beam_witness(ctx, ev, samples, base) beam = beam_witness(ctx, ev, cut, base)
emis = [s["emission"] for s in samples if s["emission"] is not None] emis = [s["emission"] for s in cut if s["emission"] is not None]
peak = max(emis) if emis else 0 peak = max(emis) if emis else 0
end = emis[-1] if emis else None end = next((s["emission"] for s in reversed(samples) if s["emission"] is not None), None)
hv = [s["hv"] for s in samples if s["hv"] is not None] hv = [s["hv"] for s in cut if s["hv"] is not None]
ir_peak = [0, 0, 0, 0] ir_peak = [0, 0, 0, 0]
for s in samples: for s in samples:
if s["ir"] and len(s["ir"]) == 4: if s["ir"] and len(s["ir"]) == 4:
@@ -533,12 +588,34 @@ def emission_witness(ctx):
ev["exhaust_floor"] = floor ev["exhaust_floor"] = floor
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)", ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
ev["exhaust_at_first_fire"], floor) ev["exhaust_at_first_fire"], floor)
# X-3: job-based disarm at Idle after M2 # The rapid after the M5, with the window open and FIRE clear: every
dt = wait_disarm(ctx, 75) # sample from the dark point until the window closes reads no emission
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None # and the HV current at its idle reading. The head's reports place the
ctx.log("time-to-disarm after Idle: %s s", ev["disarm_after_idle_s"]) # rapid inside that stretch: out to +20 mm, then back at the start.
stretch = [dark] + tail
x0 = dark["x"]
i_out = next((i for i, s in enumerate(stretch) if s.get("x") is not None and s["x"] - x0 >= 19.5), None)
i_back = (next((i for i in range(i_out + 1, len(stretch))
if stretch[i].get("x") is not None and abs(stretch[i]["x"] - x0) < 0.05), None)
if i_out is not None else None)
closed = stretch[-1] if not stretch[-1]["armed"] else None
relit = [s for s in stretch if s["emission"]]
hv_dark = max((s["hv"] for s in stretch if s["hv"] is not None), default=0)
dt = (closed["t"] - stretch[i_back]["t"]) if (closed and i_back is not None) else None
ev["rapid"] = {"samples": len(stretch), "span_s": round(stretch[-1]["t"] - dark["t"], 2),
"x_start": x0, "x_max": max((s["x"] for s in stretch if s.get("x") is not None), default=None),
"out_at_s": round(stretch[i_out]["t"] - dark["t"], 2) if i_out is not None else None,
"back_at_s": round(stretch[i_back]["t"] - dark["t"], 2) if i_back is not None else None,
"relit": [(round(s["t"] - dark["t"], 2), s["emission"]) for s in relit][:8],
"hv_max": hv_dark, "locked_at_dark": dark["locked"],
"locked_at_out": stretch[i_out]["locked"] if i_out is not None else None}
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
ctx.log("the rapid after the M5: %d samples over %.1f s from the dark point, out at +%s s, back at +%s s, "
"%d with emission, HV max %s; the window closed %s s after the head was back", len(stretch),
ev["rapid"]["span_s"], ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"], len(relit), hv_dark,
ev["disarm_after_idle_s"])
ctx.check(armed_seen, "the armed window was never observed (arm refused, or no button press)") ctx.check(armed_seen, "the armed window was never observed (arm refused, or no button press)")
ctx.check(peak > 0, "no emission witnessed (emission_samples stayed 0)") ctx.check(peak > 0, "no emission witnessed on the square (emission_samples stayed 0)")
first_idle_fire = ev["idle_airflow_fire"][0] if idle_fire else (None, None, None) first_idle_fire = ev["idle_airflow_fire"][0] if idle_fire else (None, None, None)
ctx.check(not idle_fire, ctx.check(not idle_fire,
"the laser fired while the cooling engine had no run session, so the fans were at " "the laser fired while the cooling engine had no run session, so the fans were at "
@@ -550,8 +627,18 @@ def emission_witness(ctx):
"emission sample, floor %.0f)", ev["exhaust_at_first_fire"], floor) "emission sample, floor %.0f)", ev["exhaust_at_first_fire"], floor)
ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)", ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)",
ev["hv_min"], ev["hv_max"]) ev["hv_min"], ev["hv_max"])
ctx.check(i_out is not None and i_back is not None,
"the rapid did not run inside the dark stretch: out at %s, back at %s (X from %s, max %s)",
ev["rapid"]["out_at_s"], ev["rapid"]["back_at_s"], x0, ev["rapid"]["x_max"])
ctx.check(stretch[i_out]["locked"] is False, "the laser latch was locked with the head at +20 mm "
"(laser_locked %s): the rapid ran unarmed", stretch[i_out]["locked"])
ctx.check(not relit, "the laser emitted after the M5: %d samples, first at +%s s (emission_samples %s) "
"- a rapid after M5 fired", len(relit), relit[0]["t"] - dark["t"] if relit else None,
relit[0]["emission"] if relit else None)
ctx.check(hv_dark <= HV_DARK_MAX, "HV current %s after the M5 (idle reads ~0): the discharge ran "
"through the rapid", hv_dark)
ctx.check(dt is not None and dt < 10.0, ctx.check(dt is not None and dt < 10.0,
"the M2 job did not disarm promptly at Idle (%s s; the idle grace is ~60 s)", "the M2 job did not disarm promptly (%s s after the head was back; the idle grace is ~60 s)",
ev["disarm_after_idle_s"]) ev["disarm_after_idle_s"])
ctx.check(gap["button_latch_unlocked_samples"] and gap["button_latch_unlocked_max"] == 0, ctx.check(gap["button_latch_unlocked_samples"] and gap["button_latch_unlocked_max"] == 0,
"the hardware button latch read SET while the laser latch was unlocked (max %s over %d " "the hardware button latch read SET while the laser latch was unlocked (max %s over %d "
@@ -563,93 +650,14 @@ def emission_witness(ctx):
judge_beam(ctx, beam) judge_beam(ctx, beam)
check_button_dark(ctx, ev) check_button_dark(ctx, ev)
ctx.confirm("Did the laser mark a 20 mm square outline on the scrap, all four sides?") ctx.confirm("Did the laser mark a 20 mm square outline on the scrap, all four sides?")
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s, disarmed %.1f s after Idle, button " ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s; the rapid after the M5 dark (HV max %s); "
"dark, mark confirmed", peak, ev["hv_min"], ev["hv_max"], beam["delta"], dt) "disarmed %.1f s after the head was back, button dark, mark confirmed", peak, ev["hv_min"],
ev["hv_max"], beam["delta"], hv_dark, dt)
HV_DARK_MAX = 20 # hv_current_raw reads 0 with the tube off; hundreds under fire 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")
# +20 out on the cut, then the two rapids the test is about, then
# a third that puts the head back where the job found it: the
# first two net to +20 and a job that walks the head 20 mm and
# leaves it there is dirt the hand-back has to jog out. The
# return is inside the sampling window and after the M5, so it is
# one more rapid that must be dark, which is what the test asks
# of the other two.
job = ["G91", "G21", "M3", "S400",
"G1 X20 F600",
"M5", "G4 P2.5",
"G0 X-20", "G4 P2.5",
"G0 X20", "G4 P2.5",
"G0 X-20", "G4 P0.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.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL", @test("laser.armed-kill", title="Armed kill mid-fire: the expected stop, then a SIGKILL",
+1 -1
View File
@@ -37,7 +37,7 @@ page's takeover does that; from a host, stop them first.
| `cool_report_test.py` | Host-side cooling report harness (null-sink controller, a stand-in for forgectrl's listener on `FORGECTRL_PORT`): every `POST /cool/state` carries the secret the supervisor hands the controller at its spawn (`GF_REPORT_SECRET`) as `X-ForgeFIRM-Report`; with no secret the header is absent; a value that is not 32 hex digits, a CR LF with a header behind it included, never reaches the wire; and the homing runner the controller starts is handed the secret (it reports in the controller's place during a gfcloud `$H`), and none when the controller has none or a malformed one. `python3 cool_report_test.py <grblHAL_glowforge>`. | | `cool_report_test.py` | Host-side cooling report harness (null-sink controller, a stand-in for forgectrl's listener on `FORGECTRL_PORT`): every `POST /cool/state` carries the secret the supervisor hands the controller at its spawn (`GF_REPORT_SECRET`) as `X-ForgeFIRM-Report`; with no secret the header is absent; a value that is not 32 hex digits, a CR LF with a header behind it included, never reaches the wire; and the homing runner the controller starts is handed the secret (it reports in the controller's place during a gfcloud `$H`), and none when the controller has none or a malformed one. `python3 cool_report_test.py <grblHAL_glowforge>`. |
| `manual_home_test.py` | Host-side manual homing and motor release harness (null-sink controller, `GFSINK_DUMP` and `GFSINK_ATTR_LOG`): `$H` under `homing_mode = manual` ships nothing and declares X0 Y0, and with a manual home offset declares minus it and jogs dark to the origin before it answers, a jog cancel ending the move short; while the motors are released (`$MD`) every motion source is refused and ships nothing, `$X` and a soft reset do not unlock it, and only `$ME` and a manual `$H` write the energize. Imports its sender and port client from `ctlport_test.py`. `python3 manual_home_test.py <grblHAL_glowforge>`. | | `manual_home_test.py` | Host-side manual homing and motor release harness (null-sink controller, `GFSINK_DUMP` and `GFSINK_ATTR_LOG`): `$H` under `homing_mode = manual` ships nothing and declares X0 Y0, and with a manual home offset declares minus it and jogs dark to the origin before it answers, a jog cancel ending the move short; while the motors are released (`$MD`) every motion source is refused and ships nothing, `$X` and a soft reset do not unlock it, and only `$ME` and a manual `$H` write the energize. Imports its sender and port client from `ctlport_test.py`. `python3 manual_home_test.py <grblHAL_glowforge>`. |
| `tray_test.py` | Host-side crumb tray harness (null-sink controller, `GFSINK_DUMP`): `M103 P1` and `P0` move Z and its envelope by `tray_offset_mm` on the lens step grid, tell the sender, write and remove the `tray.out` marker beside the shared config, and ship no step; the errors of `M103`; the switch waits for the move before it; the port's `tray` op and its refusals; the mode across a soft reset and a controller restart; the offset's range; an unreferenced lens; a camera home in both modes. Imports its sender and port client from `ctlport_test.py`. `python3 tray_test.py <grblHAL_glowforge>`. | | `tray_test.py` | Host-side crumb tray harness (null-sink controller, `GFSINK_DUMP`): `M103 P1` and `P0` move Z and its envelope by `tray_offset_mm` on the lens step grid, tell the sender, write and remove the `tray.out` marker beside the shared config, and ship no step; the errors of `M103`; the switch waits for the move before it; the port's `tray` op and its refusals; the mode across a soft reset and a controller restart; the offset's range; an unreferenced lens; a camera home in both modes. Imports its sender and port client from `ctlport_test.py`. `python3 tray_test.py <grblHAL_glowforge>`. |
| `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), `dpatch [F] [pitch] [length]` (depth witness for the density dose curve: 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; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m4feeds [S] [F1] [F2]` (the density time base across feeds: one out-and-back line pair per feed at the same S under M4 density, one armed run; the operator reads within-line evenness and reversal darkness at both feeds - M4's velocity scaling is what holds dose per mm through the accel), `m4corner [S] [F]` (M4 velocity-scaled power into corners: a corner-heavy vector pattern at 30 % under M4 density, one armed run; the operator confirms every commanded segment marks - the floor makes a dropout unreachable - and the drill asserts the arm report, one discharge window 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 <secs> [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 <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), `dpatch [F] [pitch] [length]` (depth witness for the density dose curve: 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; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m4feeds [S] [F1] [F2]` (the density time base across feeds: one out-and-back line pair per feed at the same S under M4 density, one armed run; the operator reads within-line evenness and reversal darkness at both feeds - M4's velocity scaling is what holds dose per mm through the accel), `m4corner [S] [F]` (M4 velocity-scaled power into corners: a corner-heavy vector pattern at 30 % under M4 density, one armed run; the operator confirms every commanded segment marks - the floor makes a dropout unreachable - and the drill asserts the arm report, one discharge window 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.emission-witness` carries its check), `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 <secs> [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. | | `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. |
| `planner_blocks_test.py` | Host-side planner buffer depth harness (null-sink controller): `$398` written to 400 and to 1000, the controller restarted on the same settings store each time, and it must answer on the port, report the depth in its status report and run a move. A byte-wide ring index once made 255 blocks or more spin the start. Runs in the grblHAL repo's CI. | | `planner_blocks_test.py` | Host-side planner buffer depth harness (null-sink controller): `$398` written to 400 and to 1000, the controller restarted on the same settings store each time, and it must answer on the port, report the depth in its status report and run a move. A byte-wide ring index once made 255 blocks or more spin the start. Runs in the grblHAL repo's CI. |
| `xy_mode_test.py` | Host-side XY microstep mode harness (null-sink controller): `xy_microsteps` in the shared config sets `$100`/`$101` and the machine tick, a typed `$100` is overwritten on the spot, a value that is not a mode falls back to x32 with a warning, and `$110`/`$111` are held under a tick lowered with `GFSINK_RATE`. Runs in the grblHAL repo's CI. | | `xy_mode_test.py` | Host-side XY microstep mode harness (null-sink controller): `xy_microsteps` in the shared config sets `$100`/`$101` and the machine tick, a typed `$100` is overwritten on the spot, a value that is not a mode falls back to x32 with a warning, and `$110`/`$111` are held under a tick lowered with `GFSINK_RATE`. Runs in the grblHAL repo's CI. |
+2 -1
View File
@@ -96,7 +96,8 @@ Drills (pass a name):
reads dark after the M5, and laser_on_sampled never goes reads dark after the M5, and laser_on_sampled never goes
nonzero again after its first zero past the line. Prints the nonzero again after its first zero past the line. Prints the
9 s after the line at 40 ms steps. The catalog's 9 s after the line at 40 ms steps. The catalog's
laser.m5-rapid-dark is its port. laser.emission-witness carries its check: a rapid after
the M5, sampled dark.
m4corner M4 velocity-scaled power into corners and short segments: m4corner M4 velocity-scaled power into corners and short segments:
a corner-heavy vector pattern (a long line, 1.1 mm zigzag a corner-heavy vector pattern (a long line, 1.1 mm zigzag
teeth, a 2 mm square, a 180 degree reversal, 0.5 mm teeth) teeth, a 2 mm square, a 180 degree reversal, 0.5 mm teeth)