Validate MeerK40t hatch planning on mock spooler

This commit is contained in:
Codex 2026-10-18 18:16:39 +02:00
parent a63a4ee4a6
commit ceb70be30e
6 changed files with 226 additions and 3 deletions

View file

@ -89,6 +89,9 @@ Subsequent offline evidence:
- [MeerK40t mock MOPA spike](../research/meerk40t-mock-mopa-spike.md):
deterministic controller-level frequency, pulse-width and square encoding;
- [MeerK40t hatch cut-plan and mock-spooler spike](../research/meerk40t-hatch-cutplan-spike.md):
stock planning and spooling preserve a synthetic hatch and MOPA settings on
forced mock transport;
- [Rayforge addon spike](../research/rayforge-addon-spike.md): external binary
driver load and lifecycle are viable, with a documented registry API gap.
- [Rayforge-to-JCZ equivalence spike](../research/rayforge-jcz-equivalence-spike.md):

View file

@ -0,0 +1,36 @@
# MeerK40t hatch cut-plan and mock-spooler spike
- Date: 2026-10-18
- MeerK40t commit: `d511944e08788e88077727f682957759884ff5ca`
- Scope: synthetic hatch through stock planning and mock spooling
## Result
A synthetic 2 mm square was assigned MeerK40t's default hatch effect and run
through the stock `copy`, `preprocess`, `validate`, `blob`, `preopt` and
`optimize` stages. The resulting plan contained one hatch mark line for this
small fixture and preserved 50 percent power, 250 mm/s speed, 40 kHz frequency
and 200 ns pulse width in the Balor command list.
The same plan was submitted to the stock spooler. It completed using
`MockConnection`, and mock packets were observed. A PyUSB trap was installed
before MeerK40t imports; any USB discovery or disposal attempt would fail the
run. Two runs produced identical golden output with SHA-256
`52c20ea7ef7221ff0321386e26e200dbec3b13d0223488b9d2e83236fd713f47`.
## What this proves
- Stock MeerK40t can turn a hatch effect into Balor mark geometry.
- MOPA frequency and pulse width survive the cut-plan and LaserJob boundary.
- The stock spooler can complete the synthetic job on forced mock transport.
## Remaining limits
- The fixture uses MeerK40t's default synthetic hatch configuration; it does
not establish a production hatch recipe or machine-safe parameter range.
- It does not test the graphical workflow, project import, correction files,
framing, cancellation, status or recovery.
- It establishes no compatibility with physical hardware.
No USB enumeration, controller command, galvo motion or laser emission was
attempted.

View file

@ -107,9 +107,14 @@ The first work is entirely offline:
The first controller-level part of the MeerK40t mock evaluation is recorded in
[the mock MOPA spike](research/meerk40t-mock-mopa-spike.md). It confirms
deterministic frequency, pulse-width and square-geometry command-list encoding
through BalorMK's forced mock transport. Full cut-plan/spooler and hatch
coverage remains open, so this does not yet complete the stock-application
evaluation or settle ADR 0002.
through BalorMK's forced mock transport. That first spike left cut-plan,
spooler and hatch coverage open and did not settle ADR 0002.
[The hatch cut-plan and mock-spooler follow-up](research/meerk40t-hatch-cutplan-spike.md)
now confirms that stock planning turns a synthetic hatch into Balor geometry,
preserves frequency and pulse width, and completes through `MockConnection`
with USB access trapped. Graphical workflow, import, correction, cancellation
and recovery coverage remain open.
The first Rayforge addon proof is recorded in
[the external binary-driver addon spike](research/rayforge-addon-spike.md).

View file

