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 }