XY microstep modes: the baseline, the catalog test and the bench tools

The baseline derives x/y_mode, step_freq, ramp_rate and the configured
markers from the xy_microsteps setting; ramp_rate joins the GRBL
controller's set (the driver writes it; the cloud client runs at the
module's). motion.microstep-modes cycles 8, 16 and 32: the save restarts
the idle controller, the kernel reads the mode with its tick and ramp,
$100/$101 are the mode's and a typed $100 is overwritten, a 40 mm jog at
top speed returns to Idle with the kernel counters over the mode agreeing
with the commanded travel and the accelerometer seeing the head move; the
setting is put back as found.

Bench tools: xy_mode_test.py (the null-sink harness the grblHAL CI runs),
raster_dry.py (a top-speed raster per mode), xy_pattern_accel.py (the
operator's pattern from home with the machine silent and the head
accelerometer listening), arc_tolerance_sweep.py (a $12 ladder on the 9
in circle: the chord rate the protocol loop feeds, about 300 a second, is
the ceiling, not the core), and the xymode and xycircle live drills.
BRINGUP carries the present state and the facts; CAMPAIGN-LOG the dated
record, including the planner-blocks spin (a $398 of 255 or more loops
forever at start, a core bug) and its recovery.
This commit is contained in:
ScottW514
2026-09-07 18:05:28 -04:00
parent bfb5cb27d8
commit 8fc5250d6d
13 changed files with 1974 additions and 26 deletions
+83 -4
View File
@@ -142,7 +142,8 @@ core mutex stands in for interrupt masking. `GFSINK` unset = null-sink mode
environment (on a Windows host, inside the WSL distro). Produces
`build-arm/grblHAL_glowforge`
in the checkout (`-O1 -g`; machine constants force-included into the core:
53.333 µsteps/mm XY @ ×8, 2.832 half-steps/mm Z, 0.417" Z travel,
53.333 µsteps/mm XY at ×8 (106.667 at ×16, 213.333 at ×32, the
`xy_microsteps` setting, 8 by default), 2.832 half-steps/mm Z, 0.417" Z travel,
12000 mm/min max, 700/590 mm/s² accel - factory-derived, see
`puls_profile.py`).
2. Deploy: move the new binary over `/usr/bin/grblHAL_glowforge` (mv replaces
@@ -152,12 +153,14 @@ core mutex stands in for interrupt masking. `GFSINK` unset = null-sink mode
3. Standalone start (bench/debug only - requires forgectrl stopped, since the
broker's exclusive hold on `/dev/glowforge` makes any self-open fail EBUSY):
`cd /data/forgefirm && GFSINK=/dev/glowforge grblHAL_glowforge -p 23 -e
/data/forgefirm/EEPROM-glowforge.DAT`. Env knobs: `GFSINK_RATE` (machine
tick, default 28160 Hz = factory travel tick), `GFSINK_DEPTH_MS` (queue
/data/forgefirm/EEPROM-glowforge.DAT`. Env knobs: `GFSINK_RATE` (a machine
tick override; the default is the `xy_microsteps` mode's: 28160 Hz at 8, the
factory travel tick, 56320 at 16, 112640 at 32), `GFSINK_DEPTH_MS` (queue
depth = feed-hold latency, default 200). Standalone, the driver opens the
device itself and every takeover runs the `rail_settle_s` off-period; under
the broker it inherits the fd and skips the settle (the rail never dropped). The driver
applies the full analog machine config at init either way (×8 modes, decay 1,
applies the full analog machine config at init either way (the `xy_microsteps`
mode on both axes, 8 by default, with the tick and the stop ramp scaled to it; decay 1,
motor_lock 0 with every axis in the pulse path and the Z soft limit always
on, laser latched, PIC hold currents) and swaps PIC run/hold
currents around motion. Each motion run logs a producer-stats line
@@ -991,6 +994,40 @@ is committed.
Travel moves peak 202 mm/s vector (≈ 8 in/s) at STfr=28160 Hz;
prints and hunts run STfr=10000. Cut feed in the sample print: 145 mm/s. Z
cadence ≈ 61–115 ms per half-step (≈ 5.7 mm/s max).
- **XY microstep modes** (bench, dry, the hot-deployed driver and
forgectrl): the machine holds its 200 mm/s top speed at 8, 16 and 32
with the tick scaled (28160, 56320, 112640 Hz) and the stop ramp with it
(125000, 250000, 500000 Hz/s). A 60-pass raster of 150 mm at F12000 (63 s)
returns the kernel counters to dx = dy = 0.000 at every mode, with zero
underruns and zero clamped events; the controller takes 25.6, 28.3 and
35.0 percent of the core at 8, 16 and 32 during it, and 25.0, 25.6 and
26.6 percent in `motion.step-timing-under-load` against the nice-5 hog.
Every dry catalog motion test passes at every mode, drift 0.000 mm, the
cancel's return exact. A mode change from the panel restarts the idle
controller and the kernel reads the new mode about 2 s later. Under the
laser (a 152.4 mm circle at S400 under M4 density at F2400, one per
mode): the discharge window 12.0 s at every mode against 12.0 s
expected, the HV current mean 402, 391 and 398 at 8, 16 and 32, dark
after M5, zero underruns; the laser ticks scaled to the tick in force
keep the pulse period a time, and the material shows it. By the head
accelerometer with the machine silent (the quiet hold: every fan, the
pump and the TEC off; a fixed 10 s wait; about 600 Hz over the bus at
the crash watch's 4 g scale), over a pattern at F12000 from home (18 by
9 in, a 9 in circle): the cruise RMS on the circle 2289, 1939 and 1638
counts in X at 8, 16 and 32 (Y 1343, 1192, 1181), overall 1742, 1500 and
1369 in X, the single-axis legs alike within a few percent, the rest
floor about 50 (about 200 with the pump running: the pump is in the
reading); the finer mode is the quieter one, most where both axes move.
- **Arc chords**: grblHAL traces an arc as chords of sagitta `$12` (0.002 mm
default), each a planner block, so a 9 in circle at F12000 is 531 chords
of 1.35 mm and 148 block boundaries a second, an audible tone that no
microstep mode changes. The protocol loop feeds about 300 chords a second
at top speed with the planner kept full; above that (`$12` 0.00025 and
finer on that radius) the feed sags mid-arc and the circle takes longer,
with the CPU flat at 33 percent and no underrun, and a deeper planner
buffer (`$398` 250) changes nothing. Finer chords move the tone up and
do not lower the vibration. `$398` at 255 or more spins the controller
at start (a core bug, "Next work"); the usable maximum is 254.
- **Factory analog config** (constant across all captured jobs, 2018→2026):
PIC currents X 135 run / 33 hold, Y 22 run / 5 hold (axis DAC scales differ by
design); x/y_decay=1; ×8 microstepping; run currents applied only while
@@ -1425,6 +1462,48 @@ feature requests, enhancements) will eventually be tracked as GitHub issues.
far): re-measure the two heat coefficients and the machine's
air-assist offset; and if a lit check still trips, the
void-on-emission design with the tube as its own flow tracer.
8. **XY microstep modes (8, 16, 32).** One setting, `xy_microsteps`, is
the XY scale: the GRBL controller reads it at its start and derives
`$100`/`$101`, its machine tick (28160 Hz at 8, doubled at 16,
quadrupled at 32) and the kernel stop ramp from it, forgectrl admits
the three values and restarts an idle GRBL controller on a change, and
the catalog's `motion.microstep-modes` cycles the modes. Host-proven
(the driver's `xy_scale_test` and the `xy_mode_test.py` harness, the
forgectrl and forgetest unit tests, the coverage lint) and dry
bench-proven at all three modes on the hot-deployed binaries: the
catalog test, `motion.step-timing-under-load`, `motion.pacing` and
`motion.cancel-abort` at each mode, and a 60-pass top-speed raster
(`scripts/bench/raster_dry.py`) at each, every counter back to its
start, zero underruns, zero clamped events, the controller at 25 to
35 percent of the core, and live under the laser at all three modes
(a 152.4 mm circle at S400 F2400 per mode, the HV current at cruise
alike across them, the operator's verdict "perfect" on each; the
facts bank has the numbers, `scripts/bench/live_fire_drills.py`
`xymode` and `xycircle` are the drills), and by the head
accelerometer with every fan off (`scripts/bench/xy_pattern_accel.py`
through the new `POST /cool/quiet`): the vibration at 200 mm/s falls
with the finer mode, most on a circle (x RMS down 29 percent at 32).
Uncommitted. Owed: the commits, pins, image and campaign. 32 is admitted; the
fallbacks, should another machine not hold it, are the 84480 Hz tick
or the `$110` ceiling the driver holds under a lower tick. Cloud mode
runs at the service's own 8: the service plans every stream at 8
whatever the machine reports (the machine reports 1, as the factory
firmware does, and every header comes back 8), and the cloud client
now refuses a header at any other mode before a byte reaches the
ring, the way it refuses a wrong serial or format. Finer modes in
cloud mode (a local stream expander in the feeder, or the service
planning finer) wait for a user's report of such a refusal, by
decision.
9. **Planner buffer of 255 blocks or more spins the controller.** The
core's `plan_reset_buffer()` links the block ring with a `uint_fast8_t`
index, so `$398` at 255 or more never finishes at start: the main
thread sits at 100 percent in `plan_reset()`, the Grbl port stops
answering, and forgectrl still reports the controller running (the
supervisor sees deaths and silent armed reporters, not an idle spin).
Reproduced on the host build. Owed in the fork (openglow-org
grblHAL-core, branch forgefirm): the index widened to `uint_fast16_t`,
or the sanity check capped at 254, and a host test that starts the
null-sink build with `$398=400`. Until then the usable maximum is 254.
**Deliberately not gated:** an armed GRBL job after an underrun cuts at the
stale origin unless homing is required (GRBL mode permits unhomed cutting; the
+205
View File
@@ -9336,6 +9336,211 @@ emergency lever, and it safes the machine with `cnc/stop` and the latch
before the signal instead. The mode switch, the cooling gate and the daemon
shutdown do gate on `machine_is_idle()`, which reads `cnc/state`.
## 2026-09-07: XY microstep modes 8, 16 and 32, dry bench proof
One setting, `xy_microsteps`, became the XY scale in GRBL mode: the driver
derives `$100`/`$101`, its machine tick (28160 Hz at 8, 56320 at 16,
112640 at 32) and the kernel stop ramp (125000, 250000, 500000 Hz/s) from
it, forgectrl admits the three values and restarts an idle GRBL controller
on a change, and the catalog gained `motion.microstep-modes`. Host proof
first: the driver's `xy_scale_test` and the `xy_mode_test.py` null-sink
harness, the forgectrl and forgetest unit tests, and the coverage lint (82
tests, 0 uncovered). Then the bench, 18:27 to 18:45Z, on image
20260907005922 (dev) with the cross-built forgectrl (sha256 01ed40d3) and
driver (83893a00) hot-deployed through `/etc/init.d/forgectrl stop` and
`start`, and the forgetest files with a forgetest restart (queue idle;
catalog 82 tests).
- `motion.microstep-modes` PASS in 24 s: at 8, 16 and 32 the kernel read
the mode on both axes with the mode's tick and ramp, `$100`/`$101` were
the mode's, a typed `$100=53.333` came back as the mode's value on the
spot, a 40 mm jog at F12000 measured 39.990, 40.000 and 40.000 mm by the
kernel counters, drift 0.000, and the head accelerometer saw the head on
every leg. The first attempt failed on the test itself (it read the
counters before the kernel had played out the queue behind grblHAL's
Idle); the test now waits for the machine to be idle first.
- `motion.step-timing-under-load`, `motion.pacing` and
`motion.cancel-abort` PASS at every mode. The controller under the
nice-5 hog: 25.0, 25.6 and 26.6 percent of the core at 8, 16 and 32;
zero clamped events; `cnc/underruns` 0 throughout; the pacing move
30.000 mm with drift 0.000; the cancel's return exact.
- The dry raster (`scripts/bench/raster_dry.py`, 60 passes of 150 mm at
F12000 with the laser off, 63 s per mode) PASS at every mode: the kernel
counters back to dx = dy = 0.000, Grbl drift 0.0, zero underruns, zero
clamped events; controller CPU 25.6, 28.3 and 35.0 percent at 8, 16
and 32.
- A mode change from the panel restarts the controller and the kernel
reads the new mode about 2 s later; each switch in the drills settled in
2 s.
**Live fire, 19:03 to 19:12Z, operator present, one armed run per turn,
the button pressed by the operator for each.** Two new drills in
`scripts/bench/live_fire_drills.py`: `xymode` (a 20 mm out-and-back line
pair per feed, F1200 and F6000, at S400 under M4 density) and `xycircle`
(a 152.4 mm circle from its top at S400 under M4 density at F2400, the
operator's request for something the eye can follow), each a dark rapid
+X by an offset first and a rapid back to the origin after. The mode was
set from the settings before each run (the controller restarted, the
kernel at the new mode within 2 s each time).
| Run | Mode, tick, ramp | Lit | HV current mean | Thermopile mean |
|---|---|---|---|---|
| line pair at offset 0 | x8, 28160 Hz, 125000 Hz/s | 2.1 s (F1200), 0.7 s (F6000) | 385, 382 | 2621, 2197 |
| circle at 76.2 mm | x8, 28160 Hz, 125000 Hz/s | 12.0 s (12.0 expected) | 402 | 2558 |
| circle at 101.6 mm | x16, 56320 Hz, 250000 Hz/s | 12.0 s | 391 | 2570 |
| circle at 127 mm | x32, 112640 Hz, 500000 Hz/s | 12.0 s | 398 | 2507 |
Every run: one discharge window (two for the line pair), dark after M5
(HV max 0), `cnc/underruns` 0, the machine idle with the latch locked
after. The HV current at cruise reads alike at the three modes, which is
what the scaling of the laser ticks to the tick in force was built to
give; the operator judged each circle "perfect" against the one before.
No scope check of the FIRE period: the operator ruled the arithmetic
sufficient (the period is the reference ticks times the tick ratio, a
whole number at every mode).
**The head accelerometer, every fan off, 19:29 to 19:36Z.** forgectrl
gained `POST /cool/quiet` (the engine's quiet hold the commissioning finder
uses, now reachable by the bench tools; taken only from an idle machine,
released by the engine when a run session opens or after 600 s; `/cool/status`
shows `quiet_hold`), hot-deployed (sha256 051804c4) and exercised: the air
assist, exhaust, intake and purge all read 0 while held and came back to the
idle posture on release. The machine was homed (`$H`, 48 s, `H:1`, Z 3.080)
and `scripts/bench/xy_pattern_accel.py` ran the operator's pattern at each
mode: from home to (18, 9) in, to (9, 9) in, a 9 in circle from its
mid-bottom back to (9, 9), to (9, 0), home, every leg at F12000, the fans
held quiet with the fixed 10 s wait, the head accelerometer read over the
bus at about 600 Hz (CTRL4 0x04, the crash watch's 4 g scale) and the
cruise windows trimmed 0.35 s at each end. Every leg reached 12000 mm/min
and ended on its waypoint to 0.003 mm; the circle took 3.87 s at every
mode; the kernel counters came back to dx = dy = 0.000; underruns 0.
| Cruise RMS (raw counts, mean removed) | x8 | x16 | x32 |
|---|---|---|---|
| at rest, fans off | x 197, y 123 | x 213, y 129 | x 246, y 138 |
| the diagonal to (18, 9) | x 1093, y 1100 | x 1083, y 1076 | x 1055, y 1078 |
| the 9 in circle | x 2210, y 1392 | x 1832, y 1189 | x 1573, y 1166 |
| the return legs (mean of three) | x 1145, y 1073 | x 1045, y 1076 | x 1005, y 983 |
| overall | x 1693, y 1231 | x 1458, y 1133 | x 1317, y 1098 |
The vibration at speed falls with the finer mode on every leg that moves
both axes, most on the circle (x RMS down 17 percent at 16 and 29 percent
at 32 against 8; the peak-to-peak on the circle 21229, 18141, 12480); the
single-axis legs sit within a few percent of each other. The records with
the raw traces are beside the image under `images/20260907005922/bench-data/`.
**The same pattern with the machine silent, 19:45 to 19:49Z.** The operator
asked for the coolant pump off too, so the modes could be heard: the quiet
hold now takes every fan, the pump and the TEC (forgectrl sha256 38b79727,
hot-deployed; the tick leaves the TEC alone while the hold stands and
rewrites it at the release; the pump comes back at the release). The rest
floor with the pump off is a quarter of what it was with it on (x RMS 50,
57 and 52 counts at 8, 16 and 32 against about 200 before: the pump was in
the reading), and the pattern gave the same picture:
| Cruise RMS (raw counts, mean removed), pump off | x8 | x16 | x32 |
|---|---|---|---|
| the diagonal to (18, 9) | x 1111, y 1134 | x 1038, y 1101 | x 1097, y 1110 |
| the 9 in circle | x 2289, y 1343 | x 1939, y 1192 | x 1638, y 1181 |
| overall | x 1742, y 1209 | x 1500, y 1110 | x 1369, y 1118 |
Every leg again at 12000 mm/min, ending on its waypoint, the circle 3.88 s
at every mode, the kernel counters back to zero, underruns 0, and the idle
posture back after each release (air assist 204, purge on, pump on). The
machine was homed again first (`$H`, 51 s) because the forgectrl restart
had dropped the homed flag.
**How fine an arc, 19:55 to 20:40Z.** The circle sounded the same at every
mode, with the same harmonic through parts of the arc, and the arithmetic
says why: grblHAL traces an arc as chords whose sagitta is `$12`, every
chord a planner block, so the 9 in circle at F12000 under the default
0.002 mm is 531 chords of 1.35 mm and 148 block boundaries a second, a
148 Hz tone that the planner's geometry makes and the motor's step grid
cannot change. `scripts/bench/arc_tolerance_sweep.py` ran a `$12` ladder on
the circle at x16, the machine silent, to see how far down the one core
takes it:
| `$12` | Chords | Boundaries/s | Circle | Lowest mid feed | Free planner blocks (of 100) | CPU | Circle X RMS |
|---|---|---|---|---|---|---|---|
| 0.002 | 531 of 1.35 mm | 148 | 3.86 s | 12000 | 0 to 77 | 32.6 % | 1823 |
| 0.001 | 751 of 0.96 mm | 209 | 3.85 s | 12000 | 0 to 65 | 33.0 % | 1815 |
| 0.0005 | 1062 of 0.68 mm | 296 | 3.87 s | 12000 | 0 to 45 | 33.1 % | 1835 |
| 0.00025 | 1502 of 0.48 mm | 418 | 3.89 s | 10592 | 0 to 23 | 33.2 % | 1911 |
| 0.0001 | 2374 of 0.30 mm | 661 | 4.13 s | 6220 | 0 to 2 | 33.2 % | 2262 |
No clamped events and no underrun at any rung; the CPU flat at 33 percent
all the way down, so the core is not what runs out. Above about 300
chords a second the feed sags mid-circle and the circle takes longer: the
planner plans to a stop at the end of what it holds, and with 100 blocks of
0.3 mm it holds 30 mm against a 29 mm stopping distance from 200 mm/s.
Finer chords did not lower the vibration; the tone moved up (148, 209,
296 Hz) and the RMS stayed put, then rose where the feed sagged. The same
ladder with `$398` raised to 250 blocks gave the same numbers (10706 and
6355 mm/min at the two failing rungs, the buffer never more than about 80
blocks full, CPU 34 percent): the chord rate the protocol loop feeds is the
ceiling, not the buffer depth and not the core. The finest `$12` that holds
top speed on this radius is 0.0005; at a lower feed a finer value holds in
proportion, and a smaller radius reaches the same chord rate sooner. The
records (the 100-block ladder) are beside the image under
`images/20260907005922/bench-data/`.
Two findings on the way. A trial launched with `$398=400` left the
controller spinning at start: the core's `plan_reset_buffer()` links the
block ring with a `uint_fast8_t` index, so a buffer of 255 blocks or more
never finishes (the sanity check allows up to 1000). The main thread sat
at 100 percent in `plan_reset()`, the port's backlog filled and connects
timed out, and forgectrl kept reporting the controller running and
verified. Reproduced on the host null-sink build under gdb. Recovered by
stopping the controller through forgectrl, rewriting the planner-blocks
value and the block's Modbus CRC-16 in the settings store, and starting
it again. The fork owes the one-line fix (`uint_fast16_t`); until then the
usable maximum is 254, and the deeper-buffer trial ran at 250. And the
launch that wrote 400 was a remote command the operator had rejected at
the prompt: the process on the board had already started and ran on as an
orphan, which is a rule for the bench now (check the board after any
rejected or interrupted remote command before re-issuing).
**The pre-commit review of the catalog, 20:40 to 20:55Z.** Read against
every change, it turned up four things, each fixed and proven before any
commit. The quiet hold had taken the pump for the commissioning finder as
well, which was proven with the pump running: the hold is now two levels,
every fan off (`cool_quiet_hold`, the finder's) and the pump and the TEC
too only on request (`POST /cool/quiet?on=1&pump=1`, the bench tools').
The engine's release of the hold on a run session had been level-triggered,
which would have dropped the finder's hold after a burn when the
controller's report still said run; it now fires on the session's opening
edge (the flood rising or the armed window opening), verified on the bench
by taking the pump hold and sending `M8`: the hold released within 3 s
and the pump came back. The forgetest baseline had kept `cnc/ramp_rate`
among the values it holds in every mode, while the driver now writes it
and the cloud client runs at the module's; it is in the GRBL controller's
set now, skipped in cloud mode. And the forgectrl dev mock is held to the
daemon's tables by a unit test, so the new setting and the new route
needed their mock entries, and the bench README lists every tool. Also
checked: the docs site builds clean under strict mode, the style check is
clean, the coverage lint is clean, every host test passes (the driver's
five C tests and four harnesses, forgectrl's eight C tests and the mock
parity test, forgetest's suite), and the deployed forgectrl (sha256
323a7d14) shows both hold levels and the release on the bench.
**Cloud mode, decided at 21:05Z.** The captures say the service plans
every stream at 8 and writes 8 into every header (23 of 23 here, the
factory captures the same) while the machine reports 1, so the reported
value is not what it plans with. The operator does not expect the service
to plan finer, so the cloud client now refuses a header whose `XSmm` or
`YSmm` is anything but 8, with the serial and format checks, before a byte
reaches the ring; a header without the tags runs at 8. Host-proven in
`gfutilities` (`test_puls_header.py`); finer modes in cloud mode wait for a
user's report of such a refusal.
Left after the session: the board in GRBL mode at 8 with the key cleared,
`$12` 0.002 and `$398` 100 as found, homed, `/tmp` clean, `/data` holding
only factory state and ForgeFIRM's own files, the hot-deployed binaries in
place until the next flash. Cloud mode was left at the service's own 8 by
decision: the service plans every stream at 8 whatever the machine
reports. Nothing committed at the time of writing: the operator's order is
no commit until the code is proven, and the commit is the operator's call.
## Reference notes
### Head-IRQ source validation — the beam-emission hypothesis
+82 -21
View File
@@ -46,10 +46,11 @@ from .log import now_ts
# motor_lock/x_mode/y_mode/x_decay/y_decay and the hold currents are the
# GRBL controller's init writes (glowforge_io.c): motor_lock 0, every axis
# in the pulse path (a job's Z moves the lens; the driver's Z soft limit
# guards it), step_freq its default machine tick, ramp_rate the module
# default; streaming is only ever 1 inside a live job; the head white LED
# is a camera lamp, off at idle; the loop heater and TEC are the
# diagnostics' tools, off at idle.
# guards it), step_freq its machine tick and ramp_rate the kernel stop
# ramp, both derived from the XY microstep mode (the values here are the
# x8 default's; fixed_sysfs() gives the mode's); streaming is only ever 1
# inside a live job; the head white LED is a camera lamp, off at idle; the
# loop heater and TEC are the diagnostics' tools, off at idle.
FIXED_SYSFS = [
("cnc/motor_lock", "0"),
("cnc/x_mode", "8"),
@@ -68,11 +69,12 @@ FIXED_SYSFS = [
# The subset the GRBL controller writes at its start: checked and restored
# in GRBL mode only. The cloud client sets its own values for these from
# every pulse header (step_freq 10 kHz, the run currents) and hands the
# hold currents back at idle; forcing the GRBL values under it would be
# the baseline configuring another controller's machine.
# every pulse header (step_freq 10 kHz, the run currents), runs at the
# service's own x8 with the module's ramp whatever xy_microsteps says, and
# hands the hold currents back at idle; forcing the GRBL values under it
# would be the baseline configuring another controller's machine.
GRBL_CONTROLLER_SYSFS = ("cnc/motor_lock", "cnc/x_mode", "cnc/y_mode", "cnc/x_decay", "cnc/y_decay",
"cnc/step_freq", "pic/x_step_current", "pic/y_step_current")
"cnc/step_freq", "cnc/ramp_rate", "pic/x_step_current", "pic/y_step_current")
# Settings the baseline never hands back as bare settings: controller_mode
# is the persisted mirror of the live mode (the mode item restores it
@@ -117,6 +119,41 @@ GRBL_PORT_S = 30 # the Grbl port after the supervisor reports
XY_STEPS_PER_MM = 53.333 # boards/glowforge.h (x8 microstepping)
RETURN_MAX_MM = 100.0 # a displaced head is jogged back at most this far
# The XY microstep mode (the xy_microsteps setting: 8, 16 or 32; unset =
# 8). The GRBL controller reads it at its start and derives its scale,
# its machine tick and the kernel stop ramp from it (boards/glowforge.h):
# the fixed values of x/y_mode, step_freq and ramp_rate are the mode's.
XY_MODES = (8, 16, 32)
XY_MODE_DEFAULT = 8
XY_STEPS_PER_MM_OF = {8: 53.333, 16: 106.667, 32: 213.333} # $100/$101 per mode
XY_TICK_X8_HZ = 28160 # the x8 machine tick
XY_RAMP_X8_HZ_PER_S = 125000 # the kernel stop ramp at it
def xy_mode_of(settings):
"""The mode a /settings body names (unset, unreadable, or not a mode
reads as the default)."""
try:
mode = int((settings or {}).get("xy_microsteps") or XY_MODE_DEFAULT)
except (TypeError, ValueError, AttributeError):
return XY_MODE_DEFAULT
return mode if mode in XY_MODES else XY_MODE_DEFAULT
def xy_steps_per_mm(mode):
return XY_STEPS_PER_MM_OF[mode]
def fixed_sysfs(mode=XY_MODE_DEFAULT):
"""FIXED_SYSFS with the mode's own x/y_mode, step_freq and ramp_rate:
the tick and the ramp scale with the mode (x16 doubles both, x32
quadruples them)."""
k = mode // XY_MODE_DEFAULT
own = {"cnc/x_mode": str(mode), "cnc/y_mode": str(mode),
"cnc/step_freq": str(XY_TICK_X8_HZ * k),
"cnc/ramp_rate": str(XY_RAMP_X8_HZ_PER_S * k)}
return [(attr, own.get(attr, val)) for attr, val in FIXED_SYSFS]
def leds_root():
r = os.environ.get("GF_LEDS_ROOT") or "/sys/class/leds/"
@@ -228,6 +265,12 @@ class Baseline:
except hw.HwError as e:
return None, str(e)
def xy_mode(self):
"""The XY microstep mode the settings name now (the default when
forgectrl does not answer)."""
st, body = self.fc_get("/settings")
return xy_mode_of(body if st == 200 and isinstance(body, dict) else None)
def wait_settled(self, timeout=SETTLE_S, unreachable_s=10):
"""Block until forgectrl reports a settled supervisor: motion
verified (the probe passed), motion-fault (the ladder exhausted),
@@ -510,7 +553,7 @@ class Baseline:
if residue:
left.append(Leftover("pulse ring", "%d unplayed bytes" % residue, "0 (nothing queued)",
"unrestorable: the next run would replay them first"))
for attr, want in FIXED_SYSFS:
for attr, want in fixed_sysfs(self.xy_mode()):
if self.cloud_mode() and attr in GRBL_CONTROLLER_SYSFS:
continue
got = hw.sysfs_read(attr)
@@ -703,7 +746,7 @@ def check_fixed_against(ref, log):
machine worth a look, not a leftover."""
diffs = []
sysfs = ref.get("sysfs") or {}
for attr, want in FIXED_SYSFS + IDLE_READBACKS:
for attr, want in fixed_sysfs(ref_xy_mode(ref)) + IDLE_READBACKS:
got = sysfs.get(attr)
if got is not None and got != want:
diffs.append("%s: boot=%s constant=%s" % (attr, got, want))
@@ -719,34 +762,52 @@ def check_fixed_against(ref, log):
# the state /mode already calls "running", because "running" is the spawn,
# not the config. motor_lock is no marker: the probe and the controller
# both leave it 0.
CONFIGURED_MARKERS = [(a, dict(FIXED_SYSFS)[a]) for a in ("cnc/step_freq", "cnc/y_mode")]
CONFIGURED_MARKER_ATTRS = ("cnc/step_freq", "cnc/y_mode")
CONFIGURED_MARKERS = [(a, dict(FIXED_SYSFS)[a]) for a in CONFIGURED_MARKER_ATTRS]
CONFIGURED_TIMEOUT_S = 20
CONFIGURED_SETTLE_S = 1.0
def configured_markers(mode=XY_MODE_DEFAULT):
"""The markers with the mode's own values (the tick and the mode both
follow the setting)."""
return [(a, dict(fixed_sysfs(mode))[a]) for a in CONFIGURED_MARKER_ATTRS]
def ref_xy_mode(ref):
"""The XY microstep mode a saved reference was taken under: its own
/settings dump, else the default."""
fc = (ref or {}).get("forgectrl") or {}
return xy_mode_of(fc.get("/settings") if isinstance(fc, dict) else None)
def reference_preconfig(ref):
"""True when a saved reference shows the pre-controller state: every
marker present differs from its fixed value (the probe's step_freq and
the module's y_mode together), i.e. it was dumped before the
controller's init writes landed."""
sysfs = (ref or {}).get("sysfs") or {}
seen = [(sysfs.get(a), want) for a, want in CONFIGURED_MARKERS if sysfs.get(a) is not None]
markers = configured_markers(ref_xy_mode(ref))
seen = [(sysfs.get(a), want) for a, want in markers if sysfs.get(a) is not None]
return bool(seen) and all(got != want for got, want in seen)
def wait_controller_configured(log, mode_body, timeout=CONFIGURED_TIMEOUT_S, sleep=time.sleep):
def wait_controller_configured(log, mode_body, timeout=CONFIGURED_TIMEOUT_S, sleep=time.sleep,
xy_mode=XY_MODE_DEFAULT):
"""After the supervisor reports the controller running: block until the
GRBL controller's init writes have landed (the CONFIGURED_MARKERS read
their fixed values), then a short settle. Only the GRBL controller
writes those; in any other mode return at once. Bounded: on timeout
the caller proceeds and the reference will say so."""
GRBL controller's init writes have landed (the configured markers read
the fixed values of the XY microstep mode in force), then a short
settle. Only the GRBL controller writes those; in any other mode
return at once. Bounded: on timeout the caller proceeds and the
reference will say so."""
if not (isinstance(mode_body, dict) and mode_body.get("controller") == "running"
and (mode_body.get("mode") or "grbl") == "grbl"):
return True
markers = configured_markers(xy_mode)
t0 = time.time()
while time.time() - t0 < timeout:
got = [(a, hw.sysfs_read(a)) for a, _ in CONFIGURED_MARKERS]
if all(g == want for (a, g), (_, want) in zip(got, CONFIGURED_MARKERS)):
got = [(a, hw.sysfs_read(a)) for a, _ in markers]
if all(g == want for (a, g), (_, want) in zip(got, markers)):
sleep(CONFIGURED_SETTLE_S)
log("baseline: controller configured %.1f s after running" % (time.time() - t0))
return True
@@ -755,7 +816,7 @@ def wait_controller_configured(log, mode_body, timeout=CONFIGURED_TIMEOUT_S, sle
sleep(0.25)
log("baseline: WARNING - controller did not apply its config within %d s (%s); "
"the reference may show the supervisor's probe values"
% (timeout, ", ".join("%s=%s" % (a, hw.sysfs_read(a)) for a, _ in CONFIGURED_MARKERS)))
% (timeout, ", ".join("%s=%s" % (a, hw.sysfs_read(a)) for a, _ in markers)))
return False
@@ -794,7 +855,7 @@ def boot_reference(log, data_dir):
return None
bl = Baseline(log)
mode = bl.wait_settled()
wait_controller_configured(log, mode)
wait_controller_configured(log, mode, xy_mode=bl.xy_mode())
ref = dump_all(bl)
ref.update({"ts": now_ts(), "boot_id": bid, "uptime_s": uptime_s()})
try:
+32
View File
@@ -249,6 +249,38 @@ TOOLS = [
"desc": "The Z soft limit belongs to the driver, not to $20: an unreferenced Z is collapsed to where the "
"lens stands, and neither a $20 nor a $132 write frees it. A CI harness (the grblHAL repo): needs "
"the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
{"id": "raster-dry", "title": "Dry top-speed raster per XY microstep mode", "script": "raster_dry.py",
"safety": "dry", "where": "board", "ported": True,
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
"desc": "For each mode given: stores xy_microsteps, waits for the restarted controller, streams a 60-pass "
"raster of 150 mm at F12000 with the laser off, and reports the peak feed, the controller CPU, the "
"kernel counters against the start, underruns and clamped events. Needs the head with 150 mm of "
"free +X and 12 mm of free +Y travel, no other Grbl client. Ends at x8."},
{"id": "xy-pattern-accel", "title": "XY microstep modes by the head accelerometer, the machine silent",
"script": "xy_pattern_accel.py", "safety": "dry", "where": "board", "ported": True,
"args": [_arg("modes", "choice", "8 16 32", "the modes to run, in order", ["8", "16", "32", "8 16 32"])],
"desc": "From home at F12000: to (18, 9) in, to (9, 9) in, a 9 in circle from its mid-bottom back to "
"(9, 9), to (9, 0), home. Per mode: every fan, the coolant pump and the TEC commanded off through "
"the engine's quiet hold (the machine silent, the modes audible), the fixed 10 s wait, the head "
"accelerometer sampled over the bus at 800 Hz through the pattern; "
"cruise-window RMS and peak-to-peak per leg and overall, the kernel counters against home, "
"underruns; JSON with the trace to the bench data directory. Needs the machine homed and at home, "
"the lid closed, no other Grbl client, the bed clear. Ends at x8."},
{"id": "arc-tolerance-sweep", "title": "How fine an arc the controller can plan ($12 ladder)",
"script": "arc_tolerance_sweep.py", "safety": "dry", "where": "board", "ported": True,
"args": [_arg("mode", "choice", "16", "the XY microstep mode", ["8", "16", "32"], flag="--mode"),
_arg("ladder", "str", "0.002 0.001 0.0005 0.00025 0.0001", "the $12 values to try, in order")],
"desc": "From home, the machine silent: to (9, 9) in, then the 9 in circle at F12000 once per $12 in the "
"ladder. Per rung: the chords and boundaries a second, the circle time against the ideal, the lowest "
"feed mid-circle, the fewest free planner blocks, the controller CPU, clamped events, underruns, the "
"accelerometer's cruise RMS. $12 goes back to what it was; the head returns home. Needs the machine "
"homed and at home, the lid closed, no other Grbl client."},
{"id": "xy-mode-test", "title": "XY microstep mode harness", "script": "xy_mode_test.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "The XY scale is the microstep mode's, never typed: xy_microsteps sets $100/$101 and the "
"machine tick, a typed $100 is overwritten, and $110/$111 are held under a lowered tick. A CI "
"harness (the grblHAL repo): needs the host-built null-sink controller, not the machine, so it "
"is not a bench-page tool."},
{"id": "puls-profile", "title": "Factory .puls profile decoder", "script": "puls_profile.py",
"safety": "dry", "where": "host", "ported": False, "args": [],
"desc": "Decodes factory pulse streams into velocity/accel profiles. Runs anywhere; needs a .puls file "
+1 -1
View File
@@ -23,7 +23,7 @@ job's length for its progress report.
import gzip
import struct
STEPS_PER_MM = 53.333 # boards/glowforge.h, x8 microstepping
STEPS_PER_MM = 53.333 # x8 microstepping: the service's scale, which cloud mode runs at
# The header of a factory print for this machine type, as captured.
PRINT_HEADER = {
+140
View File
@@ -9,6 +9,7 @@ import os
import time
from ..catalog import test
from .. import baseline as _baseline
from .. import hw
from ..runner import Failed
@@ -289,6 +290,145 @@ def jog_roundtrip(ctx):
"motion on %d of %d legs", len(moves), maxrate, drift, max(p2px, p2py), len(moving), len(moves))
# ---------------------------------------------------------- microstep modes
def grbl_setting(g, key):
"""One $-setting's value from the controller, or None."""
for line in g.command(key):
if line.startswith(key + "="):
try:
return float(line[len(key) + 1:].split()[0])
except ValueError:
return None
return None
def set_xy_mode(ctx, fc, value):
"""Store xy_microsteps (empty = clear it) and wait for the GRBL
controller forgectrl restarts for it to come back configured, with
the Grbl port answering. Returns the /mode body the machine settled
on."""
if value:
st, body = fc.post("/settings", data={"xy_microsteps": value})
else:
st, body = fc.post("/settings", params={"xy_microsteps": ""})
ctx.check(st == 200, "could not set xy_microsteps=%r (%s: %s)", value or "(clear)", st, body)
mode = int(value or _baseline.XY_MODE_DEFAULT)
ctx.sleep(2.0) # the controller's stop is under way
bl = _baseline.Baseline(ctx.log, abort=ctx.run.aborted.is_set)
body = bl.wait_settled()
ctx.check(isinstance(body, dict) and body.get("controller") == "running",
"the controller did not come back after the xy_microsteps write (/mode: %s)", body)
ctx.check(_baseline.wait_controller_configured(ctx.log, body, xy_mode=mode),
"the controller came back but did not apply its x%d config", mode)
end = time.time() + _baseline.GRBL_PORT_S
while True:
ctx.checkpoint()
try:
with hw.Grbl():
return body
except OSError:
if time.time() > end:
raise Failed("the Grbl port did not answer within %d s of the restart"
% _baseline.GRBL_PORT_S)
time.sleep(1.0)
@test("motion.microstep-modes",
title="XY microstep modes: the setting drives the drivers, the scale and the tick",
subsystem="motion", kind="auto", mode="grbl", est_min=5,
covers=_MOTION_COVERS + [("forgectrl", "src/main.c"), ("forgectrl", "src/settings.*")],
requires=["motion.jog-roundtrip"],
steps=["Bed clear, lid closed; the head needs 40 mm of free +X travel."],
description="One setting, xy_microsteps, is the XY scale. For each mode it admits (8, 16, "
"32): the save restarts the idle controller; the kernel reads the mode on both "
"axes with the mode's machine tick and stop ramp; $100/$101 are the mode's and a "
"typed $100 is overwritten on the spot; a 40 mm jog out and back at the top speed "
"returns to Idle with the kernel counters, over the mode, agreeing with the "
"commanded travel, no drift, and the head accelerometer seeing the head move. "
"The setting is put back as found, with the controller restarted for it.")
def microstep_modes(ctx):
ev = ctx.evidence
fc = ctx.forgectrl
ctx.check(hw.AccelSampler().available,
"no head accelerometer found (i2c %s): the motion witness is missing", hw.HEAD_ACCEL_I2C)
was = (fc.settings() or {}).get("xy_microsteps") or ""
ev["xy_microsteps_before"] = was or "(unset)"
results = []
try:
for mode in _baseline.XY_MODES:
ctx.checkpoint()
ctx.log("--- x%d ---", mode)
set_xy_mode(ctx, fc, str(mode))
r = {"mode": mode, "sysfs": {}}
results.append(r) # in the evidence even when a check fails
for attr, want in _baseline.fixed_sysfs(mode):
if attr not in ("cnc/x_mode", "cnc/y_mode", "cnc/step_freq", "cnc/ramp_rate"):
continue
got = hw.sysfs_read(attr)
r["sysfs"][attr] = got
ctx.check(got == want, "x%d: %s reads %s, want %s", mode, attr, got, want)
spm = _baseline.xy_steps_per_mm(mode)
accel = hw.AccelSampler()
with ctx.grbl() as g, accel:
clean_slate(ctx, g)
for key in ("$100", "$101"):
v = grbl_setting(g, key)
r[key] = v
ctx.check(v is not None and abs(v - spm) < 0.001,
"x%d: %s=%s, want %.3f", mode, key, v, spm)
g.command("$100=53.333")
v = grbl_setting(g, "$100")
r["typed_100"] = v
ctx.check(v is not None and abs(v - spm) < 0.001,
"x%d: a typed $100 stuck at %s (want %.3f: the scale is the mode's)", mode, v, spm)
start = g.status_report()["MPos"]
k0 = kernel_xy_mm(ctx)
t0 = time.time()
rr = g.command("$J=G91X40F12000")
ctx.check(not any(x.startswith("error") for x in rr), "x%d: jog refused: %s", mode, rr)
peak, states, _ = wait_idle(ctx, g, 30)
ctx.check("TIMEOUT" not in states, "x%d: the jog out did not return to Idle: %s", mode, states)
# grblHAL's Idle leads the kernel by the stream depth and
# the decel tail: the counters are read once the machine
# itself is idle.
machine_idle(ctx)
out_mm = kernel_xy_mm(ctx)[0] - k0[0]
r["kernel_out_mm"] = round(out_mm, 3)
r["peak"] = peak
ctx.check(abs(out_mm - 40.0) <= 0.2,
"x%d: the kernel counters say %.3f mm for a 40 mm jog: the scale and the "
"drivers' mode disagree", mode, out_mm)
rr = g.command("$J=G91X-40F12000")
ctx.check(not any(x.startswith("error") for x in rr), "x%d: jog back refused: %s", mode, rr)
peak2, states, _ = wait_idle(ctx, g, 30)
ctx.check("TIMEOUT" not in states, "x%d: the jog back did not return to Idle: %s", mode, states)
machine_idle(ctx)
t1 = time.time()
final = g.status_report()["MPos"]
drift = abs(final[0] - start[0])
p2px, p2py, n = accel.p2p(t0, t1)
r.update({"drift_mm": round(drift, 3), "accel_p2p": [p2px, p2py], "accel_samples": n})
ctx.log("x%d: out %.3f mm by the kernel, peak %.0f/%.0f mm/min, drift %.3f mm, "
"accel p2p x=%d y=%d over %d samples",
mode, out_mm, peak, peak2, drift, p2px, p2py, n)
ctx.check(drift <= 0.05, "x%d: position drift %.3f mm", mode, drift)
ctx.check(max(peak, peak2) >= 6000, "x%d: the jog peaked at only %.0f mm/min", mode,
max(peak, peak2))
ctx.check(max(p2px, p2py) >= hw.ACCEL_P2P_MOVING,
"x%d: the head did not move during the jog (accel p2p x=%d y=%d, below %d): "
"the counters ran without the gantry", mode, p2px, p2py, hw.ACCEL_P2P_MOVING)
machine_idle(ctx)
finally:
ev["modes"] = results
set_xy_mode(ctx, fc, was)
ev["xy_microsteps_after"] = (fc.settings() or {}).get("xy_microsteps") or "(unset)"
ctx.log("PASS: %s", "; ".join("x%d: %.3f mm, drift %.3f, accel %d"
% (r["mode"], r["kernel_out_mm"], r["drift_mm"], max(r["accel_p2p"]))
for r in results))
# ---------------------------------------------------------------- liveness
@test("motion.liveness-probe", title="Supervisor motion-liveness verdict", subsystem="motion",
+59
View File
@@ -271,6 +271,65 @@ class BaselineTests(unittest.TestCase):
{"sysfs": {"cnc/motor_lock": "8", "cnc/step_freq": "10000", "cnc/y_mode": "8"}}))
self.assertFalse(baseline.reference_preconfig({"sysfs": {}}))
# -- the XY microstep mode drives the fixed values ------------------------
def test_the_fixed_values_follow_the_microstep_mode(self):
x8 = dict(baseline.fixed_sysfs(8))
self.assertEqual(x8, dict(baseline.FIXED_SYSFS))
x16 = dict(baseline.fixed_sysfs(16))
self.assertEqual((x16["cnc/x_mode"], x16["cnc/y_mode"], x16["cnc/step_freq"], x16["cnc/ramp_rate"]),
("16", "16", "56320", "250000"))
x32 = dict(baseline.fixed_sysfs(32))
self.assertEqual((x32["cnc/x_mode"], x32["cnc/step_freq"], x32["cnc/ramp_rate"]),
("32", "112640", "500000"))
# everything else is the same list, in the same order
self.assertEqual([a for a, _ in baseline.fixed_sysfs(32)], [a for a, _ in baseline.FIXED_SYSFS])
self.assertEqual(x32["cnc/x_decay"], x8["cnc/x_decay"])
def test_the_mode_is_read_from_the_settings_with_a_default(self):
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "16"}), 16)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "32"}), 32)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": ""}), 8)
self.assertEqual(baseline.xy_mode_of({}), 8)
self.assertEqual(baseline.xy_mode_of(None), 8)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "24"}), 8)
self.assertEqual(baseline.xy_mode_of({"xy_microsteps": "abc"}), 8)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": {"xy_microsteps": "16"}}}), 16)
self.assertEqual(baseline.ref_xy_mode({"forgectrl": {"/settings": None}}), 8)
self.assertEqual(baseline.ref_xy_mode({}), 8)
def test_fixed_constants_are_checked_at_the_reference_mode(self):
ref = {"sysfs": {"cnc/x_mode": "16", "cnc/step_freq": "56320", "cnc/ramp_rate": "250000"},
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}
self.assertEqual(baseline.check_fixed_against(ref, self.lines.append), [])
ref["forgectrl"]["/settings"]["xy_microsteps"] = "8"
diffs = baseline.check_fixed_against(ref, self.lines.append)
self.assertEqual(diffs, ["cnc/x_mode: boot=16 constant=8",
"cnc/step_freq: boot=56320 constant=28160",
"cnc/ramp_rate: boot=250000 constant=125000"])
def test_wait_configured_watches_the_mode_in_force(self):
self._probe_state()
calls = {"n": 0}
def sleep(_s):
calls["n"] += 1
if calls["n"] == 2: # an x16 controller's init writes land
self._attr("cnc/step_freq", "56320")
self._attr("cnc/y_mode", "16")
ok = baseline.wait_controller_configured(
self.lines.append, {"controller": "running", "mode": "grbl", "motion": "verified"},
timeout=5, sleep=sleep, xy_mode=16)
self.assertTrue(ok)
self.assertTrue(any("controller configured" in l for l in self.lines))
# a reference taken under x16 is not pre-config
self.assertFalse(baseline.reference_preconfig(
{"sysfs": {"cnc/step_freq": "56320", "cnc/y_mode": "16"},
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}))
self.assertTrue(baseline.reference_preconfig(
{"sysfs": {"cnc/step_freq": "10000", "cnc/y_mode": "1"},
"forgectrl": {"/settings": {"xy_microsteps": "16"}}}))
def test_stale_preconfig_reference_is_retaken_on_a_fresh_boot(self):
os.environ["FORGETEST_BOOT_ID"] = "test-boot-2"
try:
+4
View File
@@ -35,6 +35,10 @@ page's takeover does that; from a host, stop them first.
| `z_envelope_test.py` | Host-side Z envelope harness (null-sink controller): the Z soft limit belongs to the driver, not to `$20`, so the driver re-applies `sys.work_envelope`, `sys.homed` and `sys.soft_limits` for Z from the settings-changed chain. Checks that an unreferenced Z is collapsed to where the lens stands and blocks a move each way, that X and Y stay free, and that neither a `$20` write (the core clears the soft-limit mask in the setter) nor a `$132` write (which un-homes the axis as well) frees Z. 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 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. |
| `xy_mode_test.py` | Host-side XY microstep mode harness (null-sink controller): `xy_microsteps` in the shared config sets `$100`/`$101` and the machine tick, a typed `$100` is overwritten on the spot, a value that is not a mode falls back to x8 with a warning, and `$110`/`$111` are held under a tick lowered with `GFSINK_RATE`. Runs in the grblHAL repo's CI. |
| `raster_dry.py` | A dry raster at top speed at each XY microstep mode (runs on the board; the controller in GRBL mode, idle, no other Grbl client): per mode given (default 8, 16, 32) it stores `xy_microsteps` through forgectrl, waits for the restarted controller, streams 60 passes of 150 mm at F12000 with the laser off, and reports the peak feed, the controller CPU, the kernel counters against the start, `cnc/underruns` and any clamped-event line. Needs 150 mm of free +X and 12 mm of free +Y travel. Ends at x8. |
| `xy_pattern_accel.py` | The XY microstep modes by the head accelerometer with the machine silent (runs on the board; the machine homed and at home, the lid closed, no other Grbl client): per mode it takes the cooling engine's quiet hold with the pump (`POST /cool/quiet?on=1&pump=1`: every fan, the pump and the TEC off), waits the fixed 10 s, samples the LIS2HH12 over `/dev/i2c-3` in one SMBus block transaction per reading at about 600 Hz, and runs the pattern from home at F12000: to (18, 9) in, to (9, 9) in, a 9 in circle from its mid-bottom back to (9, 9), to (9, 0), home. Reports the cruise-window RMS and peak-to-peak per leg and overall, the leg times, the kernel counters against home and `cnc/underruns`; JSON with the trace in the bench data directory. Ends at x8. |
| `arc_tolerance_sweep.py` | How fine an arc the controller can plan (runs on the board; the machine homed and at home, the lid closed, no other Grbl client, the machine silent through the quiet hold): a `$12` ladder on the 9 in circle at F12000 at the mode given (`--mode`, default 16). Per rung: the chords and the chord boundaries a second, the circle time against the ideal, the lowest feed mid-circle and the fewest free planner blocks, the controller CPU, clamped events, underruns and the accelerometer's cruise RMS. `$12` goes back to what it was on every exit path; the head returns home. |
| `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). |
| `lens_travel.py` | Lens travel drill (runs on the board; stops the controller through forgectrl and restarts it; lens motion only): steps the lens over sysfs the way the focus card's lens home does and counts, per condition and round, the hall's hysteresis band (down off the rising edge until the hall leaves home, back up until it reads home), the rising edge's height above the bottom stop after a drive onto it, and the top stop's height above the edge (a drive up, down until the hall leaves home, back up; the difference). Conditions: the card's half-step drive onto the stop, a short drive, a long one, the factory's full-step home mode; `--ladder` drives a list of half-step descents below the leave-home point and counts each back (exact until the stop, short by an even number after a stall), `--current` picks the run current, the hold current, or hold down / run up, `--settle` and `--cadence` vary the timing; `--park bottom|top|edge-N|edge+N` holds the lens there for a depth-gauge reading with the controller in standby, `--park edge` returns it to the rising edge and restarts the controller. Stall drills on the bench reference machine only. The posture found is put back. |
| `lens_stop_accel.py` | Lens stop detection by the head accelerometer (runs on the board; stops the controller through forgectrl and restarts it; lens motion only; stall drills on the bench reference machine only): reads the LIS2HH12 straight over `/dev/i2c-3` in six-byte bursts at 800 Hz (the iio path waits a sample period per read), steps the lens one half-step at a time from the hall's rising edge toward each stop and past it, and prints per step the peak-to-peak on each axis. A free step rings strongly on every second half-step; at a stop the ring dies, and a rotor slip a few steps later is a burst three times any free ring. `--find N` runs the contact rule N times per stop (a strong-parity step ringing under `--thresh`, or a burst over four times it, calls contact; the lens backs off `--back` and the count home proves no slip). `--save` keeps the per-step sample traces as JSON. |
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""How fine an arc the one core can plan: a $12 ladder on the 9 in circle (on the board).
Usage: arc_tolerance_sweep.py [--mode M] [tolerances...]
(default: --mode 16, the ladder 0.002 0.001 0.0005 0.00025 0.0001)
grblHAL traces an arc as chords whose sagitta is $12, so the chord length
is about 2 sqrt(2 r $12) and every chord is a planner block: on the 9 in
circle at F12000 the default 0.002 mm gives 1.35 mm chords and 148 block
boundaries a second, which is the tone the circle plays at every
microstep mode. A finer $12 moves the tone up and asks the single core
for more blocks a second. This drill finds where that stops holding:
with the machine silent (the engine's quiet hold, the fixed 10 s wait)
and the head accelerometer listening, it moves from home to (9, 9) in,
then for each $12 in the ladder runs the 9 in circle from its mid-bottom
at F12000 and reports the chords, the boundaries a second, the circle
time against the ideal, the lowest feed in the middle of the circle and
the fewest free planner blocks (a starved planner shows as both), the
controller CPU over the circle, any clamped-event line the driver logged,
cnc/underruns, and the accelerometer's cruise RMS. $12 goes back to what
it was, on every exit path, and the head returns home.
Needs the controller in GRBL mode, homed and standing at home, the lid
closed, no other Grbl client, and the bed clear across the circle.
"""
import json
import math
import os
import re
import subprocess
import sys
import time
from gfbench import data_path
from xy_pattern_accel import (Accel, Sampler, Grbl, fail, preflight, quiet, set_mode, stats,
sysfs, window, FEED, IN, QUIET_S, TRIM_S)
LOG = "/data/log/forgefirm/grblhal/grblhal.log"
RADIUS = 4.5 * IN
CIRCLE = "G2 X0 Y0 I0 J%.3f F%d" % (-RADIUS, FEED)
DEFAULT_LADDER = [0.002, 0.001, 0.0005, 0.00025, 0.0001]
def parse_args(argv):
mode = 16
tols = []
it = iter(argv)
for a in it:
if a == "--mode":
mode = int(next(it))
else:
tols += [float(x) for x in a.split()]
return mode, tols or DEFAULT_LADDER
def controller_pid():
out = subprocess.run(["pidof", "grblHAL_glowforge"], capture_output=True, text=True).stdout.split()
return int(out[0]) if out else None
def cpu_ticks(pid):
with open("/proc/%d/stat" % pid) as f:
s = f.read().split()
return int(s[13]) + int(s[14])
def grbl_setting(g, key):
g.s.sendall((key + "\n").encode())
g.s.settimeout(1.0)
buf = ""
end = time.time() + 1.5
while time.time() < end and "ok" not in buf:
try:
buf += g.s.recv(4096).decode(errors="replace")
except OSError:
break
m = re.search(r"^%s=(\S+)" % re.escape(key), buf, re.M)
return m.group(1) if m else None
def chords(tol):
seg = math.floor(math.pi * RADIUS / math.sqrt(tol * (2 * RADIUS - tol)))
return seg, 2 * math.pi * RADIUS / seg
def run_circle(g, pid):
"""The circle, timed by the state transitions and watched at about
40 Hz for the feed and the planner's free blocks."""
k0 = int(sysfs("cnc/underruns"))
log_off = os.path.getsize(LOG) if os.path.exists(LOG) else 0
c0 = cpu_ticks(pid)
g.s.sendall((CIRCLE + "\n").encode())
t_run = t_idle = None
feeds, bfs = [], []
end = time.time() + 120
while time.time() < end:
g.s.sendall(b"?")
g.s.settimeout(1.0)
buf = ""
t_end = time.time() + 1.0
while time.time() < t_end and ">" not in buf:
try:
buf += g.s.recv(4096).decode(errors="replace")
except OSError:
break
if "error:" in buf or "ALARM" in buf:
fail("the controller answered %r" % buf.strip()[-200:])
m = re.search(r"<(\w+)[^>]*", buf)
st = m.group(1) if m else None
now = time.monotonic()
f = re.search(r"FS:([\d.]+),", buf)
b = re.search(r"Bf:(\d+),", buf)
if st and st.startswith("Run"):
if t_run is None:
t_run = now
if f:
feeds.append((now, float(f.group(1))))
if b:
bfs.append((now, int(b.group(1))))
elif st and st.startswith("Idle") and t_run is not None:
t_idle = now
break
elif st and st.startswith("Alarm"):
fail("alarm during the circle: %s" % st)
time.sleep(0.02)
if t_idle is None:
fail("the circle did not finish")
cpu = 100.0 * (cpu_ticks(pid) - c0) / (os.sysconf("SC_CLK_TCK") * (t_idle - t_run))
g.drain()
clamped = []
if os.path.exists(LOG):
with open(LOG, errors="replace") as fh:
fh.seek(log_off)
clamped = [l.strip() for l in fh if "late events clamped" in l or "underrun" in l]
# the middle of the circle: the ramps at each end left out
mid = [(t, v) for t, v in feeds if t_run + TRIM_S <= t <= t_idle - TRIM_S]
mid_bf = [v for t, v in bfs if t_run + TRIM_S <= t <= t_idle - TRIM_S]
return {"t_run": t_run, "t_idle": t_idle, "seconds": round(t_idle - t_run, 2), "cpu_pct": round(cpu, 1),
"peak_feed": max((v for _, v in feeds), default=0), "min_mid_feed": min((v for _, v in mid), default=0),
"min_free_blocks": min(mid_bf, default=None), "max_free_blocks": max(mid_bf, default=None),
"status_samples": len(feeds), "clamped": clamped, "underruns": [k0, int(sysfs("cnc/underruns"))]}
def main():
mode, ladder = parse_args(sys.argv[1:])
home = preflight()
set_mode(mode)
pid = controller_pid()
g = Grbl()
accel = sampler = None
orig = None
rows = []
stamp = time.strftime("%Y%m%d%H%M%S")
try:
st, pos, _ = g.status()
if not st or not st.startswith("Idle"):
fail("controller is %s, not Idle" % st)
if abs(pos[0] - home[0]) > 0.05 or abs(pos[1] - home[1]) > 0.05:
fail("the head is not at home (MPos %s): run $H first" % (pos,))
orig = grbl_setting(g, "$12")
if orig is None:
fail("could not read $12")
print("x%d: step_freq %s; $12 now %s; the ladder %s" % (mode, sysfs("cnc/step_freq"), orig, ladder))
print("quiet hold: %s" % quiet(True))
time.sleep(QUIET_S)
accel = Accel()
accel.start()
sampler = Sampler(accel)
sampler.start()
g.cmd("G21")
g.cmd("G91")
g.run_leg("G1 X%.3f Y%.3f F%d" % (9 * IN, 9 * IN, FEED))
ideal = 2 * math.pi * RADIUS * 60.0 / FEED
for tol in ladder:
# plain decimals only: the controller reads no exponent, so a
# "5e-05" would land as 5 mm
r = g.cmd("$12=%.6f" % tol)
got = grbl_setting(g, "$12")
if "ok" not in r or got is None:
fail("$12=%g refused: %r" % (tol, r.strip()))
# $$ shows three decimals; the stored float keeps what was typed
# (the core parses the value with strtof and rounds nothing).
if abs(float(got) - tol) > 0.0006:
fail("$12=%g reads back as %s" % (tol, got))
seg, chord = chords(tol)
time.sleep(1.5)
res = run_circle(g, pid)
xs, ys = window(sampler.samples, res["t_run"] + TRIM_S, res["t_idle"] - TRIM_S)
res.update({"tol": tol, "reported": got, "chords": seg, "chord_mm": round(chord, 3),
"boundaries_per_s": round(seg / ideal, 0), "accel_x": stats(xs), "accel_y": stats(ys)})
rows.append(res)
ok = (res["min_mid_feed"] >= 0.97 * FEED and not res["clamped"] and res["underruns"][0] == res["underruns"][1]
and res["seconds"] <= ideal + 0.6)
res["result"] = "PASS" if ok else "FAIL"
print("$12=%-8g %5d chords of %.3f mm, %4.0f/s | circle %.2f s (ideal %.2f) | mid feed min %5.0f | "
"planner free min %s max %s | CPU %5.1f %% | clamped %d | underruns %d->%d | accel x rms %7s y rms %7s | %s"
% (tol, seg, chord, res["boundaries_per_s"], res["seconds"], ideal, res["min_mid_feed"],
res["min_free_blocks"], res["max_free_blocks"], res["cpu_pct"], len(res["clamped"]),
res["underruns"][0], res["underruns"][1], res["accel_x"]["rms"], res["accel_y"]["rms"],
res["result"]))
for l in res["clamped"][:3]:
print(" ", l)
time.sleep(2.0)
finally:
try:
if orig is not None:
g.cmd("$12=%.6f" % float(orig))
print("$12 restored to %s" % grbl_setting(g, "$12"))
except Exception as e:
print("WARNING: could not restore $12: %s" % e)
try:
quiet(False)
except SystemExit:
print("WARNING: the quiet hold did not release; the engine drops it itself within 600 s")
try:
if sampler is not None:
sampler.stop()
finally:
if accel is not None:
accel.restore()
try:
st, pos, _ = g.status()
if pos and (abs(pos[0] - home[0]) > 0.05 or abs(pos[1] - home[1]) > 0.05):
g.run_leg("G1 X%.3f Y%.3f F%d" % (home[0] - pos[0], home[1] - pos[1], FEED))
print("head back at home: MPos %s" % (g.status()[1],))
g.cmd("G90")
finally:
g.close()
path = data_path("arc_tolerance_sweep_x%d_%s.json" % (mode, stamp))
with open(path, "w") as f:
json.dump({"mode": mode, "feed": FEED, "radius_mm": RADIUS, "rows": rows}, f)
print("record: %s" % path)
return 0 if rows and all(r["result"] == "PASS" for r in rows) else 1
if __name__ == "__main__":
sys.exit(main())
+264
View File
@@ -113,6 +113,26 @@ Drills (pass a name):
the reversal darkness across the two feeds. Requires
laser_power_model = density.
m4feeds [S] [F1] [F2] e.g. m4feeds 600 1000 4000
xymode The XY microstep mode under the laser: one out-and-back
20 mm line pair per feed (default 1200 and 6000 mm/min) at
the same S under M4 density, passes offset +Y, one armed
run, the block shifted +X by an offset so the runs at 8, 16
and 32 sit side by side for the operator's eye. Reports the
mode, tick and ramp the kernel holds, the discharge window
and the HV current per pass: with the laser ticks scaled to
the tick in force, the current at cruise should read alike
at every mode, and the operator compares the marks. Set the
mode from the panel (xy_microsteps) before each run.
xymode [S] [F1] [F2] [X offset mm] e.g. xymode 400 1200 6000 25
xycircle The XY microstep mode under the laser, the operator's eye
version: a dark rapid +X by an offset, then one full circle
of the given diameter starting at its top (the center below
the start in +Y) at S under M4 density, laser off, and a
rapid back to the origin. One armed run; the runs at 8, 16
and 32 use different offsets so the circles sit side by
side. Reports the mode, tick and ramp, the discharge window
and the HV current.
xycircle [S] [F] [X offset mm] [diameter mm] e.g. xycircle 400 2400 76.2 152.4
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
@@ -175,6 +195,7 @@ The G-4 arm-refuses-when-a-fire-gate-is-active drill is operator-manual
(kill the pump during the button wait); this harness prints the cue.
"""
import json
import math
import os
import re
import socket
@@ -3018,6 +3039,248 @@ def drill_m4feeds(g):
return 0 if ok else 1
XYM_MM = 20.0
XYM_LEG_GAP = 0.6
XYM_ROW_GAP = 5.0
def drill_xymode(g):
sval = int(sys.argv[2]) if len(sys.argv) > 2 else 400
f1 = int(sys.argv[3]) if len(sys.argv) > 3 else 1200
f2 = int(sys.argv[4]) if len(sys.argv) > 4 else 6000
xoff = float(sys.argv[5]) if len(sys.argv) > 5 else 0.0
if not 50 <= sval <= 1000 or not 300 <= f1 < f2 <= 12000 or not 0 <= xoff <= 300:
print('usage: xymode [S 50..1000] [F1] [F2] [X offset 0..300] (300 <= F1 < F2 <= 12000)')
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' % model)
return 2
def sysfs(attr):
with open(SYSFS + '/' + attr) as f:
return f.read().strip()
fails = []
def check(cond, msg):
print(' %s: %s' % ('ok' if cond else 'FAIL', msg))
if not cond:
fails.append(msg)
mode, tick, ramp = sysfs('cnc/x_mode'), sysfs('cnc/step_freq'), sysfs('cnc/ramp_rate')
print('=== XY microstep mode under the laser: x%s, %s Hz tick, %s Hz/s ramp, S%d under M4 density at F%d and F%d ==='
% (mode, tick, ramp, sval, f1, f2))
check(sysfs('cnc/y_mode') == mode, 'both axes at x%s' % mode)
print('connect: %s' % prepare(g))
print('pre-fire: %s' % sample_forgectrl())
arm_cue()
print('>>> The block: %g mm along +X by %g mm along +Y, starting %g mm in +X from the head.'
% (XYM_MM + 2, XYM_ROW_GAP + 4, xoff))
print('>>> Pass 1 (F%d) first, pass 2 (F%d) %g mm past it in +Y; each pass an out leg'
% (f1, f2, XYM_ROW_GAP))
print('>>> and a return leg %g mm apart. The head comes back to where it started.\n' % XYM_LEG_GAP)
sampler.start()
aborted = False
try:
for ln in ('G91', 'G21'):
g.cmd(ln)
if xoff:
g.cmd('G0 X%g' % xoff)
g.wait_state('Idle', 30)
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' % (XYM_MM, feed)).encode())
g.s.sendall(('G1 Y%g F%d\n' % (XYM_LEG_GAP, feed)).encode())
g.s.sendall(('G1 X%g F%d\n' % (-XYM_MM, feed)).encode())
g.s.sendall(b'M5\n')
g.s.sendall(('G0 Y%g\n' % -XYM_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' % XYM_ROW_GAP).encode())
g.wait_state('Idle', 30)
g.s.sendall(('G0 Y%g\n' % -XYM_ROW_GAP).encode())
g.wait_state('Idle', 30)
if xoff:
g.cmd('G0 X%g' % -xoff)
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 x%s (%d samples, %.1f Hz) ---' % (mode, 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' % (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)
check(sysfs('cnc/underruns') == '0', 'no underrun (cnc/underruns %s)' % sysfs('cnc/underruns'))
print('\n--- the operator reads the material ---')
print('Two line pairs, F%d nearest the start, F%d %g mm past it in +Y, this block %g mm in +X.'
% (f1, f2, XYM_ROW_GAP, xoff))
print(' Compare this block against the other modes: evenness along each line, the')
print(' darkness at cruise, the reversal at the far end. The current per pass above')
print(' should read alike across the modes: the laser ticks are scaled to the tick.')
ok = not fails
print('XYMODE x%s %s' % (mode, 'instrument checks PASS - the material verdict is yours'
if ok else 'FAIL: %d check(s) failed' % len(fails)))
return 0 if ok else 1
def drill_xycircle(g):
sval = int(sys.argv[2]) if len(sys.argv) > 2 else 400
feed = int(sys.argv[3]) if len(sys.argv) > 3 else 2400
xoff = float(sys.argv[4]) if len(sys.argv) > 4 else 76.2
dia = float(sys.argv[5]) if len(sys.argv) > 5 else 152.4
if not 50 <= sval <= 1000 or not 300 <= feed <= 12000 or not 0 <= xoff <= 300 or not 10 <= dia <= 250:
print('usage: xycircle [S 50..1000] [F 300..12000] [X offset 0..300] [diameter 10..250]')
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' % model)
return 2
def sysfs(attr):
with open(SYSFS + '/' + attr) as f:
return f.read().strip()
fails = []
def check(cond, msg):
print(' %s: %s' % ('ok' if cond else 'FAIL', msg))
if not cond:
fails.append(msg)
r = dia / 2.0
mode, tick, ramp = sysfs('cnc/x_mode'), sysfs('cnc/step_freq'), sysfs('cnc/ramp_rate')
print('=== XY microstep mode under the laser: x%s, %s Hz tick, %s Hz/s ramp, a %g mm circle at S%d under M4 density at F%d ==='
% (mode, tick, ramp, dia, sval, feed))
check(sysfs('cnc/y_mode') == mode, 'both axes at x%s' % mode)
print('connect: %s' % prepare(g))
print('pre-fire: %s' % sample_forgectrl())
arm_cue()
print('>>> A dark rapid %g mm in +X, then the circle from its top: %g mm across, its' % (xoff, dia))
print('>>> center %g mm in +Y from the start, so it spans %g mm each side of the start' % (r, r))
print('>>> and %g mm in +Y. Laser off, then a rapid back to the origin.\n' % dia)
sampler.start()
aborted = False
try:
for ln in ('G91', 'G21'):
g.cmd(ln)
if xoff:
g.cmd('G0 X%g' % xoff)
g.wait_state('Idle', 60)
g.s.sendall(b'M4 S%d\n' % sval)
g.s.sendall(('G2 X0 Y0 I0 J%g F%d\n' % (r, feed)).encode())
g.s.sendall(b'M5\n')
st = g.wait_state('Run', 300)
if not st.startswith('Run'):
print('FAIL: the circle never ran (state=%s)' % st)
g.rt(b'\x18')
aborted = True
return 1
g.wait_state('Idle', 300)
time.sleep(0.5)
g.drain()
print(' the circle ran')
if xoff:
g.cmd('G0 X%g' % -xoff)
g.wait_state('Idle', 60)
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 x%s (%d samples, %.1f Hz) ---' % (mode, len(tr), sampler.rate()))
check(len(segs) == 1, '%d discharge window(s), expected 1' % len(segs))
for (a, b) in segs:
hv = _stats(_window(tr, a + 0.2, b - 0.1, 'hv'))
tp = _stats(_window(tr, a + 0.2, b - 0.1, 'tp'))
print(' circle: %.1f s lit (expected about %.1f s at F%d), hv mean %.0f, tp mean %.0f'
% (b - a, math.pi * dia * 60.0 / feed, feed, 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 M5 (hv max %d)' % hv_after)
check(sysfs('cnc/underruns') == '0', 'no underrun (cnc/underruns %s)' % sysfs('cnc/underruns'))
ok = not fails
print('XYCIRCLE x%s %s' % (mode, 'instrument checks PASS - the material verdict is yours'
if ok else 'FAIL: %d check(s) failed' % len(fails)))
return 0 if ok else 1
def post_ctrl(action):
# http.client preserves the header-name case exactly as given.
import http.client
@@ -3230,6 +3493,7 @@ def main():
'pcurve': drill_pcurve, 'm5dark': drill_m5dark,
'dpatch': drill_dpatch, 'flowload': drill_flowload,
'm4corner': drill_m4corner, 'm4feeds': drill_m4feeds,
'xymode': drill_xymode, 'xycircle': drill_xycircle,
'senderchg': drill_senderchg, 'overrun': drill_overrun,
'holdres': drill_holdres,
'expstop': drill_expstop, 'ctrlstart': drill_ctrlstart}
+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""A dry raster at top speed at each XY microstep mode (on the board).
Usage: raster_dry.py [modes...] (default: 8 16 32)
Per mode: stores xy_microsteps through forgectrl (which restarts the
idle GRBL controller), waits for the controller to come back at that
mode, then streams a raster with the laser off (M5): LINES passes of
LEN mm along X at F12000, Y stepped by PITCH between passes, and back to
the start. Reports the peak feed, the controller CPU over the job, the
kernel counters against the start (the head must come back within TOL mm
on both axes), Grbl's own drift, cnc/underruns, and any "late events
clamped" line the driver logged. Ends with the key cleared (x8).
Needs the controller in GRBL mode, idle, no other Grbl client, the bed
clear, and LEN mm of free +X travel plus LINES x PITCH mm of free +Y
travel from where the head stands.
"""
import json
import os
import re
import socket
import subprocess
import sys
import time
import urllib.request
FC = "http://127.0.0.1"
FC_TOKEN = "/data/forgefirm/panel.token"
MODES = [int(a) for arg in sys.argv[1:] for a in arg.split()] or [8, 16, 32]
LINES, LEN, PITCH, FEED, TOL = 60, 150.0, 0.2, 12000, 0.05
LOG = "/data/log/forgefirm/grblhal/grblhal.log"
def fc(path, data=None):
token = open(FC_TOKEN).read().strip()
body = None
if data is not None:
body = "&".join("%s=%s" % (k, v) for k, v in data.items()).encode()
req = urllib.request.Request(FC + path, data=body,
headers={"Authorization": "Bearer " + token, "X-ForgeFIRM-Token": token,
"Content-Type": "application/x-www-form-urlencoded"})
try:
return json.load(urllib.request.urlopen(req, timeout=30))
except urllib.error.HTTPError as e:
return {"http": e.code, "body": e.read().decode()}
def sysfs(attr):
with open("/sys/glowforge/" + attr) as f:
return f.read().strip()
def kernel_mm():
p = fc("/status").get("pos") or {}
return float(p.get("x", 0)), float(p.get("y", 0))
def controller_pid():
out = subprocess.run(["pidof", "grblHAL_glowforge"], capture_output=True, text=True).stdout.split()
return int(out[0]) if out else None
def cpu_ticks(pid):
with open("/proc/%d/stat" % pid) as f:
s = f.read().split()
return int(s[13]) + int(s[14])
def set_mode(mode):
r = fc("/settings?xy_microsteps=", {}) if mode == 8 else fc("/settings", {"xy_microsteps": str(mode)})
if "http" in r:
raise SystemExit("settings write refused: %s" % r)
time.sleep(3)
t0 = time.time()
while time.time() - t0 < 180:
m = fc("/mode")
if m.get("controller") == "running" and m.get("motion") == "verified" and sysfs("cnc/x_mode") == str(mode):
time.sleep(2)
print("x%d: controller pid %s, step_freq %s, ramp_rate %s"
% (mode, m.get("pid"), sysfs("cnc/step_freq"), sysfs("cnc/ramp_rate")))
return
time.sleep(2)
raise SystemExit("x%d: the controller did not come back" % mode)
class Grbl:
def __init__(self):
self.s = socket.create_connection(("127.0.0.1", 23), timeout=5)
time.sleep(0.3)
self.drain()
def drain(self):
self.s.settimeout(0.2)
out = b""
try:
while True:
d = self.s.recv(4096)
if not d:
break
out += d
except (socket.timeout, OSError):
pass
return out.decode(errors="replace")
def cmd(self, line, timeout=30):
self.s.sendall((line + "\n").encode())
self.s.settimeout(timeout)
buf = ""
end = time.time() + timeout
while time.time() < end:
try:
buf += self.s.recv(4096).decode(errors="replace")
except socket.timeout:
break
if "ok\r\n" in buf or "error" in buf or "ALARM" in buf:
break
return buf
def status(self):
self.s.sendall(b"?")
self.s.settimeout(1.0)
buf = ""
end = time.time() + 1.0
while time.time() < end and ">" not in buf:
try:
buf += self.s.recv(4096).decode(errors="replace")
except socket.timeout:
break
m = re.search(r"<(\w+)[^>]*MPos:([-\d.]+),([-\d.]+),([-\d.]+)", buf)
f = re.search(r"FS:([\d.]+),", buf)
if not m:
return None, None, 0.0
return m.group(1), (float(m.group(2)), float(m.group(3))), float(f.group(1)) if f else 0.0
def wait_idle(self, timeout):
peak = 0.0
end = time.time() + timeout
seen_run = False
while time.time() < end:
st, pos, f = self.status()
peak = max(peak, f)
if st and st.startswith(("Run", "Jog")):
seen_run = True
if st and st.startswith("Idle") and seen_run:
return True, peak, pos
if st and st.startswith("Alarm"):
return False, peak, pos
time.sleep(0.1)
return False, peak, None
def close(self):
self.s.close()
def raster(mode):
pid = controller_pid()
k0 = kernel_mm()
und0 = int(sysfs("cnc/underruns"))
log_off = os.path.getsize(LOG) if os.path.exists(LOG) else 0
g = Grbl()
peak = 0.0
try:
st, pos0, _ = g.status()
if not st or not st.startswith("Idle"):
print("x%d: controller is %s, not Idle" % (mode, st))
return False
for line in ("M5", "G21", "G90", "G91"):
g.cmd(line)
c0 = cpu_ticks(pid)
t0 = time.time()
for i in range(LINES):
dx = LEN if i % 2 == 0 else -LEN
r = g.cmd("G1 X%.3f F%d" % (dx, FEED), timeout=60)
if "error" in r or "ALARM" in r:
print("x%d: line %d refused: %r" % (mode, i, r.strip()))
break
g.cmd("G1 Y%.3f F%d" % (PITCH, FEED), timeout=60)
peak = max(peak, g.status()[2])
g.cmd("G1 Y%.3f F%d" % (-LINES * PITCH, FEED), timeout=60)
ok, peak_end, pos1 = g.wait_idle(120)
peak = max(peak, peak_end)
g.cmd("G90")
elapsed = time.time() - t0
cpu = 100.0 * (cpu_ticks(pid) - c0) / (os.sysconf("SC_CLK_TCK") * elapsed)
finally:
g.close()
# the machine itself idle: the kernel plays out the depth behind Idle
end = time.time() + 20
while time.time() < end and fc("/status").get("state") != "idle":
time.sleep(0.5)
k1 = kernel_mm()
und1 = int(sysfs("cnc/underruns"))
clamped = []
if os.path.exists(LOG):
with open(LOG, errors="replace") as f:
f.seek(log_off)
clamped = [l.strip() for l in f if "late events clamped" in l or "underrun" in l]
drift = (pos1[0] - pos0[0], pos1[1] - pos0[1]) if (pos1 and pos0) else None
kd = (k1[0] - k0[0], k1[1] - k0[1])
verdict = ok and abs(kd[0]) <= TOL and abs(kd[1]) <= TOL and und1 == und0 and not clamped
print("x%d: %s | %d passes of %.0f mm at F%d in %.1f s, peak %.0f mm/min, controller CPU %.1f %% | "
"kernel return dx=%.3f dy=%.3f mm | grbl drift %s | underruns %d->%d | clamped lines %d"
% (mode, "PASS" if verdict else "FAIL", LINES, LEN, FEED, elapsed, peak, cpu, kd[0], kd[1],
drift, und0, und1, len(clamped)))
for l in clamped[:5]:
print(" ", l)
return verdict
def main():
verdicts = {}
for mode in MODES:
print("=== x%d ===" % mode)
set_mode(mode)
verdicts[mode] = raster(mode)
if MODES[-1] != 8:
print("=== restore x8 ===")
set_mode(8)
print("settings xy_microsteps:", repr(fc("/settings").get("xy_microsteps")))
print("RESULT:", " ".join("x%d=%s" % (m, "PASS" if v else "FAIL") for m, v in verdicts.items()))
return 0 if all(verdicts.values()) else 1
if __name__ == "__main__":
sys.exit(main())
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env python3
"""Host-side verification of the XY microstep mode wiring.
The XY microstep mode is one number in the shared config, xy_microsteps
(8, 16 or 32), and the driver derives three things from it at start and
never takes them typed: $100/$101, the machine tick, and the kernel stop
ramp. This harness drives the native grblHAL_glowforge binary in
null-sink mode (no hardware, no root) over TCP, one process per config:
1. no key: x8, $100/$101 = 53.333, the 28160 Hz tick
2. xy_microsteps = 16: 106.667, the 56320 Hz tick; a typed $100 is
overwritten on the spot; $110 is left alone under a tick that
carries it
3. xy_microsteps = 32: 213.333, the 112640 Hz tick
4. a value that is not a mode: x8 with a warning in the log
5. GFSINK_RATE lowered under the mode's tick: $110/$111 are held at
the feed the tick carries, one step per tick per axis
The binary keeps its settings in EEPROM.DAT in the working directory, so
each run starts from defaults in a temporary directory and leaves
nothing behind.
Usage: xy_mode_test.py [path-to-binary]
(default ./build-native/grblHAL_glowforge)
"""
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import time
BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else "build-native/grblHAL_glowforge")
PORT = 2401
def fail(msg):
print("FAIL: %s" % msg)
sys.exit(1)
class Session:
"""One null-sink controller process with its own settings store and
its own shared config file."""
def __init__(self, conf=None, env_extra=None):
self.workdir = tempfile.mkdtemp(prefix="xy-mode-")
env = dict(os.environ, GF_STATE_DIR=self.workdir, FFLOG_STDERR="1",
FFLOG_LEVEL="info")
for key in ("GFSINK", "GFSINK_RATE", "GF_SWITCH_FILE", "GF_VERDICT_FILE"):
env.pop(key, None)
conf_path = os.path.join(self.workdir, "forgefirm.conf")
with open(conf_path, "w") as f:
f.write(conf or "")
env["GFHOME_CONF"] = conf_path
env.update(env_extra or {})
self.proc = subprocess.Popen([BIN, "-p", str(PORT)],
cwd=self.workdir, env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE)
self.sock = self.connect()
self.read(1.0) # banner and any boot messages
def connect(self):
for _ in range(50):
try:
return socket.create_connection(("127.0.0.1", PORT), timeout=1)
except OSError:
time.sleep(0.1)
err = b""
if self.proc.poll() is not None:
err = self.proc.stderr.read() or b""
fail("cannot connect to the controller (exit=%s)\n%s"
% (self.proc.poll(), err.decode(errors="replace")))
def read(self, timeout=0.8):
out = b""
end = time.time() + timeout
while time.time() < end:
self.sock.settimeout(max(0.05, end - time.time()))
try:
data = self.sock.recv(4096)
except (socket.timeout, OSError):
break
if not data:
break
out += data
return out.decode(errors="replace")
def send(self, line, timeout=1.0):
self.sock.sendall((line + "\n").encode())
return self.read(timeout)
def setting(self, key):
reply = self.send(key)
m = re.search(r"^%s=(\S+)" % re.escape(key), reply, re.M)
if not m:
fail("no value reported for %s: %r" % (key, reply))
return float(m.group(1))
def write_setting(self, assignment):
reply = self.send(assignment)
if "ok" not in reply:
fail("settings write %r refused: %r" % (assignment, reply))
def close(self):
"""Stop the process and return everything it logged."""
try:
self.sock.close()
except OSError:
pass
self.proc.terminate()
try:
_out, err = self.proc.communicate(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
_out, err = self.proc.communicate()
shutil.rmtree(self.workdir, ignore_errors=True)
return (err or b"").decode(errors="replace")
def check(ok, ok_msg, fail_msg):
if not ok:
fail(fail_msg)
print("ok %s" % ok_msg)
def near(got, want, tol=0.0005):
return abs(got - want) <= tol
def expect_scale(s, mode, spm):
for key in ("$100", "$101"):
got = s.setting(key)
check(near(got, spm), "%s=%.3f at x%d" % (key, got, mode),
"%s is %.3f at x%d, want %.3f" % (key, got, mode, spm))
def expect_log(log, needle, what):
check(needle in log, what, "the log does not say %r:\n%s" % (needle, log))
def case_default():
s = Session()
try:
expect_scale(s, 8, 53.333)
rate = s.setting("$110")
check(near(rate, 12000.0), "$110=%.0f stands at x8" % rate,
"$110 is %.3f at x8, want 12000" % rate)
finally:
log = s.close()
expect_log(log, "x8 microsteps, 28160 Hz machine tick", "no key: x8, the 28160 Hz tick")
def case_x16():
s = Session("xy_microsteps = 16\n")
try:
expect_scale(s, 16, 106.667)
# A typed $100 is accepted and overwritten on the spot: the scale
# is the mode's, never the sender's.
s.write_setting("$100=53.333")
got = s.setting("$100")
check(near(got, 106.667), "a typed $100 is back at %.3f" % got,
"a typed $100 stuck at %.3f under x16" % got)
rate = s.setting("$110")
check(near(rate, 12000.0), "$110=%.0f stands under a tick that carries it" % rate,
"$110 is %.3f under x16, want 12000" % rate)
finally:
log = s.close()
expect_log(log, "x16 microsteps, 56320 Hz machine tick", "x16: the 56320 Hz tick")
def case_x32():
s = Session("xy_microsteps = 32\n")
try:
expect_scale(s, 32, 213.333)
finally:
log = s.close()
expect_log(log, "x32 microsteps, 112640 Hz machine tick", "x32: the 112640 Hz tick")
def case_invalid():
s = Session("xy_microsteps = 24\n")
try:
expect_scale(s, 8, 53.333)
finally:
log = s.close()
expect_log(log, "xy_microsteps '24' is not 8, 16 or 32", "a value that is not a mode is refused with a warning")
expect_log(log, "x8 microsteps, 28160 Hz machine tick", "and the machine runs at x8")
def case_low_tick():
# 1000 Hz carries 1000 steps/s per axis: 562.5 mm/min at 106.667
# steps/mm. $110/$111 are held there, and a typed value above it
# comes back held.
s = Session("xy_microsteps = 16\n", {"GFSINK_RATE": "1000"})
try:
expect_scale(s, 16, 106.667)
for key in ("$110", "$111"):
got = s.setting(key)
check(near(got, 562.5, 0.05), "%s held at %.3f under a 1000 Hz tick" % (key, got),
"%s is %.3f under a 1000 Hz tick, want 562.5" % (key, got))
s.write_setting("$110=6000")
got = s.setting("$110")
check(near(got, 562.5, 0.05), "a typed $110=6000 comes back held at %.3f" % got,
"a typed $110=6000 stuck at %.3f under a 1000 Hz tick" % got)
finally:
log = s.close()
expect_log(log, "1000 Hz machine tick", "GFSINK_RATE took the tick to 1000 Hz")
expect_log(log, "held at the ceiling", "the hold is logged")
def main():
if not os.path.isfile(BIN):
fail("no controller binary at %s" % BIN)
case_default()
case_x16()
case_x32()
case_invalid()
case_low_tick()
print("PASS xy_mode_test")
if __name__ == "__main__":
main()
+414
View File
@@ -0,0 +1,414 @@
#!/usr/bin/env python3
"""The XY microstep modes by the head accelerometer, the machine silent (on the board).
Usage: xy_pattern_accel.py [modes...] (default: 8 16 32)
The pattern, from home at full speed (F12000), one leg at a time:
1. home to X 18 in, Y 9 in (457.2, 228.6 mm)
2. to X 9 in, Y 9 in (228.6, 228.6 mm)
3. a 9 in circle from its mid-bottom, the center 4.5 in toward the
back, ending back at X 9 in, Y 9 in
4. to X 9 in, Y 0 (228.6, 0 mm)
5. to X 0, Y 0 home
Per mode: stores xy_microsteps through forgectrl (which restarts the idle
GRBL controller), waits for the controller at that mode, takes the
engine's quiet hold (POST /cool/quiet: air assist, exhaust, intake and
purge fans, the coolant pump and the TEC all off, so the machine is silent
and the modes can be heard as well as measured; a dry pattern, the laser
latched), waits the fixed QUIET_S, then samples the head accelerometer
(the crash watch's LIS2HH12 on i2c-3, straight over the bus at 800 Hz)
through the pattern. Reports, per leg and overall, the cruise-window
RMS and peak-to-peak of X and Y with the mean removed, the leg times,
the kernel counters against home, and cnc/underruns; JSON with the raw
trace to the bench data directory. Releases the hold and puts the
accelerometer back on every exit path. Ends at x8.
Needs the controller in GRBL mode, homed and standing at home, the lid
closed, no other Grbl client, and the bed clear across the pattern.
"""
import ctypes
import fcntl
import json
import math
import os
import re
import socket
import struct
import sys
import threading
import time
from gfbench import data_path, forgectrl_get, forgectrl_post
def modes_from_argv(argv):
"""The modes named on the command line ("8 16 32" as one argument or
several), the three by default."""
return [int(a) for arg in argv for a in arg.split()] or [8, 16, 32]
FEED = 12000
IN = 25.4
LEGS = [("home to (18, 9) in", "G1 X%.3f Y%.3f F%d" % (18 * IN, 9 * IN, FEED)),
("to (9, 9) in", "G1 X%.3f F%d" % (-9 * IN, FEED)),
("9 in circle", "G2 X0 Y0 I0 J%.3f F%d" % (-4.5 * IN, FEED)),
("to (9, 0) in", "G1 Y%.3f F%d" % (-9 * IN, FEED)),
("to home", "G1 X%.3f F%d" % (-9 * IN, FEED))]
QUIET_S = 10.0 # the fixed wait after every fan is commanded off
TRIM_S = 0.35 # the ramps at each end of a leg, outside the cruise window
TOL_MM = 0.05
SYS = "/sys/glowforge/"
I2C_DEV = "/dev/i2c-3"
I2C_SLAVE_FORCE = 0x0706
I2C_SMBUS = 0x0720
I2C_SMBUS_READ = 1
I2C_SMBUS_I2C_BLOCK_DATA = 8
ADDR = 0x1E
CTRL1 = 0x20
CTRL1_RUN = 0x6F # 800 Hz, BDU, XYZ on (the crash watch's run value)
CTRL4 = 0x23
OUT_X_L = 0x28
class SmbusData(ctypes.Union):
_fields_ = [("byte", ctypes.c_uint8), ("word", ctypes.c_uint16), ("block", ctypes.c_uint8 * 34)]
class SmbusIoctl(ctypes.Structure):
_fields_ = [("read_write", ctypes.c_uint8), ("command", ctypes.c_uint8),
("size", ctypes.c_uint32), ("data", ctypes.POINTER(SmbusData))]
class Accel:
"""The chip over the bus. The six output registers come in ONE bus
transaction (an SMBus block read), so another reader on the bus (the
crash watch) cannot slip a register pointer between the write and
the read."""
def __init__(self):
self.fd = os.open(I2C_DEV, os.O_RDWR)
fcntl.ioctl(self.fd, I2C_SLAVE_FORCE, ADDR)
self.ctrl1_found = self.reg(CTRL1)
self.ctrl4 = self.reg(CTRL4)
def reg(self, r):
os.write(self.fd, bytes([r]))
return os.read(self.fd, 1)[0]
def set_reg(self, r, v):
os.write(self.fd, bytes([r, v]))
def start(self):
self.set_reg(CTRL1, CTRL1_RUN)
time.sleep(0.02)
def restore(self):
try:
self.set_reg(CTRL1, self.ctrl1_found)
finally:
os.close(self.fd)
def read(self):
data = SmbusData()
data.block[0] = 6
req = SmbusIoctl(I2C_SMBUS_READ, OUT_X_L, I2C_SMBUS_I2C_BLOCK_DATA, ctypes.pointer(data))
fcntl.ioctl(self.fd, I2C_SMBUS, req)
return struct.unpack("<hhh", bytes(data.block[1:7]))
class Sampler(threading.Thread):
def __init__(self, accel):
super().__init__(daemon=True)
self.accel = accel
self.samples = [] # (t, x, y, z)
self.errors = 0
self._halt = threading.Event()
def run(self):
while not self._halt.is_set():
try:
x, y, z = self.accel.read()
self.samples.append((time.monotonic(), x, y, z))
except OSError:
self.errors += 1
time.sleep(0.002)
def stop(self):
self._halt.set()
self.join(2.0)
def sysfs(attr):
with open(SYS + attr) as f:
return f.read().strip()
def fc_get(path):
st, body = forgectrl_get(path)
return body if st == 200 and isinstance(body, dict) else {}
def fail(msg):
print("FAIL: %s" % msg)
raise SystemExit(1)
def set_mode(mode):
if mode == 8:
st, body = forgectrl_post("/settings", params={"xy_microsteps": ""})
else:
st, body = forgectrl_post("/settings", data={"xy_microsteps": str(mode)})
if st != 200:
fail("settings write refused: %s %s" % (st, body))
time.sleep(3)
t0 = time.time()
while time.time() - t0 < 180:
m = fc_get("/mode")
if m.get("controller") == "running" and m.get("motion") == "verified" and sysfs("cnc/x_mode") == str(mode):
time.sleep(2)
print("x%d: controller pid %s, step_freq %s, ramp_rate %s"
% (mode, m.get("pid"), sysfs("cnc/step_freq"), sysfs("cnc/ramp_rate")))
return
time.sleep(2)
fail("x%d: the controller did not come back" % mode)
def quiet(on):
"""The engine's quiet hold with the pump: every fan, the coolant pump
and the TEC off (on), or the phase's posture back (off)."""
st, body = forgectrl_post("/cool/quiet", params={"on": "1" if on else "0", "pump": "1"})
if st != 200:
fail("quiet hold %s refused: %s %s" % ("on" if on else "off", st, body))
return body
class Grbl:
def __init__(self):
self.s = socket.create_connection(("127.0.0.1", 23), timeout=5)
time.sleep(0.3)
self.drain()
def drain(self):
self.s.settimeout(0.2)
try:
while self.s.recv(4096):
pass
except (socket.timeout, OSError):
pass
def cmd(self, line, timeout=30):
self.s.sendall((line + "\n").encode())
self.s.settimeout(timeout)
buf = ""
end = time.time() + timeout
while time.time() < end:
try:
buf += self.s.recv(4096).decode(errors="replace")
except socket.timeout:
break
if "ok\r\n" in buf or "error" in buf or "ALARM" in buf:
break
return buf
def status(self):
self.s.sendall(b"?")
self.s.settimeout(1.0)
buf = ""
end = time.time() + 1.0
while time.time() < end and ">" not in buf:
try:
buf += self.s.recv(4096).decode(errors="replace")
except socket.timeout:
break
if "error:" in buf or "ALARM" in buf:
fail("the controller answered %r" % buf.strip()[-200:])
m = re.search(r"<(\w+)[^>]*MPos:([-\d.]+),([-\d.]+),([-\d.]+)", buf)
f = re.search(r"FS:([\d.]+),", buf)
if not m:
return None, None, 0.0
return m.group(1), (float(m.group(2)), float(m.group(3))), float(f.group(1)) if f else 0.0
def run_leg(self, gcode, timeout=120):
"""Send one move and time it by the state transitions: t_run at
the first Run report, t_idle at the Idle after it. The line's own
"ok" is not waited for first: an arc is fed to the planner
segment by segment and its ok arrives late in the motion, so it
is drained along with the reports instead."""
self.s.sendall((gcode + "\n").encode())
t_run = t_idle = None
peak = 0.0
pos = None
end = time.time() + timeout
while time.time() < end:
st, pos, fs = self.status()
now = time.monotonic()
peak = max(peak, fs)
if st and st.startswith(("Run", "Jog")) and t_run is None:
t_run = now
if st and st.startswith("Idle") and t_run is not None:
t_idle = now
break
if st and st.startswith("Alarm"):
fail("alarm during %r: %s" % (gcode, st))
time.sleep(0.02)
if t_idle is None:
fail("the move %r did not finish" % gcode)
self.drain()
return t_run, t_idle, peak, pos
def close(self):
self.s.close()
def window(samples, t0, t1):
xs = [s[1] for s in samples if t0 <= s[0] <= t1]
ys = [s[2] for s in samples if t0 <= s[0] <= t1]
return xs, ys
def stats(vals):
if len(vals) < 2:
return {"n": len(vals), "rms": None, "p2p": None}
mean = sum(vals) / len(vals)
rms = math.sqrt(sum((v - mean) ** 2 for v in vals) / len(vals))
return {"n": len(vals), "rms": round(rms, 1), "p2p": max(vals) - min(vals)}
def preflight():
st = fc_get("/status")
m = fc_get("/mode")
if m.get("mode") != "grbl" or m.get("controller") != "running":
fail("the GRBL controller is not running (%s)" % m)
if not st.get("homed"):
fail("the machine is not homed: run $H first, the pattern starts from home")
if not (st.get("switches") or {}).get("lid"):
fail("the lid is open")
if st.get("state") != "idle":
fail("the machine is not idle (%s)" % st.get("state"))
home = (float((fc_get("/settings") or {}).get("gfcloud_home_x") or 0),
float((fc_get("/settings") or {}).get("gfcloud_home_y") or 0))
return home
def run_pattern(mode, home):
g = Grbl()
accel = None
sampler = None
record = {"mode": mode, "step_freq": sysfs("cnc/step_freq"), "ramp_rate": sysfs("cnc/ramp_rate"),
"feed": FEED, "legs": [], "quiet_s": QUIET_S}
try:
st, pos, _ = g.status()
if not st or not st.startswith("Idle"):
fail("controller is %s, not Idle" % st)
if abs(pos[0] - home[0]) > TOL_MM or abs(pos[1] - home[1]) > TOL_MM:
fail("the head is not at home (MPos %s, home %s): run $H first" % (pos, home))
k0 = fc_get("/status").get("pos") or {}
und0 = int(sysfs("cnc/underruns"))
print("x%d: quiet hold: %s" % (mode, quiet(True)))
pwm = {a: sysfs(a) for a in ("thermal/exhaust_pwm", "thermal/intake_pwm", "head/air_assist_pwm",
"head/purge_air", "thermal/water_pump_on", "thermal/tec_on")}
record["fans"] = pwm
if any(v != "0" for v in pwm.values()):
fail("a fan, the pump or the TEC is still commanded on: %s" % pwm)
print("x%d: every fan, the pump and the TEC commanded off (%s); waiting %.0f s" % (mode, pwm, QUIET_S))
time.sleep(QUIET_S)
accel = Accel()
record["ctrl4"] = accel.ctrl4
accel.start()
sampler = Sampler(accel)
sampler.start()
time.sleep(1.0)
t_base0, t_base1 = time.monotonic() - 1.0, time.monotonic()
g.cmd("G21")
g.cmd("G91")
for name, gcode in LEGS:
t_run, t_idle, peak, pos = g.run_leg(gcode)
record["legs"].append({"leg": name, "gcode": gcode, "t_run": t_run, "t_idle": t_idle,
"peak_feed": peak, "end_mpos": pos})
print(" %-22s %5.2f s, peak %5.0f mm/min, end MPos (%.3f, %.3f)"
% (name, t_idle - t_run, peak, pos[0], pos[1]))
g.cmd("G90")
time.sleep(1.0)
end = time.time() + 20
while time.time() < end and fc_get("/status").get("state") != "idle":
time.sleep(0.5)
k1 = fc_get("/status").get("pos") or {}
und1 = int(sysfs("cnc/underruns"))
finally:
# The fans first, whatever else fails: the hold must not outlive
# the listening (the engine would drop it itself within 600 s).
try:
quiet(False)
except SystemExit:
print("WARNING: the quiet hold did not release; the engine drops it itself within 600 s")
try:
if sampler is not None:
sampler.stop()
finally:
if accel is not None:
accel.restore()
g.close()
samples = sampler.samples
record["samples"] = len(samples)
record["read_errors"] = sampler.errors
record["rate_hz"] = round(len(samples) / (samples[-1][0] - samples[0][0]), 1) if len(samples) > 1 else 0
bx, by = window(samples, t_base0, t_base1)
record["baseline"] = {"x": stats(bx), "y": stats(by)}
print(" baseline at rest (fans off): x rms %s p2p %s, y rms %s p2p %s, %d samples at %.0f Hz, %d read errors"
% (record["baseline"]["x"]["rms"], record["baseline"]["x"]["p2p"],
record["baseline"]["y"]["rms"], record["baseline"]["y"]["p2p"],
len(samples), record["rate_hz"], sampler.errors))
allx, ally = [], []
for leg in record["legs"]:
t0, t1 = leg["t_run"] + TRIM_S, leg["t_idle"] - TRIM_S
xs, ys = window(samples, t0, t1)
leg["cruise"] = {"x": stats(xs), "y": stats(ys), "seconds": round(max(0.0, t1 - t0), 2)}
allx += xs
ally += ys
print(" %-22s cruise %4.2f s: x rms %6s p2p %6s | y rms %6s p2p %6s (%d samples)"
% (leg["leg"], leg["cruise"]["seconds"], leg["cruise"]["x"]["rms"], leg["cruise"]["x"]["p2p"],
leg["cruise"]["y"]["rms"], leg["cruise"]["y"]["p2p"], leg["cruise"]["x"]["n"]))
record["overall"] = {"x": stats(allx), "y": stats(ally)}
kd = (float(k1.get("x", 0)) - float(k0.get("x", 0)), float(k1.get("y", 0)) - float(k0.get("y", 0)))
record["kernel_return_mm"] = kd
record["underruns"] = [und0, und1]
ok = abs(kd[0]) <= TOL_MM and abs(kd[1]) <= TOL_MM and und1 == und0
record["result"] = "PASS" if ok else "FAIL"
print(" overall cruise: x rms %s p2p %s | y rms %s p2p %s | kernel return dx=%.3f dy=%.3f | underruns %d->%d | %s"
% (record["overall"]["x"]["rms"], record["overall"]["x"]["p2p"], record["overall"]["y"]["rms"],
record["overall"]["y"]["p2p"], kd[0], kd[1], und0, und1, record["result"]))
record["trace"] = [(round(s[0] - samples[0][0], 4), s[1], s[2], s[3]) for s in samples]
return record
def main():
modes = modes_from_argv(sys.argv[1:])
home = preflight()
stamp = time.strftime("%Y%m%d%H%M%S")
results = []
for mode in modes:
print("=== x%d ===" % mode)
set_mode(mode)
rec = run_pattern(mode, home)
path = data_path("xy_pattern_accel_x%d_%s.json" % (mode, stamp))
with open(path, "w") as f:
json.dump(rec, f)
print(" record: %s" % path)
results.append(rec)
if modes[-1] != 8:
print("=== restore x8 ===")
set_mode(8)
print("\n=== summary (cruise RMS with the mean removed, raw counts; CTRL4 0x%02x) ==="
% results[0].get("ctrl4", 0))
print("%-6s %10s %10s %10s %10s %8s" % ("mode", "x rms", "x p2p", "y rms", "y p2p", "result"))
for rec in results:
o = rec["overall"]
print("%-6s %10s %10s %10s %10s %8s" % ("x%d" % rec["mode"], o["x"]["rms"], o["x"]["p2p"],
o["y"]["rms"], o["y"]["p2p"], rec["result"]))
return 0 if all(r["result"] == "PASS" for r in results) else 1
if __name__ == "__main__":
sys.exit(main())