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
+24 -7
View File
@@ -623,6 +623,18 @@ until `releases/v<version>/acceptance.json` is committed.
## Hardware facts bank (measured) ## Hardware facts bank (measured)
- **Fan speeds at the cut profile** (exhaust duty 65535, intake 43278, air
assist 1023; sampled at 1 Hz over 120 s from idle, the exhaust duct's
inline booster fan off): exhaust **11640 rpm** steady (spread 11444 to
11947, 90 percent of steady in 5 s), intakes **4157 / 4158 rpm** (spread
under 100, 7 s), air assist **11050 rpm** (spread 30, 1 s); at idle the
exhaust and intakes read 0 / ~745 rpm and the air assist ~1900 rpm
(idle duty 204). The purge-air fan is always on and reads **~625 counts**
of `head/purge_air_current` (~1 when off). The airflow floors are 55
percent of these (bands 50 to 60 percent); an inline booster fan changes
the exhaust's back pressure and can move its reading by a few percent
either way, well inside the margin.
- **DRV8825 stepper drivers wedge on 40 V rail glitches** (factory board; the - **DRV8825 stepper drivers wedge on 40 V rail glitches** (factory board; the
TMC2130s belong to the upgraded OpenGlow board only). A glitch can leave the TMC2130s belong to the upgraded OpenGlow board only). A glitch can leave the
drivers unserviceable: SDMA playback and the position counters run normally drivers unserviceable: SDMA playback and the position counters run normally
@@ -1340,13 +1352,18 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`.
while the run profile is applied (exhaust, intakes and air assist by while the run profile is applied (exhaust, intakes and air assist by
tachometer, purge by current), a spin-up grace, three ticks under the tachometer, purge by current), a spin-up grace, three ticks under the
floor, a fault for the rest of the session (`AIRFLOW`, no resume), a floor, a fault for the rest of the session (`AIRFLOW`, no resume), a
header window only ever raising a floor; `cooling.fan-gate-trips` in header window only ever raising a floor. A fan is judged at the
the catalog. The shipped floors are provisional, 55 percent of one operating point its floor was measured at: always while armed (when a
run-duty snapshot (exhaust 3700, intake 1800, air assist 6000 rpm, job's profile may raise a fan but never lower it below the run duty),
purge current 300, grace 15 s) until `fan_floor_measure.py` has been and unarmed whenever it is commanded at the run duty; a cloud hunt,
run on the bench and the numbers in `forgectrl/src/gates.c` set from sent with the extraction fans off, is measured and not judged.
it. The coolant critical tier and the watch-only board temperatures `cooling.fan-gate-trips` and the hunt leg of `cloud.mode-switch` in
follow. the catalog. The shipped floors are 55 percent of the bench machine's
measured steady speeds (facts bank: exhaust 6400, intake 2290, air
assist 6000 rpm, purge current 300, grace 15 s); the bench run of the
gate tests on an image carrying them, and the unplugged-tach drill,
are still owed. The coolant critical tier and the watch-only board
temperatures follow.
Nothing here can put energy where it was not commanded: the hardware chain Nothing here can put energy where it was not commanded: the hardware chain
is the emission boundary and no header field touches it, and forgectrl runs is the emission boundary and no header field touches it, and forgectrl runs
+54
View File
@@ -3254,6 +3254,60 @@ value), and the test quoted the session's first job-limits line, a hunt's,
where the print's is the one worth keeping (it now takes the first line where the print's is the one worth keeping (it now takes the first line
after the print's action request). after the print's action request).
## 2026-08-22: the fan floors measured, and a hunt that would have tripped them
`fan_floor_measure.py spinup` (bench page `fan-floor`, 120 s at the cut
profile from idle, GRBL mode, the exhaust duct's inline booster fan off, so
the exhaust worked against more back pressure than a normal cut):
| fan | steady rpm | min | max | sd | t90 |
|---|---|---|---|---|---|
| exhaust | 11638 | 11444 | 11947 | 103 | 5 s |
| intake 1 | 4157 | 4102 | 4193 | 12 | 7 s |
| intake 2 | 4158 | 4128 | 4173 | 6 | 7 s |
| air assist | 11048 | 11029 | 11061 | 8 | 1 s |
Purge current 627 at idle and 625 at run duty: the engine holds purge air on
continuously, so both are the "on" reading (the off reading, ~1, is from an
earlier observation). The floors shipped from this: exhaust 6400, intake
2290, air assist 6000 rpm (55 percent of steady, bands 50 to 60 percent),
purge current 300, grace 15 s (twice the slowest time to 90 percent). The
provisional floors had come from a snapshot at a lower exhaust speed; the
measured margin is larger.
The run also sent the first `/cool/status` with the limits and the fan rows
through a 512-byte reply buffer, and then a 160-byte limits fragment: twice
a cut-off document, found by the measurement tool refusing to parse it.
Both fixed in forgectrl with a host test that renders the widest legal
document and parses it (`tests/coolfmt_test.c`).
Reading the measurement through cloud mode found a defect in the gates as
first built: the service reports every action as a run, and its hunt and
motion headers command the exhaust and the intakes off and the air assist
at idle, so a hunt longer than the grace would have tripped `AIRFLOW` at
any floor. Decision (operator): a fan is judged only at the operating
point its floor was measured at: always while the laser is armed, when a
job's profile may raise a fan but never lower it below the run duty; and
unarmed when commanded at the run duty. A hunt is measured, published as
`unjudged`, and not judged. `cloud.mode-switch` now watches the connect-time
hunt's gate rows for it.
Both tests ran the same day on a hot-deployed forgectrl (the working tree
cross-built and installed over dev image `20260821230723`; informational,
not acceptance). `cloud.mode-switch` PASS: the hunt reported two run ticks
with the exhaust off and `unjudged`, verdict `OK` throughout.
`cooling.fan-gate-trips` FAIL first, for two reasons worth keeping: the
run-start tick resolved the effective limits before it reloaded the
settings, so for one tick `gates_off` named the exhaust while its row still
carried the old floor (fixed: the session start re-resolves the limits after
the reload, so rows, `gates_off` and the log line agree); and the test's
2 s grace, chosen against the provisional 1800 rpm intake floor, now let
both intakes trip at ~1850 rpm on their 7 s spin-up (the test grace is 8 s).
Rerun PASS in 59 s: only the exhaust tripped in the exhaust leg, only the
purge in the purge leg, the exhaust read `off` with floor 0 in the off leg,
and the restore showed every fan `ok` at the shipped floors (exhaust 11723,
intakes 4157 and 4162, air assist 11078 rpm, purge 628 counts).
## Superseded status notes ## Superseded status notes
### Shared machine services — remaining polish, as listed 2026-08-13 ### Shared machine services — remaining polish, as listed 2026-08-13
+19 -7
View File
@@ -145,9 +145,20 @@ floor.
- **The floors** are settings (§8): `cool_tach_exhaust_min_rpm`, - **The floors** are settings (§8): `cool_tach_exhaust_min_rpm`,
`cool_tach_intake_min_rpm` (either intake), `cool_tach_air_assist_min_rpm` `cool_tach_intake_min_rpm` (either intake), `cool_tach_air_assist_min_rpm`
and `cool_purge_min_current`, each a fraction of what the fan reaches at and `cool_purge_min_current`, each 55 percent of the steady speed the fan
run duty on the bench machine. A cloud job's header can raise a tach floor reaches at the cut profile on the bench machine (exhaust 11640, intakes
for that job, never lower it (§2). 4160, air assist 11050 rpm; the recommended bands are 50 to 60 percent).
A cloud job's header can raise a tach floor for that job, never lower it
(§2).
- **A fan is judged at the operating point its floor was measured at.**
While the laser is armed every fan is judged, and a job's own fan profile
(a cloud header's run duties) may raise a fan above the cut profile but
never lower it while armed. Unarmed, a fan is judged whenever it is
commanded at or above the cut profile (a bare `M8` from a GRBL job), and a
fan the job runs slower is measured, published as `unjudged`, and not
judged: the factory's hunts and homing moves run with the exhaust and the
intakes off and the air assist at idle, and nothing can fire during them.
The purge fan has no duty (it is always on) and is judged in every run.
- **A spin-up grace** (`cool_fan_grace_s`) runs from the moment the run - **A spin-up grace** (`cool_fan_grace_s`) runs from the moment the run
profile is written; nothing counts inside it, because the big exhaust fan profile is written; nothing counts inside it, because the big exhaust fan
takes seconds to reach speed. takes seconds to reach speed.
@@ -164,7 +175,8 @@ floor.
reading in a job that would have tripped the shipped default is logged. reading in a job that would have tripped the shipped default is logged.
`/cool/status` carries each fan's reading, floor and state (`grace`, `ok`, `/cool/status` carries each fan's reading, floor and state (`grace`, `ok`,
`under`, `TRIPPED`, `off`, or `idle` outside a run) as `fan_gates`. `under`, `TRIPPED`, `off`, `unjudged` for a fan the job runs below the cut
profile unarmed, or `idle` outside a run) as `fan_gates`.
--- ---
@@ -355,9 +367,9 @@ start of every run, so a change takes effect on your next job.
| `cool_temp_resume` | 31 °C | 5 to 59 °C | 20 to 36 °C | Resume gate: below it, continue. Always kept below the ceiling. | | `cool_temp_resume` | 31 °C | 5 to 59 °C | 20 to 36 °C | Resume gate: below it, continue. Always kept below the ceiling. |
| `cool_cooldown_s` | 15 s | 0 to 1800 s | | Smoke-clear phase at run duty after a job. | | `cool_cooldown_s` | 15 s | 0 to 1800 s | | Smoke-clear phase at run duty after a job. |
| `cool_cooldown_max_s` | 300 s | 0 to 1800 s | | Cap on the thermal cooldown phase. | | `cool_cooldown_max_s` | 300 s | 0 to 1800 s | | Cap on the thermal cooldown phase. |
| `cool_tach_exhaust_min_rpm` | 3700 rpm | 0 to 20000 | 2500 to 5000 | Exhaust fan floor at run duty (§3a). `0` turns the gate off. | | `cool_tach_exhaust_min_rpm` | 6400 rpm | 0 to 20000 | 5800 to 7000 | Exhaust fan floor at run duty (§3a). `0` turns the gate off. |
| `cool_tach_intake_min_rpm` | 1800 rpm | 0 to 20000 | 1200 to 2500 | Intake fan floor, either intake (§3a). `0` turns the gate off. | | `cool_tach_intake_min_rpm` | 2290 rpm | 0 to 20000 | 2100 to 2500 | Intake fan floor, either intake (§3a). `0` turns the gate off. |
| `cool_tach_air_assist_min_rpm` | 6000 rpm | 0 to 30000 | 4000 to 8000 | Air-assist fan floor (§3a). `0` turns the gate off. | | `cool_tach_air_assist_min_rpm` | 6000 rpm | 0 to 30000 | 5500 to 6600 | Air-assist fan floor (§3a). `0` turns the gate off. |
| `cool_purge_min_current` | 300 raw | 0 to 1023 | 150 to 500 | Purge-air fan current floor (the fan has no tachometer; about 1 off, about 630 on). `0` turns the gate off. | | `cool_purge_min_current` | 300 raw | 0 to 1023 | 150 to 500 | Purge-air fan current floor (the fan has no tachometer; about 1 off, about 630 on). `0` turns the gate off. |
| `cool_fan_grace_s` | 15 s | 0 to 120 s | 5 to 30 s | Spin-up window after the run profile is written, during which no floor counts. | | `cool_fan_grace_s` | 15 s | 0 to 120 s | 5 to 30 s | Spin-up window after the run profile is written, during which no floor counts. |
+46 -3
View File
@@ -96,15 +96,18 @@ def wait_mode(ctx, fc, want_mode, want_controller="running", timeout=90):
@test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl", subsystem="cloud", @test("cloud.mode-switch", title="Controller mode switch grbl -> cloud -> grbl", subsystem="cloud",
kind="auto", mode="grbl", est_min=4, kind="auto", mode="grbl", est_min=5,
covers=_CLOUD_COVERS, requires=["forgectrl.auth", "motion.pacing"], covers=_CLOUD_COVERS + [("forgectrl", "src/cool.*"), ("forgectrl", "src/airflow.*")],
requires=["forgectrl.auth", "motion.pacing"],
steps=["Bed clear: the cloud client homes the head to the corner on connect (the factory " steps=["Bed clear: the cloud client homes the head to the corner on connect (the factory "
"hunt) and the test jogs it back to where it started afterward. Cloud credentials " "hunt) and the test jogs it back to where it started afterward. Cloud credentials "
"configured; the machine on the network."], "configured; the machine on the network."],
description="POST /mode switches to the cloud controller: gfcloud comes up under " description="POST /mode switches to the cloud controller: gfcloud comes up under "
"supervision, authenticates and establishes its service session (its own log " "supervision, authenticates and establishes its service session (its own log "
"lines are the evidence; the connect-time firmware probe is recorded when " "lines are the evidence; the connect-time firmware probe is recorded when "
"configured); the camera service survives the switch; switching back brings " "configured); its connect-time hunt, which runs with the extraction fans off, "
"is measured by the airflow gates but not judged (no AIRFLOW, the exhaust row "
"reads unjudged); the camera service survives the switch; switching back brings "
"grblHAL up with the Grbl port open and Idle, and the head returns to its start.") "grblHAL up with the Grbl port open and Idle, and the head returns to its start.")
def mode_switch(ctx): def mode_switch(ctx):
fc = ctx.forgectrl fc = ctx.forgectrl
@@ -157,6 +160,26 @@ def mode_switch(ctx):
st, cam1 = fc.get("/cam/status") st, cam1 = fc.get("/cam/status")
ev["cam_during_cloud"] = cam1 ev["cam_during_cloud"] = cam1
ctx.check(st == 200 and isinstance(cam1, dict), "camera status lost during cloud mode (%s)", st) ctx.check(st == 200 and isinstance(cam1, dict), "camera status lost during cloud mode (%s)", st)
# The connect-time hunt reports a run to the cooling engine with the
# exhaust and the intakes commanded off (the factory's motion profile):
# the gates measure it and judge nothing, whatever the floors say.
hunt, samples = watch_hunt_gates(ctx, log_offset, HUNT_TIMEOUT_S)
ev["hunt"] = hunt
ev["hunt_gates"] = samples
ctx.check(hunt, "the connect-time hunt did not finish within %d s", HUNT_TIMEOUT_S)
run = [c for c in samples if c.get("phase") == "run"]
ctx.log("hunt: %d status samples, %d in phase run; verdicts %s", len(samples), len(run),
sorted({c.get("verdict") for c in samples}))
ctx.check(run, "no /cool/status sample saw the hunt as a run session (the client did not "
"report run, or the hunt was shorter than the 1 s poll)")
ctx.check(all(c.get("verdict") != "AIRFLOW" for c in samples),
"a hunt with its extraction fans off tripped an airflow gate")
exh = [((c.get("fan_gates") or {}).get("exhaust") or {}) for c in run]
ctx.check(exh and all(g.get("state") == "unjudged" for g in exh),
"the exhaust gate judged a hunt: states %s", sorted({g.get("state") for g in exh}))
ctx.check(exh and all(g.get("reading", 0) < g.get("floor", 0) for g in exh),
"the exhaust was not off during the hunt (readings %s), so the unjudged state "
"proves nothing", sorted({g.get("reading") for g in exh}))
st, body = fc.post("/mode", data={"controller": "grbl"}) st, body = fc.post("/mode", data={"controller": "grbl"})
ctx.log("POST /mode controller=grbl -> %s %s", st, body) ctx.log("POST /mode controller=grbl -> %s %s", st, body)
@@ -347,6 +370,26 @@ def wait_action_finished(ctx, offset, action, timeout, poll=0.5):
return None return None
def watch_hunt_gates(ctx, offset, timeout):
"""Wait for the action's terminal line like wait_action_finished,
sampling /cool/status once a second meanwhile. Returns (line or
None, samples)."""
fc = ctx.forgectrl
samples = []
t0 = time.time()
while time.time() - t0 < timeout:
ctx.checkpoint()
st, c = fc.get("/cool/status")
if st == 200 and isinstance(c, dict):
samples.append(c)
lines = log_lines_since(GFCLOUD_LOG, offset)
i = action_finish_index(lines, "hunt")
if i is not None:
return lines[i], samples
time.sleep(1)
return None, samples
def message(line): def message(line):
"""The message part of a log line (after 'ISO-time gfcloud[pid]').""" """The message part of a log line (after 'ISO-time gfcloud[pid]')."""
return line.split(" ", 2)[-1] if line else None return line.split(" ", 2)[-1] if line else None
+6 -4
View File
@@ -342,7 +342,7 @@ def gate_off(ctx):
FAN_KEYS = ("cool_tach_exhaust_min_rpm", "cool_purge_min_current", "cool_fan_grace_s") FAN_KEYS = ("cool_tach_exhaust_min_rpm", "cool_purge_min_current", "cool_fan_grace_s")
FAN_GRACE_S = "2" # the shortest grace the test can wait out with margin FAN_GRACE_S = "8" # past the intakes' 7 s to 90 percent, so only the leg's floor trips
FAN_TRIP_WAIT_S = 20 # grace + three ticks, with slack for the 1 Hz pipeline FAN_TRIP_WAIT_S = 20 # grace + three ticks, with slack for the 1 Hz pipeline
@@ -352,10 +352,11 @@ def _fan_gate(c, name):
@test("cooling.fan-gate-trips", title="A fan under its floor past the grace is a fault; a floor of zero is off", @test("cooling.fan-gate-trips", title="A fan under its floor past the grace is a fault; a floor of zero is off",
subsystem="cooling", kind="auto", mode="grbl", est_min=4, subsystem="cooling", kind="auto", mode="grbl", est_min=4,
covers=_COOL_COVERS, requires=["cooling.gate-off"], covers=_COOL_COVERS + [("forgectrl", "src/main.c")], requires=["cooling.gate-off"],
steps=["Machine idle, fans quiet. The test writes the exhaust floor, the purge current floor and " steps=["Machine idle, fans quiet. The test writes the exhaust floor, the purge current floor and "
"the spin-up grace and restores them; four short M8/M9 cycles spin the fans."], "the spin-up grace and restores them; four short M8/M9 cycles spin the fans."],
description="The airflow gates run while the cut fan profile is applied. An exhaust floor no fan " description="The airflow gates judge a fan commanded at the cut fan profile, which a bare M8 "
"applies, armed or not. An exhaust floor no fan "
"can meet must trip AIRFLOW after the grace plus three ticks (hold, fire blocked, no " "can meet must trip AIRFLOW after the grace plus three ticks (hold, fire blocked, no "
"resume while the session lasts); a purge current floor at the ADC rail must trip the " "resume while the session lasts); a purge current floor at the ADC rail must trip the "
"same way; a floor of zero must read off in gates_off and trip nothing; restored, the " "same way; a floor of zero must read off in gates_off and trip nothing; restored, the "
@@ -410,7 +411,8 @@ def fan_gate_trips(ctx):
# gates_off says so, and the other fans are judged on their own. # gates_off says so, and the other fans are judged on their own.
_set_gates(ctx, fc, {"cool_tach_exhaust_min_rpm": "0", _set_gates(ctx, fc, {"cool_tach_exhaust_min_rpm": "0",
"cool_purge_min_current": orig["cool_purge_min_current"]}) "cool_purge_min_current": orig["cool_purge_min_current"]})
c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and "exhaust" in (c.get("gates_off") or []), c = _run_session(ctx, grbl, fc, lambda c: c.get("verdict") == "OK" and "exhaust" in (c.get("gates_off") or [])
and _fan_gate(c, "exhaust").get("state") == "off",
"exhaust off leg") "exhaust off leg")
ev["exhaust_off"] = {"cool": c, "gate": _fan_gate(c, "exhaust")} ev["exhaust_off"] = {"cool": c, "gate": _fan_gate(c, "exhaust")}
ctx.check(c.get("verdict") == "OK", "the exhaust floor at zero did not clear the gate: %s", c) ctx.check(c.get("verdict") == "OK", "the exhaust floor at zero did not clear the gate: %s", c)
+1 -1
View File
@@ -498,7 +498,7 @@ class FanGateTests(unittest.TestCase):
self.assertEqual(ev["exhaust_off"]["cool"]["gates_off"], ["exhaust"]) self.assertEqual(ev["exhaust_off"]["cool"]["gates_off"], ["exhaust"])
self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values())) self.assertTrue(all(g["state"] == "ok" for g in ev["restored"].values()))
posts = self.settings_posts() posts = self.settings_posts()
self.assertEqual(posts[0], {"cool_tach_exhaust_min_rpm": "20000.0", "cool_fan_grace_s": "2"}) self.assertEqual(posts[0], {"cool_tach_exhaust_min_rpm": "20000.0", "cool_fan_grace_s": "8"})
self.assertEqual(posts[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "", self.assertEqual(posts[-1], {"cool_tach_exhaust_min_rpm": "", "cool_purge_min_current": "",
"cool_fan_grace_s": ""}) "cool_fan_grace_s": ""})
self.assertEqual(self.grbl.commands.count("M8"), 4) self.assertEqual(self.grbl.commands.count("M8"), 4)
@@ -2,5 +2,5 @@
# only SRCREV and PV here - the image manifest leaves *-pin.inc out of the # only SRCREV and PV here - the image manifest leaves *-pin.inc out of the
# layer content hash because the component entry already identifies the # layer content hash because the component entry already identifies the
# pinned source (forgefirm-image-manifest.bbclass). # pinned source (forgefirm-image-manifest.bbclass).
SRCREV = "50096be4dca74297ae52188960af2704e693f682" SRCREV = "47e42569e8def2ed6fe10f61e9ab9ff4f9c8807d"
PV = "0.1.0" PV = "0.1.0"
+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. | | `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). | | `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_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_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_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_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), reports the steady speed (the mean of the last --steady seconds),
the time to 90 % of it, and the spread (min, max, standard the time to 90 % of it, and the spread (min, max, standard
deviation) over the steady window; for the purge fan the current 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 cut No commands: samples for --seconds while the operator runs a
real cut, and reports the spread the same way. This is the real cut, and reports the spread the same way. This is the
@@ -56,7 +58,10 @@ def fans_now():
def phase(): def phase():
st, body = forgectrl_request("GET", "/cool/status") 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): 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 t90 = next((r["t"] for r in rows if r[k] >= target), None) if target > 0 else None
st["t90_s"] = t90 st["t90_s"] = t90
out[k] = st 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 return out
@@ -151,8 +156,8 @@ def main(argv):
raise SystemExit("the engine is in phase %r; run this from idle" % ph) raise SystemExit("the engine is in phase %r; run this from idle" % ph)
idle = fans_now() idle = fans_now()
result["idle_fans"] = idle result["idle_fans"] = idle
result["purge_off"] = purge_current() result["purge_idle"] = purge_current()
print("idle: fans %s purge %s" % (idle, result["purge_off"])) print("idle: fans %s purge %s" % (idle, result["purge_idle"]))
g = Grbl() g = Grbl()
try: try:
print("M8:", g.cmd("M8")) 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), 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), r.get("max", 0), r.get("sd", 0),
"%.0f s" % r["t90_s"] if r.get("t90_s") is not None else "-")) "%.0f s" % r["t90_s"] if r.get("t90_s") is not None else "-"))
po = rep["purge_on"] po = rep["purge_run"]
print("purge current: off %s, on %.0f (min %s max %s)" % (result["purge_off"], po.get("mean", 0), print("purge current: idle %s, run %.0f (min %s max %s); the pump is always on, a dead one reads ~1"
po.get("min"), po.get("max"))) % (result["purge_idle"], po.get("mean", 0), po.get("min"), po.get("max")))
print() print()
print("candidate floors at 55 %% of steady: " + " ".join( print("candidate floors at 55 %% of steady: " + " ".join(
"%s %.0f rpm" % (k, 0.55 * rep[k].get("mean", 0)) for k in FANS)) "%s %.0f rpm" % (k, 0.55 * rep[k].get("mean", 0)) for k in FANS))