Add transport-free JCZ list buffering
This commit is contained in:
parent
47d83684ee
commit
bf5dd439ce
9 changed files with 220 additions and 21 deletions
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
## Result
|
||||
|
||||
The transport-free JCZ semantic encoder rejected all six synthetic negative
|
||||
The transport-free JCZ semantic encoder rejected all eight synthetic negative
|
||||
cases before producing output:
|
||||
|
||||
| Case | Rejection |
|
||||
|
|
@ -17,9 +17,11 @@ cases before producing output:
|
|||
| Zero frequency | Frequency was not positive |
|
||||
| Zero pulse width | Pulse width was not positive |
|
||||
| Unsupported command | A synthetic `CUSTOM` Ops command had no explicit mapping |
|
||||
| Missing required state | A job omitted pulse width, rapid speed, frequency and mark speed |
|
||||
| State after motion | A frequency change appeared after the first move |
|
||||
|
||||
Two runs produced identical results with SHA-256
|
||||
`0adab8fb1060fde1e172c674da3d0d4ae5936b01490891b898f86cc4f7ca3c3f`.
|
||||
`1372037e089185365bb7600762d49afa938063ef53f888604f87c68cad829b83`.
|
||||
The checked result is `tests/fixtures/rayforge-jcz-negative-cases.json`.
|
||||
|
||||
## Boundary
|
||||
|
|
|
|||
|
|
@ -13,8 +13,16 @@ The opcode and value fields agree with the pinned BalorMK mock trace for power,
|
|||
mark and jump speed, frequency, MOPA pulse width, the square coordinates and
|
||||
distances, one millisecond dwell, and end-of-list.
|
||||
|
||||
The encoder now places those records into the pinned BalorMK list shape:
|
||||
exactly 256 records and 3072 bytes per buffer, with unused final-buffer slots
|
||||
filled by `listEndOfList`. Before packing, it requires one complete state block
|
||||
ahead of all motion and emits the evidence-backed order `listReadyMark`, pulse
|
||||
width, jump speed, frequency, power and mark speed, followed by geometry and
|
||||
end-of-list. State changes after motion and duplicate or missing state fields
|
||||
fail closed.
|
||||
|
||||
Two independent runs produced identical output with SHA-256
|
||||
`b3d34600daac7060c3d3b28edf1627813d3bc518b1a64be11367f8e2106a51c6`.
|
||||
`6217c9f8fee0e612872de6c20f5b033a4df885a173a442baa54629465fc5db5b`.
|
||||
The checked records are in
|
||||
`tests/fixtures/rayforge-jcz-equivalence.json` and are asserted byte-for-byte by
|
||||
the standard-library test suite.
|
||||
|
|
@ -29,10 +37,9 @@ operations continue to fail closed.
|
|||
|
||||
## Remaining gaps
|
||||
|
||||
- Records are emitted in Rayforge operation order. A production adapter must
|
||||
define controller-safe state ordering from evidence.
|
||||
- List framing, fixed-size buffers, padding, upload, status, cancellation and
|
||||
recovery are absent.
|
||||
- Upload, status, cancellation and recovery are absent.
|
||||
- Port-state commands are deliberately omitted because their machine-specific
|
||||
bit semantics can arm output and have not yet passed the hardware gate.
|
||||
- The 110 mm coordinate mapping is synthetic and has no lens correction.
|
||||
- Machine-specific bounds and the pulse-width unit contract still require
|
||||
authoritative evidence; they are not guessed here.
|
||||
|
|
|
|||
|
|
@ -288,6 +288,11 @@ the no-device JCZ driver, preserves process values as raw observations with
|
|||
unit uncertainty, and keeps encoding disabled until the remaining profile and
|
||||
project evidence exists.
|
||||
|
||||
Item 4 now covers transport-free fixed-size list buffering and an explicit
|
||||
state-before-motion order derived from pinned BalorMK. Port commands, upload,
|
||||
execute, status and cancellation remain outside the encoder and cannot be
|
||||
reached by this spike.
|
||||
|
||||
## Decision rules
|
||||
|
||||
- A simulator test precedes every equivalent hardware behavior.
|
||||
|
|
|
|||
|
|
@ -110,10 +110,24 @@ def run(source: Path, addon: Path, balor_fixture: Path) -> dict[str, object]:
|
|||
"rayforge_ops_count": ops.len(),
|
||||
"semantic_trace": semantic,
|
||||
"command_records_hex": encoded.driver_data["command_records_hex"],
|
||||
"controller_list_commands": [
|
||||
item["command"]
|
||||
for item in encoded.driver_data["controller_list_trace"]
|
||||
],
|
||||
"list_buffer_size": encoded.driver_data["list_buffer_size"],
|
||||
"list_record_capacity": encoded.driver_data["list_record_capacity"],
|
||||
"list_buffer_count": len(encoded.driver_data["list_buffers"]),
|
||||
"list_buffer_sha256": encoded.driver_data["list_buffer_sha256"],
|
||||
"list_meaningful_record_count": encoded.driver_data[
|
||||
"list_meaningful_record_count"
|
||||
],
|
||||
"list_padding_record_count": encoded.driver_data[
|
||||
"list_padding_record_count"
|
||||
],
|
||||
"balormk_equivalence": checks,
|
||||
"limitations": [
|
||||
"12-byte command records only; no list buffering or transport",
|
||||
"record ordering follows the Rayforge Ops stream",
|
||||
"3072-byte list buffers only; no upload or transport",
|
||||
"safe state ordering follows pinned BalorMK program/set_settings order while omitting port commands",
|
||||
"synthetic field mapping; no lens correction",
|
||||
"pulse width treated as nanoseconds from LaserStep UI contract",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -95,12 +95,40 @@ def run(source: Path, addon: Path) -> dict[str, object]:
|
|||
"unsupported_ops_command", Encoder(), unsupported, "unsupported"
|
||||
)
|
||||
|
||||
incomplete_state = Ops()
|
||||
incomplete_state.job_start()
|
||||
incomplete_state.set_power(0.5)
|
||||
incomplete_state.job_end()
|
||||
expect_rejection(
|
||||
"missing_required_state",
|
||||
Encoder(),
|
||||
incomplete_state,
|
||||
"missing required JCZ state",
|
||||
)
|
||||
|
||||
late_state = Ops()
|
||||
late_state.job_start()
|
||||
late_state.set_power(0.5)
|
||||
late_state.set_feed_rate(15_000)
|
||||
late_state.set_rapid_rate(120_000)
|
||||
late_state.set_frequency(40_000)
|
||||
late_state.set_pulse_width(200)
|
||||
late_state.move_to(55, 55, 0)
|
||||
late_state.set_frequency(50_000)
|
||||
late_state.job_end()
|
||||
expect_rejection(
|
||||
"state_change_after_motion",
|
||||
Encoder(),
|
||||
late_state,
|
||||
"state change after motion",
|
||||
)
|
||||
|
||||
return {
|
||||
"rayforge_commit": commit,
|
||||
"profile": Encoder.SYNTHETIC_PROFILE_ID,
|
||||
"transport": None,
|
||||
"cases": cases,
|
||||
"all_failed_closed": len(cases) == 6,
|
||||
"all_failed_closed": len(cases) == 8,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ This is deliberately not a controller implementation. It has no discovery
|
|||
specification, transport, USB import, or machine I/O.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
|
|
@ -35,8 +36,20 @@ class OfflineJCZEncoder(OpsEncoder):
|
|||
"power": 0x8012,
|
||||
"mark_speed": 0x800C,
|
||||
"pulse_width": 0x8026,
|
||||
"ready": 0x8051,
|
||||
}
|
||||
|
||||
LIST_RECORD_SIZE = 12
|
||||
LIST_BUFFER_SIZE = 0xC00
|
||||
LIST_RECORD_CAPACITY = LIST_BUFFER_SIZE // LIST_RECORD_SIZE
|
||||
STATE_ORDER = (
|
||||
"pulse_width",
|
||||
"jump_speed",
|
||||
"frequency",
|
||||
"power",
|
||||
"mark_speed",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
profile_id: str = SYNTHETIC_PROFILE_ID,
|
||||
|
|
@ -66,7 +79,9 @@ class OfflineJCZEncoder(OpsEncoder):
|
|||
command = item["command"]
|
||||
if command == "job_start":
|
||||
continue
|
||||
if command == "job_end":
|
||||
if command == "ready":
|
||||
records.append(self._record(self.OPCODES[command]))
|
||||
elif command == "job_end":
|
||||
records.append(self._record(self.OPCODES["end_of_list"]))
|
||||
elif command == "pulse_width":
|
||||
records.append(
|
||||
|
|
@ -100,6 +115,58 @@ class OfflineJCZEncoder(OpsEncoder):
|
|||
raise ValueError(f"cannot packetize semantic command: {command}")
|
||||
return records
|
||||
|
||||
def _controller_list_trace(
|
||||
self, trace: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
if not trace or trace[0]["command"] != "job_start":
|
||||
raise ValueError("JCZ list must start with job_start")
|
||||
if trace[-1]["command"] != "job_end":
|
||||
raise ValueError("JCZ list must end with job_end")
|
||||
|
||||
states: dict[str, dict[str, Any]] = {}
|
||||
body: list[dict[str, Any]] = []
|
||||
motion_seen = False
|
||||
for item in trace[1:-1]:
|
||||
command = item["command"]
|
||||
if command in self.STATE_ORDER:
|
||||
if motion_seen:
|
||||
raise ValueError(
|
||||
"JCZ state change after motion is not supported"
|
||||
)
|
||||
if command in states:
|
||||
raise ValueError(f"duplicate JCZ state command: {command}")
|
||||
states[command] = item
|
||||
else:
|
||||
motion_seen = True
|
||||
body.append(item)
|
||||
|
||||
missing = [name for name in self.STATE_ORDER if name not in states]
|
||||
if missing:
|
||||
raise ValueError(f"missing required JCZ state: {', '.join(missing)}")
|
||||
return (
|
||||
[{"command": "ready"}]
|
||||
+ [states[name] for name in self.STATE_ORDER]
|
||||
+ body
|
||||
+ [trace[-1]]
|
||||
)
|
||||
|
||||
def _pack_list_buffers(self, records: list[bytes]) -> list[bytes]:
|
||||
if not records:
|
||||
return []
|
||||
end_record = self._record(self.OPCODES["end_of_list"])
|
||||
buffers = []
|
||||
for offset in range(0, len(records), self.LIST_RECORD_CAPACITY):
|
||||
chunk = records[offset : offset + self.LIST_RECORD_CAPACITY]
|
||||
if len(chunk) < self.LIST_RECORD_CAPACITY:
|
||||
chunk = chunk + [end_record] * (
|
||||
self.LIST_RECORD_CAPACITY - len(chunk)
|
||||
)
|
||||
packet = b"".join(chunk)
|
||||
if len(packet) != self.LIST_BUFFER_SIZE:
|
||||
raise AssertionError("JCZ list buffer is not exactly 0xC00 bytes")
|
||||
buffers.append(packet)
|
||||
return buffers
|
||||
|
||||
@classmethod
|
||||
def _coordinate(cls, value_mm: float) -> int:
|
||||
if not 0.0 <= value_mm <= cls.FIELD_MM:
|
||||
|
|
@ -186,7 +253,9 @@ class OfflineJCZEncoder(OpsEncoder):
|
|||
raise ValueError(f"unsupported Ops command: {kind}")
|
||||
op_count = len(trace)
|
||||
|
||||
records = self._encode_records(trace)
|
||||
controller_trace = [] if ops is None else self._controller_list_trace(trace)
|
||||
records = self._encode_records(controller_trace)
|
||||
list_buffers = self._pack_list_buffers(records)
|
||||
payload = b"".join(records)
|
||||
if ops is None:
|
||||
payload = json.dumps(
|
||||
|
|
@ -202,6 +271,18 @@ class OfflineJCZEncoder(OpsEncoder):
|
|||
"binary": payload,
|
||||
"command_records_hex": [record.hex() for record in records],
|
||||
"semantic_trace": trace,
|
||||
"controller_list_trace": controller_trace,
|
||||
"list_buffers": list_buffers,
|
||||
"list_buffer_sha256": [
|
||||
hashlib.sha256(packet).hexdigest()
|
||||
for packet in list_buffers
|
||||
],
|
||||
"list_record_capacity": self.LIST_RECORD_CAPACITY,
|
||||
"list_buffer_size": self.LIST_BUFFER_SIZE,
|
||||
"list_meaningful_record_count": len(records),
|
||||
"list_padding_record_count": (
|
||||
len(list_buffers) * self.LIST_RECORD_CAPACITY - len(records)
|
||||
),
|
||||
"transport": None,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
34
tests/fixtures/rayforge-jcz-equivalence.json
vendored
34
tests/fixtures/rayforge-jcz-equivalence.json
vendored
|
|
@ -20,11 +20,12 @@
|
|||
{"command": "job_end"}
|
||||
],
|
||||
"command_records_hex": [
|
||||
"128000080000000000000000",
|
||||
"0c8094000000000000000000",
|
||||
"518000000000000000000000",
|
||||
"2680c8000000000000000000",
|
||||
"0680a7040000000000000000",
|
||||
"1b80f4010000000000000000",
|
||||
"2680c8000000000000000000",
|
||||
"128000080000000000000000",
|
||||
"0c8094000000000000000000",
|
||||
"0180007000700000a0160000",
|
||||
"058000710070000000010000",
|
||||
"058000710071000000010000",
|
||||
|
|
@ -33,6 +34,29 @@
|
|||
"038064000000000000000000",
|
||||
"028000000000000000000000"
|
||||
],
|
||||
"controller_list_commands": [
|
||||
"ready",
|
||||
"pulse_width",
|
||||
"jump_speed",
|
||||
"frequency",
|
||||
"power",
|
||||
"mark_speed",
|
||||
"jump",
|
||||
"mark",
|
||||
"mark",
|
||||
"mark",
|
||||
"mark",
|
||||
"laser_on_point",
|
||||
"job_end"
|
||||
],
|
||||
"list_buffer_size": 3072,
|
||||
"list_record_capacity": 256,
|
||||
"list_buffer_count": 1,
|
||||
"list_buffer_sha256": [
|
||||
"df9894c746df451054d7237e1e020153e2a65cf2ceaea71dde7d9a40fabaf3e1"
|
||||
],
|
||||
"list_meaningful_record_count": 13,
|
||||
"list_padding_record_count": 243,
|
||||
"balormk_equivalence": {
|
||||
"power_50_percent": true,
|
||||
"speed_250_mm_s": true,
|
||||
|
|
@ -42,8 +66,8 @@
|
|||
"dwell_1_ms": true
|
||||
},
|
||||
"limitations": [
|
||||
"12-byte command records only; no list buffering or transport",
|
||||
"record ordering follows the Rayforge Ops stream",
|
||||
"3072-byte list buffers only; no upload or transport",
|
||||
"safe state ordering follows pinned BalorMK program/set_settings order while omitting port commands",
|
||||
"synthetic field mapping; no lens correction",
|
||||
"pulse width treated as nanoseconds from LaserStep UI contract"
|
||||
]
|
||||
|
|
|
|||
10
tests/fixtures/rayforge-jcz-negative-cases.json
vendored
10
tests/fixtures/rayforge-jcz-negative-cases.json
vendored
|
|
@ -32,6 +32,16 @@
|
|||
"case": "unsupported_ops_command",
|
||||
"result": "rejected",
|
||||
"reason": "unsupported Ops command: CUSTOM"
|
||||
},
|
||||
{
|
||||
"case": "missing_required_state",
|
||||
"result": "rejected",
|
||||
"reason": "missing required JCZ state: pulse_width, jump_speed, frequency, mark_speed"
|
||||
},
|
||||
{
|
||||
"case": "state_change_after_motion",
|
||||
"result": "rejected",
|
||||
"reason": "JCZ state change after motion is not supported"
|
||||
}
|
||||
],
|
||||
"all_failed_closed": true
|
||||
|
|
|
|||
|
|
@ -62,11 +62,12 @@ class RayforgeAddonSpikeTests(unittest.TestCase):
|
|||
self.assertTrue(all(len(record) == 24 for record in records))
|
||||
self.assertEqual(
|
||||
[
|
||||
"128000080000000000000000",
|
||||
"0c8094000000000000000000",
|
||||
"518000000000000000000000",
|
||||
"2680c8000000000000000000",
|
||||
"0680a7040000000000000000",
|
||||
"1b80f4010000000000000000",
|
||||
"2680c8000000000000000000",
|
||||
"128000080000000000000000",
|
||||
"0c8094000000000000000000",
|
||||
"0180007000700000a0160000",
|
||||
"058000710070000000010000",
|
||||
"058000710071000000010000",
|
||||
|
|
@ -78,11 +79,38 @@ class RayforgeAddonSpikeTests(unittest.TestCase):
|
|||
records,
|
||||
)
|
||||
|
||||
def test_controller_list_is_fixed_size_and_safely_ordered(self):
|
||||
result = json.loads(EQUIVALENCE_FIXTURE.read_text(encoding="utf-8"))
|
||||
self.assertEqual(3072, result["list_buffer_size"])
|
||||
self.assertEqual(256, result["list_record_capacity"])
|
||||
self.assertEqual(1, result["list_buffer_count"])
|
||||
self.assertEqual(1, len(result["list_buffer_sha256"]))
|
||||
self.assertEqual(13, result["list_meaningful_record_count"])
|
||||
self.assertEqual(243, result["list_padding_record_count"])
|
||||
self.assertEqual(
|
||||
[
|
||||
"ready",
|
||||
"pulse_width",
|
||||
"jump_speed",
|
||||
"frequency",
|
||||
"power",
|
||||
"mark_speed",
|
||||
"jump",
|
||||
"mark",
|
||||
"mark",
|
||||
"mark",
|
||||
"mark",
|
||||
"laser_on_point",
|
||||
"job_end",
|
||||
],
|
||||
result["controller_list_commands"],
|
||||
)
|
||||
|
||||
def test_negative_cases_all_fail_closed_without_transport(self):
|
||||
result = json.loads(NEGATIVE_FIXTURE.read_text(encoding="utf-8"))
|
||||
self.assertTrue(result["all_failed_closed"])
|
||||
self.assertIsNone(result["transport"])
|
||||
self.assertEqual(6, len(result["cases"]))
|
||||
self.assertEqual(8, len(result["cases"]))
|
||||
self.assertEqual(
|
||||
{"rejected"}, {case["result"] for case in result["cases"]}
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue