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:
co-authored by
Claude Sonnet 5
parent
e713504fc9
commit
7ed652c5b1
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user