Files
FabledSteward/tests/plugins/host_agent/test_sensors.py
T
bvandeusenandClaude Opus 5.5 0b29cdf26c
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m21s
CI / unit (push) Successful in 1m31s
CI / publish (push) Successful in 17s
feat(host_agent): a temperature sensor is judged by how it moves against the CPU (#5783)
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>
2026-10-14 21:24:34 -04:00

75 lines
3.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 temperature sensors are thermometers, judged from how each moves
against the host's CPU (#5783). VDNT-Util01's SYSTIN ran at r = −0.81
against its CPU package over a week; CPUTIN beside it at +0.83 (#5782)."""
import math
from datetime import datetime, timedelta, timezone
from plugins.host_agent import sensors as s
T0 = datetime(2026, 10, 8, tzinfo=timezone.utc)
def _hours(n, f):
"""{hour: f(i)} for n hourly buckets."""
return {T0 + timedelta(hours=i): f(i) for i in range(n)}
def cpu(i): # a CPU that swings 50-80 °C through the day
return 65 + 15 * math.sin(i / 24 * 2 * math.pi)
def test_a_sensor_that_falls_as_the_cpu_warms_is_not_a_thermometer():
ref = _hours(96, cpu)
systin = _hours(96, lambda i: 114 - 0.1 * (cpu(i) - 65)) # Util01's shape
why = s.judge(ref, systin)
assert why.startswith("falls as the CPU warms (r -1.00 over 96 h")
def test_a_sensor_that_holds_still_is_not_a_thermometer():
why = s.judge(_hours(96, cpu), _hours(96, lambda i: 110.0 + (0.1 if i % 2 else 0.0)))
assert why == "holds at 110 °C while the CPU moved 50–80 °C"
def test_a_wired_sensor_is_left_alone_even_when_it_barely_follows_the_cpu():
ref = _hours(96, cpu)
assert s.judge(ref, _hours(96, lambda i: 40 + 0.3 * (cpu(i) - 65))) is None # a board sensor
assert s.judge(ref, _hours(96, lambda i: 45 + 3 * math.sin(i))) is None # a drive, its own rhythm
def test_nothing_is_judged_without_enough_history_or_a_cpu_that_moved():
falls = lambda i: 114 - 0.1 * (cpu(i) - 65) # noqa: E731
assert s.judge(_hours(48, cpu), _hours(48, falls)) is None # 48 h < MIN_HOURS
idle = _hours(96, lambda i: 50 + (i % 3)) # moved 2 °C
assert s.judge(idle, _hours(96, lambda i: 110 - (i % 3))) is None
def test_the_cpu_reference_is_found_by_its_driver_names():
assert s.reference_label(["SYSTIN", "Core 0", "Package id 1", "Package id 0"]) == "Package id 0"
assert s.reference_label(["Tccd1", "Tctl", "Composite"]) == "Tctl"
assert s.reference_label(["PECI Agent 0", "SYSTIN"]) == "PECI Agent 0"
assert s.reference_label(["Composite", "temp1"]) is None
def test_hosts_are_judged_from_hourly_rows_and_a_host_without_a_cpu_reading_is_skipped():
rows = []
for i in range(96):
h = T0 + timedelta(hours=i)
rows += [("util:temp:Package id 0", h, cpu(i)),
("util:temp:SYSTIN", h, 114 - 0.1 * (cpu(i) - 65)),
("util:temp:CPUTIN", h, 40 + 0.3 * (cpu(i) - 65)),
("nas:temp:temp1", h, 110 - 0.1 * (cpu(i) - 65)),
("util", h, 1.0)] # not a sensor row
got = s.judge_hosts(rows)
assert list(got) == ["util"] and list(got["util"]) == ["SYSTIN"]
def test_a_judged_sensor_is_left_out_of_the_host_temperature_and_listed_with_why(monkeypatch):
from plugins.host_agent.routes import is_thermometer, split_temps
monkeypatch.setattr(s, "_unwired", {"util": {"temp3": "falls as the CPU warms (r -0.81 over 168 h)"}})
assert not is_thermometer("util", "temp3", 62.0) # a plausible value, still not a thermometer
assert is_thermometer("other", "temp3", 62.0)
real, unwired = split_temps("util", {"temp3": 62.0, "Package id 0": 55.0})
assert list(real) == ["Package id 0"]
assert unwired == {"temp3": (62.0, "falls as the CPU warms (r -0.81 over 168 h)")}