Characterize Rayforge graphical workflow seam
This commit is contained in:
parent
9201f432a2
commit
fa41bf5d51
6 changed files with 336 additions and 1 deletions
|
|
@ -106,6 +106,10 @@ Subsequent offline evidence:
|
|||
- [Synthetic LightBurn MOPA import spike](../research/rayforge-lightburn-mopa-import-spike.md):
|
||||
geometry and common settings survive, but frequency is not mapped to the
|
||||
workflow and pulse-width representation still lacks fixture evidence.
|
||||
- [Graphical workflow model spike](../research/rayforge-graphical-workflow-spike.md):
|
||||
Rayforge's generated settings contract exposes frequency and pulse width,
|
||||
preserves them through step serialization and supplies preview Ops without
|
||||
transport; packaged GTK interaction remains untested.
|
||||
|
||||
These results support continuing toward complete list buffering and production
|
||||
integration, but they do not yet satisfy every proof obligation or change this
|
||||
|
|
|
|||
61
docs/research/rayforge-graphical-workflow-spike.md
Normal file
61
docs/research/rayforge-graphical-workflow-spike.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# Rayforge graphical workflow model spike
|
||||
|
||||
- Date: 2026-10-19
|
||||
- Rayforge commit: `c28918c87f8273dd1d244f5a057addc192807fa5`
|
||||
- Transport: none
|
||||
- Controller backend reachable: no
|
||||
|
||||
## Result
|
||||
|
||||
Pinned Rayforge exposes power, cut speed, frequency and pulse width through
|
||||
the existing laser-step `VarSet` contract. The graphical laser settings page
|
||||
builds its rows from that contract and places frequency and pulse width in a
|
||||
capability-gated **Machine** section. The declared units are Hz and ns.
|
||||
|
||||
A synthetic contour step retained power `0.42`, cut speed `750`, frequency
|
||||
`40000` and pulse width `100` through Rayforge's normal step serialization and
|
||||
deserialization. The same values were placed in a transport-free `Ops` stream
|
||||
and wrapped in Rayforge's preview-facing `JobArtifact`. Its preview contained
|
||||
nine operations, a 40 mm declared path and no encoded machine code.
|
||||
|
||||
The deterministic result is stored in
|
||||
`tests/fixtures/rayforge-graphical-workflow.json`.
|
||||
|
||||
## Interpretation
|
||||
|
||||
This is evidence that a JCZ addon can reuse Rayforge's existing workflow
|
||||
model, settings-page machinery and preview artifact instead of creating a
|
||||
parallel editor. It also confirms that MOPA frequency and pulse width can be
|
||||
represented in the workflow UI and project-step model once a compatible laser
|
||||
head advertises those capabilities.
|
||||
|
||||
This is not a claim that the packaged application has been exercised by a
|
||||
human. It is a headless model, UI-contract and preview-artifact test. The
|
||||
Machine section intentionally remains hidden unless the active machine and
|
||||
head provide PWM parameters.
|
||||
|
||||
## Remaining graphical evidence
|
||||
|
||||
A later no-device Windows test must:
|
||||
|
||||
1. launch a packaged Rayforge build with the external addon;
|
||||
2. select a synthetic JCZ-capable profile and confirm the Machine section;
|
||||
3. open or create geometry, edit MOPA values and inspect the preview;
|
||||
4. save and reload the project without losing those values;
|
||||
5. exercise start, hold, cancel and explicit recovery against the no-device
|
||||
driver.
|
||||
|
||||
That test must still expose no USB discovery or controller transport. Until it
|
||||
passes, the graphical half of roadmap item 5 and ADR 0002 remain open.
|
||||
|
||||
## Reproduce
|
||||
|
||||
Run against the pinned Rayforge checkout:
|
||||
|
||||
```powershell
|
||||
python scripts/spike_rayforge_graphical_workflow.py <rayforge-checkout>
|
||||
```
|
||||
|
||||
On Windows the imported Rayforge task manager creates a local asyncio wake-up
|
||||
socket. A sandbox that blocks local socket creation cannot run the spike even
|
||||
though the spike performs no network or controller I/O.
|
||||
|
|
@ -297,7 +297,14 @@ The cancellation half of item 5 now has an asynchronous no-device proof:
|
|||
hold/resume work through the Rayforge driver contract, cancel clears every
|
||||
pending list buffer, restart fails until explicit recovery, and a separately
|
||||
encoded follow-up job can then complete. Physical stop/reset commands remain
|
||||
absent. Graphical workflow assessment is still open.
|
||||
absent.
|
||||
|
||||
The first graphical-workflow assessment now confirms that Rayforge's existing
|
||||
generated settings UI exposes frequency and pulse width with Hz/ns contracts,
|
||||
the values survive step serialization, and the preview artifact is populated
|
||||
without machine code or transport. This is a headless UI-contract and pipeline
|
||||
proof, not a completed human GTK test. Packaged Windows interaction with the
|
||||
external addon remains open before item 5 or ADR 0002 can close.
|
||||
|
||||
## Decision rules
|
||||
|
||||
|
|
|
|||
160
scripts/spike_rayforge_graphical_workflow.py
Normal file
160
scripts/spike_rayforge_graphical_workflow.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
"""Characterize Rayforge's graphical MOPA workflow without opening hardware."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
RAYFORGE_COMMIT = "c28918c87f8273dd1d244f5a057addc192807fa5"
|
||||
|
||||
|
||||
def _commit_of(source: Path) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(source), "rev-parse", "HEAD"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def run(source: Path) -> dict[str, object]:
|
||||
commit = _commit_of(source)
|
||||
if commit != RAYFORGE_COMMIT:
|
||||
raise RuntimeError(f"expected Rayforge {RAYFORGE_COMMIT}, found {commit}")
|
||||
|
||||
sys.path.insert(0, str(source))
|
||||
sys.path.insert(
|
||||
0,
|
||||
str(source / "rayforge" / "builtin_addons" / "rayforge-addon-laser"),
|
||||
)
|
||||
sys.path.insert(
|
||||
0,
|
||||
str(source / "rayforge" / "builtin_addons" / "rayforge-addon-post"),
|
||||
)
|
||||
|
||||
from post_processors.worker import register_transformers
|
||||
from rayforge.pipeline.transformer.registry import transformer_registry
|
||||
|
||||
register_transformers(transformer_registry)
|
||||
|
||||
from laser_essentials.steps.contour_step import ContourStep
|
||||
from rayforge.pipeline.artifact.job import JobArtifact
|
||||
from raygeo.ops import Ops
|
||||
|
||||
step = ContourStep(name="Synthetic MOPA contour")
|
||||
step.power = 0.42
|
||||
step.cut_speed = 750
|
||||
step.frequency = 40000
|
||||
step.pulse_width = 100
|
||||
serialized = step.to_dict()
|
||||
restored = ContourStep.from_dict(serialized)
|
||||
|
||||
vars_by_key = {var.key: var for var in step.recipe_varset()}
|
||||
ui_source = (
|
||||
source
|
||||
/ "rayforge"
|
||||
/ "builtin_addons"
|
||||
/ "rayforge-addon-laser"
|
||||
/ "laser_essentials"
|
||||
/ "widgets"
|
||||
/ "laser_step_page.py"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
ops = Ops()
|
||||
ops.set_power(step.power)
|
||||
ops.set_feed_rate(step.cut_speed)
|
||||
ops.set_frequency(step.frequency)
|
||||
ops.set_pulse_width(step.pulse_width)
|
||||
ops.move_to(10, 10)
|
||||
ops.line_to(20, 10)
|
||||
ops.line_to(20, 20)
|
||||
ops.line_to(10, 20)
|
||||
ops.line_to(10, 10)
|
||||
artifact = JobArtifact(
|
||||
ops=ops,
|
||||
distance=40.0,
|
||||
generation_id=1,
|
||||
time_estimate=0.053,
|
||||
)
|
||||
preview = artifact.preview_ops
|
||||
if preview is None:
|
||||
raise AssertionError("Rayforge produced no preview operation stream")
|
||||
|
||||
required = ("power", "cut_speed", "frequency", "pulse_width")
|
||||
missing = [key for key in required if key not in vars_by_key]
|
||||
if missing:
|
||||
raise AssertionError(f"workflow variables missing: {missing}")
|
||||
|
||||
return {
|
||||
"rayforge_commit": commit,
|
||||
"transport": None,
|
||||
"controller_backend_reachable": False,
|
||||
"assessment_scope": "headless model, UI contract, and preview artifact",
|
||||
"workflow_controls": {
|
||||
key: {
|
||||
"label": vars_by_key[key].label,
|
||||
"unit_contract": (
|
||||
"Hz" if key == "frequency" else "ns" if key == "pulse_width" else None
|
||||
),
|
||||
}
|
||||
for key in required
|
||||
},
|
||||
"ui_contract": {
|
||||
"machine_section_keys": ["frequency", "pulse_width"],
|
||||
"varset_generated_rows": "add_varset_section" in ui_source,
|
||||
"machine_section_is_capability_gated": "get_pwm_params" in ui_source,
|
||||
},
|
||||
"serialized_step": {
|
||||
"type": serialized["step_type"],
|
||||
"power": serialized["power"],
|
||||
"cut_speed": serialized["cut_speed"],
|
||||
"frequency": serialized["frequency"],
|
||||
"pulse_width": serialized["pulse_width"],
|
||||
},
|
||||
"round_trip_preserved": {
|
||||
"power": restored.power == step.power,
|
||||
"cut_speed": restored.cut_speed == step.cut_speed,
|
||||
"frequency": restored.frequency == step.frequency,
|
||||
"pulse_width": restored.pulse_width == step.pulse_width,
|
||||
},
|
||||
"preview": {
|
||||
"available": True,
|
||||
"operation_count": len(preview.to_dict()["commands"]),
|
||||
"distance_mm": artifact.distance,
|
||||
"time_estimate_s": artifact.time_estimate,
|
||||
"machine_code": artifact.machine_code,
|
||||
},
|
||||
"remaining_manual_evidence": [
|
||||
"launch the packaged Windows application with the external addon",
|
||||
"confirm the Machine section is visible for a synthetic JCZ-capable head",
|
||||
"exercise open, edit, preview, save, reload, cancel, and recovery by user input",
|
||||
],
|
||||
"conclusion": (
|
||||
"Rayforge's existing workflow model and generated settings UI expose "
|
||||
"frequency and pulse width, preserve them in project-step serialization, "
|
||||
"and provide a preview operation stream without transport; packaged GTK "
|
||||
"interaction remains a manual evidence gap"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("source", type=Path, help="pinned Rayforge checkout")
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
result = run(args.source.resolve())
|
||||
rendered = json.dumps(result, indent=2, sort_keys=True) + "\n"
|
||||
if args.output:
|
||||
args.output.write_text(rendered, encoding="utf-8", newline="\n")
|
||||
else:
|
||||
print(rendered, end="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
58
tests/fixtures/rayforge-graphical-workflow.json
vendored
Normal file
58
tests/fixtures/rayforge-graphical-workflow.json
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"assessment_scope": "headless model, UI contract, and preview artifact",
|
||||
"conclusion": "Rayforge's existing workflow model and generated settings UI expose frequency and pulse width, preserve them in project-step serialization, and provide a preview operation stream without transport; packaged GTK interaction remains a manual evidence gap",
|
||||
"controller_backend_reachable": false,
|
||||
"preview": {
|
||||
"available": true,
|
||||
"distance_mm": 40.0,
|
||||
"machine_code": null,
|
||||
"operation_count": 9,
|
||||
"time_estimate_s": 0.053
|
||||
},
|
||||
"rayforge_commit": "c28918c87f8273dd1d244f5a057addc192807fa5",
|
||||
"remaining_manual_evidence": [
|
||||
"launch the packaged Windows application with the external addon",
|
||||
"confirm the Machine section is visible for a synthetic JCZ-capable head",
|
||||
"exercise open, edit, preview, save, reload, cancel, and recovery by user input"
|
||||
],
|
||||
"round_trip_preserved": {
|
||||
"cut_speed": true,
|
||||
"frequency": true,
|
||||
"power": true,
|
||||
"pulse_width": true
|
||||
},
|
||||
"serialized_step": {
|
||||
"cut_speed": 750,
|
||||
"frequency": 40000,
|
||||
"power": 0.42,
|
||||
"pulse_width": 100,
|
||||
"type": "ContourStep"
|
||||
},
|
||||
"transport": null,
|
||||
"ui_contract": {
|
||||
"machine_section_is_capability_gated": true,
|
||||
"machine_section_keys": [
|
||||
"frequency",
|
||||
"pulse_width"
|
||||
],
|
||||
"varset_generated_rows": true
|
||||
},
|
||||
"workflow_controls": {
|
||||
"cut_speed": {
|
||||
"label": "Cut Speed",
|
||||
"unit_contract": null
|
||||
},
|
||||
"frequency": {
|
||||
"label": "Frequency",
|
||||
"unit_contract": "Hz"
|
||||
},
|
||||
"power": {
|
||||
"label": "Power",
|
||||
"unit_contract": null
|
||||
},
|
||||
"pulse_width": {
|
||||
"label": "Pulse Width",
|
||||
"unit_contract": "ns"
|
||||
}
|
||||
}
|
||||
}
|
||||
45
tests/test_rayforge_graphical_workflow_spike.py
Normal file
45
tests/test_rayforge_graphical_workflow_spike.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
RESULT = ROOT / "tests" / "fixtures" / "rayforge-graphical-workflow.json"
|
||||
|
||||
|
||||
class RayforgeGraphicalWorkflowSpikeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.result = json.loads(RESULT.read_text(encoding="utf-8"))
|
||||
|
||||
def test_workflow_is_transport_free(self):
|
||||
self.assertIsNone(self.result["transport"])
|
||||
self.assertFalse(self.result["controller_backend_reachable"])
|
||||
|
||||
def test_mopa_controls_have_explicit_units(self):
|
||||
controls = self.result["workflow_controls"]
|
||||
self.assertEqual("Hz", controls["frequency"]["unit_contract"])
|
||||
self.assertEqual("ns", controls["pulse_width"]["unit_contract"])
|
||||
self.assertTrue(self.result["ui_contract"]["varset_generated_rows"])
|
||||
self.assertTrue(
|
||||
self.result["ui_contract"]["machine_section_is_capability_gated"]
|
||||
)
|
||||
|
||||
def test_step_round_trip_preserves_process_values(self):
|
||||
self.assertTrue(all(self.result["round_trip_preserved"].values()))
|
||||
step = self.result["serialized_step"]
|
||||
self.assertEqual(40000, step["frequency"])
|
||||
self.assertEqual(100, step["pulse_width"])
|
||||
|
||||
def test_preview_exists_without_machine_code(self):
|
||||
preview = self.result["preview"]
|
||||
self.assertTrue(preview["available"])
|
||||
self.assertGreater(preview["operation_count"], 0)
|
||||
self.assertIsNone(preview["machine_code"])
|
||||
|
||||
def test_manual_gtk_evidence_is_not_claimed(self):
|
||||
self.assertGreater(len(self.result["remaining_manual_evidence"]), 0)
|
||||
self.assertIn("headless", self.result["assessment_scope"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Reference in a new issue