@ -0,0 +1,128 @@
"""Run a synthetic MOPA hatch through MeerK40t planning without hardware."""
from __future__ import annotations
import argparse
import json
import struct
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from spike_meerk40t_mock_mopa import PINNED_COMMIT, _install_usb_trap
def _commit_of(source: Path) -> str:
return subprocess.run(
["git", "-C", str(source), "rev-parse", "HEAD"],
check=True, capture_output=True, text=True,
).stdout.strip()
def run(source: Path) -> dict[str, object]:
commit = _commit_of(source)
if commit != PINNED_COMMIT:
raise RuntimeError(f"expected MeerK40t {PINNED_COMMIT}, found {commit}")
_install_usb_trap()
sys.path.insert(0, str(source))
from test import bootstrap
from meerk40t.balormk.mock_connection import MockConnection
with tempfile.TemporaryDirectory() as temporary:
job_path = Path(temporary) / "synthetic-hatch.lmc"
kernel = bootstrap.bootstrap(profile="ThePirateRay_HATCH")
try:
kernel.console("service device start -i balor 0\n")
device = kernel.device
device.mock = True
device.pulse_width_enabled = True
device.default_pulse_width = 200
kernel.console("rect 54mm 54mm 2mm 2mm\n")
kernel.console("operation* delete\n")
kernel.console("hatch\n")
operation = list(kernel.elements.ops())[0]
hatch_effect = operation.children[0]
if hatch_effect.type != "effect hatch":
raise AssertionError(
f"hatch command created unexpected node: {hatch_effect.type}"
)
element = list(kernel.elements.elems())[0]
hatch_effect.add_reference(element)
operation.speed = 250.0
operation.power = 500
operation.frequency = 40.0
operation.pulse_width_enabled = True
operation.pulse_width = 200
kernel.console(
f'plan clear copy preprocess validate blob preopt optimize save_job "{job_path}"\n'
)
saved = job_path.read_text(encoding="utf-8")
controller = device.driver.connection
controller.connect_if_needed()
if not isinstance(controller.connection, MockConnection):
raise AssertionError("spooler did not select MockConnection")
packets: list[str] = []
controller.connection.send = packets.append
controller.connection.set_implied_response(
struct.pack("<4H", 0, 0, 0, 0x20)
)
kernel.console("plan spool\n")
deadline = time.monotonic() + 10.0
while device.spooler.queue and time.monotonic() < deadline:
time.sleep(0.02)
if device.spooler.queue:
raise TimeoutError("mock spooler did not become idle")
finally:
kernel()
lines = saved.splitlines()
mark_lines = [line for line in lines if line.startswith("listMarkTo")]
required = ("listQSwitchPeriod", "listFiberYLPMPulseWidth", "listEndOfList")
if any(not any(line.startswith(item) for line in lines) for item in required):
raise AssertionError("planned hatch lost required MOPA/list commands")
if not mark_lines:
raise AssertionError(f"planned output contains no mark geometry: {lines}")
return {
"upstream": "meerk40t/meerk40t",
"commit": commit,
"transport": "MockConnection",
"usb_access": "trapped; none attempted",
"input": {
"geometry": "synthetic 2 mm square with default hatch effect",
"power_per_mille": 500,
"speed_mm_per_s": 250.0,
"frequency_khz": 40.0,
"pulse_width_ns": 200,
},
"cutplan": {
"command_count": len(lines),
"mark_count": len(mark_lines),
"contains_frequency": any("listQSwitchPeriod" in line for line in lines),
"contains_pulse_width": any("listFiberYLPMPulseWidth" in line for line in lines),
},
"spooler": {
"completed": True,
"mock_packets_observed": bool(packets),
},
"trace": lines,
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("source", type=Path)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
result = run(args.source.resolve())
rendered = json.dumps(result, indent=2) + "\n"
if args.output:
args.output.write_text(rendered, encoding="utf-8", newline="\n")
else:
print(rendered, end="")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,40 @@
{
"upstream": "meerk40t/meerk40t",
"commit": "d511944e08788e88077727f682957759884ff5ca",
"transport": "MockConnection",
"usb_access": "trapped; none attempted",
"input": {
"geometry": "synthetic 2 mm square with default hatch effect",
"power_per_mille": 500,
"speed_mm_per_s": 250.0,
"frequency_khz": 40.0,
"pulse_width_ns": 200
},
"cutplan": {
"command_count": 15,
"mark_count": 1,
"contains_frequency": true,
"contains_pulse_width": true
},
"spooler": {
"completed": true,
"mock_packets_observed": true
},
"trace": [
"listReadyMark 0000 0000 0000 0000 0000",
"listDelayTime 0320 0000 0000 0000 0000",
"listWritePort 0001 0000 0000 0000 0000",
"listJumpSpeed 04a7 0000 0000 0000 0000",
"listFiberYLPMPulseWidth 00c8 0000 0000 0000 0000",
"listQSwitchPeriod 01f4 0000 0000 0000 0000",
"listMarkCurrent 0800 0000 0000 0000 0000",
"listMarkSpeed 0094 0000 0000 0000 0000",
"listLaserOnDelay 0064 0000 0000 0000 0000",
"listLaserOffDelay 0064 0000 0000 0000 0000",
"listPolygonDelay 000a 0000 0000 0000 0000",
"listJumpTo 7fff 8253 0000 0253 0000",
"listMarkTo 7fff 7dac 0000 04a7 0000",
"listDelayTime 001e 0000 0000 0000 0000",
"listEndOfList 0000 0000 0000 0000 0000"
]
}

View file

@ -9,6 +9,7 @@ from unittest.mock import patch
SCRIPT = Path(__file__).parents[1] / "scripts" / "spike_meerk40t_mock_mopa.py"
FIXTURE = Path(__file__).parent / "fixtures" / "meerk40t-mock-mopa-trace.json"
HATCH_FIXTURE = Path(__file__).parent / "fixtures" / "meerk40t-hatch-cutplan.json"
SPEC = importlib.util.spec_from_file_location("spike_meerk40t_mock_mopa", SCRIPT)
MODULE = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
@ -16,6 +17,16 @@ SPEC.loader.exec_module(MODULE)
class MockSpikeSafetyTests(unittest.TestCase):
def test_hatch_cutplan_and_spooler_stay_on_mock_transport(self):
result = json.loads(HATCH_FIXTURE.read_text(encoding="utf-8"))
self.assertEqual("MockConnection", result["transport"])
self.assertEqual("trapped; none attempted", result["usb_access"])
self.assertTrue(result["spooler"]["completed"])
self.assertTrue(result["spooler"]["mock_packets_observed"])
self.assertEqual(1, result["cutplan"]["mark_count"])
self.assertTrue(result["cutplan"]["contains_frequency"])
self.assertTrue(result["cutplan"]["contains_pulse_width"])
def test_golden_trace_records_pin_mock_and_mopa_values(self):
result = json.loads(FIXTURE.read_text(encoding="utf-8"))
trace = "\n".join(result["trace"])