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
+21
View File
@@ -262,6 +262,27 @@ func TestFanSpinPeriodSec(t *testing.T) {
}
}
func TestRenderTopoPSURowUsesObservedMaxWhenNoRating(t *testing.T) {
ok := "OK"
draw := 340.0
psus := []schema.HardwarePowerSupply{
{HardwareComponentStatus: schema.HardwareComponentStatus{Status: &ok}, InputPowerW: &draw}, // no WattageW
}
// Observed peak (keyed by ordinal) stands in for the missing nameplate.
html := renderTopoPSURow(psus, map[string]float64{"0": 2400})
if !strings.Contains(html, `data-psu-max-src="observed"`) {
t.Fatalf("expected observed-scale marker: %s", html)
}
if !strings.Contains(html, "340 / ~2400 W · ~14% load") {
t.Fatalf("expected estimated load line: %s", html)
}
// Without an observed peak either, just the raw watts, no fill.
html = renderTopoPSURow(psus, nil)
if strings.Contains(html, "% load") {
t.Fatalf("no rating and no observed peak → no load figure: %s", html)
}
}
func TestTopoPageRendersStorageDisksGroupedByType(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.json")