feat: change statistics render of metric plot to min/max/median

- #263
This commit is contained in:
Christoph Kluge 2024-05-08 16:17:42 +02:00
parent 597bccc080
commit 684cb5a376
9 changed files with 163 additions and 31 deletions

View File

@ -150,9 +150,10 @@ type MetricStatistics {
} }
type StatsSeries { type StatsSeries {
mean: [NullableFloat!]! mean: [NullableFloat!]!
min: [NullableFloat!]! median: [NullableFloat!]!
max: [NullableFloat!]! min: [NullableFloat!]!
max: [NullableFloat!]!
} }
type MetricFootprints { type MetricFootprints {

View File

@ -251,9 +251,10 @@ type ComplexityRoot struct {
} }
StatsSeries struct { StatsSeries struct {
Max func(childComplexity int) int Max func(childComplexity int) int
Mean func(childComplexity int) int Mean func(childComplexity int) int
Min func(childComplexity int) int Median func(childComplexity int) int
Min func(childComplexity int) int
} }
SubCluster struct { SubCluster struct {
@ -1344,6 +1345,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.StatsSeries.Mean(childComplexity), true return e.complexity.StatsSeries.Mean(childComplexity), true
case "StatsSeries.median":
if e.complexity.StatsSeries.Median == nil {
break
}
return e.complexity.StatsSeries.Median(childComplexity), true
case "StatsSeries.min": case "StatsSeries.min":
if e.complexity.StatsSeries.Min == nil { if e.complexity.StatsSeries.Min == nil {
break break
@ -1867,9 +1875,10 @@ type MetricStatistics {
} }
type StatsSeries { type StatsSeries {
mean: [NullableFloat!]! mean: [NullableFloat!]!
min: [NullableFloat!]! median: [NullableFloat!]!
max: [NullableFloat!]! min: [NullableFloat!]!
max: [NullableFloat!]!
} }
type MetricFootprints { type MetricFootprints {
@ -4854,6 +4863,8 @@ func (ec *executionContext) fieldContext_JobMetric_statisticsSeries(ctx context.
switch field.Name { switch field.Name {
case "mean": case "mean":
return ec.fieldContext_StatsSeries_mean(ctx, field) return ec.fieldContext_StatsSeries_mean(ctx, field)
case "median":
return ec.fieldContext_StatsSeries_median(ctx, field)
case "min": case "min":
return ec.fieldContext_StatsSeries_min(ctx, field) return ec.fieldContext_StatsSeries_min(ctx, field)
case "max": case "max":
@ -8814,6 +8825,50 @@ func (ec *executionContext) fieldContext_StatsSeries_mean(ctx context.Context, f
return fc, nil return fc, nil
} }
func (ec *executionContext) _StatsSeries_median(ctx context.Context, field graphql.CollectedField, obj *schema.StatsSeries) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_StatsSeries_median(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
ret = graphql.Null
}
}()
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
ctx = rctx // use context from middleware stack in children
return obj.Median, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.([]schema.Float)
fc.Result = res
return ec.marshalNNullableFloat2ᚕgithubᚗcomᚋClusterCockpitᚋccᚑbackendᚋpkgᚋschemaᚐFloatᚄ(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_StatsSeries_median(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "StatsSeries",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type NullableFloat does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _StatsSeries_min(ctx context.Context, field graphql.CollectedField, obj *schema.StatsSeries) (ret graphql.Marshaler) { func (ec *executionContext) _StatsSeries_min(ctx context.Context, field graphql.CollectedField, obj *schema.StatsSeries) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_StatsSeries_min(ctx, field) fc, err := ec.fieldContext_StatsSeries_min(ctx, field)
if err != nil { if err != nil {
@ -14427,6 +14482,11 @@ func (ec *executionContext) _StatsSeries(ctx context.Context, sel ast.SelectionS
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
out.Invalids++ out.Invalids++
} }
case "median":
out.Values[i] = ec._StatsSeries_median(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "min": case "min":
out.Values[i] = ec._StatsSeries_min(ctx, field, obj) out.Values[i] = ec._StatsSeries_min(ctx, field, obj)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {

View File

@ -263,7 +263,7 @@ func cacheKey(
// For /monitoring/job/<job> and some other places, flops_any and mem_bw need // For /monitoring/job/<job> and some other places, flops_any and mem_bw need
// to be available at the scope 'node'. If a job has a lot of nodes, // to be available at the scope 'node'. If a job has a lot of nodes,
// statisticsSeries should be available so that a min/mean/max Graph can be // statisticsSeries should be available so that a min/median/max Graph can be
// used instead of a lot of single lines. // used instead of a lot of single lines.
func prepareJobData( func prepareJobData(
job *schema.Job, job *schema.Job,

View File

@ -4,7 +4,13 @@
// license that can be found in the LICENSE file. // license that can be found in the LICENSE file.
package util package util
import "golang.org/x/exp/constraints" import (
"fmt"
"math"
"sort"
"golang.org/x/exp/constraints"
)
func Min[T constraints.Ordered](a, b T) T { func Min[T constraints.Ordered](a, b T) T {
if a < b { if a < b {
@ -19,3 +25,36 @@ func Max[T constraints.Ordered](a, b T) T {
} }
return b return b
} }
func sortedCopy(input []float64) []float64 {
sorted := make([]float64, len(input))
copy(sorted, input)
sort.Float64s(sorted)
return sorted
}
func Mean(input []float64) (float64, error) {
if len(input) == 0 {
return math.NaN(), fmt.Errorf("input array is empty: %#v", input)
}
sum := 0.0
for _, n := range input {
sum += n
}
return sum / float64(len(input)), nil
}
func Median(input []float64) (median float64, err error) {
c := sortedCopy(input)
// Even numbers: add the two middle numbers, divide by two (use mean function)
// Odd numbers: Use the middle number
l := len(c)
if l == 0 {
return math.NaN(), fmt.Errorf("input array is empty: %#v", input)
} else if l%2 == 0 {
median, _ = Mean(c[l/2-1 : l/2+1])
} else {
median = c[l/2]
}
return median, nil
}

View File

@ -10,6 +10,8 @@ import (
"math" "math"
"sort" "sort"
"unsafe" "unsafe"
"github.com/ClusterCockpit/cc-backend/internal/util"
) )
type JobData map[string]map[MetricScope]*JobMetric type JobData map[string]map[MetricScope]*JobMetric
@ -36,6 +38,7 @@ type MetricStatistics struct {
type StatsSeries struct { type StatsSeries struct {
Mean []Float `json:"mean"` Mean []Float `json:"mean"`
Median []Float `json:"median"`
Min []Float `json:"min"` Min []Float `json:"min"`
Max []Float `json:"max"` Max []Float `json:"max"`
Percentiles map[int][]Float `json:"percentiles,omitempty"` Percentiles map[int][]Float `json:"percentiles,omitempty"`
@ -120,7 +123,7 @@ func (jd *JobData) Size() int {
for _, metric := range scopes { for _, metric := range scopes {
if metric.StatisticsSeries != nil { if metric.StatisticsSeries != nil {
n += len(metric.StatisticsSeries.Max) n += len(metric.StatisticsSeries.Max)
n += len(metric.StatisticsSeries.Mean) n += len(metric.StatisticsSeries.Median)
n += len(metric.StatisticsSeries.Min) n += len(metric.StatisticsSeries.Min)
} }
@ -149,53 +152,74 @@ func (jm *JobMetric) AddStatisticsSeries() {
} }
} }
min, mean, max := make([]Float, n), make([]Float, n), make([]Float, n) // mean := make([]Float, n)
min, median, max := make([]Float, n), make([]Float, n), make([]Float, n)
i := 0 i := 0
for ; i < m; i++ { for ; i < m; i++ {
smin, ssum, smax := math.MaxFloat32, 0.0, -math.MaxFloat32 seriesCount := len(jm.Series)
// ssum := 0.0
smin, smed, smax := math.MaxFloat32, make([]float64, seriesCount), -math.MaxFloat32
notnan := 0 notnan := 0
for j := 0; j < len(jm.Series); j++ { for j := 0; j < seriesCount; j++ {
x := float64(jm.Series[j].Data[i]) x := float64(jm.Series[j].Data[i])
if math.IsNaN(x) { if math.IsNaN(x) {
continue continue
} }
notnan += 1 notnan += 1
ssum += x // ssum += x
smed[j] = x
smin = math.Min(smin, x) smin = math.Min(smin, x)
smax = math.Max(smax, x) smax = math.Max(smax, x)
} }
if notnan < 3 { if notnan < 3 {
min[i] = NaN min[i] = NaN
mean[i] = NaN // mean[i] = NaN
median[i] = NaN
max[i] = NaN max[i] = NaN
} else { } else {
min[i] = Float(smin) min[i] = Float(smin)
mean[i] = Float(ssum / float64(notnan)) // mean[i] = Float(ssum / float64(notnan))
max[i] = Float(smax) max[i] = Float(smax)
medianRaw, err := util.Median(smed)
if err != nil {
median[i] = NaN
} else {
median[i] = Float(medianRaw)
}
} }
} }
for ; i < n; i++ { for ; i < n; i++ {
min[i] = NaN min[i] = NaN
mean[i] = NaN // mean[i] = NaN
median[i] = NaN
max[i] = NaN max[i] = NaN
} }
if smooth { if smooth {
for i := 2; i < len(mean)-2; i++ { for i := 2; i < len(median)-2; i++ {
if min[i].IsNaN() { if min[i].IsNaN() {
continue continue
} }
min[i] = (min[i-2] + min[i-1] + min[i] + min[i+1] + min[i+2]) / 5 min[i] = (min[i-2] + min[i-1] + min[i] + min[i+1] + min[i+2]) / 5
max[i] = (max[i-2] + max[i-1] + max[i] + max[i+1] + max[i+2]) / 5 max[i] = (max[i-2] + max[i-1] + max[i] + max[i+1] + max[i+2]) / 5
mean[i] = (mean[i-2] + mean[i-1] + mean[i] + mean[i+1] + mean[i+2]) / 5 // mean[i] = (mean[i-2] + mean[i-1] + mean[i] + mean[i+1] + mean[i+2]) / 5
// Reduce Median further
smoothRaw := []float64{float64(median[i-2]), float64(median[i-1]), float64(median[i]), float64(median[i+1]), float64(median[i+2])}
smoothMedian, err := util.Median(smoothRaw)
if err != nil {
median[i] = NaN
} else {
median[i] = Float(smoothMedian)
}
} }
} }
jm.StatisticsSeries = &StatsSeries{Mean: mean, Min: min, Max: max} jm.StatisticsSeries = &StatsSeries{Median: median, Min: min, Max: max} // Mean: mean
} }
func (jd *JobData) AddNodeScope(metric string) bool { func (jd *JobData) AddNodeScope(metric string) bool {

View File

@ -101,7 +101,7 @@
// Calculate Avg from jobMetrics // Calculate Avg from jobMetrics
const jm = jobMetrics.find((jm) => jm.name === fm && jm.scope === "node"); const jm = jobMetrics.find((jm) => jm.name === fm && jm.scope === "node");
if (jm?.metric?.statisticsSeries) { if (jm?.metric?.statisticsSeries) {
mv = round(mean(jm.metric.statisticsSeries.mean), 2); mv = round(mean(jm.metric.statisticsSeries.median), 2);
} else if (jm?.metric?.series?.length > 1) { } else if (jm?.metric?.series?.length > 1) {
const avgs = jm.metric.series.map((jms) => jms.statistics.avg); const avgs = jm.metric.series.map((jms) => jms.statistics.avg);
mv = round(mean(avgs), 2); mv = round(mean(avgs), 2);

View File

@ -33,7 +33,7 @@
error = null; error = null;
let selectedScope = minScope(scopes); let selectedScope = minScope(scopes);
let statsPattern = /(.*)-stats$/ let statsPattern = /(.*)-stat$/
let statsSeries = rawData.map((data) => data?.statisticsSeries ? data.statisticsSeries : null) let statsSeries = rawData.map((data) => data?.statisticsSeries ? data.statisticsSeries : null)
let selectedScopeIndex let selectedScopeIndex
@ -92,7 +92,7 @@
{#each availableScopes as scope, index} {#each availableScopes as scope, index}
<option value={scope}>{scope}</option> <option value={scope}>{scope}</option>
{#if statsSeries[index]} {#if statsSeries[index]}
<option value={scope + '-stats'}>stats series ({scope})</option> <option value={scope + '-stat'}>stats series ({scope})</option>
{/if} {/if}
{/each} {/each}
{#if availableScopes.length == 1 && metricConfig?.scope != "node"} {#if availableScopes.length == 1 && metricConfig?.scope != "node"}

View File

@ -50,7 +50,7 @@
timestep timestep
statisticsSeries { statisticsSeries {
min min
mean median
max max
} }
series { series {

View File

@ -216,7 +216,7 @@
// conditional hide series color markers: // conditional hide series color markers:
if ( if (
useStatsSeries === true || // Min/Max/Avg Self-Explanatory useStatsSeries === true || // Min/Max/Median Self-Explanatory
dataSize === 1 || // Only one Y-Dataseries dataSize === 1 || // Only one Y-Dataseries
dataSize > 6 dataSize > 6
) { ) {
@ -296,7 +296,7 @@
} }
const longestSeries = useStatsSeries const longestSeries = useStatsSeries
? statisticsSeries.mean.length ? statisticsSeries.median.length
: series.reduce((n, series) => Math.max(n, series.data.length), 0); : series.reduce((n, series) => Math.max(n, series.data.length), 0);
const maxX = longestSeries * timestep; const maxX = longestSeries * timestep;
let maxY = null; let maxY = null;
@ -346,13 +346,15 @@
if (useStatsSeries) { if (useStatsSeries) {
plotData.push(statisticsSeries.min); plotData.push(statisticsSeries.min);
plotData.push(statisticsSeries.max); plotData.push(statisticsSeries.max);
plotData.push(statisticsSeries.mean); plotData.push(statisticsSeries.median);
// plotData.push(statisticsSeries.mean);
if (forNode === true) { if (forNode === true) {
// timestamp 0 with null value for reversed time axis // timestamp 0 with null value for reversed time axis
if (plotData[1].length != 0) plotData[1].push(null); if (plotData[1].length != 0) plotData[1].push(null);
if (plotData[2].length != 0) plotData[2].push(null); if (plotData[2].length != 0) plotData[2].push(null);
if (plotData[3].length != 0) plotData[3].push(null); if (plotData[3].length != 0) plotData[3].push(null);
// if (plotData[4].length != 0) plotData[4].push(null);
} }
plotSeries.push({ plotSeries.push({
@ -368,11 +370,17 @@
stroke: "green", stroke: "green",
}); });
plotSeries.push({ plotSeries.push({
label: "mean", label: "median",
scale: "y", scale: "y",
width: lineWidth, width: lineWidth,
stroke: "black", stroke: "black",
}); });
// plotSeries.push({
// label: "mean",
// scale: "y",
// width: lineWidth,
// stroke: "blue",
// });
plotBands = [ plotBands = [
{ series: [2, 3], fill: "rgba(0,255,0,0.1)" }, { series: [2, 3], fill: "rgba(0,255,0,0.1)" },
@ -422,7 +430,7 @@
// Draw plot type label: // Draw plot type label:
let textl = `${scope}${plotSeries.length > 2 ? "s" : ""}${ let textl = `${scope}${plotSeries.length > 2 ? "s" : ""}${
useStatsSeries useStatsSeries
? ": min/avg/max" ? ": min/median/max"
: metricConfig != null && scope != metricConfig.scope : metricConfig != null && scope != metricConfig.scope
? ` (${metricConfig.aggregation})` ? ` (${metricConfig.aggregation})`
: "" : ""