feat(host_agent): a temperature sensor is judged by how it moves against the CPU (#5783)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m21s
CI / unit (push) Successful in 1m31s
CI / publish (push) Successful in 17s

A board's spare thermistor inputs report whatever an open input reads, and
the value rule from #5759 only catches the impossible ones. Once an hour,
sensors.refresh judges each sensor against the host's CPU (Package id N,
Tctl/Tdie/TccdN, PECI Agent N) over a week of hourly averages. A sensor is
ruled out if it falls as the CPU warms (r <= -0.5) or holds within 0.5 °C
while the CPU moved 10 °C or more. A judgment needs at least 72 shared hours.

The host temperature leaves judged sensors out at ingest. The host page
lists them under "Not counted", with each reason given once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 21:24:34 -04:00
co-authored by Claude Opus 5.5
parent 72e754b580
commit 0b29cdf26c
9 changed files with 283 additions and 19 deletions
+25 -11
View File
@@ -113,15 +113,28 @@ def counts_as_hottest(label: str, celsius: float) -> bool:
return not (_BOARD_INPUT.match(label) and celsius >= BOARD_INPUT_CEILING_C)
def split_temps(temps: dict) -> tuple[dict, dict]:
def is_thermometer(host_name: str, label: str, celsius: float) -> bool:
"""Whether a sensor's reading may count as the host's temperature: not one
judged unwired from how it moves against the CPU (sensors.py, #5783),
and a reading counts_as_hottest allows."""
from . import sensors
return label not in sensors.unwired(host_name) and counts_as_hottest(label, celsius)
def split_temps(host_name: str, temps: dict) -> tuple[dict, dict]:
"""(real, unwired): a host's sensors, hottest first, apart from the ones
counts_as_hottest leaves out (a board input with nothing wired to it, a
reading at or below 0 °C). Those read a constant at any load, so the
page lists them apart instead of colouring them as the host's heat."""
def by_heat(d: dict) -> dict:
return dict(sorted(d.items(), key=lambda kv: -(kv[1] if kv[1] is not None else -999)))
real = {k: c for k, c in temps.items() if c is None or counts_as_hottest(k, c)}
return by_heat(real), by_heat({k: c for k, c in temps.items() if k not in real})
is_thermometer leaves out. Unwired ones map to (reading, why); they read
a constant or run against the CPU, so the page lists them apart instead
of colouring them as the host's heat."""
from . import sensors
judged = sensors.unwired(host_name)
def by_heat(d: dict, value=lambda v: v) -> dict:
return dict(sorted(d.items(), key=lambda kv: -(value(kv[1]) if value(kv[1]) is not None else -999)))
real = {k: c for k, c in temps.items() if c is None or is_thermometer(host_name, k, c)}
unwired = {k: (c, judged.get(k, "reads a value no wired sensor gives"))
for k, c in temps.items() if k not in real}
return by_heat(real), by_heat(unwired, lambda v: v[0])
def _ts_or(value, fallback: datetime) -> datetime:
@@ -305,14 +318,15 @@ def _expand_sample_to_metrics(
row("disk_write_bps", host_name, dw_total)
# Temperatures — per sensor plus the host max for at-a-glance. The max
# counts only readings that can be real (#5759).
# counts only readings that can be real (#5759), from sensors that behave
# like thermometers (#5783).
max_temp: float | None = None
for t in sample.get("temps") or []:
c = t.get("celsius")
if c is None:
continue
row("temp_c", f"{host_name}:temp:{t['label']}", c)
if counts_as_hottest(str(t["label"]), c) and (max_temp is None or c > max_temp):
if is_thermometer(host_name, str(t["label"]), c) and (max_temp is None or c > max_temp):
max_temp = c
if max_temp is not None:
row("temp_c_max", host_name, max_temp)
@@ -867,7 +881,7 @@ async def host_detail_metrics(host_id: str):
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
latest = await _latest_metrics_for_host(session, host.name)
hostlvl, cores, nets, disks_io, temps, mounts = _split_host_metrics(host.name, latest)
temps, unwired = split_temps(temps)
temps, unwired = split_temps(host.name, temps)
ls = reg.last_seen_at if reg else None
stale = ls is None or (datetime.now(timezone.utc) - ls).total_seconds() > _stale_after_seconds()
return await render_template(