mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 16:51:12 -07:00
bench: every board-runnable tool ported to the bench page
The remaining bench diagnostics run from forgetest's #bench tab. The tools that also run from a LAN host share scripts/bench/gfbench.py: GF_HOST names a remote machine (host mode, sysfs through ssh, Grbl and forgectrl over the LAN); unset, the tool runs on the board itself (local mode, sysfs directly, everything on 127.0.0.1), which is how the page runs them - with GF_HOST=127.0.0.1, the panel token in GF_TOKEN and their data files under <data>/bench/ (FORGETEST_BENCH_DATA). The helper also reads a machine setting from forgectrl, or from the settings file on the board while forgectrl is stopped. Ported: pwm_sweep / pwm_hold (scope = a takeover; the latch relocked, the write refused if FIRE or LASER_ON reads active), pwm_stream_test (PASS/FAIL exit), flow_characterize, flow_recheck_char, flow_warm_validate and flow_matrix (takeovers: forgectrl owns the thermal hardware, so the page's takeover replaces the tools' own controller stop/restart, whose command line predated the supervisor; results and logs in the bench data directory), flow_sustained, fan_test, temp_calibrate (dry; watch bounded in seconds; the threshold and the coolant conversion from the shared code), flow_escalate_drill (cool_confirm_max_s shortened through forgectrl's settings for the drill and restored; the setting's minimum is the default budget), and live_fire_drills (<drill> [S] [F], all six drills, host from GF_HOST, token from the board). flow_matrix joins the registry. What stays unported cannot run against the machine at all: the two null-sink CI harnesses and the .puls decoder. Runner: a scope tool runs inside the takeover wrapper; the bench environment above is passed to every tool. Tests: test_bench_registry (registry <-> scripts/bench consistency, every ported tool builds its command line, every script compiles, gfbench host/local modes) and the server test (scope tool takeover, the environment reaching the tool). Local mode smoke-run on the bench (temp_calibrate watch, setting, token) from /tmp, removed after. No catalog consequence: bench tools are not image components (dev-only forgetest); the acceptance catalog is unchanged.
This commit is contained in:
+33
-25
@@ -4,39 +4,47 @@ Hardware-verification tools for the ForgeFIRM bench. All run ON the
|
||||
target board (dev image, python3 present) unless noted. The dev image
|
||||
installs them under `/usr/share/forgetest/bench/`, and the acceptance
|
||||
tool's **Bench diagnostics** tab (`http://<machine>:8090/#bench`,
|
||||
`docs/ACCEPTANCE.md`) runs the board-side ones with their arguments and
|
||||
the output on the page (takeover tools get forgectrl stopped and started
|
||||
around the run); the acceptance catalog itself is built from ports of
|
||||
these drills. Host-side tools
|
||||
take the machine address from `GF_HOST` (or `argv`, where stated); the
|
||||
ones that shell into the board over ssh use the `ssh` on `PATH`, or the
|
||||
client named by `GF_SSH` (for example `GF_SSH='wsl -d <distro> -- ssh'`
|
||||
to go through a WSL distro from Windows).
|
||||
`docs/ACCEPTANCE.md`) runs them with their arguments and the output on
|
||||
the page - takeover and scope tools get forgectrl and the controller
|
||||
stopped and started around the run, live tools need the operator
|
||||
acknowledgment; the acceptance catalog itself is built from ports of
|
||||
these drills. The tools that also run from a LAN host use `gfbench.py`:
|
||||
`GF_HOST` names the machine (sysfs through ssh - the `ssh` on `PATH`, or
|
||||
the client named by `GF_SSH`, for example `GF_SSH='wsl -d <distro> --
|
||||
ssh'` from Windows - Grbl and forgectrl over the LAN); with `GF_HOST`
|
||||
unset they run on the board itself (sysfs directly, everything on
|
||||
127.0.0.1), which is how the bench page runs them, with their data files
|
||||
under `/data/forgetest/bench/` (`FORGETEST_BENCH_DATA`; next to the tool
|
||||
otherwise) and the panel token in `GF_TOKEN`. Tools that drive the
|
||||
thermal hardware directly (the flow characterization family) run with
|
||||
forgectrl - the thermal-hardware owner - and the controller stopped: the
|
||||
page's takeover does that; from a host, stop them first.
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `feeder.c` | Underrun proof: streams NOP pulse bytes to `/dev/glowforge` with wall-clock pacing, bounded queue depth, deadman flock, SCHED_FIFO. Usage: `feeder <hz> <seconds> <depth_ms>`. Proven envelope: 100 kHz × 120 s under full load, 0.2 ms worst write latency. Cross-compile with `build-feeder.sh` (WSL). |
|
||||
| `bench_phase2.py` | End-of-data protocol bench: underrun detection/ack, parked no-replay guard, resume(0), continuous-feed stability, 20× run/underrun cycles. Motion-safe (motors locked, laser latched). |
|
||||
| `check_pwm.py` | Laser PWM register check: reads PWM2 PWMCR/PWMPR via /dev/mem, expects divider 13 × ~127 counts ≈ 40 kHz. |
|
||||
| `pwm_sweep.py` | LASER_PWM scope test (runs on the board): `check` = read-only safety readbacks + PWM2 dump; `sweep` = steps PWMSAR through 50/25/75/6/100 % duty with 4 s holds, then restores. Run only in the locked state (controller stopped, cnc disabled, latch locked). |
|
||||
| `pwm_hold.py` | Holds one PWMSAR value for a scope-measurement window (`pwm_hold.py <sar> <seconds>`), then restores. Same locked-state rule. |
|
||||
| `pwm_sweep.py` | LASER_PWM scope test (runs on the board): `check` = read-only safety readbacks + PWM2 dump; `sweep` = steps PWMSAR through 50/25/75/6/100 % duty with 4 s holds, then restores. Locked state only (controller and forgectrl stopped, the pulse device closed - the page's takeover): the sweep relocks the latch itself and refuses to write if FIRE reads driven or LASER_ON reads active. |
|
||||
| `pwm_hold.py` | Holds one PWMSAR value for a scope-measurement window (`pwm_hold.py <sar> <seconds>`), then restores. Same locked-state rule and guard. |
|
||||
| `fire_test.py` | FIRE drop-timing scope test (runs on the board): A = latch locked (expects nothing on FIRE/LASER_ON), B = latch unlocked / normal end-of-data, U = true underrun. Duty 0 throughout; refuses to unlock if HV reports good. |
|
||||
| `pwm_stream_test.py` | LASER_PWM stream-path scope test (runs on the board): streams power bytes only (no step bytes, no FIRE bits, `motor_lock=15`, latch locked) through `/dev/glowforge` so the scope verifies the real power path, including the run-start duty reset and the consecutive-power-byte drop; position counters compared before/after. |
|
||||
| `pwm_stream_test.py` | LASER_PWM stream-path scope test (runs on the board, controller and forgectrl stopped): streams power bytes only (no step bytes, no FIRE bits, `motor_lock=15`, latch locked) through `/dev/glowforge` so the scope verifies the real power path, including the run-start duty reset and the consecutive-power-byte drop; position counters compared before/after. Exit 0 = counters unmoved, idle at the end, no FIRE/emission read back. |
|
||||
| `gate_a_kernel_drills.py` | Kernel laser-safety drills (run on the board with forgectrl stopped so the pulse device is free): `K1` controlled-stop deceleration floor, `K2` resume waypoint honors the locked latch, `K3` a mid-ramp latch unlock never re-arms the FIRE drive. Software witnesses (`cnc/state`, `laser_enable`, `laser_on`, `laser_on_sampled`, interlock bit 3) plus the PSU-connector LASER_ON scope point; K3 refuses to run if HV reports good. |
|
||||
| `laser_stream_test.py` | Host-side laser pulse-stream emission harness: runs the native null-sink controller with `GFSINK_DUMP`, drives small laser jobs over TCP, and checks the dumped bytes against the kernel feeder contract (leading power byte, no back-to-back power bytes, FIRE only inside cutting moves, every stream ends FIRE-clear, no FIRE on a stepless gap, no FIRE leak across cycle churn). Runs in the grblHAL repo's CI. |
|
||||
| `laser_lifecycle_test.py` | Host-side operator-armed-window lifecycle harness (null-sink controller): arm once per job with M5/M3 persistence, the M2 close, sender-change re-consent, the disarm grace counting down in Hold, and arm refusal under a blocking cooling verdict. Runs in the grblHAL repo's CI. |
|
||||
| `live_fire_drills.py` | **LIVE LASER** drills from a LAN host (`live_fire_drills.py <drill> [host]`, or `GF_HOST`): `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). Every drill waits for the operator's physical arm press; eye protection, fire watch, extinguisher, and exhaust are mandatory. |
|
||||
| `live_fire_drills.py` | **LIVE LASER** drills, on the board (the bench page) or from a LAN host (`GF_HOST`): `live_fire_drills.py <drill> [S] [F]` - `witness` (emission witness, lid-IR peaks vs the ambient baseline, HV current, job-based disarm on M2), `hold` (disarm grace in Hold), `faultpos` (armed job refuses a stale origin after an underrun), `ircut` (lid-IR characterization cut at S/F), `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. |
|
||||
| `fan_test.py` | Fan/coolant bench (Windows-side): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. |
|
||||
| `flow_characterize.py` | Coolant flow characterization using the factory temperature curve: baseline → flow → no-flow → recovery, printing the ΔT bands and their separation. Takes the heater duty as an argument (`flow_characterize.py 30`); aborts if downstream passes 45 °C. |
|
||||
| `flow_matrix.py` | **The flow-detection design matrix** (with `flow_sampler.py`, run on the board from `/usr/share/forgetest/bench/`): duty × duration × flow/no-flow, every run from a common cooled baseline, interleaved repeats. One heating trace yields the metric at every candidate duration, so cost and precision come from the same 60 runs. Prints a cost table, a precision table (mean±sd, worst-case margin, d′) and a ranked shortlist. Env: `FM_DUTIES`, `FM_REPEATS`, `FM_RESULTS`. |
|
||||
| `flow_sustained.py` | Long-run test of the real re-check cadence via M8: counts verdicts/false faults and tracks whether the loop accumulates heat. |
|
||||
| `flow_warm_validate.py` | Runs the real check from a heater-warmed baseline. Note the ceiling: 100 % duty pushes the downstream sensor past 50 °C in 30 s while the bulk barely moves, so warm-loop validation above ~23 °C needs the laser, not the heater. |
|
||||
| `flow_recheck_char.py` | Characterizes short in-run re-checks and the differential metric; shows why over-temp cannot see a stopped pump and why passive warming trends are ambiguous. |
|
||||
| `gfbench.py` | Not a tool: the helper the board/host tools share - `HOST`/`LOCAL` from `GF_HOST`, `board(cmd)` (local `sh -c` or ssh), the factory coolant conversion `degc()`, `data_path()` (`FORGETEST_BENCH_DATA` or next to the tool), forgectrl's HTTP API with the panel token, `setting(key)` (from forgectrl, or from `/data/forgefirm.conf` on the board while forgectrl is stopped). |
|
||||
| `fan_test.py` | Fan/coolant bench (board or host; controller running): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. |
|
||||
| `flow_characterize.py` | Coolant flow characterization using the factory temperature curve (board or host; forgectrl and controller stopped): baseline → flow → no-flow → recovery, printing the ΔT bands and their separation. Takes the heater duty as an argument (`flow_characterize.py 30`); aborts if downstream passes 45 °C. |
|
||||
| `flow_matrix.py` | **The flow-detection design matrix** (board or host; forgectrl and controller stopped; with `flow_sampler.py` from `/usr/share/forgetest/bench/`): duty × duration × flow/no-flow, every run from a common cooled baseline, interleaved repeats. One heating trace yields the metric at every candidate duration, so cost and precision come from the same 60 runs. Prints a cost table, a precision table (mean±sd, worst-case margin, d′) and a ranked shortlist. `flow_matrix.py [duties] [repeats]` (or env `FM_DUTIES`, `FM_REPEATS`, `FM_RESULTS`); results/log in the bench data directory, resumable. |
|
||||
| `flow_sustained.py` | Long-run test of the real re-check cadence via M8 (board or host; controller running): counts verdicts/false faults against the configured `cool_flow_rise` and tracks whether the loop accumulates heat. `flow_sustained.py [minutes]`. |
|
||||
| `flow_warm_validate.py` | Runs the real check from a heater-warmed baseline (board or host; forgectrl and controller stopped; `flow_warm_validate.py [cycles_per_case]`; results/log in the bench data directory; exit 1 if any run is misclassified). Note the ceiling: 100 % duty pushes the downstream sensor past 50 °C in 30 s while the bulk barely moves, so warm-loop validation above ~23 °C needs the laser, not the heater. |
|
||||
| `flow_recheck_char.py` | Characterizes short in-run re-checks and the differential metric (board or host; forgectrl and controller stopped; `flow_recheck_char.py [heater_pct] [window_s]`); shows why over-temp cannot see a stopped pump and why passive warming trends are ambiguous. |
|
||||
| `flow_confirm_drill.py` | Coolant flow suspicion/confirmation drill (runs on the board): one continuous M8 session walks the verdict state machine through real pump-off transients — verified → SUSPECT (+ immediate re-check) → cleared → SUSPECT → FAULT (consecutive) → recovered — printing PASS/FAIL per transition. Leaves the machine idle (M9, pump on, heater off). |
|
||||
| `flow_escalate_drill.py` | Coolant starved-re-check escalation drill (runs on the board against a controller started with a short confirmation budget): with the pump off the job-start check reads SUSPECT, the stagnant loop cannot pass the settle gate inside the budget, and the driver must escalate to FAULT. PASS/FAIL, leaves the machine idle. |
|
||||
| `flow_escalate_drill.py` | Coolant starved-re-check escalation drill (runs on the board, controller running): sets the engine's confirmation budget `cool_confirm_max_s` to a short value through forgectrl's settings (`flow_escalate_drill.py [budget_s]`, default 60, the setting's minimum) and restores it after; with the pump off the job-start check reads SUSPECT, the stagnant loop cannot pass the settle gate inside the budget, and the engine must escalate to FAULT. PASS/FAIL (exit status), leaves the machine idle. |
|
||||
| `flow_sampler.py` | Board-side coolant sampler used by the flow tools (`flow_sampler.py <duration_s> <interval_s>`, prints `elapsed,raw_down,raw_up`); run on the board (dev image: `/usr/share/forgetest/bench/`) so cadence does not depend on ssh latency. |
|
||||
| `temp_calibrate.py` | Coolant temperature spot-check helper (`watch` / `point <measured_C>` / `fit`) — pairs a measured temperature with averaged raw ADC readings and fits a per-machine line to sanity-check the factory curve against a thermometer. |
|
||||
| `temp_calibrate.py` | Coolant temperature spot-check helper (board or host): `watch [seconds]` / `point <measured_C> [note]` / `fit` — pairs a measured temperature with averaged raw ADC readings and fits a per-machine line to sanity-check the factory curve against a thermometer. Points accumulate in `temp_calibration.json` in the bench data directory. |
|
||||
| `build-glowforge.sh` | Cross-compiles **grblHAL-glowforge** (the canonical driver repo, `../../../grblHAL-glowforge`) in the Yocto build environment, borrowing the recipe toolchain. Run: `bash <path>/build-glowforge.sh` (from Windows, launch it through the WSL distro from PowerShell; Git Bash mangles /mnt/c paths). Env: `FF_SRC_TOP`, `FF_BUILD_TOP`. This is the production controller build. |
|
||||
| `build-forgectrl.sh` | Cross-compiles **forgectrl** (the canonical control-daemon repo, `../../../forgectrl`) the same way, borrowing the toolchain from the forgectrl recipe workdir (regenerate with `bitbake forgectrl` after a clean). |
|
||||
| `accel_fast.py` | Direct-I2C sampler for the two head-bus LIS2HH12s (runs on the board; unbinds/rebinds st-accel around the capture, 800 Hz ODR, ~270 Hz per device polled): optional mid-capture jogs via local grblHAL TCP. CSV to /tmp/accel.csv. The head accel is i2c-3 0x1e. |
|
||||
@@ -82,11 +90,11 @@ the threshold set at the balanced midpoint of the two bands (14.4 °C).
|
||||
`flow_warm_validate.py` then re-ran the real check from heater-warmed
|
||||
baselines (`flow_warm_results.json`).
|
||||
|
||||
To reproduce on another machine: set `GF_HOST` (and `GF_SSH` if ssh needs a
|
||||
wrapper), boot the dev image (it installs `flow_sampler.py` under
|
||||
`/usr/share/forgetest/bench/`), and run `flow_matrix.py` (env `FM_DUTIES`,
|
||||
`FM_REPEATS`, `FM_RESULTS`; ~1.6 h for the full matrix, the controller is
|
||||
stopped for the duration). The same
|
||||
To reproduce on another machine: boot the dev image and run the
|
||||
**flow-matrix** tool from the bench page (`#bench`; a takeover, ~1.6 h for
|
||||
the full matrix; the results land under `/data/forgetest/bench/`), or from a
|
||||
host with `GF_HOST` (and `GF_SSH` if ssh needs a wrapper), forgectrl and the
|
||||
controller stopped, `flow_matrix.py [duties] [repeats]`. The same
|
||||
derivation is also built into forgectrl as the panel's Diagnostics →
|
||||
**flow-calibrate** tool (3 trials per case at the operating point, reports
|
||||
both bands and a recommended threshold; the bench value it recommends
|
||||
|
||||
+13
-13
@@ -1,18 +1,18 @@
|
||||
import shlex, socket, subprocess, time
|
||||
import os
|
||||
#!/usr/bin/env python3
|
||||
"""Fan/coolant bench: snapshots the fan PWMs, tachs and coolant readings,
|
||||
drives M8 (cut-profile fans), then M9 (cooldown -> idle), and prints
|
||||
each snapshot for the tach readbacks to be judged. Needs the controller
|
||||
running with forgectrl's cooling engine. Runs on the board or from a
|
||||
host (gfbench: GF_HOST)."""
|
||||
import socket
|
||||
import time
|
||||
|
||||
from gfbench import HOST, board as _board, degc
|
||||
|
||||
HOST = os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('set GF_HOST to the machine IP address')
|
||||
# ssh client used to reach the board; override for a wrapper, e.g.
|
||||
# GF_SSH='wsl -d <distro> -- ssh'.
|
||||
SSH = shlex.split(os.environ.get('GF_SSH', 'ssh'))
|
||||
|
||||
def board(cmd):
|
||||
r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none',
|
||||
'root@' + HOST, cmd],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
return r.stdout.strip().replace('\n', ' ')
|
||||
return _board(cmd).strip().replace('\n', ' ')
|
||||
|
||||
|
||||
ATTRS = ('head/air_assist_pwm head/air_assist_tach head/purge_air '
|
||||
'thermal/exhaust_pwm thermal/tach_exhaust '
|
||||
@@ -44,7 +44,7 @@ time.sleep(0.5); drain()
|
||||
|
||||
snap('--- baseline (driver idle profile)')
|
||||
w2 = board('cat /sys/glowforge/pic/water_temp_2')
|
||||
print('water_temp_2 = %s -> %.1f C' % (w2, int(w2) * -0.09653 + 94))
|
||||
print('water_temp_2 = %s -> %.1f C' % (w2, degc(w2)))
|
||||
|
||||
print()
|
||||
print('=== M8: cut-profile fans ON (loud) ===')
|
||||
|
||||
@@ -10,43 +10,17 @@ climbs. This measures both signatures and prints the separation.
|
||||
Phases: baseline (heater off) -> flow (heater on, pump on) -> no-flow
|
||||
(pump off) -> recovery (pump on). Restores heater off / pump on.
|
||||
|
||||
Run with the controller stopped, or accept that it will fight you: the
|
||||
driver only writes the heater on M8/M9 transitions, so an idle driver
|
||||
leaves this alone.
|
||||
Drives the heater and pump directly, so it runs with forgectrl (the
|
||||
thermal-hardware owner) and the controller stopped: the bench page's
|
||||
takeover does that; from a host, stop them first. Runs on the board or
|
||||
from a host (gfbench: GF_HOST).
|
||||
|
||||
Usage: flow_characterize.py [heater_pct] (default 10)
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('set GF_HOST to the machine IP address')
|
||||
# ssh client used to reach the board; override for a wrapper, e.g.
|
||||
# GF_SSH='wsl -d <distro> -- ssh'.
|
||||
SSH = shlex.split(os.environ.get('GF_SSH', 'ssh'))
|
||||
|
||||
# Factory B-equation conversion (see kernel-module-glowforge/UAPI.md).
|
||||
F = 1024.0 * 1.3
|
||||
RD = 10000.0
|
||||
BETA = 3380.0
|
||||
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
|
||||
|
||||
|
||||
def degc(raw):
|
||||
if raw <= 0 or raw >= F:
|
||||
return float('nan')
|
||||
r = RD / (F / raw - 1.0)
|
||||
return BETA / math.log(r / RINF) - 273.15
|
||||
|
||||
|
||||
def board(cmd):
|
||||
r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none',
|
||||
'root@' + HOST, cmd],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
return r.stdout.strip()
|
||||
from gfbench import board, degc
|
||||
|
||||
|
||||
def sample():
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bench drill for the starved-re-check escalation path.
|
||||
|
||||
Runs ON THE BOARD against a controller started with a short
|
||||
confirmation budget (GFCOOL_CONFIRM_MAX_S=45; see the runbook for the
|
||||
manual start line). With the pump off, the job-start check reads
|
||||
Runs ON THE BOARD with the controller running. The cooling engine's
|
||||
confirmation budget (`cool_confirm_max_s`, re-read at every run start)
|
||||
is set to a short value for the drill through forgectrl's settings and
|
||||
restored afterward. With the pump off, the job-start check reads
|
||||
over-limit -> SUSPECT; the cooked stagnant loop then cannot pass the
|
||||
settle gate within 45 s, so the budget expires and the driver must
|
||||
escalate: "COOLANT FLOW FAULT: no clean re-check within 45 s".
|
||||
settle gate within the budget, so it expires and the engine must
|
||||
escalate: "COOLANT FLOW FAULT: no clean re-check within N s".
|
||||
|
||||
Usage: flow_escalate_drill.py [budget_s] (default 60, the setting's minimum)
|
||||
Prints PASS/FAIL and leaves the machine idle: M9 sent, pump on,
|
||||
heater off.
|
||||
heater off, the budget setting as it was.
|
||||
"""
|
||||
import select
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
from gfbench import forgectrl_post, setting
|
||||
|
||||
BUDGET_S = int(sys.argv[1]) if len(sys.argv) > 1 else 60
|
||||
T0 = time.monotonic()
|
||||
|
||||
|
||||
@@ -57,6 +63,14 @@ def wait_msg(substrs, deadline):
|
||||
return None
|
||||
|
||||
|
||||
budget_was = setting('cool_confirm_max_s') # None = not set (compiled default)
|
||||
st, body = forgectrl_post('/settings', data={'cool_confirm_max_s': str(BUDGET_S)})
|
||||
if st != 200:
|
||||
log('!! could not set cool_confirm_max_s=%d (POST /settings -> %s %s)' % (BUDGET_S, st, body))
|
||||
s.close()
|
||||
sys.exit(2)
|
||||
log('cool_confirm_max_s=%d for the drill (was %s)' % (BUDGET_S, budget_was or 'unset'))
|
||||
|
||||
ok = True
|
||||
try:
|
||||
time.sleep(0.5)
|
||||
@@ -67,10 +81,19 @@ try:
|
||||
if wait_msg(['FLOW SUSPECT'], el() + 180) is None:
|
||||
log('!! no SUSPECT within 180 s')
|
||||
ok = False
|
||||
elif wait_msg(['no clean re-check within'], el() + 120) is None:
|
||||
log('!! no escalation FAULT within 120 s of the suspect')
|
||||
elif wait_msg(['no clean re-check within'], el() + BUDGET_S + 75) is None:
|
||||
log('!! no escalation FAULT within %d s of the suspect' % (BUDGET_S + 75))
|
||||
ok = False
|
||||
finally:
|
||||
try:
|
||||
if budget_was is None:
|
||||
st, _ = forgectrl_post('/settings', params={'cool_confirm_max_s': ''})
|
||||
else:
|
||||
st, _ = forgectrl_post('/settings', data={'cool_confirm_max_s': str(budget_was)})
|
||||
log('cool_confirm_max_s restored to %s (-> %s)' % (budget_was or 'unset', st))
|
||||
except OSError as e:
|
||||
log('!! could not restore cool_confirm_max_s: %s' % e)
|
||||
ok = False
|
||||
try:
|
||||
s.sendall(b'M9\n')
|
||||
time.sleep(1.0)
|
||||
@@ -83,3 +106,4 @@ finally:
|
||||
log('--- M9 sent, pump on, heater off')
|
||||
|
||||
print('ESCALATION DRILL %s' % ('COMPLETE' if ok else 'FAILED'), flush=True)
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
@@ -21,37 +21,32 @@ so slow ambient drift spreads across conditions instead of confounding
|
||||
any single one.
|
||||
|
||||
Safety: aborts a run if downstream passes ABORT_C; heater off and pump
|
||||
on at every exit path. The controller is stopped for the duration so it
|
||||
cannot touch the fans or heater, and restarted at the end.
|
||||
on at every exit path. Drives the heater, pump and fans directly: run
|
||||
with forgectrl and the controller stopped for the duration (the bench
|
||||
page's takeover does that; from a host, stop them first). Runs on the
|
||||
board or from a host (gfbench: GF_HOST).
|
||||
|
||||
Output: incremental JSON to flow_matrix_results.json (so partial runs
|
||||
are still usable) and a summary table at the end.
|
||||
Output: incremental JSON to flow_matrix_results.json in the bench data
|
||||
directory (gfbench.data_path; so partial runs are still usable and
|
||||
resumable) and a summary table at the end.
|
||||
|
||||
Usage: flow_matrix.py [duties] [repeats] e.g. flow_matrix.py 10,20,40 5
|
||||
(or env FM_DUTIES, FM_REPEATS, FM_RESULTS)
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shlex
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('set GF_HOST to the machine IP address')
|
||||
# ssh client used to reach the board; override for a wrapper, e.g.
|
||||
# GF_SSH='wsl -d <distro> -- ssh'.
|
||||
SSH = shlex.split(os.environ.get('GF_SSH', 'ssh'))
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
RESULTS = os.path.join(HERE, os.environ.get('FM_RESULTS', 'flow_matrix_results.json'))
|
||||
from gfbench import board, degc, data_path
|
||||
|
||||
# Factory B-equation conversion (kernel-module-glowforge/UAPI.md).
|
||||
F = 1024.0 * 1.3
|
||||
RD, BETA = 10000.0, 3380.0
|
||||
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
|
||||
RESULTS = data_path(os.environ.get('FM_RESULTS', 'flow_matrix_results.json'))
|
||||
|
||||
DUTIES = [int(x) for x in os.environ.get('FM_DUTIES', '10,15,20,30,40,50').split(',')]
|
||||
REPEATS = int(os.environ.get('FM_REPEATS', '5'))
|
||||
DUTIES = [int(x) for x in (sys.argv[1] if len(sys.argv) > 1 else
|
||||
os.environ.get('FM_DUTIES', '10,15,20,30,40,50')).split(',')]
|
||||
REPEATS = int(sys.argv[2]) if len(sys.argv) > 2 else int(os.environ.get('FM_REPEATS', '5'))
|
||||
RUN_S = 75
|
||||
SAMPLE_IV = 1.0
|
||||
DURATIONS = [15, 20, 25, 30, 40, 50, 60, 75]
|
||||
@@ -64,21 +59,6 @@ FANS_RUN = ('echo 65535 > /sys/glowforge/thermal/exhaust_pwm; '
|
||||
'echo 204 > /sys/glowforge/head/air_assist_pwm')
|
||||
|
||||
|
||||
def degc(raw):
|
||||
raw = float(raw)
|
||||
if raw <= 0 or raw >= F:
|
||||
return float('nan')
|
||||
r = RD / (F / raw - 1.0)
|
||||
return BETA / math.log(r / RINF) - 273.15
|
||||
|
||||
|
||||
def board(cmd, timeout=120):
|
||||
r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none',
|
||||
'root@' + HOST, cmd],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
return r.stdout
|
||||
|
||||
|
||||
def temps():
|
||||
o = board('cat /sys/glowforge/pic/water_temp_1 /sys/glowforge/pic/water_temp_2').split()
|
||||
return degc(o[0]), degc(o[1])
|
||||
@@ -204,7 +184,7 @@ def summarize(runs, log):
|
||||
|
||||
def main():
|
||||
t_start = time.time()
|
||||
log_path = os.path.join(HERE, 'flow_matrix_log.txt')
|
||||
log_path = data_path('flow_matrix_log.txt')
|
||||
logf = open(log_path, 'a')
|
||||
|
||||
def log(msg):
|
||||
@@ -215,8 +195,6 @@ def main():
|
||||
log('=== flow matrix started %s' % time.strftime('%Y-%m-%d %H:%M:%S'))
|
||||
log('duties=%s repeats=%d run=%ds durations=%s' % (DUTIES, REPEATS, RUN_S, DURATIONS))
|
||||
|
||||
board('pkill -x grblHAL_glowfor; sleep 1; true')
|
||||
log('controller stopped for the experiment')
|
||||
board(FANS_RUN)
|
||||
safe_state()
|
||||
|
||||
@@ -264,10 +242,7 @@ def main():
|
||||
'echo 0 > /sys/glowforge/thermal/intake_pwm')
|
||||
|
||||
summarize(runs, log)
|
||||
|
||||
board('cd /data && GFSINK=/dev/glowforge nohup grblHAL_glowforge -p 23 '
|
||||
'-e /data/EEPROM-glowforge.DAT > /data/glowforge.log 2>&1 & sleep 2; true')
|
||||
log('controller restarted; total elapsed %.2f h' % ((time.time() - t_start) / 3600))
|
||||
log('results: %s; total elapsed %.2f h' % (RESULTS, (time.time() - t_start) / 3600))
|
||||
logf.close()
|
||||
|
||||
|
||||
|
||||
@@ -35,38 +35,18 @@ the machine).
|
||||
Usage: flow_recheck_char.py [heater_pct] [window_s] (default 50 30)
|
||||
Runs both flow and no-flow cases from a comparable loop state and
|
||||
prints the differential separation. Aborts if downstream passes 45 C.
|
||||
Drives the heater and pump directly: run with forgectrl and the
|
||||
controller stopped (the bench page's takeover does that). Runs on the
|
||||
board or from a host (gfbench: GF_HOST).
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('set GF_HOST to the machine IP address')
|
||||
# ssh client used to reach the board; override for a wrapper, e.g.
|
||||
# GF_SSH='wsl -d <distro> -- ssh'.
|
||||
SSH = shlex.split(os.environ.get('GF_SSH', 'ssh'))
|
||||
F = 1024.0 * 1.3
|
||||
RD, BETA = 10000.0, 3380.0
|
||||
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
|
||||
from gfbench import board, degc
|
||||
|
||||
DOWN_ABORT_C = 45.0
|
||||
|
||||
|
||||
def degc(raw):
|
||||
r = RD / (F / raw - 1.0)
|
||||
return BETA / math.log(r / RINF) - 273.15
|
||||
|
||||
|
||||
def board(cmd):
|
||||
r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none',
|
||||
'root@' + HOST, cmd],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def sample():
|
||||
o = board('cat /sys/glowforge/pic/water_temp_1 /sys/glowforge/pic/water_temp_2').split()
|
||||
return degc(int(o[0])), degc(int(o[1]))
|
||||
|
||||
@@ -6,38 +6,20 @@ job, and does the loop reach equilibrium or climb without bound? Runs
|
||||
the driver's real check cadence (M8 held for the duration) with the
|
||||
cut-profile fans, logging bulk coolant temperature and every verdict.
|
||||
|
||||
Needs the controller running with forgectrl's cooling engine (the
|
||||
verdict lines arrive on the Grbl socket). Runs on the board or from a
|
||||
host (gfbench: GF_HOST).
|
||||
|
||||
Usage: flow_sustained.py [minutes] (default 30)
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import shlex
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('set GF_HOST to the machine IP address')
|
||||
# ssh client used to reach the board; override for a wrapper, e.g.
|
||||
# GF_SSH='wsl -d <distro> -- ssh'.
|
||||
SSH = shlex.split(os.environ.get('GF_SSH', 'ssh'))
|
||||
F = 1024.0 * 1.3
|
||||
RD, BETA = 10000.0, 3380.0
|
||||
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
|
||||
from gfbench import HOST, board, degc, setting
|
||||
|
||||
|
||||
def degc(raw):
|
||||
r = RD / (F / float(raw) - 1.0)
|
||||
return BETA / math.log(r / RINF) - 273.15
|
||||
|
||||
|
||||
def board(cmd):
|
||||
r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none',
|
||||
'root@' + HOST, cmd],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
return r.stdout.strip()
|
||||
THRESHOLD = float(setting('cool_flow_rise', 14.4)) # forgectrl's configured threshold
|
||||
|
||||
|
||||
def temps():
|
||||
@@ -92,8 +74,8 @@ print()
|
||||
print('checks run: %d over %.0f min' % (len(verdicts), minutes))
|
||||
if verdicts:
|
||||
rises = [v[1] for v in verdicts]
|
||||
print('rise values: min=%.2f max=%.2f mean=%.2f (threshold 13.7)'
|
||||
% (min(rises), max(rises), sum(rises) / len(rises)))
|
||||
print('rise values: min=%.2f max=%.2f mean=%.2f (threshold %.1f)'
|
||||
% (min(rises), max(rises), sum(rises) / len(rises), THRESHOLD))
|
||||
print('false faults: %d' % sum(1 for v in verdicts if v[2]))
|
||||
print('loop temperature: start %.2f -> end %.2f (%+.2f C), peak %.2f'
|
||||
% (u0, u, u - u0, peak_up))
|
||||
|
||||
@@ -10,33 +10,25 @@ heater (which plateaus near 28-29 C - the most it can reach unaided),
|
||||
then runs the real check at 40% / 50 s with the cut-profile fans, and
|
||||
alternates flow / no-flow so both cases see the same conditions.
|
||||
|
||||
Drives the heater, pump and fans directly: run with forgectrl and the
|
||||
controller stopped (the bench page's takeover does that; from a host,
|
||||
stop them first). Runs on the board or from a host (gfbench: GF_HOST).
|
||||
Results and the log go to the bench data directory (gfbench.data_path).
|
||||
|
||||
Usage: flow_warm_validate.py [cycles_per_case] (default 3)
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shlex
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('set GF_HOST to the machine IP address')
|
||||
# ssh client used to reach the board; override for a wrapper, e.g.
|
||||
# GF_SSH='wsl -d <distro> -- ssh'.
|
||||
SSH = shlex.split(os.environ.get('GF_SSH', 'ssh'))
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
RESULTS = os.path.join(HERE, 'flow_warm_results.json')
|
||||
from gfbench import board, degc, data_path, setting
|
||||
|
||||
F = 1024.0 * 1.3
|
||||
RD, BETA = 10000.0, 3380.0
|
||||
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
|
||||
RESULTS = data_path('flow_warm_results.json')
|
||||
|
||||
DUTY = 40
|
||||
CHECK_S = 50
|
||||
THRESHOLD = 13.7
|
||||
THRESHOLD = float(setting('cool_flow_rise', 14.4)) # forgectrl's configured threshold
|
||||
WARM_TARGET_C = 25.5 # what a ~19-20 C room permits at 50% duty
|
||||
WARM_MAX_S = 780
|
||||
ABORT_C = 48.0
|
||||
@@ -47,21 +39,6 @@ FANS_OFF = ('echo 0 > /sys/glowforge/thermal/exhaust_pwm; '
|
||||
'echo 0 > /sys/glowforge/thermal/intake_pwm')
|
||||
|
||||
|
||||
def degc(raw):
|
||||
raw = float(raw)
|
||||
if raw <= 1.0 or raw >= F:
|
||||
return float('nan')
|
||||
r = RD / (F / raw - 1.0)
|
||||
return BETA / math.log(r / RINF) - 273.15
|
||||
|
||||
|
||||
def board(cmd, timeout=120):
|
||||
r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none',
|
||||
'root@' + HOST, cmd],
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
return r.stdout
|
||||
|
||||
|
||||
_last_good = (22.0, 22.0)
|
||||
|
||||
|
||||
@@ -155,7 +132,7 @@ def check(flow, log):
|
||||
|
||||
def main():
|
||||
cycles = int(sys.argv[1]) if len(sys.argv) > 1 else 3
|
||||
logf = open(os.path.join(HERE, 'flow_warm_log.txt'), 'a')
|
||||
logf = open(data_path('flow_warm_log.txt'), 'a')
|
||||
|
||||
def log(msg):
|
||||
print(msg, flush=True)
|
||||
@@ -164,7 +141,6 @@ def main():
|
||||
|
||||
log('=== warm-baseline validation %s duty=%d%% window=%ds threshold=%.1f'
|
||||
% (time.strftime('%Y-%m-%d %H:%M:%S'), DUTY, CHECK_S, THRESHOLD))
|
||||
board('pkill -x grblHAL_glowfor; sleep 1; true')
|
||||
|
||||
runs = []
|
||||
try:
|
||||
@@ -199,11 +175,9 @@ def main():
|
||||
% (min(nv) - max(fv), THRESHOLD, THRESHOLD - max(fv), min(nv) - THRESHOLD))
|
||||
wrong = [r for r in runs if ((r['down_rise'] > THRESHOLD) != (not r['flow']))]
|
||||
log(' misclassified: %d of %d' % (len(wrong), len(runs)))
|
||||
|
||||
board('cd /data && GFSINK=/dev/glowforge nohup grblHAL_glowforge -p 23 '
|
||||
'-e /data/EEPROM-glowforge.DAT > /data/glowforge.log 2>&1 & sleep 2; true')
|
||||
log('controller restarted')
|
||||
log('results: %s' % RESULTS)
|
||||
logf.close()
|
||||
return 1 if wrong else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared helpers for the bench tools that can run either ON the board or
|
||||
from a LAN host.
|
||||
|
||||
from gfbench import HOST, LOCAL, board, degc, data_path
|
||||
|
||||
HOST / LOCAL the machine address and whether this process runs on it.
|
||||
GF_HOST names a remote machine (host mode: sysfs through
|
||||
ssh, Grbl and forgectrl over the LAN). Unset, the tool
|
||||
runs on the board itself when /sys/glowforge exists (local
|
||||
mode: sysfs directly, Grbl and forgectrl on 127.0.0.1) -
|
||||
the acceptance tool's bench page runs them that way.
|
||||
board(cmd) run a shell command on the machine and return its stdout
|
||||
(local: sh -c; host: ssh, or the client named by GF_SSH,
|
||||
e.g. GF_SSH='wsl -d <distro> -- ssh').
|
||||
degc(raw) the factory B-equation coolant conversion
|
||||
(kernel-module-glowforge/UAPI.md).
|
||||
data_path(f) where a tool keeps its data files: FORGETEST_BENCH_DATA
|
||||
when set (the bench page passes <data>/bench/), else next
|
||||
to the tool.
|
||||
forgectrl_* the machine-services HTTP API (:8080) with the panel token
|
||||
from GF_TOKEN or, on the board, /data/forgefirm/panel.token.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
_LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1")
|
||||
|
||||
|
||||
def _resolve():
|
||||
host = os.environ.get("GF_HOST")
|
||||
if host:
|
||||
return host, host in _LOCAL_HOSTS
|
||||
if os.path.isdir("/sys/glowforge"):
|
||||
return "127.0.0.1", True
|
||||
raise SystemExit("set GF_HOST to the machine IP address (or run this on the board)")
|
||||
|
||||
|
||||
HOST, LOCAL = _resolve()
|
||||
SSH = shlex.split(os.environ.get("GF_SSH", "ssh"))
|
||||
|
||||
|
||||
def board(cmd, timeout=30):
|
||||
"""stdout of a shell command run on the machine (not stripped)."""
|
||||
if LOCAL:
|
||||
argv = ["sh", "-c", cmd]
|
||||
else:
|
||||
argv = SSH + ["-o", "PreferredAuthentications=none", "root@" + HOST, cmd]
|
||||
r = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
|
||||
return r.stdout
|
||||
|
||||
|
||||
# Factory B-equation conversion: 10k B3380 NTC in a 10k divider behind a
|
||||
# 1.3x gain stage, 10-bit ADC.
|
||||
F = 1024.0 * 1.3
|
||||
RD, BETA = 10000.0, 3380.0
|
||||
RINF = 10000.0 * math.exp(-3380.0 / 298.15)
|
||||
|
||||
|
||||
def degc(raw):
|
||||
try:
|
||||
raw = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return float("nan")
|
||||
if raw <= 1.0 or raw >= F:
|
||||
return float("nan")
|
||||
r = RD / (F / raw - 1.0)
|
||||
return BETA / math.log(r / RINF) - 273.15
|
||||
|
||||
|
||||
def data_dir():
|
||||
d = os.environ.get("FORGETEST_BENCH_DATA")
|
||||
if d:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
return HERE
|
||||
|
||||
|
||||
def data_path(name):
|
||||
return os.path.join(data_dir(), name)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- forgectrl
|
||||
|
||||
def forgectrl_base():
|
||||
return os.environ.get("FORGECTRL_URL") or "http://%s:8080" % HOST
|
||||
|
||||
|
||||
def token():
|
||||
tok = os.environ.get("GF_TOKEN")
|
||||
if tok:
|
||||
return tok
|
||||
if LOCAL:
|
||||
try:
|
||||
with open("/data/forgefirm/panel.token", "r", encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def forgectrl_request(method, path, params=None, data=None, timeout=8.0):
|
||||
"""(status, body): body is parsed JSON when the response is JSON, else
|
||||
text. Raises OSError-derived errors when the daemon is unreachable."""
|
||||
url = forgectrl_base() + path
|
||||
if params:
|
||||
url += ("&" if "?" in url else "?") + urllib.parse.urlencode(params)
|
||||
body = None
|
||||
hdrs = {"Host": urllib.parse.urlsplit(forgectrl_base()).netloc}
|
||||
if data is not None:
|
||||
body = urllib.parse.urlencode(data).encode()
|
||||
hdrs["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
tok = token()
|
||||
if tok:
|
||||
hdrs["X-ForgeFIRM-Token"] = tok
|
||||
req = urllib.request.Request(url, data=body, method=method, headers=hdrs)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
status, content, ctype = resp.status, resp.read(), resp.headers.get("Content-Type", "")
|
||||
except urllib.error.HTTPError as e:
|
||||
status, content = e.code, e.read()
|
||||
ctype = e.headers.get("Content-Type", "") if e.headers else ""
|
||||
text = content.decode("utf-8", "replace")
|
||||
if "json" in ctype:
|
||||
try:
|
||||
return status, json.loads(text)
|
||||
except ValueError:
|
||||
pass
|
||||
return status, text
|
||||
|
||||
|
||||
def forgectrl_get(path, **kw):
|
||||
return forgectrl_request("GET", path, **kw)
|
||||
|
||||
|
||||
def forgectrl_post(path, **kw):
|
||||
return forgectrl_request("POST", path, **kw)
|
||||
|
||||
|
||||
SETTINGS_FILE = "/data/forgefirm.conf"
|
||||
|
||||
|
||||
def setting(key, default=None):
|
||||
"""One shared machine setting (forgectrl's /settings; on the board with
|
||||
forgectrl stopped - a takeover - the settings file itself), or default
|
||||
when unset or unreadable."""
|
||||
try:
|
||||
st, body = forgectrl_get("/settings")
|
||||
if st == 200 and isinstance(body, dict):
|
||||
val = body.get(key)
|
||||
return default if val in (None, "") else val
|
||||
except OSError:
|
||||
pass
|
||||
if LOCAL:
|
||||
try:
|
||||
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
k, v = line.split("=", 1)
|
||||
if k.strip() == key:
|
||||
v = v.strip()
|
||||
return default if v == "" else v
|
||||
except OSError:
|
||||
pass
|
||||
return default
|
||||
@@ -1,10 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live-fire bench drills - Phases 4, 5, 6. Runs from a LAN host against
|
||||
grblHAL over TCP (argv[1] or GF_HOST, port 23) and forgectrl over HTTP
|
||||
(:8080). LIVE LASER: the operator must be armed with eye protection, a
|
||||
fire watch, an extinguisher, and the exhaust running. Every drill waits
|
||||
for the operator to press the physical arm button before the machine
|
||||
fires; nothing here defeats that gate.
|
||||
"""Live-fire bench drills - Phases 4, 5, 6. Runs on the board (the bench
|
||||
page) or from a LAN host, against grblHAL over TCP (port 23) and
|
||||
forgectrl over HTTP (:8080); the machine is GF_HOST, default 127.0.0.1.
|
||||
LIVE LASER: the operator must be armed with eye protection, a fire
|
||||
watch, an extinguisher, and the exhaust running. Every drill waits for
|
||||
the operator to press the physical arm button before the machine fires;
|
||||
nothing here defeats that gate.
|
||||
|
||||
Usage: live_fire_drills.py <drill> [S] [F] (S, F used by ircut)
|
||||
|
||||
Drills (pass a name):
|
||||
witness Phase 5 A-1/A-2/A-5: a short vector mark at S400. Samples
|
||||
@@ -29,15 +32,15 @@ Drills (pass a name):
|
||||
engine's own "run telemetry" line is the record. Run it
|
||||
>= 3 times on representative material; the highest peak
|
||||
delta sizes cool_fire_ir_delta.
|
||||
ircut [host] [S] [F] e.g. ircut 192.0.2.1 1000 300
|
||||
ircut [S] [F] e.g. ircut 1000 300
|
||||
expstop Armed kill on the EXPECTED-stop path: start a mark job,
|
||||
then mid-burn POST /controller/stop (the supervisor stops
|
||||
the controller: SIGTERM, reap, exit safing). PASS: emission
|
||||
drops to 0 within a few samples of the stop and stays 0,
|
||||
the kernel is not running, and POST /controller/start
|
||||
is a SEPARATE step (`ctrlstart`, run after the operator has
|
||||
judged the stop). Needs the panel token in GF_TOKEN
|
||||
(cat /data/forgefirm/panel.token on the board).
|
||||
judged the stop). Needs the panel token: GF_TOKEN, or
|
||||
/data/forgefirm/panel.token when running on the board.
|
||||
ctrlstart POST /controller/start after an expstop; no motion, no laser.
|
||||
|
||||
The G-4 arm-refuses-when-a-fire-gate-is-active drill is operator-manual
|
||||
@@ -50,12 +53,21 @@ import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
HOST = sys.argv[2] if len(sys.argv) > 2 else os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('usage: live_fire_drills.py <drill> [host] (or set GF_HOST)')
|
||||
HOST = os.environ.get('GF_HOST') or '127.0.0.1'
|
||||
PORT = 23
|
||||
BASE = 'http://%s:8080' % HOST
|
||||
|
||||
|
||||
def panel_token():
|
||||
tok = os.environ.get('GF_TOKEN', '')
|
||||
if tok:
|
||||
return tok
|
||||
try:
|
||||
with open('/data/forgefirm/panel.token') as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return ''
|
||||
|
||||
# Ambient lid-IR baseline (2026-08-14, lid closed, idle): per-channel means.
|
||||
IR_BASELINE = [37.3, 36.3, 39.5, 40.0]
|
||||
|
||||
@@ -357,8 +369,8 @@ def drill_faultpos(g):
|
||||
|
||||
|
||||
def drill_ircut(g):
|
||||
power = int(sys.argv[3]) if len(sys.argv) > 3 else 1000
|
||||
feed = int(sys.argv[4]) if len(sys.argv) > 4 else 300
|
||||
power = int(sys.argv[2]) if len(sys.argv) > 2 else 1000
|
||||
feed = int(sys.argv[3]) if len(sys.argv) > 3 else 300
|
||||
print('=== lid-IR characterization: S%d F%d 30 mm square ===' % (power, feed))
|
||||
print('connect: %s' % prepare(g))
|
||||
base = sample_forgectrl()
|
||||
@@ -405,7 +417,7 @@ def drill_ircut(g):
|
||||
def post_ctrl(action):
|
||||
# http.client preserves the header-name case exactly as given.
|
||||
import http.client
|
||||
tok = os.environ.get('GF_TOKEN', '')
|
||||
tok = panel_token()
|
||||
c = http.client.HTTPConnection(HOST, 8080, timeout=8)
|
||||
c.putrequest('POST', '/controller/' + action)
|
||||
c.putheader('X-ForgeFIRM-Token', tok)
|
||||
@@ -419,8 +431,8 @@ def post_ctrl(action):
|
||||
|
||||
def drill_expstop(g):
|
||||
print('=== armed kill on the expected-stop path (POST /controller/stop) ===')
|
||||
if not os.environ.get('GF_TOKEN'):
|
||||
raise SystemExit('set GF_TOKEN to the panel token first')
|
||||
if not panel_token():
|
||||
raise SystemExit('set GF_TOKEN to the panel token first (or run on the board)')
|
||||
print('connect: %s' % prepare(g))
|
||||
arm_cue()
|
||||
job = ['G91', 'G21', 'M4', 'S400',
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hold LASER_PWM at one duty value for a measurement window, then restore.
|
||||
Usage: pwm_hold.py [sar] [seconds] (defaults: 8, 30)"""
|
||||
Usage: pwm_hold.py [sar] [seconds] (defaults: 8, 30)
|
||||
|
||||
The duty register (PWMSAR) is the laser power SETPOINT only. Locked
|
||||
state only: run with the controller and forgectrl stopped (the bench
|
||||
page's takeover), the pulse device closed. The latch is relocked here
|
||||
as well, and the hold refuses to write if the FIRE line reads driven or
|
||||
LASER_ON reads active."""
|
||||
import mmap, struct, sys, time
|
||||
|
||||
PWM2_BASE = 0x02084000
|
||||
@@ -9,6 +15,26 @@ SAR_OFF = 0x0C
|
||||
sar = int(sys.argv[1]) if len(sys.argv) > 1 else 8
|
||||
secs = int(sys.argv[2]) if len(sys.argv) > 2 else 30
|
||||
|
||||
|
||||
def rd(name):
|
||||
with open('/sys/glowforge/' + name) as f:
|
||||
return f.read().strip()
|
||||
|
||||
|
||||
def wr(name, val):
|
||||
with open('/sys/glowforge/' + name, 'w') as f:
|
||||
f.write(str(val))
|
||||
|
||||
|
||||
try:
|
||||
wr('cnc/laser_latch', 1)
|
||||
except OSError as e:
|
||||
raise SystemExit('could not lock the laser latch: %s' % e)
|
||||
time.sleep(0.2)
|
||||
en, on = rd('cnc/laser_enable'), rd('cnc/laser_on')
|
||||
if en != '0' or on != '0':
|
||||
raise SystemExit('not the locked state: laser_enable=%s laser_on=%s' % (en, on))
|
||||
|
||||
with open('/dev/mem', 'r+b') as f:
|
||||
m = mmap.mmap(f.fileno(), 4096, mmap.MAP_SHARED,
|
||||
mmap.PROT_READ | mmap.PROT_WRITE, offset=PWM2_BASE)
|
||||
@@ -16,9 +42,11 @@ with open('/dev/mem', 'r+b') as f:
|
||||
m[SAR_OFF:SAR_OFF + 4] = struct.pack('<I', sar)
|
||||
print('PWMSAR=%d held for %d s (was %d)...' % (sar, secs, sar0))
|
||||
sys.stdout.flush()
|
||||
time.sleep(secs)
|
||||
m[SAR_OFF:SAR_OFF + 4] = struct.pack('<I', sar0)
|
||||
print('restored PWMSAR=%d' % sar0)
|
||||
with open('/sys/glowforge/cnc/laser_on_sampled') as s:
|
||||
print('laser_on_sampled =', s.read().strip())
|
||||
m.close()
|
||||
try:
|
||||
time.sleep(secs)
|
||||
finally:
|
||||
m[SAR_OFF:SAR_OFF + 4] = struct.pack('<I', sar0)
|
||||
print('restored PWMSAR=%d' % sar0)
|
||||
print('laser_enable=%s laser_on=%s laser_on_sampled=%s'
|
||||
% (rd('cnc/laser_enable'), rd('cnc/laser_on'), rd('cnc/laser_on_sampled')))
|
||||
m.close()
|
||||
|
||||
@@ -12,8 +12,12 @@ Safety posture:
|
||||
- laser_latch re-asserted locked; lid closed; HV watchdog untouched.
|
||||
- Position counters compared before/after - must be identical.
|
||||
- streaming stays 0: end-of-data is a normal completion.
|
||||
|
||||
Runs with the controller and forgectrl stopped (the bench page's
|
||||
takeover). Exit status 0 when the counters did not move, the run ended
|
||||
in an idle state and no FIRE/emission was read back; 1 otherwise.
|
||||
"""
|
||||
import fcntl, mmap, os, struct, time
|
||||
import fcntl, mmap, os, struct, sys, time
|
||||
|
||||
TICK_HZ = 10000
|
||||
PWM2_BASE = 0x02084000
|
||||
@@ -88,15 +92,18 @@ try:
|
||||
dt = time.time() - t0
|
||||
print('done: state=%s after %.1f s' % (state, dt))
|
||||
pos_after = rd_pos()
|
||||
print('post: pos before=%s after=%s MOVED=%s'
|
||||
% (pos_before, pos_after, pos_before[:3] != pos_after[:3]))
|
||||
moved = pos_before[:3] != pos_after[:3]
|
||||
print('post: pos before=%s after=%s MOVED=%s' % (pos_before, pos_after, moved))
|
||||
en, on, sampled = rd('cnc/laser_enable'), rd('cnc/laser_on'), rd('cnc/laser_on_sampled')
|
||||
print('post: laser_enable=%s laser_on=%s laser_on_sampled=%s faults=%s underruns=%s'
|
||||
% (rd('cnc/laser_enable'), rd('cnc/laser_on'),
|
||||
rd('cnc/laser_on_sampled'), rd('cnc/faults'), rd('cnc/underruns')))
|
||||
% (en, on, sampled, rd('cnc/faults'), rd('cnc/underruns')))
|
||||
print('post: PWMSAR=%d (duty after end-of-data)' % pwmsar())
|
||||
ok = (not moved) and state == 'idle' and en == '0' and on == '0' and sampled == '0'
|
||||
finally:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
os.close(fd)
|
||||
|
||||
wr('cnc/disable', 1)
|
||||
print('safe state restored: state=%s' % rd('cnc/state'))
|
||||
print('RESULT %s' % ('PASS' if ok else 'FAIL'))
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
@@ -8,6 +8,11 @@ sweep: step the PWM duty register through known values with pauses so
|
||||
The duty register (PWMSAR) is the laser power SETPOINT only. No motion
|
||||
subsystem is touched (steppers are disabled before this runs), no
|
||||
stream runs, the laser latch is locked, FIRE is never asserted.
|
||||
|
||||
Locked state only: run with the controller and forgectrl stopped (the
|
||||
bench page's takeover), the pulse device closed. The latch is relocked
|
||||
here as well, and the sweep refuses to write if the FIRE line reads
|
||||
driven or LASER_ON reads active. Usage: pwm_sweep.py [check|sweep]
|
||||
"""
|
||||
import mmap, struct, sys, time
|
||||
|
||||
@@ -34,6 +39,26 @@ def dump_regs(m):
|
||||
return sar, pr
|
||||
|
||||
|
||||
def wr(name, val):
|
||||
with open('/sys/glowforge/' + name, 'w') as f:
|
||||
f.write(str(val))
|
||||
|
||||
|
||||
def locked_state():
|
||||
"""The latch commanded locked, FIRE not driven, no emission."""
|
||||
try:
|
||||
wr('cnc/laser_latch', 1)
|
||||
except OSError as e:
|
||||
print('!! could not lock the laser latch: %s' % e)
|
||||
return False
|
||||
time.sleep(0.2)
|
||||
en, on = rd('cnc/laser_enable'), rd('cnc/laser_on')
|
||||
if en != '0' or on != '0':
|
||||
print('!! not the locked state: laser_enable=%s laser_on=%s' % (en, on))
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def safety_readback():
|
||||
print('cnc/state =', rd('cnc/state'))
|
||||
print('cnc/laser_on =', rd('cnc/laser_on'))
|
||||
@@ -56,6 +81,9 @@ with open('/dev/mem', 'r+b') as f:
|
||||
sar0, pr = dump_regs(m)
|
||||
|
||||
if mode == 'sweep':
|
||||
if not locked_state():
|
||||
m.close()
|
||||
sys.exit(2)
|
||||
period = pr + 2
|
||||
steps = [(64, '50%'), (32, '25%'), (96, '75%'), (8, '6%'), (127, '100%')]
|
||||
print('--- duty sweep: 4 s per step, watch the scope')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Coolant temperature spot-check helper (runs on Windows, reads the
|
||||
board over ssh).
|
||||
"""Coolant temperature spot-check helper (runs on the board or from a
|
||||
host; gfbench: GF_HOST).
|
||||
|
||||
The raw->Celsius conversion in UAPI.md is the factory B-equation (10k
|
||||
B3380 NTC in a 10k divider behind a 1.3x gain stage, 10-bit ADC). This
|
||||
@@ -9,47 +9,30 @@ the machine's raw ADC readings - and fits a per-machine line to
|
||||
cross-check that curve against a thermometer.
|
||||
|
||||
Usage:
|
||||
temp_calibrate.py watch live raw + current-formula C
|
||||
temp_calibrate.py point <measured_C> record a calibration point
|
||||
temp_calibrate.py fit fit and print the calibration
|
||||
temp_calibrate.py watch [seconds] live raw + current-formula C
|
||||
(default 60 s)
|
||||
temp_calibrate.py point <measured_C> [note] record a calibration point
|
||||
temp_calibrate.py fit fit and print the calibration
|
||||
|
||||
Points accumulate in temp_calibration.json next to this script. Take at
|
||||
least two points as far apart in temperature as practical (e.g. cold
|
||||
Points accumulate in temp_calibration.json in the bench data directory
|
||||
(gfbench.data_path: next to this script, or FORGETEST_BENCH_DATA). Take
|
||||
at least two points as far apart in temperature as practical (e.g. cold
|
||||
machine in the morning, and warm after a fan-off soak with the flow
|
||||
heater on).
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = os.environ.get('GF_HOST')
|
||||
if not HOST:
|
||||
raise SystemExit('set GF_HOST to the machine IP address')
|
||||
# ssh client used to reach the board; override for a wrapper, e.g.
|
||||
# GF_SSH='wsl -d <distro> -- ssh'.
|
||||
SSH = shlex.split(os.environ.get('GF_SSH', 'ssh'))
|
||||
STORE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'temp_calibration.json')
|
||||
from gfbench import board, degc, data_path
|
||||
|
||||
STORE = data_path('temp_calibration.json')
|
||||
|
||||
|
||||
def uapi_c(raw):
|
||||
"""The UAPI.md factory conversion (B-equation NTC behind divider + gain)."""
|
||||
adc_f = 1024.0 * 1.3
|
||||
if raw <= 0 or raw >= adc_f:
|
||||
return float('nan')
|
||||
rinf = 10000.0 * math.exp(-3380.0 / 298.15)
|
||||
r = 10000.0 / (adc_f / raw - 1.0)
|
||||
return 3380.0 / math.log(r / rinf) - 273.15
|
||||
|
||||
|
||||
def board(cmd):
|
||||
r = subprocess.run(SSH + ['-o', 'PreferredAuthentications=none',
|
||||
'root@' + HOST, cmd],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
return r.stdout.strip()
|
||||
return degc(raw)
|
||||
|
||||
|
||||
def raws(samples=5, delay=1.0):
|
||||
@@ -93,24 +76,36 @@ def main():
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else 'watch'
|
||||
|
||||
if mode == 'watch':
|
||||
print('raw1(down) raw2(up) uapi-C down/up (ctrl-C to stop)')
|
||||
while True:
|
||||
seconds = float(sys.argv[2]) if len(sys.argv) > 2 else 60.0
|
||||
print('raw1(down) raw2(up) uapi-C down/up (%.0f s)' % seconds)
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < seconds:
|
||||
r1, r2 = raws(1, 0)
|
||||
print(' %6.1f %6.1f %.2f / %.2f'
|
||||
% (r1, r2, uapi_c(r1), uapi_c(r2)))
|
||||
if r1 is None:
|
||||
print(' (no reading)')
|
||||
else:
|
||||
print(' %6.1f %6.1f %.2f / %.2f'
|
||||
% (r1, r2, uapi_c(r1), uapi_c(r2)), flush=True)
|
||||
time.sleep(2)
|
||||
|
||||
elif mode == 'point':
|
||||
measured = float(sys.argv[2])
|
||||
try:
|
||||
measured = float(sys.argv[2])
|
||||
except (IndexError, ValueError):
|
||||
print('point needs the thermometer reading in C (value)')
|
||||
return 2
|
||||
note = sys.argv[3] if len(sys.argv) > 3 else ''
|
||||
print('sampling raws (10 s)...')
|
||||
print('sampling raws (10 s)...', flush=True)
|
||||
r1, r2 = raws()
|
||||
if r1 is None:
|
||||
print('no readings from the machine')
|
||||
return 1
|
||||
data = load()
|
||||
data['points'].append({'measured_c': measured, 'raw1': r1, 'raw2': r2,
|
||||
'note': note, 'when': time.strftime('%Y-%m-%d %H:%M:%S')})
|
||||
save(data)
|
||||
print('recorded: measured %.2f C raw1=%.1f raw2=%.1f (%d points total)'
|
||||
% (measured, r1, r2, len(data['points'])))
|
||||
print('recorded: measured %.2f C raw1=%.1f raw2=%.1f (%d points total in %s)'
|
||||
% (measured, r1, r2, len(data['points']), STORE))
|
||||
|
||||
elif mode == 'fit':
|
||||
data = load()
|
||||
|
||||
Reference in New Issue
Block a user