Extract the fan peak-tracking into observedPeakStore (observe max under load, hold >= minHold to reject spikes, round, persist JSON) and add a second instance for PSU draw (psu-observation.json, keyed by PSU ordinal). Fed from samplePSUPower like fans are from sampleFanSpeeds, so any full-load run refines it — the Fan Ceiling Check (which also samples PSU power at a slow cadence off its loop and writes psu_<i>_peak_w), a burn, thermal cycling, and the 5s web metrics collector. /topo PSU cards now scale the load fill by wattage_w when the BMC reports it, else by the observed peak draw — marked "~N% load". This MSI stand's BMC gives only instantaneous input power, so the observed peak is the only capacity figure available. Fan behaviour is unchanged (tests exercise updateFanObservation / estimateFanDutyCyclePctFromObservation / ResolveFanMaxRPM through the new store). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VHG21rgTUiR1G3qFHTVmN
227 lines
6.6 KiB
Go
227 lines
6.6 KiB
Go
package platform
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// resetPeakStore points a store at a fresh temp file and clears its cache for
|
|
// the duration of the test.
|
|
func resetPeakStore(t *testing.T, s *observedPeakStore) {
|
|
t.Helper()
|
|
old := *s
|
|
s.path = filepath.Join(t.TempDir(), "peaks.json")
|
|
s.loaded = false
|
|
s.peaks = nil
|
|
s.candidates = nil
|
|
t.Cleanup(func() { *s = old })
|
|
}
|
|
|
|
func TestResolveFanMaxRPM(t *testing.T) {
|
|
resetPeakStore(t, fanPeaks)
|
|
|
|
// No persisted file yet: unknown fans fall back to their own current RPM.
|
|
got := ResolveFanMaxRPM(map[string]float64{"A": 4000, "B": 9000})
|
|
if !reflect.DeepEqual(got, map[string]float64{"A": 4000, "B": 9000}) {
|
|
t.Fatalf("no-persist fallback: got %v", got)
|
|
}
|
|
|
|
if err := os.WriteFile(fanPeaks.path, []byte(`{"max_rpm":{"A":17000}}`), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got = ResolveFanMaxRPM(map[string]float64{"A": 4000, "B": 9000, "C": 5000})
|
|
// A: persisted peak. B/C: no own entry -> largest peer peak (A's 17000).
|
|
if want := map[string]float64{"A": 17000, "B": 17000, "C": 17000}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("peer fallback: got %v want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestObservedPeakStore(t *testing.T) {
|
|
s := &observedPeakStore{
|
|
path: filepath.Join(t.TempDir(), "peaks.json"),
|
|
jsonKey: "max_w",
|
|
roundUp: 50,
|
|
minHold: time.Second,
|
|
}
|
|
t0 := time.Unix(0, 0)
|
|
|
|
// A single spike does not stick.
|
|
s.observe(map[string]float64{"0": 900}, t0)
|
|
if len(s.snapshot()) != 0 {
|
|
t.Fatalf("transient spike should not persist: %v", s.snapshot())
|
|
}
|
|
// Held past minHold → sticks, rounded up to the next 50.
|
|
s.observe(map[string]float64{"0": 920}, t0.Add(1200*time.Millisecond))
|
|
if got := s.snapshot()["0"]; got != 950 {
|
|
t.Fatalf("held peak: got %v want 950", got)
|
|
}
|
|
// A lower reading never lowers the peak.
|
|
s.observe(map[string]float64{"0": 400}, t0.Add(5*time.Second))
|
|
if got := s.snapshot()["0"]; got != 950 {
|
|
t.Fatalf("peak must not drop: got %v", got)
|
|
}
|
|
// Fresh store reloads from disk.
|
|
s2 := &observedPeakStore{path: s.path, jsonKey: "max_w"}
|
|
if got := s2.snapshot()["0"]; got != 950 {
|
|
t.Fatalf("reload from disk: got %v want 950", got)
|
|
}
|
|
}
|
|
|
|
func TestUpdatePSUObservationKeyedByOrdinal(t *testing.T) {
|
|
resetPeakStore(t, psuPeaks)
|
|
now := time.Unix(0, 0)
|
|
psus := []PSUReading{{Name: "PSU1", PowerW: 1200}, {Name: "PSU2", PowerW: 1400}}
|
|
updatePSUObservation(psus, now)
|
|
updatePSUObservation(psus, now.Add(1200*time.Millisecond))
|
|
got := ObservedPSUMaxW()
|
|
if got["0"] != 1200 || got["1"] != 1400 {
|
|
t.Fatalf("keyed-by-ordinal peaks: got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyFanCheckDefaults(t *testing.T) {
|
|
var o FanCheckOptions
|
|
applyFanCheckDefaults(&o)
|
|
if o.PlateauHoldSec != 60 || o.PlateauDeltaRPM != 50 || o.MinLoadSec != 90 || o.MaxLoadSec != 900 || o.RampConfirmRPM != 150 {
|
|
t.Fatalf("unexpected defaults: %+v", o)
|
|
}
|
|
o = FanCheckOptions{PlateauHoldSec: 120, MinLoadSec: 30, MaxLoadSec: 40}
|
|
applyFanCheckDefaults(&o)
|
|
if o.MinLoadSec < o.PlateauHoldSec {
|
|
t.Fatalf("MinLoadSec must be >= PlateauHoldSec, got %d", o.MinLoadSec)
|
|
}
|
|
if o.MaxLoadSec <= o.MinLoadSec {
|
|
t.Fatalf("MaxLoadSec must exceed MinLoadSec, got %d", o.MaxLoadSec)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeSummaryKey(t *testing.T) {
|
|
for in, want := range map[string]string{
|
|
"F2U-1": "F2U-1",
|
|
"aspeed / fan1": "aspeed___fan1",
|
|
"CPU0_DIMM": "CPU0_DIMM",
|
|
"weird=key here": "weird_key_here",
|
|
} {
|
|
if got := sanitizeSummaryKey(in); got != want {
|
|
t.Errorf("sanitizeSummaryKey(%q)=%q want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseFanSpeeds(t *testing.T) {
|
|
raw := "FAN1 | 2400.000 | RPM | ok\nFAN2 | 1800 RPM | ok | ok\nFAN3 | na | RPM | ns\n"
|
|
got := parseFanSpeeds(raw)
|
|
if len(got) != 2 {
|
|
t.Fatalf("fans=%d want 2 (%v)", len(got), got)
|
|
}
|
|
if got[0].Name != "FAN1" || got[0].RPM != 2400 {
|
|
t.Fatalf("fan0=%+v", got[0])
|
|
}
|
|
if got[1].Name != "FAN2" || got[1].RPM != 1800 {
|
|
t.Fatalf("fan1=%+v", got[1])
|
|
}
|
|
}
|
|
|
|
func TestFirstFanInputValue(t *testing.T) {
|
|
feature := map[string]any{
|
|
"fan1_input": 9200.0,
|
|
}
|
|
got, ok := firstFanInputValue(feature)
|
|
if !ok || got != 9200 {
|
|
t.Fatalf("got=%v ok=%v", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestParseFanDutyCyclePctSensorsJSON(t *testing.T) {
|
|
raw := []byte(`{
|
|
"chip0": {
|
|
"fan1": {"input": 9000},
|
|
"pwm1": {"input": 128},
|
|
"pwm1_enable": {"input": 1}
|
|
},
|
|
"chip1": {
|
|
"pwm2": {"input": 64}
|
|
}
|
|
}`)
|
|
|
|
got, ok := parseFanDutyCyclePctSensorsJSON(raw)
|
|
if !ok {
|
|
t.Fatalf("expected duty cycle telemetry to be parsed")
|
|
}
|
|
if got < 57 || got > 58 {
|
|
t.Fatalf("got=%v want ~57.1", got)
|
|
}
|
|
}
|
|
|
|
func TestEstimateFanDutyCyclePctFromObservation(t *testing.T) {
|
|
resetPeakStore(t, fanPeaks)
|
|
|
|
start := time.Unix(100, 0)
|
|
updateFanObservation([]FanReading{{Name: "FAN1", RPM: 5000}}, start)
|
|
if _, ok := estimateFanDutyCyclePctFromObservation([]FanReading{{Name: "FAN1", RPM: 2500}}); ok {
|
|
t.Fatalf("single-sample spike should not establish observed max")
|
|
}
|
|
|
|
updateFanObservation([]FanReading{{Name: "FAN1", RPM: 5200}}, start.Add(500*time.Millisecond))
|
|
updateFanObservation([]FanReading{{Name: "FAN1", RPM: 5100}}, start.Add(1500*time.Millisecond))
|
|
|
|
got, ok := estimateFanDutyCyclePctFromObservation([]FanReading{{Name: "FAN1", RPM: 2600}})
|
|
if !ok {
|
|
t.Fatalf("expected estimated duty cycle from persisted observed max")
|
|
}
|
|
if got < 43 || got > 44 {
|
|
t.Fatalf("got=%v want ~43.3", got)
|
|
}
|
|
|
|
fanPeaks.loaded = false
|
|
fanPeaks.peaks = nil
|
|
fanPeaks.candidates = nil
|
|
got, ok = estimateFanDutyCyclePctFromObservation([]FanReading{{Name: "FAN1", RPM: 2600}})
|
|
if !ok {
|
|
t.Fatalf("expected persisted observed max to be reloaded from disk")
|
|
}
|
|
if got < 43 || got > 44 {
|
|
t.Fatalf("reloaded got=%v want ~43.3", got)
|
|
}
|
|
}
|
|
|
|
func TestParseDCMIPowerReading(t *testing.T) {
|
|
raw := `
|
|
Instantaneous power reading: 512 Watts
|
|
Minimum during sampling period: 498 Watts
|
|
`
|
|
if got := parseDCMIPowerReading(raw); got != 512 {
|
|
t.Fatalf("parseDCMIPowerReading()=%v want 512", got)
|
|
}
|
|
}
|
|
|
|
func TestEffectiveSystemPowerReading(t *testing.T) {
|
|
now := time.Now()
|
|
cache := cachedPowerReading{Value: 480, UpdatedAt: now.Add(-5 * time.Second)}
|
|
|
|
got, updated := effectiveSystemPowerReading(cache, 0, "", "", "", now)
|
|
if got != 480 {
|
|
t.Fatalf("got=%v want cached 480", got)
|
|
}
|
|
if updated.Value != 480 {
|
|
t.Fatalf("updated=%+v", updated)
|
|
}
|
|
|
|
got, updated = effectiveSystemPowerReading(cache, 530, "dcmi", "fallback", "test", now)
|
|
if got != 530 {
|
|
t.Fatalf("got=%v want 530", got)
|
|
}
|
|
if updated.Value != 530 {
|
|
t.Fatalf("updated=%+v", updated)
|
|
}
|
|
|
|
expired := cachedPowerReading{Value: 480, UpdatedAt: now.Add(-systemPowerHoldTTL - time.Second)}
|
|
got, _ = effectiveSystemPowerReading(expired, 0, "", "", "", now)
|
|
if got != 0 {
|
|
t.Fatalf("expired cache returned %v want 0", got)
|
|
}
|
|
}
|