Files
FabledSteward/plugins/host_agent/sensors.py
T
bvandeusenandClaude Opus 5.5 ec13f3797d
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m23s
CI / unit (push) Successful in 1m31s
CI / publish (push) Skipped
perf(host_agent): the sensor judgment reads the week once, then only new hours (#5786)
On the live instance the hourly refresh took 3 min 8 s, averaging a week of
raw temp_c samples each time. The hourly averages are now kept in memory.
The first refresh after startup reads the whole window. Each later one reads
raw samples from the start of the hour the last read ended in, so the hour
that was still filling is read again whole. merge_hours folds the new rows
in and drops hours older than the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:42:11 -04:00

174 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Which of a host's temperature sensors are thermometers (#5783).
A board's spare thermistor inputs often have nothing wired to them, and the
kernel reports them anyway. No flag says so; their behaviour does. A
thermometer in a host warms as its CPU warms. An open input reads the chip's
own reference, which drifts the other way as the chip warms, or holds still:
VDNT-Util01's SYSTIN ran at r = −0.81 against its CPU package over a week,
while CPUTIN beside it ran at +0.83 (#5782).
So each sensor is judged against the host's CPU over its hourly averages, by
a scheduled task, and the verdicts are kept here for ingest and the pages to
read. Until a host has enough history, or when its CPU never moved enough to
tell, nothing is judged and routes.counts_as_hottest's value rule decides.
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta
from math import sqrt
logger = logging.getLogger(__name__)
# Hours of history judged, the shared hours a verdict needs, and how far the
# CPU must have moved in them for "it didn't follow" to mean anything.
WINDOW = timedelta(days=7)
MIN_HOURS = 72
REFERENCE_SWING_C = 10.0
# Falling as the CPU warms, this strongly, is no thermometer's behaviour.
# A wired board or drive sensor may barely follow the CPU (r near 0), so
# only a clear negative counts, not a weak one.
FALLS_R = -0.5
# Held within this over days while the CPU moved: a constant, not a reading.
FLAT_C = 0.5
# The host's CPU, in order of preference: Intel coretemp's package, AMD
# k10temp's control/die/CCD readings, then the PECI agent.
_REFERENCES = (re.compile(r"^Package id \d+$", re.I), re.compile(r"^(Tctl|Tdie)$", re.I),
re.compile(r"^Tccd\d+$", re.I), re.compile(r"^PECI Agent \d+$", re.I))
# {host name: {sensor label: why it isn't a thermometer}}, from the last refresh.
_unwired: dict[str, dict[str, str]] = {}
# {resource: {hour: average}} over the last WINDOW, and the hour the next
# refresh reads raw samples from. Reading the whole week took over three
# minutes on the live instance, so it is read once at startup, and each later
# refresh reads only the hours since the last one.
_hours: dict[str, dict[datetime, float]] = {}
_read_from: datetime | None = None
def unwired(host_name: str) -> dict[str, str]:
"""The sensors judged not to be thermometers on a host, with why."""
return _unwired.get(host_name, {})
def reference_label(labels) -> str | None:
"""The sensor that reads the host's CPU, or None when it has none."""
labels = sorted(labels)
for pattern in _REFERENCES:
found = [label for label in labels if pattern.match(label)]
if found:
return found[0]
return None
def pearson(xs: list[float], ys: list[float]) -> float | None:
"""Pearson's r, or None when either series doesn't move."""
n = len(xs)
mx, my = sum(xs) / n, sum(ys) / n
sxx = sum((x - mx) ** 2 for x in xs)
syy = sum((y - my) ** 2 for y in ys)
if sxx == 0 or syy == 0:
return None
return sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sqrt(sxx * syy)
def judge(reference: dict[datetime, float], sensor: dict[datetime, float]) -> str | None:
"""Why `sensor` isn't a thermometer, judged against the host's CPU over
the hours both have; None when it behaves like one, or when there isn't
enough to tell (too few hours, or a CPU that never moved)."""
hours = sorted(set(reference) & set(sensor))
if len(hours) < MIN_HOURS:
return None
cpu = [reference[h] for h in hours]
got = [sensor[h] for h in hours]
if max(cpu) - min(cpu) < REFERENCE_SWING_C:
return None
if max(got) - min(got) < FLAT_C:
return f"holds at {sum(got) / len(got):.0f} °C while the CPU moved {min(cpu):.0f}–{max(cpu):.0f} °C"
r = pearson(cpu, got)
if r is not None and r <= FALLS_R:
return f"falls as the CPU warms (r {r:.2f} over {len(hours)} h)"
return None
def judge_hosts(rows) -> dict[str, dict[str, str]]:
"""{host: {label: why}} from hourly rows of (resource, bucket, value_avg),
where resource is "<host>:temp:<label>"."""
series: dict[str, dict[str, dict[datetime, float]]] = {}
for resource, bucket, value in rows:
host, sep, label = resource.partition(":temp:")
if sep and value is not None:
series.setdefault(host, {}).setdefault(label, {})[bucket] = value
out: dict[str, dict[str, str]] = {}
for host, sensors in series.items():
ref = reference_label(sensors)
if ref is None:
continue
for label, points in sensors.items():
if label == ref:
continue
why = judge(sensors[ref], points)
if why:
out.setdefault(host, {})[label] = why
return out
def merge_hours(hours: dict[str, dict[datetime, float]], rows, since: datetime) -> None:
"""Fold (resource, hour, average) rows into `hours`, a newer read of an
hour replacing an older one (the hour that was still filling last time),
and drop the hours older than `since`."""
for resource, bucket, value in rows:
if value is not None:
hours.setdefault(resource, {})[bucket] = float(value)
for resource in list(hours):
kept = {h: v for h, v in hours[resource].items() if h >= since}
if kept:
hours[resource] = kept
else:
del hours[resource]
async def refresh(session, now: datetime) -> dict[str, dict[str, str]]:
"""Re-judge every host's sensors from the last WINDOW of hourly averages,
and keep the verdicts for ingest and the pages.
Hourly rollups exist only for hours older than the raw window
(metrics.retention.raw_days, 7 days by default), so the recent hours are
averaged from the raw samples here. Retention deletes the raw rows it
rolls up, so the two never cover the same hour. The sensors are named
from plugin_metric_latest, so each series is read by its index rather
than by a pattern over every host_agent row.
The first refresh reads the whole WINDOW; later ones read raw samples
from the start of the hour the last one ended in, so that hour is
re-read whole."""
global _unwired, _read_from
from sqlalchemy import func, select
from steward.models.metrics import PluginMetric as M
from steward.models.metrics import PluginMetricHourly as H
from steward.models.metrics import PluginMetricLatest as L
since = now - WINDOW
names = (await session.execute(
select(L.resource_name).where(L.source_module == "host_agent", L.metric_name == "temp_c"))).scalars().all()
if not names:
_unwired = {}
return _unwired
read_from = since if _read_from is None or _read_from < since else _read_from
hour = func.date_trunc("hour", M.recorded_at)
rows = list((await session.execute(
select(M.resource_name, hour, func.avg(M.value))
.where(M.source_module == "host_agent", M.metric_name == "temp_c",
M.resource_name.in_(names), M.recorded_at >= read_from)
.group_by(M.resource_name, hour))).all())
if _read_from is None:
rows += (await session.execute(
select(H.resource_name, H.bucket, H.value_avg)
.where(H.source_module == "host_agent", H.metric_name == "temp_c",
H.resource_name.in_(names), H.bucket >= since))).all()
merge_hours(_hours, rows, since)
_read_from = now.replace(minute=0, second=0, microsecond=0)
_unwired = judge_hosts((r, h, v) for r, pts in _hours.items() for h, v in pts.items())
return _unwired