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.
This commit is contained in:
ScottW514
2026-08-22 12:56:40 -04:00
parent f51140e528
commit 9fae47cf14
10 changed files with 372 additions and 9 deletions
+18 -3
View File
@@ -623,6 +623,11 @@ until `releases/v<version>/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
+34
View File
@@ -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
+6
View File
@@ -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
+13 -3
View File
@@ -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 "
+20 -2
View File
@@ -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.
+12
View File
@@ -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()
@@ -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"
+2
View File
@@ -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 <C>`, `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). |
+179
View File
@@ -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))
+87
View File
@@ -14,6 +14,14 @@ Usage:
temp_calibrate.py point <measured_C> [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 <C> [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