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):
|
- [Synthetic LightBurn MOPA import spike](../research/rayforge-lightburn-mopa-import-spike.md):
|
||||||
geometry and common settings survive, but frequency is not mapped to the
|
geometry and common settings survive, but frequency is not mapped to the
|
||||||
workflow and pulse-width representation still lacks fixture evidence.
|
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
|
These results support continuing toward complete list buffering and production
|
||||||
integration, but they do not yet satisfy every proof obligation or change this
|
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
|
hold/resume work through the Rayforge driver contract, cancel clears every
|
||||||
pending list buffer, restart fails until explicit recovery, and a separately
|
pending list buffer, restart fails until explicit recovery, and a separately
|
||||||
encoded follow-up job can then complete. Physical stop/reset commands remain
|
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
|
## 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