"""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, {}) # {host name: {sensor label: hwmon chip}}, as the host's latest sample said. # A sensor's metric is keyed by its label alone; the chip only names it. _chips: dict[str, dict[str, str]] = {} # What each hwmon driver measures, in the words a page uses. A driver not # listed is shown by its own name. _CHIP_WORDS = ( (re.compile(r"^(coretemp|k10temp|zenpower|cpu_thermal)$"), "CPU"), (re.compile(r"^nvme$"), "NVMe"), (re.compile(r"^drivetemp$"), "Drive"), (re.compile(r"^(nct\d+|it\d+|w83\w+|f71\w+)$"), "Board"), (re.compile(r"^pch_\w+$"), "Chipset"), (re.compile(r"^(amdgpu|radeon|nouveau|i915|xe)$"), "GPU"), (re.compile(r"^(spd5118|jc42)$"), "Memory"), (re.compile(r"^iwlwifi"), "Wi-Fi"), (re.compile(r"^acpitz$"), "ACPI"), ) def note_chips(host_name: str, temps) -> None: """Remember which chip each of a host's sensors is on, from a sample's temps. An agent that doesn't say leaves what is known as it is.""" seen = {str(t["label"]): str(t["chip"]) for t in temps or [] if t.get("chip")} if seen: _chips[host_name] = seen def chip_word(chip: str) -> str: """What a hwmon chip measures ("nvme" → "NVMe"), or its own name.""" for pattern, word in _CHIP_WORDS: if pattern.match(chip): return word return chip def sensor_name(host_name: str, label: str) -> str: """A sensor as a page names it: its chip, then its label, as in "NVMe · Sensor 2". The label alone when the chip isn't known or the label already carries it ("nvme_temp1").""" chip = _chips.get(host_name, {}).get(label) if not chip or label.lower().startswith(chip.lower()): return label return f"{chip_word(chip)} · {label}" 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: