mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -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:
+16
-4
@@ -203,10 +203,21 @@ change author's judgment (rule 1).
|
||||
|
||||
The same daemon serves `#bench`: the registry of the bench tools
|
||||
(`scripts/bench`, installed under `/usr/share/forgetest/bench/`), each with
|
||||
its safety class (`dry`, `takeover`, `live`, `scope`), argument form, and
|
||||
last run. A ported tool runs as a subprocess with the output on the page;
|
||||
unported tools are listed with Start disabled. Bench runs are recorded in
|
||||
`/data/forgetest/bench.jsonl` and never enter a campaign.
|
||||
its safety class, argument form, and last run. The classes: `dry` (reads
|
||||
or dry motion, forgectrl stays up), `takeover` (forgectrl and the
|
||||
controller stopped for the run, the pulse device free, the same wrapper
|
||||
the takeover tests use), `scope` (a takeover whose result only means
|
||||
something with the named instrument on the bench), `live` (emission
|
||||
possible; the operator acknowledgment and the physical arm press). A tool
|
||||
runs as a subprocess with the output on the page and, on the board, the
|
||||
machine as `GF_HOST=127.0.0.1`, the panel token in `GF_TOKEN`, and its data
|
||||
files under `/data/forgetest/bench/` (`FORGETEST_BENCH_DATA`); the same
|
||||
scripts run from a LAN host with `GF_HOST` set (`scripts/bench/gfbench.py`,
|
||||
`scripts/bench/README.md`). Every board-runnable tool is ported; the entries
|
||||
that stay unported are the CI harnesses of the null-sink controller and the
|
||||
factory `.puls` decoder, which do not run against the machine at all - they
|
||||
are listed so the catalog of what exists is complete. Bench runs are
|
||||
recorded in `/data/forgetest/bench.jsonl` and never enter a campaign.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -221,6 +232,7 @@ unported tools are listed with Start disabled. Bench runs are recorded in
|
||||
bench.py / coverage.py bench registry + subprocess runner; the lint
|
||||
suite/ the catalog, one module per subsystem
|
||||
forgetest/tests/ host unit tests (python3 -m unittest discover -s tests)
|
||||
scripts/bench/ the bench tools (+ gfbench.py, the board/host helper)
|
||||
scripts/acceptance-gate.py the gate
|
||||
scripts/manifest-from-tree.py manifest from the recipe pins (CI, workstation)
|
||||
releases/v<version>/ the committed artifacts
|
||||
|
||||
+33
-4
@@ -1630,6 +1630,33 @@ that image's campaign is a full one, unavoidably; from then on a
|
||||
component pin bump re-requires only the tests covering that component.
|
||||
Run the full campaign on the first pin-file image, not on `191951`.**
|
||||
|
||||
**2026-08-16, bench-tab ports complete (item 15b).** Every tool that can
|
||||
run against the machine is now runnable from the bench page: the scope
|
||||
tools (`pwm_sweep`, `pwm_hold` - now a takeover with a locked-state guard:
|
||||
the latch relocked, refused if FIRE or LASER_ON reads active;
|
||||
`pwm_stream_test` with a PASS/FAIL exit), the flow characterization
|
||||
family (`flow_characterize`, `flow_recheck_char`, `flow_warm_validate`,
|
||||
`flow_matrix` as takeovers - forgectrl owns the thermal hardware, so the
|
||||
page's takeover replaces the tools' own controller stop/restart, whose
|
||||
command line predated the supervisor; `flow_sustained`, `fan_test` and
|
||||
`temp_calibrate` stay dry), the escalation drill (`cool_confirm_max_s`
|
||||
shortened through forgectrl's settings and restored; the setting's
|
||||
minimum, 60 s, is the default budget) and the live drills (`<drill> [S]
|
||||
[F]`, all six, the token from the board). The host tools keep working
|
||||
from a workstation: `scripts/bench/gfbench.py` resolves `GF_HOST` (host
|
||||
mode, ssh) or the board itself (local mode; the page runs them that way
|
||||
with `GF_HOST=127.0.0.1`, `GF_TOKEN`, and their data files under
|
||||
`/data/forgetest/bench/`). Not ported, by nature: the two null-sink CI
|
||||
harnesses and the `.puls` decoder. Proof: `forgetest/tests/
|
||||
test_bench_registry.py` (registry <-> `scripts/bench` consistency, every
|
||||
ported tool builds its command line, every script compiles, gfbench host
|
||||
and local modes), the server test (a scope tool runs inside the takeover
|
||||
wrapper; the bench environment reaches the tool), and a local-mode smoke
|
||||
run on the bench (`temp_calibrate.py watch`, `gfbench.setting`, the
|
||||
token) staged in `/tmp` and removed. **The ported tools themselves have
|
||||
not been exercised from the page on the bench yet - that rides the next
|
||||
dev image (the confirmation campaign's image).**
|
||||
|
||||
## Hardware facts bank (measured)
|
||||
|
||||
- **DRV8825 stepper drivers wedge on 40 V rail glitches** (factory board;
|
||||
@@ -2966,9 +2993,11 @@ Run the full campaign on the first pin-file image, not on `191951`.**
|
||||
built with the pin files** (that build is a platform change against
|
||||
every result so far; after it, a component pin bump re-requires only
|
||||
the tests covering that component) - the tool on the bench is the
|
||||
tree, but a hot-patched image is not the image that ships; (b) the remaining
|
||||
bench-tab ports (scope tools, host-side flow characterization, the
|
||||
live drills - the catalog carries their acceptance forms); (c) the
|
||||
first release runs the full campaign and commits
|
||||
tree, but a hot-patched image is not the image that ships; (b) the
|
||||
bench-tab ports are **code-complete 2026-08-16** (every board-runnable
|
||||
tool is ported: the scope tools, the flow characterization family,
|
||||
the escalation drill, the live drills; record in "Release acceptance"
|
||||
above) - their bench validation rides the same next dev image; (c)
|
||||
the first release runs the full campaign and commits
|
||||
`releases/v<version>/acceptance.json` - **not yet: no release is
|
||||
cut.**
|
||||
|
||||
@@ -29,6 +29,7 @@ pins; the coverage lint is `python3 -m forgetest.coverage --manifest ...`.
|
||||
| `FORGETEST_MANIFEST` | `/etc/forgefirm-manifest.json` | the image manifest |
|
||||
| `FORGETEST_PORT`, `FORGETEST_HOST` | 8090, 0.0.0.0 | listener |
|
||||
| `FORGETEST_BENCH_DIR` | `/usr/share/forgetest/bench` | the installed bench scripts |
|
||||
| `FORGETEST_BENCH_DATA` | `<FORGETEST_DATA>/bench` | passed to bench tools: where they keep their data files (with `GF_HOST=127.0.0.1` and the panel token in `GF_TOKEN`) |
|
||||
| `FORGETEST_MARKER` | `/run/forgetest.active` | takeover marker |
|
||||
| `FORGECTRL_URL`, `FORGECTRL_TOKEN_FILE` | `http://127.0.0.1:8080`, `/data/forgefirm/panel.token` | forgectrl client |
|
||||
| `GF_SYSFS_ROOT` | `/sys/glowforge/` | kernel module sysfs |
|
||||
|
||||
@@ -12,10 +12,14 @@ so the catalog of what exists is complete, with Start disabled.
|
||||
Safety classes:
|
||||
dry reads or dry motion, no emission, forgectrl stays up
|
||||
takeover needs forgectrl stopped and the pulse device free
|
||||
scope a takeover whose result only means something with the named
|
||||
instrument on the bench (a scope on LASER_PWM / LASER_ON)
|
||||
live laser emission possible (operator acknowledgment required)
|
||||
scope needs bench instrumentation on top of the class before it
|
||||
|
||||
Bench runs are recorded in <data>/bench.jsonl and never enter a campaign.
|
||||
The tools that also run from a LAN host (gfbench.py: GF_HOST) run on the
|
||||
board here with GF_HOST=127.0.0.1, the panel token in GF_TOKEN, and
|
||||
their data files under FORGETEST_BENCH_DATA (<data>/bench/). Bench runs
|
||||
are recorded in <data>/bench.jsonl and never enter a campaign.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -69,20 +73,23 @@ TOOLS = [
|
||||
"desc": "Creeps toward a rail in bounded jog segments, detects the contact jolt, jog-cancels and backs off."},
|
||||
# -- board-side, takeover / scope ------------------------------------------
|
||||
{"id": "pwm-sweep", "title": "LASER_PWM scope sweep", "script": "pwm_sweep.py",
|
||||
"safety": "scope", "where": "board", "ported": False,
|
||||
"safety": "scope", "where": "board", "ported": True,
|
||||
"args": [_arg("mode", "choice", "check", "check = read-only, sweep = duty staircase", ["check", "sweep"])],
|
||||
"desc": "check: readbacks + PWM2 dump; sweep: PWMSAR through 50/25/75/6/100 percent with 4 s holds. Locked state only."},
|
||||
"desc": "check: readbacks + PWM2 dump; sweep: PWMSAR through 50/25/75/6/100 percent with 4 s holds. "
|
||||
"Locked state (the takeover): latch relocked, refuses if FIRE or LASER_ON reads active."},
|
||||
{"id": "pwm-hold", "title": "LASER_PWM scope hold", "script": "pwm_hold.py",
|
||||
"safety": "scope", "where": "board", "ported": False,
|
||||
"safety": "scope", "where": "board", "ported": True,
|
||||
"args": [_arg("sar", "int", 64, "PWMSAR value"), _arg("seconds", "int", 10, "hold time")],
|
||||
"desc": "Holds one PWMSAR value for a scope window, then restores. Locked state only."},
|
||||
"desc": "Holds one PWMSAR value for a scope window, then restores. Locked state (the takeover): "
|
||||
"latch relocked, refuses if FIRE or LASER_ON reads active."},
|
||||
{"id": "fire-test", "title": "FIRE drop-timing test (A/B/U)", "script": "fire_test.py",
|
||||
"safety": "takeover", "where": "board", "ported": True,
|
||||
"args": [_arg("mode", "choice", "A", "A latch locked, B unlocked/unarmed, U true underrun", ["A", "B", "U"])],
|
||||
"desc": "Duty 0 throughout; refuses to unlock if HV reports good. Software witnesses + the PSU-connector LASER_ON scope point."},
|
||||
{"id": "pwm-stream", "title": "LASER_PWM stream-path test", "script": "pwm_stream_test.py",
|
||||
"safety": "takeover", "where": "board", "ported": False, "args": [],
|
||||
"desc": "Streams power bytes only (no steps, no FIRE, motor_lock=15, latch locked) through /dev/glowforge."},
|
||||
"safety": "takeover", "where": "board", "ported": True, "args": [],
|
||||
"desc": "Streams power bytes only (no steps, no FIRE, motor_lock=15, latch locked) through /dev/glowforge; "
|
||||
"PASS = counters unmoved, idle at the end, no FIRE/emission read back. The scope on LASER_PWM sees the duty steps."},
|
||||
{"id": "gate-a-kernel", "title": "Kernel laser-safety drills K1/K2/K3", "script": "gate_a_kernel_drills.py",
|
||||
"safety": "takeover", "where": "board", "ported": True,
|
||||
"args": [_arg("drill", "choice", "K1", "K1 stop floor, K2 resume honors latch, K3 mid-ramp unlock", ["K1", "K2", "K3"])],
|
||||
@@ -97,51 +104,78 @@ TOOLS = [
|
||||
"safety": "dry", "where": "board", "ported": True, "args": [],
|
||||
"desc": "One M8 session walks the verdict state machine through real pump-off transients; PASS/FAIL per transition."},
|
||||
{"id": "flow-escalate", "title": "Coolant starved re-check escalation drill", "script": "flow_escalate_drill.py",
|
||||
"safety": "dry", "where": "board", "ported": False, "args": [],
|
||||
"desc": "With the pump off the job-start check reads SUSPECT and the driver must escalate to FAULT."},
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("budget_s", "int", 60, "cool_confirm_max_s for the drill (60-3600), restored after")],
|
||||
"desc": "With the pump off the job-start check reads SUSPECT and the engine must escalate to FAULT when "
|
||||
"the confirmation budget expires; the budget setting is shortened for the drill and restored."},
|
||||
{"id": "flow-characterize", "title": "Coolant flow characterization", "script": "flow_characterize.py",
|
||||
"safety": "dry", "where": "host", "ported": False,
|
||||
"safety": "takeover", "where": "board", "ported": True,
|
||||
"args": [_arg("duty", "int", 30, "heater duty percent")],
|
||||
"desc": "Baseline -> flow -> no-flow -> recovery with the factory temperature curve; aborts past 45 C downstream."},
|
||||
"desc": "Baseline -> flow -> no-flow -> recovery with the factory temperature curve; aborts past 45 C "
|
||||
"downstream. Drives the heater and pump directly (about 9 minutes)."},
|
||||
{"id": "flow-sustained", "title": "Coolant sustained re-check run", "script": "flow_sustained.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("minutes", "float", 30.0, "how long to hold M8")],
|
||||
"desc": "Long run of the real re-check cadence via M8: verdicts, false faults, loop heat accumulation."},
|
||||
{"id": "flow-warm", "title": "Coolant warm-baseline validation", "script": "flow_warm_validate.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "Runs the real check from a heater-warmed baseline."},
|
||||
"safety": "takeover", "where": "board", "ported": True,
|
||||
"args": [_arg("cycles", "int", 3, "cycles per case (flow / no-flow)")],
|
||||
"desc": "Runs the real check (40 percent / 50 s, cut-profile fans) from a heater-warmed baseline, alternating "
|
||||
"flow and no-flow; results to the bench data directory. Slow: about 15 minutes per cycle."},
|
||||
{"id": "flow-recheck", "title": "Coolant re-check characterization", "script": "flow_recheck_char.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "Short in-run re-checks and the differential metric."},
|
||||
"safety": "takeover", "where": "board", "ported": True,
|
||||
"args": [_arg("heater_pct", "int", 50, "heater duty percent"), _arg("window_s", "int", 30, "re-check window")],
|
||||
"desc": "Short in-run re-checks and the differential metric, flow vs no-flow from a settled loop; "
|
||||
"aborts past 45 C downstream (about 5 minutes)."},
|
||||
{"id": "flow-matrix", "title": "Coolant flow-detection design matrix", "script": "flow_matrix.py",
|
||||
"safety": "takeover", "where": "board", "ported": True,
|
||||
"args": [_arg("duties", "str", "10,15,20,30,40,50", "heater duties, percent, comma-separated"),
|
||||
_arg("repeats", "int", 5, "interleaved repeats per case")],
|
||||
"desc": "duty x flow/no-flow x repeats from a common cooled baseline; cost and precision tables and a "
|
||||
"ranked shortlist (the derivation of cool_flow_rise). Very slow: about 1.6 h for the full matrix; "
|
||||
"resumable from the results file in the bench data directory."},
|
||||
{"id": "flow-sampler", "title": "Coolant sampler", "script": "flow_sampler.py",
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("duration_s", "int", 30, "capture length"), _arg("interval_s", "float", 1.0, "sample interval")],
|
||||
"desc": "Prints elapsed,raw_down,raw_up at the interval; the sampler behind the flow tools."},
|
||||
{"id": "temp-calibrate", "title": "Coolant temperature spot-check", "script": "temp_calibrate.py",
|
||||
"safety": "dry", "where": "host", "ported": False,
|
||||
"safety": "dry", "where": "board", "ported": True,
|
||||
"args": [_arg("mode", "choice", "watch", "watch / point / fit", ["watch", "point", "fit"]),
|
||||
_arg("measured_c", "float", None, "thermometer reading for point")],
|
||||
"desc": "Pairs a measured temperature with averaged raw readings; fits a per-machine line."},
|
||||
_arg("value", "str", None, "point: the thermometer reading in C; watch: seconds (default 60)")],
|
||||
"desc": "Pairs a measured temperature with averaged raw readings; fits a per-machine line. Points "
|
||||
"accumulate in the bench data directory."},
|
||||
{"id": "fan-test", "title": "Fan/coolant bench", "script": "fan_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "Snapshots fan PWMs/tachs/temps, drives M8 -> cut fans, M9 -> cooldown -> idle. Host-side; port pending."},
|
||||
"safety": "dry", "where": "board", "ported": True, "args": [],
|
||||
"desc": "Snapshots fan PWMs/tachs/temps, drives M8 -> cut fans, M9 -> cooldown -> idle; the tach "
|
||||
"readbacks in each snapshot are the evidence."},
|
||||
# -- laser (live) --------------------------------------------------------------
|
||||
{"id": "live-fire", "title": "LIVE laser drills", "script": "live_fire_drills.py",
|
||||
"safety": "live", "where": "board", "ported": False,
|
||||
"args": [_arg("drill", "choice", "witness", "witness / hold / faultpos", ["witness", "hold", "faultpos"])],
|
||||
"argv_fixed_after": ["127.0.0.1"],
|
||||
"desc": "Emission witness, disarm grace in Hold, stale-origin refusal. The operator's arm press is required for every drill."},
|
||||
"safety": "live", "where": "board", "ported": True,
|
||||
"args": [_arg("drill", "choice", "witness", "witness / hold / faultpos / ircut / expstop / ctrlstart",
|
||||
["witness", "hold", "faultpos", "ircut", "expstop", "ctrlstart"]),
|
||||
_arg("power", "int", 1000, "ircut: S value"), _arg("feed", "int", 300, "ircut: F value")],
|
||||
"desc": "Emission witness, disarm grace in Hold, stale-origin refusal, lid-IR characterization cut, armed "
|
||||
"kill on the expected-stop path (+ the separate controller restart). The operator's arm press is "
|
||||
"required for every drill; eye protection, fire watch, extinguisher, exhaust."},
|
||||
# -- host-side harnesses (CI) ------------------------------------------------------
|
||||
{"id": "laser-stream-test", "title": "Laser pulse-stream emission harness", "script": "laser_stream_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "Null-sink controller stream capture against the feeder contract. Runs in the grblHAL repo's CI."},
|
||||
"desc": "Null-sink controller stream capture against the feeder contract. A CI harness (the grblHAL repo): "
|
||||
"it needs the host-built null-sink controller, not the machine, so it is not a bench-page tool."},
|
||||
{"id": "laser-lifecycle-test", "title": "Armed-window lifecycle harness", "script": "laser_lifecycle_test.py",
|
||||
"safety": "dry", "where": "host", "ported": False, "args": [],
|
||||
"desc": "Arm/disarm lifecycle on the null-sink controller. Runs in the grblHAL repo's CI."},
|
||||
"desc": "Arm/disarm lifecycle on the null-sink controller. 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."},
|
||||
"desc": "Decodes factory pulse streams into velocity/accel profiles. Runs anywhere; needs a .puls file "
|
||||
"(the reference captures live off the machine), so it is not a bench-page tool."},
|
||||
]
|
||||
|
||||
# Files in scripts/bench that are not tools of their own: the helper module
|
||||
# the host/board tools share, and the C feeder + build scripts (not python).
|
||||
NOT_TOOLS = ("gfbench.py",)
|
||||
|
||||
|
||||
class Bench:
|
||||
def __init__(self, tools=None, tool_dir=None, index_path=None):
|
||||
|
||||
@@ -503,7 +503,17 @@ class Runner:
|
||||
captured = self._baseline_pre(run)
|
||||
env = dict(os.environ)
|
||||
env.setdefault("PYTHONUNBUFFERED", "1")
|
||||
takeover = Takeover(run.log, "bench:" + tool["id"]) if tool.get("safety") == "takeover" else None
|
||||
# The tools that also run from a LAN host (gfbench.py) run on
|
||||
# the board here: the machine is local, the panel token is at
|
||||
# hand, and their data files go under <data>/bench/.
|
||||
env.setdefault("GF_HOST", "127.0.0.1")
|
||||
env.setdefault("FORGETEST_BENCH_DATA", os.path.join(data_dir(), "bench"))
|
||||
if not env.get("GF_TOKEN"):
|
||||
tok = hw.Forgectrl().token
|
||||
if tok:
|
||||
env["GF_TOKEN"] = tok
|
||||
takeover = (Takeover(run.log, "bench:" + tool["id"])
|
||||
if tool.get("safety") in ("takeover", "scope") else None)
|
||||
if takeover is not None:
|
||||
takeover.__enter__()
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"""The bench registry against scripts/bench: every python tool is
|
||||
registered (or named as not-a-tool), every registered script exists,
|
||||
every argument spec builds a command line, every script compiles, and
|
||||
the shared helper resolves host and local mode as documented."""
|
||||
import glob
|
||||
import os
|
||||
import py_compile
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
import helpers # noqa: F401 (sys.path)
|
||||
from forgetest import bench as bench_mod
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
BENCH = os.path.join(REPO, "scripts", "bench")
|
||||
|
||||
|
||||
class RegistryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.bench = bench_mod.Bench(tool_dir=BENCH, index_path=os.devnull)
|
||||
self.scripts = sorted(os.path.basename(p) for p in glob.glob(os.path.join(BENCH, "*.py")))
|
||||
|
||||
def test_every_python_tool_is_registered(self):
|
||||
registered = {t["script"] for t in bench_mod.TOOLS}
|
||||
missing = [s for s in self.scripts if s not in registered and s not in bench_mod.NOT_TOOLS]
|
||||
self.assertEqual(missing, [], "scripts/bench python files missing from the registry")
|
||||
|
||||
def test_every_registered_script_exists(self):
|
||||
for t in bench_mod.TOOLS:
|
||||
self.assertTrue(os.path.exists(os.path.join(BENCH, t["script"])), t["script"])
|
||||
|
||||
def test_ids_unique_and_fields_valid(self):
|
||||
ids = [t["id"] for t in bench_mod.TOOLS]
|
||||
self.assertEqual(len(ids), len(set(ids)))
|
||||
for t in bench_mod.TOOLS:
|
||||
self.assertIn(t["safety"], ("dry", "takeover", "scope", "live"), t["id"])
|
||||
self.assertIn(t["where"], ("board", "host"), t["id"])
|
||||
self.assertIsInstance(t["ported"], bool, t["id"])
|
||||
self.assertTrue(t["desc"], t["id"])
|
||||
for a in t.get("args", []):
|
||||
self.assertIn(a["type"], ("str", "int", "float", "choice"), (t["id"], a["name"]))
|
||||
if a["type"] == "choice":
|
||||
self.assertIn(a["default"], a["choices"], (t["id"], a["name"]))
|
||||
|
||||
def test_ported_tools_build_a_command_with_defaults(self):
|
||||
for t in bench_mod.TOOLS:
|
||||
if not t["ported"]:
|
||||
continue
|
||||
ok, argv, err = self.bench.command(t, {})
|
||||
self.assertTrue(ok, "%s: %s" % (t["id"], err))
|
||||
self.assertEqual(argv[1], os.path.join(BENCH, t["script"]))
|
||||
|
||||
def test_unported_tools_are_host_only(self):
|
||||
# what stays unported is what cannot run on the machine at all
|
||||
for t in bench_mod.TOOLS:
|
||||
if not t["ported"]:
|
||||
self.assertEqual(t["where"], "host", t["id"])
|
||||
self.assertIn("not a bench-page tool", t["desc"], t["id"])
|
||||
|
||||
def test_scripts_compile(self):
|
||||
for s in self.scripts:
|
||||
py_compile.compile(os.path.join(BENCH, s), doraise=True)
|
||||
|
||||
|
||||
class GfbenchTests(unittest.TestCase):
|
||||
"""gfbench.py in a subprocess (module-level host resolution)."""
|
||||
|
||||
def run_snippet(self, code, env_extra):
|
||||
env = dict(os.environ)
|
||||
env.pop("GF_HOST", None)
|
||||
env.pop("FORGETEST_BENCH_DATA", None)
|
||||
env.update(env_extra)
|
||||
r = subprocess.run([sys.executable, "-c", code], cwd=BENCH, env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=30)
|
||||
return r.returncode, r.stdout.strip()
|
||||
|
||||
def test_host_mode(self):
|
||||
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL, gfbench.SSH[0])",
|
||||
{"GF_HOST": "192.0.2.7", "GF_SSH": "fakessh -x"})
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(out, "192.0.2.7 False fakessh")
|
||||
|
||||
def test_local_when_named_local(self):
|
||||
rc, out = self.run_snippet("import gfbench; print(gfbench.HOST, gfbench.LOCAL); "
|
||||
"print(gfbench.board('echo hi').strip())",
|
||||
{"GF_HOST": "127.0.0.1"})
|
||||
self.assertEqual(rc, 0, out)
|
||||
self.assertEqual(out.splitlines(), ["127.0.0.1 True", "hi"])
|
||||
|
||||
def test_refuses_without_a_machine(self):
|
||||
if os.path.isdir("/sys/glowforge"):
|
||||
self.skipTest("running on the machine")
|
||||
rc, out = self.run_snippet("import gfbench", {})
|
||||
self.assertNotEqual(rc, 0)
|
||||
self.assertIn("GF_HOST", out)
|
||||
|
||||
def test_degc_and_data_dir(self):
|
||||
import tempfile
|
||||
d = tempfile.mkdtemp(prefix="forgetest-bench-")
|
||||
rc, out = self.run_snippet(
|
||||
"import gfbench, os; print(round(gfbench.degc(700), 2), gfbench.degc('x') != gfbench.degc('x'), "
|
||||
"os.path.dirname(gfbench.data_path('f.json')) == os.environ['FORGETEST_BENCH_DATA'])",
|
||||
{"GF_HOST": "127.0.0.1", "FORGETEST_BENCH_DATA": d})
|
||||
self.assertEqual(rc, 0, out)
|
||||
val, nan, indir = out.split()
|
||||
# a plausible coolant reading (700 counts is room temperature territory)
|
||||
self.assertTrue(15.0 < float(val) < 35.0, val)
|
||||
self.assertEqual(nan, "True")
|
||||
self.assertEqual(indir, "True")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -62,7 +62,9 @@ class ServerTests(unittest.TestCase):
|
||||
cls.tooldir = os.path.join(cls.tmp, "bench")
|
||||
os.makedirs(cls.tooldir)
|
||||
with open(os.path.join(cls.tooldir, "echo_tool.py"), "w") as f:
|
||||
f.write("import sys, time\nprint('args', sys.argv[1:])\nsys.stdout.flush()\n"
|
||||
f.write("import os, sys, time\nprint('args', sys.argv[1:])\n"
|
||||
"print('env', os.environ.get('GF_HOST'), os.environ.get('FORGETEST_BENCH_DATA'))\n"
|
||||
"sys.stdout.flush()\n"
|
||||
"if 'slow' in sys.argv: time.sleep(30)\nsys.exit(0 if 'fail' not in sys.argv else 3)\n")
|
||||
tools = [{"id": "echo", "title": "Echo", "script": "echo_tool.py", "safety": "dry", "where": "board",
|
||||
"ported": True, "desc": "echo",
|
||||
@@ -73,6 +75,8 @@ class ServerTests(unittest.TestCase):
|
||||
{"id": "hot", "title": "H", "script": "echo_tool.py", "safety": "live", "where": "board",
|
||||
"ported": True, "desc": "", "args": []},
|
||||
{"id": "tk", "title": "T", "script": "echo_tool.py", "safety": "takeover", "where": "board",
|
||||
"ported": True, "desc": "", "args": []},
|
||||
{"id": "sc", "title": "S", "script": "echo_tool.py", "safety": "scope", "where": "board",
|
||||
"ported": True, "desc": "", "args": []}]
|
||||
cls.bench = bench_mod.Bench(tools, tool_dir=cls.tooldir,
|
||||
index_path=os.path.join(cls.tmp, "bench.jsonl"))
|
||||
@@ -261,7 +265,7 @@ class ServerTests(unittest.TestCase):
|
||||
st, d = self.call("GET", "/bench")
|
||||
self.assertEqual(st, 200)
|
||||
ids = [t["id"] for t in d["tools"]]
|
||||
self.assertEqual(ids, ["echo", "unported", "hot", "tk"])
|
||||
self.assertEqual(ids, ["echo", "unported", "hot", "tk", "sc"])
|
||||
st, d = self.call("POST", "/bench/start", {"tool": "unported"})
|
||||
self.assertEqual(st, 409)
|
||||
st, d = self.call("POST", "/bench/start", {"tool": "hot"})
|
||||
@@ -274,6 +278,9 @@ class ServerTests(unittest.TestCase):
|
||||
self.assertEqual(state["last_run"]["kind"], "bench")
|
||||
self.assertEqual(state["last_run"]["finished"]["result"], "OK")
|
||||
self.assertTrue(any("['yo', '5']" in l for l in state["last_run"]["log"]))
|
||||
# the bench environment: the machine is local, data under <data>/bench
|
||||
self.assertTrue(any(" env 127.0.0.1 " in l and l.endswith("bench")
|
||||
for l in state["last_run"]["log"]), state["last_run"]["log"])
|
||||
st, d = self.call("GET", "/bench")
|
||||
self.assertEqual(d["tools"][0]["last"]["result"]["result"], "OK")
|
||||
# a failing tool and an aborted one
|
||||
@@ -295,6 +302,13 @@ class ServerTests(unittest.TestCase):
|
||||
self.assertIn("takeover: forgectrl start", log)
|
||||
self.assertIn("baseline: forgectrl unreachable for 10 s", log)
|
||||
self.assertFalse(os.path.exists(os.environ["FORGETEST_MARKER"]))
|
||||
# a scope tool is a takeover too
|
||||
st, d = self.call("POST", "/bench/start", {"tool": "sc"})
|
||||
self.assertEqual(st, 200, d)
|
||||
state = self.wait_idle(timeout=40)
|
||||
log = "\n".join(state["last_run"]["log"])
|
||||
self.assertIn("takeover: pulse device free", log)
|
||||
self.assertIn("takeover: forgectrl start", log)
|
||||
# bench runs never touched the acceptance log
|
||||
recs = self.log.read()
|
||||
self.assertFalse(any(r.get("t") == "result" and r.get("test") == "echo" for r in recs))
|
||||
|
||||
+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