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
@@ -0,0 +1,183 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// observedPeakStore is the "autotune" primitive for components that expose no
|
||||
// host-readable nameplate maximum: fan top RPM, PSU capacity. It records the
|
||||
// highest value seen per key while the box is under load, persists it to a
|
||||
// JSON file, and hands it back so live readings can be scaled against a real
|
||||
// maximum. A new peak only sticks after it has been held for at least
|
||||
// minHold, which rejects transient spikes.
|
||||
type observedPeakStore struct {
|
||||
path string // JSON file
|
||||
jsonKey string // top-level object key, e.g. "max_rpm"
|
||||
roundUp float64 // round a new peak up to this multiple; 0 = keep raw
|
||||
minHold time.Duration // a candidate peak must persist this long to stick
|
||||
|
||||
mu sync.Mutex
|
||||
loaded bool
|
||||
peaks map[string]float64
|
||||
candidates map[string]peakCandidate
|
||||
}
|
||||
|
||||
type peakCandidate struct {
|
||||
firstSeen time.Time
|
||||
val float64
|
||||
}
|
||||
|
||||
// persistedPeaks reads the file fresh (no lock, no cache mutation) and returns
|
||||
// its sanitized {key -> peak} map. Empty map when the file is missing or
|
||||
// unparsable.
|
||||
func (s *observedPeakStore) persistedPeaks() map[string]float64 {
|
||||
out := map[string]float64{}
|
||||
raw, err := os.ReadFile(s.path)
|
||||
if err != nil || len(raw) == 0 {
|
||||
return out
|
||||
}
|
||||
var doc map[string]map[string]float64
|
||||
if json.Unmarshal(raw, &doc) != nil {
|
||||
return out
|
||||
}
|
||||
for k, v := range doc[s.jsonKey] {
|
||||
k = strings.TrimSpace(k)
|
||||
if k == "" || v <= 0 {
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *observedPeakStore) loadLocked() {
|
||||
if s.loaded {
|
||||
return
|
||||
}
|
||||
s.loaded = true
|
||||
s.peaks = s.persistedPeaks()
|
||||
if s.candidates == nil {
|
||||
s.candidates = map[string]peakCandidate{}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *observedPeakStore) saveLocked() {
|
||||
if len(s.peaks) == 0 {
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(s.path)
|
||||
if dir == "" || dir == "." {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return
|
||||
}
|
||||
raw, err := json.MarshalIndent(map[string]map[string]float64{s.jsonKey: s.peaks}, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(s.path, raw, 0644)
|
||||
}
|
||||
|
||||
func (s *observedPeakStore) round(v float64) float64 {
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
if s.roundUp <= 0 {
|
||||
return v
|
||||
}
|
||||
return math.Ceil(v/s.roundUp) * s.roundUp
|
||||
}
|
||||
|
||||
// observe feeds one telemetry sample (key -> current value). Non-positive
|
||||
// values and blank keys are ignored.
|
||||
func (s *observedPeakStore) observe(samples map[string]float64, now time.Time) {
|
||||
if len(samples) == 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.loadLocked()
|
||||
changed := false
|
||||
for key, val := range samples {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" || val <= 0 {
|
||||
continue
|
||||
}
|
||||
cur := s.peaks[key]
|
||||
if val <= cur {
|
||||
delete(s.candidates, key)
|
||||
continue
|
||||
}
|
||||
if cand, ok := s.candidates[key]; ok {
|
||||
if now.Sub(cand.firstSeen) >= s.minHold {
|
||||
nv := math.Max(cand.val, val)
|
||||
if nv > cur {
|
||||
s.peaks[key] = s.round(nv)
|
||||
changed = true
|
||||
}
|
||||
delete(s.candidates, key)
|
||||
continue
|
||||
}
|
||||
if val > cand.val {
|
||||
s.candidates[key] = peakCandidate{firstSeen: cand.firstSeen, val: val}
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.candidates[key] = peakCandidate{firstSeen: now, val: val}
|
||||
}
|
||||
if changed {
|
||||
s.saveLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// snapshot returns the persisted peaks (fresh from disk), for read-only
|
||||
// consumers such as the /topo web view.
|
||||
func (s *observedPeakStore) snapshot() map[string]float64 {
|
||||
return s.persistedPeaks()
|
||||
}
|
||||
|
||||
// ── PSU capacity ────────────────────────────────────────────────────────────
|
||||
|
||||
var psuPeaks = &observedPeakStore{
|
||||
path: "/var/log/bee-sat/psu-observation.json",
|
||||
jsonKey: "max_w",
|
||||
roundUp: 50,
|
||||
minHold: time.Second,
|
||||
}
|
||||
|
||||
// updatePSUObservation feeds the current per-PSU draw (keyed by ordinal, in
|
||||
// the order the caller lists them) into the observed-capacity store. On a BMC
|
||||
// that reports only instantaneous input power this is the only way to know
|
||||
// what "100% load" looks like for each supply: observe the peak draw during
|
||||
// any full-load run (the Fan Ceiling Check, a burn, thermal cycling — the 5 s
|
||||
// metrics collector samples PSUs throughout) and remember it.
|
||||
func updatePSUObservation(psus []PSUReading, now time.Time) {
|
||||
if len(psus) == 0 {
|
||||
return
|
||||
}
|
||||
m := make(map[string]float64, len(psus))
|
||||
for i, p := range psus {
|
||||
if p.PowerW > 0 {
|
||||
m[strconv.Itoa(i)] = p.PowerW
|
||||
}
|
||||
}
|
||||
psuPeaks.observe(m, now)
|
||||
}
|
||||
|
||||
// ObservedPSUMaxW returns the persisted per-PSU observed peak draw, keyed by
|
||||
// ordinal ("0", "1", …), or nil if none recorded yet.
|
||||
func ObservedPSUMaxW() map[string]float64 {
|
||||
p := psuPeaks.snapshot()
|
||||
if len(p) == 0 {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
Reference in New Issue
Block a user