"""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)")} def test_a_later_read_replaces_the_hour_that_was_filling_and_old_hours_drop(): hours = {"h:temp:a": {T0: 50.0, T0 + timedelta(hours=1): 51.0}} s.merge_hours(hours, [("h:temp:a", T0 + timedelta(hours=1), 55.0), # the hour, read whole ("h:temp:a", T0 + timedelta(hours=2), 56.0), ("h:temp:b", T0 + timedelta(hours=2), None)], since=T0 + timedelta(hours=1)) assert hours == {"h:temp:a": {T0 + timedelta(hours=1): 55.0, T0 + timedelta(hours=2): 56.0}} s.merge_hours(hours, [], since=T0 + timedelta(hours=9)) # a sensor gone quiet assert hours == {}