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
@@ -594,7 +594,7 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string
|
||||
|
||||
hasPSU := len(hw.PowerSupplies) > 0
|
||||
if hasPSU {
|
||||
b.WriteString(renderTopoPSURow(hw.PowerSupplies))
|
||||
b.WriteString(renderTopoPSURow(hw.PowerSupplies, platform.ObservedPSUMaxW()))
|
||||
}
|
||||
|
||||
// Cooling fans — one small clickable square per fan. Square SIZE encodes
|
||||
@@ -619,10 +619,13 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string
|
||||
}
|
||||
|
||||
// renderTopoPSURow renders the POWER SUPPLIES row: one card per PSU, coloured
|
||||
// by that PSU's own status (a failed unit goes red on its own), showing input
|
||||
// voltage and draw. Cards click through to the shared PSU detail modal and
|
||||
// carry data-psu so topoLiveScript can refresh the wattage in place.
|
||||
func renderTopoPSURow(psus []schema.HardwarePowerSupply) string {
|
||||
// by that PSU's own status (a failed unit goes red on its own). The card shows
|
||||
// input voltage and draw, and a load fill rising from the bottom — same idea
|
||||
// as the fan duty-cycle fill. The load scale is the nameplate rating when the
|
||||
// BMC reports it; otherwise it is the observed peak draw (observedMaxW, keyed
|
||||
// by ordinal — the "autotune" recorded during any full-load run), and the
|
||||
// figure is marked as an estimate.
|
||||
func renderTopoPSURow(psus []schema.HardwarePowerSupply, observedMaxW map[string]float64) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(topoRowHeading("Power Supplies"))
|
||||
b.WriteString(`<div style="display:flex;flex-wrap:wrap;gap:8px">`)
|
||||
@@ -640,14 +643,25 @@ func renderTopoPSURow(psus []schema.HardwarePowerSupply) string {
|
||||
if p.WattageW != nil && *p.WattageW > 0 {
|
||||
rating = *p.WattageW
|
||||
}
|
||||
// Load scale: true rating if known, else the observed peak draw.
|
||||
scaleMax := float64(rating)
|
||||
scaleEstimate := false
|
||||
if scaleMax <= 0 {
|
||||
if m := observedMaxW[strconv.Itoa(i)]; m > 0 {
|
||||
scaleMax = m
|
||||
scaleEstimate = true
|
||||
}
|
||||
}
|
||||
|
||||
var parts []string
|
||||
if p.InputVoltage != nil && *p.InputVoltage > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%.0f V", *p.InputVoltage))
|
||||
}
|
||||
switch {
|
||||
case haveDraw && rating > 0:
|
||||
parts = append(parts, fmt.Sprintf("%.0f / %d W · %.0f%% load", draw, rating, draw/float64(rating)*100))
|
||||
case haveDraw && scaleMax > 0 && scaleEstimate:
|
||||
parts = append(parts, fmt.Sprintf("%.0f / ~%.0f W · ~%.0f%% load", draw, scaleMax, draw/scaleMax*100))
|
||||
case haveDraw && scaleMax > 0:
|
||||
parts = append(parts, fmt.Sprintf("%.0f / %d W · %.0f%% load", draw, rating, draw/scaleMax*100))
|
||||
case haveDraw:
|
||||
parts = append(parts, fmt.Sprintf("%.0f W", draw))
|
||||
case rating > 0:
|
||||
@@ -660,33 +674,29 @@ func renderTopoPSURow(psus []schema.HardwarePowerSupply) string {
|
||||
statusWord = topoSeverityStatus(p.Status)
|
||||
}
|
||||
|
||||
// Load fill (draw / rating) rises from the bottom of the card, the
|
||||
// same idea as the fan duty-cycle fill. Only drawn when the nameplate
|
||||
// rating is known — a raw wattage with nothing to scale it against is
|
||||
// not a load figure.
|
||||
loadPct := -1.0
|
||||
if haveDraw && rating > 0 {
|
||||
loadPct = draw / float64(rating) * 100
|
||||
if loadPct < 0 {
|
||||
loadPct = 0
|
||||
}
|
||||
if loadPct > 100 {
|
||||
loadPct = 100
|
||||
}
|
||||
}
|
||||
fillH := 0.0
|
||||
if loadPct >= 0 {
|
||||
fillH = loadPct
|
||||
if haveDraw && scaleMax > 0 {
|
||||
fillH = draw / scaleMax * 100
|
||||
if fillH < 0 {
|
||||
fillH = 0
|
||||
}
|
||||
if fillH > 100 {
|
||||
fillH = 100
|
||||
}
|
||||
}
|
||||
|
||||
voltAttr := ""
|
||||
if p.InputVoltage != nil && *p.InputVoltage > 0 {
|
||||
voltAttr = fmt.Sprintf("%.0f", *p.InputVoltage)
|
||||
}
|
||||
maxSrc := "rated"
|
||||
if scaleEstimate {
|
||||
maxSrc = "observed"
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, `<div class="topo-psu-tile" data-psu="%d" data-psu-max="%d" data-psu-v="%s" onclick="openComponentDetail('psu')" `+
|
||||
fmt.Fprintf(&b, `<div class="topo-psu-tile" data-psu="%d" data-psu-max="%.0f" data-psu-max-src="%s" data-psu-v="%s" onclick="openComponentDetail('psu')" `+
|
||||
`style="position:relative;overflow:hidden;cursor:pointer;min-width:104px;padding:8px 11px;border-radius:6px;background:var(--surface-2);border:1px solid %s;color:%s">`,
|
||||
i, rating, voltAttr, stroke, text)
|
||||
i, scaleMax, maxSrc, voltAttr, stroke, text)
|
||||
fmt.Fprintf(&b, `<div class="topo-psu-fill" style="position:absolute;left:0;right:0;bottom:0;height:%.0f%%;background:%s;opacity:.5;transition:height .8s linear"></div>`, fillH, stroke)
|
||||
fmt.Fprintf(&b, `<div style="position:relative"><div style="font-size:13px;font-weight:700">%s</div>`, html.EscapeString(label))
|
||||
fmt.Fprintf(&b, `<div class="topo-psu-detail" style="font-size:11px;opacity:.9;margin-top:2px">%s</div>`, html.EscapeString(detail))
|
||||
@@ -828,11 +838,15 @@ func topoLiveScript() string {
|
||||
psus.forEach(function(t){
|
||||
var p=m.psus[parseInt(t.dataset.psu,10)];if(!p)return;
|
||||
var w=p.power_w||0,max=parseFloat(t.dataset.psuMax)||0,v=t.dataset.psuV;
|
||||
var est=t.dataset.psuMaxSrc==='observed';
|
||||
var parts=[];
|
||||
if(v)parts.push(v+' V');
|
||||
if(w>0&&max>0)parts.push(Math.round(w)+' / '+max+' W · '+Math.round(w/max*100)+'% load');
|
||||
else if(w>0)parts.push(Math.round(w)+' W');
|
||||
else if(max>0)parts.push(max+' W rated');
|
||||
if(w>0&&max>0){
|
||||
var pct=Math.round(w/max*100);
|
||||
parts.push(est?Math.round(w)+' / ~'+Math.round(max)+' W · ~'+pct+'% load'
|
||||
:Math.round(w)+' / '+Math.round(max)+' W · '+pct+'% load');
|
||||
}else if(w>0)parts.push(Math.round(w)+' W');
|
||||
else if(max>0)parts.push(Math.round(max)+' W rated');
|
||||
var d=t.querySelector('.topo-psu-detail');if(d&&parts.length)d.textContent=parts.join(' · ');
|
||||
if(w>0&&max>0){var f=t.querySelector('.topo-psu-fill');if(f)f.style.height=Math.max(0,Math.min(100,w/max*100)).toFixed(0)+'%';}
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user