mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-10-15 17:19:55 -07:00
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:
+109
-101
@@ -457,15 +457,45 @@ def power_floor(ctx):
|
||||
"(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 "
|
||||
"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 "
|
||||
"Idle (job-based, not the 60 s idle grace) and the button goes dark. Across "
|
||||
"the dwell the kernel run ends and restarts: HV_ENABLE drops with the "
|
||||
"charge-pump watchdog and is back for the third side, while the hardware "
|
||||
"button latch stays clear (cnc/button_latch 0 in every armed sample), so the "
|
||||
"second half of the square marks without a second press. The operator "
|
||||
"confirms all four sides.")
|
||||
"beam, and the armed window is observed. Across the dwell the kernel run ends "
|
||||
"and restarts: HV_ENABLE drops with the charge-pump watchdog and is back for "
|
||||
"the third side, while the hardware button latch stays clear (cnc/button_latch "
|
||||
"0 in every armed sample), so the second half of the square marks without a "
|
||||
"second press. The square ends at M5 with the window still open; once the "
|
||||
"LASER_ON count reads 0 (the dark point), a rapid runs 20 mm out over the "
|
||||
"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):
|
||||
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):
|
||||
prepare(ctx, g)
|
||||
base = sample(ctx)
|
||||
@@ -475,21 +505,46 @@ def emission_witness(ctx):
|
||||
base["armed"], base["verdict"])
|
||||
ctx.check(not base["emission"], "emission_samples nonzero before the job (%s)", base["emission"])
|
||||
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",
|
||||
"G1 X20 F600", "G1 Y20 F600", "G4 P2", "G1 X-20 F600", "G1 Y-20 F600",
|
||||
"M5", "G90", "M2"]
|
||||
"M5"]
|
||||
ctx.arm_press()
|
||||
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:
|
||||
ctx.clear_notice()
|
||||
samples = cut + [dark] + tail
|
||||
ev["trail_fields"] = list(TRAIL_FIELDS)
|
||||
ev["trail"] = trail(samples)
|
||||
beam = beam_witness(ctx, ev, samples, base)
|
||||
emis = [s["emission"] for s in samples if s["emission"] is not None]
|
||||
beam = beam_witness(ctx, ev, cut, base)
|
||||
emis = [s["emission"] for s in cut if s["emission"] is not None]
|
||||
peak = max(emis) if emis else 0
|
||||
end = emis[-1] if emis else None
|
||||
hv = [s["hv"] for s in samples if s["hv"] is not None]
|
||||
end = next((s["emission"] for s in reversed(samples) if s["emission"] is not None), None)
|
||||
hv = [s["hv"] for s in cut if s["hv"] is not None]
|
||||
ir_peak = [0, 0, 0, 0]
|
||||
for s in samples:
|
||||
if s["ir"] and len(s["ir"]) == 4:
|
||||
@@ -533,12 +588,34 @@ def emission_witness(ctx):
|
||||
ev["exhaust_floor"] = floor
|
||||
ctx.log("exhaust at the first emission sample: %s rpm (floor %.0f)",
|
||||
ev["exhaust_at_first_fire"], floor)
|
||||
# X-3: job-based disarm at Idle after M2
|
||||
dt = wait_disarm(ctx, 75)
|
||||
ev["disarm_after_idle_s"] = round(dt, 1) if dt is not None else None
|
||||
ctx.log("time-to-disarm after Idle: %s s", ev["disarm_after_idle_s"])
|
||||
# The rapid after the M5, with the window open and FIRE clear: every
|
||||
# sample from the dark point until the window closes reads no emission
|
||||
# and the HV current at its idle reading. The head's reports place the
|
||||
# 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(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)
|
||||
ctx.check(not idle_fire,
|
||||
"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)
|
||||
ctx.check(hv and max(hv) > min(hv), "HV current did not rise during the burn (%s..%s)",
|
||||
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,
|
||||
"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"])
|
||||
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 "
|
||||
@@ -563,93 +650,14 @@ def emission_witness(ctx):
|
||||
judge_beam(ctx, beam)
|
||||
check_button_dark(ctx, ev)
|
||||
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 "
|
||||
"dark, mark confirmed", peak, ev["hv_min"], ev["hv_max"], beam["delta"], dt)
|
||||
ctx.log("PASS: emission peak %s -> 0, HV %s..%s, beam +%s; the rapid after the M5 dark (HV max %s); "
|
||||
"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
|
||||
|
||||
|
||||
@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",
|
||||
|
||||
Reference in New Issue
Block a user