Bind LightBurn MOPA units to verified evidence
This commit is contained in:
parent
862d5a3cca
commit
8ab25827db
24 changed files with 328 additions and 126 deletions
2
.gitattributes
vendored
2
.gitattributes
vendored
|
|
@ -4,6 +4,8 @@
|
|||
*.py text eol=lf
|
||||
*.json text eol=lf
|
||||
*.jsonl text eol=lf
|
||||
*.webmanifest text eol=lf
|
||||
*.xml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.png binary
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ operator-verified projects are preserved and analyzed. The operator confirmed a
|
|||
and [control-machine observation](docs/research/lightburn-control-machine-observation-2026-10-24.md).
|
||||
|
||||
Three operator-verified projects now establish project-level `frequency` and
|
||||
`QPulseWidth` serialization; see the
|
||||
`QPulseWidth` serialization. Correlation with the running LightBurn 2.1.03 UI
|
||||
establishes raw Hz to displayed kHz and direct ns pulse width; see the
|
||||
[verified-project analysis](docs/research/lightburn-verified-projects-2026-10-24.md).
|
||||
Missing pulse width still requires explicit resolution before executable import.
|
||||
|
||||
|
|
|
|||
|
|
@ -77,9 +77,10 @@ gates remain:
|
|||
1. A Windows artifact for the pinned Rayforge revision must be produced on a
|
||||
reviewed build runner, verified without execution, and pass the manual
|
||||
no-device desktop checklist.
|
||||
2. Promote only complete vector MOPA imports after an explicit unit contract,
|
||||
absent-pulse-width policy and exact machine-context validation. The private
|
||||
serialization-evidence gate itself is complete.
|
||||
2. The known-working private JPT `.lbrn2` serialization and unit-contract gates
|
||||
are met for LightBurn 2.1.03 vector settings. Promotion still requires
|
||||
explicit resolution of absent pulse width, image equivalence and complete
|
||||
machine-context validation.
|
||||
|
||||
Raw projects, screenshots containing identifiers, machine data and build
|
||||
binaries stay out of the public repository.
|
||||
|
|
@ -89,13 +90,13 @@ binaries stay out of the public repository.
|
|||
An offline project MOPA seam, disabled-output Rayforge workflow bridge and
|
||||
synthetic tests now exist; see
|
||||
`docs/research/lightburn-project-mopa-import.md`. It preserves both process
|
||||
element types and requires an explicit unit contract for normalization. The
|
||||
bridge binds source layer IDs, uses the host MOPA setters, serializes review
|
||||
metadata and disables every staged step. Document preflight presents those
|
||||
blockers and the offline driver rejects them before buffer preparation. It
|
||||
never authorizes encoding. The next import task is GTK presentation plus an
|
||||
independently established unit contract; absent pulse width and image
|
||||
equivalence remain blockers.
|
||||
element types and applies the evidence-bound Hz/ns contract only to LightBurn
|
||||
2.1.03. The bridge binds source layer IDs, uses the host MOPA setters,
|
||||
serializes exact unit provenance and disables every staged step. Document
|
||||
preflight presents the remaining blockers and the offline driver rejects them
|
||||
before buffer preparation. It never authorizes encoding. The next import task
|
||||
is packaged GTK presentation; absent pulse width and image equivalence remain
|
||||
blockers.
|
||||
|
||||
If a Windows artifact is available, keep the machine disconnected and run, in
|
||||
order:
|
||||
|
|
@ -108,8 +109,9 @@ order:
|
|||
|
||||
If no artifact is available, send the external-builder handoff in
|
||||
`docs/research/m1-no-device-acceptance.md`; do not start another research
|
||||
branch or install a build environment here. Kaz's explicit decision on ADR
|
||||
0003 is also outstanding.
|
||||
branch or install a build environment here. The evidence-backed vector MOPA
|
||||
import mapping and synthetic tests are complete; Kaz's explicit decision on
|
||||
ADR 0003 is also outstanding.
|
||||
The M3 protocol can also be reviewed offline, but its
|
||||
operation allowlist must stay empty until exact read-only request evidence is
|
||||
available. ADR 0002 is accepted for the offline architecture. Until the
|
||||
|
|
|
|||
|
|
@ -104,8 +104,9 @@ Subsequent offline evidence:
|
|||
external encoder deterministically packs controller-shaped command records
|
||||
matching the pinned BalorMK mock values, while retaining no transport.
|
||||
- [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.
|
||||
geometry and common settings survive; the external addon now maps complete
|
||||
vector frequency and pulse width with a LightBurn 2.1.03 evidence contract,
|
||||
while keeping every staged step disabled.
|
||||
- [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
|
||||
|
|
@ -130,7 +131,9 @@ Subsequent offline evidence:
|
|||
|
||||
Three operator-verified private projects subsequently established the
|
||||
project-level `frequency` and `QPulseWidth` serialization needed to implement
|
||||
the disabled-output import seam. Together, the evidence satisfies the host
|
||||
architecture decision. It does not authorize hardware access or claim that the
|
||||
packaged Windows interaction and import-promotion release gates have passed.
|
||||
the disabled-output import seam. The running LightBurn 2.1.03 UI further binds
|
||||
those values to raw Hz/display kHz and direct ns. Together, the evidence
|
||||
satisfies the accepted host architecture decision. It does not authorize
|
||||
hardware access or claim that the packaged Windows interaction and
|
||||
import-promotion release gates have passed.
|
||||
|
||||
|
|
|
|||
|
|
@ -66,9 +66,10 @@ Across the 60 exported cut-list entries:
|
|||
LightBurn's official galvo cut-setting documentation presents frequency in
|
||||
kHz and Q-Pulse Width in nanoseconds. The larger integers stored by the export
|
||||
show that the serialized representation cannot simply be copied into a UI
|
||||
field without an explicit conversion contract. The exact field name and unit
|
||||
are now evidenced for a device-library export, but the representation inside
|
||||
a `.lbrn2` project remains unconfirmed.
|
||||
field without an explicit conversion contract. Subsequent operator-verified
|
||||
LightBurn 2.1.03 project/UI correlation establishes raw Hz to display kHz and
|
||||
direct-ns `QPulseWidth`; see the
|
||||
[control-machine observation](lightburn-control-machine-observation-2026-10-24.md).
|
||||
|
||||
These observed recipe values and profile fields are **not certified hardware
|
||||
limits or safe operating parameters**. They must not be used to probe the
|
||||
|
|
@ -93,19 +94,16 @@ new general parser:
|
|||
|
||||
1. add evidence-backed JCZ device-profile recognition;
|
||||
2. preserve source units and raw values at the import boundary;
|
||||
3. map frequency and `QPulseWidth` only after a real `.lbrn2` fixture confirms
|
||||
project serialization;
|
||||
3. bind the now-confirmed LightBurn 2.1.03 Hz/ns contract to exact provenance;
|
||||
4. validate imported values against a separately verified machine profile;
|
||||
5. fail closed when units, profile identity or ranges are unknown.
|
||||
|
||||
## Remaining collection gaps
|
||||
|
||||
The exports settle the exact LightBurn release and the two named device
|
||||
profiles. Phase 0 still needs:
|
||||
profiles. The known-working projects, Device Settings screenshots and closed
|
||||
preferences snapshot were subsequently collected. Phase 0 still needs:
|
||||
|
||||
- two or three known-working `.lbrn2` projects, especially a MOPA/hatch job;
|
||||
- Device Settings and device-list screenshots;
|
||||
- the closed-application LightBurn preferences directory;
|
||||
- exterior machine, source, controller and installed-lens photographs;
|
||||
- mounted-lens, connection, startup and interlock notes;
|
||||
- the stock driver's identity and version on the control computer.
|
||||
|
|
|
|||
|
|
@ -42,11 +42,11 @@ unverified test files and instructed us not to use them. Nine temporary copies
|
|||
were removed from this acquisition before analysis or commit. Their originals
|
||||
were unchanged; no project content was analyzed or retained as evidence.
|
||||
|
||||
This collection closes the missing closed-application settings snapshot. It
|
||||
does **not** close the known-working JPT project gate: project-level frequency
|
||||
and pulse-width serialization still require independently identified,
|
||||
operator-verified project evidence. Do not derive an executable import mapping
|
||||
from these settings or the excluded test projects.
|
||||
This collection closes the missing closed-application settings snapshot. The
|
||||
gate was subsequently closed by three separately supplied, operator-verified
|
||||
projects plus the running LightBurn 2.1.03 UI. The excluded candidates remain
|
||||
unverified and are still not evidence. The resulting vector import mapping
|
||||
stays disabled and review-gated; it is not executable machine authorization.
|
||||
|
||||
The reviewed Windows artifact and manual no-device desktop acceptance also
|
||||
remain outstanding. No controller, USB endpoint, framing, motion or laser
|
||||
|
|
|
|||
|
|
@ -5,12 +5,15 @@ The addon's `project_import.py` reads observed `CutSetting` and
|
|||
It preserves raw `frequency`, `QPulseWidth`, layer order and image dither mode.
|
||||
The synthetic unit tests contain no private geometry or complete recipes.
|
||||
|
||||
Normalization requires the caller to declare the serialized-unit contract
|
||||
`frequency=Hz;QPulseWidth=ns`. This is an explicit input, not an inference from
|
||||
numeric magnitude or an authorization claim. With that contract, frequency is
|
||||
represented in Hz and kHz (division by 1000), and pulse width in ns. Without
|
||||
it, only raw values are returned. The seam does not establish the contract on
|
||||
behalf of the caller or read machine limits from these values.
|
||||
Normalization requires the caller to opt into the evidence-backed serialized
|
||||
unit contract `frequency=Hz;QPulseWidth=ns`. The contract is restricted to
|
||||
LightBurn `AppVersion="2.1.03"`; a missing or different version fails closed.
|
||||
It comes from correlating an operator-verified project's raw
|
||||
`frequency=1000000` and `QPulseWidth=10` with LightBurn's visible `1000 kHz`
|
||||
and `10 ns` controls. This is not an inference from magnitude or an
|
||||
authorization claim. Frequency is represented in Hz and kHz (division by
|
||||
1000), while pulse width remains in ns. Without explicit contract selection,
|
||||
only raw values are returned.
|
||||
|
||||
Absent fields remain absent, never zero or inherited defaults. Malformed,
|
||||
non-finite, non-positive, duplicate and excessively large values are rejected.
|
||||
|
|
@ -23,17 +26,19 @@ Every result has `encoding_allowed=False`, `hardware_use_allowed=False` and
|
|||
offline metadata seam. The companion workflow bridge matches the normalized
|
||||
LightBurn layer index to Rayforge's source-segment layer ID and stages complete
|
||||
vector MOPA values through the host's setters. Every staged step is disabled
|
||||
through `Step.set_visible(False)`, and its raw values, normalized values,
|
||||
review status and blockers are saved in the serializable `Step.extra` data.
|
||||
through `Step.set_visible(False)`, and its raw values, normalized values, exact
|
||||
unit-contract provenance, review status and blockers are saved in the
|
||||
serializable `Step.extra` data. Tampered or missing provenance is rejected
|
||||
before any step is mutated.
|
||||
Image settings and incomplete settings remain disabled without applying MOPA
|
||||
values. Multi-step staging is preflighted and rolled back on setter failure.
|
||||
|
||||
The bridge deliberately has no function that enables output or removes a
|
||||
blocker. Host UI and preflight integration must still establish serialized
|
||||
units from independent evidence, resolve absent pulse width and validate exact
|
||||
machine, lens, correction and parameter limits before a separate reviewed
|
||||
promotion path can exist. Rayforge's relevant Step and LaserStep files are now
|
||||
included in the pinned upstream contract manifest.
|
||||
blocker. Host UI and preflight integration must still resolve absent pulse
|
||||
width and validate exact machine, lens, correction and parameter limits before
|
||||
a separate reviewed promotion path can exist. Image-layer equivalence remains
|
||||
unverified. Rayforge's relevant Step and LaserStep files are included in the
|
||||
pinned upstream contract manifest.
|
||||
|
||||
The addon now also exposes a transport-free document preflight report with
|
||||
stable issue codes and human-readable blocker messages. The offline driver
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ required by real workshop jobs and supported by evidence.
|
|||
| Hide geometry without disabling output, and disable output without hiding geometry | LightBurn separates `Show` and `Output` | Rayforge separates `Layer.visible` canvas state from `Step.visible` job participation and serializes both; output is operation-level rather than a layer-level control | Proven offline at model/source level; aggregate UI untested | Add or propose a tri-state layer Output control over existing step states, then verify it in the packaged no-device GUI |
|
||||
| Edit common settings quickly | The layer list exposes mode, speed, power, passes and applicable galvo fields | Rayforge's generated laser-step settings expose power, speed, frequency and pulse width; serialization is proven offline | Proven offline at the model/UI-contract level | Confirm editing and save/reload in the packaged no-device GUI |
|
||||
| Reveal complexity progressively | LightBurn Beginner Mode hides some advanced settings; PirateRay calls for Beginner, Advanced and Expert levels without moving the canvas | Rayforge capability sections can hide machine-dependent fields, but three user-facing disclosure levels are not proven | Product requirement | Define the smallest beginner field set from real job needs before adding controls |
|
||||
| Preserve MOPA units and values | Galvo frequency is presented in kHz and Q-pulse width in ns | Rayforge's workflow uses Hz and ns and preserves both; LightBurn project import does not yet map frequency or pulse width into workflow settings | Partly proven; import gated | Keep explicit conversion at the import boundary and wait for a known-working `.lbrn2` before mapping `QPulseWidth` |
|
||||
| Preserve MOPA units and values | Galvo frequency is presented in kHz and Q-pulse width in ns | Rayforge's workflow uses Hz and ns; the addon maps complete LightBurn 2.1.03 vector settings with an evidence-bound raw-Hz/display-kHz and direct-ns contract | Proven offline for complete vector settings; image/absent-pulse cases gated | Verify editing and persistence in the packaged no-device GUI while retaining the version and provenance checks |
|
||||
| Apply a reusable material recipe | LightBurn libraries organize presets by material, thickness and description and apply/link them to layers | Rayforge recipes already match machine, head, material, thickness and step type and preserve Hz/ns settings; the external addon now validates versioned provenance plus exact source/lens/correction compatibility while withholding hardware authorization | Proven offline at model/source level; approval policy and portable collections remain | Integrate the validated metadata into recipe selection before changing the existing recipe UI |
|
||||
| Inspect planned behavior before any run | LightBurn Preview reflects enabled layers and planned order | Rayforge produced a populated `JobArtifact` with Ops, path length and no machine code | Proven offline; human interaction untested | Verify layer filtering, order, hatch and warnings in the packaged no-device GUI |
|
||||
| Reject a visibly invalid job | Preview/preflight should reveal unintended output; the target product must reject profile mismatch and out-of-field geometry | Existing JCZ negative tests reject profile mismatch, out-of-field geometry, invalid values and unsupported Ops | Proven offline at encoder boundary | Surface these failures as understandable preflight messages without weakening the fail-closed gate |
|
||||
|
|
@ -102,9 +102,10 @@ The PirateRay-specific work should remain narrow:
|
|||
|
||||
## Gaps that must not be papered over
|
||||
|
||||
1. Three known-working private galvo projects establish project-level
|
||||
`QPulseWidth` serialization. Omission/default behavior still requires an
|
||||
explicit policy before promotion to executable output.
|
||||
1. Three known-working private galvo projects and the running LightBurn 2.1.03
|
||||
UI establish the vector Hz/ns serialization contract. Missing
|
||||
`QPulseWidth` remains absence, never a guessed default; image-processing
|
||||
equivalence is also unproven.
|
||||
2. The current Windows environment has not launched the pinned Rayforge GTK
|
||||
application; the headless UI contract is not a human interaction test.
|
||||
3. `RequireFrameBeforeStart` is observed in a LightBurn device export and
|
||||
|
|
|
|||
|
|
@ -163,10 +163,13 @@ ISO-8601 review timestamp with timezone. No status authorizes hardware access.
|
|||
|
||||
## Remaining release gates
|
||||
|
||||
- The known-working project gate and LightBurn 2.1.03 Hz/ns serialization
|
||||
contract are complete. Missing pulse width and image equivalence remain
|
||||
explicit blockers.
|
||||
- Human interaction results from the packaged build must be recorded before
|
||||
M1 can be called a packaged desktop checkpoint.
|
||||
- The three known-working private projects establish serialized MOPA fields.
|
||||
Promotion from disabled staging still requires the explicit unit contract,
|
||||
absent-pulse-width policy and exact machine-context validation already
|
||||
enforced by preflight.
|
||||
Promotion from disabled staging still requires an absent-pulse-width policy,
|
||||
image equivalence and exact machine-context validation already enforced by
|
||||
preflight.
|
||||
- Physical controller behavior remains outside M1.
|
||||
|
|
|
|||
|
|
@ -159,8 +159,9 @@ Source inspection found particularly relevant implementation details:
|
|||
including speed, power, passes, offsets, raster interval/angle and dither;
|
||||
- `.lbdev` device profiles have a separate importer;
|
||||
- laser steps and recipes serialize `frequency` in Hz; the laser-step UI
|
||||
describes `pulse_width` as nanoseconds, while the Ruida encoder calls the
|
||||
same Ops value microseconds, so the unit contract requires clarification;
|
||||
describes `pulse_width` as nanoseconds. Later LightBurn 2.1.03 evidence binds
|
||||
the JPT import seam to raw Hz/display kHz and direct ns without changing the
|
||||
separate Ruida encoder question;
|
||||
- the neutral `Ops` command set contains frequency and pulse-width commands;
|
||||
- binary rather than G-code drivers are supported through an `OpsEncoder`;
|
||||
- driver state, cancel/hold, discovery, machine profiles, simulator and
|
||||
|
|
@ -224,8 +225,9 @@ stability and binary-wheel review.
|
|||
## Finding 4: LightBurn migration is mostly an extension problem
|
||||
|
||||
Rayforge's existing LightBurn import removes the need to create a general
|
||||
`.lbrn2` parser. The focused task is to build a private, known-working fixture
|
||||
matrix and identify which galvo-specific fields are currently dropped.
|
||||
`.lbrn2` parser. The private known-working fixture matrix now identifies the
|
||||
vector MOPA fields and units; the focused remaining work is packaged GUI
|
||||
verification plus unsupported image and omitted-value behavior.
|
||||
|
||||
Likely gaps to test include:
|
||||
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@ operations continue to fail closed.
|
|||
- 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.
|
||||
- The LightBurn 2.1.03 import unit contract is now evidenced, but certified
|
||||
machine-specific bounds and physical behavior remain unknown and unguessed.
|
||||
- No hardware compatibility or safe marking behavior is established.
|
||||
|
||||
## Decision impact
|
||||
|
|
|
|||
|
|
@ -18,12 +18,13 @@ for JCZ use rather than receiving guessed defaults.
|
|||
|
||||
The external addon now supplies the narrow profile seam missing from Rayforge
|
||||
core. It recognizes only the explicit public synthetic profile identity,
|
||||
requires `JCZFiber`, positive work-area dimensions and
|
||||
`RequireFrameBeforeStart: true`, and associates the result only with
|
||||
`PirateRayJCZNoDeviceDriver`. Frequency and `QPulseWidth` remain raw
|
||||
observations with unverified serialized-unit conversion. Encoding remains
|
||||
blocked because the physical profile, lens/correction match, project
|
||||
serialization and machine-specific limits are not yet verified.
|
||||
requires LightBurn 2.1.03, `JCZFiber`, the observed 110 x 110 mm work area,
|
||||
Q-pulse-width enablement, fiber-type code 3, a valid 1-4000 kHz profile range,
|
||||
and `RequireFrameBeforeStart: true`. It associates the result only with
|
||||
`PirateRayJCZNoDeviceDriver`. Cut-list frequency is normalized from serialized
|
||||
Hz to display kHz and `QPulseWidth` remains ns. Encoding stays blocked because
|
||||
physical identity, lens/correction match, certified limits and promotion
|
||||
review are not verified.
|
||||
|
||||
The checked result is
|
||||
`tests/fixtures/rayforge-lightburn-device-import.json`. Two runs produced the
|
||||
|
|
@ -52,8 +53,8 @@ python scripts/spike_rayforge_lightburn_device_import.py C:\path\to\rayforge
|
|||
|
||||
## Next seam
|
||||
|
||||
The next extension is offline controller-list buffering and explicit safe
|
||||
state ordering. Cut-list recipes and project-layer `QPulseWidth` mapping remain
|
||||
gated until a known-working `.lbrn2` fixture establishes project serialization.
|
||||
Offline controller-list buffering and explicit state ordering are complete.
|
||||
The next software gate is packaged Windows GTK interaction. Image-layer
|
||||
equivalence and absent `QPulseWidth` remain blocked rather than defaulted.
|
||||
|
||||
No USB enumeration, controller command, motion, framing or emission occurred.
|
||||
|
|
|
|||
|
|
@ -12,14 +12,13 @@ square and preserved its layer name, geometry, 50 percent power, 250 mm/s
|
|||
speed and one pass. LightBurn's observed `frequency` and
|
||||
`overrideFrequency` elements also survive in the source asset's raw metadata.
|
||||
|
||||
However, the importer does not map frequency into the workflow layer settings.
|
||||
It maps only power, speed and passes from this fixture. The inspected Rayforge
|
||||
LightBurn project fixtures and importer contain no evidence-backed mapping for
|
||||
a MOPA pulse-width field. A later private LightBurn device-library export
|
||||
establishes the field name `QPulseWidth` and its nanosecond UI contract, but it
|
||||
does not establish how that value is serialized in a `.lbrn2` project.
|
||||
Consequently, the imported job is explicitly classified as unsafe for JCZ
|
||||
encoding rather than being completed with a guessed mapping.
|
||||
The pinned upstream importer does not map frequency into workflow settings; it
|
||||
maps only power, speed and passes from this fixture. The external addon now
|
||||
supplies that narrow mapping for LightBurn 2.1.03. Subsequent operator-verified
|
||||
evidence correlates raw `frequency=1000000` and `QPulseWidth=10` with visible
|
||||
`1000 kHz` and `10 ns`, establishing the version-bound Hz/ns serialization
|
||||
contract. The staged job nevertheless remains unsafe for JCZ encoding because
|
||||
machine, lens, correction and promotion review are still unresolved.
|
||||
|
||||
Two runs produced identical output with SHA-256
|
||||
`9ebce7b5b3388abfeed024e25f8f46001260bf814da19808bfc83fb6554fc26e`.
|
||||
|
|
@ -35,18 +34,18 @@ The golden result is `tests/fixtures/rayforge-lightburn-mopa-import.json`.
|
|||
uses `QPulseWidth`; official LightBurn documentation defines Q-pulse width
|
||||
in nanoseconds. See the
|
||||
[device-export analysis](lightburn-device-exports-2026-10-19.md).
|
||||
- **Unknown:** the exact `QPulseWidth` representation and omission/default
|
||||
behavior in a known-working galvo `.lbrn2` project.
|
||||
- **Observed after this spike:** three known-working galvo projects use the
|
||||
case-sensitive `QPulseWidth` field. Missing fields remain absent; their
|
||||
effective default is still unknown and is never invented.
|
||||
- **Not inferred:** no pulse-width tag, unit, default or machine limit was
|
||||
invented for the public fixture.
|
||||
|
||||
## Decision impact
|
||||
|
||||
Rayforge's existing LightBurn importer is useful for ordinary vector geometry
|
||||
and common process settings, but it does not yet satisfy M1's MOPA parameter
|
||||
survival requirement. The narrowest likely extension is an importer mapping
|
||||
for frequency plus `QPulseWidth`, followed by an explicit unit conversion and
|
||||
validation gate. A private known-working LightBurn project is still needed to
|
||||
establish project serialization before implementing that mapping.
|
||||
Rayforge's existing LightBurn importer remains useful for ordinary vector
|
||||
geometry and common process settings. The external addon now implements the
|
||||
narrow frequency/`QPulseWidth` mapping with a version- and evidence-bound unit
|
||||
contract, stages values on disabled workflow steps, and retains all blockers.
|
||||
Image equivalence, absent pulse width and physical compatibility remain gated.
|
||||
|
||||
No USB enumeration, controller command, motion or emission was attempted.
|
||||
|
|
|
|||
|
|
@ -66,11 +66,15 @@ It does **not** yet establish:
|
|||
- status, interlock, error, abort, and recovery behavior;
|
||||
- whether an apparently harmless query can move a galvo or arm emission;
|
||||
- the effective pulse-width default for settings that omit `QPulseWidth`.
|
||||
- safe controller discovery, state and recovery behavior beyond the offline
|
||||
mock evidence.
|
||||
|
||||
The later LightBurn export collection now establishes version `2.1.03`, a
|
||||
110 x 110 mm `JCZFiber`/`LibUSB` JPT profile, and the `QPulseWidth` device
|
||||
library field. See the
|
||||
[device-export analysis](research/lightburn-device-exports-2026-10-19.md).
|
||||
The later verified-project and live-UI collection establishes LightBurn 2.1.03
|
||||
project frequency as serialized Hz/displayed kHz and `QPulseWidth` as ns.
|
||||
|
||||
Consequently, treating a vendor DLL or opaque profile as a callable black box
|
||||
would be premature and would make the safety boundary impossible to audit.
|
||||
|
|
@ -154,11 +158,12 @@ and explicit state-before-motion ordering. Upload, execution and physical
|
|||
transport remain outside M1.
|
||||
|
||||
[The synthetic LightBurn MOPA import spike](research/rayforge-lightburn-mopa-import-spike.md)
|
||||
confirms that Rayforge preserves geometry, power, speed and passes. Three later
|
||||
operator-verified projects establish project-level `frequency` and
|
||||
`QPulseWidth` fields. The addon now stages complete vector settings into
|
||||
disabled Rayforge steps and rejects them at preflight until units, defaults and
|
||||
machine context are explicitly approved.
|
||||
confirms that Rayforge preserves geometry, power, speed and passes. Three
|
||||
operator-verified LightBurn 2.1.03 projects plus the running UI establish the
|
||||
serialized Hz/display-kHz relationship and direct ns `QPulseWidth` contract.
|
||||
The external addon maps complete vector settings into disabled workflow steps
|
||||
with exact evidence provenance. Missing pulse width, image equivalence and
|
||||
every hardware gate still fail closed.
|
||||
|
||||
ADR 0002 accepts the Rayforge-addon architecture with BalorMK as the pinned
|
||||
behavioral oracle. Packaged Windows interaction is a release gate, not an open
|
||||
|
|
@ -271,8 +276,8 @@ the M3 protocol draft are all present. Work now converges on two deliverables:
|
|||
1. produce the pinned Windows Rayforge checkpoint on a reviewed external build
|
||||
runner, verify the transferred artifact set and complete the no-device
|
||||
desktop interaction checklist;
|
||||
2. promote complete vector MOPA imports only after recording the explicit unit
|
||||
contract, absent-pulse-width policy and exact machine-context validation.
|
||||
2. promote complete vector MOPA imports only after an absent-pulse-width
|
||||
policy, image equivalence and exact machine-context validation.
|
||||
|
||||
Exterior photographs, additional screenshots and speculative protocol research
|
||||
are not M1 blockers. Hardware work remains separately gated by M3.
|
||||
|
|
@ -291,10 +296,10 @@ Rayforge preserves the common name and work-area fields, but maps no
|
|||
profile model. The fixture and regression test remain transport-free.
|
||||
|
||||
Item 3 now has a minimal external-addon seam: it recognizes only the synthetic
|
||||
`JCZFiber` fixture with explicit provenance and frame-before-start, binds it to
|
||||
the no-device JCZ driver, preserves process values as raw observations with
|
||||
unit uncertainty, and keeps encoding disabled until the unit/default and exact
|
||||
machine-context review passes.
|
||||
LightBurn 2.1.03 `JCZFiber` fixture with explicit provenance, the observed
|
||||
110 x 110 mm/Q-pulse/frequency/frame contract, and binds it to the no-device
|
||||
JCZ driver. Vector project values use the evidence-backed Hz/ns mapping, while
|
||||
encoding remains disabled pending physical compatibility and review.
|
||||
|
||||
Item 4 now covers transport-free fixed-size list buffering and an explicit
|
||||
state-before-motion order derived from pinned BalorMK. Port commands, upload,
|
||||
|
|
|
|||
|
|
@ -110,13 +110,17 @@ evidence for:
|
|||
|
||||
The remaining M1 release gates are deliberately narrow:
|
||||
|
||||
- vector project frequency and `QPulseWidth` now use a LightBurn 2.1.03,
|
||||
evidence-bound Hz/ns contract; absent pulse width and image equivalence stay
|
||||
blocked;
|
||||
- packaged Windows/GTK interaction is blocked until a reviewed Rayforge
|
||||
runtime with PyGObject and MSYS2/UCRT64 is available; the repository build
|
||||
entry point fetches pinned source but never installs that runtime;
|
||||
- three operator-verified projects establish project-level `frequency` and
|
||||
`QPulseWidth` serialization, and the offline bridge stages complete vector
|
||||
settings with output disabled; promotion still requires an explicit unit
|
||||
contract, absent-pulse-width policy and exact machine-context validation;
|
||||
settings with output disabled; promotion still requires an
|
||||
absent-pulse-width policy, image equivalence and exact machine-context
|
||||
validation;
|
||||
- graphical workflow, lens correction and controller behavior remain unproven
|
||||
at the physical boundary and belong to later gated milestones.
|
||||
|
||||
|
|
|
|||
|
|
@ -60,12 +60,18 @@ def run(source: Path, fixture: Path, addon: Path) -> dict[str, object]:
|
|||
"fixture": fixture.name,
|
||||
"fixture_provenance": "synthetic public data",
|
||||
"raw_profile": {
|
||||
"app_version": document["AppVersion"],
|
||||
"name": raw["DisplayName"],
|
||||
"type": raw["Type"],
|
||||
"work_area_mm": [raw["Width"], raw["Height"]],
|
||||
"require_frame_before_start": settings[
|
||||
"RequireFrameBeforeStart"
|
||||
],
|
||||
"q_pulse_width_enabled": settings["Laser_QPulseWidthEnabled"],
|
||||
"fiber_type_raw": settings["Laser_FiberType"],
|
||||
"frequency_ui_range_khz": [
|
||||
settings["Laser_MinFreq"], settings["Laser_MaxFreq"]
|
||||
],
|
||||
"cut_entry": {
|
||||
"frequency": cut_entry["frequency"],
|
||||
"QPulseWidth": cut_entry["QPulseWidth"],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ from typing import Any
|
|||
|
||||
DRIVER_NAME = "PirateRayJCZNoDeviceDriver"
|
||||
SYNTHETIC_PROFILE_ID = "synthetic-jpt-mopa-110-v1"
|
||||
EVIDENCE_APP_VERSION = "2.1.03"
|
||||
EVIDENCE_WORK_AREA_MM = (110, 110)
|
||||
EVIDENCE_FIBER_TYPE_RAW = 3
|
||||
EVIDENCE_FIBER_TYPE_UI_LABEL = "IPG_YLPM"
|
||||
FREQUENCY_HZ_PER_KHZ = 1000
|
||||
|
||||
|
||||
class JCZProfileImportError(ValueError):
|
||||
|
|
@ -24,9 +29,10 @@ def _is_positive_number(value: Any) -> bool:
|
|||
def import_lightburn_jcz_profile(document: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recognize the public synthetic profile without enabling encoding.
|
||||
|
||||
The returned process values remain serialized raw observations. This
|
||||
function does not infer LightBurn project fields, units, machine limits,
|
||||
lens correction, controller transport, or hardware compatibility.
|
||||
The returned process values use the independently observed LightBurn
|
||||
2.1.03 Hz/ns serialization contract. This function does not infer physical
|
||||
machine identity, safe operating limits, lens correction, controller
|
||||
transport, or hardware compatibility.
|
||||
"""
|
||||
|
||||
if document.get("FixtureProvenance") != (
|
||||
|
|
@ -35,6 +41,8 @@ def import_lightburn_jcz_profile(document: dict[str, Any]) -> dict[str, Any]:
|
|||
raise JCZProfileImportError("profile provenance is absent or unsupported")
|
||||
if document.get("SyntheticProfileId") != SYNTHETIC_PROFILE_ID:
|
||||
raise JCZProfileImportError("synthetic profile identity mismatch")
|
||||
if document.get("AppVersion") != EVIDENCE_APP_VERSION:
|
||||
raise JCZProfileImportError("unsupported LightBurn AppVersion")
|
||||
|
||||
devices = document.get("DeviceList")
|
||||
if not isinstance(devices, list) or len(devices) != 1:
|
||||
|
|
@ -47,12 +55,24 @@ def import_lightburn_jcz_profile(document: dict[str, Any]) -> dict[str, Any]:
|
|||
height = device.get("Height")
|
||||
if not all(_is_positive_number(value) for value in (width, height)):
|
||||
raise JCZProfileImportError("positive work-area dimensions are required")
|
||||
if (width, height) != EVIDENCE_WORK_AREA_MM:
|
||||
raise JCZProfileImportError("synthetic JPT profile must use 110 x 110 mm")
|
||||
|
||||
settings = device.get("Settings")
|
||||
if not isinstance(settings, dict) or settings.get(
|
||||
"RequireFrameBeforeStart"
|
||||
) is not True:
|
||||
raise JCZProfileImportError("RequireFrameBeforeStart must be explicitly true")
|
||||
if settings.get("Laser_QPulseWidthEnabled") is not True:
|
||||
raise JCZProfileImportError("Q-pulse-width control must be explicitly enabled")
|
||||
if settings.get("Laser_FiberType") != EVIDENCE_FIBER_TYPE_RAW:
|
||||
raise JCZProfileImportError("observed fiber-type code mismatch")
|
||||
min_frequency = settings.get("Laser_MinFreq")
|
||||
max_frequency = settings.get("Laser_MaxFreq")
|
||||
if not all(
|
||||
_is_positive_number(value) for value in (min_frequency, max_frequency)
|
||||
) or min_frequency > max_frequency:
|
||||
raise JCZProfileImportError("valid observed frequency range is required")
|
||||
|
||||
cut_list = device.get("CutList")
|
||||
if not isinstance(cut_list, list) or not cut_list:
|
||||
|
|
@ -70,17 +90,24 @@ def import_lightburn_jcz_profile(document: dict[str, Any]) -> dict[str, Any]:
|
|||
raise JCZProfileImportError(
|
||||
"frequency and QPulseWidth must be positive raw values"
|
||||
)
|
||||
frequency_khz = frequency / FREQUENCY_HZ_PER_KHZ
|
||||
if not min_frequency <= frequency_khz <= max_frequency:
|
||||
raise JCZProfileImportError(
|
||||
"cut-list frequency is outside the observed profile range"
|
||||
)
|
||||
preserved_entries.append(
|
||||
{
|
||||
"name": entry.get("Name", ""),
|
||||
"frequency": {
|
||||
"raw_value": frequency,
|
||||
"unit_status": "serialized-unit-conversion-unverified",
|
||||
"serialized_unit": "Hz",
|
||||
"value_hz": frequency,
|
||||
"value_khz": frequency_khz,
|
||||
},
|
||||
"q_pulse_width": {
|
||||
"raw_value": pulse_width,
|
||||
"ui_unit_contract": "nanoseconds",
|
||||
"serialized_unit_conversion": "unverified",
|
||||
"serialized_unit": "ns",
|
||||
"value_ns": pulse_width,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -91,20 +118,31 @@ def import_lightburn_jcz_profile(document: dict[str, Any]) -> dict[str, Any]:
|
|||
"transport": None,
|
||||
"source": {
|
||||
"format": "LightBurn synthetic .lbdev",
|
||||
"app_version": document["AppVersion"],
|
||||
"provenance": document["FixtureProvenance"],
|
||||
"backend": "JCZFiber",
|
||||
},
|
||||
"work_area_mm": [float(width), float(height)],
|
||||
"safety": {"require_frame_before_start": True},
|
||||
"observed_device_contract": {
|
||||
"q_pulse_width_enabled": True,
|
||||
"frequency_ui_range_khz": [min_frequency, max_frequency],
|
||||
"frequency_serialized_unit": "Hz",
|
||||
"q_pulse_width_serialized_unit": "ns",
|
||||
"fiber_type_raw": EVIDENCE_FIBER_TYPE_RAW,
|
||||
"fiber_type_ui_label": EVIDENCE_FIBER_TYPE_UI_LABEL,
|
||||
"fiber_type_identity_status": "label-is-not-manufacturer-identity",
|
||||
},
|
||||
"process_metadata": {
|
||||
"status": "preserved-raw-not-mapped",
|
||||
"status": "normalized-offline-not-authorized",
|
||||
"cut_list": preserved_entries,
|
||||
},
|
||||
"encoding_allowed": False,
|
||||
"blockers": [
|
||||
"synthetic profile is not physical machine identity",
|
||||
"lens and correction profile are unverified",
|
||||
"LightBurn project QPulseWidth serialization is unverified",
|
||||
"machine-specific parameter limits are unverified",
|
||||
"IPG_YLPM UI label is not manufacturer identity",
|
||||
"observed profile ranges are not certified safe machine limits",
|
||||
"image settings with absent QPulseWidth remain unresolved",
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,19 @@ import xml.etree.ElementTree as ET
|
|||
|
||||
|
||||
UNIT_CONTRACT = "frequency=Hz;QPulseWidth=ns"
|
||||
UNIT_CONTRACT_APP_VERSION = "2.1.03"
|
||||
UNIT_CONTRACT_EVIDENCE = {
|
||||
"status": "operator-verified-ui-project-correlation",
|
||||
"lightburn_app_version": UNIT_CONTRACT_APP_VERSION,
|
||||
"frequency_serialized_unit": "Hz",
|
||||
"frequency_ui_unit": "kHz",
|
||||
"frequency_serialized_units_per_ui_unit": 1000,
|
||||
"q_pulse_width_serialized_unit": "ns",
|
||||
"q_pulse_width_ui_unit": "ns",
|
||||
"analysis_ref": (
|
||||
"docs/research/lightburn-control-machine-observation-2026-10-24.md"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ProjectImportError(ValueError):
|
||||
|
|
@ -44,9 +57,10 @@ def _layer_id(element: ET.Element) -> str | None:
|
|||
def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dict:
|
||||
"""Preserve observed XML fields and optionally normalize declared units.
|
||||
|
||||
The caller must supply an independently established serialized-unit contract.
|
||||
No unit is inferred from magnitude. Missing pulse width is never defaulted.
|
||||
Results are offline metadata, not executable Rayforge workflow settings.
|
||||
The caller must opt into the independently established LightBurn 2.1.03
|
||||
serialized-unit contract. No unit is inferred from magnitude. Missing pulse
|
||||
width is never defaulted. Results are offline metadata, not executable
|
||||
Rayforge workflow settings.
|
||||
"""
|
||||
if unit_contract not in (None, UNIT_CONTRACT):
|
||||
raise ProjectImportError("unsupported serialized-unit contract")
|
||||
|
|
@ -62,6 +76,12 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
|
|||
raise ProjectImportError("invalid project XML") from exc
|
||||
if root.tag != "LightBurnProject" or root.get("FormatVersion") != "1":
|
||||
raise ProjectImportError("unsupported project format")
|
||||
app_version = root.get("AppVersion")
|
||||
if unit_contract is not None and app_version != UNIT_CONTRACT_APP_VERSION:
|
||||
raise ProjectImportError(
|
||||
"unit contract requires LightBurn AppVersion "
|
||||
+ UNIT_CONTRACT_APP_VERSION
|
||||
)
|
||||
layers = []
|
||||
layer_ids: set[str] = set()
|
||||
for setting in root:
|
||||
|
|
@ -102,7 +122,10 @@ def import_project_mopa(data: bytes, *, unit_contract: str | None = None) -> dic
|
|||
"normalized": normalized, "blockers": blockers})
|
||||
if not layers:
|
||||
raise ProjectImportError("project has no process settings")
|
||||
return {"layers": layers, "unit_contract": unit_contract, "transport": None,
|
||||
evidence = dict(UNIT_CONTRACT_EVIDENCE) if unit_contract else None
|
||||
return {"layers": layers, "unit_contract": unit_contract,
|
||||
"unit_contract_evidence": evidence,
|
||||
"lightburn_app_version": app_version, "transport": None,
|
||||
"encoding_allowed": False, "hardware_use_allowed": False,
|
||||
"blockers": ["machine identity, correction and limits unverified",
|
||||
"offline import seam is not wired to host workflow"]}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from collections.abc import Mapping
|
|||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from .project_import import UNIT_CONTRACT, UNIT_CONTRACT_EVIDENCE
|
||||
|
||||
|
||||
METADATA_KEY = "pirateray_import"
|
||||
|
||||
|
|
@ -59,6 +61,8 @@ def stage_mopa_workflow_steps(
|
|||
isinstance(item, str) and item for item in global_blockers
|
||||
):
|
||||
raise WorkflowImportError("global blockers are missing")
|
||||
unit_contract = imported.get("unit_contract")
|
||||
unit_contract_evidence = imported.get("unit_contract_evidence")
|
||||
|
||||
plans = []
|
||||
step_identities: set[int] = set()
|
||||
|
|
@ -86,6 +90,10 @@ def stage_mopa_workflow_steps(
|
|||
apply_values = normalized is not None and not layer_blockers
|
||||
frequency = pulse_width = None
|
||||
if apply_values:
|
||||
if unit_contract != UNIT_CONTRACT:
|
||||
raise WorkflowImportError("normalized units lack the supported contract")
|
||||
if unit_contract_evidence != UNIT_CONTRACT_EVIDENCE:
|
||||
raise WorkflowImportError("normalized units lack exact evidence provenance")
|
||||
if not callable(getattr(step, "set_frequency", None)) or not callable(
|
||||
getattr(step, "set_pulse_width", None)
|
||||
):
|
||||
|
|
@ -114,6 +122,8 @@ def stage_mopa_workflow_steps(
|
|||
"source_layer_id": layer_id,
|
||||
"raw": deepcopy(layer.get("raw")),
|
||||
"normalized": deepcopy(layer.get("normalized")),
|
||||
"unit_contract": unit_contract,
|
||||
"unit_contract_evidence": deepcopy(unit_contract_evidence),
|
||||
"blockers": blockers,
|
||||
"encoding_allowed": False,
|
||||
"hardware_use_allowed": False,
|
||||
|
|
|
|||
|
|
@ -3,33 +3,49 @@
|
|||
"blockers": [
|
||||
"synthetic profile is not physical machine identity",
|
||||
"lens and correction profile are unverified",
|
||||
"LightBurn project QPulseWidth serialization is unverified",
|
||||
"machine-specific parameter limits are unverified"
|
||||
"IPG_YLPM UI label is not manufacturer identity",
|
||||
"observed profile ranges are not certified safe machine limits",
|
||||
"image settings with absent QPulseWidth remain unresolved"
|
||||
],
|
||||
"driver": "PirateRayJCZNoDeviceDriver",
|
||||
"encoding_allowed": false,
|
||||
"observed_device_contract": {
|
||||
"fiber_type_identity_status": "label-is-not-manufacturer-identity",
|
||||
"fiber_type_raw": 3,
|
||||
"fiber_type_ui_label": "IPG_YLPM",
|
||||
"frequency_serialized_unit": "Hz",
|
||||
"frequency_ui_range_khz": [
|
||||
1,
|
||||
4000
|
||||
],
|
||||
"q_pulse_width_enabled": true,
|
||||
"q_pulse_width_serialized_unit": "ns"
|
||||
},
|
||||
"process_metadata": {
|
||||
"cut_list": [
|
||||
{
|
||||
"frequency": {
|
||||
"raw_value": 40000,
|
||||
"unit_status": "serialized-unit-conversion-unverified"
|
||||
"serialized_unit": "Hz",
|
||||
"value_hz": 40000,
|
||||
"value_khz": 40.0
|
||||
},
|
||||
"name": "Synthetic MOPA entry",
|
||||
"q_pulse_width": {
|
||||
"raw_value": 100,
|
||||
"serialized_unit_conversion": "unverified",
|
||||
"ui_unit_contract": "nanoseconds"
|
||||
"serialized_unit": "ns",
|
||||
"value_ns": 100
|
||||
}
|
||||
}
|
||||
],
|
||||
"status": "preserved-raw-not-mapped"
|
||||
"status": "normalized-offline-not-authorized"
|
||||
},
|
||||
"profile_id": "synthetic-jpt-mopa-110-v1",
|
||||
"safety": {
|
||||
"require_frame_before_start": true
|
||||
},
|
||||
"source": {
|
||||
"app_version": "2.1.03",
|
||||
"backend": "JCZFiber",
|
||||
"format": "LightBurn synthetic .lbdev",
|
||||
"provenance": "synthetic public data; not a machine export"
|
||||
|
|
@ -61,11 +77,18 @@
|
|||
]
|
||||
},
|
||||
"raw_profile": {
|
||||
"app_version": "2.1.03",
|
||||
"cut_entry": {
|
||||
"QPulseWidth": 100,
|
||||
"frequency": 40000
|
||||
},
|
||||
"fiber_type_raw": 3,
|
||||
"frequency_ui_range_khz": [
|
||||
1,
|
||||
4000
|
||||
],
|
||||
"name": "Synthetic JCZ MOPA",
|
||||
"q_pulse_width_enabled": true,
|
||||
"require_frame_before_start": true,
|
||||
"type": "JCZFiber",
|
||||
"work_area_mm": [
|
||||
|
|
|
|||
7
tests/fixtures/synthetic-jcz-device.lbdev
vendored
7
tests/fixtures/synthetic-jcz-device.lbdev
vendored
|
|
@ -1,6 +1,7 @@
|
|||
{
|
||||
"FixtureProvenance": "synthetic public data; not a machine export",
|
||||
"SyntheticProfileId": "synthetic-jpt-mopa-110-v1",
|
||||
"AppVersion": "2.1.03",
|
||||
"DeviceList": [
|
||||
{
|
||||
"DisplayName": "Synthetic JCZ MOPA",
|
||||
|
|
@ -10,7 +11,11 @@
|
|||
"Height": 110,
|
||||
"HomeOnStartup": false,
|
||||
"Settings": {
|
||||
"RequireFrameBeforeStart": true
|
||||
"RequireFrameBeforeStart": true,
|
||||
"Laser_QPulseWidthEnabled": true,
|
||||
"Laser_FiberType": 3,
|
||||
"Laser_MinFreq": 1,
|
||||
"Laser_MaxFreq": 4000
|
||||
},
|
||||
"CutList": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,12 +4,14 @@ from pathlib import Path
|
|||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1] / "spikes/rayforge-jcz-no-device-addon"))
|
||||
from pirate_ray_jcz.project_import import (
|
||||
ProjectImportError, UNIT_CONTRACT, import_project_mopa,
|
||||
ProjectImportError, UNIT_CONTRACT, UNIT_CONTRACT_EVIDENCE,
|
||||
import_project_mopa,
|
||||
)
|
||||
|
||||
|
||||
def project(fields, tag="CutSetting", kind="Scan", layer_id="0"):
|
||||
return (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">'
|
||||
return (f'<LightBurnProject AppVersion="2.1.03" FormatVersion="1">'
|
||||
f'<{tag} type="{kind}">'
|
||||
f'<index Value="{layer_id}"/>{fields}</{tag}></LightBurnProject>').encode()
|
||||
|
||||
|
||||
|
|
@ -22,6 +24,8 @@ class ProjectMopaTests(unittest.TestCase):
|
|||
self.assertEqual(result["layers"][0]["normalized"], {
|
||||
"frequency_hz": 125000, "frequency_khz": 125, "pulse_width_ns": 25,
|
||||
})
|
||||
self.assertEqual(result["unit_contract_evidence"], UNIT_CONTRACT_EVIDENCE)
|
||||
self.assertEqual(result["lightburn_app_version"], "2.1.03")
|
||||
self.assertFalse(result["encoding_allowed"])
|
||||
self.assertFalse(result["hardware_use_allowed"])
|
||||
self.assertIsNone(result["transport"])
|
||||
|
|
@ -36,6 +40,23 @@ class ProjectMopaTests(unittest.TestCase):
|
|||
with self.assertRaises(ProjectImportError):
|
||||
import_project_mopa(project(FIELDS), unit_contract="kHz/us")
|
||||
|
||||
def test_contract_is_version_bound_and_matches_verified_ui_correlation(self):
|
||||
verified = '<frequency Value="1000000"/><QPulseWidth Value="10"/>'
|
||||
layer = import_project_mopa(
|
||||
project(verified), unit_contract=UNIT_CONTRACT
|
||||
)["layers"][0]
|
||||
self.assertEqual(layer["normalized"], {
|
||||
"frequency_hz": 1000000,
|
||||
"frequency_khz": 1000,
|
||||
"pulse_width_ns": 10,
|
||||
})
|
||||
for app_version in ("2.0.00", ""):
|
||||
data = project(verified).replace(b'AppVersion="2.1.03"',
|
||||
f'AppVersion="{app_version}"'.encode())
|
||||
with self.subTest(app_version=app_version):
|
||||
with self.assertRaisesRegex(ProjectImportError, "AppVersion 2.1.03"):
|
||||
import_project_mopa(data, unit_contract=UNIT_CONTRACT)
|
||||
|
||||
def test_missing_fields_remain_absent(self):
|
||||
for fields in ('<frequency Value="125000"/>', '<QPulseWidth Value="25"/>', ''):
|
||||
with self.subTest(fields=fields):
|
||||
|
|
@ -73,7 +94,7 @@ class ProjectMopaTests(unittest.TestCase):
|
|||
def test_layer_indices_are_normalized_and_must_be_unique(self):
|
||||
layer = import_project_mopa(project(FIELDS, layer_id="007"))["layers"][0]
|
||||
self.assertEqual(layer["source_layer_id"], "7")
|
||||
duplicate = ('<LightBurnProject FormatVersion="1">'
|
||||
duplicate = ('<LightBurnProject AppVersion="2.1.03" FormatVersion="1">'
|
||||
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||
'<CutSetting type="Scan"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||
'</LightBurnProject>').encode()
|
||||
|
|
@ -81,7 +102,7 @@ class ProjectMopaTests(unittest.TestCase):
|
|||
import_project_mopa(duplicate)
|
||||
|
||||
def test_multiple_layers_preserve_order_and_missing_pulse(self):
|
||||
data = ('<LightBurnProject FormatVersion="1">'
|
||||
data = ('<LightBurnProject AppVersion="2.1.03" FormatVersion="1">'
|
||||
'<CutSetting type="Cut"><index Value="0"/>'+FIELDS+'</CutSetting>'
|
||||
'<CutSetting_Img type="Image"><index Value="1"/><frequency Value="125000"/>'
|
||||
'</CutSetting_Img></LightBurnProject>').encode()
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ class Step:
|
|||
|
||||
|
||||
def imported(fields, layer_id="3", kind="Cut", tag="CutSetting"):
|
||||
data = (f'<LightBurnProject FormatVersion="1"><{tag} type="{kind}">'
|
||||
data = (f'<LightBurnProject AppVersion="2.1.03" FormatVersion="1">'
|
||||
f'<{tag} type="{kind}">'
|
||||
f'<index Value="{layer_id}"/>{fields}</{tag}></LightBurnProject>').encode()
|
||||
return import_project_mopa(data, unit_contract=UNIT_CONTRACT)
|
||||
|
||||
|
|
@ -50,6 +51,10 @@ class WorkflowImportTests(unittest.TestCase):
|
|||
self.assertEqual(gate["status"], "staged-review-required")
|
||||
self.assertFalse(gate["encoding_allowed"])
|
||||
self.assertTrue(gate["blockers"])
|
||||
self.assertEqual(gate["unit_contract"], UNIT_CONTRACT)
|
||||
self.assertEqual(
|
||||
gate["unit_contract_evidence"], imported(FIELDS)["unit_contract_evidence"]
|
||||
)
|
||||
|
||||
def test_missing_pulse_and_image_mode_do_not_apply_parameters(self):
|
||||
for result in (
|
||||
|
|
@ -74,7 +79,7 @@ class WorkflowImportTests(unittest.TestCase):
|
|||
stage_mopa_workflow_steps(imported(FIELDS), {"3": step})
|
||||
|
||||
two_layers = import_project_mopa(
|
||||
('<LightBurnProject FormatVersion="1">'
|
||||
('<LightBurnProject AppVersion="2.1.03" FormatVersion="1">'
|
||||
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
|
||||
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
|
||||
|
|
@ -84,7 +89,7 @@ class WorkflowImportTests(unittest.TestCase):
|
|||
|
||||
def test_preflight_failure_does_not_mutate_any_step(self):
|
||||
result = import_project_mopa(
|
||||
('<LightBurnProject FormatVersion="1">'
|
||||
('<LightBurnProject AppVersion="2.1.03" FormatVersion="1">'
|
||||
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
|
||||
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
|
||||
|
|
@ -94,9 +99,19 @@ class WorkflowImportTests(unittest.TestCase):
|
|||
self.assertTrue(first.visible)
|
||||
self.assertEqual((first.frequency, first.pulse_width), (1, 2))
|
||||
|
||||
def test_tampered_unit_evidence_is_rejected_before_mutation(self):
|
||||
result = imported(FIELDS)
|
||||
result["unit_contract_evidence"]["frequency_ui_unit"] = "MHz"
|
||||
step = Step()
|
||||
with self.assertRaisesRegex(WorkflowImportError, "exact evidence provenance"):
|
||||
stage_mopa_workflow_steps(result, {"3": step})
|
||||
self.assertTrue(step.visible)
|
||||
self.assertEqual((step.frequency, step.pulse_width), (1, 2))
|
||||
self.assertEqual(step.extra, {"preserved": True})
|
||||
|
||||
def test_setter_failure_rolls_back_every_step(self):
|
||||
result = import_project_mopa(
|
||||
('<LightBurnProject FormatVersion="1">'
|
||||
('<LightBurnProject AppVersion="2.1.03" FormatVersion="1">'
|
||||
'<CutSetting type="Cut"><index Value="1"/>'+FIELDS+'</CutSetting>'
|
||||
'<CutSetting type="Cut"><index Value="2"/>'+FIELDS+'</CutSetting>'
|
||||
'</LightBurnProject>').encode(), unit_contract=UNIT_CONTRACT)
|
||||
|
|
|
|||
|
|
@ -54,11 +54,20 @@ class RayforgeLightBurnDeviceImportSpikeTests(unittest.TestCase):
|
|||
self.assertEqual("PirateRayJCZNoDeviceDriver", seam["driver"])
|
||||
self.assertIsNone(seam["transport"])
|
||||
self.assertFalse(seam["encoding_allowed"])
|
||||
self.assertEqual("preserved-raw-not-mapped", seam["process_metadata"]["status"])
|
||||
self.assertEqual("normalized-offline-not-authorized", seam["process_metadata"]["status"])
|
||||
entry = seam["process_metadata"]["cut_list"][0]
|
||||
self.assertEqual(40000, entry["frequency"]["raw_value"])
|
||||
self.assertEqual(40000, entry["frequency"]["value_hz"])
|
||||
self.assertEqual(40, entry["frequency"]["value_khz"])
|
||||
self.assertEqual(100, entry["q_pulse_width"]["raw_value"])
|
||||
self.assertEqual("unverified", entry["q_pulse_width"]["serialized_unit_conversion"])
|
||||
self.assertEqual(100, entry["q_pulse_width"]["value_ns"])
|
||||
contract = seam["observed_device_contract"]
|
||||
self.assertEqual([1, 4000], contract["frequency_ui_range_khz"])
|
||||
self.assertEqual("IPG_YLPM", contract["fiber_type_ui_label"])
|
||||
self.assertEqual(
|
||||
"label-is-not-manufacturer-identity",
|
||||
contract["fiber_type_identity_status"],
|
||||
)
|
||||
|
||||
def test_addon_seam_rejects_wrong_backend_and_missing_frame_gate(self):
|
||||
source = json.loads(SOURCE.read_text(encoding="utf-8"))
|
||||
|
|
@ -86,6 +95,32 @@ class RayforgeLightBurnDeviceImportSpikeTests(unittest.TestCase):
|
|||
with self.assertRaises(JCZProfileImportError):
|
||||
import_lightburn_jcz_profile(ambiguous_frequency)
|
||||
|
||||
for frequency in (500, 4_001_000):
|
||||
outside_range = deepcopy(source)
|
||||
outside_range["DeviceList"][0]["CutList"][0]["frequency"] = frequency
|
||||
with self.subTest(frequency=frequency):
|
||||
with self.assertRaisesRegex(JCZProfileImportError, "outside"):
|
||||
import_lightburn_jcz_profile(outside_range)
|
||||
|
||||
def test_observed_profile_contract_fails_closed_on_mismatch(self):
|
||||
source = json.loads(SOURCE.read_text(encoding="utf-8"))
|
||||
mutations = (
|
||||
("wrong version", lambda doc: doc.update(AppVersion="2.2.00")),
|
||||
("wrong width", lambda doc: doc["DeviceList"][0].update(Width=300)),
|
||||
("q pulse disabled", lambda doc: doc["DeviceList"][0]["Settings"].update(
|
||||
Laser_QPulseWidthEnabled=False)),
|
||||
("wrong fiber code", lambda doc: doc["DeviceList"][0]["Settings"].update(
|
||||
Laser_FiberType=2)),
|
||||
("reversed frequency range", lambda doc: doc["DeviceList"][0]["Settings"].update(
|
||||
Laser_MinFreq=4000, Laser_MaxFreq=1)),
|
||||
)
|
||||
for label, mutate in mutations:
|
||||
candidate = deepcopy(source)
|
||||
mutate(candidate)
|
||||
with self.subTest(label=label):
|
||||
with self.assertRaises(JCZProfileImportError):
|
||||
import_lightburn_jcz_profile(candidate)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue