auto_suspend: busctl->shutdown now, 1min timer config, AMD JSON parsing, locale fix, unit tests

This commit is contained in:
2026-06-09 20:45:08 +02:00
parent f8a0a8ab1f
commit 80c154df03
9 changed files with 701 additions and 81 deletions
@@ -48,13 +48,9 @@ NVIDIA_BUSY_4GAPS = """ 0
NVIDIA_BUSY_SINGLE = """ 95
"""
NVIDIA_NONE = ""
AMD_SMII_GFX = _load_fixture("amd-smi-monitor--csv.txt")
AMD_SMII_GFX_0 = """gpu,power_usage,hotspot_temperature,memory_temperature,gfx,gfx_clock,mem,mem_clock,encoder,decoder,vclock,dclock,single_bit_ecc,double_bit_ecc,pcie_replay,vram_used,vram_total,pcie_bw,pviol,tviol,phot_tviol,vr_tviol,hbm_tviol
0,20,52,62,0,1200,0,96,N/A,0.0,25,25,0,0,N/A,11928,16334,N/A,N/A,N/A,N/A,N/A,N/A
"""
AMD_SMII_GFX_80 = """gpu,power_usage,hotspot_temperature,memory_temperature,gfx,gfx_clock,mem,mem_clock,encoder,decoder,vclock,dclock,single_bit_ecc,double_bit_ecc,pcie_replay,vram_used,vram_total,pcie_bw,pviol,tviol,phot_tviol,vr_tviol,hbm_tviol
0,180,78,82,80,1800,90,200,N/A,45.0,25,25,0,0,N/A,14000,16334,N/A,N/A,N/A,N/A,N/A,N/A
"""
AMD_SMII_JSON = _load_fixture("amd-smi-monitor--json.txt")
AMD_SMII_GFX_0 = '[{"gpu":0,"gfx":{"value":0,"unit":"%"}}]'
AMD_SMII_GFX_80 = '[{"gpu":0,"gfx":{"value":80,"unit":"%"}}]'
WHO_WITH_USERS = _load_fixture("who.with-users.txt")
WHO_EMPTY = ""
@@ -65,10 +61,6 @@ MPSTAT_HIGH = """Linux 6.17.11-200.fc42.x86_64 (gpu-01) 06/08/26 _x86_64_ (12
Average: all 85.30 0.00 8.20 0.50 0.00 0.10 0.00 0.00 0.00 5.90
"""
AMD_SMII_ONLY_HEADER = """gpu,power_usage,hotspot_temperature,memory_temperature,gfx
0,20,52,62,0
"""
# ---------------------------------------------------------------------------
# Tests
@@ -194,6 +186,22 @@ class TestMeasureCpu(unittest.TestCase):
self.assertIsNone(result)
logger.warning.assert_called_with("mpstat timed out")
def test_portuguese_locale(self):
"""Verify mpstat with C locale (LC_ALL=C) parses correctly.
The measure_cpu function forces LC_ALL=C so mpstat always outputs
in English regardless of system locale.
"""
from auto_suspend import measure_cpu
logger = MagicMock()
with patch("auto_suspend.subprocess.run") as run:
run.return_value = _make_mock_result(MPSTAT_IDLE)
result = measure_cpu(logger)
# Verify subprocess was called with LC_ALL=C
call_kwargs = run.call_args
self.assertEqual(call_kwargs[1]["env"]["LC_ALL"], "C")
self.assertAlmostEqual(result, 99.58, places=1)
class TestMeasureGpuNvidia(unittest.TestCase):
@@ -253,7 +261,7 @@ class TestMeasureGpuAmd(unittest.TestCase):
from auto_suspend import measure_gpu_amd
logger = MagicMock()
with patch("auto_suspend.subprocess.run") as run:
run.return_value = _make_mock_result(AMD_SMII_GFX)
run.return_value = _make_mock_result(AMD_SMII_JSON)
result = measure_gpu_amd(logger)
self.assertAlmostEqual(result, 100.0, places=1)
@@ -289,6 +297,14 @@ class TestMeasureGpuAmd(unittest.TestCase):
result = measure_gpu_amd(logger)
self.assertIsNone(result)
def test_empty_array(self):
from auto_suspend import measure_gpu_amd
logger = MagicMock()
with patch("auto_suspend.subprocess.run") as run:
run.return_value = _make_mock_result("[]")
result = measure_gpu_amd(logger)
self.assertIsNone(result)
def test_returncode_nonzero(self):
from auto_suspend import measure_gpu_amd
logger = MagicMock()
@@ -371,6 +387,77 @@ class TestUpdateAverages(unittest.TestCase):
self.assertAlmostEqual(state["gpu_avg"], 5.0)
self.assertEqual(state["sample_count"], 2)
def test_ema_decay_idle_gpu(self):
"""
Simulate GPU going from 100% to 0% idle and verify EMA decays.
This validates that the EMA calculation correctly handles the scenario
where a busy GPU becomes idle — the average should converge toward
idle values after enough consecutive idle samples.
"""
from auto_suspend import fresh_state, update_averages
logger = MagicMock()
state = fresh_state()
# Phase 1: GPU was busy (simulate 10 consecutive 100% samples)
for _ in range(10):
state = update_averages(state, cpu_n=95.0, gpu_n=100.0, alpha=0.3, logger=logger)
self.assertAlmostEqual(state["gpu_avg"], 100.0, places=1)
self.assertEqual(state["sample_count"], 10)
# Phase 2: GPU goes idle (0%) for 20 consecutive samples
for _ in range(20):
state = update_averages(state, cpu_n=99.0, gpu_n=0.0, alpha=0.3, logger=logger)
# After 20 idle samples from 100%, EMA should be very close to 0
# EMA = 0.7^20 * 100 ≈ 0.08%
self.assertAlmostEqual(state["gpu_avg"], 0.08, places=1)
self.assertEqual(state["sample_count"], 30)
def test_ema_convergence_multiple_values(self):
"""
Verify EMA converges to the steady-state value regardless of start.
With alpha=0.3 and 100 identical samples, the average should equal
the sample value (since EMA_new = 0.3*x + 0.7*x = x).
"""
from auto_suspend import fresh_state, update_averages
logger = MagicMock()
state = fresh_state()
gpu_idle = 2.0
for _ in range(50):
state = update_averages(state, cpu_n=95.0, gpu_n=gpu_idle, alpha=0.3, logger=logger)
self.assertAlmostEqual(state["gpu_avg"], gpu_idle, places=2)
self.assertAlmostEqual(state["cpu_avg"], 95.0, places=2)
def test_ema_with_missing_gpu_sample(self):
"""
Verify None GPU sample preserves the average.
When measure_gpu_amd returns None (e.g., tool unavailable), the
average should NOT be updated — it should keep the last value.
"""
from auto_suspend import fresh_state, update_averages
logger = MagicMock()
state = fresh_state()
# Seed with a GPU average
state = update_averages(state, cpu_n=95.0, gpu_n=10.0, alpha=0.3, logger=logger)
self.assertAlmostEqual(state["gpu_avg"], 10.0)
# Now GPU measurement fails (None) — average should be preserved
state = update_averages(state, cpu_n=95.0, gpu_n=None, alpha=0.3, logger=logger)
self.assertAlmostEqual(state["gpu_avg"], 10.0)
self.assertEqual(state["sample_count"], 2)
# After GPU returns, EMA should continue from the preserved value
state = update_averages(state, cpu_n=95.0, gpu_n=0.0, alpha=0.3, logger=logger)
# EMA = 0.3 * 0 + 0.7 * 10 = 7.0
self.assertAlmostEqual(state["gpu_avg"], 7.0)
class TestEvaluate(unittest.TestCase):