From f7c5ffad32a7043b470a46995e83ca6ba161b6c5 Mon Sep 17 00:00:00 2001 From: neoden Date: Mon, 26 Jan 2026 22:04:20 +0200 Subject: [PATCH 1/2] add EMA smoothing to CPU and temperature widgets --- config.example.toml | 6 +- config/config.go | 36 ++++++----- config/config_test.go | 61 +++++++++++++++++-- util/ema.go | 48 +++++++++++++++ util/ema_test.go | 108 +++++++++++++++++++++++++++++++++ widgets/cpu.go | 59 +++++++++--------- widgets/cpu_test.go | 134 +++++++++++++++++++---------------------- widgets/temperature.go | 18 +++++- 8 files changed, 344 insertions(+), 126 deletions(-) create mode 100644 util/ema.go create mode 100644 util/ema_test.go diff --git a/config.example.toml b/config.example.toml index d37227d..d39c004 100644 --- a/config.example.toml +++ b/config.example.toml @@ -38,8 +38,8 @@ enabled = true show_above = 50 # show per-core info when any core above this % show_core_above = 95 -# averaging window in seconds (smooths out spikes) -average_seconds = 5 +# smoothing window in seconds (EMA-based, reduces flickering) +smoothing_interval_seconds = 3 # urgent when above this % urgent_above = 95 @@ -102,3 +102,5 @@ interfaces = ["tun*", "wg*", "tap*", "cscotun*"] # label = "CPU" # show_above = 75 # urgent_above = 90 +# smoothing window in seconds (EMA-based, reduces flickering, default 3) +# smoothing_interval_seconds = 3 diff --git a/config/config.go b/config/config.go index ec64fa9..fae5045 100644 --- a/config/config.go +++ b/config/config.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "path/filepath" @@ -24,11 +25,11 @@ type ClockConfig struct { } type CPUConfig struct { - Enabled bool `toml:"enabled"` - ShowAbove int `toml:"show_above"` - ShowCoreAbove int `toml:"show_core_above"` - AverageSeconds int `toml:"average_seconds"` - UrgentAbove int `toml:"urgent_above"` + Enabled bool `toml:"enabled"` + ShowAbove int `toml:"show_above"` + ShowCoreAbove int `toml:"show_core_above"` + SmoothingIntervalSeconds int `toml:"smoothing_interval_seconds"` + UrgentAbove int `toml:"urgent_above"` } type RAMConfig struct { @@ -62,10 +63,11 @@ type VPNConfig struct { } type TemperatureConfig struct { - Path string `toml:"path"` - Label string `toml:"label"` - ShowAbove int `toml:"show_above"` - UrgentAbove int `toml:"urgent_above"` + Path string `toml:"path"` + Label string `toml:"label"` + ShowAbove int `toml:"show_above"` + UrgentAbove int `toml:"urgent_above"` + SmoothingIntervalSeconds int `toml:"smoothing_interval_seconds"` } type Config struct { @@ -97,11 +99,11 @@ func Default() *Config { Enabled: true, }, CPU: CPUConfig{ - Enabled: true, - ShowAbove: 50, - ShowCoreAbove: 95, - AverageSeconds: 5, - UrgentAbove: 95, + Enabled: true, + ShowAbove: 50, + ShowCoreAbove: 95, + SmoothingIntervalSeconds: 3, + UrgentAbove: 95, }, RAM: RAMConfig{ Enabled: true, @@ -147,10 +149,14 @@ func Load() (*Config, error) { return cfg, nil } - _, err = toml.DecodeFile(configPath, cfg) + meta, err := toml.DecodeFile(configPath, cfg) if err != nil { return cfg, err } + for _, key := range meta.Undecoded() { + fmt.Fprintf(os.Stderr, "config: unknown key %q\n", key.String()) + } + return cfg, nil } diff --git a/config/config_test.go b/config/config_test.go index d347ee4..d7bc947 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) @@ -46,8 +47,8 @@ func TestDefault(t *testing.T) { if cfg.CPU.ShowCoreAbove != 95 { t.Errorf("CPU.ShowCoreAbove = %d, want 95", cfg.CPU.ShowCoreAbove) } - if cfg.CPU.AverageSeconds != 5 { - t.Errorf("CPU.AverageSeconds = %d, want 5", cfg.CPU.AverageSeconds) + if cfg.CPU.SmoothingIntervalSeconds != 3 { + t.Errorf("CPU.SmoothingIntervalSeconds = %d, want 3", cfg.CPU.SmoothingIntervalSeconds) } if cfg.CPU.UrgentAbove != 95 { t.Errorf("CPU.UrgentAbove = %d, want 95", cfg.CPU.UrgentAbove) @@ -152,7 +153,7 @@ func TestLoad_WithConfigFile(t *testing.T) { [cpu] enabled = false show_above = 75 -average_seconds = 10 +smoothing_interval_seconds = 10 [battery] urgent_below = 15 @@ -178,8 +179,8 @@ show_below = 10 if cfg.CPU.ShowAbove != 75 { t.Errorf("CPU.ShowAbove = %d, want 75", cfg.CPU.ShowAbove) } - if cfg.CPU.AverageSeconds != 10 { - t.Errorf("CPU.AverageSeconds = %d, want 10", cfg.CPU.AverageSeconds) + if cfg.CPU.SmoothingIntervalSeconds != 10 { + t.Errorf("CPU.SmoothingIntervalSeconds = %d, want 10", cfg.CPU.SmoothingIntervalSeconds) } if cfg.Battery.UrgentBelow != 15 { t.Errorf("Battery.UrgentBelow = %d, want 15", cfg.Battery.UrgentBelow) @@ -214,3 +215,53 @@ func TestLoad_InvalidToml(t *testing.T) { t.Error("Load() expected error for invalid TOML, got nil") } } + +func TestLoad_UnknownKeys(t *testing.T) { + origXDG := os.Getenv("XDG_CONFIG_HOME") + defer os.Setenv("XDG_CONFIG_HOME", origXDG) + + tmpDir := t.TempDir() + os.Setenv("XDG_CONFIG_HOME", tmpDir) + + configDir := filepath.Join(tmpDir, "h2status") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatalf("Failed to create config dir: %v", err) + } + + // Config with unknown keys + configContent := ` +[cpu] +enabled = true +unknown_key = "value" +average_seconds = 5 +` + configPath := filepath.Join(configDir, "config.toml") + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("Failed to write config: %v", err) + } + + // Capture stderr + origStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + _, err := Load() + + w.Close() + os.Stderr = origStderr + + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + var buf [1024]byte + n, _ := r.Read(buf[:]) + output := string(buf[:n]) + + if !strings.Contains(output, "cpu.unknown_key") { + t.Errorf("expected warning about cpu.unknown_key, got: %s", output) + } + if !strings.Contains(output, "cpu.average_seconds") { + t.Errorf("expected warning about cpu.average_seconds (renamed), got: %s", output) + } +} diff --git a/util/ema.go b/util/ema.go new file mode 100644 index 0000000..81a06ed --- /dev/null +++ b/util/ema.go @@ -0,0 +1,48 @@ +package util + +// EMA implements exponential moving average smoothing. +// Alpha is calculated from period: α = 2 / (period + 1) +type EMA struct { + alpha float64 + value float64 + primed bool + samples int + period int +} + +// NewEMA creates a new EMA smoother with the given period. +// Period corresponds to the "average_seconds" config parameter. +func NewEMA(period int) *EMA { + if period < 1 { + period = 1 + } + return &EMA{ + alpha: 2.0 / float64(period+1), + period: period, + } +} + +// Update adds a new sample and returns the smoothed value. +func (e *EMA) Update(value float64) float64 { + if !e.primed { + e.value = value + e.primed = true + e.samples = 1 + } else { + e.value = e.alpha*value + (1-e.alpha)*e.value + if e.samples < e.period { + e.samples++ + } + } + return e.value +} + +// Value returns the current smoothed value. +func (e *EMA) Value() float64 { + return e.value +} + +// Ready returns true when enough samples have been collected. +func (e *EMA) Ready() bool { + return e.samples >= e.period +} diff --git a/util/ema_test.go b/util/ema_test.go new file mode 100644 index 0000000..f6d352a --- /dev/null +++ b/util/ema_test.go @@ -0,0 +1,108 @@ +package util + +import ( + "math" + "testing" +) + +func TestNewEMA(t *testing.T) { + tests := []struct { + period int + expectedAlpha float64 + }{ + {1, 1.0}, // α = 2/(1+1) = 1 + {2, 2.0 / 3.0}, // α = 2/(2+1) = 0.667 + {5, 2.0 / 6.0}, // α = 2/(5+1) = 0.333 + {10, 2.0 / 11.0}, // α = 2/(10+1) = 0.182 + {0, 1.0}, // period < 1 should default to 1 + {-1, 1.0}, // period < 1 should default to 1 + } + + for _, tt := range tests { + ema := NewEMA(tt.period) + if math.Abs(ema.alpha-tt.expectedAlpha) > 0.001 { + t.Errorf("NewEMA(%d).alpha = %f, want %f", tt.period, ema.alpha, tt.expectedAlpha) + } + } +} + +func TestEMA_Update(t *testing.T) { + ema := NewEMA(1) // α = 1, so EMA = current value + + // With α = 1, EMA should equal current value + result := ema.Update(10) + if result != 10 { + t.Errorf("Update(10) = %f, want 10", result) + } + + result = ema.Update(20) + if result != 20 { + t.Errorf("Update(20) = %f, want 20", result) + } +} + +func TestEMA_Smoothing(t *testing.T) { + ema := NewEMA(5) // α = 0.333 + + // First value sets the baseline + ema.Update(100) + if ema.Value() != 100 { + t.Errorf("First update should set value directly, got %f", ema.Value()) + } + + // Sudden spike should be smoothed + ema.Update(200) + if ema.Value() >= 200 || ema.Value() <= 100 { + t.Errorf("Spike should be smoothed, got %f", ema.Value()) + } + + // Continue updating with high value - should approach it + for i := 0; i < 20; i++ { + ema.Update(200) + } + if ema.Value() < 195 { + t.Errorf("After many updates, should approach 200, got %f", ema.Value()) + } +} + +func TestEMA_Ready(t *testing.T) { + ema := NewEMA(3) + + if ema.Ready() { + t.Error("Should not be ready before any updates") + } + + ema.Update(10) + if ema.Ready() { + t.Error("Should not be ready after 1 update (need 3)") + } + + ema.Update(20) + if ema.Ready() { + t.Error("Should not be ready after 2 updates (need 3)") + } + + ema.Update(30) + if !ema.Ready() { + t.Error("Should be ready after 3 updates") + } + + ema.Update(40) + if !ema.Ready() { + t.Error("Should still be ready after 4 updates") + } +} + +func TestEMA_Value(t *testing.T) { + ema := NewEMA(2) + + // Before any updates, value should be 0 + if ema.Value() != 0 { + t.Errorf("Value before updates should be 0, got %f", ema.Value()) + } + + ema.Update(50) + if ema.Value() != 50 { + t.Errorf("Value after first update should be 50, got %f", ema.Value()) + } +} diff --git a/widgets/cpu.go b/widgets/cpu.go index 50eb1e4..acd7ea8 100644 --- a/widgets/cpu.go +++ b/widgets/cpu.go @@ -3,6 +3,7 @@ package widgets import ( "bufio" "fmt" + "math" "strconv" "strings" @@ -10,6 +11,7 @@ import ( "neoden/h2status/config" "neoden/h2status/swaybar" + "neoden/h2status/util" ) type CPUSnapshot struct { @@ -34,14 +36,15 @@ type CPU struct { fs afero.Fs cfg config.CPUConfig prevSnapshot *CPUSnapshot - history []CPUUsage + totalEMA *util.EMA + coreEMAs []*util.EMA } func NewCPU(cfg config.CPUConfig, fs afero.Fs) *CPU { return &CPU{ - fs: fs, - cfg: cfg, - history: make([]CPUUsage, 0, cfg.AverageSeconds), + fs: fs, + cfg: cfg, + totalEMA: util.NewEMA(cfg.SmoothingIntervalSeconds), } } @@ -54,41 +57,39 @@ func (c *CPU) Update() { if c.prevSnapshot != nil { usage := calcUsage(c.prevSnapshot, snapshot) - c.addToHistory(usage) - } - c.prevSnapshot = snapshot -} + // Initialize core EMAs if needed + if c.coreEMAs == nil { + c.coreEMAs = make([]*util.EMA, len(usage.PerCore)) + for i := range c.coreEMAs { + c.coreEMAs[i] = util.NewEMA(c.cfg.SmoothingIntervalSeconds) + } + } -func (c *CPU) addToHistory(usage CPUUsage) { - if len(c.history) >= c.cfg.AverageSeconds { - copy(c.history, c.history[1:]) - c.history = c.history[:c.cfg.AverageSeconds-1] + // Update EMAs + c.totalEMA.Update(usage.Total) + for i, core := range usage.PerCore { + if i < len(c.coreEMAs) { + c.coreEMAs[i].Update(core) + } + } } - c.history = append(c.history, usage) + + c.prevSnapshot = snapshot } func (c *CPU) GetAverageUsage() *CPUUsage { - if len(c.history) < c.cfg.AverageSeconds { + if !c.totalEMA.Ready() { return nil } - numCores := len(c.history[0].PerCore) avg := CPUUsage{ - PerCore: make([]float64, numCores), - } - - for _, u := range c.history { - avg.Total += u.Total - for i, core := range u.PerCore { - avg.PerCore[i] += core - } + Total: c.totalEMA.Value(), + PerCore: make([]float64, len(c.coreEMAs)), } - n := float64(len(c.history)) - avg.Total /= n - for i := range avg.PerCore { - avg.PerCore[i] /= n + for i, ema := range c.coreEMAs { + avg.PerCore[i] = ema.Value() } return &avg @@ -121,9 +122,9 @@ func (c *CPU) GetBlock() string { var text string if showCores { - text = fmt.Sprintf("\uf2db %d%% (%d@%d%%)", int(avg.Total), hotCores, int(maxCore)) + text = fmt.Sprintf("\uf2db %d%% (%d@%d%%)", int(math.Round(avg.Total)), hotCores, int(math.Round(maxCore))) } else { - text = fmt.Sprintf("\uf2db %d%%", int(avg.Total)) + text = fmt.Sprintf("\uf2db %d%%", int(math.Round(avg.Total))) } urgent := avg.Total > float64(c.cfg.UrgentAbove) diff --git a/widgets/cpu_test.go b/widgets/cpu_test.go index 505676f..b03dd2d 100644 --- a/widgets/cpu_test.go +++ b/widgets/cpu_test.go @@ -1,12 +1,14 @@ package widgets import ( + "fmt" "math" "testing" "github.com/spf13/afero" "neoden/h2status/config" + "neoden/h2status/util" ) func TestParseCPUTime(t *testing.T) { @@ -188,76 +190,48 @@ func TestCalcUsage(t *testing.T) { } } -func TestCPU_AddToHistory(t *testing.T) { - cfg := config.CPUConfig{AverageSeconds: 3} - cpu := NewCPU(cfg, afero.NewMemMapFs()) - // Add first usage - cpu.addToHistory(CPUUsage{Total: 10, PerCore: []float64{10}}) - if len(cpu.history) != 1 { - t.Errorf("history length = %d, want 1", len(cpu.history)) - } - - // Add second usage - cpu.addToHistory(CPUUsage{Total: 20, PerCore: []float64{20}}) - if len(cpu.history) != 2 { - t.Errorf("history length = %d, want 2", len(cpu.history)) - } - - // Add third usage (at capacity) - cpu.addToHistory(CPUUsage{Total: 30, PerCore: []float64{30}}) - if len(cpu.history) != 3 { - t.Errorf("history length = %d, want 3", len(cpu.history)) - } - - // Add fourth usage (should evict first) - cpu.addToHistory(CPUUsage{Total: 40, PerCore: []float64{40}}) - if len(cpu.history) != 3 { - t.Errorf("history length = %d, want 3", len(cpu.history)) - } +func TestCPU_GetAverageUsage(t *testing.T) { + fs := afero.NewMemMapFs() - // Check oldest was evicted - if cpu.history[0].Total != 20 { - t.Errorf("history[0].Total = %f, want 20", cpu.history[0].Total) - } - if cpu.history[2].Total != 40 { - t.Errorf("history[2].Total = %f, want 40", cpu.history[2].Total) - } -} + // Create proc/stat with 2 cores + afero.WriteFile(fs, "/proc/stat", []byte(`cpu 1000 0 0 1000 0 0 0 0 0 0 +cpu0 500 0 0 500 0 0 0 0 0 0 +cpu1 500 0 0 500 0 0 0 0 0 0 +`), 0644) -func TestCPU_GetAverageUsage(t *testing.T) { - cfg := config.CPUConfig{AverageSeconds: 3} - cpu := NewCPU(cfg, afero.NewMemMapFs()) + cfg := config.CPUConfig{SmoothingIntervalSeconds: 3} + cpu := NewCPU(cfg, fs) - // Not enough history + // First update - just saves snapshot + cpu.Update() avg := cpu.GetAverageUsage() if avg != nil { - t.Error("GetAverageUsage() should return nil when history is not full") + t.Error("GetAverageUsage() should return nil before EMA is ready") } - // Fill history - cpu.addToHistory(CPUUsage{Total: 10, PerCore: []float64{5, 15}}) - cpu.addToHistory(CPUUsage{Total: 20, PerCore: []float64{10, 30}}) - cpu.addToHistory(CPUUsage{Total: 30, PerCore: []float64{15, 45}}) + // Simulate updates with 50% usage + for i := 0; i < 3; i++ { + // Update stat file - double all values each time + prev := 1000 * (i + 1) + curr := 1000 * (i + 2) + content := []byte(fmt.Sprintf(`cpu %d 0 0 %d 0 0 0 0 0 0 +cpu0 %d 0 0 %d 0 0 0 0 0 0 +cpu1 %d 0 0 %d 0 0 0 0 0 0 +`, curr, curr, curr/2, curr/2, curr/2, curr/2)) + afero.WriteFile(fs, "/proc/stat", content, 0644) + _ = prev + cpu.Update() + } avg = cpu.GetAverageUsage() if avg == nil { - t.Fatal("GetAverageUsage() returned nil") + t.Fatal("GetAverageUsage() returned nil after enough samples") } - // Average of 10, 20, 30 = 20 - if math.Abs(avg.Total-20.0) > 0.001 { - t.Errorf("GetAverageUsage().Total = %f, want 20.0", avg.Total) - } - - // Average of core 0: (5 + 10 + 15) / 3 = 10 - if math.Abs(avg.PerCore[0]-10.0) > 0.001 { - t.Errorf("GetAverageUsage().PerCore[0] = %f, want 10.0", avg.PerCore[0]) - } - - // Average of core 1: (15 + 30 + 45) / 3 = 30 - if math.Abs(avg.PerCore[1]-30.0) > 0.001 { - t.Errorf("GetAverageUsage().PerCore[1] = %f, want 30.0", avg.PerCore[1]) + // Should be around 50% (active == idle) + if avg.Total < 45 || avg.Total > 55 { + t.Errorf("GetAverageUsage().Total = %f, want ~50", avg.Total) } } @@ -270,16 +244,16 @@ cpu0 500 100 150 2000 0 0 0 0 0 0 cpu1 500 100 150 2000 0 0 0 0 0 0 `), 0644) - cfg := config.CPUConfig{AverageSeconds: 1} + cfg := config.CPUConfig{SmoothingIntervalSeconds: 1} cpu := NewCPU(cfg, fs) cpu.Update() - // First update just saves snapshot, no history yet + // First update just saves snapshot, no EMA values yet if cpu.prevSnapshot == nil { t.Fatal("prevSnapshot should not be nil after first Update") } - if len(cpu.history) != 0 { - t.Errorf("history length = %d, want 0 after first update", len(cpu.history)) + if cpu.coreEMAs != nil { + t.Error("coreEMAs should be nil after first update") } // Second snapshot - 50% usage (idle doubled, total doubled) @@ -290,14 +264,17 @@ cpu1 1000 200 300 4000 0 0 0 0 0 0 cpu.Update() - if len(cpu.history) != 1 { - t.Fatalf("history length = %d, want 1 after second update", len(cpu.history)) + if cpu.coreEMAs == nil { + t.Fatal("coreEMAs should not be nil after second update") + } + if len(cpu.coreEMAs) != 2 { + t.Errorf("coreEMAs length = %d, want 2", len(cpu.coreEMAs)) } // Total: (2000-1000 + 400-200 + 600-300) / (11000-5500) = 1500/5500 ≈ 27.3% expectedUsage := 100 * float64(1500) / float64(5500) - if math.Abs(cpu.history[0].Total-expectedUsage) > 0.1 { - t.Errorf("history[0].Total = %f, want ~%f", cpu.history[0].Total, expectedUsage) + if math.Abs(cpu.totalEMA.Value()-expectedUsage) > 0.1 { + t.Errorf("totalEMA.Value() = %f, want ~%f", cpu.totalEMA.Value(), expectedUsage) } } @@ -397,11 +374,23 @@ func TestCPU_GetBlock(t *testing.T) { ShowAbove: tt.showAbove, ShowCoreAbove: tt.showCoreAbove, UrgentAbove: tt.urgentAbove, - AverageSeconds: 1, + SmoothingIntervalSeconds: 1, + } + + // Create CPU with primed EMAs + totalEMA := util.NewEMA(1) + totalEMA.Update(tt.total) + + coreEMAs := make([]*util.EMA, len(tt.perCore)) + for i, v := range tt.perCore { + coreEMAs[i] = util.NewEMA(1) + coreEMAs[i].Update(v) } + cpu := &CPU{ - cfg: cfg, - history: []CPUUsage{{Total: tt.total, PerCore: tt.perCore}}, + cfg: cfg, + totalEMA: totalEMA, + coreEMAs: coreEMAs, } block := cpu.GetBlock() @@ -422,14 +411,15 @@ func TestCPU_GetBlock(t *testing.T) { } } -func TestCPU_GetBlock_NoHistory(t *testing.T) { +func TestCPU_GetBlock_NotReady(t *testing.T) { + // EMA not ready (not enough samples) cpu := &CPU{ - cfg: config.CPUConfig{AverageSeconds: 3}, - history: []CPUUsage{}, // not enough history + cfg: config.CPUConfig{SmoothingIntervalSeconds: 3}, + totalEMA: util.NewEMA(3), // needs 3 samples to be ready } block := cpu.GetBlock() if block != "" { - t.Errorf("GetBlock() = %q, want empty when no history", block) + t.Errorf("GetBlock() = %q, want empty when EMA not ready", block) } } diff --git a/widgets/temperature.go b/widgets/temperature.go index 3b23631..66d9f4c 100644 --- a/widgets/temperature.go +++ b/widgets/temperature.go @@ -12,6 +12,7 @@ import ( "neoden/h2status/config" "neoden/h2status/swaybar" + "neoden/h2status/util" ) type TempSensor struct { @@ -19,7 +20,8 @@ type TempSensor struct { Label string ShowAbove int UrgentAbove int - Value int // current temperature in C + Value int // smoothed temperature in C + ema *util.EMA } type Temperature struct { @@ -27,17 +29,24 @@ type Temperature struct { sensors []TempSensor } +const defaultSmoothingInterval = 3 + func NewTemperature(cfgs []config.TemperatureConfig, fs afero.Fs) *Temperature { t := &Temperature{fs: fs} if len(cfgs) > 0 { // Use configured sensors for _, tc := range cfgs { + interval := tc.SmoothingIntervalSeconds + if interval == 0 { + interval = defaultSmoothingInterval + } t.sensors = append(t.sensors, TempSensor{ Path: tc.Path, Label: tc.Label, ShowAbove: tc.ShowAbove, UrgentAbove: tc.UrgentAbove, + ema: util.NewEMA(interval), }) } } else { @@ -147,6 +156,7 @@ func (t *Temperature) autoDetectSensors() []TempSensor { Label: label, ShowAbove: 75, UrgentAbove: 90, + ema: util.NewEMA(defaultSmoothingInterval), }) return sensors } @@ -169,6 +179,7 @@ func (t *Temperature) autoDetectSensors() []TempSensor { Label: zoneType, ShowAbove: 75, UrgentAbove: 90, + ema: util.NewEMA(defaultSmoothingInterval), }) return sensors } @@ -191,8 +202,9 @@ func (t *Temperature) Update() { continue } - // Convert from millidegrees to degrees - t.sensors[i].Value = val / 1000 + // Convert from millidegrees to degrees and apply EMA smoothing + rawTemp := float64(val) / 1000 + t.sensors[i].Value = int(t.sensors[i].ema.Update(rawTemp)) } } From d3261f4b78af01b4bc65d2c8e5a0ed2b2d575971 Mon Sep 17 00:00:00 2001 From: neoden Date: Mon, 26 Jan 2026 22:18:58 +0200 Subject: [PATCH 2/2] review fixes --- util/ema.go | 2 +- widgets/cpu.go | 24 ++++++----- widgets/cpu_test.go | 2 - widgets/temperature.go | 11 +++--- widgets/temperature_test.go | 79 +++++++++++++++++++++++++++++++++++++ widgets/widget.go | 3 ++ 6 files changed, 103 insertions(+), 18 deletions(-) diff --git a/util/ema.go b/util/ema.go index 81a06ed..8303055 100644 --- a/util/ema.go +++ b/util/ema.go @@ -11,7 +11,7 @@ type EMA struct { } // NewEMA creates a new EMA smoother with the given period. -// Period corresponds to the "average_seconds" config parameter. +// Period corresponds to the "smoothing_interval_seconds" config parameter. func NewEMA(period int) *EMA { if period < 1 { period = 1 diff --git a/widgets/cpu.go b/widgets/cpu.go index acd7ea8..e21ceb0 100644 --- a/widgets/cpu.go +++ b/widgets/cpu.go @@ -33,18 +33,24 @@ type CPUUsage struct { } type CPU struct { - fs afero.Fs - cfg config.CPUConfig - prevSnapshot *CPUSnapshot - totalEMA *util.EMA - coreEMAs []*util.EMA + fs afero.Fs + cfg config.CPUConfig + prevSnapshot *CPUSnapshot + smoothingInterval int + totalEMA *util.EMA + coreEMAs []*util.EMA } func NewCPU(cfg config.CPUConfig, fs afero.Fs) *CPU { + interval := cfg.SmoothingIntervalSeconds + if interval == 0 { + interval = DefaultSmoothingInterval + } return &CPU{ - fs: fs, - cfg: cfg, - totalEMA: util.NewEMA(cfg.SmoothingIntervalSeconds), + fs: fs, + cfg: cfg, + smoothingInterval: interval, + totalEMA: util.NewEMA(interval), } } @@ -62,7 +68,7 @@ func (c *CPU) Update() { if c.coreEMAs == nil { c.coreEMAs = make([]*util.EMA, len(usage.PerCore)) for i := range c.coreEMAs { - c.coreEMAs[i] = util.NewEMA(c.cfg.SmoothingIntervalSeconds) + c.coreEMAs[i] = util.NewEMA(c.smoothingInterval) } } diff --git a/widgets/cpu_test.go b/widgets/cpu_test.go index b03dd2d..10262eb 100644 --- a/widgets/cpu_test.go +++ b/widgets/cpu_test.go @@ -213,14 +213,12 @@ cpu1 500 0 0 500 0 0 0 0 0 0 // Simulate updates with 50% usage for i := 0; i < 3; i++ { // Update stat file - double all values each time - prev := 1000 * (i + 1) curr := 1000 * (i + 2) content := []byte(fmt.Sprintf(`cpu %d 0 0 %d 0 0 0 0 0 0 cpu0 %d 0 0 %d 0 0 0 0 0 0 cpu1 %d 0 0 %d 0 0 0 0 0 0 `, curr, curr, curr/2, curr/2, curr/2, curr/2)) afero.WriteFile(fs, "/proc/stat", content, 0644) - _ = prev cpu.Update() } diff --git a/widgets/temperature.go b/widgets/temperature.go index 66d9f4c..b749685 100644 --- a/widgets/temperature.go +++ b/widgets/temperature.go @@ -3,6 +3,7 @@ package widgets import ( "fmt" "io" + "math" "os" "path/filepath" "strconv" @@ -29,8 +30,6 @@ type Temperature struct { sensors []TempSensor } -const defaultSmoothingInterval = 3 - func NewTemperature(cfgs []config.TemperatureConfig, fs afero.Fs) *Temperature { t := &Temperature{fs: fs} @@ -39,7 +38,7 @@ func NewTemperature(cfgs []config.TemperatureConfig, fs afero.Fs) *Temperature { for _, tc := range cfgs { interval := tc.SmoothingIntervalSeconds if interval == 0 { - interval = defaultSmoothingInterval + interval = DefaultSmoothingInterval } t.sensors = append(t.sensors, TempSensor{ Path: tc.Path, @@ -156,7 +155,7 @@ func (t *Temperature) autoDetectSensors() []TempSensor { Label: label, ShowAbove: 75, UrgentAbove: 90, - ema: util.NewEMA(defaultSmoothingInterval), + ema: util.NewEMA(DefaultSmoothingInterval), }) return sensors } @@ -179,7 +178,7 @@ func (t *Temperature) autoDetectSensors() []TempSensor { Label: zoneType, ShowAbove: 75, UrgentAbove: 90, - ema: util.NewEMA(defaultSmoothingInterval), + ema: util.NewEMA(DefaultSmoothingInterval), }) return sensors } @@ -204,7 +203,7 @@ func (t *Temperature) Update() { // Convert from millidegrees to degrees and apply EMA smoothing rawTemp := float64(val) / 1000 - t.sensors[i].Value = int(t.sensors[i].ema.Update(rawTemp)) + t.sensors[i].Value = int(math.Round(t.sensors[i].ema.Update(rawTemp))) } } diff --git a/widgets/temperature_test.go b/widgets/temperature_test.go index a6c3e53..e172e1b 100644 --- a/widgets/temperature_test.go +++ b/widgets/temperature_test.go @@ -439,3 +439,82 @@ func TestPrintDetectedSensorsTo_ThermalZoneNoType(t *testing.T) { t.Errorf("output should not contain zone without type: %s", output) } } + +func TestTemperature_EMASmoothing(t *testing.T) { + fs := afero.NewMemMapFs() + afero.WriteFile(fs, "/sys/hwmon/temp1", []byte("50000\n"), 0644) // 50°C + + cfgs := []config.TemperatureConfig{ + {Path: "/sys/hwmon/temp1", Label: "CPU", ShowAbove: 40, UrgentAbove: 90, SmoothingIntervalSeconds: 3}, + } + + temp := NewTemperature(cfgs, fs) + temp.Update() + + // First update should set value directly + if temp.sensors[0].Value != 50 { + t.Errorf("First update: Value = %d, want 50", temp.sensors[0].Value) + } + + // Spike to 80°C - should be smoothed + afero.WriteFile(fs, "/sys/hwmon/temp1", []byte("80000\n"), 0644) + temp.Update() + + // Value should be between 50 and 80 due to smoothing + if temp.sensors[0].Value <= 50 || temp.sensors[0].Value >= 80 { + t.Errorf("After spike: Value = %d, want between 50 and 80", temp.sensors[0].Value) + } + + // Continue updating with 80°C - should converge + for i := 0; i < 20; i++ { + temp.Update() + } + + // Should be close to 80 now + if temp.sensors[0].Value < 78 { + t.Errorf("After convergence: Value = %d, want >= 78", temp.sensors[0].Value) + } +} + +func TestTemperature_EMARounding(t *testing.T) { + fs := afero.NewMemMapFs() + afero.WriteFile(fs, "/sys/hwmon/temp1", []byte("50000\n"), 0644) + + cfgs := []config.TemperatureConfig{ + {Path: "/sys/hwmon/temp1", Label: "CPU", ShowAbove: 40, UrgentAbove: 90, SmoothingIntervalSeconds: 1}, + } + + temp := NewTemperature(cfgs, fs) + temp.Update() + + // With period=1, alpha=1, so value should equal input + // 49.5°C should round to 50 + afero.WriteFile(fs, "/sys/hwmon/temp1", []byte("49500\n"), 0644) + temp.Update() + + if temp.sensors[0].Value != 50 { + t.Errorf("Value = %d, want 50 (rounded from 49.5)", temp.sensors[0].Value) + } +} + +func TestTemperature_DefaultSmoothingInterval(t *testing.T) { + fs := afero.NewMemMapFs() + afero.WriteFile(fs, "/sys/hwmon/temp1", []byte("50000\n"), 0644) + + // Config without SmoothingIntervalSeconds - should use default + cfgs := []config.TemperatureConfig{ + {Path: "/sys/hwmon/temp1", Label: "CPU", ShowAbove: 40, UrgentAbove: 90}, + } + + temp := NewTemperature(cfgs, fs) + + // Verify EMA was created (sensor has ema field) + if temp.sensors[0].ema == nil { + t.Error("EMA should be initialized with default interval") + } + + temp.Update() + if temp.sensors[0].Value != 50 { + t.Errorf("Value = %d, want 50", temp.sensors[0].Value) + } +} diff --git a/widgets/widget.go b/widgets/widget.go index ada3de6..305aa34 100644 --- a/widgets/widget.go +++ b/widgets/widget.go @@ -26,6 +26,9 @@ var Log = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ Level: slog.LevelInfo, })) +// Default smoothing interval for EMA in seconds +const DefaultSmoothingInterval = 3 + // Helper functions func FormatBytes(b uint64) string {