mirror of
https://github.com/openglow-org/forgefirm.git
synced 2026-09-27 08:41:13 -07:00
Bench: fan_floor_measure.py, the numbers the airflow gates ship with
Two modes. spinup opens a run session with M8 from the engine's idle posture, samples the four tachometers and the purge-air current once a second, ends the session with M9 and waits for the engine to leave run; per fan it reports the steady speed at run duty, the time to 90 percent of it and the spread over the steady window, the purge current off and on, and candidate floors at 55 percent of steady. cut sends nothing and samples while the operator runs a real cut, for the spread the debounce has to ride over under the only load that matters. Registered on the bench page as fan-floor. Tooling only; no catalog consequence.
This commit is contained in:
@@ -36,6 +36,7 @@ page's takeover does that; from a host, stop them first.
|
||||
| `pacing_test.py` | Protocol-loop pacing check (runs on the board, dry motion): idle and parked-in-Hold states are coarse-paced, active motion is tight-paced, and a feed-hold/resume mid-move preserves position with no feeder starve. |
|
||||
| `gfbench.py` | Not a tool: the helper the board/host tools share - `HOST`/`LOCAL` from `GF_HOST`, `board(cmd)` (local `sh -c` or ssh), the factory coolant conversion `degc()`, `data_path()` (`FORGETEST_BENCH_DATA` or next to the tool), forgectrl's HTTP API with the panel token, `setting(key)` (from forgectrl, or from `/data/forgefirm.conf` on the board while forgectrl is stopped). |
|
||||
| `fan_test.py` | Fan/coolant bench (board or host; controller running): snapshots fan PWMs/tachs/temps, drives M8 → cut fans, M9 → cooldown → idle, verifying via tach readbacks. |
|
||||
| `fan_floor_measure.py` | The numbers the airflow gates ship with (board or host): `spinup` opens a run session with M8 from idle and samples the four tachs and the purge current at 1 Hz, reporting per fan the steady speed, the time to 90 percent and the spread over the steady window, plus the purge current off and on, and candidate floors at 55 percent; `cut` samples only, during a real cut, for the spread under load. Results as JSON in the bench data directory. |
|
||||
| `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]`. |
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fan floor measurement: the numbers the airflow gates ship with.
|
||||
|
||||
Two modes, both sampling the four tachometers (/status fans, rpm) and the
|
||||
purge-air current (sysfs head/purge_air_current, raw) once a second:
|
||||
|
||||
spinup From the engine's idle posture, M8 opens a run session (the cut
|
||||
fan profile), the tool samples for --seconds, then M9 ends it and
|
||||
the tool waits for the engine to leave phase "run". Per fan it
|
||||
reports the steady speed (the mean of the last --steady seconds),
|
||||
the time to 90 % of it, and the spread (min, max, standard
|
||||
deviation) over the steady window; for the purge fan the current
|
||||
off and on. Needs the controller in GRBL mode, idle, fans quiet.
|
||||
|
||||
cut No commands: samples for --seconds while the operator runs a
|
||||
real cut, and reports the spread the same way. This is the
|
||||
number the debounce has to ride over, measured under the only
|
||||
load that matters.
|
||||
|
||||
Results print as a table and land as JSON in the bench data directory
|
||||
(FORGETEST_BENCH_DATA, else beside this file): fan_floor_<mode>_<stamp>.json.
|
||||
Runs on the board (the bench page) or from a LAN host (gfbench: GF_HOST).
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
|
||||
from gfbench import HOST, LOCAL, board, data_path, forgectrl_request
|
||||
|
||||
FANS = ("exhaust", "intake_1", "intake_2", "air_assist")
|
||||
PURGE_ATTR = "/sys/glowforge/head/purge_air_current"
|
||||
|
||||
|
||||
def usage():
|
||||
raise SystemExit("usage: fan_floor_measure.py spinup|cut [--seconds N] [--steady N]")
|
||||
|
||||
|
||||
def purge_current():
|
||||
try:
|
||||
if LOCAL:
|
||||
with open(PURGE_ATTR) as f:
|
||||
return int(f.read().strip())
|
||||
return int(board("cat " + PURGE_ATTR).strip())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def fans_now():
|
||||
st, body = forgectrl_request("GET", "/status")
|
||||
if st != 200 or not isinstance(body, dict):
|
||||
return {}
|
||||
return dict(body.get("fans") or {})
|
||||
|
||||
|
||||
def phase():
|
||||
st, body = forgectrl_request("GET", "/cool/status")
|
||||
return body.get("phase") if st == 200 and isinstance(body, dict) else None
|
||||
|
||||
|
||||
def sample(seconds, label):
|
||||
rows = []
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < seconds:
|
||||
t = round(time.time() - t0, 1)
|
||||
f = fans_now()
|
||||
row = {"t": t, "purge": purge_current()}
|
||||
row.update({k: f.get(k, 0) for k in FANS})
|
||||
rows.append(row)
|
||||
if len(rows) % 10 == 1:
|
||||
print(" %s +%5.1f s exhaust %5d intake %5d/%5d air %5d purge %s"
|
||||
% (label, t, row["exhaust"], row["intake_1"], row["intake_2"],
|
||||
row["air_assist"], row["purge"]))
|
||||
time.sleep(max(0.0, 1.0 - ((time.time() - t0) % 1.0)))
|
||||
return rows
|
||||
|
||||
|
||||
def stats(vals):
|
||||
vals = [v for v in vals if v is not None]
|
||||
if not vals:
|
||||
return {"n": 0}
|
||||
mean = sum(vals) / len(vals)
|
||||
sd = math.sqrt(sum((v - mean) ** 2 for v in vals) / len(vals)) if len(vals) > 1 else 0.0
|
||||
return {"n": len(vals), "mean": round(mean, 1), "min": min(vals), "max": max(vals),
|
||||
"sd": round(sd, 1)}
|
||||
|
||||
|
||||
def spinup_report(rows, steady_s):
|
||||
steady = [r for r in rows if r["t"] >= rows[-1]["t"] - steady_s]
|
||||
out = {}
|
||||
for k in FANS:
|
||||
st = stats([r[k] for r in steady])
|
||||
target = 0.9 * st.get("mean", 0)
|
||||
t90 = next((r["t"] for r in rows if r[k] >= target), None) if target > 0 else None
|
||||
st["t90_s"] = t90
|
||||
out[k] = st
|
||||
out["purge_on"] = stats([r["purge"] for r in steady])
|
||||
return out
|
||||
|
||||
|
||||
class Grbl:
|
||||
def __init__(self):
|
||||
self.s = socket.create_connection((HOST, 23), timeout=5)
|
||||
self.s.settimeout(0.15)
|
||||
time.sleep(0.5)
|
||||
self.drain()
|
||||
|
||||
def drain(self):
|
||||
out = b""
|
||||
try:
|
||||
while True:
|
||||
d = self.s.recv(4096)
|
||||
if not d:
|
||||
break
|
||||
out += d
|
||||
except socket.timeout:
|
||||
pass
|
||||
return out.decode("ascii", "replace")
|
||||
|
||||
def cmd(self, line, wait=0.4):
|
||||
self.s.sendall(line.encode() + b"\n")
|
||||
time.sleep(wait)
|
||||
return self.drain().strip()
|
||||
|
||||
def close(self):
|
||||
self.s.close()
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 2 or argv[1] not in ("spinup", "cut"):
|
||||
usage()
|
||||
mode = argv[1]
|
||||
seconds = 120
|
||||
steady_s = 60
|
||||
args = argv[2:]
|
||||
while args:
|
||||
a = args.pop(0)
|
||||
if a == "--seconds":
|
||||
seconds = int(args.pop(0))
|
||||
elif a == "--steady":
|
||||
steady_s = int(args.pop(0))
|
||||
else:
|
||||
usage()
|
||||
stamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
result = {"mode": mode, "seconds": seconds, "steady_s": steady_s, "started": stamp}
|
||||
|
||||
if mode == "spinup":
|
||||
ph = phase()
|
||||
if ph != "idle":
|
||||
raise SystemExit("the engine is in phase %r; run this from idle" % ph)
|
||||
idle = fans_now()
|
||||
result["idle_fans"] = idle
|
||||
result["purge_off"] = purge_current()
|
||||
print("idle: fans %s purge %s" % (idle, result["purge_off"]))
|
||||
g = Grbl()
|
||||
try:
|
||||
print("M8:", g.cmd("M8"))
|
||||
rows = sample(seconds, "run")
|
||||
finally:
|
||||
print("M9:", g.cmd("M9"))
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < 20 and phase() == "run":
|
||||
time.sleep(1)
|
||||
g.close()
|
||||
result["rows"] = rows
|
||||
result["report"] = spinup_report(rows, steady_s)
|
||||
rep = result["report"]
|
||||
print()
|
||||
print("%-11s %8s %8s %8s %6s %7s" % ("fan", "steady", "min", "max", "sd", "t90"))
|
||||
for k in FANS:
|
||||
r = rep[k]
|
||||
print("%-11s %8.0f %8d %8d %6.1f %7s" % (k, r.get("mean", 0), r.get("min", 0),
|
||||
r.get("max", 0), r.get("sd", 0),
|
||||
"%.0f s" % r["t90_s"] if r.get("t90_s") is not None else "-"))
|
||||
po = rep["purge_on"]
|
||||
print("purge current: off %s, on %.0f (min %s max %s)" % (result["purge_off"], po.get("mean", 0),
|
||||
po.get("min"), po.get("max")))
|
||||
print()
|
||||
print("candidate floors at 55 %% of steady: " + " ".join(
|
||||
"%s %.0f rpm" % (k, 0.55 * rep[k].get("mean", 0)) for k in FANS))
|
||||
else:
|
||||
print("sampling %d s while the cut runs (no commands sent)" % seconds)
|
||||
rows = sample(seconds, "cut")
|
||||
result["rows"] = rows
|
||||
result["report"] = {k: stats([r[k] for r in rows]) for k in FANS}
|
||||
result["report"]["purge"] = stats([r["purge"] for r in rows])
|
||||
rep = result["report"]
|
||||
print()
|
||||
print("%-11s %8s %8s %8s %6s" % ("fan", "mean", "min", "max", "sd"))
|
||||
for k in FANS + ("purge",):
|
||||
r = rep[k]
|
||||
print("%-11s %8.0f %8s %8s %6.1f" % (k, r.get("mean", 0), r.get("min"), r.get("max"), r.get("sd", 0)))
|
||||
|
||||
path = data_path("fan_floor_%s_%s.json" % (mode, stamp))
|
||||
with open(path, "w") as f:
|
||||
json.dump(result, f, indent=1)
|
||||
print("wrote", path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv)
|
||||
Reference in New Issue
Block a user