feat: autotune PSU capacity from the same full-load run as fan ceilings

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
This commit is contained in:
Mikhail Chusavitin
2026-09-04 12:53:13 +03:00
co-authored by Claude Sonnet 5
parent e713504fc9
commit 7ed652c5b1
7 changed files with 396 additions and 175 deletions
+61 -28
View File
@@ -8,15 +8,20 @@ import (
"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) {
oldPath := fanObservationStatePath
oldInit := fanObservationInit
fanObservationStatePath = filepath.Join(t.TempDir(), "fan-observation.json")
fanObservationInit = false
t.Cleanup(func() {
fanObservationStatePath = oldPath
fanObservationInit = oldInit
})
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})
@@ -24,7 +29,7 @@ func TestResolveFanMaxRPM(t *testing.T) {
t.Fatalf("no-persist fallback: got %v", got)
}
if err := os.WriteFile(fanObservationStatePath, []byte(`{"max_rpm":{"A":17000}}`), 0644); err != nil {
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})
@@ -34,6 +39,49 @@ func TestResolveFanMaxRPM(t *testing.T) {
}
}
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)
@@ -109,22 +157,7 @@ func TestParseFanDutyCyclePctSensorsJSON(t *testing.T) {
}
func TestEstimateFanDutyCyclePctFromObservation(t *testing.T) {
t.Parallel()
oldPath := fanObservationStatePath
oldState := fanObservation
oldInit := fanObservationInit
oldCandidates := fanPeakCandidates
fanObservationStatePath = filepath.Join(t.TempDir(), "fan-observation.json")
fanObservation = fanObservationState{}
fanObservationInit = false
fanPeakCandidates = make(map[string]fanPeakCandidate)
t.Cleanup(func() {
fanObservationStatePath = oldPath
fanObservation = oldState
fanObservationInit = oldInit
fanPeakCandidates = oldCandidates
})
resetPeakStore(t, fanPeaks)
start := time.Unix(100, 0)
updateFanObservation([]FanReading{{Name: "FAN1", RPM: 5000}}, start)
@@ -143,9 +176,9 @@ func TestEstimateFanDutyCyclePctFromObservation(t *testing.T) {
t.Fatalf("got=%v want ~43.3", got)
}
fanObservation = fanObservationState{}
fanObservationInit = false
fanPeakCandidates = make(map[string]fanPeakCandidate)
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")