mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
Prove the density-only model; record the rasters and the decision
The stream harness keeps the analog rendering as the host-test conservatism reference (rule 13's mask, the duty ladders) and drops the M101 switch sessions; rule 18 stays as the derived-floor proof, now satisfied from boot by the precompute. The lifecycle harness's state-files scenario asserts the derived floor is in $$ before any arm. The catalog's laser.power-model-switch goes and laser.power-floor reads the one floor key with no M-code needed. The mswitch drill goes; the m4corner drill becomes a single density pass; the dpatch drill returns to density only. The CAMPAIGN-LOG records the first rasters (254 and 508 DPI grayscale wedges: tonality held to ~14 pulse slots per pixel, no dither artifact, one benign stale-verdict suppression under CPU starvation) and the decision that ends the analog mode - the strike transient fires a spot at every beam-on, and the finish comparison found no advantage. BRINGUP, LIGHTBURN, MOTION and SAFETY describe the density-only present.
This commit is contained in:
@@ -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), `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
@@ -29,10 +29,11 @@ reported messages:
|
||||
job start on its own; with lid_policy = hold the stock door hold and
|
||||
cycle-start resume apply
|
||||
9. the controller publishes its state for the daemon: grbl.settings
|
||||
(the $$ view) and grbl.state (JSON with ts_mono, machine state,
|
||||
sender session, laser window and dose model, modals) appear under
|
||||
GF_STATE_DIR, follow the sender connection, the armed window and
|
||||
an M101 switch, and carry a fresh ts_mono
|
||||
(the $$ view, the derived floor in force from boot) and grbl.state
|
||||
(JSON with ts_mono, machine state, sender session, laser window
|
||||
and dose model, modals) appear under GF_STATE_DIR, follow the
|
||||
sender connection and the armed window, and carry a fresh
|
||||
ts_mono
|
||||
10. the job start survives a pause: a job paused and resumed by the
|
||||
button, then cancelled by the lid, returns to where the job began,
|
||||
not to where it was paused (the core restarts a held cycle through
|
||||
@@ -294,29 +295,23 @@ def test_status_files():
|
||||
assert '"connected":true' in st, "sender not reported connected: %r" % st
|
||||
assert '"armed":false' in st, "armed before any job: %r" % st
|
||||
assert '"model":"density"' in st and '"floor_pct":10' in st, \
|
||||
"model/floor missing: %r" % st
|
||||
"model/floor missing (the floor must be derived from boot): %r" % st
|
||||
assert '"modals":"[GC:' in st, "modal report missing: %r" % st
|
||||
ts0 = float(st.split('"ts_mono":')[1].split(',')[0])
|
||||
|
||||
cfg = open(os.path.join(s.workdir, "grbl.settings")).read()
|
||||
assert "$35=" in cfg and "$30=" in cfg, "settings file lacks $ lines"
|
||||
|
||||
# The armed window and a dose-model switch reach the file.
|
||||
# The derived floor is in force from boot, before any arm.
|
||||
assert "$35=10" in cfg, "the derived floor is not in $$ from boot: %r" % [
|
||||
l for l in cfg.splitlines() if l.startswith("$35")]
|
||||
# The armed window reaches the file.
|
||||
send_line(s.sock, "G91", s.log)
|
||||
send_line(s.sock, "M3 S100", s.log)
|
||||
st = read_state(s, '"armed":true')
|
||||
assert '"armed":true' in st, "armed window not published: %r" % st
|
||||
send_line(s.sock, "M5", s.log)
|
||||
send_line(s.sock, "M101 P0", s.log)
|
||||
st = read_state(s, '"model":"analog"')
|
||||
assert '"model":"analog"' in st and '"floor_pct":16' in st, \
|
||||
"the M101 switch not published: %r" % st
|
||||
cfg = open(os.path.join(s.workdir, "grbl.settings")).read()
|
||||
assert "$35=16" in cfg, "the switched floor not republished: %r" % [
|
||||
l for l in cfg.splitlines() if l.startswith("$35")]
|
||||
send_line(s.sock, "M2", s.log)
|
||||
st = read_state(s, '"model":"density"')
|
||||
assert '"model":"density"' in st, "the M2 revert not published: %r" % st
|
||||
|
||||
# A reconnect bumps the generation and stays connected.
|
||||
gen0 = int(st.split('"generation":')[1].split(',')[0])
|
||||
|
||||
@@ -58,21 +58,16 @@ 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
|
||||
18. the floor is derived, never typed: $35 is loaded from the floor
|
||||
key at every precompute, 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
|
||||
|
||||
The analog sessions select the reference mode through the config; on
|
||||
hardware the controller ignores it (density is the only product model -
|
||||
analog's strike transient puts a spot at every beam-on), but the
|
||||
null-sink build honors it so these rules can hold the density model to
|
||||
account against the continuous rendering (rule 13's mask above all).
|
||||
|
||||
Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge)
|
||||
"""
|
||||
@@ -198,149 +193,19 @@ def duty_for_floor(s, floor_pct):
|
||||
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;
|
||||
# density pays none, because the level rides the FIRE bits.
|
||||
JOB_LEVELS = ["G91", "G21", "M3"]
|
||||
for _s in (100, 300, 600):
|
||||
for _ in range(20):
|
||||
JOB_LEVELS.append("G1 X0.5 F3000 S%d" % _s)
|
||||
JOB_LEVELS.append("M5")
|
||||
|
||||
|
||||
# Session I: the levels arrive on their own lines, and the moves are long
|
||||
# enough that the planner drains between them, so each S is executed with
|
||||
# nothing streaming. The state has no event to ride and must be
|
||||
# re-asserted at the next run's first byte.
|
||||
IDLE_S_LEVELS = (100, 300, 600)
|
||||
IDLE_S_MM = 5.0
|
||||
IDLE_S_FEED = 300
|
||||
JOB_IDLE_S = ["G91", "G21", "M3"]
|
||||
for _i, _s in enumerate(IDLE_S_LEVELS):
|
||||
JOB_IDLE_S.append("S%d" % _s)
|
||||
JOB_IDLE_S.append("G1 X%g F%d" % (IDLE_S_MM if _i % 2 == 0 else -IDLE_S_MM,
|
||||
IDLE_S_FEED))
|
||||
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):
|
||||
print("FAIL: %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def send_line(sock, line, log, expect_error=False):
|
||||
def send_line(sock, line, log):
|
||||
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") != expect_error:
|
||||
fail("%s response to %r" % (r, line))
|
||||
if r == "error":
|
||||
fail("error response to %r" % line)
|
||||
return
|
||||
|
||||
|
||||
@@ -440,11 +305,6 @@ 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)
|
||||
|
||||
@@ -893,156 +753,6 @@ def main():
|
||||
"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")
|
||||
|
||||
|
||||
|
||||
@@ -96,16 +96,26 @@ 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).
|
||||
m4corner M4 velocity-scaled power into corners and short segments:
|
||||
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)
|
||||
at 30 %% under M4 at F2000, one pass under density. The
|
||||
floor is the guard: velocity brings the commanded power to
|
||||
the floor at every corner, and the model cannot go dark by
|
||||
construction - the operator confirms every commanded
|
||||
segment marks. One discharge window, dark after.
|
||||
m4corner [S] [F] e.g. m4corner 300 2000
|
||||
m4feeds B3, the density time base across feeds: under M4 density,
|
||||
one out-and-back 20 mm line pair per feed (default 1000 and
|
||||
4000 mm/min) at the same S, passes offset +Y, one armed
|
||||
run. M4 scales the commanded power with velocity inside a
|
||||
move, so each line should read evenly dark from its slow
|
||||
ends to its fast middle; the reversal point is where the
|
||||
factory's own compensation still let dose per mm rise
|
||||
~1.8x. The operator compares evenness within each pass and
|
||||
the reversal darkness across the two feeds. Requires
|
||||
laser_power_model = density.
|
||||
m4feeds [S] [F1] [F2] e.g. m4feeds 600 1000 4000
|
||||
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
|
||||
@@ -1414,12 +1424,9 @@ def drill_dpatch(g):
|
||||
if feed_ref < 60 or not 0.05 <= pitch <= 2.0 or not 5.0 <= width <= 200.0:
|
||||
print('usage: dpatch [F mm/min >= 60] [pitch 0.05..2 mm] [length 5..200 mm]')
|
||||
return 2
|
||||
model = conf_get('laser_power_model') or 'density'
|
||||
if model not in ('density', 'analog'):
|
||||
print('unknown laser_power_model %s' % model)
|
||||
return 2
|
||||
tp_curve = DPATCH_TP_P20 if model == 'density' else DPATCH_TP_ANALOG
|
||||
unit = 'density' if model == 'density' else 'duty'
|
||||
model = 'density' # the only model on hardware
|
||||
tp_curve = DPATCH_TP_P20
|
||||
unit = 'density'
|
||||
levels, (rpm_max, rpm_min, floor, ceil) = pcurve_levels(g, DPATCH_DENSITY_PCT)
|
||||
if levels is None:
|
||||
print('PRECONDITION FAILED: cannot read $30/$31/$35/$36 (%s/%s/%s/%s)'
|
||||
@@ -2755,22 +2762,31 @@ def drill_m5dark(g):
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
MSWITCH_S = 500
|
||||
MSWITCH_MM = 20.0
|
||||
MSWITCH_FEED = 600
|
||||
# The corner pattern: a long segment above the ~1.6 mm accelerate-in-
|
||||
# and-out distance at F2000, teeth and a square well below it, and a
|
||||
# reversal - every place M4 drives the commanded power to the floor.
|
||||
M4C_PATTERN = (
|
||||
('G1', 10.0, 0.0), # long: reaches programmed feed
|
||||
('G1', 1.0, 0.6), ('G1', 1.0, -0.6), ('G1', 1.0, 0.6), ('G1', 1.0, -0.6),
|
||||
('G1', 1.0, 0.6), ('G1', 1.0, -0.6), # 1.2 mm teeth
|
||||
('G1', 2.0, 0.0), ('G1', 0.0, 2.0), ('G1', -2.0, 0.0), ('G1', 0.0, -2.0),
|
||||
('G1', 5.0, 0.0), ('G1', -5.0, 0.0), # 180 degree reversal
|
||||
('G1', 0.5, 0.4), ('G1', 0.5, -0.4), ('G1', 0.5, 0.4), ('G1', 0.5, -0.4),
|
||||
('G1', 3.0, 0.0), # finish long
|
||||
)
|
||||
M4C_ROW_GAP = 8.0 # mm between the two passes
|
||||
|
||||
|
||||
def drill_mswitch(g):
|
||||
print('=== M101 on the machine: density line, switch, analog line, revert ===')
|
||||
def drill_m4corner(g):
|
||||
sval = int(sys.argv[2]) if len(sys.argv) > 2 else 300
|
||||
feed = int(sys.argv[3]) if len(sys.argv) > 3 else 2000
|
||||
if not 50 <= sval <= 1000 or not 300 <= feed <= 6000:
|
||||
print('usage: m4corner [S 50..1000] [F 300..6000]')
|
||||
return 2
|
||||
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):
|
||||
@@ -2781,70 +2797,52 @@ def drill_mswitch(g):
|
||||
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
|
||||
dx = sum(x for _c, x, _y in M4C_PATTERN)
|
||||
dy = sum(y for _c, _x, y in M4C_PATTERN)
|
||||
span_x = 0.0
|
||||
run_x = 0.0
|
||||
for _c, x, _y in M4C_PATTERN:
|
||||
run_x += x
|
||||
span_x = max(span_x, run_x)
|
||||
|
||||
print('=== M4 into corners: the pattern at S%d F%d under density ===' % (sval, feed))
|
||||
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')
|
||||
print('>>> A fresh area: %g mm along +X by %g mm along +Y from the head.\n' % (span_x + 2, 4))
|
||||
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)
|
||||
for i, mname in enumerate(('density',)):
|
||||
g.s.sendall(b'M4 S%d\n' % sval)
|
||||
for cmd, x, y in M4C_PATTERN:
|
||||
parts = [cmd]
|
||||
if x:
|
||||
parts.append('X%g' % x)
|
||||
if y:
|
||||
parts.append('Y%g' % y)
|
||||
parts.append('F%d' % feed)
|
||||
g.s.sendall((' '.join(parts) + '\n').encode())
|
||||
g.s.sendall(b'M5\n')
|
||||
st = g.wait_state('Run', 300 if i == 0 else 60)
|
||||
if not st.startswith('Run'):
|
||||
print('FAIL: pass %d never ran (state=%s)' % (i + 1, st))
|
||||
g.rt(b'\x18')
|
||||
aborted = True
|
||||
return 1
|
||||
g.wait_state('Idle', 120)
|
||||
time.sleep(0.5)
|
||||
g.drain()
|
||||
print(' %s pass ran' % mname)
|
||||
g.s.sendall(('G0 X%g Y%g\n' % (-dx, -dy)).encode())
|
||||
g.wait_state('Idle', 30)
|
||||
check(logged('laser armed (density, floor'), 'the arm named the density model')
|
||||
g.cmd('G90')
|
||||
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()
|
||||
@@ -2869,33 +2867,159 @@ def drill_mswitch(g):
|
||||
cur = [smp['t'], smp['t']]
|
||||
elif on:
|
||||
cur[1] = smp['t']
|
||||
elif cur is not None and smp['t'] - cur[1] > 1.0:
|
||||
elif cur is not None and smp['t'] - cur[1] > 1.5:
|
||||
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
|
||||
check(len(segs) == 1, '%d discharge window(s), expected 1' % len(segs))
|
||||
for name, (a, b) in zip(('density',), segs):
|
||||
hv = _stats(_window(tr, a + 0.2, b - 0.1, 'hv'))
|
||||
tp = _stats(_window(tr, a + 0.2, b - 0.1, 'tp'))
|
||||
print(' %s pass: %.1f s lit, hv mean %.0f max %d, tp mean %.0f'
|
||||
% (name, b - a, hv['mean'], hv['max'], tp['mean'] or 0))
|
||||
if segs:
|
||||
t_end = segs[-1][1]
|
||||
hv_after = max((smp['hv'] for smp in tr if smp['t'] > t_end + 0.5
|
||||
and smp['hv'] is not None), default=0)
|
||||
check(hv_after <= HV_DARK_MAX, 'dark after the second M5 (hv max %d)' % hv_after)
|
||||
check(hv_after <= HV_DARK_MAX, 'dark after the last M5 (hv max %d)' % hv_after)
|
||||
|
||||
print('\n--- the operator reads the material ---')
|
||||
print('Trace the whole path with your eye:')
|
||||
print(' 1. DROPOUT: any commanded segment with NO mark at all - look hardest')
|
||||
print(' at the 1.2 mm teeth, the 0.5 mm teeth and the 2 mm square,')
|
||||
print(' where M4 never lets the power rise off the floor. Under density')
|
||||
print(' a dropout should be impossible; confirm it.')
|
||||
print(' 2. The long 10 mm lines are the reference: mid-line is the')
|
||||
print(' pattern cut at full commanded power.')
|
||||
ok = not fails
|
||||
print('MSWITCH %s' % ('PASS: the switch, the floors and the revert hold on the machine'
|
||||
print('M4CORNER %s' % ('instrument checks PASS - the material verdict is yours'
|
||||
if ok else 'FAIL: %d check(s) failed' % len(fails)))
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
M4F_MM = 60.0
|
||||
M4F_LEG_GAP = 0.6 # the return leg sits beside the out leg
|
||||
M4F_ROW_GAP = 5.0
|
||||
|
||||
|
||||
def drill_m4feeds(g):
|
||||
sval = int(sys.argv[2]) if len(sys.argv) > 2 else 600
|
||||
f1 = int(sys.argv[3]) if len(sys.argv) > 3 else 1000
|
||||
f2 = int(sys.argv[4]) if len(sys.argv) > 4 else 4000
|
||||
if not 50 <= sval <= 1000 or not 300 <= f1 < f2 <= 8000:
|
||||
print('usage: m4feeds [S 50..1000] [F1] [F2] (300 <= F1 < F2 <= 8000)')
|
||||
return 2
|
||||
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; B3 is the density '
|
||||
'time-base question' % model)
|
||||
return 2
|
||||
fails = []
|
||||
|
||||
def check(cond, msg):
|
||||
print(' %s: %s' % ('ok' if cond else 'FAIL', msg))
|
||||
if not cond:
|
||||
fails.append(msg)
|
||||
|
||||
print('=== B3, dose per mm across feeds: S%d under M4 density at F%d and F%d ==='
|
||||
% (sval, f1, f2))
|
||||
print('connect: %s' % prepare(g))
|
||||
print('pre-fire: %s' % sample_forgectrl())
|
||||
arm_cue()
|
||||
print('>>> A fresh area: %g mm along +X by %g mm along +Y from the head.' % (M4F_MM + 2, M4F_ROW_GAP + 4))
|
||||
print('>>> Pass 1 (F%d) cuts at the head; pass 2 (F%d) %g mm in +Y.'
|
||||
% (f1, f2, M4F_ROW_GAP))
|
||||
print('>>> Each pass is an out leg and a return leg %g mm apart - a long' % M4F_LEG_GAP)
|
||||
print('>>> U with its turn at the far end, so both legs read separately.\n')
|
||||
sampler.start()
|
||||
aborted = False
|
||||
try:
|
||||
for ln in ('G91', 'G21'):
|
||||
g.cmd(ln)
|
||||
for i, feed in enumerate((f1, f2)):
|
||||
g.s.sendall(b'M4 S%d\n' % sval)
|
||||
g.s.sendall(('G1 X%g F%d\n' % (M4F_MM, feed)).encode())
|
||||
g.s.sendall(('G1 Y%g F%d\n' % (M4F_LEG_GAP, feed)).encode())
|
||||
g.s.sendall(('G1 X%g F%d\n' % (-M4F_MM, feed)).encode())
|
||||
g.s.sendall(b'M5\n')
|
||||
g.s.sendall(('G0 Y%g\n' % -M4F_LEG_GAP).encode())
|
||||
st = g.wait_state('Run', 300 if i == 0 else 60)
|
||||
if not st.startswith('Run'):
|
||||
print('FAIL: pass %d never ran (state=%s)' % (i + 1, st))
|
||||
g.rt(b'\x18')
|
||||
aborted = True
|
||||
return 1
|
||||
g.wait_state('Idle', 120)
|
||||
time.sleep(0.5)
|
||||
g.drain()
|
||||
print(' pass at F%d ran' % feed)
|
||||
if i == 0:
|
||||
g.s.sendall(('G0 Y%g\n' % M4F_ROW_GAP).encode())
|
||||
g.wait_state('Idle', 30)
|
||||
g.cmd('G90')
|
||||
g.cmd('M2')
|
||||
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.5:
|
||||
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 window(s), expected 2 (one per feed)' % len(segs))
|
||||
for feed, (a, b) in zip((f1, f2), segs):
|
||||
hv = _stats(_window(tr, a + 0.2, b - 0.1, 'hv'))
|
||||
tp = _stats(_window(tr, a + 0.2, b - 0.1, 'tp'))
|
||||
print(' F%d pass: %.1f s lit, hv mean %.0f, tp mean %.0f (the beam at '
|
||||
'cruise should read alike at both feeds: M4 commands S at speed)'
|
||||
% (feed, b - a, hv['mean'], tp['mean'] or 0))
|
||||
if segs:
|
||||
t_end = segs[-1][1]
|
||||
hv_after = max((smp['hv'] for smp in tr if smp['t'] > t_end + 0.5
|
||||
and smp['hv'] is not None), default=0)
|
||||
check(hv_after <= HV_DARK_MAX, 'dark after the last M5 (hv max %d)' % hv_after)
|
||||
|
||||
print('\n--- the operator reads the material ---')
|
||||
print('Two out-and-back line pairs, F%d nearest you first, F%d %g mm past it.' % (f1, f2, M4F_ROW_GAP))
|
||||
print(' 1. EVENNESS within each pass: each line should be equally dark from')
|
||||
print(' its slow ends to its fast middle - that is M4 holding dose per')
|
||||
print(' mm constant through the accel. Ends darker than the middle =')
|
||||
print(' partial compensation (the factory rides a ~1.8x rise).')
|
||||
print(' 2. The REVERSAL point (far end) is the worst case - compare its')
|
||||
print(' darkness against the mid-line of the same pass, at both feeds.')
|
||||
print(' 3. ACROSS the passes: the faster pass is expected ~%gx lighter per' % (float(f2) / f1))
|
||||
print(' mm overall (feed is the dose control at cruise); the question is')
|
||||
print(' whether the within-line evenness holds at both.')
|
||||
ok = not fails
|
||||
print('M4FEEDS %s' % ('instrument checks PASS - the material verdict is yours'
|
||||
if ok else 'FAIL: %d check(s) failed' % len(fails)))
|
||||
return 0 if ok else 1
|
||||
|
||||
@@ -2988,7 +3112,7 @@ def main():
|
||||
'pthresh': drill_pthresh, 'dladder': drill_dladder,
|
||||
'pcurve': drill_pcurve, 'm5dark': drill_m5dark,
|
||||
'dpatch': drill_dpatch, 'flowload': drill_flowload,
|
||||
'mswitch': drill_mswitch,
|
||||
'm4corner': drill_m4corner, 'm4feeds': drill_m4feeds,
|
||||
'senderchg': drill_senderchg, 'overrun': drill_overrun,
|
||||
'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart}
|
||||
if drill not in drills:
|
||||
|
||||
Reference in New Issue
Block a user