diff --git a/audit/internal/platform/sat_fan_stress.go b/audit/internal/platform/sat_fan_stress.go index 4648b25..ccaf30d 100644 --- a/audit/internal/platform/sat_fan_stress.go +++ b/audit/internal/platform/sat_fan_stress.go @@ -12,6 +12,7 @@ import ( "strconv" "strings" "sync" + "syscall" "time" ) @@ -35,29 +36,6 @@ type FanReading struct { 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 @@ -76,8 +54,6 @@ type fanPeakCandidate struct { } var ( - systemPowerCacheMu sync.Mutex - systemPowerCache cachedPowerReading fanObservationMu sync.Mutex fanObservation fanObservationState fanObservationInit bool @@ -157,10 +133,16 @@ func (s *System) RunFanCheck(ctx context.Context, baseDir string, opts FanCheckO 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. + // GPU load is the hottest sustained NVIDIA load we have — dcgmproftester + // -t 1004 / targeted_power, the same engine the Power/Thermal Fit + // benchmark uses (resolveBenchmarkPowerLoadCommand) — not the + // compute-throughput bee-gpu-burn, which tops out well below TDP and so + // never demands the fans' true ceiling. + // + // Sources reach full load at different times (stressapptest is instant; a + // dcgmproftester kernel compiles and ramps), so the plateau clock does not + // start until every launched source reports its process running, plus a + // fixed GPU ramp grace. loadCtx, loadCancel := context.WithTimeout(ctx, time.Duration(opts.MaxLoadSec)*time.Second) defer loadCancel() @@ -190,14 +172,20 @@ func (s *System) RunFanCheck(ctx context.Context, baseDir string, opts FanCheckO 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") + cmd, label, err := buildFanCheckGPULoadCmd(loadCtx, vendor, opts.MaxLoadSec, opts.GPUIndices) + if err != nil || cmd == nil { + logFunc("GPU load unavailable: " + errString(err)) + appendSATVerboseLog(verboseLog, "gpu load unavailable: "+errString(err)) started <- false return } - logFunc("GPU load running (" + vendor + ")") + if err := cmd.Start(); err != nil { + logFunc("GPU load failed to start: " + err.Error()) + appendSATVerboseLog(verboseLog, "gpu load start error: "+err.Error()) + started <- false + return + } + logFunc("GPU load running (" + label + ")") started <- true _ = cmd.Wait() }() @@ -226,19 +214,39 @@ func (s *System) RunFanCheck(ctx context.Context, baseDir string, opts FanCheckO 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. + // ── Sample loop with IPMI-hang protection. + // + // Under full load ipmitool over KCS can take tens of seconds per call or + // wedge outright. So: every fan read is time-boxed (readFansBounded runs + // it in a goroutine we abandon on timeout — a wedged KCS read can never + // block this loop), and the polling interval backs off geometrically when + // reads are slow and tightens again when they recover. A plateau is only + // declared while telemetry is healthy (interval near the floor); a + // degraded run just rides out to MaxLoadSec and records the peak it saw. + const ( + fanPollFloor = 1 * time.Second + fanPollCeil = 30 * time.Second + fanReadTMO = 8 * time.Second + ) type fanState struct { peak float64 lastRiseSec float64 } fanBy := map[string]*fanState{} - var rows []FanStressRow rampConfirmed := false plateauReached := false aborted := false + degraded := false + goodSamples := 0 + poll := fanPollFloor + + csvPath := filepath.Join(runDir, "fan-sensors.csv") + _ = os.WriteFile(csvPath, []byte("elapsed_sec,fan_name,rpm\n"), 0644) + csvFile, _ := os.OpenFile(csvPath, os.O_APPEND|os.O_WRONLY, 0644) + if csvFile != nil { + defer csvFile.Close() + } - ticker := time.NewTicker(time.Second) - defer ticker.Stop() loop: for { select { @@ -247,13 +255,36 @@ loop: break loop case <-loadCtx.Done(): break loop // MaxLoadSec reached - case <-ticker.C: + case <-time.After(poll): } elapsed := time.Since(start).Seconds() - row := sampleFanStressRow(opts.GPUIndices, "load", elapsed) - rows = append(rows, row) - for _, f := range row.Fans { + readStart := time.Now() + fans, ok := readFansBounded(fanReadTMO) + readDur := time.Since(readStart) + + // Adapt the interval to how ipmitool is behaving. + switch { + case !ok || readDur > fanReadTMO*3/4: + if poll < fanPollCeil { + poll = minDuration(poll*2, fanPollCeil) + degraded = true + appendSATVerboseLog(verboseLog, fmt.Sprintf("[%.0fs] ipmitool slow (%.1fs, ok=%v) — polling backed off to %s", + elapsed, readDur.Seconds(), ok, poll)) + logFunc(fmt.Sprintf("IPMI slow — fan polling backed off to %s", poll)) + } + case poll > fanPollFloor && readDur < fanPollFloor: + poll = maxDuration(poll*2/3, fanPollFloor) + } + if !ok { + continue + } + goodSamples++ + + for _, f := range fans { + if csvFile != nil { + fmt.Fprintf(csvFile, "%.0f,%s,%.0f\n", elapsed, f.Name, f.RPM) + } st := fanBy[f.Name] if st == nil { fanBy[f.Name] = &fanState{peak: f.RPM, lastRiseSec: elapsed} @@ -270,7 +301,11 @@ loop: } } - if elapsed >= float64(opts.MinLoadSec) && time.Since(readyAt) >= time.Duration(opts.PlateauHoldSec)*time.Second && len(fanBy) > 0 { + // Only trust a plateau while telemetry is healthy and we have enough + // recent samples to have actually seen a flat window. + healthy := poll <= 2*fanPollFloor && goodSamples >= 5 + if healthy && rampConfirmed && 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) { @@ -278,7 +313,7 @@ loop: break } } - if allFlat && rampConfirmed { + if allFlat { plateauReached = true logFunc(fmt.Sprintf("All %d fans plateaued at %.0fs of load", len(fanBy), elapsed)) break loop @@ -295,6 +330,8 @@ loop: return runDir, ctx.Err() } + loadDur := time.Since(start).Seconds() + // ── Verdict. statuses := readFanStatuses() var summary strings.Builder @@ -304,19 +341,12 @@ loop: 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, "load_duration_sec=%.0f\n", loadDur) + fmt.Fprintf(&summary, "fan_samples=%d\n", goodSamples) + fmt.Fprintf(&summary, "telemetry_degraded=%v\n", degraded) + if t := boundedGPUMaxTemp(opts.GPUIndices); t > 0 { + fmt.Fprintf(&summary, "gpu_temp_c=%.0f\n", t) } - 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)) @@ -349,14 +379,126 @@ loop: } 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 errString(err error) string { + if err == nil { + return "no GPU stress tool" + } + return err.Error() +} + +func minDuration(a, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} + +func maxDuration(a, b time.Duration) time.Duration { + if a > b { + return a + } + return b +} + +// readFansBounded runs "ipmitool sdr type Fan" but never blocks the caller +// longer than timeout: the read happens in a goroutine that is abandoned if it +// does not return in time (a KCS read wedged in uninterruptible I/O cannot be +// killed, so we leave it and move on). ok=false means "no usable sample this +// tick" — the caller must treat that as missing data, not as a flat fan. +func readFansBounded(timeout time.Duration) ([]FanReading, bool) { + type result struct { + fans []FanReading + ok bool + } + ch := make(chan result, 1) + go func() { + out, err := exec.Command("ipmitool", "sdr", "type", "Fan").Output() + if err != nil { + ch <- result{} + return + } + fans := parseFanSpeeds(string(out)) + if len(fans) == 0 { + ch <- result{} + return + } + ch <- result{fans, true} + }() + select { + case r := <-ch: + if r.ok { + updateFanObservation(r.fans, time.Now()) + } + return r.fans, r.ok + case <-time.After(timeout): + return nil, false + } +} + +// boundedGPUMaxTemp returns the hottest GPU temperature via a single +// time-boxed nvidia-smi call, or 0 if unavailable. +func boundedGPUMaxTemp(gpuIndices []int) float64 { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + args := []string{"--query-gpu=temperature.gpu", "--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.CommandContext(ctx, "nvidia-smi", args...).Output() + if err != nil { + return 0 + } + var max float64 + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if v, err := strconv.ParseFloat(strings.TrimSpace(line), 64); err == nil && v > max { + max = v + } + } + return max +} + +// buildFanCheckGPULoadCmd builds the hottest sustained GPU load for the fan +// check. NVIDIA uses the Power/Thermal Fit engine (dcgmproftester -t 1004 / +// targeted_power); AMD uses the RVS gst stressor. +func buildFanCheckGPULoadCmd(ctx context.Context, vendor string, durSec int, gpuIndices []int) (*exec.Cmd, string, error) { + switch strings.ToLower(vendor) { + case "nvidia": + argv, env, err := resolveBenchmarkPowerLoadCommand(durSec, gpuIndices) + if err != nil { + return nil, "", err + } + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + if len(env) > 0 { + cmd.Env = append(os.Environ(), env...) + } + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + return nil + } + return cmd, "dcgmproftester targeted_power", nil + case "amd": + cmd := buildAMDGPUStressCmd(ctx, durSec) + if cmd == nil { + return nil, "", nil + } + return cmd, "rvs gst", nil + } + return nil, "", nil +} + func applyFanCheckDefaults(o *FanCheckOptions) { if o.PlateauHoldSec <= 0 { o.PlateauHoldSec = 60 @@ -459,68 +601,6 @@ func ResolveFanMaxRPM(current map[string]float64) map[string]float64 { 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() @@ -940,21 +1020,6 @@ func sampleCPUTempViaSensors() float64 { 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 { @@ -986,76 +1051,6 @@ func effectiveSystemPowerReading(cache cachedPowerReading, current float64, sour 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 { diff --git a/audit/internal/webui/page_topo_diagram.go b/audit/internal/webui/page_topo_diagram.go index 9a0af7c..71bbf22 100644 --- a/audit/internal/webui/page_topo_diagram.go +++ b/audit/internal/webui/page_topo_diagram.go @@ -614,10 +614,10 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string }})) } - // Cooling fans — one small square per fan (no PCIe/CPU affinity, arbitrary - // count, so a wrapping flex row like PSUs rather than SVG boxes). Each - // square is sized by rpm / observed-max-rpm and carries a fan glyph that - // spins via CSS — faster when the fan is spinning faster. + // Cooling fans — one small clickable square per fan (no PCIe/CPU affinity, + // arbitrary count, so a wrapping flex row like PSUs rather than SVG boxes). + // Square SIZE encodes the fan's ceiling RPM (its class); the coloured FILL + // rising from the bottom encodes live duty cycle (current / ceiling). if fans := dedupeFansByName(hw.Sensors); len(fans) > 0 { current := map[string]float64{} for _, f := range fans { @@ -625,26 +625,28 @@ func renderTopoMainDiagram(hw schema.HardwareSnapshot, exportDir string) string current[strings.TrimSpace(f.Name)] = float64(*f.RPM) } } - b.WriteString(renderTopoFanRow(fans, platform.ResolveFanMaxRPM(current))) + b.WriteString(renderTopoFanRow(fans, platform.ResolveFanMaxRPM(current), platform.ObservedFanMaxRPM())) } return topoCard("Topology", b.String()) } -// renderTopoFanRow renders the COOLING row: one clickable square per fan, -// side length scaled by rpm/maxRPM and a fan glyph whose spin rate tracks the -// same ratio. maxByName comes from platform.ResolveFanMaxRPM — it already has -// an entry for every fan (persisted peak, else peer peak, else current RPM), -// so no fallback logic lives here. -func renderTopoFanRow(fans []schema.HardwareFanSensor, maxByName map[string]float64) string { +// renderTopoFanRow renders the COOLING row. ceilByName (from +// platform.ResolveFanMaxRPM) has a value for every fan and drives tile size. +// observedByName (from platform.ObservedFanMaxRPM) holds only ceilings that +// were actually measured under load — a fan present there gets a duty-cycle +// fill; one that isn't shows no fill (ceiling not measured yet). +func renderTopoFanRow(fans []schema.HardwareFanSensor, ceilByName, observedByName map[string]float64) string { const ( - fanTileMin = 30 // px, a stalled / slowest fan - fanTileMax = 58 // px, a fan at its ceiling + fanTileMin = 34 // px, the smallest-ceiling fan + fanTileMax = 60 // px, the largest-ceiling fan ) - var tally topoStatusTally - for _, f := range fans { - tally.add(classifyTopoSeverity(f.Status)) + ceilMax := 0.0 + for _, v := range ceilByName { + if v > ceilMax { + ceilMax = v + } } var b strings.Builder @@ -653,54 +655,84 @@ func renderTopoFanRow(fans []schema.HardwareFanSensor, maxByName map[string]floa b.WriteString(`
`) for _, f := range fans { name := strings.TrimSpace(f.Name) - fill, stroke, text := topoSeverityColors(classifyTopoSeverity(f.Status)) + _, stroke, text := topoSeverityColors(classifyTopoSeverity(f.Status)) + ceil := ceilByName[name] - denom := maxByName[name] - ratio := 0.0 - title := name - if f.RPM != nil { - if denom > 0 { - ratio = float64(*f.RPM) / denom + sizeRatio := 1.0 + if ceilMax > 0 && ceil > 0 { + sizeRatio = ceil / ceilMax + } + side := fanTileMin + int(float64(fanTileMax-fanTileMin)*sizeRatio+0.5) + glyphSz := side * 7 / 16 + + // Duty cycle: only when the ceiling was actually measured under load. + duty := -1.0 + if _, measured := observedByName[name]; measured && ceil > 0 && f.RPM != nil { + duty = float64(*f.RPM) / ceil * 100 + if duty < 0 { + duty = 0 } - if ratio < 0 { - ratio = 0 + if duty > 100 { + duty = 100 } - if ratio > 1 { - ratio = 1 - } - if denom > float64(*f.RPM) { - title = fmt.Sprintf("%s · %d RPM (max %d)", name, *f.RPM, int(denom)) - } else { - title = fmt.Sprintf("%s · %d RPM", name, *f.RPM) - } - } else { - title = name + " · no reading" } - side := fanTileMin + int(float64(fanTileMax-fanTileMin)*ratio+0.5) - // Spin period: 2.6s at rest down to 0.5s at the observed peak. A fan - // with no reading doesn't spin. - spin := "" + title := name + switch { + case f.RPM == nil: + title = name + " · no reading" + case duty >= 0: + title = fmt.Sprintf("%s · %d RPM · %.0f%% duty (ceiling %d)", name, *f.RPM, duty, int(ceil)) + default: + title = fmt.Sprintf("%s · %d RPM · ceiling not measured — run Fan Ceiling Check", name, *f.RPM) + } + + glyph := fmt.Sprintf(`` if f.RPM != nil && *f.RPM > 0 { - period := 2.6 - 2.1*ratio - spin = fmt.Sprintf(`` - } else { - spin = fmt.Sprintf(`` + period := fanSpinPeriodSec(float64(*f.RPM)) + glyph = fmt.Sprintf(`` + } + + fillBar := "" + if duty >= 0 { + fillBar = fmt.Sprintf(`
`, duty, stroke) } fmt.Fprintf(&b, `
%s
`, - html.EscapeString(title), side, side, fill, stroke, text, spin) + `style="position:relative;overflow:hidden;width:%dpx;height:%dpx;display:flex;align-items:center;justify-content:center;`+ + `border-radius:5px;background:var(--surface-2);border:1px solid %s;color:%s;cursor:pointer">`+ + `%s%s
`, + html.EscapeString(title), side, side, stroke, text, fillBar, glyph) } b.WriteString(``) - fmt.Fprintf(&b, `
%d fans · %s · tile size ∝ RPM / observed max
`, - len(fans), html.EscapeString(tally.line())) return b.String() } +// fanSpinPeriodSec maps an absolute fan RPM to a CSS animation period (one +// full turn of the glyph, in seconds). The real period would be 60/RPM — a +// blur at any real fan speed — so it is compressed into a band the eye can +// actually read: at/below fanSpinRPMLo the glyph turns at its slowest still +// clearly-moving rate, at/above fanSpinRPMHi at the fastest rate past which +// faster is indistinguishable (and starts to stutter), linear in between. +func fanSpinPeriodSec(rpm float64) float64 { + const ( + fanSpinRPMLo = 1000.0 + fanSpinRPMHi = 13000.0 + fanSpinSlowSec = 2.2 + fanSpinFastSec = 0.35 + ) + switch { + case rpm <= fanSpinRPMLo: + return fanSpinSlowSec + case rpm >= fanSpinRPMHi: + return fanSpinFastSec + default: + t := (rpm - fanSpinRPMLo) / (fanSpinRPMHi - fanSpinRPMLo) + return fanSpinSlowSec + t*(fanSpinFastSec-fanSpinSlowSec) + } +} + // topoFanSpinStyle emits the keyframes + base class for the spinning fan // glyph once per row. A repeated identical