Files
Mikhail ChusavitinandClaude Sonnet 5 7ed652c5b1 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
2026-09-04 12:53:13 +03:00

184 lines
4.7 KiB
Go

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
}