mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
Prove the density-only model; record the rasters and the decision
The stream harness keeps the analog rendering as the host-test conservatism reference (rule 13's mask, the duty ladders) and drops the M101 switch sessions; rule 18 stays as the derived-floor proof, now satisfied from boot by the precompute. The lifecycle harness's state-files scenario asserts the derived floor is in $$ before any arm. The catalog's laser.power-model-switch goes and laser.power-floor reads the one floor key with no M-code needed. The mswitch drill goes; the m4corner drill becomes a single density pass; the dpatch drill returns to density only. The CAMPAIGN-LOG records the first rasters (254 and 508 DPI grayscale wedges: tonality held to ~14 pulse slots per pixel, no dither artifact, one benign stale-verdict suppression under CPU starvation) and the decision that ends the analog mode - the strike transient fires a spot at every beam-on, and the finish comparison found no advantage. BRINGUP, LIGHTBURN, MOTION and SAFETY describe the density-only present.
This commit is contained in:
+43
-63
@@ -206,69 +206,48 @@ of the first tick byte it covers, FIRE as bit 4 OR'd into tick bytes. The
|
||||
spindle PWM is precomputed to a period of exactly 127, so computed values ARE
|
||||
power bytes (`$30` default 1000 → S1000 = 127).
|
||||
|
||||
**Dose model.** Two models render the per-segment value the core computes:
|
||||
`density` (the default) pins the duty at full and modulates the FIRE bit,
|
||||
`analog` ships the value as a power byte instead. Density uses a base
|
||||
period of `laser_pulse_ticks` (default 20 = 710 us at 28160 Hz, the
|
||||
factory's ~1.43 kHz) whose on-count is dithered between adjacent integers
|
||||
with the remainder carried, so densities finer than one tick per period
|
||||
average out. Density is what the tube's dead band below its lasing
|
||||
threshold requires: every pulse it emits is full-power, so no commanded
|
||||
level lands in the band, and a level change inside a run costs no stream
|
||||
byte at all. `laser_pulse_min_ticks` (default 3 = 106 us) is the shortest
|
||||
pulse it will emit: below it a period is skipped and its debt carried, so a
|
||||
faint level arrives as fewer full-width pulses instead of stubs the supply
|
||||
cannot strike - measured on the bench, a 36 us stub draws no discharge at
|
||||
all, and the factory never emits below one of its 100 us ticks. The debt is
|
||||
conserved, so the average density is unchanged: at level 2 the stream goes
|
||||
from 444 one-tick bursts to 147 three-tick bursts, same density to four
|
||||
decimals. Structurally the model is a mask on the core's fire state and
|
||||
never a source of one, so emission stays exactly where the core commanded
|
||||
it.
|
||||
**Dose model: density, the only one.** The shipper renders the
|
||||
per-segment value the core computes by pinning the duty at full and
|
||||
modulating the FIRE bit on a base period of `laser_pulse_ticks` (default
|
||||
20 = 710 us at 28160 Hz, the factory's ~1.43 kHz) whose on-count is
|
||||
dithered between adjacent integers with the remainder carried, so
|
||||
densities finer than one tick per period average out. Every pulse is
|
||||
full-power, so no commanded level can land in the tube's dead band, and
|
||||
no beam-on ever dwells: the analog alternative (duty as the power byte,
|
||||
continuous FIRE) fires the strike transient as a visible spot at every
|
||||
turn-on whatever the power, and was removed as a product mode for it -
|
||||
the rendering survives only as the host harness's conservatism
|
||||
reference, selectable solely in the null-sink build.
|
||||
`laser_pulse_min_ticks` (default 3 = 106 us) is the shortest pulse the
|
||||
model emits: below it a period is skipped and its debt carried, so a
|
||||
faint level arrives as fewer full-width pulses instead of stubs the
|
||||
supply cannot strike - measured on the bench, a 36 us stub draws no
|
||||
discharge at all, and the factory never emits below one of its 100 us
|
||||
ticks. The debt is conserved, so the average density is unchanged: at
|
||||
level 2 the stream goes from 444 one-tick bursts to 147 three-tick
|
||||
bursts, same density to four decimals. Structurally the model is a mask
|
||||
on the core's fire state and never a source of one, so emission stays
|
||||
exactly where the core commanded it.
|
||||
|
||||
**Selecting the model.** `laser_power_model` in the shared machine config
|
||||
(the control panel's GRBL tab) is the boot default. A job selects its own
|
||||
with the driver M-code `M101 P0` (analog) or `M101 P1` (density), sent with
|
||||
the spindle off: the switch is refused (`error:253`, reason reported) with
|
||||
the spindle commanded on or the controller not idle, because a model change
|
||||
under fire could pair density's pinned full duty with analog's continuous
|
||||
FIRE; between kernel runs, which end dark and lead with a power byte, there
|
||||
is no torn state to reach. The planner drains before the switch, the armed
|
||||
window stays open across it, so `M5` / `M101` / `M3` inside a job switches
|
||||
models between its sections with no new press. The switch is
|
||||
program-scoped: it reverts to the boot default at `M2`/`M30` and on a soft
|
||||
reset, so a job header can declare the model it needs without leaving the
|
||||
machine in it; `M101 P<n> Q1` sticks until the next switch or a controller
|
||||
restart. The stream leads the first run after a switch dark (duty 0) until
|
||||
the commanded power lands, so neither model's number is ever shipped under
|
||||
the other.
|
||||
|
||||
**The floor is derived, never typed.** Each model has an S-range floor as a
|
||||
config key: `laser_floor_density` (default 10, the lowest density that
|
||||
still marks, putting a commanded 1 % at 10.2 % density) and
|
||||
`laser_floor_analog` (default 16, the duty this tube lases at; 3 to 14 % is
|
||||
a dead band). At every arm and every switch the controller loads the
|
||||
selected model's floor into `$35` in RAM and re-precomputes the PWM
|
||||
mapping; the stored `$35` is never written, `$$` reports the floor in
|
||||
force, and a `$35` typed by a sender is overwritten at the next arm. The
|
||||
arm and switch reports name both (`laser armed (density, floor 10 %)`,
|
||||
`laser power model set for this program (analog, floor 16 %)`), and a floor
|
||||
of 0 is honored with a note (the ladders run that way). The cooling report
|
||||
carries the model in force (`model=` on `POST /cool/state`) so the engine's
|
||||
tube-heat share follows an `M101` as well as the default.
|
||||
**The floor is derived, never typed.** `laser_floor_density` (default 10,
|
||||
the lowest density that still marks, putting a commanded 1 % at 10.2 %
|
||||
density) is loaded into `$35` at every spindle precompute - boot
|
||||
included - in RAM only: the stored `$35` is never written, `$$` reports
|
||||
the floor in force, and a `$35` typed by a sender is overwritten on the
|
||||
spot. The arm report names it (`laser armed (density, floor 10 %)`), and
|
||||
a floor of 0 is honored with a note (the ladders run that way). The
|
||||
cooling report carries the model with the job state.
|
||||
|
||||
**Measured dose response (this bench, 2026-08-30, by the head thermopile,
|
||||
the tube current and the operator's eye on Thick Draftboard and acrylic).**
|
||||
Density delivers about half of the CW light at 80 % density, a third at
|
||||
60 %, a fifth at 45 % and a fourteenth at 30 %: the curve is the tube's
|
||||
(pulsed against CW), not the sensor's, and it is the same physics behind the
|
||||
factory's 18.9 to 79.5 % mapping with Full Power kept apart. Analog is
|
||||
close to linear above 30 % duty (0.82 / 0.68 / 0.54 / 0.37 of CW at 80 /
|
||||
60 / 45 / 30 % duty, the tube current equal to the duty) with the lasing
|
||||
knee at 20 to 23 % duty below that. The finish on acrylic is the same under
|
||||
both models, and the only visible pattern is mechanical (present under CW
|
||||
too), so the models differ in their S scale, not their mark. A per-model S
|
||||
correction (E4 in the working file) is the open item that follows.
|
||||
(pulsed against CW), not the sensor's, and it is the same physics behind
|
||||
the factory's 18.9 to 79.5 % mapping with Full Power kept apart. An S
|
||||
correction from that curve (E4 in the working file) is the open item that
|
||||
follows. Rasters hold their tonality down to ~14 pulse slots per pixel
|
||||
(508 DPI at 6000 mm/min): the dither accumulator's cross-pixel averaging
|
||||
recovers the levels, with no visible dither pattern.
|
||||
|
||||
An S word takes effect whether or not motion is in progress. Per-segment
|
||||
updates carry the level inside a laser block, but an S executed between
|
||||
@@ -319,7 +298,7 @@ on a clean re-check; FAULT leaves the hold and the gate for the operator.
|
||||
never open the Grbl socket (a connection displaces the sender), so the
|
||||
controller writes two files under `/run/forgefirm`, atomically, on
|
||||
edges: `grbl.settings` (the `$$` view, rewritten on every setting
|
||||
change, the M101 switch included) and `grbl.state` (JSON: machine state
|
||||
change and whenever the derived floor moves) and `grbl.state` (JSON: machine state
|
||||
and alarm, the sender session with peer and generation, the laser's
|
||||
armed window and dose model with its floor, the exact `[GC:...]` modal
|
||||
report, overrides, driver version, `ts_mono` for age; on change plus a
|
||||
@@ -1359,11 +1338,12 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
|
||||
|
||||
**The defaults are flipped:** `laser_power_model` defaults to `density`
|
||||
with a 10 % floor (`laser_floor_density`), so a stock machine runs the
|
||||
model and a commanded 1 % marks. The analog path remains as
|
||||
`laser_power_model = analog` or `M101 P0` in a job, with its own floor
|
||||
key (`laser_floor_analog`, 16); the controller derives `$35` from the
|
||||
selected model's key at every arm and switch, so no floor is typed and
|
||||
no mismatch exists ("Laser control (GRBL mode)" above).
|
||||
model and a commanded 1 % marks. The analog rendering is not a
|
||||
product mode (it fires the strike transient as a spot at every
|
||||
beam-on; removed 2026-08-30) and survives only as the host harness's
|
||||
conservatism reference; the controller derives `$35` from
|
||||
`laser_floor_density` at every precompute, so no floor is typed
|
||||
("Laser control (GRBL mode)" above).
|
||||
|
||||
Owed: validation at production feeds. Every ladder behind these
|
||||
defaults ran at F300 or F100, where dose per millimeter is generous and
|
||||
|
||||
@@ -6002,6 +6002,20 @@ Built the same evening, host-proven: the controller's published state (the C6 ga
|
||||
|
||||
Bench-proven the same day, one armed run of the new `mswitch` drill on the hot-installed cross-built binaries (forgectrl md5 4dc4677a, grblHAL_glowforge 17e5502f, operator-run install): the arm reported "laser armed (density, floor 10 %)", `M5` then `M101 P0` answered ok and reported "laser power model set for this program (analog, floor 16 %)", `$$` read `$35=16` while analog was in force and `$35=10` again after `M2` reported the revert, the armed window carried across the switch with no re-prompt, and the 25 Hz trace showed exactly two discharge segments, the density line pulsed (hv mean 390, max-mean 547) and the analog line steady (mean 567, max-mean 28), dark after the second `M5` (hv max 0). Board cleaned; the hot-deployed binaries stay until the next flash, `/tmp/*.prev` is the rollback.
|
||||
|
||||
### M4 into corners under both models, no dropouts, 2026-08-30
|
||||
|
||||
Image 20260830200842 (fresh flash; the C6 state files verified live on first boot, the sender field exercised end to end). The `m4corner` drill, one armed run: a corner-heavy pattern (10 mm line, 1.2 mm teeth, 2 mm square, 5 mm reversal, 0.5 mm teeth) at S300 F2000 under M4, cut under density and again under analog via `M101 P0`, passes offset 8 mm (`bench-data/m4corner_20260830.log`). Operator: no unmarked commanded segment on either pass - the derived floors close the analog dead-band dropout, and density cannot reach one. Artifacts as the curves predicted: analog scorches the line start and the square corners (floor 16 plus M4 dwell) and reads ~0.35 of CW at the same commanded 30 % where density reads ~0.12-0.15 and runs faint; the operator accepts density's weakness pending E4 (the per-model S correction). The M101 switch, its report, the M2 revert and two clean discharge windows all held on the shipped image. Items A3 and B4 of the working file close.
|
||||
|
||||
### The density time base across feeds: even all the way, 2026-08-30
|
||||
|
||||
The `m4feeds` drill on image 20260830200842, one armed run: a 60 mm U per feed (out and return legs 0.6 mm apart, the turn at the far end) at S1000 under M4 density, F1000 and F4000, passes 5 mm apart (`bench-data/m4feeds_S1000_20260830.log`; a first 20 mm retraced attempt was unreadable, `m4feeds_20260830.log`). At cruise the beam read identically at both feeds (thermopile 3202 / 3184, current 961 / 895), as M4 commands; the operator read the material "even all the way" - end to middle and through the turns, both feeds. B3 of the working file closes: M4's velocity scaling holds dose per mm through the accel, the per-tick time base stands, no per-step base is needed.
|
||||
|
||||
### The first rasters, and the decision that ends the analog mode, 2026-08-30
|
||||
|
||||
B5 on image 20260830200842, the generated grayscale wedge (`bench-data/gray-wedge.png`) from LightBurn in Image/Grayscale mode under density. Run 1, 254 DPI at 3000 mm/min (37 min, one kernel run of 23.5M callbacks): every band distinguishable, the ramp graduated, no pattern beyond the mechanical stepper signature, no dropout; the dark third saturates into char at 100 % layer power (dose, not modulation). One mid-job "fire suppressed: coolant" warning traced to a single stale-verdict beat under CPU starvation (the controller logged "1 late events clamped ... step generation was starved of CPU", min pacing margin 0.0 ms); the flow re-checks passed all job with the tube share off, the ceiling gates on the upstream sensor and never tripped, and nothing shows on the material. Run 2, 508 DPI at 6000 mm/min (39 min, 25.1M callbacks, min margin 13.3 ms, zero clamps, no suppression): tonality held at ~14 pulse slots per pixel - the dither accumulator's cross-pixel averaging recovered the levels - no coarsening, no moire. Witness photos `bench-data/raster_run1_254dpi_20260830.jpg` and `raster_run2_508dpi_20260830.jpg`.
|
||||
|
||||
The analog pass (A5) was cancelled by the operator's decision that ends the mode: analog fires a spot at every turn-on, low power included - the strike transient, seen at A1 as the start-of-line spots and at the corner drill as the scorch halo - and the finish comparison had already found no advantage (identical finish on acrylic). Density is the only product model from here; the analog rendering survives only as the host harness's conservatism reference (the mask rule), selectable only in the null-sink build.
|
||||
|
||||
## Reference notes
|
||||
|
||||
### Head-IRQ source validation — the beam-emission hypothesis
|
||||
|
||||
+13
-30
@@ -147,38 +147,21 @@ restart the controller with the head re-parked.)
|
||||
- **Console tab**: raw grbl — `?` status, `$$` settings, `$X` unlock,
|
||||
`$J=G91X10F1200` jog.
|
||||
|
||||
## Power models
|
||||
## Power
|
||||
|
||||
The controller has two ways to turn a layer's power into light, and a job
|
||||
can pick either.
|
||||
The controller drives the tube the way the factory does: every pulse
|
||||
fires at full power, and the power setting decides how many ticks of
|
||||
each 710 us period fire. Every power level marks, low levels included,
|
||||
because no pulse is ever too weak to strike. The response is not linear:
|
||||
on this machine 80 % gives about half the light of 100 %, 60 % about a
|
||||
third, 30 % about a fourteenth - so pick engrave power by test card, and
|
||||
prefer setting darkness with speed. Grayscale images fade cleanly into
|
||||
the shadows (a low level becomes sparse full-power pulses), and 254 to
|
||||
508 DPI rasters hold their tonal steps.
|
||||
|
||||
- **Density** (the default): every pulse is full power, and the power
|
||||
setting decides how many ticks of each 710 us period fire. This is what
|
||||
the factory does. Every power level marks, low levels included, because
|
||||
no pulse is ever too weak to strike; the trade is that the response is
|
||||
not linear: on this machine 80 % gives about half the light of 100 %, 60 %
|
||||
about a third, 30 % about a fourteenth.
|
||||
- **Analog**: the beam runs continuously and the power setting sets the PWM
|
||||
duty. Close to linear above 30 %, with a floor at the duty the tube lases
|
||||
at (16 %), below which nothing marks. The finish on acrylic is the same
|
||||
as density's.
|
||||
|
||||
The default model is set on the control panel (GRBL tab, "Laser power
|
||||
model"). A job selects its own with a line in its G-code, with the laser
|
||||
off:
|
||||
|
||||
M5 ; beam off (the switch is refused while the spindle is on)
|
||||
M101 P0 ; analog for this program (M101 P1 = density)
|
||||
M3 ...
|
||||
|
||||
Put it in the job's start G-code (Edit -> Device Settings -> GCode -> Start
|
||||
G-Code in LightBurn) or between sections of a job. It reverts to the panel
|
||||
default when the program ends (`M2`) or on Stop, so a job never leaves the
|
||||
machine in a model you did not pick; `M101 P0 Q1` typed in the Console
|
||||
sticks until the next `M101` or a controller restart. The console reports
|
||||
every switch and every arm with the model and its floor, and `$35` (the
|
||||
power floor) is set by the controller from the selected model: do not type
|
||||
it, it is overwritten at the next job.
|
||||
`$35`, the power floor, is set by the controller from the machine config
|
||||
(the control panel's GRBL tab, "Laser dose"): do not type it, it is
|
||||
overwritten at every job.
|
||||
|
||||
## Air assist / fans
|
||||
|
||||
|
||||
+1
-3
@@ -426,9 +426,7 @@ only be changed while the machine is idle.
|
||||
| `lid_policy` | `cancel` | `cancel` = factory behavior; `hold` = stock Grbl door parking. |
|
||||
| `laser_button_timeout_s` | 300 | How long the machine waits at the button prompt. |
|
||||
| `laser_disarm_s` | 60 | Spindle-off grace before the armed window closes. |
|
||||
| `laser_power_model` | `density` | The boot-default dose model: `density` (full-power pulses, dose by pulse density) or `analog` (continuous beam, dose by PWM duty). A job overrides it with `M101 P0` / `M101 P1`. |
|
||||
| `laser_floor_density` | 10 | The S-range floor under density, percent of full; loaded into `$35` at every arm and switch. |
|
||||
| `laser_floor_analog` | 16 | The S-range floor under analog, percent duty (the duty the tube lases at); loaded into `$35` the same way. |
|
||||
| `laser_floor_density` | 10 | The S-range floor, percent of full: the lowest pulse density that still marks. Loaded into `$35` at every spindle precompute; `$35` is derived, never typed. |
|
||||
| `laser_pulse_ticks` | 20 | Density base period in machine ticks (35.5 us each). |
|
||||
| `laser_pulse_min_ticks` | 3 | Shortest density pulse in ticks; below it a period is skipped and its debt carried. |
|
||||
| `rail_settle_s` | 2.5 | Motor-rail off period when a controller takes the device standalone. |
|
||||
|
||||
+6
-8
@@ -203,14 +203,12 @@ would not allow.
|
||||
under a job left standing, so a long pause ends with the machine disarmed
|
||||
and the next emission needs a fresh press. The relock waits for the kernel to
|
||||
finish the queue tail so a controlled stop can never leave FIRE driven.
|
||||
- **Dose-model switch.** `M101` changes how a commanded power is rendered
|
||||
(full-power pulses at a density, or a continuous beam at a duty). It is
|
||||
refused with the spindle commanded on or the controller not idle, because
|
||||
a change under fire could pair the density model's pinned full duty with
|
||||
the analog model's continuous FIRE; between kernel runs, which end dark
|
||||
and lead with a power byte, no torn state exists. The first run after a
|
||||
switch leads dark (duty 0) until the commanded power lands. A switch
|
||||
reverts at program end and on reset unless the operator made it stick.
|
||||
- **Dose model.** Density is the only model: every pulse fires at full
|
||||
power and the commanded level only masks FIRE ticks the core asked for,
|
||||
never adds one, so emission stays exactly where the core commanded it.
|
||||
The analog rendering (continuous FIRE at a duty) is not selectable on a
|
||||
machine - it fires the tube's strike transient as a spot at every
|
||||
beam-on - and exists only as the host harness's conservatism reference.
|
||||
- **Coolant fire gates.** The armed window requires a fresh `fire_ok` verdict
|
||||
from the cooling engine (flow verification, over-temperature, the airflow
|
||||
floors on every fan, lid-IR emission witness); a stale or failed verdict
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"""laser.* - LIVE laser tests, ported from `scripts/bench/live_fire_drills.py`.
|
||||
|
||||
Every test here can emit, except `laser.power-floor` and
|
||||
`laser.power-model-switch`, which only read and switch settings with the
|
||||
spindle off. The page starts a firing one only with the operator's
|
||||
Every test here can emit, except `laser.power-floor`, which only reads
|
||||
settings. The page starts a firing one only with the operator's
|
||||
eye-protection / fire-watch / exhaust acknowledgment; the test then
|
||||
prompts for the scrap and the button, streams a small job through the
|
||||
controller, and the machine fires only after the operator presses the
|
||||
@@ -32,29 +31,22 @@ _LASER_COVERS = [("grblhal-glowforge", "src/**"), ("kernel-module-glowforge", "*
|
||||
("forgectrl", "src/super.c"), ("forgectrl", "src/cool.c"),
|
||||
("forgectrl", "src/status.c"), ("forgectrl", "src/main.c")]
|
||||
|
||||
# The per-model S-range floors the controller loads into $35 at every
|
||||
# arm and switch (glowforge_laser.c: laser_floor_density /
|
||||
# laser_floor_analog, with these defaults when the config carries no
|
||||
# key). Density: the lowest pulse density that still marks. Analog: the
|
||||
# duty this tube lases at, against the hardware's 127-count PWM period.
|
||||
FLOOR_DEFAULT = {"density": 10.0, "analog": 16.0}
|
||||
# The S-range floor the controller derives $35 from at every precompute
|
||||
# (glowforge_laser.c: laser_floor_density, this default when the config
|
||||
# carries no key): the lowest pulse density that still marks, against
|
||||
# the hardware's 127-count PWM period. Density is the only dose model.
|
||||
FLOOR_DEFAULT = 10.0
|
||||
PWM_PERIOD = 127
|
||||
PWMSAR_FLOOR_MIN = {"density": 12, "analog": 20}
|
||||
PWMSAR_FLOOR_MIN = 12
|
||||
|
||||
|
||||
def _model_floors(fc):
|
||||
"""(configured model, {model: floor percent}) from forgectrl's settings:
|
||||
the config keys the controller derives $35 from, defaults applied."""
|
||||
cfg = fc.settings()
|
||||
model = (cfg.get("laser_power_model") or "density").strip() or "density"
|
||||
floors = {}
|
||||
for m in ("density", "analog"):
|
||||
raw = (cfg.get("laser_floor_%s" % m) or "").strip()
|
||||
try:
|
||||
floors[m] = float(raw) if raw else FLOOR_DEFAULT[m]
|
||||
except ValueError:
|
||||
floors[m] = FLOOR_DEFAULT[m]
|
||||
return model, floors
|
||||
def _floor(fc):
|
||||
"""The configured floor percent, default applied."""
|
||||
raw = (fc.settings().get("laser_floor_density") or "").strip()
|
||||
try:
|
||||
return float(raw) if raw else FLOOR_DEFAULT
|
||||
except ValueError:
|
||||
return FLOOR_DEFAULT
|
||||
|
||||
|
||||
def _grbl_settings(lines):
|
||||
@@ -309,34 +301,22 @@ def wait_disarm(ctx, timeout):
|
||||
return None
|
||||
|
||||
|
||||
@test("laser.power-floor", title="The configured model's floor is the one in force",
|
||||
@test("laser.power-floor", title="The derived floor is the one in force",
|
||||
subsystem="laser", kind="auto", mode="grbl", est_min=1,
|
||||
covers=[("grblhal-glowforge", "src/**"), ("forgectrl", "src/main.c")],
|
||||
description="The controller derives $35 from the configured dose model's floor key "
|
||||
"(laser_floor_density / laser_floor_analog) at every arm and switch, and "
|
||||
"never from a typed value. Unfloored, the low end of S asks for pulses too "
|
||||
"far apart to re-strike (density) or a duty in the tube's dead band "
|
||||
"(analog), so a commanded 1 % would emit nothing. Reads the model and the "
|
||||
"floors from forgectrl, sends an M101 for the configured model so the "
|
||||
"derivation runs without a fire, then reads $$ and checks $35 is that "
|
||||
"model's floor, that it lands at or above the measured minimum, and that "
|
||||
"$31 is 0 (the floor, not $31, sets the bottom of the range).")
|
||||
description="The controller derives $35 from the floor key (laser_floor_density) at "
|
||||
"every spindle precompute, boot included, and never from a typed value. "
|
||||
"Unfloored, the low end of S asks for pulses too far apart to re-strike, "
|
||||
"so a commanded 1 % would emit nothing. Reads the floor key from forgectrl "
|
||||
"and $$ from the controller, and checks $35 is that floor, that it lands at "
|
||||
"or above the measured minimum, and that $31 is 0 (the floor, not $31, sets "
|
||||
"the bottom of the range).")
|
||||
def power_floor(ctx):
|
||||
ev = ctx.evidence
|
||||
model, floors = _model_floors(ctx.forgectrl)
|
||||
ev["configured"] = {"model": model, "floors": floors}
|
||||
ctx.log("configured model %s, floors %s", model, floors)
|
||||
want = _floor(ctx.forgectrl)
|
||||
ev["configured_floor"] = want
|
||||
with ctx.grbl() as g:
|
||||
# The switch for the model already selected: the derivation runs
|
||||
# at the switch as at the arm, spindle off, nothing fires, and the
|
||||
# program-scoped override is cleared by the M2 that follows.
|
||||
reply = g.command("M101 P%d" % (1 if model == "density" else 0), timeout=5)
|
||||
ev["m101"] = reply
|
||||
ctx.check(any(ln.startswith("ok") for ln in reply) and
|
||||
not any(ln.startswith("error") for ln in reply),
|
||||
"M101 for the configured model was not accepted: %s", reply)
|
||||
lines = g.command("$$", timeout=5)
|
||||
g.command("M2", timeout=5)
|
||||
vals = _grbl_settings(lines)
|
||||
ctx.check(vals, "no $-settings in the response to $$")
|
||||
|
||||
@@ -349,57 +329,15 @@ def power_floor(ctx):
|
||||
ctx.log("$35=%.1f%% -> PWMSAR %d of %d ($31=%s, $32=%s, $36=%s)", floor_pct, counts,
|
||||
PWM_PERIOD, rpm_min, vals.get("$32"), vals.get("$36"))
|
||||
|
||||
want = floors[model]
|
||||
ctx.check(abs(floor_pct - want) < 0.05,
|
||||
"$35 is %.1f %%, expected the %s floor %.1f %% from the machine config",
|
||||
floor_pct, model, want)
|
||||
ctx.check(counts >= PWMSAR_FLOOR_MIN[model],
|
||||
"the %s floor lands at PWMSAR %d, below the %d the tube needs",
|
||||
model, counts, PWMSAR_FLOOR_MIN[model])
|
||||
"$35 is %.1f %%, expected the derived floor %.1f %% from the machine config",
|
||||
floor_pct, want)
|
||||
ctx.check(counts >= PWMSAR_FLOOR_MIN,
|
||||
"the floor lands at PWMSAR %d, below the %d the tube needs",
|
||||
counts, PWMSAR_FLOOR_MIN)
|
||||
ctx.check(rpm_min == 0, "$31 is %s, not 0: the bottom of the S range is no longer the floor",
|
||||
rpm_min)
|
||||
ctx.log("PASS: under %s, nonzero S never commands less than PWMSAR %d", model, counts)
|
||||
|
||||
|
||||
@test("laser.power-model-switch", title="M101 switches the dose model and its floor, and reverts at M2",
|
||||
subsystem="laser", kind="auto", mode="grbl", est_min=1,
|
||||
covers=[("grblhal-glowforge", "src/glowforge_laser.c"), ("grblhal-glowforge", "src/driver.c"),
|
||||
("forgectrl", "src/main.c")],
|
||||
description="A job selects its dose model with M101 P0 (analog) or M101 P1 (density) "
|
||||
"after an M5; the controller loads that model's floor into $35 on the spot "
|
||||
"and reverts to the configured default at program end unless the line "
|
||||
"carried Q1. With the spindle off and nothing armed: switches to each model "
|
||||
"and reads $35 back as that model's floor, checks the reported message names "
|
||||
"the model and the floor, then sends M2 and checks the configured model's "
|
||||
"floor is back. Nothing fires: the switch is refused with the spindle on, "
|
||||
"and no M3 is sent.")
|
||||
def power_model_switch(ctx):
|
||||
ev = ctx.evidence
|
||||
model, floors = _model_floors(ctx.forgectrl)
|
||||
other = "analog" if model == "density" else "density"
|
||||
ev["configured"] = {"model": model, "floors": floors}
|
||||
with ctx.grbl() as g:
|
||||
for m in (other, model, other):
|
||||
reply = g.command("M101 P%d" % (1 if m == "density" else 0), timeout=5)
|
||||
text = "\n".join(reply)
|
||||
ctx.check(any(ln.startswith("ok") for ln in reply), "M101 -> %s not accepted: %s", m, reply)
|
||||
ctx.check("laser power model set for this program (%s, floor %g %%)" % (m, floors[m]) in text,
|
||||
"the switch to %s was not reported with its floor: %s", m, reply)
|
||||
vals = _grbl_settings(g.command("$$", timeout=5))
|
||||
ev["after_%s" % m] = vals.get("$35")
|
||||
ctx.check(vals.get("$35") is not None and abs(vals["$35"] - floors[m]) < 0.05,
|
||||
"after M101 -> %s, $35 is %s, expected %.1f", m, vals.get("$35"), floors[m])
|
||||
reply = g.command("M2", timeout=5)
|
||||
text = "\n".join(reply)
|
||||
vals = _grbl_settings(g.command("$$", timeout=5))
|
||||
ev["after_m2"] = vals.get("$35")
|
||||
ctx.check("laser power model reverted (%s, floor %g %%)" % (model, floors[model]) in text,
|
||||
"M2 did not report the revert to %s: %s", model, reply)
|
||||
ctx.check(vals.get("$35") is not None and abs(vals["$35"] - floors[model]) < 0.05,
|
||||
"after M2, $35 is %s, expected the configured %s floor %.1f",
|
||||
vals.get("$35"), model, floors[model])
|
||||
ctx.log("PASS: M101 loads %s's floor %g and %s's floor %g; M2 reverts to %s",
|
||||
other, floors[other], model, floors[model], model)
|
||||
ctx.log("PASS: nonzero S never commands less than PWMSAR %d", counts)
|
||||
|
||||
|
||||
@test("laser.emission-witness", title="Live emission witness (S400 vector mark) and job-based disarm",
|
||||
|
||||
@@ -32,7 +32,7 @@ page's takeover does that; from a host, stop them first.
|
||||
| `gate_a_kernel_drills.py` | Kernel laser-safety drills (run on the board with forgectrl stopped so the pulse device is free): `K1` controlled-stop deceleration floor, `K2` resume waypoint honors the locked latch, `K3` a mid-ramp latch unlock never re-arms the FIRE drive. Software witnesses (`cnc/state`, `laser_enable`, `laser_on`, `laser_on_sampled`, interlock bit 3) plus the PSU-connector LASER_ON scope point; K3 refuses to run if HV reports good. |
|
||||
| `laser_stream_test.py` | Host-side laser pulse-stream emission harness: runs the native null-sink controller with `GFSINK_DUMP`, drives small laser jobs over TCP, and checks the dumped bytes against the kernel feeder contract (leading power byte, no back-to-back power bytes, FIRE only inside cutting moves, every stream ends FIRE-clear, no FIRE on a stepless gap, no FIRE leak across cycle churn, the rapids after an M5 executed at idle ship dark, and the next job in the same process fires at the level the previous one ended at). Runs in the grblHAL repo's CI. |
|
||||
| `laser_lifecycle_test.py` | Host-side operator-armed-window lifecycle harness (null-sink controller): arm once per job with M5/M3 persistence, the M2 close, sender-change re-consent, the disarm grace counting down in Hold, and arm refusal under a blocking cooling verdict. Runs in the grblHAL repo's CI. |
|
||||
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the dose curve of the configured `laser_power_model`: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density or duty; the operator matches each row-B patch to the row-A patch of equal depth, which reads the model's light fraction off the material beside the thermopile's prediction; JSON record), `mswitch` (the M101 dose-model switch on the machine, one armed run: a density line at S500, M5, M101 P0, the same line back under analog, M2; asserts the arm/switch/revert reports with each model's floor, $$ showing the floor in force, no re-prompt across the switch, exactly two discharge segments with a pulsed character under density and a steady one under analog, and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <secs> [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
|
||||
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `pthresh` (laser power-threshold ladder: 13 constant-power rungs from 2 % to 30 % of full on scrap; the lowest rung that marks is the tube's striking threshold and reads directly as the `$35` value - requires `$35` = 0 for the run), `dladder` (density ladder at a chosen base period), `pcurve` (laser performance-curve ladder: one 100 mm line per level at 10 mm/s under M3, the laser off between rungs and a mid-ladder rung repeated at the end; reads `pic/hv_current` and the head thermopile `head/beam_detect_analog` (a scatter detector in the beam path upstream of the final mirror, so it sees the beam, not the material) from sysfs at ~25 Hz on the board, brackets each rung on the controller's Run/Idle states, and reports per rung the current with a clipped-at-1023 flag, the thermopile delta over its laser-off baseline and in-line drift, then the normalized curve, monotonicity, a line fit with its threshold intercept and the repeat-rung drift; JSON record with the raw trace in the bench data directory; rungs follow `laser_power_model`, a comma list overrides; a curve measurement wants `$35` = 0), `dpatch [F] [pitch] [length]` (depth witness for the density dose curve: two rows of small serpentine-filled patches, row A CW at feeds giving relative doses 1.0 to 0.25 of the reference feed, row B at the reference feed at 100/80/60/45/30 % density; the operator matches each row-B patch to the row-A patch of equal depth, which reads the density's light fraction off the material beside the thermopile's prediction; JSON record), `m4feeds [S] [F1] [F2]` (the density time base across feeds: one out-and-back line pair per feed at the same S under M4 density, one armed run; the operator reads within-line evenness and reversal darkness at both feeds - M4's velocity scaling is what holds dose per mm through the accel), `m4corner [S] [F]` (M4 velocity-scaled power into corners: a corner-heavy vector pattern at 30 % under M4 density, one armed run; the operator confirms every commanded segment marks - the floor makes a dropout unreachable - and the drill asserts the arm report, one discharge window and dark after), `m5dark` (the rapids after an M5 ship dark: one 20 mm line at M3 S400, M5, dwell, rapid back, dwell, rapid forward; PASS when the 25 Hz current trace shows one discharge segment and reads dark after the M5 and `laser_on_sampled` never re-lights; the catalog's `laser.m5-rapid-dark` is its port), `flowload` (cooling under laser load, one armed run per invocation, the conf keys it writes put back at the end, the pump never commanded off: `t1` reproduces the flow-check trip with the check on at its defaults and two 30 x 4 mm CW fills at F1500 starting on the press with no dark dwell, and reports the engine's rise/dT verdict beside the 25 Hz trace of both coolant sensors, the current, the digital witness and the heater output in 5 s bins across the window, with the shape at fire start; `t2 <secs> [pct]` runs with the check off and one fill of about `secs` lit seconds at CW or at `pct` density, and reports the lag to each sensor, the rise per raw-second of `hv_current` and what a full 50 s window would add against the 1.6 C margin; `fit` fits rise against dose over every t2 record; JSON records), `expstop` (armed kill on the expected-stop path; needs the panel token - `GF_TOKEN`, or the board's token file) and `ctrlstart` (the separate controller restart after it). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
|
||||
| `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. |
|
||||
| `gfbench.py` | Not a tool: the helper the board/host tools share - `HOST`/`LOCAL` from `GF_HOST`, `board(cmd)` (local `sh -c` or ssh), the factory coolant conversion `degc()`, `data_path()` (`FORGETEST_BENCH_DATA` or next to the tool), forgectrl's HTTP API with the panel token, `setting(key)` (from forgectrl, or from `/data/forgefirm.conf` on the board while forgectrl is stopped). |
|
||||
| `fan_test.py` | Fan/coolant bench (board or host; controller running): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. |
|
||||
|
||||
@@ -29,10 +29,11 @@ reported messages:
|
||||
job start on its own; with lid_policy = hold the stock door hold and
|
||||
cycle-start resume apply
|
||||
9. the controller publishes its state for the daemon: grbl.settings
|
||||
(the $$ view) and grbl.state (JSON with ts_mono, machine state,
|
||||
sender session, laser window and dose model, modals) appear under
|
||||
GF_STATE_DIR, follow the sender connection, the armed window and
|
||||
an M101 switch, and carry a fresh ts_mono
|
||||
(the $$ view, the derived floor in force from boot) and grbl.state
|
||||
(JSON with ts_mono, machine state, sender session, laser window
|
||||
and dose model, modals) appear under GF_STATE_DIR, follow the
|
||||
sender connection and the armed window, and carry a fresh
|
||||
ts_mono
|
||||
10. the job start survives a pause: a job paused and resumed by the
|
||||
button, then cancelled by the lid, returns to where the job began,
|
||||
not to where it was paused (the core restarts a held cycle through
|
||||
@@ -294,29 +295,23 @@ def test_status_files():
|
||||
assert '"connected":true' in st, "sender not reported connected: %r" % st
|
||||
assert '"armed":false' in st, "armed before any job: %r" % st
|
||||
assert '"model":"density"' in st and '"floor_pct":10' in st, \
|
||||
"model/floor missing: %r" % st
|
||||
"model/floor missing (the floor must be derived from boot): %r" % st
|
||||
assert '"modals":"[GC:' in st, "modal report missing: %r" % st
|
||||
ts0 = float(st.split('"ts_mono":')[1].split(',')[0])
|
||||
|
||||
cfg = open(os.path.join(s.workdir, "grbl.settings")).read()
|
||||
assert "$35=" in cfg and "$30=" in cfg, "settings file lacks $ lines"
|
||||
|
||||
# The armed window and a dose-model switch reach the file.
|
||||
# The derived floor is in force from boot, before any arm.
|
||||
assert "$35=10" in cfg, "the derived floor is not in $$ from boot: %r" % [
|
||||
l for l in cfg.splitlines() if l.startswith("$35")]
|
||||
# The armed window reaches the file.
|
||||
send_line(s.sock, "G91", s.log)
|
||||
send_line(s.sock, "M3 S100", s.log)
|
||||
st = read_state(s, '"armed":true')
|
||||
assert '"armed":true' in st, "armed window not published: %r" % st
|
||||
send_line(s.sock, "M5", s.log)
|
||||
send_line(s.sock, "M101 P0", s.log)
|
||||
st = read_state(s, '"model":"analog"')
|
||||
assert '"model":"analog"' in st and '"floor_pct":16' in st, \
|
||||
"the M101 switch not published: %r" % st
|
||||
cfg = open(os.path.join(s.workdir, "grbl.settings")).read()
|
||||
assert "$35=16" in cfg, "the switched floor not republished: %r" % [
|
||||
l for l in cfg.splitlines() if l.startswith("$35")]
|
||||
send_line(s.sock, "M2", s.log)
|
||||
st = read_state(s, '"model":"density"')
|
||||
assert '"model":"density"' in st, "the M2 revert not published: %r" % st
|
||||
|
||||
# A reconnect bumps the generation and stays connected.
|
||||
gen0 = int(st.split('"generation":')[1].split(',')[0])
|
||||
|
||||
@@ -58,21 +58,16 @@ over TCP, then checks the dumps against the kernel feeder contract:
|
||||
the M3 that opens the next job is the only thing that can light its
|
||||
first move - set_state must push the whole state, fire included,
|
||||
never the duty alone
|
||||
18. the floor is derived, never typed: $35 is loaded from the selected
|
||||
model's floor key (laser_floor_density / laser_floor_analog) at
|
||||
the arm, so a $35 typed by the sender is overwritten - the ladder
|
||||
renders through the key's floor, and the arm report names the
|
||||
model and the floor in force
|
||||
19. M101 switches the model at a boundary and nowhere else: with the
|
||||
spindle off and the controller idle, the rendering changes exactly
|
||||
at the switch (analog duties before, pinned full duty and dithered
|
||||
FIRE after, or the reverse) and there is no torn transition - no
|
||||
window of continuous FIRE at full duty anywhere across it
|
||||
20. M101 is refused with the spindle on (error 253, the reason
|
||||
reported), and the stream is unchanged by the refusal
|
||||
21. a switch is program-scoped: M2 reverts it to the boot default and
|
||||
the next job renders under the default, while M101 ... Q1 sticks
|
||||
across M2
|
||||
18. the floor is derived, never typed: $35 is loaded from the floor
|
||||
key at every precompute, so a $35 typed by the sender is
|
||||
overwritten - the ladder renders through the key's floor, and the
|
||||
arm report names the model and the floor in force
|
||||
|
||||
The analog sessions select the reference mode through the config; on
|
||||
hardware the controller ignores it (density is the only product model -
|
||||
analog's strike transient puts a spot at every beam-on), but the
|
||||
null-sink build honors it so these rules can hold the density model to
|
||||
account against the continuous rendering (rule 13's mask above all).
|
||||
|
||||
Usage: laser_stream_test.py [path-to-binary] (default ./build-native/grblHAL_glowforge)
|
||||
"""
|
||||
@@ -198,149 +193,19 @@ def duty_for_floor(s, floor_pct):
|
||||
return int(s * (PWM_PERIOD - lo) / RPM_MAX) + lo
|
||||
|
||||
|
||||
# Sessions L-N: the M101 dose-model switch. One cut, the spindle off,
|
||||
# the switch, a second cut at the same S. Each cut is a kernel run of
|
||||
# its own (the planner drains at WAIT_IDLE), so the switch lands between
|
||||
# runs, which is the only place it is allowed to.
|
||||
SWITCH_S = 500
|
||||
SWITCH_MM = 5.0
|
||||
SWITCH_FEED = 600
|
||||
SWITCH_TICKS = SWITCH_MM / (SWITCH_FEED / 60.0) * 28160
|
||||
|
||||
|
||||
def job_switch(mcode):
|
||||
return [
|
||||
"G91", "G21",
|
||||
"M3 S%d" % SWITCH_S,
|
||||
"G1 X%g F%d" % (SWITCH_MM, SWITCH_FEED),
|
||||
WAIT_IDLE, ("sleep", 0.5),
|
||||
"M5",
|
||||
mcode,
|
||||
"M3 S%d" % SWITCH_S,
|
||||
"G1 X%g" % -SWITCH_MM,
|
||||
WAIT_IDLE, ("sleep", 0.5),
|
||||
"M5",
|
||||
]
|
||||
|
||||
|
||||
JOB_SWITCH_A2D = job_switch("M101 P1")
|
||||
JOB_SWITCH_D2A = job_switch("M101 P0")
|
||||
|
||||
# The refusal: the switch arrives with the spindle still on. The stream
|
||||
# must be what the job without the M101 would have produced.
|
||||
JOB_SWITCH_REFUSED = [
|
||||
"G91", "G21",
|
||||
"M3 S%d" % SWITCH_S,
|
||||
"G1 X%g F%d" % (SWITCH_MM, SWITCH_FEED),
|
||||
WAIT_IDLE, ("sleep", 0.5),
|
||||
("expect_error", "M101 P1"),
|
||||
"G1 X%g" % -SWITCH_MM,
|
||||
WAIT_IDLE, ("sleep", 0.5),
|
||||
"M5",
|
||||
]
|
||||
|
||||
|
||||
def job_revert(mcode):
|
||||
"""Two programs: the first switches and ends in M2, the second cuts
|
||||
at the same S with no switch of its own."""
|
||||
return [
|
||||
"G91", "G21",
|
||||
mcode,
|
||||
"M3 S%d" % SWITCH_S,
|
||||
"G1 X%g F%d" % (SWITCH_MM, SWITCH_FEED),
|
||||
WAIT_IDLE, ("sleep", 0.5),
|
||||
"M5", "G90", "M2", ("sleep", 1.0),
|
||||
"G91",
|
||||
"M3 S%d" % SWITCH_S,
|
||||
"G1 X%g" % -SWITCH_MM,
|
||||
WAIT_IDLE, ("sleep", 0.5),
|
||||
"M5",
|
||||
]
|
||||
|
||||
|
||||
JOB_SWITCH_REVERT = job_revert("M101 P0")
|
||||
JOB_SWITCH_STICKY = job_revert("M101 P0 Q1")
|
||||
|
||||
# Session H: three levels inside one kernel run. The moves are short and
|
||||
# fast so the planner never drains, and each carries its own S word, so
|
||||
# the level changes land mid-run. Analog pays a power byte per level;
|
||||
# density pays none, because the level rides the FIRE bits.
|
||||
JOB_LEVELS = ["G91", "G21", "M3"]
|
||||
for _s in (100, 300, 600):
|
||||
for _ in range(20):
|
||||
JOB_LEVELS.append("G1 X0.5 F3000 S%d" % _s)
|
||||
JOB_LEVELS.append("M5")
|
||||
|
||||
|
||||
# Session I: the levels arrive on their own lines, and the moves are long
|
||||
# enough that the planner drains between them, so each S is executed with
|
||||
# nothing streaming. The state has no event to ride and must be
|
||||
# re-asserted at the next run's first byte.
|
||||
IDLE_S_LEVELS = (100, 300, 600)
|
||||
IDLE_S_MM = 5.0
|
||||
IDLE_S_FEED = 300
|
||||
JOB_IDLE_S = ["G91", "G21", "M3"]
|
||||
for _i, _s in enumerate(IDLE_S_LEVELS):
|
||||
JOB_IDLE_S.append("S%d" % _s)
|
||||
JOB_IDLE_S.append("G1 X%g F%d" % (IDLE_S_MM if _i % 2 == 0 else -IDLE_S_MM,
|
||||
IDLE_S_FEED))
|
||||
JOB_IDLE_S.append("M5")
|
||||
|
||||
|
||||
# Session J: the bench ladder's shape. M5 executes with the planner
|
||||
# drained and the kernel run over, and the rapids that follow start a
|
||||
# new run; the core issues no per-segment laser update for moves made
|
||||
# with the spindle off, so the stream's wanted state is all that decides
|
||||
# whether those rapids fire. A bare G0 with no M3 since the M5 is the
|
||||
# same case one step further.
|
||||
M5_IDLE_MM = 5.0
|
||||
M5_IDLE_FEED = 600
|
||||
M5_IDLE_TICKS = M5_IDLE_MM / (M5_IDLE_FEED / 60.0) * 28160
|
||||
JOB_M5_IDLE = [
|
||||
"G91", "G21",
|
||||
"M3 S500",
|
||||
"G1 X%g F%d" % (M5_IDLE_MM, M5_IDLE_FEED),
|
||||
WAIT_IDLE, ("sleep", 0.5),
|
||||
"M5", ("sleep", 0.5),
|
||||
"G0 X%g" % -M5_IDLE_MM, "G0 Y1",
|
||||
WAIT_IDLE,
|
||||
"G0 X%g" % M5_IDLE_MM,
|
||||
WAIT_IDLE,
|
||||
"M3 S500",
|
||||
"G1 X%g" % -M5_IDLE_MM,
|
||||
WAIT_IDLE, ("sleep", 0.5),
|
||||
"M5",
|
||||
]
|
||||
|
||||
|
||||
# Session K: two jobs in one controller process, the second at the level
|
||||
# the first ended at. M2 leaves S modal and resets the motion mode to G1,
|
||||
# so the next job's M3 executes at that S; the core records it and issues
|
||||
# no per-segment update for a G1 at the same level, so the set_state is
|
||||
# the only thing that can light it. The parser starts in G0, which is why
|
||||
# a process's FIRST job never shows this: its M3 runs at rpm 0.
|
||||
JOB_NEXT = [
|
||||
"G91", "G21", "M3", "S500",
|
||||
"G1 X%g F%d" % (M5_IDLE_MM, M5_IDLE_FEED),
|
||||
WAIT_IDLE, ("sleep", 0.5),
|
||||
"M5", "G0 X%g" % -M5_IDLE_MM, "G0 Y1",
|
||||
WAIT_IDLE, "G90", "M2", ("sleep", 1.0),
|
||||
]
|
||||
|
||||
|
||||
def fail(msg):
|
||||
print("FAIL: %s" % msg)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def send_line(sock, line, log, expect_error=False):
|
||||
def send_line(sock, line, log):
|
||||
sock.sendall((line + "\n").encode())
|
||||
while True:
|
||||
r = read_avail(sock, log, 5.0, until=("ok", "error"))
|
||||
if r is None:
|
||||
fail("no ok/error for %r" % line)
|
||||
if (r == "error") != expect_error:
|
||||
fail("%s response to %r" % (r, line))
|
||||
if r == "error":
|
||||
fail("error response to %r" % line)
|
||||
return
|
||||
|
||||
|
||||
@@ -440,11 +305,6 @@ def run_session(name, steps, conf=None, workdir=None, keep=False,
|
||||
wait_idle(sock, log)
|
||||
elif isinstance(step, tuple) and step[0] == "sleep":
|
||||
time.sleep(step[1])
|
||||
elif isinstance(step, tuple) and step[0] == "expect_error":
|
||||
send_line(sock, step[1], log, expect_error=True)
|
||||
# The core skips every G-code line after an error until
|
||||
# the sender resyncs with an empty line (or a $ command).
|
||||
send_line(sock, "", log)
|
||||
else:
|
||||
send_line(sock, step, log)
|
||||
|
||||
@@ -893,156 +753,6 @@ def main():
|
||||
"ladder renders through the %g %% floor key, levels %s"
|
||||
% (PWM_MIN_PCT, list(expect_levels)))
|
||||
|
||||
# --- sessions L: the switch, both directions (rule 19) --------------
|
||||
def split_at_switch(data, marker):
|
||||
"""Byte offset of the first power byte satisfying marker(duty)."""
|
||||
for i, b in enumerate(data):
|
||||
if b & 0x80 and marker(b & 0x7F):
|
||||
return i
|
||||
return None
|
||||
|
||||
def longest_burst(ticks):
|
||||
run = worst = 0
|
||||
for t in ticks:
|
||||
run = run + 1 if t & 0x10 else 0
|
||||
worst = max(worst, run)
|
||||
return worst
|
||||
|
||||
def fire_by_power(data):
|
||||
"""Fire ticks per power byte in force: the duties FIRE rode."""
|
||||
cur, out = None, {}
|
||||
for b in data:
|
||||
if b & 0x80:
|
||||
cur = b & 0x7F
|
||||
elif b & 0x10:
|
||||
out[cur] = out.get(cur, 0) + 1
|
||||
return out
|
||||
|
||||
# Analog -> density. Before the switch: analog duties, continuous
|
||||
# FIRE. After: full duty only, FIRE dithered at the level, bursts no
|
||||
# longer than the base period.
|
||||
a2d = run_session("switch-a2d", JOB_SWITCH_A2D, conf=ANALOG_CONF)
|
||||
text = run_session.text
|
||||
if "laser power model set for this program (density, floor %g %%)" % PWM_MIN_PCT not in text:
|
||||
fail("[switch-a2d] the switch was not reported (text: %r)" % text[-400:])
|
||||
cut = split_at_switch(a2d, lambda d: d == PWM_PERIOD)
|
||||
if cut is None:
|
||||
fail("[switch-a2d] no full-duty power byte after the switch: the density "
|
||||
"model never took over")
|
||||
before, after = a2d[:cut], a2d[cut:]
|
||||
duty = duty_for(SWITCH_S)
|
||||
# A run may lead with a dark duty-0 byte across the idle pads; what
|
||||
# matters is the duty FIRE rides on each side of the switch.
|
||||
if set(fire_by_power(before)) != {duty}:
|
||||
fail("[switch-a2d] FIRE rode duties %s before the switch, expected only %d"
|
||||
% (sorted(fire_by_power(before)), duty))
|
||||
if set(b & 0x7F for b in after if b & 0x80) != {PWM_PERIOD}:
|
||||
fail("[switch-a2d] duties after the switch %s: a level reached PWMSAR "
|
||||
"under density" % sorted(set(b & 0x7F for b in after if b & 0x80)))
|
||||
tb, ta = tick_bytes(before), tick_bytes(after)
|
||||
sb, sa = fire_spans(tb), fire_spans(ta)
|
||||
if len(sb) != 1 or len(sa) != 1:
|
||||
fail("[switch-a2d] fire spans before/after the switch %s / %s, expected "
|
||||
"one each" % (sb, sa))
|
||||
if longest_burst(ta) > DENSITY_PERIOD:
|
||||
fail("[switch-a2d] a %d-tick continuous FIRE burst at full duty after the "
|
||||
"switch: a torn transition" % longest_burst(ta))
|
||||
dens_after = sum(1 for t in ta[sa[0][0]:sa[0][1]] if t & 0x10) / float(sa[0][1] - sa[0][0])
|
||||
if abs(dens_after - duty / float(PWM_PERIOD)) > 0.03:
|
||||
fail("[switch-a2d] density after the switch %.3f, expected %.3f"
|
||||
% (dens_after, duty / float(PWM_PERIOD)))
|
||||
check_termination("switch-a2d", a2d)
|
||||
print("PASS [switch-a2d]: analog duty %d before, full duty + density %.3f after, "
|
||||
"longest burst %d <= %d" % (duty, dens_after, longest_burst(ta), DENSITY_PERIOD))
|
||||
|
||||
# Density -> analog. The analog default floor (16) applies after the
|
||||
# switch, so the duty is the one that floor gives.
|
||||
d2a = run_session("switch-d2a", JOB_SWITCH_D2A, conf=DENSITY_CONF_FLOORED)
|
||||
text = run_session.text
|
||||
if "laser power model set for this program (analog, floor %g %%)" % ANALOG_FLOOR_DEFAULT_PCT not in text:
|
||||
fail("[switch-d2a] the switch was not reported with the analog floor (text: %r)"
|
||||
% text[-400:])
|
||||
duty_a = duty_for_floor(SWITCH_S, ANALOG_FLOOR_DEFAULT_PCT)
|
||||
cut = split_at_switch(d2a, lambda d: d == duty_a)
|
||||
if cut is None:
|
||||
fail("[switch-d2a] no analog power byte (%d) after the switch" % duty_a)
|
||||
before, after = d2a[:cut], d2a[cut:]
|
||||
if set(fire_by_power(before)) != {PWM_PERIOD}:
|
||||
fail("[switch-d2a] FIRE rode duties %s before the switch, expected full only"
|
||||
% sorted(fire_by_power(before)))
|
||||
if set(b & 0x7F for b in before if b & 0x80) - {PWM_PERIOD, 0}:
|
||||
fail("[switch-d2a] a density level shipped as a duty across the switch: "
|
||||
"power bytes %s" % sorted(set(b & 0x7F for b in before if b & 0x80)))
|
||||
if set(fire_by_power(after)) != {duty_a}:
|
||||
fail("[switch-d2a] FIRE rode duties %s after the switch, expected only %d"
|
||||
% (sorted(fire_by_power(after)), duty_a))
|
||||
ta = tick_bytes(after)
|
||||
sa = fire_spans(ta)
|
||||
if len(sa) != 1:
|
||||
fail("[switch-d2a] fire spans after the switch %s, expected one" % sa)
|
||||
got = sum(1 for t in ta[sa[0][0]:sa[0][1]] if t & 0x10) / float(sa[0][1] - sa[0][0])
|
||||
if got < 0.999:
|
||||
fail("[switch-d2a] FIRE after the switch is not continuous (%.3f): the "
|
||||
"dither is still masking under analog" % got)
|
||||
check_termination("switch-d2a", d2a)
|
||||
print("PASS [switch-d2a]: full duty + dither before, continuous FIRE at duty %d "
|
||||
"(floor %g) after" % (duty_a, ANALOG_FLOOR_DEFAULT_PCT))
|
||||
|
||||
# --- session M: refused with the spindle on (rule 20) ---------------
|
||||
ref = run_session("switch-refused", JOB_SWITCH_REFUSED, conf=ANALOG_CONF)
|
||||
text = run_session.text
|
||||
if "M5 first" not in text or "error:253" not in text:
|
||||
fail("[switch-refused] the refusal was not reported as error 253 with its "
|
||||
"reason (text: %r)" % text[-400:])
|
||||
if "laser power model set" in text:
|
||||
fail("[switch-refused] the switch was applied despite the refusal")
|
||||
if set(fire_by_power(ref)) != {duty}:
|
||||
fail("[switch-refused] FIRE rode duties %s, expected only %d: the stream "
|
||||
"changed under a refused switch" % (sorted(fire_by_power(ref)), duty))
|
||||
check_cut_spans("switch-refused", tick_bytes(ref), 2, SWITCH_TICKS,
|
||||
"the two G1 moves, both analog")
|
||||
check_termination("switch-refused", ref)
|
||||
print("PASS [switch-refused]: M101 with the spindle on -> error:253, stream "
|
||||
"unchanged (duty %d throughout)" % duty)
|
||||
|
||||
# --- session N: program scope and Q1 (rule 21) ----------------------
|
||||
rev = run_session("switch-revert", JOB_SWITCH_REVERT, conf=DENSITY_CONF_FLOORED)
|
||||
text = run_session.text
|
||||
if "laser power model reverted (density, floor %g %%)" % PWM_MIN_PCT not in text:
|
||||
fail("[switch-revert] M2 did not report the revert (text: %r)" % text[-600:])
|
||||
if text.count("laser armed (analog, floor %g %%)" % ANALOG_FLOOR_DEFAULT_PCT) != 1 or \
|
||||
text.count("laser armed (density, floor %g %%)" % PWM_MIN_PCT) != 1:
|
||||
fail("[switch-revert] expected one analog arm then one density arm "
|
||||
"(text: %r)" % text[-600:])
|
||||
cut = split_at_switch(rev, lambda d: d == PWM_PERIOD)
|
||||
if cut is None:
|
||||
fail("[switch-revert] the second job never rendered under density")
|
||||
before, after = rev[:cut], rev[cut:]
|
||||
if set(fire_by_power(before)) != {duty_a}:
|
||||
fail("[switch-revert] first job FIRE rode duties %s, expected the analog %d"
|
||||
% (sorted(fire_by_power(before)), duty_a))
|
||||
if longest_burst(tick_bytes(after)) > DENSITY_PERIOD:
|
||||
fail("[switch-revert] continuous FIRE at full duty in the second job: the "
|
||||
"revert did not restore the dither")
|
||||
check_termination("switch-revert", rev)
|
||||
print("PASS [switch-revert]: job 1 analog at duty %d, M2 reverts, job 2 density"
|
||||
% duty_a)
|
||||
|
||||
stk = run_session("switch-sticky", JOB_SWITCH_STICKY, conf=DENSITY_CONF_FLOORED)
|
||||
text = run_session.text
|
||||
if "reverted" in text:
|
||||
fail("[switch-sticky] a Q1 switch was reverted at M2")
|
||||
if text.count("laser armed (analog, floor %g %%)" % ANALOG_FLOOR_DEFAULT_PCT) != 2:
|
||||
fail("[switch-sticky] expected both jobs to arm analog (text: %r)" % text[-600:])
|
||||
if set(fire_by_power(stk)) != {duty_a}:
|
||||
fail("[switch-sticky] FIRE rode duties %s, expected the analog %d in both jobs"
|
||||
% (sorted(fire_by_power(stk)), duty_a))
|
||||
check_cut_spans("switch-sticky", tick_bytes(stk), 2, SWITCH_TICKS,
|
||||
"one G1 per job, both analog")
|
||||
check_termination("switch-sticky", stk)
|
||||
print("PASS [switch-sticky]: M101 P0 Q1 holds across M2; both jobs analog at "
|
||||
"duty %d" % duty_a)
|
||||
|
||||
print("PASS: all stream emission rules hold")
|
||||
|
||||
|
||||
|
||||
@@ -96,16 +96,26 @@ Drills (pass a name):
|
||||
nonzero again after its first zero past the line. Prints the
|
||||
9 s after the line at 40 ms steps. The catalog's
|
||||
laser.m5-rapid-dark is its port.
|
||||
mswitch The M101 dose-model switch on the machine, one armed run:
|
||||
a 20 mm density line at S500, M5, M101 P0 (the reply and
|
||||
the switch report are asserted), a 20 mm analog line back
|
||||
at the same S, M5, M2. PASS when the arm names the model
|
||||
and floor, the switch and the M2 revert are reported, $$
|
||||
shows each model's floor while it is in force, exactly two
|
||||
discharge segments appear with nothing after them, the
|
||||
window never re-prompts, and the current character flips
|
||||
(pulsed spikes under density, steady under analog).
|
||||
Requires laser_power_model = density (the default).
|
||||
m4corner M4 velocity-scaled power into corners and short segments:
|
||||
a corner-heavy vector pattern (a long line, 1.1 mm zigzag
|
||||
teeth, a 2 mm square, a 180 degree reversal, 0.5 mm teeth)
|
||||
at 30 %% under M4 at F2000, one pass under density. The
|
||||
floor is the guard: velocity brings the commanded power to
|
||||
the floor at every corner, and the model cannot go dark by
|
||||
construction - the operator confirms every commanded
|
||||
segment marks. One discharge window, dark after.
|
||||
m4corner [S] [F] e.g. m4corner 300 2000
|
||||
m4feeds B3, the density time base across feeds: under M4 density,
|
||||
one out-and-back 20 mm line pair per feed (default 1000 and
|
||||
4000 mm/min) at the same S, passes offset +Y, one armed
|
||||
run. M4 scales the commanded power with velocity inside a
|
||||
move, so each line should read evenly dark from its slow
|
||||
ends to its fast middle; the reversal point is where the
|
||||
factory's own compensation still let dose per mm rise
|
||||
~1.8x. The operator compares evenness within each pass and
|
||||
the reversal darkness across the two feeds. Requires
|
||||
laser_power_model = density.
|
||||
m4feeds [S] [F1] [F2] e.g. m4feeds 600 1000 4000
|
||||
dpatch Depth witness for the dose curve of the configured
|
||||
laser_power_model: two rows of small engraved patches
|
||||
(serpentine G1 fills) on the stock. Row A is CW (S1000) at
|
||||
@@ -1414,12 +1424,9 @@ def drill_dpatch(g):
|
||||
if feed_ref < 60 or not 0.05 <= pitch <= 2.0 or not 5.0 <= width <= 200.0:
|
||||
print('usage: dpatch [F mm/min >= 60] [pitch 0.05..2 mm] [length 5..200 mm]')
|
||||
return 2
|
||||
model = conf_get('laser_power_model') or 'density'
|
||||
if model not in ('density', 'analog'):
|
||||
print('unknown laser_power_model %s' % model)
|
||||
return 2
|
||||
tp_curve = DPATCH_TP_P20 if model == 'density' else DPATCH_TP_ANALOG
|
||||
unit = 'density' if model == 'density' else 'duty'
|
||||
model = 'density' # the only model on hardware
|
||||
tp_curve = DPATCH_TP_P20
|
||||
unit = 'density'
|
||||
levels, (rpm_max, rpm_min, floor, ceil) = pcurve_levels(g, DPATCH_DENSITY_PCT)
|
||||
if levels is None:
|
||||
print('PRECONDITION FAILED: cannot read $30/$31/$35/$36 (%s/%s/%s/%s)'
|
||||
@@ -2755,22 +2762,31 @@ def drill_m5dark(g):
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
MSWITCH_S = 500
|
||||
MSWITCH_MM = 20.0
|
||||
MSWITCH_FEED = 600
|
||||
# The corner pattern: a long segment above the ~1.6 mm accelerate-in-
|
||||
# and-out distance at F2000, teeth and a square well below it, and a
|
||||
# reversal - every place M4 drives the commanded power to the floor.
|
||||
M4C_PATTERN = (
|
||||
('G1', 10.0, 0.0), # long: reaches programmed feed
|
||||
('G1', 1.0, 0.6), ('G1', 1.0, -0.6), ('G1', 1.0, 0.6), ('G1', 1.0, -0.6),
|
||||
('G1', 1.0, 0.6), ('G1', 1.0, -0.6), # 1.2 mm teeth
|
||||
('G1', 2.0, 0.0), ('G1', 0.0, 2.0), ('G1', -2.0, 0.0), ('G1', 0.0, -2.0),
|
||||
('G1', 5.0, 0.0), ('G1', -5.0, 0.0), # 180 degree reversal
|
||||
('G1', 0.5, 0.4), ('G1', 0.5, -0.4), ('G1', 0.5, 0.4), ('G1', 0.5, -0.4),
|
||||
('G1', 3.0, 0.0), # finish long
|
||||
)
|
||||
M4C_ROW_GAP = 8.0 # mm between the two passes
|
||||
|
||||
|
||||
def drill_mswitch(g):
|
||||
print('=== M101 on the machine: density line, switch, analog line, revert ===')
|
||||
def drill_m4corner(g):
|
||||
sval = int(sys.argv[2]) if len(sys.argv) > 2 else 300
|
||||
feed = int(sys.argv[3]) if len(sys.argv) > 3 else 2000
|
||||
if not 50 <= sval <= 1000 or not 300 <= feed <= 6000:
|
||||
print('usage: m4corner [S 50..1000] [F 300..6000]')
|
||||
return 2
|
||||
sampler = Sampler(PCURVE_SAMPLE_HZ)
|
||||
if not sampler.local:
|
||||
print('run this on the board: the witnesses are sysfs at 25 Hz')
|
||||
return 2
|
||||
model = conf_get('laser_power_model') or 'density'
|
||||
if model != 'density':
|
||||
print('PRECONDITION FAILED: laser_power_model is %s, the drill asserts '
|
||||
'the density default and its revert' % model)
|
||||
return 2
|
||||
fails = []
|
||||
|
||||
def check(cond, msg):
|
||||
@@ -2781,70 +2797,52 @@ def drill_mswitch(g):
|
||||
def logged(needle):
|
||||
return any(needle in ln for _t, ln in g.log)
|
||||
|
||||
def floor35(text):
|
||||
for ln in text.splitlines():
|
||||
if ln.startswith('$35='):
|
||||
try:
|
||||
return float(ln[4:])
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
dx = sum(x for _c, x, _y in M4C_PATTERN)
|
||||
dy = sum(y for _c, _x, y in M4C_PATTERN)
|
||||
span_x = 0.0
|
||||
run_x = 0.0
|
||||
for _c, x, _y in M4C_PATTERN:
|
||||
run_x += x
|
||||
span_x = max(span_x, run_x)
|
||||
|
||||
print('=== M4 into corners: the pattern at S%d F%d under density ===' % (sval, feed))
|
||||
print('connect: %s' % prepare(g))
|
||||
print('pre-fire: %s' % sample_forgectrl())
|
||||
arm_cue()
|
||||
print('>>> %g mm of free +X travel at the head, scrap under it: one line' % MSWITCH_MM)
|
||||
print('>>> out under density, the switch, the same line back under analog.\n')
|
||||
print('>>> A fresh area: %g mm along +X by %g mm along +Y from the head.\n' % (span_x + 2, 4))
|
||||
sampler.start()
|
||||
aborted = False
|
||||
try:
|
||||
for ln in ('G91', 'G21'):
|
||||
g.cmd(ln)
|
||||
# Section 1: density. The M3 blocks in the arm until the press.
|
||||
g.s.sendall(b'M3 S%d\n' % MSWITCH_S)
|
||||
g.s.sendall(('G1 X%g F%d\n' % (MSWITCH_MM, MSWITCH_FEED)).encode())
|
||||
st = g.wait_state('Run', 300)
|
||||
if not st.startswith('Run'):
|
||||
print('FAIL: the job never ran (state=%s)' % st)
|
||||
g.rt(b'\x18')
|
||||
return 1
|
||||
g.wait_state('Idle', 60)
|
||||
time.sleep(0.5)
|
||||
g.drain()
|
||||
check(logged('laser armed (density, floor 10 %)'),
|
||||
'the arm names the density model and its floor')
|
||||
# The switch, spindle off.
|
||||
r = g.cmd('M5')
|
||||
check('error' not in r, 'M5 accepted (%s)' % r.replace('\n', ' '))
|
||||
r = g.cmd('M101 P0', timeout=10)
|
||||
check('ok' in r and 'error' not in r, 'M101 P0 accepted (%s)' % r.replace('\n', ' '))
|
||||
time.sleep(0.3)
|
||||
g.drain()
|
||||
check(logged('laser power model set for this program (analog, floor 16 %)'),
|
||||
'the switch is reported with the analog floor')
|
||||
f = floor35(g.cmd('$$', timeout=10))
|
||||
check(f == 16.0, '$$ shows the analog floor in force ($35=%s)' % f)
|
||||
# Section 2: analog, same S, no new press allowed.
|
||||
presses = sum(1 for _t, ln in g.log if 'press the button' in ln)
|
||||
g.s.sendall(b'M3 S%d\n' % MSWITCH_S)
|
||||
g.s.sendall(('G1 X%g F%d\n' % (-MSWITCH_MM, MSWITCH_FEED)).encode())
|
||||
st = g.wait_state('Run', 60)
|
||||
check(st.startswith('Run'), 'the analog section ran (state=%s)' % st)
|
||||
g.wait_state('Idle', 60)
|
||||
time.sleep(0.5)
|
||||
g.drain()
|
||||
check(sum(1 for _t, ln in g.log if 'press the button' in ln) == presses,
|
||||
'the open window carried across the switch: no re-prompt')
|
||||
for ln in ('M5', 'G90'):
|
||||
g.cmd(ln)
|
||||
for i, mname in enumerate(('density',)):
|
||||
g.s.sendall(b'M4 S%d\n' % sval)
|
||||
for cmd, x, y in M4C_PATTERN:
|
||||
parts = [cmd]
|
||||
if x:
|
||||
parts.append('X%g' % x)
|
||||
if y:
|
||||
parts.append('Y%g' % y)
|
||||
parts.append('F%d' % feed)
|
||||
g.s.sendall((' '.join(parts) + '\n').encode())
|
||||
g.s.sendall(b'M5\n')
|
||||
st = g.wait_state('Run', 300 if i == 0 else 60)
|
||||
if not st.startswith('Run'):
|
||||
print('FAIL: pass %d never ran (state=%s)' % (i + 1, st))
|
||||
g.rt(b'\x18')
|
||||
aborted = True
|
||||
return 1
|
||||
g.wait_state('Idle', 120)
|
||||
time.sleep(0.5)
|
||||
g.drain()
|
||||
print(' %s pass ran' % mname)
|
||||
g.s.sendall(('G0 X%g Y%g\n' % (-dx, -dy)).encode())
|
||||
g.wait_state('Idle', 30)
|
||||
check(logged('laser armed (density, floor'), 'the arm named the density model')
|
||||
g.cmd('G90')
|
||||
g.cmd('M2')
|
||||
time.sleep(0.5)
|
||||
g.drain()
|
||||
check(logged('laser power model reverted (density, floor 10 %)'),
|
||||
'M2 reverts to the density default and reports it')
|
||||
f = floor35(g.cmd('$$', timeout=10))
|
||||
check(f == 10.0, '$$ shows the density floor back ($35=%s)' % f)
|
||||
# Let the disarm land before judging the trace.
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 90:
|
||||
smp = sample_forgectrl()
|
||||
@@ -2869,33 +2867,159 @@ def drill_mswitch(g):
|
||||
cur = [smp['t'], smp['t']]
|
||||
elif on:
|
||||
cur[1] = smp['t']
|
||||
elif cur is not None and smp['t'] - cur[1] > 1.0:
|
||||
elif cur is not None and smp['t'] - cur[1] > 1.5:
|
||||
segs.append(cur)
|
||||
cur = None
|
||||
if cur:
|
||||
segs.append(cur)
|
||||
print('\n--- results (%d samples, %.1f Hz) ---' % (len(tr), sampler.rate()))
|
||||
check(len(segs) == 2, '%d discharge segment(s), expected exactly 2 (one per line)'
|
||||
% len(segs))
|
||||
if len(segs) == 2:
|
||||
for name, (a, b) in zip(('density', 'analog'), segs):
|
||||
hv = _stats(_window(tr, a + 0.3, b - 0.1, 'hv'))
|
||||
print(' %s line: %.2f s, hv mean %.0f max %d (max-mean %.0f)'
|
||||
% (name, b - a, hv['mean'], hv['max'], hv['max'] - hv['mean']))
|
||||
hv_d = _stats(_window(tr, segs[0][0] + 0.3, segs[0][1] - 0.1, 'hv'))
|
||||
hv_a = _stats(_window(tr, segs[1][0] + 0.3, segs[1][1] - 0.1, 'hv'))
|
||||
check(hv_a['max'] - hv_a['mean'] < 150,
|
||||
'the analog line is a steady discharge (max-mean %.0f)'
|
||||
% (hv_a['max'] - hv_a['mean']))
|
||||
check(hv_d['max'] - hv_d['mean'] > 250,
|
||||
'the density line is a pulsed discharge (max-mean %.0f)'
|
||||
% (hv_d['max'] - hv_d['mean']))
|
||||
t_end = segs[1][1]
|
||||
hv_after = max((smp['hv'] for smp in tr if smp['t'] > t_end + 0.3
|
||||
check(len(segs) == 1, '%d discharge window(s), expected 1' % len(segs))
|
||||
for name, (a, b) in zip(('density',), segs):
|
||||
hv = _stats(_window(tr, a + 0.2, b - 0.1, 'hv'))
|
||||
tp = _stats(_window(tr, a + 0.2, b - 0.1, 'tp'))
|
||||
print(' %s pass: %.1f s lit, hv mean %.0f max %d, tp mean %.0f'
|
||||
% (name, b - a, hv['mean'], hv['max'], tp['mean'] or 0))
|
||||
if segs:
|
||||
t_end = segs[-1][1]
|
||||
hv_after = max((smp['hv'] for smp in tr if smp['t'] > t_end + 0.5
|
||||
and smp['hv'] is not None), default=0)
|
||||
check(hv_after <= HV_DARK_MAX, 'dark after the second M5 (hv max %d)' % hv_after)
|
||||
check(hv_after <= HV_DARK_MAX, 'dark after the last M5 (hv max %d)' % hv_after)
|
||||
|
||||
print('\n--- the operator reads the material ---')
|
||||
print('Trace the whole path with your eye:')
|
||||
print(' 1. DROPOUT: any commanded segment with NO mark at all - look hardest')
|
||||
print(' at the 1.2 mm teeth, the 0.5 mm teeth and the 2 mm square,')
|
||||
print(' where M4 never lets the power rise off the floor. Under density')
|
||||
print(' a dropout should be impossible; confirm it.')
|
||||
print(' 2. The long 10 mm lines are the reference: mid-line is the')
|
||||
print(' pattern cut at full commanded power.')
|
||||
ok = not fails
|
||||
print('MSWITCH %s' % ('PASS: the switch, the floors and the revert hold on the machine'
|
||||
print('M4CORNER %s' % ('instrument checks PASS - the material verdict is yours'
|
||||
if ok else 'FAIL: %d check(s) failed' % len(fails)))
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
M4F_MM = 60.0
|
||||
M4F_LEG_GAP = 0.6 # the return leg sits beside the out leg
|
||||
M4F_ROW_GAP = 5.0
|
||||
|
||||
|
||||
def drill_m4feeds(g):
|
||||
sval = int(sys.argv[2]) if len(sys.argv) > 2 else 600
|
||||
f1 = int(sys.argv[3]) if len(sys.argv) > 3 else 1000
|
||||
f2 = int(sys.argv[4]) if len(sys.argv) > 4 else 4000
|
||||
if not 50 <= sval <= 1000 or not 300 <= f1 < f2 <= 8000:
|
||||
print('usage: m4feeds [S 50..1000] [F1] [F2] (300 <= F1 < F2 <= 8000)')
|
||||
return 2
|
||||
sampler = Sampler(PCURVE_SAMPLE_HZ)
|
||||
if not sampler.local:
|
||||
print('run this on the board: the witnesses are sysfs at 25 Hz')
|
||||
return 2
|
||||
model = conf_get('laser_power_model') or 'density'
|
||||
if model != 'density':
|
||||
print('PRECONDITION FAILED: laser_power_model is %s; B3 is the density '
|
||||
'time-base question' % model)
|
||||
return 2
|
||||
fails = []
|
||||
|
||||
def check(cond, msg):
|
||||
print(' %s: %s' % ('ok' if cond else 'FAIL', msg))
|
||||
if not cond:
|
||||
fails.append(msg)
|
||||
|
||||
print('=== B3, dose per mm across feeds: S%d under M4 density at F%d and F%d ==='
|
||||
% (sval, f1, f2))
|
||||
print('connect: %s' % prepare(g))
|
||||
print('pre-fire: %s' % sample_forgectrl())
|
||||
arm_cue()
|
||||
print('>>> A fresh area: %g mm along +X by %g mm along +Y from the head.' % (M4F_MM + 2, M4F_ROW_GAP + 4))
|
||||
print('>>> Pass 1 (F%d) cuts at the head; pass 2 (F%d) %g mm in +Y.'
|
||||
% (f1, f2, M4F_ROW_GAP))
|
||||
print('>>> Each pass is an out leg and a return leg %g mm apart - a long' % M4F_LEG_GAP)
|
||||
print('>>> U with its turn at the far end, so both legs read separately.\n')
|
||||
sampler.start()
|
||||
aborted = False
|
||||
try:
|
||||
for ln in ('G91', 'G21'):
|
||||
g.cmd(ln)
|
||||
for i, feed in enumerate((f1, f2)):
|
||||
g.s.sendall(b'M4 S%d\n' % sval)
|
||||
g.s.sendall(('G1 X%g F%d\n' % (M4F_MM, feed)).encode())
|
||||
g.s.sendall(('G1 Y%g F%d\n' % (M4F_LEG_GAP, feed)).encode())
|
||||
g.s.sendall(('G1 X%g F%d\n' % (-M4F_MM, feed)).encode())
|
||||
g.s.sendall(b'M5\n')
|
||||
g.s.sendall(('G0 Y%g\n' % -M4F_LEG_GAP).encode())
|
||||
st = g.wait_state('Run', 300 if i == 0 else 60)
|
||||
if not st.startswith('Run'):
|
||||
print('FAIL: pass %d never ran (state=%s)' % (i + 1, st))
|
||||
g.rt(b'\x18')
|
||||
aborted = True
|
||||
return 1
|
||||
g.wait_state('Idle', 120)
|
||||
time.sleep(0.5)
|
||||
g.drain()
|
||||
print(' pass at F%d ran' % feed)
|
||||
if i == 0:
|
||||
g.s.sendall(('G0 Y%g\n' % M4F_ROW_GAP).encode())
|
||||
g.wait_state('Idle', 30)
|
||||
g.cmd('G90')
|
||||
g.cmd('M2')
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 90:
|
||||
smp = sample_forgectrl()
|
||||
if smp and not smp['armed']:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
time.sleep(1.5)
|
||||
except Exception as e:
|
||||
aborted = True
|
||||
print('ABORTED: %s' % e)
|
||||
g.rt(b'\x18')
|
||||
finally:
|
||||
sampler.stop()
|
||||
if aborted:
|
||||
return 1
|
||||
|
||||
tr = sampler.samples
|
||||
segs, cur = [], None
|
||||
for smp in tr:
|
||||
on = smp['hv'] is not None and smp['hv'] > HV_DARK_MAX
|
||||
if on and cur is None:
|
||||
cur = [smp['t'], smp['t']]
|
||||
elif on:
|
||||
cur[1] = smp['t']
|
||||
elif cur is not None and smp['t'] - cur[1] > 1.5:
|
||||
segs.append(cur)
|
||||
cur = None
|
||||
if cur:
|
||||
segs.append(cur)
|
||||
print('\n--- results (%d samples, %.1f Hz) ---' % (len(tr), sampler.rate()))
|
||||
check(len(segs) == 2, '%d discharge window(s), expected 2 (one per feed)' % len(segs))
|
||||
for feed, (a, b) in zip((f1, f2), segs):
|
||||
hv = _stats(_window(tr, a + 0.2, b - 0.1, 'hv'))
|
||||
tp = _stats(_window(tr, a + 0.2, b - 0.1, 'tp'))
|
||||
print(' F%d pass: %.1f s lit, hv mean %.0f, tp mean %.0f (the beam at '
|
||||
'cruise should read alike at both feeds: M4 commands S at speed)'
|
||||
% (feed, b - a, hv['mean'], tp['mean'] or 0))
|
||||
if segs:
|
||||
t_end = segs[-1][1]
|
||||
hv_after = max((smp['hv'] for smp in tr if smp['t'] > t_end + 0.5
|
||||
and smp['hv'] is not None), default=0)
|
||||
check(hv_after <= HV_DARK_MAX, 'dark after the last M5 (hv max %d)' % hv_after)
|
||||
|
||||
print('\n--- the operator reads the material ---')
|
||||
print('Two out-and-back line pairs, F%d nearest you first, F%d %g mm past it.' % (f1, f2, M4F_ROW_GAP))
|
||||
print(' 1. EVENNESS within each pass: each line should be equally dark from')
|
||||
print(' its slow ends to its fast middle - that is M4 holding dose per')
|
||||
print(' mm constant through the accel. Ends darker than the middle =')
|
||||
print(' partial compensation (the factory rides a ~1.8x rise).')
|
||||
print(' 2. The REVERSAL point (far end) is the worst case - compare its')
|
||||
print(' darkness against the mid-line of the same pass, at both feeds.')
|
||||
print(' 3. ACROSS the passes: the faster pass is expected ~%gx lighter per' % (float(f2) / f1))
|
||||
print(' mm overall (feed is the dose control at cruise); the question is')
|
||||
print(' whether the within-line evenness holds at both.')
|
||||
ok = not fails
|
||||
print('M4FEEDS %s' % ('instrument checks PASS - the material verdict is yours'
|
||||
if ok else 'FAIL: %d check(s) failed' % len(fails)))
|
||||
return 0 if ok else 1
|
||||
|
||||
@@ -2988,7 +3112,7 @@ def main():
|
||||
'pthresh': drill_pthresh, 'dladder': drill_dladder,
|
||||
'pcurve': drill_pcurve, 'm5dark': drill_m5dark,
|
||||
'dpatch': drill_dpatch, 'flowload': drill_flowload,
|
||||
'mswitch': drill_mswitch,
|
||||
'm4corner': drill_m4corner, 'm4feeds': drill_m4feeds,
|
||||
'senderchg': drill_senderchg, 'overrun': drill_overrun,
|
||||
'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart}
|
||||
if drill not in drills:
|
||||
|
||||
Reference in New Issue
Block a user