"""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 ":temp: