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>
This commit is contained in:
@@ -162,7 +162,8 @@ def test_the_page_lists_unwired_sensors_apart_hottest_first():
|
||||
"""VDNT-Util01's sensor list read SYSTIN 115 °C in red at the top, though
|
||||
the host's temperature already left it out (#5759)."""
|
||||
from plugins.host_agent.routes import split_temps
|
||||
real, unwired = split_temps({"SYSTIN": 115.0, "AUXTIN0": 110.0, "Package id 0": 52.0,
|
||||
"Composite": 48.9, "PCH_CHIP_TEMP": 0.0, "CPUTIN": 47.0, "gone": None})
|
||||
real, unwired = split_temps("h", {"SYSTIN": 115.0, "AUXTIN0": 110.0, "Package id 0": 52.0,
|
||||
"Composite": 48.9, "PCH_CHIP_TEMP": 0.0, "CPUTIN": 47.0, "gone": None})
|
||||
assert list(real) == ["Package id 0", "Composite", "CPUTIN", "gone"]
|
||||
assert list(unwired) == ["SYSTIN", "AUXTIN0", "PCH_CHIP_TEMP"]
|
||||
assert unwired["SYSTIN"] == (115.0, "reads a value no wired sensor gives")
|
||||
|
||||
@@ -77,3 +77,21 @@ async def test_the_metrics_page_grades_by_the_same_cutoffs(app):
|
||||
assert 'class="stat-val tone-warn" style="font-size:1.6rem;">72°C' in page
|
||||
assert '<span class="tone-crit" style="font-variant-numeric:tabular-nums;">90°C' in page
|
||||
assert not hex_colours(page), hex_colours(page)
|
||||
|
||||
|
||||
async def test_sensors_not_counted_are_listed_once_per_reason(app):
|
||||
"""Util01's five open inputs read as one list with its reason, not five
|
||||
repeats of it; a sensor judged against the CPU carries its own (#5783)."""
|
||||
held = "reads a value no wired sensor gives"
|
||||
falls = "falls as the CPU warms (r -0.81 over 168 h)"
|
||||
async with app.test_request_context("/plugins/host_agent/h1/metrics"):
|
||||
page = await render_plugin_template(
|
||||
app, "host_agent", "_host_metrics.html", host=SimpleNamespace(address="10.0.0.2"), stale=False,
|
||||
reg=SimpleNamespace(distro=None, kernel=None, arch=None, agent_version=None,
|
||||
last_seen_at=datetime.now(timezone.utc)),
|
||||
hostlvl={"temp_c_max": 52.0}, cores=[], nets={}, disks_io={}, temps={"Package id 0": 52.0},
|
||||
unwired_temps={"SYSTIN": (115.0, held), "AUXTIN0": (110.0, held), "temp3": (62.0, falls)},
|
||||
mounts={})
|
||||
page = " ".join(page.split())
|
||||
assert (f"Not counted, likely nothing wired to them: temp3 62°C ({falls}); "
|
||||
f"SYSTIN 115°C, AUXTIN0 110°C ({held}).") in page
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""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)")}
|
||||
Reference in New Issue
Block a user