mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
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:
+83
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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())
|
||||
@@ -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}
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user