Repurpose the previously-unwired RunFanStressTest into RunFanCheck, a
Load-tier SAT test that drives stressapptest (CPU+memory) and, when a GPU
is present, a GPU burn to 100% simultaneously, then watches every fan
until none has climbed for ~60s. The observed peak RPM per fan is the
"ceiling"; it is persisted through the existing fan-observation store.
MSI G4201 / AMI MegaRAC exposes no host-side fan force (every OEM IPMI
command returns 0xc1; Redfish Thermal is GET-only), so load-driven ramp
is the closest safe equivalent. See
bible-local/decisions/2026-09-04-fan-ceiling-check.md.
- platform.ResolveFanMaxRPM: per-fan max with fallback (persisted peak ->
peer peak -> current RPM), resolved in platform, not the view.
- platform.ErrTestNotApplicable: no load source or no fan sensors ->
task lands as cancelled ("not applicable"), never failed, so an
engineer never sees a false red. executeTaskWithOptions maps the
sentinel; finalizeTaskForResult honours a pre-set TaskCancelled.
- Verdict FAIL only for a fan at 0 RPM / IPMI cr-nr under load.
- /topo: one small spinning square per fan, sized by RPM / resolved max,
clickable through to a new "fan" component-detail type; per-fan status
recorded to the component-status DB from the fan SAT summary.
- Wiring: /api/sat/fan/run route, "fan" task target, Load-page card,
stress-mode Run All.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019VHG21rgTUiR1G3qFHTVmN
1078 lines
30 KiB
Go
1078 lines
30 KiB
Go
package platform
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// FanCheckOptions configures the fan-ceiling check: it drives CPU (+memory)
|
|
// and, when present, GPU load to 100% simultaneously, then watches every fan
|
|
// until none has climbed for PlateauHoldSec — at which point each fan is
|
|
// considered to be at its physical ceiling and the observed peak is recorded.
|
|
type FanCheckOptions struct {
|
|
PlateauHoldSec int // a fan must not rise > PlateauDeltaRPM for this long to count as plateaued (default 60)
|
|
PlateauDeltaRPM int // RPM increase that still counts as "climbing" (default 50)
|
|
MinLoadSec int // never declare a plateau before this many seconds of load (default 90)
|
|
MaxLoadSec int // hard cap on the load phase; finish (success) even if not every fan plateaued (default 900)
|
|
RampConfirmRPM int // at least one fan must exceed baseline by this before a plateau is "real" (default 150)
|
|
SizeMB int // GPU memory to allocate per GPU (0 = auto)
|
|
GPUIndices []int // which GPU indices to load (empty = all detected)
|
|
}
|
|
|
|
// FanReading holds one fan sensor reading.
|
|
type FanReading struct {
|
|
Name string
|
|
RPM float64
|
|
}
|
|
|
|
// GPUStressMetric holds per-GPU metrics during the stress test.
|
|
type GPUStressMetric struct {
|
|
Index int
|
|
TempC float64
|
|
UsagePct float64
|
|
PowerW float64
|
|
ClockMHz float64
|
|
Throttled bool // true if any throttle reason is active
|
|
}
|
|
|
|
// FanStressRow is one second-interval telemetry sample covering all monitored dimensions.
|
|
type FanStressRow struct {
|
|
TimestampUTC string
|
|
ElapsedSec float64
|
|
Phase string // "baseline", "load1", "pause", "load2", "cooldown"
|
|
GPUs []GPUStressMetric
|
|
Fans []FanReading
|
|
CPUMaxTempC float64 // highest CPU temperature from ipmitool / sensors
|
|
SysPowerW float64
|
|
SysPowerSource string
|
|
SysPowerMode string
|
|
}
|
|
|
|
type cachedPowerReading struct {
|
|
Value float64
|
|
Source string
|
|
Mode string
|
|
Reason string
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type fanObservationState struct {
|
|
MaxRPM map[string]float64 `json:"max_rpm"`
|
|
}
|
|
|
|
type fanPeakCandidate struct {
|
|
FirstSeen time.Time
|
|
RPM float64
|
|
}
|
|
|
|
var (
|
|
systemPowerCacheMu sync.Mutex
|
|
systemPowerCache cachedPowerReading
|
|
fanObservationMu sync.Mutex
|
|
fanObservation fanObservationState
|
|
fanObservationInit bool
|
|
fanPeakCandidates = make(map[string]fanPeakCandidate)
|
|
)
|
|
|
|
const systemPowerHoldTTL = 15 * time.Second
|
|
|
|
var fanObservationStatePath = "/var/log/bee-sat/fan-observation.json"
|
|
|
|
const fanObservationMinPeakHold = time.Second
|
|
|
|
func normalizeObservedFanMaxRPM(rpm float64) float64 {
|
|
if rpm <= 0 {
|
|
return 0
|
|
}
|
|
return math.Ceil(rpm/1000.0) * 1000.0
|
|
}
|
|
|
|
// RunFanCheck drives CPU (+memory) and, when a GPU is present, GPU load to
|
|
// 100% simultaneously and watches every fan until none has climbed for
|
|
// PlateauHoldSec. At that point each fan is taken to be at its physical
|
|
// ceiling; the observed peak RPM is persisted (fanObservationStatePath, the
|
|
// same store ObservedFanMaxRPM reads) so the topology view can size each fan
|
|
// tile against a real maximum.
|
|
//
|
|
// Outcome:
|
|
// - success ("ceiling found") once every fan plateaus, or when MaxLoadSec is
|
|
// hit — a run that simply ran out of time still recorded the highest RPM
|
|
// seen and is not a failure.
|
|
// - a fan reading 0 RPM, or an IPMI status of cr/nr, while under full load is
|
|
// a real defect → FAILED.
|
|
// - if there is no way to load this box (no stressapptest/stress-ng and no
|
|
// GPU burn tool) or no fan sensors are readable, the test cannot say
|
|
// anything about the hardware and returns ErrTestNotApplicable so the task
|
|
// is cancelled, not failed.
|
|
//
|
|
// No GPU is not an error: CPU/memory load alone is enough to exercise the
|
|
// cooling loop on most platforms.
|
|
func (s *System) RunFanCheck(ctx context.Context, baseDir string, opts FanCheckOptions, logFunc func(string)) (string, error) {
|
|
if logFunc == nil {
|
|
logFunc = func(string) {}
|
|
}
|
|
if baseDir == "" {
|
|
baseDir = "/var/log/bee-sat"
|
|
}
|
|
applyFanCheckDefaults(&opts)
|
|
|
|
baseFans, fanErr := sampleFanSpeeds()
|
|
if len(baseFans) == 0 {
|
|
return "", fmt.Errorf("no fan sensors readable via ipmitool or lm-sensors (%v): %w", fanErr, ErrTestNotApplicable)
|
|
}
|
|
baselineRPM := make(map[string]float64, len(baseFans))
|
|
for _, f := range baseFans {
|
|
baselineRPM[f.Name] = f.RPM
|
|
}
|
|
|
|
vendor := s.DetectGPUVendor()
|
|
haveGPU := vendor == "nvidia" || vendor == "amd"
|
|
_, cpuPathErr := satLookPath("stressapptest")
|
|
if cpuPathErr != nil {
|
|
_, cpuPathErr = satLookPath("stress-ng")
|
|
}
|
|
haveCPU := cpuPathErr == nil
|
|
if !haveCPU && !haveGPU {
|
|
return "", fmt.Errorf("no load source: stressapptest/stress-ng missing and no NVIDIA/AMD GPU stress tool available: %w", ErrTestNotApplicable)
|
|
}
|
|
|
|
ts := time.Now().UTC().Format("20060102-150405")
|
|
runDir := filepath.Join(baseDir, "fan-check-"+ts)
|
|
if err := os.MkdirAll(runDir, 0755); err != nil {
|
|
return "", err
|
|
}
|
|
verboseLog := filepath.Join(runDir, "verbose.log")
|
|
appendSATVerboseLog(verboseLog, fmt.Sprintf("[%s] fan check start: %d fans, gpu=%s cpu=%v",
|
|
time.Now().UTC().Format(time.RFC3339), len(baseFans), vendor, haveCPU))
|
|
logFunc(fmt.Sprintf("Fan check: %d fans; load = CPU/mem:%v + GPU:%s", len(baseFans), haveCPU, orNone(haveGPU, vendor)))
|
|
|
|
// ── Load: every source runs at the same time, each in its own goroutine.
|
|
// Sources can take different amounts of time to actually reach full load
|
|
// (stressapptest is near-instant; a GPU burn kernel needs to compile and
|
|
// ramp), so the plateau clock does not start until every launched source
|
|
// has reported its process running, plus a fixed GPU ramp grace.
|
|
loadCtx, loadCancel := context.WithTimeout(ctx, time.Duration(opts.MaxLoadSec)*time.Second)
|
|
defer loadCancel()
|
|
|
|
var loadWG sync.WaitGroup
|
|
started := make(chan bool, 2) // true = source is running, false = failed to launch
|
|
launched := 0
|
|
|
|
if haveCPU {
|
|
launched++
|
|
loadWG.Add(1)
|
|
go func() {
|
|
defer loadWG.Done()
|
|
cmd, err := buildCPUStressCmd(loadCtx)
|
|
if err != nil {
|
|
logFunc("CPU/memory load failed to start: " + err.Error())
|
|
appendSATVerboseLog(verboseLog, "cpu load start error: "+err.Error())
|
|
started <- false
|
|
return
|
|
}
|
|
logFunc("CPU/memory load running (stressapptest)")
|
|
started <- true
|
|
_ = cmd.Wait()
|
|
}()
|
|
}
|
|
if haveGPU {
|
|
launched++
|
|
loadWG.Add(1)
|
|
go func() {
|
|
defer loadWG.Done()
|
|
cmd := buildGPUStressCmd(loadCtx, vendor, opts.MaxLoadSec)
|
|
if cmd == nil {
|
|
logFunc("GPU load unavailable (no burn tool for " + vendor + ")")
|
|
appendSATVerboseLog(verboseLog, "gpu load: no burn tool")
|
|
started <- false
|
|
return
|
|
}
|
|
logFunc("GPU load running (" + vendor + ")")
|
|
started <- true
|
|
_ = cmd.Wait()
|
|
}()
|
|
}
|
|
|
|
start := time.Now()
|
|
activeLoads := 0
|
|
for i := 0; i < launched; i++ {
|
|
select {
|
|
case ok := <-started:
|
|
if ok {
|
|
activeLoads++
|
|
}
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
if activeLoads == 0 {
|
|
loadCancel()
|
|
loadWG.Wait()
|
|
return "", fmt.Errorf("every load source failed to start: %w", ErrTestNotApplicable)
|
|
}
|
|
readyAt := time.Now()
|
|
if haveGPU {
|
|
readyAt = readyAt.Add(20 * time.Second) // GPU kernel ramp grace
|
|
}
|
|
appendSATVerboseLog(verboseLog, fmt.Sprintf("%d load source(s) active; plateau clock effective from +%.0fs",
|
|
activeLoads, readyAt.Sub(start).Seconds()))
|
|
|
|
// ── Sample loop: one row/second, per-fan plateau tracking.
|
|
type fanState struct {
|
|
peak float64
|
|
lastRiseSec float64
|
|
}
|
|
fanBy := map[string]*fanState{}
|
|
var rows []FanStressRow
|
|
rampConfirmed := false
|
|
plateauReached := false
|
|
aborted := false
|
|
|
|
ticker := time.NewTicker(time.Second)
|
|
defer ticker.Stop()
|
|
loop:
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
aborted = true
|
|
break loop
|
|
case <-loadCtx.Done():
|
|
break loop // MaxLoadSec reached
|
|
case <-ticker.C:
|
|
}
|
|
elapsed := time.Since(start).Seconds()
|
|
row := sampleFanStressRow(opts.GPUIndices, "load", elapsed)
|
|
rows = append(rows, row)
|
|
|
|
for _, f := range row.Fans {
|
|
st := fanBy[f.Name]
|
|
if st == nil {
|
|
fanBy[f.Name] = &fanState{peak: f.RPM, lastRiseSec: elapsed}
|
|
continue
|
|
}
|
|
if f.RPM > st.peak {
|
|
if f.RPM-st.peak > float64(opts.PlateauDeltaRPM) {
|
|
st.lastRiseSec = elapsed
|
|
}
|
|
st.peak = f.RPM
|
|
}
|
|
if f.RPM >= baselineRPM[f.Name]+float64(opts.RampConfirmRPM) {
|
|
rampConfirmed = true
|
|
}
|
|
}
|
|
|
|
if elapsed >= float64(opts.MinLoadSec) && time.Since(readyAt) >= time.Duration(opts.PlateauHoldSec)*time.Second && len(fanBy) > 0 {
|
|
allFlat := true
|
|
for _, st := range fanBy {
|
|
if elapsed-st.lastRiseSec < float64(opts.PlateauHoldSec) {
|
|
allFlat = false
|
|
break
|
|
}
|
|
}
|
|
if allFlat && rampConfirmed {
|
|
plateauReached = true
|
|
logFunc(fmt.Sprintf("All %d fans plateaued at %.0fs of load", len(fanBy), elapsed))
|
|
break loop
|
|
}
|
|
}
|
|
}
|
|
|
|
loadCancel()
|
|
loadWG.Wait()
|
|
|
|
if aborted && ctx.Err() != nil {
|
|
_ = os.WriteFile(filepath.Join(runDir, "summary.txt"),
|
|
[]byte("run_at_utc="+time.Now().UTC().Format(time.RFC3339)+"\noverall_status=UNKNOWN\naborted=true\n"), 0644)
|
|
return runDir, ctx.Err()
|
|
}
|
|
|
|
// ── Verdict.
|
|
statuses := readFanStatuses()
|
|
var summary strings.Builder
|
|
fmt.Fprintf(&summary, "run_at_utc=%s\n", time.Now().UTC().Format(time.RFC3339))
|
|
fmt.Fprintf(&summary, "fans_total=%d\n", len(baseFans))
|
|
fmt.Fprintf(&summary, "active_load_sources=%d\n", activeLoads)
|
|
fmt.Fprintf(&summary, "gpu_vendor=%s\n", orNone(haveGPU, vendor))
|
|
fmt.Fprintf(&summary, "plateau_reached=%v\n", plateauReached)
|
|
fmt.Fprintf(&summary, "ramp_confirmed=%v\n", rampConfirmed)
|
|
if len(rows) > 0 {
|
|
fmt.Fprintf(&summary, "load_duration_sec=%.0f\n", rows[len(rows)-1].ElapsedSec)
|
|
}
|
|
fmt.Fprintf(&summary, "max_gpu_temp_c=%.1f\n", analyzeMaxTemp(rows, func(r FanStressRow) float64 {
|
|
var m float64
|
|
for _, g := range r.GPUs {
|
|
if g.TempC > m {
|
|
m = g.TempC
|
|
}
|
|
}
|
|
return m
|
|
}))
|
|
fmt.Fprintf(&summary, "max_cpu_temp_c=%.1f\n", analyzeMaxTemp(rows, func(r FanStressRow) float64 { return r.CPUMaxTempC }))
|
|
|
|
stats := satStats{}
|
|
names := make([]string, 0, len(baselineRPM))
|
|
for n := range baselineRPM {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
peak := baselineRPM[name]
|
|
if st := fanBy[name]; st != nil {
|
|
peak = st.peak
|
|
}
|
|
st := strings.ToLower(strings.TrimSpace(statuses[name]))
|
|
bad := peak <= 0 || st == "cr" || st == "nr"
|
|
key := sanitizeSummaryKey(name)
|
|
fmt.Fprintf(&summary, "fan_%s_baseline_rpm=%.0f\n", key, baselineRPM[name])
|
|
fmt.Fprintf(&summary, "fan_%s_max_rpm=%.0f\n", key, peak)
|
|
if bad {
|
|
reason := "0 RPM under load"
|
|
if st == "cr" || st == "nr" {
|
|
reason = "IPMI status " + st
|
|
}
|
|
fmt.Fprintf(&summary, "fan_%s_status=FAILED (%s)\n", key, reason)
|
|
logFunc(fmt.Sprintf("FAIL %s: %s", name, reason))
|
|
stats.Failed++
|
|
} else {
|
|
fmt.Fprintf(&summary, "fan_%s_status=OK\n", key)
|
|
stats.OK++
|
|
}
|
|
}
|
|
writeSATStats(&summary, stats)
|
|
|
|
_ = WriteFanStressCSV(filepath.Join(runDir, "metrics.csv"), rows, opts.GPUIndices)
|
|
_ = WriteFanSensorsCSV(filepath.Join(runDir, "fan-sensors.csv"), rows)
|
|
if err := os.WriteFile(filepath.Join(runDir, "summary.txt"), []byte(summary.String()), 0644); err != nil {
|
|
return "", err
|
|
}
|
|
return runDir, nil
|
|
}
|
|
|
|
func applyFanCheckDefaults(o *FanCheckOptions) {
|
|
if o.PlateauHoldSec <= 0 {
|
|
o.PlateauHoldSec = 60
|
|
}
|
|
if o.PlateauDeltaRPM <= 0 {
|
|
o.PlateauDeltaRPM = 50
|
|
}
|
|
if o.MinLoadSec <= 0 {
|
|
o.MinLoadSec = 90
|
|
}
|
|
if o.MaxLoadSec <= 0 {
|
|
o.MaxLoadSec = 900
|
|
}
|
|
if o.RampConfirmRPM <= 0 {
|
|
o.RampConfirmRPM = 150
|
|
}
|
|
if o.MinLoadSec < o.PlateauHoldSec {
|
|
o.MinLoadSec = o.PlateauHoldSec
|
|
}
|
|
if o.MaxLoadSec <= o.MinLoadSec {
|
|
o.MaxLoadSec = o.MinLoadSec + o.PlateauHoldSec
|
|
}
|
|
}
|
|
|
|
func orNone(present bool, v string) string {
|
|
if present && v != "" {
|
|
return v
|
|
}
|
|
return "none"
|
|
}
|
|
|
|
// sanitizeSummaryKey makes a fan sensor name safe as a summary.txt key
|
|
// fragment (keys are parsed by splitting on '=' and whitespace).
|
|
func sanitizeSummaryKey(name string) string {
|
|
var b strings.Builder
|
|
for _, r := range name {
|
|
switch {
|
|
case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteByte('_')
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// readFanStatuses returns the per-fan IPMI status word ("ok", "cr", "nr", ...)
|
|
// from "ipmitool sdr type Fan". Empty map when ipmitool is unavailable.
|
|
func readFanStatuses() map[string]string {
|
|
out, err := exec.Command("ipmitool", "sdr", "type", "Fan").Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
m := map[string]string{}
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 3 {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(parts[0])
|
|
if name == "" {
|
|
continue
|
|
}
|
|
m[name] = strings.ToLower(strings.TrimSpace(parts[2]))
|
|
}
|
|
return m
|
|
}
|
|
|
|
// ResolveFanMaxRPM returns, for every fan name in current (name -> current
|
|
// RPM), the RPM to treat as that fan's 100% reference. Preference order:
|
|
// 1. the persisted observed peak (fanObservationStatePath), written by
|
|
// RunFanCheck and by live-metrics sampling under load;
|
|
// 2. the largest peak observed on any peer fan (keeps a group visually
|
|
// consistent when only some fans have a recorded peak);
|
|
// 3. the fan's own current RPM (so a tile is never sized against zero).
|
|
//
|
|
// The fallback lives here, not in the view, so every consumer of a fan
|
|
// maximum applies the same rule.
|
|
func ResolveFanMaxRPM(current map[string]float64) map[string]float64 {
|
|
persisted := readPersistedFanMaxRPM()
|
|
|
|
peerMax := 0.0
|
|
for _, v := range persisted {
|
|
if v > peerMax {
|
|
peerMax = v
|
|
}
|
|
}
|
|
|
|
out := make(map[string]float64, len(current))
|
|
for name, rpm := range current {
|
|
switch {
|
|
case persisted[name] > 0:
|
|
out[name] = persisted[name]
|
|
case peerMax > 0:
|
|
out[name] = peerMax
|
|
default:
|
|
out[name] = rpm
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sampleFanStressRow collects all metrics for one telemetry sample.
|
|
func sampleFanStressRow(gpuIndices []int, phase string, elapsed float64) FanStressRow {
|
|
row := FanStressRow{
|
|
TimestampUTC: time.Now().UTC().Format(time.RFC3339),
|
|
ElapsedSec: elapsed,
|
|
Phase: phase,
|
|
}
|
|
row.GPUs = sampleGPUStressMetrics(gpuIndices)
|
|
row.Fans, _ = sampleFanSpeeds()
|
|
row.CPUMaxTempC = sampleCPUMaxTemp()
|
|
row.SysPowerW, row.SysPowerSource, row.SysPowerMode = sampleSystemPowerResolved()
|
|
return row
|
|
}
|
|
|
|
// sampleGPUStressMetrics queries nvidia-smi for temperature, utilization, power,
|
|
// clock frequency, and active throttle reasons for each GPU.
|
|
func sampleGPUStressMetrics(gpuIndices []int) []GPUStressMetric {
|
|
args := []string{
|
|
"--query-gpu=index,temperature.gpu,utilization.gpu,power.draw,clocks.current.graphics,clocks_throttle_reasons.active",
|
|
"--format=csv,noheader,nounits",
|
|
}
|
|
if len(gpuIndices) > 0 {
|
|
ids := make([]string, len(gpuIndices))
|
|
for i, idx := range gpuIndices {
|
|
ids[i] = strconv.Itoa(idx)
|
|
}
|
|
args = append([]string{"--id=" + strings.Join(ids, ",")}, args...)
|
|
}
|
|
out, err := exec.Command("nvidia-smi", args...).Output()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var metrics []GPUStressMetric
|
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
parts := strings.Split(line, ", ")
|
|
if len(parts) < 6 {
|
|
continue
|
|
}
|
|
idx, _ := strconv.Atoi(strings.TrimSpace(parts[0]))
|
|
throttleVal := strings.TrimSpace(parts[5])
|
|
// Throttled if active reasons bitmask is non-zero.
|
|
throttled := throttleVal != "0x0000000000000000" &&
|
|
throttleVal != "0x0" &&
|
|
throttleVal != "0" &&
|
|
throttleVal != "" &&
|
|
throttleVal != "N/A"
|
|
metrics = append(metrics, GPUStressMetric{
|
|
Index: idx,
|
|
TempC: parseGPUFloat(parts[1]),
|
|
UsagePct: parseGPUFloat(parts[2]),
|
|
PowerW: parseGPUFloat(parts[3]),
|
|
ClockMHz: parseGPUFloat(parts[4]),
|
|
Throttled: throttled,
|
|
})
|
|
}
|
|
return metrics
|
|
}
|
|
|
|
// sampleFanSpeeds reads fan RPM values from ipmitool sdr.
|
|
func sampleFanSpeeds() ([]FanReading, error) {
|
|
out, err := exec.Command("ipmitool", "sdr", "type", "Fan").Output()
|
|
if err == nil {
|
|
if fans := parseFanSpeeds(string(out)); len(fans) > 0 {
|
|
updateFanObservation(fans, time.Now())
|
|
return fans, nil
|
|
}
|
|
}
|
|
fans, sensorsErr := sampleFanSpeedsViaSensorsJSON()
|
|
if len(fans) > 0 {
|
|
updateFanObservation(fans, time.Now())
|
|
return fans, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return nil, sensorsErr
|
|
}
|
|
|
|
// readPersistedFanMaxRPM reads fanObservationStatePath and returns its
|
|
// sanitized {fan name -> observed peak RPM} map (empty names / non-positive
|
|
// values dropped). Returns an empty map when the file is missing or unparsable.
|
|
func readPersistedFanMaxRPM() map[string]float64 {
|
|
out := map[string]float64{}
|
|
raw, err := os.ReadFile(fanObservationStatePath)
|
|
if err != nil || len(raw) == 0 {
|
|
return out
|
|
}
|
|
var persisted fanObservationState
|
|
if json.Unmarshal(raw, &persisted) != nil {
|
|
return out
|
|
}
|
|
for name, rpm := range persisted.MaxRPM {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || rpm <= 0 {
|
|
continue
|
|
}
|
|
out[name] = rpm
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ObservedFanMaxRPM returns the per-fan observed peak RPM map persisted by
|
|
// fan-stress SAT runs, or nil if none is recorded yet. It reads the file
|
|
// directly without touching the in-process observation cache or its lock, so
|
|
// read-only consumers (the /topo web view) can call it without perturbing a
|
|
// concurrent SAT run's peak tracking.
|
|
func ObservedFanMaxRPM() map[string]float64 {
|
|
out := readPersistedFanMaxRPM()
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func loadFanObservationLocked() {
|
|
if fanObservationInit {
|
|
return
|
|
}
|
|
fanObservationInit = true
|
|
fanObservation.MaxRPM = readPersistedFanMaxRPM()
|
|
}
|
|
|
|
func saveFanObservationLocked() {
|
|
if len(fanObservation.MaxRPM) == 0 {
|
|
return
|
|
}
|
|
dir := filepath.Dir(fanObservationStatePath)
|
|
if dir == "" || dir == "." {
|
|
dir = "/var/log/bee-sat"
|
|
}
|
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
|
return
|
|
}
|
|
raw, err := json.MarshalIndent(fanObservation, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
_ = os.WriteFile(fanObservationStatePath, raw, 0644)
|
|
}
|
|
|
|
func updateFanObservation(fans []FanReading, now time.Time) {
|
|
if len(fans) == 0 {
|
|
return
|
|
}
|
|
fanObservationMu.Lock()
|
|
defer fanObservationMu.Unlock()
|
|
loadFanObservationLocked()
|
|
changed := false
|
|
for _, fan := range fans {
|
|
name := strings.TrimSpace(fan.Name)
|
|
if name == "" || fan.RPM <= 0 {
|
|
continue
|
|
}
|
|
currentMax := fanObservation.MaxRPM[name]
|
|
if fan.RPM <= currentMax {
|
|
delete(fanPeakCandidates, name)
|
|
continue
|
|
}
|
|
if cand, ok := fanPeakCandidates[name]; ok {
|
|
if now.Sub(cand.FirstSeen) >= fanObservationMinPeakHold {
|
|
newMax := math.Max(cand.RPM, fan.RPM)
|
|
if newMax > currentMax {
|
|
fanObservation.MaxRPM[name] = normalizeObservedFanMaxRPM(newMax)
|
|
changed = true
|
|
}
|
|
delete(fanPeakCandidates, name)
|
|
continue
|
|
}
|
|
if fan.RPM > cand.RPM {
|
|
fanPeakCandidates[name] = fanPeakCandidate{FirstSeen: cand.FirstSeen, RPM: fan.RPM}
|
|
}
|
|
continue
|
|
}
|
|
fanPeakCandidates[name] = fanPeakCandidate{FirstSeen: now, RPM: fan.RPM}
|
|
}
|
|
if changed {
|
|
saveFanObservationLocked()
|
|
}
|
|
}
|
|
|
|
func estimateFanDutyCyclePctFromObservation(fans []FanReading) (float64, bool) {
|
|
if len(fans) == 0 {
|
|
return 0, false
|
|
}
|
|
fanObservationMu.Lock()
|
|
defer fanObservationMu.Unlock()
|
|
loadFanObservationLocked()
|
|
var samples []float64
|
|
for _, fan := range fans {
|
|
name := strings.TrimSpace(fan.Name)
|
|
if name == "" || fan.RPM <= 0 {
|
|
continue
|
|
}
|
|
maxRPM := fanObservation.MaxRPM[name]
|
|
if maxRPM <= 0 {
|
|
continue
|
|
}
|
|
pct := fan.RPM / maxRPM * 100.0
|
|
if pct > 100 {
|
|
pct = 100
|
|
}
|
|
if pct < 0 {
|
|
pct = 0
|
|
}
|
|
samples = append(samples, pct)
|
|
}
|
|
if len(samples) == 0 {
|
|
return 0, false
|
|
}
|
|
return benchmarkMean(samples), true
|
|
}
|
|
|
|
// parseFanSpeeds parses "ipmitool sdr type Fan" output.
|
|
// Handles two formats:
|
|
//
|
|
// Old: "FAN1 | 2400.000 | RPM | ok" (value in col[1], unit in col[2])
|
|
// New: "FAN1 | 41h | ok | 29.1 | 4340 RPM" (value+unit combined in last col)
|
|
func parseFanSpeeds(raw string) []FanReading {
|
|
var fans []FanReading
|
|
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 2 {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(parts[0])
|
|
// Find the first field that contains "RPM" (either as a standalone unit or inline)
|
|
rpmVal := 0.0
|
|
found := false
|
|
for _, p := range parts[1:] {
|
|
p = strings.TrimSpace(p)
|
|
if !strings.Contains(strings.ToUpper(p), "RPM") {
|
|
continue
|
|
}
|
|
if strings.EqualFold(p, "RPM") {
|
|
continue // unit-only column in old format; value is in previous field
|
|
}
|
|
val, err := parseFanRPMValue(p)
|
|
if err == nil {
|
|
rpmVal = val
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
// Old format: unit "RPM" is in col[2], value is in col[1]
|
|
if !found && len(parts) >= 3 && strings.EqualFold(strings.TrimSpace(parts[2]), "RPM") {
|
|
valStr := strings.TrimSpace(parts[1])
|
|
if !strings.EqualFold(valStr, "na") && !strings.EqualFold(valStr, "disabled") && valStr != "" {
|
|
if val, err := parseFanRPMValue(valStr); err == nil {
|
|
rpmVal = val
|
|
found = true
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
continue
|
|
}
|
|
fans = append(fans, FanReading{Name: name, RPM: rpmVal})
|
|
}
|
|
return fans
|
|
}
|
|
|
|
func parseFanRPMValue(raw string) (float64, error) {
|
|
fields := strings.Fields(strings.TrimSpace(strings.ReplaceAll(raw, ",", "")))
|
|
if len(fields) == 0 {
|
|
return 0, strconv.ErrSyntax
|
|
}
|
|
return strconv.ParseFloat(fields[0], 64)
|
|
}
|
|
|
|
func sampleFanSpeedsViaSensorsJSON() ([]FanReading, error) {
|
|
out, err := exec.Command("sensors", "-j").Output()
|
|
if err != nil || len(out) == 0 {
|
|
return nil, err
|
|
}
|
|
var doc map[string]map[string]any
|
|
if err := json.Unmarshal(out, &doc); err != nil {
|
|
return nil, err
|
|
}
|
|
chips := make([]string, 0, len(doc))
|
|
for chip := range doc {
|
|
chips = append(chips, chip)
|
|
}
|
|
sort.Strings(chips)
|
|
var fans []FanReading
|
|
seen := map[string]struct{}{}
|
|
for _, chip := range chips {
|
|
features := doc[chip]
|
|
names := make([]string, 0, len(features))
|
|
for name := range features {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
feature, ok := features[name].(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
rpm, ok := firstFanInputValue(feature)
|
|
if !ok || rpm <= 0 {
|
|
continue
|
|
}
|
|
label := strings.TrimSpace(name)
|
|
if chip != "" && !strings.Contains(strings.ToLower(label), strings.ToLower(chip)) {
|
|
label = chip + " / " + label
|
|
}
|
|
if _, ok := seen[label]; ok {
|
|
continue
|
|
}
|
|
seen[label] = struct{}{}
|
|
fans = append(fans, FanReading{Name: label, RPM: rpm})
|
|
}
|
|
}
|
|
return fans, nil
|
|
}
|
|
|
|
func sampleFanDutyCyclePctFromFans(fans []FanReading) (float64, bool, bool) {
|
|
if len(fans) == 0 {
|
|
return 0, false, false
|
|
}
|
|
if pct, ok := estimateFanDutyCyclePctFromObservation(fans); ok {
|
|
return pct, true, true
|
|
}
|
|
return 0, false, false
|
|
}
|
|
|
|
func parseFanDutyCyclePctSensorsJSON(raw []byte) (float64, bool) {
|
|
var doc map[string]map[string]any
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
return 0, false
|
|
}
|
|
var samples []float64
|
|
for _, features := range doc {
|
|
for name, feature := range features {
|
|
if strings.EqualFold(name, "Adapter") {
|
|
continue
|
|
}
|
|
featureMap, ok := feature.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if duty, ok := firstFanDutyValue(name, featureMap); ok {
|
|
samples = append(samples, duty)
|
|
}
|
|
}
|
|
}
|
|
if len(samples) == 0 {
|
|
return 0, false
|
|
}
|
|
return benchmarkMean(samples), true
|
|
}
|
|
|
|
func firstFanDutyValue(featureName string, feature map[string]any) (float64, bool) {
|
|
featureName = strings.ToLower(strings.TrimSpace(featureName))
|
|
if strings.Contains(featureName, "enable") || strings.Contains(featureName, "mode") || strings.Contains(featureName, "alarm") {
|
|
return 0, false
|
|
}
|
|
if strings.Contains(featureName, "pwm") {
|
|
for _, key := range []string{"input", "value", "current"} {
|
|
if value, ok := feature[key]; ok {
|
|
if duty, parsed := parseFanDutyValue(value); parsed {
|
|
return duty, true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
keys := make([]string, 0, len(feature))
|
|
for key := range feature {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
lower := strings.ToLower(key)
|
|
if !strings.Contains(lower, "pwm") {
|
|
continue
|
|
}
|
|
if strings.Contains(lower, "enable") || strings.Contains(lower, "mode") || strings.Contains(lower, "alarm") {
|
|
continue
|
|
}
|
|
if duty, parsed := parseFanDutyValue(feature[key]); parsed {
|
|
return duty, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func parseFanDutyValue(value any) (float64, bool) {
|
|
switch v := value.(type) {
|
|
case float64:
|
|
return normalizePWMAsDutyPct(v)
|
|
case string:
|
|
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
|
|
return normalizePWMAsDutyPct(f)
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func normalizePWMAsDutyPct(raw float64) (float64, bool) {
|
|
if raw < 0 {
|
|
return 0, false
|
|
}
|
|
if raw <= 100 {
|
|
return raw, true
|
|
}
|
|
if raw <= 255 {
|
|
return raw / 255.0 * 100.0, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func firstFanInputValue(feature map[string]any) (float64, bool) {
|
|
return firstSensorInputValue(feature, "fan")
|
|
}
|
|
|
|
// sampleCPUMaxTemp returns the highest CPU/inlet temperature from ipmitool or sensors.
|
|
func sampleCPUMaxTemp() float64 {
|
|
out, err := exec.Command("ipmitool", "sdr", "type", "Temperature").Output()
|
|
if err != nil {
|
|
return sampleCPUTempViaSensors()
|
|
}
|
|
return parseIPMIMaxTemp(string(out))
|
|
}
|
|
|
|
// parseIPMIMaxTemp extracts the maximum temperature from "ipmitool sdr type Temperature".
|
|
func parseIPMIMaxTemp(raw string) float64 {
|
|
var max float64
|
|
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
|
|
parts := strings.Split(line, "|")
|
|
if len(parts) < 3 {
|
|
continue
|
|
}
|
|
unit := strings.TrimSpace(parts[2])
|
|
if !strings.Contains(strings.ToLower(unit), "degrees") {
|
|
continue
|
|
}
|
|
valStr := strings.TrimSpace(parts[1])
|
|
if strings.EqualFold(valStr, "na") || valStr == "" {
|
|
continue
|
|
}
|
|
val, err := strconv.ParseFloat(valStr, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if val > max {
|
|
max = val
|
|
}
|
|
}
|
|
return max
|
|
}
|
|
|
|
// sampleCPUTempViaSensors falls back to lm-sensors when ipmitool is unavailable.
|
|
func sampleCPUTempViaSensors() float64 {
|
|
out, err := exec.Command("sensors", "-u").Output()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
var max float64
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
if !strings.HasSuffix(fields[0], "_input:") {
|
|
continue
|
|
}
|
|
val, err := strconv.ParseFloat(fields[1], 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if val > 0 && val < 150 && val > max {
|
|
max = val
|
|
}
|
|
}
|
|
return max
|
|
}
|
|
|
|
// sampleSystemPowerResolved reads system power via the global autotune source,
|
|
// falling back to the historical heuristic before autotune or when degraded.
|
|
func sampleSystemPowerResolved() (float64, string, string) {
|
|
now := time.Now()
|
|
current, decision, err := SampleSystemPowerResolved("")
|
|
systemPowerCacheMu.Lock()
|
|
defer systemPowerCacheMu.Unlock()
|
|
if err != nil {
|
|
current = 0
|
|
}
|
|
value, updated := effectiveSystemPowerReading(systemPowerCache, current, decision.EffectiveSource, decision.Mode, decision.Reason, now)
|
|
systemPowerCache = updated
|
|
return value, updated.Source, updated.Mode
|
|
}
|
|
|
|
// parseDCMIPowerReading extracts the instantaneous power reading from ipmitool dcmi output.
|
|
// Sample: " Instantaneous power reading: 500 Watts"
|
|
func parseDCMIPowerReading(raw string) float64 {
|
|
for _, line := range strings.Split(raw, "\n") {
|
|
if !strings.Contains(strings.ToLower(line), "instantaneous") {
|
|
continue
|
|
}
|
|
parts := strings.Fields(line)
|
|
for i, p := range parts {
|
|
if strings.EqualFold(p, "Watts") && i > 0 {
|
|
val, err := strconv.ParseFloat(parts[i-1], 64)
|
|
if err == nil {
|
|
return val
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func effectiveSystemPowerReading(cache cachedPowerReading, current float64, source, mode, reason string, now time.Time) (float64, cachedPowerReading) {
|
|
if current > 0 {
|
|
cache = cachedPowerReading{Value: current, Source: source, Mode: mode, Reason: reason, UpdatedAt: now}
|
|
return current, cache
|
|
}
|
|
if cache.Value > 0 && !cache.UpdatedAt.IsZero() && now.Sub(cache.UpdatedAt) <= systemPowerHoldTTL {
|
|
return cache.Value, cache
|
|
}
|
|
return 0, cache
|
|
}
|
|
|
|
// analyzeMaxTemp returns the maximum value of the given extractor across all rows.
|
|
func analyzeMaxTemp(rows []FanStressRow, extract func(FanStressRow) float64) float64 {
|
|
var max float64
|
|
for _, row := range rows {
|
|
if v := extract(row); v > max {
|
|
max = v
|
|
}
|
|
}
|
|
return max
|
|
}
|
|
|
|
// WriteFanStressCSV writes the wide-format metrics CSV with one row per second.
|
|
// GPU columns are generated per index in gpuIndices order.
|
|
func WriteFanStressCSV(path string, rows []FanStressRow, gpuIndices []int) error {
|
|
if len(rows) == 0 {
|
|
return os.WriteFile(path, []byte("no data\n"), 0644)
|
|
}
|
|
|
|
var b strings.Builder
|
|
|
|
// Header: fixed system columns + per-GPU columns.
|
|
b.WriteString("timestamp_utc,elapsed_sec,phase,fan_avg_rpm,fan_min_rpm,fan_max_rpm,cpu_max_temp_c,sys_power_w")
|
|
for _, idx := range gpuIndices {
|
|
fmt.Fprintf(&b, ",gpu%d_temp_c,gpu%d_usage_pct,gpu%d_power_w,gpu%d_clock_mhz,gpu%d_throttled",
|
|
idx, idx, idx, idx, idx)
|
|
}
|
|
b.WriteRune('\n')
|
|
|
|
for _, row := range rows {
|
|
favg, fmin, fmax := fanRPMStats(row.Fans)
|
|
fmt.Fprintf(&b, "%s,%.1f,%s,%.0f,%.0f,%.0f,%.1f,%.1f",
|
|
row.TimestampUTC,
|
|
row.ElapsedSec,
|
|
row.Phase,
|
|
favg, fmin, fmax,
|
|
row.CPUMaxTempC,
|
|
row.SysPowerW,
|
|
)
|
|
gpuByIdx := make(map[int]GPUStressMetric, len(row.GPUs))
|
|
for _, g := range row.GPUs {
|
|
gpuByIdx[g.Index] = g
|
|
}
|
|
for _, idx := range gpuIndices {
|
|
g := gpuByIdx[idx]
|
|
throttled := 0
|
|
if g.Throttled {
|
|
throttled = 1
|
|
}
|
|
fmt.Fprintf(&b, ",%.1f,%.1f,%.1f,%.0f,%d",
|
|
g.TempC, g.UsagePct, g.PowerW, g.ClockMHz, throttled)
|
|
}
|
|
b.WriteRune('\n')
|
|
}
|
|
|
|
return os.WriteFile(path, []byte(b.String()), 0644)
|
|
}
|
|
|
|
// WriteFanSensorsCSV writes individual fan sensor readings in long (tidy) format.
|
|
func WriteFanSensorsCSV(path string, rows []FanStressRow) error {
|
|
var b strings.Builder
|
|
b.WriteString("timestamp_utc,elapsed_sec,phase,fan_name,rpm\n")
|
|
for _, row := range rows {
|
|
for _, f := range row.Fans {
|
|
fmt.Fprintf(&b, "%s,%.1f,%s,%s,%.0f\n",
|
|
row.TimestampUTC, row.ElapsedSec, row.Phase, f.Name, f.RPM)
|
|
}
|
|
}
|
|
return os.WriteFile(path, []byte(b.String()), 0644)
|
|
}
|
|
|
|
// fanRPMStats computes average, min, max RPM across all fans in a sample row.
|
|
func fanRPMStats(fans []FanReading) (avg, min, max float64) {
|
|
if len(fans) == 0 {
|
|
return 0, 0, 0
|
|
}
|
|
min = fans[0].RPM
|
|
max = fans[0].RPM
|
|
var total float64
|
|
for _, f := range fans {
|
|
total += f.RPM
|
|
if f.RPM < min {
|
|
min = f.RPM
|
|
}
|
|
if f.RPM > max {
|
|
max = f.RPM
|
|
}
|
|
}
|
|
return total / float64(len(fans)), min, max
|
|
}
|