From 9fae47cf14d18e89211c76b060e293a091e0f431 Mon Sep 17 00:00:00 2001 From: ScottW514 Date: Sat, 22 Aug 2026 12:56:40 -0400 Subject: [PATCH] The critical tier proven on a rising loop; the board temperatures watched forgectrl pin 76115fd: the chassis LM75 and the supply sensor ride /status as temps (degrees and a raw count), the engine ranges them over every run session into one run-end line, and a critical fault that clears with its session yields the reason to the standing hold. Bench: critical_tier_drill.py (a bench tool now, registered as critical-tier) sets the ceiling, the resume gate and the critical line a few tenths above the live upstream reading and lets the engine's own flow-check heater warm the loop through them inside one M8 session; temp_calibrate.py gains supply-watch, supply-point and supply-fit for the supply sensor against a thermometer on its heatsink, the fit shown beside UAPI.md's unverified guess. Catalog: cooling.gate-off checks the /status temps fields and the run-end board-temperature line (the unit fake mirrors both, one new failure case); cooling.critical-tier checks the reason after a faulted session. Docs: CAMPAIGN-LOG entries for cooling.critical-tier on dev image 20260822154257 and the warm-loop drill (OVERTEMP at 10 s, CRITICAL at 14 s, the fault ending with the session); BRINGUP item 19, the facts bank (board temperatures at idle), COOLING section 9, the bench README. --- docs/BRINGUP.md | 21 +- docs/CAMPAIGN-LOG.md | 34 ++++ docs/COOLING.md | 6 + forgetest/forgetest/bench.py | 16 +- forgetest/forgetest/suite/cooling.py | 22 ++- forgetest/tests/test_cooling_suite.py | 12 ++ .../forgectrl/forgectrl-pin.inc | 2 +- scripts/bench/README.md | 2 + scripts/bench/critical_tier_drill.py | 179 ++++++++++++++++++ scripts/bench/temp_calibrate.py | 87 +++++++++ 10 files changed, 372 insertions(+), 9 deletions(-) create mode 100644 scripts/bench/critical_tier_drill.py diff --git a/docs/BRINGUP.md b/docs/BRINGUP.md index c0d0a98..a06cc01 100644 --- a/docs/BRINGUP.md +++ b/docs/BRINGUP.md @@ -623,6 +623,11 @@ until `releases/v/acceptance.json` is committed. ## Hardware facts bank (measured) +- **Board temperatures at idle** (room ~22 C, machine on for hours): the + chassis LM75 reads **29.0 C**, `pic/pwr_temp` reads **589 raw** (the + unverified guess `raw * 0.08715 - 21` would make that 30.3 C; a thermometer + on the supply heatsink decides). Ranged per job by the engine from here + on. - **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 @@ -1375,9 +1380,19 @@ Open items only. Anything closed is in `CAMPAIGN-LOG.md`. cross-check) is the fail tier above the ceiling's pause: at or over it in a run session the verdict is `CRITICAL` (fire blocked, hold, no resume this job), the fault ends with the session, no header touches - it; `cooling.critical-tier` in the catalog (45), bench run owed on the - next image, plus the heater-driven warm-loop drill to the critical - line. The watch-only board temperatures follow. + it; `cooling.critical-tier` in the catalog (45), PASS on dev image + `20260822154257`, and the warm-loop drill (`critical_tier_drill.py`) + PASS the same day on a genuinely rising loop: `OVERTEMP` at the + ceiling, `CRITICAL` four seconds later at the line, the fault ending + with the session (CAMPAIGN-LOG). **The board temperatures are watched:** + the chassis LM75 (degrees) and the supply sensor (raw count, its + conversion still unverified) ride `/status` as `temps`, show on the + Status tab, and are ranged over every run session into one run-end + log line; no gate behind either until the record says where one + belongs, and the supply's conversion waits on thermometer points at + the heatsink during a long cut (`temp_calibrate.py supply-point`, + three points, then `supply-fit`). Bench run of the status fields and + the log line owed on the next image. 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 diff --git a/docs/CAMPAIGN-LOG.md b/docs/CAMPAIGN-LOG.md index ee9f5dd..966cd22 100644 --- a/docs/CAMPAIGN-LOG.md +++ b/docs/CAMPAIGN-LOG.md @@ -3368,6 +3368,40 @@ and flashed the same day (dev image `20260822145201`, forgectrl d51dbdb): `cooling.fan-gate-trips` PASS in 60 s, the engine reading `OK`, no hold, fire allowed in the smoke-clear phase right after the tripped session. +## 2026-08-22: the coolant critical tier, on an image and on a rising loop + +Dev image `20260822154257` (forgectrl a1875a8 pinned by forgefirm f51140e): +`cooling.critical-tier` PASS in 23 s. As recorded: the settings API refused +a critical line equal to the ceiling (`400 cool_temp_critical_c must be +above cool_temp_max`) and changed nothing; with the ceiling at 6 C, the +resume gate at 5 C and the critical line at 7 C under 24.3 C coolant the +session read `CRITICAL` (`fire_ok false`, `hold true`, no `resume_ok`, +reason `CRITICAL: coolant 24.3 C at or over the 7 C critical line - hold, +no resume this job`); after the session the ceiling alone held +(`OVERTEMP`); with the critical line at its top of 70 the gate was off +(`gates_off` naming `coolant_critical`, the run-start log line) and the +ceiling alone paused; restored, `OK` with nothing off. + +The physical drill, `critical_tier_drill.py`, the same day. The loop heater +reaches the high twenties at most, so the lines were set a few tenths +above the live upstream reading (24.57 C: ceiling 25.0, resume 24.8, +critical 25.3) and the engine's own flow-check heater (100 percent, 300 s +windows, rechecks every 30 s, the suspect threshold at its top) warmed the +loop through them inside one `M8` session. Transitions, as sampled once a +second: `OK` at 24.1 C; `OVERTEMP` at 10 s with the upstream at 25.05 C +(`coolant 25.0 C over 25 C limit - hold until 25 C`); `CRITICAL` at 14 s +at 26.24 C (`fire_ok false`, `hold true`, relayed on the Grbl port as a +`[MSG:Warning: CRITICAL: ...]`). `M9` ended the fault and the ceiling's +`OVERTEMP` stood in the smoke-clear phase (upstream 27.7 C, downstream +49.7 C from the heater, which the session end switched off). Settings +restored and re-read in a short session: `OK`, limits back to 33 / 31 / 38. + +One find, cosmetic: after the session the reason text still read the +critical line's message under the ceiling's verdict, because the ceiling +names itself only on its rising edge and the critical fault had overwritten +it. The engine now re-publishes the standing hold's reason when a critical +fault clears (no new log line), and `cooling.critical-tier` checks it. + ## Superseded status notes ### Shared machine services — remaining polish, as listed 2026-08-13 diff --git a/docs/COOLING.md b/docs/COOLING.md index cca04ba..93781a4 100644 --- a/docs/COOLING.md +++ b/docs/COOLING.md @@ -435,6 +435,12 @@ Stated plainly so nobody counts on them: cannot be detected (the output has no readback), so this will become a user setting plus a simple hysteresis around the factory's setpoints. - **A fire watch that acts** (§7). +- **Chassis and supply ceilings.** Both temperatures are measured and not + gated: the chassis LM75 in degrees and the supply sensor as a raw count, + in `/status` as `temps`, and ranged over every job in one log line + (`temps this job: ...`). A ceiling for each comes from that record once + there is enough of it, and the supply's conversion from a thermometer on + its heatsink (`temp_calibrate.py supply-point`). - **Fan floors measured on more than one machine.** The shipped floors are a fraction of one bench machine's run-duty speeds; a machine whose fans read differently sets its own (§8), and a floor of zero turns that gate diff --git a/forgetest/forgetest/bench.py b/forgetest/forgetest/bench.py index e8106a2..827cfc6 100644 --- a/forgetest/forgetest/bench.py +++ b/forgetest/forgetest/bench.py @@ -126,6 +126,13 @@ TOOLS = [ "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": "critical-tier", "title": "Coolant critical-tier warm-loop drill", "script": "critical_tier_drill.py", + "safety": "dry", "where": "board", "ported": True, + "args": [_arg("max_seconds", "int", 1200, "give up after this long without CRITICAL", flag="--max-seconds")], + "desc": "Sets the ceiling, the resume gate and the critical line a few tenths above the live upstream " + "reading and lets the engine's own flow-check heater warm the loop through them inside one M8 " + "session: OVERTEMP at the ceiling, then CRITICAL (fire blocked, hold, no resume), the fault ending " + "with the session; settings restored and re-read. Results to the bench data directory."}, {"id": "flow-recheck", "title": "Coolant re-check characterization", "script": "flow_recheck_char.py", "safety": "takeover", "where": "board", "ported": True, "args": [_arg("heater_pct", "int", 50, "heater duty percent"), _arg("window_s", "int", 30, "re-check window")], @@ -144,10 +151,13 @@ TOOLS = [ "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": "board", "ported": True, - "args": [_arg("mode", "choice", "watch", "watch / point / fit", ["watch", "point", "fit"]), + "args": [_arg("mode", "choice", "watch", "coolant: watch / point / fit; supply (pic/pwr_temp): " + "supply-watch / supply-point / supply-fit", + ["watch", "point", "fit", "supply-watch", "supply-point", "supply-fit"]), _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."}, + "desc": "Pairs a measured temperature with averaged raw readings; fits a per-machine line. The coolant " + "sensors, or the power supply's (thermometer on its heatsink, against the unverified guess in " + "UAPI.md). Points accumulate in the bench data directory."}, {"id": "fan-test", "title": "Fan/coolant bench", "script": "fan_test.py", "safety": "dry", "where": "board", "ported": True, "args": [], "desc": "Snapshots fan PWMs/tachs/temps, drives M8 -> cut fans, M9 -> cooldown -> idle; the tach " diff --git a/forgetest/forgetest/suite/cooling.py b/forgetest/forgetest/suite/cooling.py index 4a186de..19aa473 100644 --- a/forgetest/forgetest/suite/cooling.py +++ b/forgetest/forgetest/suite/cooling.py @@ -270,7 +270,9 @@ def _tail_has(fc, needle): "(OVERTEMP, hold, fire blocked) at the next run start; set to its top the " "engine must skip the gate (verdict OK), report it in gates_off on /status " "and /cool/status, say so in the settings reply, and log the run-start line; " - "restored, everything reads as before.") + "restored, everything reads as before. Alongside: /status carries the watched " + "board temperatures (chassis in degrees, supply as a raw count) and every run " + "session ends with them ranged into one log line.") def gate_off(ctx): fc = ctx.forgectrl ev = ctx.evidence @@ -278,8 +280,17 @@ def gate_off(ctx): orig = {k: before.get(k, "") for k in GATE_KEYS} ev["orig"] = orig ctx.log("original: %s", orig) - up = (fc.status().get("coolant") or {}).get("up_c") + st0 = fc.status() + up = (st0.get("coolant") or {}).get("up_c") ctx.check(up is not None and up > 8.0, "coolant too cold for the trip leg (up_c %s)", up) + # The board temperatures ride /status, watched and not gated: the + # chassis in degrees, the supply as its raw count. + temps = st0.get("temps") or {} + ev["temps"] = temps + ctx.check(isinstance(temps.get("chassis_c"), (int, float)) and 5.0 <= temps["chassis_c"] <= 80.0, + "/status temps.chassis_c is not a plausible chassis temperature: %s", temps) + ctx.check(isinstance(temps.get("supply_raw"), int) and 0 <= temps["supply_raw"] <= 1023, + "/status temps.supply_raw is not a 10-bit count: %s", temps) c0 = _cool(fc) ctx.check(c0.get("verdict") == "OK", "engine is not at OK before the test: %s", c0) ctx.check(c0.get("gates_off") == [], "a gate is already off: %s", c0.get("gates_off")) @@ -336,6 +347,10 @@ def gate_off(ctx): ctx.check(c.get("verdict") == "OK" and c.get("gates_off") == [], "engine did not return to OK with no gate off after the restore: %s", c) ctx.log("restored ceiling %s reports state %s", val, state) + # Every run session ends with the board temperatures ranged + # into one line. + ctx.check(_tail_has(fc, "temps this job: chassis "), + "the run-end board-temperature line is missing from the forgectrl log") finally: if not restored: # The engine reads settings at run start only: restoring @@ -436,6 +451,9 @@ def critical_tier(ctx): ctx.check(c.get("verdict") == "OVERTEMP", "after the faulted session the engine reads %s, expected the ceiling's OVERTEMP: %s", c.get("verdict"), c) + ctx.check("CRITICAL" not in (c.get("reason") or ""), + "the reason still names the ended critical fault under the ceiling's hold: %r", + c.get("reason")) # Leg 2: the critical line at its top is the gate off; the # ceiling alone pauses, and gates_off says so. diff --git a/forgetest/tests/test_cooling_suite.py b/forgetest/tests/test_cooling_suite.py index d4a33df..0a4ff2f 100644 --- a/forgetest/tests/test_cooling_suite.py +++ b/forgetest/tests/test_cooling_suite.py @@ -229,11 +229,13 @@ class GateOffTests(unittest.TestCase): cooling.SESSION_END_WAIT_S = 3 self.fc.state["status"] = dict(self.fc.state["status"], coolant={"down_c": 22.4, "up_c": 22.3, "pump": True, "tec": False}, + temps={"chassis_c": 29.0, "supply_raw": 589}, gates_off=[]) self.fc.state["cool"] = {"phase": "idle", "verdict": "OK", "fire_ok": False, "hold": False, "gates_off": []} self.fc.state["settings"].update({"cool_temp_max": "", "cool_temp_resume": ""}) self.log_line = True # the engine writes its run-start line + self.temps_line = True # the engine writes its run-end temperature line self.report_off = True # the engine reports the off gate self.trips = True # the engine trips a low ceiling self.sessions = 0 # run sessions the engine saw (M8 with the phase not run) @@ -279,6 +281,10 @@ class GateOffTests(unittest.TestCase): def end(): time.sleep(0.3) cool["phase"] = "smoke" + if self.temps_line: + self.fc.state["logs_tail"]["text"] += ( + "Aug 22 12:00:30 forgectrl: cool: temps this job: chassis 29.0..29.4 C, " + "supply raw 587..592\n") time.sleep(0.2) cool["phase"] = "idle" threading.Thread(target=end, daemon=True).start() @@ -367,6 +373,12 @@ class GateOffTests(unittest.TestCase): self.assertIn("run-start log line", str(cm.exception)) self.assertEqual(self.fc.state["settings"]["cool_temp_max"], "") + def test_a_missing_run_end_temperature_line_fails(self): + self.temps_line = False + with self.assertRaises(Failed) as cm: + self.run_test() + self.assertIn("board-temperature line", str(cm.exception)) + def test_a_custom_ceiling_is_restored_verbatim(self): self.fc.state["settings"].update({"cool_temp_max": "30", "cool_temp_resume": "28"}) self._describe() diff --git a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc index be7b120..7ef9572 100644 --- a/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc +++ b/meta-forgefirm/recipes-forgefirm/forgectrl/forgectrl-pin.inc @@ -2,5 +2,5 @@ # only SRCREV and PV here - the image manifest leaves *-pin.inc out of the # layer content hash because the component entry already identifies the # pinned source (forgefirm-image-manifest.bbclass). -SRCREV = "a1875a8113b45c42e252caa3e4b409c1ba4b39fe" +SRCREV = "76115fd87d0b085cd087a59ae0d58213007c42bf" PV = "0.1.0" diff --git a/scripts/bench/README.md b/scripts/bench/README.md index d067d38..8c124c3 100644 --- a/scripts/bench/README.md +++ b/scripts/bench/README.md @@ -40,6 +40,8 @@ page's takeover does that; from a host, stop them first. | `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]`. | +| `temp_calibrate.py` (`supply-*` modes) | The power supply's sensor (`pic/pwr_temp`, raw) against a thermometer on its heatsink: `supply-watch`, `supply-point `, `supply-fit`; the fit is printed beside `UAPI.md`'s unverified guess. Three points during a long cut settle it. | +| `critical_tier_drill.py` | The coolant critical tier on a rising temperature (board or host): sets the ceiling, the resume gate and the critical line a few tenths above the live upstream reading and lets the engine's own flow-check heater warm the loop through them inside one `M8` session, expecting `OVERTEMP` at the ceiling and then `CRITICAL` (fire blocked, hold, no resume) with the fault ending at `M9`; restores the settings and cycles a session so the engine re-reads them. Results as JSON in the bench data directory. | | `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). | diff --git a/scripts/bench/critical_tier_drill.py b/scripts/bench/critical_tier_drill.py new file mode 100644 index 0000000..f7bd1ae --- /dev/null +++ b/scripts/bench/critical_tier_drill.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Coolant critical-tier drill: the loop warmed through the lines by the +engine itself, inside one run session. + +The loop heater reaches the high twenties at most, so the drill sets the +coolant ceiling, the resume gate and the critical line a few tenths above +the live upstream reading and lets the engine's own flow-check heater +(duty 100 percent, 300 s windows, rechecks every 30 s, the suspect +threshold parked at its top so the warm-up is not read as a flow fault) +carry the coolant through them during a bare M8 session. Expected, in +order: OVERTEMP at the ceiling (the pause tier), CRITICAL at the critical +line (the fail tier: fire blocked, hold, no resume), the fault ending with +the session and the ceiling's hold standing after it. The settings are +restored at the end and a short session makes the engine re-read them. + +No laser, no motion, nothing armed. Runs on the board (the bench page) or +from a LAN host (gfbench: GF_HOST). Results as JSON in the bench data +directory. + +Usage: critical_tier_drill.py [--max-seconds N] (default 1200) +""" +import json +import socket +import sys +import time + +from gfbench import HOST, data_path, forgectrl_get, forgectrl_post + +KEYS = ("cool_temp_max", "cool_temp_resume", "cool_temp_critical_c", + "cool_flow_heater_pct", "cool_flow_check_s", "cool_recheck_s", "cool_flow_rise") +ABOVE = {"cool_temp_max": 0.4, "cool_temp_resume": 0.2, "cool_temp_critical_c": 0.7} +HEAT = {"cool_flow_heater_pct": "100", "cool_flow_check_s": "300", "cool_recheck_s": "30", + "cool_flow_rise": "40"} + + +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 cool(): + st, c = forgectrl_get("/cool/status") + if st != 200 or not isinstance(c, dict): + raise RuntimeError("/cool/status -> %s %r" % (st, c)) + return c + + +def row(c, t): + return "%5.0f s %-6s %-8s fire_ok=%-5s hold=%-5s up %.2f down %.2f | %s" % ( + t, c["phase"], c["verdict"], c["fire_ok"], c["hold"], c["up_c"], c["down_c"], c.get("reason", "")) + + +def session_ended(wait=60): + for _ in range(wait): + time.sleep(1) + c = cool() + if c["phase"] != "run": + return c + return cool() + + +def main(argv): + max_s = 1200 + args = argv[1:] + while args: + a = args.pop(0) + if a == "--max-seconds": + max_s = int(args.pop(0)) + else: + print(__doc__) + return 2 + st, before = forgectrl_get("/settings") + if st != 200: + print("GET /settings -> %s" % st) + return 2 + orig = {k: before.get(k, "") for k in KEYS} + print("original settings: %s" % orig) + c = cool() + if c["phase"] == "run": + print("a run session is already open; nothing done") + return 2 + t0_up = c["up_c"] + lines = {k: "%.1f" % (t0_up + d) for k, d in ABOVE.items()} + lines.update(HEAT) + print("upstream %.2f C; drill settings: %s" % (t0_up, lines)) + st, body = forgectrl_post("/settings", params=lines) + if st != 200: + print("POST /settings -> %s %r" % (st, body)) + return 2 + result = {"started": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "upstream_start_c": t0_up, + "settings": lines, "transitions": [], "critical_s": None} + g = None + rc = 1 + try: + g = Grbl() + print("M8 -> %s" % g.cmd("M8")) + t0 = time.time() + last = None + crit_at = None + while time.time() - t0 < max_s: + time.sleep(1) + c = cool() + t = time.time() - t0 + key = (c["verdict"], c["fire_ok"], c["hold"]) + if key != last or int(t) % 15 == 0: + print(row(c, t)) + if key != last: + result["transitions"].append({"t_s": round(t), "verdict": c["verdict"], "up_c": c["up_c"], + "down_c": c["down_c"], "reason": c.get("reason", "")}) + last = key + if c["verdict"] == "CRITICAL" and crit_at is None: + crit_at = t + result["critical_s"] = round(t) + if crit_at is not None and t - crit_at >= 10: + break + if crit_at is None: + print("no CRITICAL within %d s (upstream %.2f C)" % (max_s, c["up_c"])) + print("M9 -> %s" % g.cmd("M9")) + c = session_ended() + print("after the session: %s" % row(c, time.time() - t0)) + result["after_session"] = {k: c.get(k) for k in ("phase", "verdict", "fire_ok", "hold", "reason", "up_c")} + rc = 0 if crit_at is not None else 1 + finally: + if g: + try: + g.cmd("M9") + except OSError: + pass + g.close() + st, body = forgectrl_post("/settings", params=orig) + print("restore POST /settings -> %s" % st) + result["restored"] = st == 200 + try: + session_ended() + g2 = Grbl() + g2.cmd("M8") + time.sleep(3) + c = cool() + print("re-read session: %s %s limits %s" % (c["phase"], c["verdict"], c.get("limits"))) + g2.cmd("M9") + g2.close() + c = session_ended() + print("final: %s" % row(c, 0)) + result["final"] = {k: c.get(k) for k in ("phase", "verdict", "fire_ok", "hold", "up_c")} + except OSError as e: + print("re-read session failed: %s" % e) + out = data_path("critical_tier_drill_%s.json" % time.strftime("%Y%m%d-%H%M%S")) + with open(out, "w") as f: + json.dump(result, f, indent=1) + print("wrote %s" % out) + print("RESULT: %s" % ("PASS" if rc == 0 else "FAIL")) + return rc + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/bench/temp_calibrate.py b/scripts/bench/temp_calibrate.py index 51cf3c6..1cfc75b 100644 --- a/scripts/bench/temp_calibrate.py +++ b/scripts/bench/temp_calibrate.py @@ -14,6 +14,14 @@ Usage: temp_calibrate.py point [note] record a calibration point temp_calibrate.py fit fit and print the calibration + temp_calibrate.py supply-watch [seconds] the same for the power supply + temp_calibrate.py supply-point [note] sensor (pic/pwr_temp, raw): + temp_calibrate.py supply-fit thermometer on the heatsink vs + the raw count; UAPI.md's guess + raw * 0.08715 - 21 is shown + beside it. Points go to + supply_calibration.json. + 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 @@ -28,6 +36,22 @@ import time from gfbench import board, degc, data_path STORE = data_path('temp_calibration.json') +SUPPLY_STORE = data_path('supply_calibration.json') + + +def supply_guess_c(raw): + """UAPI.md's unverified guess for pic/pwr_temp.""" + return raw * 0.08715 - 21 + + +def supply_raw(samples=5, delay=1.0): + acc, n = 0, 0 + for _ in range(samples): + out = board('cat /sys/glowforge/pic/pwr_temp').strip() + if out.isdigit(): + acc += int(out); n += 1 + time.sleep(delay) + return acc / n if n else None def uapi_c(raw): @@ -72,8 +96,71 @@ def fit(points, key): return slope, my - slope * mx +def supply_main(mode): + if mode == 'supply-watch': + seconds = float(sys.argv[2]) if len(sys.argv) > 2 else 60.0 + print('pwr_temp raw guess-C (%.0f s)' % seconds) + t0 = time.time() + while time.time() - t0 < seconds: + r = supply_raw(1, 0) + if r is None: + print(' (no reading)') + else: + print(' %6.1f %.2f' % (r, supply_guess_c(r)), flush=True) + time.sleep(2) + return 0 + if mode == 'supply-point': + try: + measured = float(sys.argv[2]) + except (IndexError, ValueError): + print('supply-point needs the thermometer reading in C (value)') + return 2 + note = sys.argv[3] if len(sys.argv) > 3 else '' + print('sampling pwr_temp (5 s)...', flush=True) + r = supply_raw() + if r is None: + print('no reading from the machine') + return 1 + data = {'points': []} + if os.path.exists(SUPPLY_STORE): + with open(SUPPLY_STORE) as f: + data = json.load(f) + data['points'].append({'measured_c': measured, 'raw': r, 'note': note, + 'when': time.strftime('%Y-%m-%d %H:%M:%S')}) + with open(SUPPLY_STORE, 'w') as f: + json.dump(data, f, indent=2) + print('recorded: measured %.2f C raw=%.1f (guess %.1f C) (%d points total in %s)' + % (measured, r, supply_guess_c(r), len(data['points']), SUPPLY_STORE)) + return 0 + if mode == 'supply-fit': + if not os.path.exists(SUPPLY_STORE): + print('no points yet') + return 1 + with open(SUPPLY_STORE) as f: + pts = json.load(f)['points'] + if len(pts) < 2: + print('need at least 2 points (have %d)' % len(pts)) + return 1 + print('points:') + for p in pts: + print(' %6.2f C raw=%.1f guess %.1f C %s %s' + % (p['measured_c'], p['raw'], supply_guess_c(p['raw']), p['when'], p['note'])) + slope, offset = fit(pts, 'raw') + if slope is None: + print('points share one raw value; no fit') + return 1 + print('fit: degC = %.5f * raw + %.2f (UAPI.md guess: 0.08715 * raw - 21)' % (slope, offset)) + worst = max(abs(p['measured_c'] - supply_guess_c(p['raw'])) for p in pts) + print('the guess is off by at most %.1f C at these points' % worst) + return 0 + print(__doc__) + return 2 + + def main(): mode = sys.argv[1] if len(sys.argv) > 1 else 'watch' + if mode.startswith('supply-'): + return supply_main(mode) if mode == 'watch': seconds = float(sys.argv[2]) if len(sys.argv) > 2 else 60.0