Prove the M101 dose-model switch; record the judged dose curves

The stream harness gains rules 18 to 21: the floor is derived from the
selected model's config key at the arm and a typed $35 is overwritten;
M101 switches the rendering exactly at the boundary in both directions
with no continuous FIRE at full duty across it; a refused switch (the
spindle on) leaves the stream unchanged, and the harness resyncs with
an empty line because the core skips G-code after an error until the
sender resyncs; M2 reverts a program-scoped switch and Q1 holds. The
analog sessions pin laser_floor_analog at the density floor so the
existing duty expectations stand, and the density ladder's unfloored
run moves from a chained $35 write to the laser_floor_density key.

The catalog's laser.power-floor becomes model-aware: it reads the
configured model and the floor keys from forgectrl, switches to the
configured model with M101 so the derivation runs without a fire, and
expects $35 to be that model's floor. The new laser.power-model-switch
switches to each model with the spindle off, checks the reported
message and $35 after each switch, and checks the M2 revert. The new
mswitch bench drill runs the switch on the machine in one armed run.

Docs follow: BRINGUP's Laser control section describes the switch, the
derived floors and the measured dose response of both models; the
MOTION settings table gains the five keys; LIGHTBURN gains a Power
models section and drops the stale 30 percent floor advice; SAFETY
names the switch's refusal rule; the CAMPAIGN-LOG records the judged
depth-witness runs of 2026-08-30 and the switch's host and bench proof.
This commit is contained in:
ScottW514
2026-08-30 15:08:34 -04:00
parent f88c7784d4
commit 3025996c86
9 changed files with 684 additions and 87 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ page's takeover does that; from a host, stop them first.
| `gate_a_kernel_drills.py` | Kernel laser-safety drills (run on the board with forgectrl stopped so the pulse device is free): `K1` controlled-stop deceleration floor, `K2` resume waypoint honors the locked latch, `K3` a mid-ramp latch unlock never re-arms the FIRE drive. Software witnesses (`cnc/state`, `laser_enable`, `laser_on`, `laser_on_sampled`, interlock bit 3) plus the PSU-connector LASER_ON scope point; K3 refuses to run if HV reports good. |
| `laser_stream_test.py` | Host-side laser pulse-stream emission harness: runs the native null-sink controller with `GFSINK_DUMP`, drives small laser jobs over TCP, and checks the dumped bytes against the kernel feeder contract (leading power byte, no back-to-back power bytes, FIRE only inside cutting moves, every stream ends FIRE-clear, no FIRE on a stepless gap, no FIRE leak across cycle churn, the rapids after an M5 executed at idle ship dark, and the next job in the same process fires at the level the previous one ended at). Runs in the grblHAL repo's CI. |
| `laser_lifecycle_test.py` | Host-side operator-armed-window lifecycle harness (null-sink controller): arm once per job with M5/M3 persistence, the M2 close, sender-change re-consent, the disarm grace counting down in Hold, and arm refusal under a blocking cooling verdict. Runs in the grblHAL repo's CI. |
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <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 dose curve of the configured `laser_power_model`: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density or duty; the operator matches each row-B patch to the row-A patch of equal depth, which reads the model's light fraction off the material beside the thermopile's prediction; JSON record), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <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 dose curve of the configured `laser_power_model`: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density or duty; the operator matches each row-B patch to the row-A patch of equal depth, which reads the model's light fraction off the material beside the thermopile's prediction; JSON record), `mswitch` (the M101 dose-model switch on the machine, one armed run: a density line at S500, M5, M101 P0, the same line back under analog, M2; asserts the arm/switch/revert reports with each model's floor, $$ showing the floor in force, no re-prompt across the switch, exactly two discharge segments with a pulsed character under density and a steady one under analog, and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <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. |
| `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. |
+282 -18
View File
@@ -58,6 +58,21 @@ over TCP, then checks the dumps against the kernel feeder contract:
the M3 that opens the next job is the only thing that can light its
first move - set_state must push the whole state, fire included,
never the duty alone
18. the floor is derived, never typed: $35 is loaded from the selected
model's floor key (laser_floor_density / laser_floor_analog) at
the arm, so a $35 typed by the sender is overwritten - the ladder
renders through the key's floor, and the arm report names the
model and the floor in force
19. M101 switches the model at a boundary and nowhere else: with the
spindle off and the controller idle, the rendering changes exactly
at the switch (analog duties before, pinned full duty and dithered
FIRE after, or the reverse) and there is no torn transition - no
window of continuous FIRE at full duty anywhere across it
20. M101 is refused with the spindle on (error 253, the reason
reported), and the stream is unchanged by the refusal
21. a switch is program-scoped: M2 reverts it to the boot default and
the next job renders under the default, while M101 ... Q1 sticks
across M2
Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge)
"""
@@ -153,16 +168,99 @@ JOB_LADDER.append("M5")
# the floor exists only to keep an analog duty out of the tube's dead
# band - under density every pulse is full-power, and a floor would just
# clamp the light end of the range.
ANALOG_CONF = "laser_power_model = analog\n"
# The floors are config keys, loaded into $35 at every arm (rule 18).
# The analog sessions pin theirs at the board's density floor so the
# duty expectations above hold unchanged; the analog default is the
# tube's lasing duty (16), covered by the switch sessions below.
ANALOG_FLOOR_DEFAULT_PCT = 16.0
ANALOG_CONF = ("laser_power_model = analog\n"
"laser_floor_analog = %g\n" % PWM_MIN_PCT)
DENSITY_PERIOD = 20
DENSITY_MIN_TICKS = 3
DENSITY_CONF_BASE = ("laser_pulse_ticks = %d\n"
"laser_pulse_min_ticks = %d\n"
% (DENSITY_PERIOD, DENSITY_MIN_TICKS))
# The density ladder runs unfloored: the floor exists only to keep an
# analog duty out of the tube's dead band, and here it would just clamp
# the light end of the range. A floor of 0 is honored as written.
DENSITY_CONF = ("laser_power_model = density\n"
"laser_pulse_ticks = %d\n"
"laser_pulse_min_ticks = %d\n"
% (DENSITY_PERIOD, DENSITY_MIN_TICKS))
"laser_floor_density = 0\n" + DENSITY_CONF_BASE)
# The shipped density default: no floor key, so the board's floor applies.
DENSITY_CONF_FLOORED = "laser_power_model = density\n" + DENSITY_CONF_BASE
DENSITY_LEVEL = tuple(int(x * PWM_PERIOD / RPM_MAX) for x in LADDER_S)
# A $35 typed ahead of the job: rule 18 says the arm overwrites it.
JOB_DENSITY = ["$35=0"] + JOB_LADDER
def duty_for_floor(s, floor_pct):
"""Duty the core computes for an S word against a given floor."""
lo = int(PWM_PERIOD * floor_pct / 100.0)
return int(s * (PWM_PERIOD - lo) / RPM_MAX) + lo
# Sessions L-N: the M101 dose-model switch. One cut, the spindle off,
# the switch, a second cut at the same S. Each cut is a kernel run of
# its own (the planner drains at WAIT_IDLE), so the switch lands between
# runs, which is the only place it is allowed to.
SWITCH_S = 500
SWITCH_MM = 5.0
SWITCH_FEED = 600
SWITCH_TICKS = SWITCH_MM / (SWITCH_FEED / 60.0) * 28160
def job_switch(mcode):
return [
"G91", "G21",
"M3 S%d" % SWITCH_S,
"G1 X%g F%d" % (SWITCH_MM, SWITCH_FEED),
WAIT_IDLE, ("sleep", 0.5),
"M5",
mcode,
"M3 S%d" % SWITCH_S,
"G1 X%g" % -SWITCH_MM,
WAIT_IDLE, ("sleep", 0.5),
"M5",
]
JOB_SWITCH_A2D = job_switch("M101 P1")
JOB_SWITCH_D2A = job_switch("M101 P0")
# The refusal: the switch arrives with the spindle still on. The stream
# must be what the job without the M101 would have produced.
JOB_SWITCH_REFUSED = [
"G91", "G21",
"M3 S%d" % SWITCH_S,
"G1 X%g F%d" % (SWITCH_MM, SWITCH_FEED),
WAIT_IDLE, ("sleep", 0.5),
("expect_error", "M101 P1"),
"G1 X%g" % -SWITCH_MM,
WAIT_IDLE, ("sleep", 0.5),
"M5",
]
def job_revert(mcode):
"""Two programs: the first switches and ends in M2, the second cuts
at the same S with no switch of its own."""
return [
"G91", "G21",
mcode,
"M3 S%d" % SWITCH_S,
"G1 X%g F%d" % (SWITCH_MM, SWITCH_FEED),
WAIT_IDLE, ("sleep", 0.5),
"M5", "G90", "M2", ("sleep", 1.0),
"G91",
"M3 S%d" % SWITCH_S,
"G1 X%g" % -SWITCH_MM,
WAIT_IDLE, ("sleep", 0.5),
"M5",
]
JOB_SWITCH_REVERT = job_revert("M101 P0")
JOB_SWITCH_STICKY = job_revert("M101 P0 Q1")
# Session H: three levels inside one kernel run. The moves are short and
# fast so the planner never drains, and each carries its own S word, so
# the level changes land mid-run. Analog pays a power byte per level;
@@ -235,14 +333,14 @@ def fail(msg):
sys.exit(1)
def send_line(sock, line, log):
def send_line(sock, line, log, expect_error=False):
sock.sendall((line + "\n").encode())
while True:
r = read_avail(sock, log, 5.0, until=("ok", "error"))
if r is None:
fail("no ok/error for %r" % line)
if r == "error":
fail("error response to %r" % line)
if (r == "error") != expect_error:
fail("%s response to %r" % (r, line))
return
@@ -342,6 +440,11 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
wait_idle(sock, log)
elif isinstance(step, tuple) and step[0] == "sleep":
time.sleep(step[1])
elif isinstance(step, tuple) and step[0] == "expect_error":
send_line(sock, step[1], log, expect_error=True)
# The core skips every G-code line after an error until
# the sender resyncs with an empty line (or a $ command).
send_line(sock, "", log)
else:
send_line(sock, step, log)
@@ -667,18 +770,14 @@ def main():
[counts[d] for d in LADDER_DUTY], gap_d))
# --- session E: the same ladder under the density model -------------
# $35 is written by a first launch and takes effect on the second:
# the floor exists only to keep an analog duty out of the tube's
# dead band, and under density it would just clamp the light end.
wd = tempfile.mkdtemp(prefix="laser-test-")
run_session("density-setup", ["$35=0"], conf=DENSITY_CONF, workdir=wd,
keep=True, arm_required=False)
dens = run_session("density", JOB_DENSITY, conf=DENSITY_CONF, workdir=wd)
# Unfloored through the config key (laser_floor_density = 0), which
# the arm loads into $35.
dens = run_session("density", JOB_LADDER, conf=DENSITY_CONF)
rendered = check_density("density", dens, DENSITY_LEVEL, DENSITY_PERIOD,
DENSITY_MIN_TICKS)
check_termination("density", dens)
if "laser armed (density)" not in run_session.text:
fail("[density] the arm did not select the density model")
if "laser armed (density, floor 0 %)" not in run_session.text:
fail("[density] the arm did not select the density model at floor 0")
print("PASS [density]: %d bytes, %d power bytes all at full duty, "
"level->density %s"
% (len(dens), sum(1 for b in dens if b & 0x80), rendered))
@@ -689,7 +788,7 @@ def main():
"inside the %d the core commanded" % (d_fire, a_fire))
# --- session F: full level under the model is continuous fire -------
full = run_session("density-full", ["$35=0"] + JOB_M3_TERM, conf=DENSITY_CONF)
full = run_session("density-full", JOB_M3_TERM, conf=DENSITY_CONF)
ticks = tick_bytes(full)
spans = fire_spans(ticks)
if not spans:
@@ -703,7 +802,7 @@ def main():
% (b - a))
# --- session G: churn under the model (rules 7-9 still hold) --------
ch = run_session("density-churn", ["$35=0"] + JOB_CHURN, conf=DENSITY_CONF)
ch = run_session("density-churn", JOB_CHURN, conf=DENSITY_CONF)
if not count_fire(ch):
fail("[density-churn] no FIRE bits in the stream")
check_termination("density-churn", ch)
@@ -779,6 +878,171 @@ def main():
"G1; 2 fire spans of %s ticks"
% (name, [s1 - s0 for s0, s1 in spans]))
# --- rule 18: the floor is derived from the key, never typed --------
# The same ladder with a $35=0 typed ahead of it, under the shipped
# density default (no floor key): the arm loads the board's floor and
# every rung renders through it.
floored = run_session("floor-derived", JOB_DENSITY, conf=DENSITY_CONF_FLOORED)
expect_levels = tuple(duty_for(x) for x in LADDER_S)
check_density("floor-derived", floored, expect_levels, DENSITY_PERIOD,
DENSITY_MIN_TICKS)
if "laser armed (density, floor %g %%)" % PWM_MIN_PCT not in run_session.text:
fail("[floor-derived] the arm report does not name the derived floor "
"(text: %r)" % run_session.text[-400:])
print("PASS [floor-derived]: a typed $35=0 is overwritten at the arm; the "
"ladder renders through the %g %% floor key, levels %s"
% (PWM_MIN_PCT, list(expect_levels)))
# --- sessions L: the switch, both directions (rule 19) --------------
def split_at_switch(data, marker):
"""Byte offset of the first power byte satisfying marker(duty)."""
for i, b in enumerate(data):
if b & 0x80 and marker(b & 0x7F):
return i
return None
def longest_burst(ticks):
run = worst = 0
for t in ticks:
run = run + 1 if t & 0x10 else 0
worst = max(worst, run)
return worst
def fire_by_power(data):
"""Fire ticks per power byte in force: the duties FIRE rode."""
cur, out = None, {}
for b in data:
if b & 0x80:
cur = b & 0x7F
elif b & 0x10:
out[cur] = out.get(cur, 0) + 1
return out
# Analog -> density. Before the switch: analog duties, continuous
# FIRE. After: full duty only, FIRE dithered at the level, bursts no
# longer than the base period.
a2d = run_session("switch-a2d", JOB_SWITCH_A2D, conf=ANALOG_CONF)
text = run_session.text
if "laser power model set for this program (density, floor %g %%)" % PWM_MIN_PCT not in text:
fail("[switch-a2d] the switch was not reported (text: %r)" % text[-400:])
cut = split_at_switch(a2d, lambda d: d == PWM_PERIOD)
if cut is None:
fail("[switch-a2d] no full-duty power byte after the switch: the density "
"model never took over")
before, after = a2d[:cut], a2d[cut:]
duty = duty_for(SWITCH_S)
# A run may lead with a dark duty-0 byte across the idle pads; what
# matters is the duty FIRE rides on each side of the switch.
if set(fire_by_power(before)) != {duty}:
fail("[switch-a2d] FIRE rode duties %s before the switch, expected only %d"
% (sorted(fire_by_power(before)), duty))
if set(b & 0x7F for b in after if b & 0x80) != {PWM_PERIOD}:
fail("[switch-a2d] duties after the switch %s: a level reached PWMSAR "
"under density" % sorted(set(b & 0x7F for b in after if b & 0x80)))
tb, ta = tick_bytes(before), tick_bytes(after)
sb, sa = fire_spans(tb), fire_spans(ta)
if len(sb) != 1 or len(sa) != 1:
fail("[switch-a2d] fire spans before/after the switch %s / %s, expected "
"one each" % (sb, sa))
if longest_burst(ta) > DENSITY_PERIOD:
fail("[switch-a2d] a %d-tick continuous FIRE burst at full duty after the "
"switch: a torn transition" % longest_burst(ta))
dens_after = sum(1 for t in ta[sa[0][0]:sa[0][1]] if t & 0x10) / float(sa[0][1] - sa[0][0])
if abs(dens_after - duty / float(PWM_PERIOD)) > 0.03:
fail("[switch-a2d] density after the switch %.3f, expected %.3f"
% (dens_after, duty / float(PWM_PERIOD)))
check_termination("switch-a2d", a2d)
print("PASS [switch-a2d]: analog duty %d before, full duty + density %.3f after, "
"longest burst %d <= %d" % (duty, dens_after, longest_burst(ta), DENSITY_PERIOD))
# Density -> analog. The analog default floor (16) applies after the
# switch, so the duty is the one that floor gives.
d2a = run_session("switch-d2a", JOB_SWITCH_D2A, conf=DENSITY_CONF_FLOORED)
text = run_session.text
if "laser power model set for this program (analog, floor %g %%)" % ANALOG_FLOOR_DEFAULT_PCT not in text:
fail("[switch-d2a] the switch was not reported with the analog floor (text: %r)"
% text[-400:])
duty_a = duty_for_floor(SWITCH_S, ANALOG_FLOOR_DEFAULT_PCT)
cut = split_at_switch(d2a, lambda d: d == duty_a)
if cut is None:
fail("[switch-d2a] no analog power byte (%d) after the switch" % duty_a)
before, after = d2a[:cut], d2a[cut:]
if set(fire_by_power(before)) != {PWM_PERIOD}:
fail("[switch-d2a] FIRE rode duties %s before the switch, expected full only"
% sorted(fire_by_power(before)))
if set(b & 0x7F for b in before if b & 0x80) - {PWM_PERIOD, 0}:
fail("[switch-d2a] a density level shipped as a duty across the switch: "
"power bytes %s" % sorted(set(b & 0x7F for b in before if b & 0x80)))
if set(fire_by_power(after)) != {duty_a}:
fail("[switch-d2a] FIRE rode duties %s after the switch, expected only %d"
% (sorted(fire_by_power(after)), duty_a))
ta = tick_bytes(after)
sa = fire_spans(ta)
if len(sa) != 1:
fail("[switch-d2a] fire spans after the switch %s, expected one" % sa)
got = sum(1 for t in ta[sa[0][0]:sa[0][1]] if t & 0x10) / float(sa[0][1] - sa[0][0])
if got < 0.999:
fail("[switch-d2a] FIRE after the switch is not continuous (%.3f): the "
"dither is still masking under analog" % got)
check_termination("switch-d2a", d2a)
print("PASS [switch-d2a]: full duty + dither before, continuous FIRE at duty %d "
"(floor %g) after" % (duty_a, ANALOG_FLOOR_DEFAULT_PCT))
# --- session M: refused with the spindle on (rule 20) ---------------
ref = run_session("switch-refused", JOB_SWITCH_REFUSED, conf=ANALOG_CONF)
text = run_session.text
if "M5 first" not in text or "error:253" not in text:
fail("[switch-refused] the refusal was not reported as error 253 with its "
"reason (text: %r)" % text[-400:])
if "laser power model set" in text:
fail("[switch-refused] the switch was applied despite the refusal")
if set(fire_by_power(ref)) != {duty}:
fail("[switch-refused] FIRE rode duties %s, expected only %d: the stream "
"changed under a refused switch" % (sorted(fire_by_power(ref)), duty))
check_cut_spans("switch-refused", tick_bytes(ref), 2, SWITCH_TICKS,
"the two G1 moves, both analog")
check_termination("switch-refused", ref)
print("PASS [switch-refused]: M101 with the spindle on -> error:253, stream "
"unchanged (duty %d throughout)" % duty)
# --- session N: program scope and Q1 (rule 21) ----------------------
rev = run_session("switch-revert", JOB_SWITCH_REVERT, conf=DENSITY_CONF_FLOORED)
text = run_session.text
if "laser power model reverted (density, floor %g %%)" % PWM_MIN_PCT not in text:
fail("[switch-revert] M2 did not report the revert (text: %r)" % text[-600:])
if text.count("laser armed (analog, floor %g %%)" % ANALOG_FLOOR_DEFAULT_PCT) != 1 or \
text.count("laser armed (density, floor %g %%)" % PWM_MIN_PCT) != 1:
fail("[switch-revert] expected one analog arm then one density arm "
"(text: %r)" % text[-600:])
cut = split_at_switch(rev, lambda d: d == PWM_PERIOD)
if cut is None:
fail("[switch-revert] the second job never rendered under density")
before, after = rev[:cut], rev[cut:]
if set(fire_by_power(before)) != {duty_a}:
fail("[switch-revert] first job FIRE rode duties %s, expected the analog %d"
% (sorted(fire_by_power(before)), duty_a))
if longest_burst(tick_bytes(after)) > DENSITY_PERIOD:
fail("[switch-revert] continuous FIRE at full duty in the second job: the "
"revert did not restore the dither")
check_termination("switch-revert", rev)
print("PASS [switch-revert]: job 1 analog at duty %d, M2 reverts, job 2 density"
% duty_a)
stk = run_session("switch-sticky", JOB_SWITCH_STICKY, conf=DENSITY_CONF_FLOORED)
text = run_session.text
if "reverted" in text:
fail("[switch-sticky] a Q1 switch was reverted at M2")
if text.count("laser armed (analog, floor %g %%)" % ANALOG_FLOOR_DEFAULT_PCT) != 2:
fail("[switch-sticky] expected both jobs to arm analog (text: %r)" % text[-600:])
if set(fire_by_power(stk)) != {duty_a}:
fail("[switch-sticky] FIRE rode duties %s, expected the analog %d in both jobs"
% (sorted(fire_by_power(stk)), duty_a))
check_cut_spans("switch-sticky", tick_bytes(stk), 2, SWITCH_TICKS,
"one G1 per job, both analog")
check_termination("switch-sticky", stk)
print("PASS [switch-sticky]: M101 P0 Q1 holds across M2; both jobs analog at "
"duty %d" % duty_a)
print("PASS: all stream emission rules hold")
+158 -2
View File
@@ -96,6 +96,16 @@ Drills (pass a name):
nonzero again after its first zero past the line. Prints the
9 s after the line at 40 ms steps. The catalog's
laser.m5-rapid-dark is its port.
mswitch The M101 dose-model switch on the machine, one armed run:
a 20 mm density line at S500, M5, M101 P0 (the reply and
the switch report are asserted), a 20 mm analog line back
at the same S, M5, M2. PASS when the arm names the model
and floor, the switch and the M2 revert are reported, $$
shows each model's floor while it is in force, exactly two
discharge segments appear with nothing after them, the
window never re-prompts, and the current character flips
(pulsed spikes under density, steady under analog).
Requires laser_power_model = density (the default).
dpatch Depth witness for the dose curve of the configured
laser_power_model: two rows of small engraved patches
(serpentine G1 fills) on the stock. Row A is CW (S1000) at
@@ -808,8 +818,8 @@ def drill_dladder(g):
line = []
if line:
print(' ' + ' '.join(line))
print('Check the controller said "laser armed (density)" - a plain')
print('"laser armed" means the analog path ran and this is a duty')
print('Check the controller said "laser armed (density, ...)" - an')
print('"(analog, ...)" arm means the analog path ran and this is a duty')
print('ladder, not a density one.')
print('\nRead the material: count rungs from the FIRST one drawn.')
print('Two readings, and the second is the one only the bench can give:')
@@ -2745,6 +2755,151 @@ def drill_m5dark(g):
return 0 if ok else 1
MSWITCH_S = 500
MSWITCH_MM = 20.0
MSWITCH_FEED = 600
def drill_mswitch(g):
print('=== M101 on the machine: density line, switch, analog line, revert ===')
sampler = Sampler(PCURVE_SAMPLE_HZ)
if not sampler.local:
print('run this on the board: the witnesses are sysfs at 25 Hz')
return 2
model = conf_get('laser_power_model') or 'density'
if model != 'density':
print('PRECONDITION FAILED: laser_power_model is %s, the drill asserts '
'the density default and its revert' % model)
return 2
fails = []
def check(cond, msg):
print(' %s: %s' % ('ok' if cond else 'FAIL', msg))
if not cond:
fails.append(msg)
def logged(needle):
return any(needle in ln for _t, ln in g.log)
def floor35(text):
for ln in text.splitlines():
if ln.startswith('$35='):
try:
return float(ln[4:])
except ValueError:
return None
return None
print('connect: %s' % prepare(g))
print('pre-fire: %s' % sample_forgectrl())
arm_cue()
print('>>> %g mm of free +X travel at the head, scrap under it: one line' % MSWITCH_MM)
print('>>> out under density, the switch, the same line back under analog.\n')
sampler.start()
aborted = False
try:
for ln in ('G91', 'G21'):
g.cmd(ln)
# Section 1: density. The M3 blocks in the arm until the press.
g.s.sendall(b'M3 S%d\n' % MSWITCH_S)
g.s.sendall(('G1 X%g F%d\n' % (MSWITCH_MM, MSWITCH_FEED)).encode())
st = g.wait_state('Run', 300)
if not st.startswith('Run'):
print('FAIL: the job never ran (state=%s)' % st)
g.rt(b'\x18')
return 1
g.wait_state('Idle', 60)
time.sleep(0.5)
g.drain()
check(logged('laser armed (density, floor 10 %)'),
'the arm names the density model and its floor')
# The switch, spindle off.
r = g.cmd('M5')
check('error' not in r, 'M5 accepted (%s)' % r.replace('\n', ' '))
r = g.cmd('M101 P0', timeout=10)
check('ok' in r and 'error' not in r, 'M101 P0 accepted (%s)' % r.replace('\n', ' '))
time.sleep(0.3)
g.drain()
check(logged('laser power model set for this program (analog, floor 16 %)'),
'the switch is reported with the analog floor')
f = floor35(g.cmd('$$', timeout=10))
check(f == 16.0, '$$ shows the analog floor in force ($35=%s)' % f)
# Section 2: analog, same S, no new press allowed.
presses = sum(1 for _t, ln in g.log if 'press the button' in ln)
g.s.sendall(b'M3 S%d\n' % MSWITCH_S)
g.s.sendall(('G1 X%g F%d\n' % (-MSWITCH_MM, MSWITCH_FEED)).encode())
st = g.wait_state('Run', 60)
check(st.startswith('Run'), 'the analog section ran (state=%s)' % st)
g.wait_state('Idle', 60)
time.sleep(0.5)
g.drain()
check(sum(1 for _t, ln in g.log if 'press the button' in ln) == presses,
'the open window carried across the switch: no re-prompt')
for ln in ('M5', 'G90'):
g.cmd(ln)
g.cmd('M2')
time.sleep(0.5)
g.drain()
check(logged('laser power model reverted (density, floor 10 %)'),
'M2 reverts to the density default and reports it')
f = floor35(g.cmd('$$', timeout=10))
check(f == 10.0, '$$ shows the density floor back ($35=%s)' % f)
# Let the disarm land before judging the trace.
t0 = time.time()
while time.time() - t0 < 90:
smp = sample_forgectrl()
if smp and not smp['armed']:
break
time.sleep(0.2)
time.sleep(1.5)
except Exception as e:
aborted = True
print('ABORTED: %s' % e)
g.rt(b'\x18')
finally:
sampler.stop()
if aborted:
return 1
tr = sampler.samples
segs, cur = [], None
for smp in tr:
on = smp['hv'] is not None and smp['hv'] > HV_DARK_MAX
if on and cur is None:
cur = [smp['t'], smp['t']]
elif on:
cur[1] = smp['t']
elif cur is not None and smp['t'] - cur[1] > 1.0:
segs.append(cur)
cur = None
if cur:
segs.append(cur)
print('\n--- results (%d samples, %.1f Hz) ---' % (len(tr), sampler.rate()))
check(len(segs) == 2, '%d discharge segment(s), expected exactly 2 (one per line)'
% len(segs))
if len(segs) == 2:
for name, (a, b) in zip(('density', 'analog'), segs):
hv = _stats(_window(tr, a + 0.3, b - 0.1, 'hv'))
print(' %s line: %.2f s, hv mean %.0f max %d (max-mean %.0f)'
% (name, b - a, hv['mean'], hv['max'], hv['max'] - hv['mean']))
hv_d = _stats(_window(tr, segs[0][0] + 0.3, segs[0][1] - 0.1, 'hv'))
hv_a = _stats(_window(tr, segs[1][0] + 0.3, segs[1][1] - 0.1, 'hv'))
check(hv_a['max'] - hv_a['mean'] < 150,
'the analog line is a steady discharge (max-mean %.0f)'
% (hv_a['max'] - hv_a['mean']))
check(hv_d['max'] - hv_d['mean'] > 250,
'the density line is a pulsed discharge (max-mean %.0f)'
% (hv_d['max'] - hv_d['mean']))
t_end = segs[1][1]
hv_after = max((smp['hv'] for smp in tr if smp['t'] > t_end + 0.3
and smp['hv'] is not None), default=0)
check(hv_after <= HV_DARK_MAX, 'dark after the second M5 (hv max %d)' % hv_after)
ok = not fails
print('MSWITCH %s' % ('PASS: the switch, the floors and the revert hold on the machine'
if ok else 'FAIL: %d check(s) failed' % len(fails)))
return 0 if ok else 1
def post_ctrl(action):
# http.client preserves the header-name case exactly as given.
import http.client
@@ -2833,6 +2988,7 @@ def main():
'pthresh': drill_pthresh, 'dladder': drill_dladder,
'pcurve': drill_pcurve, 'm5dark': drill_m5dark,
'dpatch': drill_dpatch, 'flowload': drill_flowload,
'mswitch': drill_mswitch,
'senderchg': drill_senderchg, 'overrun': drill_overrun,
'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart}
if drill not in drills: