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
+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",
kind="auto", mode="grbl", est_min=4,
covers=_CLOUD_COVERS, requires=["forgectrl.auth", "motion.pacing"],
kind="auto", mode="grbl", est_min=5,
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 "
"hunt) and the test jogs it back to where it started afterward. Cloud credentials "
"configured; the machine on the network."],
description="POST /mode switches to the cloud controller: gfcloud comes up under "
"supervision, authenticates and establishes its service session (its own log "
"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.")
def mode_switch(ctx):
fc = ctx.forgectrl
@@ -157,6 +160,26 @@ def mode_switch(ctx):
st, cam1 = fc.get("/cam/status")
ev["cam_during_cloud"] = cam1
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"})
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
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):
"""The message part of a log line (after 'ISO-time gfcloud[pid]')."""
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_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
@@ -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",
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 "
"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 "
"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 "
@@ -410,7 +411,8 @@ def fan_gate_trips(ctx):
# gates_off says so, and the other fans are judged on their own.
_set_gates(ctx, fc, {"cool_tach_exhaust_min_rpm": "0",
"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")
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)