Fan floors from the measurement; a hunt is measured, not judged

forgectrl pin 47e4256: the airflow floors set from the bench measurement
(exhaust 6400, intake 2290, air assist 6000 rpm, purge current 300, grace
15 s) and the operating-point rule: a fan is judged while the laser is
armed, when a job's profile may raise it but never lower it below the run
duty, or whenever it is commanded at the run duty; the service's hunts,
sent with the extraction fans off, are measured and published unjudged.

Catalog: cloud.mode-switch now waits out the connect-time hunt sampling
/cool/status and requires no AIRFLOW, the exhaust row unjudged and the
exhaust actually off; its covers gain forgectrl src/cool.* and
src/airflow.*. cooling.fan-gate-trips uses an 8 s test grace (the
intakes take 7 s to 90 percent and tripped under the old 2 s at the new
floor) and its off leg waits for the row state as well as gates_off.
fan_floor_measure.py names a reply that is not JSON and calls the purge
readings idle and run (the pump is always on).

Docs: COOLING 3a and the settings table, BRINGUP item 19 and a facts-bank
entry with the measured fan speeds, the bench README, and a CAMPAIGN-LOG
entry for the measurement, the two status-document finds, the hunt find
and the hot-deployed bench runs of both tests.
This commit is contained in:
ScottW514
2026-08-22 09:56:53 -04:00
parent 71a79b6675
commit adcd1adb9a
9 changed files with 165 additions and 32 deletions
+1 -1
View File
@@ -36,7 +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. |
| `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 at idle and at run duty (the pump is always on; a dead one reads about 1), 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]`. |
+13 -8
View File
@@ -10,7 +10,9 @@ purge-air current (sysfs head/purge_air_current, raw) once a second:
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.
at idle and at run duty (the engine holds purge air on at all
times, so both are the "on" reading; a dead pump reads about 1).
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
@@ -56,7 +58,10 @@ def fans_now():
def phase():
st, body = forgectrl_request("GET", "/cool/status")
return body.get("phase") if st == 200 and isinstance(body, dict) else None
if st != 200 or not isinstance(body, dict):
raise SystemExit("GET /cool/status: HTTP %s with a body that is not a JSON object: %r"
% (st, body if isinstance(body, str) else type(body).__name__))
return body.get("phase")
def sample(seconds, label):
@@ -95,7 +100,7 @@ def spinup_report(rows, steady_s):
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])
out["purge_run"] = stats([r["purge"] for r in steady])
return out
@@ -151,8 +156,8 @@ def main(argv):
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"]))
result["purge_idle"] = purge_current()
print("idle: fans %s purge %s" % (idle, result["purge_idle"]))
g = Grbl()
try:
print("M8:", g.cmd("M8"))
@@ -173,9 +178,9 @@ def main(argv):
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")))
po = rep["purge_run"]
print("purge current: idle %s, run %.0f (min %s max %s); the pump is always on, a dead one reads ~1"
% (result["purge_idle"], 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))