From 3a00ea81f300060626c8720c870185c3d637b8f8 Mon Sep 17 00:00:00 2001 From: "Harper, Jason M" Date: Fri, 10 Apr 2026 10:30:04 -0700 Subject: [PATCH] fix: cap telemetry chart Y-axis when outliers distort scale Turbostat and sar telemetry data occasionally contains single readings that are orders of magnitude too high, compressing useful data into a tiny band at the bottom of HTML charts. Add percentile-based outlier detection that sets a hard Y-axis max when the actual maximum exceeds P99 by more than 50%, keeping charts readable while preserving outlier values in tooltips. JSON and XLSX outputs are unaffected. Co-Authored-By: Claude Opus 4.6 --- cmd/telemetry/telemetry_renderers.go | 27 +++++++ cmd/telemetry/telemetry_renderers_test.go | 92 +++++++++++++++++++++++ internal/report/render_html.go | 7 +- 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 cmd/telemetry/telemetry_renderers_test.go diff --git a/cmd/telemetry/telemetry_renderers.go b/cmd/telemetry/telemetry_renderers.go index 4bd995d2..f03d4380 100644 --- a/cmd/telemetry/telemetry_renderers.go +++ b/cmd/telemetry/telemetry_renderers.go @@ -15,11 +15,38 @@ import ( "strings" ) +// computeAxisMax examines all datasets and returns a Y-axis hard max string. +// If outliers are detected (actual max > P99 * 1.5), it returns a value slightly +// above P99. Otherwise it returns "" (no constraint, use auto-scale). +func computeAxisMax(data [][]float64) string { + var all []float64 + for _, dataset := range data { + all = append(all, dataset...) + } + if len(all) < 4 { + return "" + } + sorted := make([]float64, len(all)) + copy(sorted, all) + slices.Sort(sorted) + p99Idx := int(float64(len(sorted)-1) * 0.99) + p99 := sorted[p99Idx] + actualMax := sorted[len(sorted)-1] + if p99 > 0 && actualMax > p99*1.5 { + return fmt.Sprintf("%f", p99*1.1) + } + return "" +} + func telemetryTableHTMLRenderer(tableValues table.TableValues, data [][]float64, datasetNames []string, chartConfig report.ChartTemplateStruct, datasetHiddenFlags []bool) string { if len(tableValues.Fields) == 0 { slog.Error("no fields in table", slog.String("table", tableValues.Name)) return "" } + // Auto-detect outliers and set hard Y-axis max for auto-scaled charts + if chartConfig.YaxisMax == "" && chartConfig.SuggestedMax == "0" { + chartConfig.YaxisMax = computeAxisMax(data) + } tsFieldIdx := 0 var timestamps []string for i := range tableValues.Fields[0].Values { diff --git a/cmd/telemetry/telemetry_renderers_test.go b/cmd/telemetry/telemetry_renderers_test.go new file mode 100644 index 00000000..1b426648 --- /dev/null +++ b/cmd/telemetry/telemetry_renderers_test.go @@ -0,0 +1,92 @@ +// Copyright (C) 2021-2025 Intel Corporation +// SPDX-License-Identifier: BSD-3-Clause + +package telemetry + +import ( + "fmt" + "testing" +) + +func TestComputeAxisMax(t *testing.T) { + tests := []struct { + name string + data [][]float64 + wantEmpty bool // true means expect "" (no constraint) + wantAbove float64 + wantBelow float64 + }{ + { + name: "normal data, no outliers", + data: [][]float64{{10, 12, 11, 13, 10, 12, 11, 14, 10, 13}}, + wantEmpty: true, + }, + { + name: "single extreme outlier", + data: [][]float64{{10, 12, 11, 13, 10, 12, 11, 14, 10, 10000}}, + wantEmpty: false, + wantAbove: 13, + wantBelow: 10000, + }, + { + name: "all identical values", + data: [][]float64{{5, 5, 5, 5, 5, 5, 5, 5, 5, 5}}, + wantEmpty: true, + }, + { + name: "too few data points", + data: [][]float64{{10, 20, 30}}, + wantEmpty: true, + }, + { + name: "empty data", + data: [][]float64{}, + wantEmpty: true, + }, + { + name: "multiple datasets, one with outlier", + data: [][]float64{{10, 12, 11, 13, 10}, {11, 14, 10, 13, 50000}}, + wantEmpty: false, + wantAbove: 13, + wantBelow: 50000, + }, + { + name: "gradual increase, no outlier", + data: [][]float64{{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}}, + wantEmpty: true, + }, + { + name: "all zeros", + data: [][]float64{{0, 0, 0, 0, 0}}, + wantEmpty: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeAxisMax(tt.data) + if tt.wantEmpty { + if got != "" { + t.Errorf("computeAxisMax() = %q, want empty string", got) + } + return + } + if got == "" { + t.Errorf("computeAxisMax() = empty string, want a constraining value") + return + } + // Parse and check bounds + var val float64 + n, err := fmt.Sscanf(got, "%f", &val) + if err != nil || n != 1 { + t.Errorf("computeAxisMax() = %q, could not parse as float", got) + return + } + if val <= tt.wantAbove { + t.Errorf("computeAxisMax() = %f, want > %f", val, tt.wantAbove) + } + if val >= tt.wantBelow { + t.Errorf("computeAxisMax() = %f, want < %f", val, tt.wantBelow) + } + }) + } +} diff --git a/internal/report/render_html.go b/internal/report/render_html.go index 01786950..a3a0da4b 100644 --- a/internal/report/render_html.go +++ b/internal/report/render_html.go @@ -448,7 +448,8 @@ new Chart(document.getElementById('{{.ID}}'), { display: true }, suggestedMin: {{.SuggestedMin}}, - suggestedMax: {{.SuggestedMax}}, + suggestedMax: {{.SuggestedMax}},{{if .YaxisMax}} + max: {{.YaxisMax}},{{end}} } }, plugins: { @@ -525,7 +526,8 @@ new Chart(document.getElementById('{{.ID}}'), { display: true }, suggestedMin: {{.SuggestedMin}}, - suggestedMax: {{.SuggestedMax}}, + suggestedMax: {{.SuggestedMax}},{{if .YaxisMax}} + max: {{.YaxisMax}},{{end}} } }, plugins: { @@ -590,6 +592,7 @@ type ChartTemplateStruct struct { AspectRatio string SuggestedMin string SuggestedMax string + YaxisMax string // hard max for Y-axis; "" means no constraint } // CreateFieldNameWithDescription creates HTML for a field name with optional description tooltip