Implement the following plan:

# Plan: Add RRDTool-style Average Consolidation Function to Resampler

## Context

The current downsampler in `cc-lib/v2/resampler` offers two algorithms:
- **LTTB** (LargestTriangleThreeBucket): Perceptually-aware — picks points that preserve visual shape (peaks/valleys). Used at all call sites.
- **SimpleResampler**: Decimation — picks every nth point. Fast but lossy.

Neither produces scientifically accurate averages over time intervals. RRDTool's **AVERAGE Consolidation Function (CF)** divides data into fixed-size buckets and computes the arithmetic mean of all points in each bucket. This is the standard approach for monitoring data where the true mean per interval matters more than visual fidelity (e.g., for statistics, SLA reporting, capacity planning).

The goal: add an `AverageResampler` to cc-lib, then expose it as a per-query option in cc-backend's GraphQL API so the frontend/caller can choose the algorithm.

---

## Part 1: cc-lib changes (`/Users/jan/prg/CC/cc-lib/`)

### 1a. Add `AverageResampler` to `resampler/resampler.go`

New exported function with the same signature as existing resamplers:

```go
func AverageResampler(data []schema.Float, oldFrequency int64, newFrequency int64) ([]schema.Float, int64, error)
```

**Algorithm (RRDTool AVERAGE CF):**
1. Call `validateFrequency()` for early-exit checks (reuses existing validation).
2. Compute `step = newFrequency / oldFrequency` (points per bucket).
3. For each bucket of `step` consecutive points, compute arithmetic mean skipping NaN values. If all values in a bucket are NaN, output NaN for that bucket.
4. Return averaged data, `newFrequency`, nil.

**NaN handling**: Skip NaN values and average only valid points (consistent with RRDTool behavior and `AddStats()` in `pkg/metricstore/api.go`). This differs from LTTB which propagates NaN if *any* bucket point is NaN.

### 1b. Add `ResamplerFunc` type and lookup

Add a function type and string-based lookup to make algorithm selection easy for consumers:

```go
// ResamplerFunc is the signature shared by all resampler algorithms.
type ResamplerFunc func(data []schema.Float, oldFrequency int64, newFrequency int64) ([]schema.Float, int64, error)

// GetResampler returns the resampler function for the given name.
// Valid names: "lttb" (default), "average", "simple". Empty string returns LTTB.
func GetResampler(name string) (ResamplerFunc, error)
```

### 1c. Add tests to `resampler/resampler_test.go`

Following the existing test patterns (table-driven, same helper functions):
- Basic averaging: `[1,2,3,4,5,6]` step=2 → `[1.5, 3.5, 5.5]`
- NaN skipping within buckets
- All-NaN bucket → NaN output
- Early-exit conditions (same set as SimpleResampler/LTTB tests)
- Frequency validation errors
- Benchmark: `BenchmarkAverageResampler`

---

## Part 2: cc-backend changes (`/Users/jan/tmp/cc-backend/`)

### 2a. Add `replace` directive to `go.mod`

```
replace github.com/ClusterCockpit/cc-lib/v2 => ../../prg/CC/cc-lib
```

### 2b. Add GraphQL enum and query parameter

In `api/schema.graphqls`, add enum and parameter to both queries that accept `resolution`:

```graphql
enum ResampleAlgo {
  LTTB
  AVERAGE
  SIMPLE
}
```

Add `resampleAlgo: ResampleAlgo` parameter to:
- `jobMetrics(id: ID!, metrics: [String!], scopes: [MetricScope!], resolution: Int, resampleAlgo: ResampleAlgo): [JobMetricWithName!]!`
- `nodeMetricsList(..., resolution: Int, resampleAlgo: ResampleAlgo): NodesResultList!`

Then run `make graphql` to regenerate.

### 2c. Update resolvers to pass algorithm through

**`internal/graph/schema.resolvers.go`**:

`JobMetrics` resolver (line ~501): accept new `resampleAlgo *model.ResampleAlgo` parameter (auto-generated by gqlgen), convert to string, pass to `LoadData`.

`NodeMetricsList` resolver (line ~875): same treatment, pass to `LoadNodeListData`.

### 2d. Thread algorithm through data loading

**`internal/metricdispatch/dataLoader.go`**:

- `LoadData()` (line 85): add `resampleAlgo string` parameter. Use `resampler.GetResampler(resampleAlgo)` to get the function, call it instead of hardcoded `resampler.LargestTriangleThreeBucket` at line 145.
- `LoadNodeListData()` (line 409): add `resampleAlgo string` parameter, pass through to `InternalMetricStore.LoadNodeListData`.

**`pkg/metricstore/query.go`**:

- `InternalMetricStore.LoadNodeListData()` (line 616): add `resampleAlgo string` to signature, include in `APIQueryRequest` or pass to `MemoryStore.Read`.

**`pkg/metricstore/metricstore.go`**:

- `MemoryStore.Read()` (line ~682): add `resampleAlgo string` parameter. At line 740, use `resampler.GetResampler(resampleAlgo)` instead of hardcoded LTTB.

### 2e. Update cache key

**`internal/metricdispatch/dataLoader.go`**: The `cacheKey()` function must include the algorithm name so different algorithms don't serve stale cached results.

---

## Files to modify

| File | Repo | Change |
|------|------|--------|
| `resampler/resampler.go` | cc-lib | Add `AverageResampler`, `ResamplerFunc`, `GetResampler` |
| `resampler/resampler_test.go` | cc-lib | Add tests + benchmark |
| `go.mod` | cc-backend | Add `replace` directive |
| `api/schema.graphqls` | cc-backend | Add `ResampleAlgo` enum, update query params |
| `internal/graph/generated/` | cc-backend | Regenerated (`make graphql`) |
| `internal/graph/schema.resolvers.go` | cc-backend | Pass algorithm to data loaders |
| `internal/metricdispatch/dataLoader.go` | cc-backend | Thread algorithm to resampler calls, update cache key |
| `pkg/metricstore/query.go` | cc-backend | Thread algorithm through `LoadNodeListData` |
| `pkg/metricstore/metricstore.go` | cc-backend | Thread algorithm through `Read()`, use `GetResampler` |

## Verification

1. `cd /Users/jan/prg/CC/cc-lib && go test ./resampler/...` — new + existing tests pass
2. `cd /Users/jan/tmp/cc-backend && make graphql` — GraphQL code regenerates cleanly
3. `make` — full build succeeds
4. `make test` — all existing tests pass
5. Manual: GraphQL playground query with `resampleAlgo: AVERAGE` vs default LTTB — average produces smoother curves without artificial peak preservation

If you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: /Users/jan/.claude/projects/-Users-jan-tmp-cc-backend/190ae749-6a19-47a7-82da-dba5a0e7402d.jsonl


I'll start by reading the key files in parallel to understand the current code.


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-lib.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→// Package resampler provides time-series data downsampling algorithms.
     7→//
     8→// This package implements two downsampling strategies for reducing the number
     9→// of data points in time-series data while preserving important characteristics:
    10→//
    11→//   - SimpleResampler: A fast, straightforward algorithm that selects every nth point
    12→//   - LargestTriangleThreeBucket (LTTB): A perceptually-aware algorithm that preserves
    13→//     visual characteristics by selecting points that maximize the area of triangles
    14→//     formed with neighboring points
    15→//
    16→// Both algorithms are designed to work with schema.Float data and handle NaN values
    17→// appropriately. They require that the new sampling frequency is a multiple of the
    18→// old frequency.
    19→//
    20→// References:
    21→//   - LTTB Algorithm: https://skemman.is/bitstream/1946/15343/3/SS_MSthesis.pdf
    22→//   - Implementation adapted from: https://github.com/haoel/downsampling
    23→package resampler
    24→
    25→import (
    26→	"fmt"
    27→	"math"
    28→
    29→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    30→)
    31→
    32→// MinimumRequiredPoints is the default number of points required to trigger resampling.
    33→// If the data has fewer points, the original time series will be returned without resampling.
    34→var MinimumRequiredPoints int = 1000
    35→
    36→// calculateTriangleArea computes the area of a triangle defined by three points.
    37→//
    38→// The area is calculated using the cross product formula:
    39→// Area = 0.5 * |((paX - pcX) * (pbY - paY)) - ((paX - pbX) * (pcY - paY))|
    40→//
    41→// This is used by the LTTB algorithm to determine which points preserve the
    42→// most visual information when downsampling.
    43→func calculateTriangleArea(paX, paY, pbX, pbY, pcX, pcY schema.Float) float64 {
    44→	area := ((paX-pcX)*(pbY-paY) - (paX-pbX)*(pcY-paY)) * 0.5
    45→	return math.Abs(float64(area))
    46→}
    47→
    48→// calculateAverageDataPoint computes the average point from a slice of data points.
    49→//
    50→// Parameters:
    51→//   - points: slice of Y values to average
    52→//   - xStart: starting X coordinate for the points
    53→//
    54→// Returns:
    55→//   - avgX: average X coordinate
    56→//   - avgY: average Y value, or NaN if any input point is NaN
    57→//
    58→// This function is used by LTTB to find the centroid of points in a bucket,
    59→// which is then used to calculate triangle areas for point selection.
    60→func calculateAverageDataPoint(points []schema.Float, xStart int64) (avgX schema.Float, avgY schema.Float) {
    61→	hasNaN := false
    62→	for _, point := range points {
    63→		avgX += schema.Float(xStart)
    64→		avgY += point
    65→		xStart++
    66→		if math.IsNaN(float64(point)) {
    67→			hasNaN = true
    68→		}
    69→	}
    70→
    71→	l := schema.Float(len(points))
    72→	avgX /= l
    73→	avgY /= l
    74→
    75→	if hasNaN {
    76→		return avgX, schema.NaN
    77→	}
    78→	return avgX, avgY
    79→}
    80→
    81→// SimpleResampler performs simple downsampling by selecting every nth point.
    82→//
    83→// This is the fastest downsampling method but may miss important features in the data.
    84→// It works by calculating a step size (newFrequency / oldFrequency) and selecting
    85→// every step-th point from the original data.
    86→//
    87→// Parameters:
    88→//   - data: input time-series data points
    89→//   - oldFrequency: original sampling frequency (points per time unit)
    90→//   - newFrequency: target sampling frequency (must be a multiple of oldFrequency)
    91→//
    92→// Returns:
    93→//   - Downsampled data slice
    94→//   - Actual frequency used (may be oldFrequency if downsampling wasn't performed)
    95→//   - Error if newFrequency is not a multiple of oldFrequency
    96→//
    97→// The function returns the original data unchanged if:
    98→//   - Either frequency is 0
    99→//   - newFrequency <= oldFrequency (no downsampling needed)
   100→//   - The resulting data would have fewer than 1 point
   101→//   - The original data has fewer than 100 points
   102→//   - The downsampled data would have the same or more points than the original
   103→//
   104→// Example:
   105→//
   106→//	data := []schema.Float{1.0, 2.0, 3.0, 4.0, 5.0, 6.0}
   107→//	downsampled, freq, err := SimpleResampler(data, 1, 2)
   108→//	// Returns: [1.0, 3.0, 5.0], 2, nil
   109→func SimpleResampler(data []schema.Float, oldFrequency int64, newFrequency int64) ([]schema.Float, int64, error) {
   110→	// checks if the frequencies are valid or not.
   111→	newDataLength, step, err := validateFrequency(len(data), oldFrequency, newFrequency)
   112→	if err != nil {
   113→		return nil, 0, err
   114→	}
   115→	if newDataLength == -1 {
   116→		return data, oldFrequency, nil
   117→	}
   118→
   119→	newData := make([]schema.Float, newDataLength)
   120→	for i := range newDataLength {
   121→		newData[i] = data[i*step]
   122→	}
   123→
   124→	return newData, newFrequency, nil
   125→}
   126→
   127→func validateFrequency(lenData int, oldFrequency, newFrequency int64) (int, int, error) {
   128→	// Validate inputs and check if downsampling is needed
   129→	if oldFrequency == 0 || newFrequency == 0 || newFrequency <= oldFrequency {
   130→		return -1, 0, nil
   131→	}
   132→
   133→	// Ensure new frequency is a multiple of old frequency
   134→	if newFrequency%oldFrequency != 0 {
   135→		return -1, 0, fmt.Errorf("new sampling frequency (%d) must be a multiple of old frequency (%d)", newFrequency, oldFrequency)
   136→	}
   137→
   138→	step := int(newFrequency / oldFrequency)
   139→	newDataLength := lenData / step
   140→
   141→	// Don't downsample if result would be trivial or counterproductive
   142→	if (newDataLength == 0) || (lenData < MinimumRequiredPoints) || (newDataLength >= lenData) {
   143→		return -1, 0, nil
   144→	}
   145→
   146→	return newDataLength, step, nil
   147→}
   148→
   149→// LargestTriangleThreeBucket (LTTB) performs perceptually-aware downsampling.
   150→//
   151→// LTTB is a downsampling algorithm that preserves the visual characteristics of
   152→// time-series data by selecting points that form the largest triangles with their
   153→// neighbors. This ensures that important peaks, valleys, and trends are retained
   154→// even when significantly reducing the number of points.
   155→//
   156→// Algorithm Overview:
   157→//  1. The data is divided into buckets (except first and last points which are always kept)
   158→//  2. For each bucket, the algorithm selects the point that forms the largest triangle
   159→//     with the previous selected point and the average of the next bucket
   160→//  3. This maximizes the visual area and preserves important features
   161→//
   162→// Time Complexity: O(n) where n is the number of input points
   163→// Space Complexity: O(m) where m is the number of output points
   164→//
   165→// Parameters:
   166→//   - data: input time-series data points
   167→//   - oldFrequency: original sampling frequency (points per time unit)
   168→//   - newFrequency: target sampling frequency (must be a multiple of oldFrequency)
   169→//
   170→// Returns:
   171→//   - Downsampled data slice
   172→//   - Actual frequency used (may be oldFrequency if downsampling wasn't performed)
   173→//   - Error if newFrequency is not a multiple of oldFrequency
   174→//
   175→// The function returns the original data unchanged if:
   176→//   - Either frequency is 0
   177→//   - newFrequency <= oldFrequency (no downsampling needed)
   178→//   - The resulting data would have fewer than 1 point
   179→//   - The original data has fewer than 100 points
   180→//   - The downsampled data would have the same or more points than the original
   181→//
   182→// NaN Handling:
   183→// The algorithm properly handles NaN values and preserves them in the output when
   184→// they represent the maximum area point in a bucket.
   185→//
   186→// Example:
   187→//
   188→//	data := []schema.Float{1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0}
   189→//	downsampled, freq, err := LargestTriangleThreeBucket(data, 1, 2)
   190→//	// Returns downsampled data preserving visual characteristics
   191→//
   192→// References:
   193→//   - Original paper: https://skemman.is/bitstream/1946/15343/3/SS_MSthesis.pdf
   194→//   - Adapted from: https://github.com/haoel/downsampling/blob/master/core/lttb.go
   195→func LargestTriangleThreeBucket(data []schema.Float, oldFrequency int64, newFrequency int64) ([]schema.Float, int64, error) {
   196→	// checks if the frequencies are valid or not.
   197→	newDataLength, _, err := validateFrequency(len(data), oldFrequency, newFrequency)
   198→	if err != nil {
   199→		return nil, 0, err
   200→	}
   201→	if newDataLength == -1 {
   202→		return data, oldFrequency, nil
   203→	}
   204→
   205→	newData := make([]schema.Float, 0, newDataLength)
   206→
   207→	// Bucket size. Leave room for start and end data points
   208→	bucketSize := float64(len(data)-2) / float64(newDataLength-2)
   209→
   210→	// Always add the first point
   211→	newData = append(newData, data[0])
   212→
   213→	// Bucket pointers:
   214→	// - bucketLow: current bucket's start
   215→	// - bucketMiddle: current bucket's end (also next bucket's start)
   216→	// - bucketHigh: next bucket's end
   217→	bucketLow := 1
   218→	bucketMiddle := int(math.Floor(bucketSize)) + 1
   219→
   220→	var prevMaxAreaPoint int
   221→
   222→	// Process each bucket (excluding first and last points)
   223→	for i := 0; i < newDataLength-2; i++ {
   224→		bucketHigh := int(math.Floor(float64(i+2)*bucketSize)) + 1
   225→		if bucketHigh >= len(data)-1 {
   226→			bucketHigh = len(data) - 2
   227→		}
   228→
   229→		// Calculate average point for next bucket (point c in triangle)
   230→		avgPointX, avgPointY := calculateAverageDataPoint(data[bucketMiddle:bucketHigh+1], int64(bucketMiddle))
   231→
   232→		// Get the range for current bucket
   233→		currBucketStart := bucketLow
   234→		currBucketEnd := bucketMiddle
   235→
   236→		// Point a (previously selected point)
   237→		pointX := prevMaxAreaPoint
   238→		pointY := data[prevMaxAreaPoint]
   239→
   240→		maxArea := -1.0
   241→		var maxAreaPoint int
   242→		flag_ := 0
   243→
   244→		// Find the point in current bucket that forms the largest triangle
   245→		for ; currBucketStart < currBucketEnd; currBucketStart++ {
   246→			area := calculateTriangleArea(
   247→				schema.Float(pointX), pointY,
   248→				avgPointX, avgPointY,
   249→				schema.Float(currBucketStart), data[currBucketStart],
   250→			)
   251→
   252→			if area > maxArea || math.IsNaN(area) {
   253→				maxArea = area
   254→				maxAreaPoint = currBucketStart
   255→			}
   256→			// if math.IsNaN(float64(avgPointY)) {
   257→			// 	flag_ = 1
   258→			// }
   259→		}
   260→
   261→		// Add the point with maximum area from this bucket
   262→		if flag_ == 1 {
   263→			newData = append(newData, schema.NaN) // Pick this point from the bucket
   264→		} else {
   265→			newData = append(newData, data[maxAreaPoint]) // Pick this point from the bucket
   266→		}
   267→
   268→		prevMaxAreaPoint = maxAreaPoint
   269→
   270→		// Move to the next bucket
   271→		bucketLow = bucketMiddle
   272→		bucketMiddle = bucketHigh
   273→	}
   274→
   275→	// Always add the last point
   276→	newData = append(newData, data[len(data)-1])
   277→
   278→	return newData, newFrequency, nil
   279→}
   280→
   281→func SetMinimumRequiredPoints(setVal int) {
   282→	MinimumRequiredPoints = setVal
   283→}
   284→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-lib.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

// Package resampler provides time-series data downsampling algorithms.
//
// This package implements two downsampling strategies for reducing the number
// of data points in time-series data while preserving important characteristics:
//
//   - SimpleResampler: A fast, straightforward algorithm that selects every nth point
//   - LargestTriangleThreeBucket (LTTB): A perceptually-aware algorithm that preserves
//     visual characteristics by selecting points that maximize the area of triangles
//     formed with neighboring points
//
// Both algorithms are designed to work with schema.Float data and handle NaN values
// appropriately. They require that the new sampling frequency is a multiple of the
// old frequency.
//
// References:
//   - LTTB Algorithm: https://skemman.is/bitstream/1946/15343/3/SS_MSthesis.pdf
//   - Implementation adapted from: https://github.com/haoel/downsampling
package resampler

import (
	"fmt"
	"math"

	"github.com/ClusterCockpit/cc-lib/v2/schema"
)

// MinimumRequiredPoints is the default number of points required to trigger resampling.
// If the data has fewer points, the original time series will be returned without resampling.
var MinimumRequiredPoints int = 1000

// calculateTriangleArea computes the area of a triangle defined by three points.
//
// The area is calculated using the cross product formula:
// Area = 0.5 * |((paX - pcX) * (pbY - paY)) - ((paX - pbX) * (pcY - paY))|
//
// This is used by the LTTB algorithm to determine which points preserve the
// most visual information when downsampling.
func calculateTriangleArea(paX, paY, pbX, pbY, pcX, pcY schema.Float) float64 {
	area := ((paX-pcX)*(pbY-paY) - (paX-pbX)*(pcY-paY)) * 0.5
	return math.Abs(float64(area))
}

// calculateAverageDataPoint computes the average point from a slice of data points.
//
// Parameters:
//   - points: slice of Y values to average
//   - xStart: starting X coordinate for the points
//
// Returns:
//   - avgX: average X coordinate
//   - avgY: average Y value, or NaN if any input point is NaN
//
// This function is used by LTTB to find the centroid of points in a bucket,
// which is then used to calculate triangle areas for point selection.
func calculateAverageDataPoint(points []schema.Float, xStart int64) (avgX schema.Float, avgY schema.Float) {
	hasNaN := false
	for _, point := range points {
		avgX += schema.Float(xStart)
		avgY += point
		xStart++
		if math.IsNaN(float64(point)) {
			hasNaN = true
		}
	}

	l := schema.Float(len(points))
	avgX /= l
	avgY /= l

	if hasNaN {
		return avgX, schema.NaN
	}
	return avgX, avgY
}

// SimpleResampler performs simple downsampling by selecting every nth point.
//
// This is the fastest downsampling method but may miss important features in the data.
// It works by calculating a step size (newFrequency / oldFrequency) and selecting
// every step-th point from the original data.
//
// Parameters:
//   - data: input time-series data points
//   - oldFrequency: original sampling frequency (points per time unit)
//   - newFrequency: target sampling frequency (must be a multiple of oldFrequency)
//
// Returns:
//   - Downsampled data slice
//   - Actual frequency used (may be oldFrequency if downsampling wasn't performed)
//   - Error if newFrequency is not a multiple of oldFrequency
//
// The function returns the original data unchanged if:
//   - Either frequency is 0
//   - newFrequency <= oldFrequency (no downsampling needed)
//   - The resulting data would have fewer than 1 point
//   - The original data has fewer than 100 points
//   - The downsampled data would have the same or more points than the original
//
// Example:
//
//	data := []schema.Float{1.0, 2.0, 3.0, 4.0, 5.0, 6.0}
//	downsampled, freq, err := SimpleResampler(data, 1, 2)
//	// Returns: [1.0, 3.0, 5.0], 2, nil
func SimpleResampler(data []schema.Float, oldFrequency int64, newFrequency int64) ([]schema.Float, int64, error) {
	// checks if the frequencies are valid or not.
	newDataLength, step, err := validateFrequency(len(data), oldFrequency, newFrequency)
	if err != nil {
		return nil, 0, err
	}
	if newDataLength == -1 {
		return data, oldFrequency, nil
	}

	newData := make([]schema.Float, newDataLength)
	for i := range newDataLength {
		newData[i] = data[i*step]
	}

	return newData, newFrequency, nil
}

func validateFrequency(lenData int, oldFrequency, newFrequency int64) (int, int, error) {
	// Validate inputs and check if downsampling is needed
	if oldFrequency == 0 || newFrequency == 0 || newFrequency <= oldFrequency {
		return -1, 0, nil
	}

	// Ensure new frequency is a multiple of old frequency
	if newFrequency%oldFrequency != 0 {
		return -1, 0, fmt.Errorf("new sampling frequency (%d) must be a multiple of old frequency (%d)", newFrequency, oldFrequency)
	}

	step := int(newFrequency / oldFrequency)
	newDataLength := lenData / step

	// Don't downsample if result would be trivial or counterproductive
	if (newDataLength == 0) || (lenData < MinimumRequiredPoints) || (newDataLength >= lenData) {
		return -1, 0, nil
	}

	return newDataLength, step, nil
}

// LargestTriangleThreeBucket (LTTB) performs perceptually-aware downsampling.
//
// LTTB is a downsampling algorithm that preserves the visual characteristics of
// time-series data by selecting points that form the largest triangles with their
// neighbors. This ensures that important peaks, valleys, and trends are retained
// even when significantly reducing the number of points.
//
// Algorithm Overview:
//  1. The data is divided into buckets (except first and last points which are always kept)
//  2. For each bucket, the algorithm selects the point that forms the largest triangle
//     with the previous selected point and the average of the next bucket
//  3. This maximizes the visual area and preserves important features
//
// Time Complexity: O(n) where n is the number of input points
// Space Complexity: O(m) where m is the number of output points
//
// Parameters:
//   - data: input time-series data points
//   - oldFrequency: original sampling frequency (points per time unit)
//   - newFrequency: target sampling frequency (must be a multiple of oldFrequency)
//
// Returns:
//   - Downsampled data slice
//   - Actual frequency used (may be oldFrequency if downsampling wasn't performed)
//   - Error if newFrequency is not a multiple of oldFrequency
//
// The function returns the original data unchanged if:
//   - Either frequency is 0
//   - newFrequency <= oldFrequency (no downsampling needed)
//   - The resulting data would have fewer than 1 point
//   - The original data has fewer than 100 points
//   - The downsampled data would have the same or more points than the original
//
// NaN Handling:
// The algorithm properly handles NaN values and preserves them in the output when
// they represent the maximum area point in a bucket.
//
// Example:
//
//	data := []schema.Float{1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0}
//	downsampled, freq, err := LargestTriangleThreeBucket(data, 1, 2)
//	// Returns downsampled data preserving visual characteristics
//
// References:
//   - Original paper: https://skemman.is/bitstream/1946/15343/3/SS_MSthesis.pdf
//   - Adapted from: https://github.com/haoel/downsampling/blob/master/core/lttb.go
func LargestTriangleThreeBucket(data []schema.Float, oldFrequency int64, newFrequency int64) ([]schema.Float, int64, error) {
	// checks if the frequencies are valid or not.
	newDataLength, _, err := validateFrequency(len(data), oldFrequency, newFrequency)
	if err != nil {
		return nil, 0, err
	}
	if newDataLength == -1 {
		return data, oldFrequency, nil
	}

	newData := make([]schema.Float, 0, newDataLength)

	// Bucket size. Leave room for start and end data points
	bucketSize := float64(len(data)-2) / float64(newDataLength-2)

	// Always add the first point
	newData = append(newData, data[0])

	// Bucket pointers:
	// - bucketLow: current bucket's start
	// - bucketMiddle: current bucket's end (also next bucket's start)
	// - bucketHigh: next bucket's end
	bucketLow := 1
	bucketMiddle := int(math.Floor(bucketSize)) + 1

	var prevMaxAreaPoint int

	// Process each bucket (excluding first and last points)
	for i := 0; i < newDataLength-2; i++ {
		bucketHigh := int(math.Floor(float64(i+2)*bucketSize)) + 1
		if bucketHigh >= len(data)-1 {
			bucketHigh = len(data) - 2
		}

		// Calculate average point for next bucket (point c in triangle)
		avgPointX, avgPointY := calculateAverageDataPoint(data[bucketMiddle:bucketHigh+1], int64(bucketMiddle))

		// Get the range for current bucket
		currBucketStart := bucketLow
		currBucketEnd := bucketMiddle

		// Point a (previously selected point)
		pointX := prevMaxAreaPoint
		pointY := data[prevMaxAreaPoint]

		maxArea := -1.0
		var maxAreaPoint int
		flag_ := 0

		// Find the point in current bucket that forms the largest triangle
		for ; currBucketStart < currBucketEnd; currBucketStart++ {
			area := calculateTriangleArea(
				schema.Float(pointX), pointY,
				avgPointX, avgPointY,
				schema.Float(currBucketStart), data[currBucketStart],
			)

			if area > maxArea || math.IsNaN(area) {
				maxArea = area
				maxAreaPoint = currBucketStart
			}
			// if math.IsNaN(float64(avgPointY)) {
			// 	flag_ = 1
			// }
		}

		// Add the point with maximum area from this bucket
		if flag_ == 1 {
			newData = append(newData, schema.NaN) // Pick this point from the bucket
		} else {
			newData = append(newData, data[maxAreaPoint]) // Pick this point from the bucket
		}

		prevMaxAreaPoint = maxAreaPoint

		// Move to the next bucket
		bucketLow = bucketMiddle
		bucketMiddle = bucketHigh
	}

	// Always add the last point
	newData = append(newData, data[len(data)-1])

	return newData, newFrequency, nil
}

func SetMinimumRequiredPoints(setVal int) {
	MinimumRequiredPoints = setVal
}


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-lib.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→package resampler
     6→
     7→import (
     8→	"math"
     9→	"testing"
    10→
    11→	"github.com/ClusterCockpit/cc-lib/v2/schema"
    12→)
    13→
    14→// TestCalculateTriangleArea tests the triangle area calculation helper function
    15→func TestCalculateTriangleArea(t *testing.T) {
    16→	tests := []struct {
    17→		name     string
    18→		paX, paY schema.Float
    19→		pbX, pbY schema.Float
    20→		pcX, pcY schema.Float
    21→		expected float64
    22→	}{
    23→		{
    24→			name: "Right triangle",
    25→			paX:  0, paY: 0,
    26→			pbX: 0, pbY: 3,
    27→			pcX: 4, pcY: 0,
    28→			expected: 6.0,
    29→		},
    30→		{
    31→			name: "Collinear points (zero area)",
    32→			paX:  0, paY: 0,
    33→			pbX: 1, pbY: 1,
    34→			pcX: 2, pcY: 2,
    35→			expected: 0.0,
    36→		},
    37→		{
    38→			name: "Negative coordinates",
    39→			paX:  -1, paY: -1,
    40→			pbX: -1, pbY: 2,
    41→			pcX: 3, pcY: -1,
    42→			expected: 6.0,
    43→		},
    44→	}
    45→
    46→	for _, tt := range tests {
    47→		t.Run(tt.name, func(t *testing.T) {
    48→			result := calculateTriangleArea(tt.paX, tt.paY, tt.pbX, tt.pbY, tt.pcX, tt.pcY)
    49→			if math.Abs(result-tt.expected) > 1e-10 {
    50→				t.Errorf("calculateTriangleArea() = %v, want %v", result, tt.expected)
    51→			}
    52→		})
    53→	}
    54→}
    55→
    56→// TestCalculateAverageDataPoint tests the average point calculation helper function
    57→func TestCalculateAverageDataPoint(t *testing.T) {
    58→	tests := []struct {
    59→		name      string
    60→		points    []schema.Float
    61→		xStart    int64
    62→		expectedX schema.Float
    63→		expectedY schema.Float
    64→		expectNaN bool
    65→	}{
    66→		{
    67→			name:      "Simple average",
    68→			points:    []schema.Float{1.0, 2.0, 3.0},
    69→			xStart:    0,
    70→			expectedX: 1.0,
    71→			expectedY: 2.0,
    72→			expectNaN: false,
    73→		},
    74→		{
    75→			name:      "Single point",
    76→			points:    []schema.Float{5.0},
    77→			xStart:    10,
    78→			expectedX: 10.0,
    79→			expectedY: 5.0,
    80→			expectNaN: false,
    81→		},
    82→		{
    83→			name:      "With NaN value",
    84→			points:    []schema.Float{1.0, schema.NaN, 3.0},
    85→			xStart:    0,
    86→			expectedX: 1.0,
    87→			expectNaN: true,
    88→		},
    89→		{
    90→			name:      "All NaN values",
    91→			points:    []schema.Float{schema.NaN, schema.NaN},
    92→			xStart:    0,
    93→			expectedX: 0.5,
    94→			expectNaN: true,
    95→		},
    96→	}
    97→
    98→	for _, tt := range tests {
    99→		t.Run(tt.name, func(t *testing.T) {
   100→			avgX, avgY := calculateAverageDataPoint(tt.points, tt.xStart)
   101→			if math.Abs(float64(avgX-tt.expectedX)) > 1e-10 {
   102→				t.Errorf("calculateAverageDataPoint() avgX = %v, want %v", avgX, tt.expectedX)
   103→			}
   104→			if tt.expectNaN {
   105→				if !math.IsNaN(float64(avgY)) {
   106→					t.Errorf("calculateAverageDataPoint() avgY should be NaN, got %v", avgY)
   107→				}
   108→			} else {
   109→				if math.Abs(float64(avgY-tt.expectedY)) > 1e-10 {
   110→					t.Errorf("calculateAverageDataPoint() avgY = %v, want %v", avgY, tt.expectedY)
   111→				}
   112→			}
   113→		})
   114→	}
   115→}
   116→
   117→// TestSimpleResampler tests the SimpleResampler function
   118→func TestSimpleResampler(t *testing.T) {
   119→	// Set minimum required points to 100 for testing
   120→	SetMinimumRequiredPoints(100)
   121→	defer SetMinimumRequiredPoints(1000) // Restore default after test
   122→
   123→	tests := []struct {
   124→		name         string
   125→		data         []schema.Float
   126→		oldFrequency int64
   127→		newFrequency int64
   128→		expectedLen  int
   129→		expectedFreq int64
   130→		expectError  bool
   131→		checkValues  bool
   132→		expectedData []schema.Float
   133→	}{
   134→		{
   135→			name:         "Normal downsampling",
   136→			data:         makeTestData(200, 1.0),
   137→			oldFrequency: 1,
   138→			newFrequency: 2,
   139→			expectedLen:  100,
   140→			expectedFreq: 2,
   141→			expectError:  false,
   142→		},
   143→		{
   144→			name:         "No downsampling needed (new <= old)",
   145→			data:         makeTestData(200, 1.0),
   146→			oldFrequency: 2,
   147→			newFrequency: 1,
   148→			expectedLen:  200,
   149→			expectedFreq: 2,
   150→			expectError:  false,
   151→		},
   152→		{
   153→			name:         "Zero old frequency",
   154→			data:         makeTestData(200, 1.0),
   155→			oldFrequency: 0,
   156→			newFrequency: 2,
   157→			expectedLen:  200,
   158→			expectedFreq: 0,
   159→			expectError:  false,
   160→		},
   161→		{
   162→			name:         "Zero new frequency",
   163→			data:         makeTestData(200, 1.0),
   164→			oldFrequency: 1,
   165→			newFrequency: 0,
   166→			expectedLen:  200,
   167→			expectedFreq: 1,
   168→			expectError:  false,
   169→		},
   170→		{
   171→			name:         "Non-multiple frequency",
   172→			data:         makeTestData(200, 1.0),
   173→			oldFrequency: 3,
   174→			newFrequency: 7,
   175→			expectError:  true,
   176→		},
   177→		{
   178→			name:         "Small dataset (< 100 points)",
   179→			data:         makeTestData(50, 1.0),
   180→			oldFrequency: 1,
   181→			newFrequency: 2,
   182→			expectedLen:  50,
   183→			expectedFreq: 1,
   184→			expectError:  false,
   185→		},
   186→		{
   187→			name:         "Exact values check",
   188→			data:         []schema.Float{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
   189→			oldFrequency: 1,
   190→			newFrequency: 2,
   191→			expectedLen:  10,
   192→			expectedFreq: 1,
   193→			expectError:  false,
   194→			checkValues:  false, // Too small, won't downsample
   195→		},
   196→		{
   197→			name:         "Large downsampling factor",
   198→			data:         makeTestData(1000, 1.0),
   199→			oldFrequency: 1,
   200→			newFrequency: 10,
   201→			expectedLen:  100,
   202→			expectedFreq: 10,
   203→			expectError:  false,
   204→		},
   205→	}
   206→
   207→	for _, tt := range tests {
   208→		t.Run(tt.name, func(t *testing.T) {
   209→			result, freq, err := SimpleResampler(tt.data, tt.oldFrequency, tt.newFrequency)
   210→
   211→			if tt.expectError {
   212→				if err == nil {
   213→					t.Errorf("SimpleResampler() expected error, got nil")
   214→				}
   215→				return
   216→			}
   217→
   218→			if err != nil {
   219→				t.Errorf("SimpleResampler() unexpected error: %v", err)
   220→				return
   221→			}
   222→
   223→			if len(result) != tt.expectedLen {
   224→				t.Errorf("SimpleResampler() result length = %v, want %v", len(result), tt.expectedLen)
   225→			}
   226→
   227→			if freq != tt.expectedFreq {
   228→				t.Errorf("SimpleResampler() frequency = %v, want %v", freq, tt.expectedFreq)
   229→			}
   230→
   231→			if tt.checkValues && tt.expectedData != nil {
   232→				for i := range result {
   233→					if result[i] != tt.expectedData[i] {
   234→						t.Errorf("SimpleResampler() result[%d] = %v, want %v", i, result[i], tt.expectedData[i])
   235→					}
   236→				}
   237→			}
   238→		})
   239→	}
   240→}
   241→
   242→// TestLargestTriangleThreeBucket tests the LTTB algorithm
   243→func TestLargestTriangleThreeBucket(t *testing.T) {
   244→	// Set minimum required points to 100 for testing
   245→	SetMinimumRequiredPoints(100)
   246→	defer SetMinimumRequiredPoints(1000) // Restore default after test
   247→
   248→	tests := []struct {
   249→		name         string
   250→		data         []schema.Float
   251→		oldFrequency int64
   252→		newFrequency int64
   253→		expectedLen  int
   254→		expectedFreq int64
   255→		expectError  bool
   256→	}{
   257→		{
   258→			name:         "Normal downsampling",
   259→			data:         makeTestData(200, 1.0),
   260→			oldFrequency: 1,
   261→			newFrequency: 2,
   262→			expectedLen:  100,
   263→			expectedFreq: 2,
   264→			expectError:  false,
   265→		},
   266→		{
   267→			name:         "Sine wave pattern",
   268→			data:         makeSineWave(500, 10),
   269→			oldFrequency: 1,
   270→			newFrequency: 5,
   271→			expectedLen:  100,
   272→			expectedFreq: 5,
   273→			expectError:  false,
   274→		},
   275→		{
   276→			name:         "No downsampling needed (new <= old)",
   277→			data:         makeTestData(200, 1.0),
   278→			oldFrequency: 2,
   279→			newFrequency: 1,
   280→			expectedLen:  200,
   281→			expectedFreq: 2,
   282→			expectError:  false,
   283→		},
   284→		{
   285→			name:         "Zero old frequency",
   286→			data:         makeTestData(200, 1.0),
   287→			oldFrequency: 0,
   288→			newFrequency: 2,
   289→			expectedLen:  200,
   290→			expectedFreq: 0,
   291→			expectError:  false,
   292→		},
   293→		{
   294→			name:         "Zero new frequency",
   295→			data:         makeTestData(200, 1.0),
   296→			oldFrequency: 1,
   297→			newFrequency: 0,
   298→			expectedLen:  200,
   299→			expectedFreq: 1,
   300→			expectError:  false,
   301→		},
   302→		{
   303→			name:         "Non-multiple frequency",
   304→			data:         makeTestData(200, 1.0),
   305→			oldFrequency: 3,
   306→			newFrequency: 7,
   307→			expectError:  true,
   308→		},
   309→		{
   310→			name:         "Small dataset (< 100 points)",
   311→			data:         makeTestData(50, 1.0),
   312→			oldFrequency: 1,
   313→			newFrequency: 2,
   314→			expectedLen:  50,
   315→			expectedFreq: 1,
   316→			expectError:  false,
   317→		},
   318→		{
   319→			name:         "With NaN values",
   320→			data:         makeTestDataWithNaN(200),
   321→			oldFrequency: 1,
   322→			newFrequency: 2,
   323→			expectedLen:  100,
   324→			expectedFreq: 2,
   325→			expectError:  false,
   326→		},
   327→		{
   328→			name:         "Large downsampling factor",
   329→			data:         makeTestData(1000, 1.0),
   330→			oldFrequency: 1,
   331→			newFrequency: 10,
   332→			expectedLen:  100,
   333→			expectedFreq: 10,
   334→			expectError:  false,
   335→		},
   336→	}
   337→
   338→	for _, tt := range tests {
   339→		t.Run(tt.name, func(t *testing.T) {
   340→			result, freq, err := LargestTriangleThreeBucket(tt.data, tt.oldFrequency, tt.newFrequency)
   341→
   342→			if tt.expectError {
   343→				if err == nil {
   344→					t.Errorf("LargestTriangleThreeBucket() expected error, got nil")
   345→				}
   346→				return
   347→			}
   348→
   349→			if err != nil {
   350→				t.Errorf("LargestTriangleThreeBucket() unexpected error: %v", err)
   351→				return
   352→			}
   353→
   354→			if len(result) != tt.expectedLen {
   355→				t.Errorf("LargestTriangleThreeBucket() result length = %v, want %v", len(result), tt.expectedLen)
   356→			}
   357→
   358→			if freq != tt.expectedFreq {
   359→				t.Errorf("LargestTriangleThreeBucket() frequency = %v, want %v", freq, tt.expectedFreq)
   360→			}
   361→
   362→			// Verify first and last points are preserved
   363→			if len(result) > 0 && len(tt.data) > 0 {
   364→				// Handle NaN comparison specially since NaN != NaN
   365→				if math.IsNaN(float64(tt.data[0])) {
   366→					if !math.IsNaN(float64(result[0])) {
   367→						t.Errorf("LargestTriangleThreeBucket() first point not preserved: got %v, want NaN", result[0])
   368→					}
   369→				} else if result[0] != tt.data[0] {
   370→					t.Errorf("LargestTriangleThreeBucket() first point not preserved: got %v, want %v", result[0], tt.data[0])
   371→				}
   372→
   373→				if math.IsNaN(float64(tt.data[len(tt.data)-1])) {
   374→					if !math.IsNaN(float64(result[len(result)-1])) {
   375→						t.Errorf("LargestTriangleThreeBucket() last point not preserved: got %v, want NaN", result[len(result)-1])
   376→					}
   377→				} else if result[len(result)-1] != tt.data[len(tt.data)-1] {
   378→					t.Errorf("LargestTriangleThreeBucket() last point not preserved: got %v, want %v", result[len(result)-1], tt.data[len(tt.data)-1])
   379→				}
   380→			}
   381→		})
   382→	}
   383→}
   384→
   385→// TestLTTBPreservesFeatures tests that LTTB preserves important features better than simple resampling
   386→func TestLTTBPreservesFeatures(t *testing.T) {
   387→	// Set minimum required points to 100 for testing
   388→	SetMinimumRequiredPoints(100)
   389→	defer SetMinimumRequiredPoints(1000) // Restore default after test
   390→
   391→	// Create a dataset with a spike
   392→	data := make([]schema.Float, 200)
   393→	for i := range data {
   394→		if i == 100 {
   395→			data[i] = 100.0 // Spike
   396→		} else {
   397→			data[i] = 1.0
   398→		}
   399→	}
   400→
   401→	lttbResult, _, _ := LargestTriangleThreeBucket(data, 1, 2)
   402→
   403→	// LTTB should be more likely to preserve the spike
   404→	lttbHasSpike := false
   405→
   406→	for _, v := range lttbResult {
   407→		if v > 50.0 {
   408→			lttbHasSpike = true
   409→			break
   410→		}
   411→	}
   412→
   413→	// This test is probabilistic, but LTTB should generally preserve the spike
   414→	if !lttbHasSpike {
   415→		t.Log("Warning: LTTB did not preserve spike (may happen occasionally)")
   416→	}
   417→}
   418→
   419→// Helper functions for test data generation
   420→
   421→// makeTestData creates a simple linear test dataset
   422→func makeTestData(size int, increment schema.Float) []schema.Float {
   423→	data := make([]schema.Float, size)
   424→	for i := range data {
   425→		data[i] = schema.Float(i) * increment
   426→	}
   427→	return data
   428→}
   429→
   430→// makeSineWave creates a sine wave test dataset
   431→func makeSineWave(size int, periods float64) []schema.Float {
   432→	data := make([]schema.Float, size)
   433→	for i := range data {
   434→		angle := 2 * math.Pi * periods * float64(i) / float64(size)
   435→		data[i] = schema.Float(math.Sin(angle))
   436→	}
   437→	return data
   438→}
   439→
   440→// makeTestDataWithNaN creates test data with some NaN values
   441→func makeTestDataWithNaN(size int) []schema.Float {
   442→	data := make([]schema.Float, size)
   443→	for i := range data {
   444→		if i%20 == 0 {
   445→			data[i] = schema.NaN
   446→		} else {
   447→			data[i] = schema.Float(i)
   448→		}
   449→	}
   450→	return data
   451→}
   452→
   453→// Benchmark tests
   454→
   455→func BenchmarkSimpleResampler(b *testing.B) {
   456→	data := makeTestData(10000, 1.0)
   457→	b.ResetTimer()
   458→	for i := 0; i < b.N; i++ {
   459→		_, _, _ = SimpleResampler(data, 1, 10)
   460→	}
   461→}
   462→
   463→func BenchmarkLargestTriangleThreeBucket(b *testing.B) {
   464→	data := makeTestData(10000, 1.0)
   465→	b.ResetTimer()
   466→	for i := 0; i < b.N; i++ {
   467→		_, _, _ = LargestTriangleThreeBucket(data, 1, 10)
   468→	}
   469→}
   470→
   471→func BenchmarkLTTBLargeDataset(b *testing.B) {
   472→	data := makeTestData(100000, 1.0)
   473→	b.ResetTimer()
   474→	for i := 0; i < b.N; i++ {
   475→		_, _, _ = LargestTriangleThreeBucket(data, 1, 100)
   476→	}
   477→}
   478→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-lib.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package resampler

import (
	"math"
	"testing"

	"github.com/ClusterCockpit/cc-lib/v2/schema"
)

// TestCalculateTriangleArea tests the triangle area calculation helper function
func TestCalculateTriangleArea(t *testing.T) {
	tests := []struct {
		name     string
		paX, paY schema.Float
		pbX, pbY schema.Float
		pcX, pcY schema.Float
		expected float64
	}{
		{
			name: "Right triangle",
			paX:  0, paY: 0,
			pbX: 0, pbY: 3,
			pcX: 4, pcY: 0,
			expected: 6.0,
		},
		{
			name: "Collinear points (zero area)",
			paX:  0, paY: 0,
			pbX: 1, pbY: 1,
			pcX: 2, pcY: 2,
			expected: 0.0,
		},
		{
			name: "Negative coordinates",
			paX:  -1, paY: -1,
			pbX: -1, pbY: 2,
			pcX: 3, pcY: -1,
			expected: 6.0,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := calculateTriangleArea(tt.paX, tt.paY, tt.pbX, tt.pbY, tt.pcX, tt.pcY)
			if math.Abs(result-tt.expected) > 1e-10 {
				t.Errorf("calculateTriangleArea() = %v, want %v", result, tt.expected)
			}
		})
	}
}

// TestCalculateAverageDataPoint tests the average point calculation helper function
func TestCalculateAverageDataPoint(t *testing.T) {
	tests := []struct {
		name      string
		points    []schema.Float
		xStart    int64
		expectedX schema.Float
		expectedY schema.Float
		expectNaN bool
	}{
		{
			name:      "Simple average",
			points:    []schema.Float{1.0, 2.0, 3.0},
			xStart:    0,
			expectedX: 1.0,
			expectedY: 2.0,
			expectNaN: false,
		},
		{
			name:      "Single point",
			points:    []schema.Float{5.0},
			xStart:    10,
			expectedX: 10.0,
			expectedY: 5.0,
			expectNaN: false,
		},
		{
			name:      "With NaN value",
			points:    []schema.Float{1.0, schema.NaN, 3.0},
			xStart:    0,
			expectedX: 1.0,
			expectNaN: true,
		},
		{
			name:      "All NaN values",
			points:    []schema.Float{schema.NaN, schema.NaN},
			xStart:    0,
			expectedX: 0.5,
			expectNaN: true,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			avgX, avgY := calculateAverageDataPoint(tt.points, tt.xStart)
			if math.Abs(float64(avgX-tt.expectedX)) > 1e-10 {
				t.Errorf("calculateAverageDataPoint() avgX = %v, want %v", avgX, tt.expectedX)
			}
			if tt.expectNaN {
				if !math.IsNaN(float64(avgY)) {
					t.Errorf("calculateAverageDataPoint() avgY should be NaN, got %v", avgY)
				}
			} else {
				if math.Abs(float64(avgY-tt.expectedY)) > 1e-10 {
					t.Errorf("calculateAverageDataPoint() avgY = %v, want %v", avgY, tt.expectedY)
				}
			}
		})
	}
}

// TestSimpleResampler tests the SimpleResampler function
func TestSimpleResampler(t *testing.T) {
	// Set minimum required points to 100 for testing
	SetMinimumRequiredPoints(100)
	defer SetMinimumRequiredPoints(1000) // Restore default after test

	tests := []struct {
		name         string
		data         []schema.Float
		oldFrequency int64
		newFrequency int64
		expectedLen  int
		expectedFreq int64
		expectError  bool
		checkValues  bool
		expectedData []schema.Float
	}{
		{
			name:         "Normal downsampling",
			data:         makeTestData(200, 1.0),
			oldFrequency: 1,
			newFrequency: 2,
			expectedLen:  100,
			expectedFreq: 2,
			expectError:  false,
		},
		{
			name:         "No downsampling needed (new <= old)",
			data:         makeTestData(200, 1.0),
			oldFrequency: 2,
			newFrequency: 1,
			expectedLen:  200,
			expectedFreq: 2,
			expectError:  false,
		},
		{
			name:         "Zero old frequency",
			data:         makeTestData(200, 1.0),
			oldFrequency: 0,
			newFrequency: 2,
			expectedLen:  200,
			expectedFreq: 0,
			expectError:  false,
		},
		{
			name:         "Zero new frequency",
			data:         makeTestData(200, 1.0),
			oldFrequency: 1,
			newFrequency: 0,
			expectedLen:  200,
			expectedFreq: 1,
			expectError:  false,
		},
		{
			name:         "Non-multiple frequency",
			data:         makeTestData(200, 1.0),
			oldFrequency: 3,
			newFrequency: 7,
			expectError:  true,
		},
		{
			name:         "Small dataset (< 100 points)",
			data:         makeTestData(50, 1.0),
			oldFrequency: 1,
			newFrequency: 2,
			expectedLen:  50,
			expectedFreq: 1,
			expectError:  false,
		},
		{
			name:         "Exact values check",
			data:         []schema.Float{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
			oldFrequency: 1,
			newFrequency: 2,
			expectedLen:  10,
			expectedFreq: 1,
			expectError:  false,
			checkValues:  false, // Too small, won't downsample
		},
		{
			name:         "Large downsampling factor",
			data:         makeTestData(1000, 1.0),
			oldFrequency: 1,
			newFrequency: 10,
			expectedLen:  100,
			expectedFreq: 10,
			expectError:  false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result, freq, err := SimpleResampler(tt.data, tt.oldFrequency, tt.newFrequency)

			if tt.expectError {
				if err == nil {
					t.Errorf("SimpleResampler() expected error, got nil")
				}
				return
			}

			if err != nil {
				t.Errorf("SimpleResampler() unexpected error: %v", err)
				return
			}

			if len(result) != tt.expectedLen {
				t.Errorf("SimpleResampler() result length = %v, want %v", len(result), tt.expectedLen)
			}

			if freq != tt.expectedFreq {
				t.Errorf("SimpleResampler() frequency = %v, want %v", freq, tt.expectedFreq)
			}

			if tt.checkValues && tt.expectedData != nil {
				for i := range result {
					if result[i] != tt.expectedData[i] {
						t.Errorf("SimpleResampler() result[%d] = %v, want %v", i, result[i], tt.expectedData[i])
					}
				}
			}
		})
	}
}

// TestLargestTriangleThreeBucket tests the LTTB algorithm
func TestLargestTriangleThreeBucket(t *testing.T) {
	// Set minimum required points to 100 for testing
	SetMinimumRequiredPoints(100)
	defer SetMinimumRequiredPoints(1000) // Restore default after test

	tests := []struct {
		name         string
		data         []schema.Float
		oldFrequency int64
		newFrequency int64
		expectedLen  int
		expectedFreq int64
		expectError  bool
	}{
		{
			name:         "Normal downsampling",
			data:         makeTestData(200, 1.0),
			oldFrequency: 1,
			newFrequency: 2,
			expectedLen:  100,
			expectedFreq: 2,
			expectError:  false,
		},
		{
			name:         "Sine wave pattern",
			data:         makeSineWave(500, 10),
			oldFrequency: 1,
			newFrequency: 5,
			expectedLen:  100,
			expectedFreq: 5,
			expectError:  false,
		},
		{
			name:         "No downsampling needed (new <= old)",
			data:         makeTestData(200, 1.0),
			oldFrequency: 2,
			newFrequency: 1,
			expectedLen:  200,
			expectedFreq: 2,
			expectError:  false,
		},
		{
			name:         "Zero old frequency",
			data:         makeTestData(200, 1.0),
			oldFrequency: 0,
			newFrequency: 2,
			expectedLen:  200,
			expectedFreq: 0,
			expectError:  false,
		},
		{
			name:         "Zero new frequency",
			data:         makeTestData(200, 1.0),
			oldFrequency: 1,
			newFrequency: 0,
			expectedLen:  200,
			expectedFreq: 1,
			expectError:  false,
		},
		{
			name:         "Non-multiple frequency",
			data:         makeTestData(200, 1.0),
			oldFrequency: 3,
			newFrequency: 7,
			expectError:  true,
		},
		{
			name:         "Small dataset (< 100 points)",
			data:         makeTestData(50, 1.0),
			oldFrequency: 1,
			newFrequency: 2,
			expectedLen:  50,
			expectedFreq: 1,
			expectError:  false,
		},
		{
			name:         "With NaN values",
			data:         makeTestDataWithNaN(200),
			oldFrequency: 1,
			newFrequency: 2,
			expectedLen:  100,
			expectedFreq: 2,
			expectError:  false,
		},
		{
			name:         "Large downsampling factor",
			data:         makeTestData(1000, 1.0),
			oldFrequency: 1,
			newFrequency: 10,
			expectedLen:  100,
			expectedFreq: 10,
			expectError:  false,
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result, freq, err := LargestTriangleThreeBucket(tt.data, tt.oldFrequency, tt.newFrequency)

			if tt.expectError {
				if err == nil {
					t.Errorf("LargestTriangleThreeBucket() expected error, got nil")
				}
				return
			}

			if err != nil {
				t.Errorf("LargestTriangleThreeBucket() unexpected error: %v", err)
				return
			}

			if len(result) != tt.expectedLen {
				t.Errorf("LargestTriangleThreeBucket() result length = %v, want %v", len(result), tt.expectedLen)
			}

			if freq != tt.expectedFreq {
				t.Errorf("LargestTriangleThreeBucket() frequency = %v, want %v", freq, tt.expectedFreq)
			}

			// Verify first and last points are preserved
			if len(result) > 0 && len(tt.data) > 0 {
				// Handle NaN comparison specially since NaN != NaN
				if math.IsNaN(float64(tt.data[0])) {
					if !math.IsNaN(float64(result[0])) {
						t.Errorf("LargestTriangleThreeBucket() first point not preserved: got %v, want NaN", result[0])
					}
				} else if result[0] != tt.data[0] {
					t.Errorf("LargestTriangleThreeBucket() first point not preserved: got %v, want %v", result[0], tt.data[0])
				}

				if math.IsNaN(float64(tt.data[len(tt.data)-1])) {
					if !math.IsNaN(float64(result[len(result)-1])) {
						t.Errorf("LargestTriangleThreeBucket() last point not preserved: got %v, want NaN", result[len(result)-1])
					}
				} else if result[len(result)-1] != tt.data[len(tt.data)-1] {
					t.Errorf("LargestTriangleThreeBucket() last point not preserved: got %v, want %v", result[len(result)-1], tt.data[len(tt.data)-1])
				}
			}
		})
	}
}

// TestLTTBPreservesFeatures tests that LTTB preserves important features better than simple resampling
func TestLTTBPreservesFeatures(t *testing.T) {
	// Set minimum required points to 100 for testing
	SetMinimumRequiredPoints(100)
	defer SetMinimumRequiredPoints(1000) // Restore default after test

	// Create a dataset with a spike
	data := make([]schema.Float, 200)
	for i := range data {
		if i == 100 {
			data[i] = 100.0 // Spike
		} else {
			data[i] = 1.0
		}
	}

	lttbResult, _, _ := LargestTriangleThreeBucket(data, 1, 2)

	// LTTB should be more likely to preserve the spike
	lttbHasSpike := false

	for _, v := range lttbResult {
		if v > 50.0 {
			lttbHasSpike = true
			break
		}
	}

	// This test is probabilistic, but LTTB should generally preserve the spike
	if !lttbHasSpike {
		t.Log("Warning: LTTB did not preserve spike (may happen occasionally)")
	}
}

// Helper functions for test data generation

// makeTestData creates a simple linear test dataset
func makeTestData(size int, increment schema.Float) []schema.Float {
	data := make([]schema.Float, size)
	for i := range data {
		data[i] = schema.Float(i) * increment
	}
	return data
}

// makeSineWave creates a sine wave test dataset
func makeSineWave(size int, periods float64) []schema.Float {
	data := make([]schema.Float, size)
	for i := range data {
		angle := 2 * math.Pi * periods * float64(i) / float64(size)
		data[i] = schema.Float(math.Sin(angle))
	}
	return data
}

// makeTestDataWithNaN creates test data with some NaN values
func makeTestDataWithNaN(size int) []schema.Float {
	data := make([]schema.Float, size)
	for i := range data {
		if i%20 == 0 {
			data[i] = schema.NaN
		} else {
			data[i] = schema.Float(i)
		}
	}
	return data
}

// Benchmark tests

func BenchmarkSimpleResampler(b *testing.B) {
	data := makeTestData(10000, 1.0)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_, _, _ = SimpleResampler(data, 1, 10)
	}
}

func BenchmarkLargestTriangleThreeBucket(b *testing.B) {
	data := makeTestData(10000, 1.0)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_, _, _ = LargestTriangleThreeBucket(data, 1, 10)
	}
}

func BenchmarkLTTBLargeDataset(b *testing.B) {
	data := makeTestData(100000, 1.0)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		_, _, _ = LargestTriangleThreeBucket(data, 1, 100)
	}
}


     1→scalar Time
     2→scalar Any
     3→
     4→scalar NullableFloat
     5→scalar MetricScope
     6→scalar JobState
     7→scalar SchedulerState
     8→scalar MonitoringState
     9→
    10→type Node {
    11→  id: ID!
    12→  hostname: String!
    13→  cluster: String!
    14→  subCluster: String!
    15→  jobsRunning: Int!
    16→  cpusAllocated: Int
    17→  memoryAllocated: Int
    18→  gpusAllocated: Int
    19→  schedulerState: SchedulerState!
    20→  healthState: MonitoringState!
    21→  metaData: Any
    22→  healthData: Any
    23→}
    24→
    25→type NodeStates {
    26→  state: String!
    27→  count: Int!
    28→}
    29→
    30→type NodeStatesTimed {
    31→  state: String!
    32→  counts: [Int!]!
    33→  times: [Int!]!
    34→}
    35→
    36→type Job {
    37→  id: ID!
    38→  jobId: Int!
    39→  user: String!
    40→  project: String!
    41→  cluster: String!
    42→  subCluster: String!
    43→  startTime: Time!
    44→  duration: Int!
    45→  walltime: Int!
    46→  numNodes: Int!
    47→  numHWThreads: Int!
    48→  numAcc: Int!
    49→  energy: Float!
    50→  SMT: Int!
    51→  shared: String!
    52→  partition: String!
    53→  arrayJobId: Int!
    54→  monitoringStatus: Int!
    55→  state: JobState!
    56→  tags: [Tag!]!
    57→  resources: [Resource!]!
    58→  concurrentJobs: JobLinkResultList
    59→  footprint: [FootprintValue]
    60→  energyFootprint: [EnergyFootprintValue]
    61→  metaData: Any
    62→  userData: User
    63→}
    64→
    65→type JobLink {
    66→  id: ID!
    67→  jobId: Int!
    68→}
    69→
    70→type Cluster {
    71→  name: String!
    72→  partitions: [String!]! # Slurm partitions
    73→  subClusters: [SubCluster!]! # Hardware partitions/subclusters
    74→}
    75→
    76→type SubCluster {
    77→  name: String!
    78→  nodes: String!
    79→  numberOfNodes: Int!
    80→  processorType: String!
    81→  socketsPerNode: Int!
    82→  coresPerSocket: Int!
    83→  threadsPerCore: Int!
    84→  flopRateScalar: MetricValue!
    85→  flopRateSimd: MetricValue!
    86→  memoryBandwidth: MetricValue!
    87→  topology: Topology!
    88→  metricConfig: [MetricConfig!]!
    89→  footprint: [String!]!
    90→}
    91→
    92→type FootprintValue {
    93→  name: String!
    94→  stat: String!
    95→  value: Float!
    96→}
    97→
    98→type EnergyFootprintValue {
    99→  hardware: String!
   100→  metric: String!
   101→  value: Float!
   102→}
   103→
   104→type MetricValue {
   105→  name: String
   106→  unit: Unit!
   107→  value: Float!
   108→}
   109→
   110→type Topology {
   111→  node: [Int!]
   112→  socket: [[Int!]!]
   113→  memoryDomain: [[Int!]!]
   114→  die: [[Int!]!]
   115→  core: [[Int!]!]
   116→  accelerators: [Accelerator!]
   117→}
   118→
   119→type Accelerator {
   120→  id: String!
   121→  type: String!
   122→  model: String!
   123→}
   124→
   125→type SubClusterConfig {
   126→  name: String!
   127→  peak: Float
   128→  normal: Float
   129→  caution: Float
   130→  alert: Float
   131→  remove: Boolean
   132→}
   133→
   134→type MetricConfig {
   135→  name: String!
   136→  unit: Unit!
   137→  scope: MetricScope!
   138→  aggregation: String!
   139→  timestep: Int!
   140→  peak: Float!
   141→  normal: Float
   142→  caution: Float!
   143→  alert: Float!
   144→  lowerIsBetter: Boolean
   145→  subClusters: [SubClusterConfig!]!
   146→}
   147→
   148→type Tag {
   149→  id: ID!
   150→  type: String!
   151→  name: String!
   152→  scope: String!
   153→}
   154→
   155→type Resource {
   156→  hostname: String!
   157→  hwthreads: [Int!]
   158→  accelerators: [String!]
   159→  configuration: String
   160→}
   161→
   162→type JobMetricWithName {
   163→  name: String!
   164→  scope: MetricScope!
   165→  metric: JobMetric!
   166→}
   167→
   168→type ClusterMetricWithName {
   169→  name: String!
   170→  unit: Unit
   171→  timestep: Int!
   172→  data: [NullableFloat!]!
   173→}
   174→
   175→type JobMetric {
   176→  unit: Unit
   177→  timestep: Int!
   178→  series: [Series!]
   179→  statisticsSeries: StatsSeries
   180→}
   181→
   182→type Series {
   183→  hostname: String!
   184→  id: String
   185→  statistics: MetricStatistics
   186→  data: [NullableFloat!]!
   187→}
   188→
   189→type StatsSeries {
   190→  mean: [NullableFloat!]!
   191→  median: [NullableFloat!]!
   192→  min: [NullableFloat!]!
   193→  max: [NullableFloat!]!
   194→}
   195→
   196→type NamedStatsWithScope {
   197→  name: String!
   198→  scope: MetricScope!
   199→  stats: [ScopedStats!]!
   200→}
   201→
   202→type ScopedStats {
   203→  hostname: String!
   204→  id: String
   205→  data: MetricStatistics!
   206→}
   207→
   208→type JobStats {
   209→  id: Int!
   210→  jobId: String!
   211→  startTime: Int!
   212→  duration: Int!
   213→  cluster: String!
   214→  subCluster: String!
   215→  numNodes: Int!
   216→  numHWThreads: Int
   217→  numAccelerators: Int
   218→  stats: [NamedStats!]!
   219→}
   220→
   221→type NamedStats {
   222→  name: String!
   223→  data: MetricStatistics!
   224→}
   225→
   226→type Unit {
   227→  base: String!
   228→  prefix: String
   229→}
   230→
   231→type MetricStatistics {
   232→  avg: Float!
   233→  min: Float!
   234→  max: Float!
   235→}
   236→
   237→type MetricFootprints {
   238→  metric: String!
   239→  data: [NullableFloat!]!
   240→}
   241→
   242→type Footprints {
   243→  timeWeights: TimeWeights!
   244→  metrics: [MetricFootprints!]!
   245→}
   246→
   247→type TimeWeights {
   248→  nodeHours: [NullableFloat!]!
   249→  accHours: [NullableFloat!]!
   250→  coreHours: [NullableFloat!]!
   251→}
   252→
   253→enum Aggregate {
   254→  USER
   255→  PROJECT
   256→  CLUSTER
   257→  SUBCLUSTER
   258→}
   259→enum SortByAggregate {
   260→  TOTALWALLTIME
   261→  TOTALJOBS
   262→  TOTALUSERS
   263→  TOTALNODES
   264→  TOTALNODEHOURS
   265→  TOTALCORES
   266→  TOTALCOREHOURS
   267→  TOTALACCS
   268→  TOTALACCHOURS
   269→}
   270→
   271→type NodeMetrics {
   272→  host: String!
   273→  nodeState: String!
   274→  metricHealth: String!
   275→  subCluster: String!
   276→  metrics: [JobMetricWithName!]!
   277→}
   278→
   279→type ClusterMetrics {
   280→  nodeCount: Int!
   281→  metrics: [ClusterMetricWithName!]!
   282→}
   283→
   284→type NodesResultList {
   285→  items: [NodeMetrics!]!
   286→  offset: Int
   287→  limit: Int
   288→  count: Int
   289→  totalNodes: Int
   290→  hasNextPage: Boolean
   291→}
   292→
   293→type ClusterSupport {
   294→  cluster: String!
   295→  subClusters: [String!]!
   296→}
   297→
   298→type GlobalMetricListItem {
   299→  name: String!
   300→  unit: Unit!
   301→  scope: MetricScope!
   302→  footprint: String
   303→  availability: [ClusterSupport!]!
   304→}
   305→
   306→type Count {
   307→  name: String!
   308→  count: Int!
   309→}
   310→
   311→type User {
   312→  username: String!
   313→  name: String!
   314→  email: String!
   315→}
   316→
   317→input MetricStatItem {
   318→  metricName: String!
   319→  range: FloatRange!
   320→}
   321→
   322→type Query {
   323→  clusters: [Cluster!]! # List of all clusters
   324→  tags: [Tag!]! # List of all tags
   325→  globalMetrics: [GlobalMetricListItem!]!
   326→
   327→  user(username: String!): User
   328→  allocatedNodes(cluster: String!): [Count!]!
   329→
   330→  ## Node Queries New
   331→  node(id: ID!): Node
   332→  nodes(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList!
   333→  nodesWithMeta(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList!
   334→  nodeStates(filter: [NodeFilter!]): [NodeStates!]!
   335→  nodeStatesTimed(filter: [NodeFilter!], type: String!): [NodeStatesTimed!]!
   336→
   337→  job(id: ID!): Job
   338→  jobMetrics(
   339→    id: ID!
   340→    metrics: [String!]
   341→    scopes: [MetricScope!]
   342→    resolution: Int
   343→  ): [JobMetricWithName!]!
   344→
   345→  jobStats(id: ID!, metrics: [String!]): [NamedStats!]!
   346→
   347→  scopedJobStats(
   348→    id: ID!
   349→    metrics: [String!]
   350→    scopes: [MetricScope!]
   351→  ): [NamedStatsWithScope!]!
   352→
   353→  jobs(
   354→    filter: [JobFilter!]
   355→    page: PageRequest
   356→    order: OrderByInput
   357→  ): JobResultList!
   358→
   359→  jobsStatistics(
   360→    filter: [JobFilter!]
   361→    metrics: [String!]
   362→    page: PageRequest
   363→    sortBy: SortByAggregate
   364→    groupBy: Aggregate
   365→    numDurationBins: String
   366→    numMetricBins: Int
   367→  ): [JobsStatistics!]!
   368→
   369→  jobsMetricStats(filter: [JobFilter!], metrics: [String!]): [JobStats!]!
   370→  jobsFootprints(filter: [JobFilter!], metrics: [String!]!): Footprints
   371→
   372→  rooflineHeatmap(
   373→    filter: [JobFilter!]!
   374→    rows: Int!
   375→    cols: Int!
   376→    minX: Float!
   377→    minY: Float!
   378→    maxX: Float!
   379→    maxY: Float!
   380→  ): [[Float!]!]!
   381→
   382→  nodeMetrics(
   383→    cluster: String!
   384→    nodes: [String!]
   385→    scopes: [MetricScope!]
   386→    metrics: [String!]
   387→    from: Time!
   388→    to: Time!
   389→  ): [NodeMetrics!]!
   390→
   391→  nodeMetricsList(
   392→    cluster: String!
   393→    subCluster: String!
   394→    stateFilter: String!
   395→    nodeFilter: String!
   396→    scopes: [MetricScope!]
   397→    metrics: [String!]
   398→    from: Time!
   399→    to: Time!
   400→    page: PageRequest
   401→    resolution: Int
   402→  ): NodesResultList!
   403→
   404→  clusterMetrics(
   405→    cluster: String!
   406→    metrics: [String!]
   407→    from: Time!
   408→    to: Time!
   409→  ): ClusterMetrics!
   410→}
   411→
   412→type Mutation {
   413→  createTag(type: String!, name: String!, scope: String!): Tag!
   414→  deleteTag(id: ID!): ID!
   415→  addTagsToJob(job: ID!, tagIds: [ID!]!): [Tag!]!
   416→  removeTagsFromJob(job: ID!, tagIds: [ID!]!): [Tag!]!
   417→  removeTagFromList(tagIds: [ID!]!): [Int!]!
   418→
   419→  updateConfiguration(name: String!, value: String!): String
   420→}
   421→
   422→type IntRangeOutput {
   423→  from: Int!
   424→  to: Int!
   425→}
   426→type TimeRangeOutput {
   427→  range: String
   428→  from: Time!
   429→  to: Time!
   430→}
   431→
   432→input NodeFilter {
   433→  hostname: StringInput
   434→  cluster: StringInput
   435→  subCluster: StringInput
   436→  schedulerState: SchedulerState
   437→  healthState: MonitoringState
   438→  timeStart: Int
   439→}
   440→
   441→input JobFilter {
   442→  tags: [ID!]
   443→  dbId: [ID!]
   444→  jobId: StringInput
   445→  arrayJobId: Int
   446→  user: StringInput
   447→  project: StringInput
   448→  jobName: StringInput
   449→  cluster: StringInput
   450→  subCluster: StringInput
   451→  partition: StringInput
   452→  duration: IntRange
   453→  energy: FloatRange
   454→
   455→  numNodes: IntRange
   456→  numAccelerators: IntRange
   457→  numHWThreads: IntRange
   458→
   459→  startTime: TimeRange
   460→  state: [JobState!]
   461→  metricStats: [MetricStatItem!]
   462→  shared: String
   463→  schedule: String
   464→  node: StringInput
   465→}
   466→
   467→input OrderByInput {
   468→  field: String!
   469→  type: String!
   470→  order: SortDirectionEnum! = ASC
   471→}
   472→
   473→enum SortDirectionEnum {
   474→  DESC
   475→  ASC
   476→}
   477→
   478→input StringInput {
   479→  eq: String
   480→  neq: String
   481→  contains: String
   482→  startsWith: String
   483→  endsWith: String
   484→  in: [String!]
   485→}
   486→
   487→input IntRange {
   488→  from: Int!
   489→  to: Int!
   490→}
   491→input TimeRange {
   492→  range: String
   493→  from: Time
   494→  to: Time
   495→}
   496→
   497→input FloatRange {
   498→  from: Float!
   499→  to: Float!
   500→}
   501→
   502→type NodeStateResultList {
   503→  items: [Node!]!
   504→  count: Int
   505→}
   506→
   507→type JobResultList {
   508→  items: [Job!]!
   509→  offset: Int
   510→  limit: Int
   511→  count: Int
   512→  hasNextPage: Boolean
   513→}
   514→
   515→type JobLinkResultList {
   516→  listQuery: String
   517→  items: [JobLink!]!
   518→  count: Int
   519→}
   520→
   521→type HistoPoint {
   522→  count: Int!
   523→  value: Int!
   524→}
   525→
   526→type MetricHistoPoints {
   527→  metric: String!
   528→  unit: String!
   529→  stat: String
   530→  data: [MetricHistoPoint!]
   531→}
   532→
   533→type MetricHistoPoint {
   534→  bin: Int
   535→  count: Int!
   536→  min: Int
   537→  max: Int
   538→}
   539→
   540→type JobsStatistics {
   541→  id: ID! # If `groupBy` was used, ID of the user/project/cluster/subcluster
   542→  name: String! # if User-Statistics: Given Name of Account (ID) Owner
   543→  totalUsers: Int! # if *not* User-Statistics: Number of active users (based on running jobs)
   544→  totalJobs: Int! # Number of jobs
   545→  runningJobs: Int! # Number of running jobs
   546→  shortJobs: Int! # Number of jobs with a duration of less than config'd ShortRunningJobsDuration
   547→  totalWalltime: Int! # Sum of the duration of all matched jobs in hours
   548→  totalNodes: Int! # Sum of the nodes of all matched jobs
   549→  totalNodeHours: Int! # Sum of the node hours of all matched jobs
   550→  totalCores: Int! # Sum of the cores of all matched jobs
   551→  totalCoreHours: Int! # Sum of the core hours of all matched jobs
   552→  totalAccs: Int! # Sum of the accs of all matched jobs
   553→  totalAccHours: Int! # Sum of the gpu hours of all matched jobs
   554→  histDuration: [HistoPoint!]! # value: hour, count: number of jobs with a rounded duration of value
   555→  histNumNodes: [HistoPoint!]! # value: number of nodes, count: number of jobs with that number of nodes
   556→  histNumCores: [HistoPoint!]! # value: number of cores, count: number of jobs with that number of cores
   557→  histNumAccs: [HistoPoint!]! # value: number of accs, count: number of jobs with that number of accs
   558→  histMetrics: [MetricHistoPoints!]! # metric: metricname, data array of histopoints: value: metric average bin, count: number of jobs with that metric average
   559→}
   560→
   561→input PageRequest {
   562→  itemsPerPage: Int!
   563→  page: Int!
   564→}
   565→

scalar Time
scalar Any

scalar NullableFloat
scalar MetricScope
scalar JobState
scalar SchedulerState
scalar MonitoringState

type Node {
  id: ID!
  hostname: String!
  cluster: String!
  subCluster: String!
  jobsRunning: Int!
  cpusAllocated: Int
  memoryAllocated: Int
  gpusAllocated: Int
  schedulerState: SchedulerState!
  healthState: MonitoringState!
  metaData: Any
  healthData: Any
}

type NodeStates {
  state: String!
  count: Int!
}

type NodeStatesTimed {
  state: String!
  counts: [Int!]!
  times: [Int!]!
}

type Job {
  id: ID!
  jobId: Int!
  user: String!
  project: String!
  cluster: String!
  subCluster: String!
  startTime: Time!
  duration: Int!
  walltime: Int!
  numNodes: Int!
  numHWThreads: Int!
  numAcc: Int!
  energy: Float!
  SMT: Int!
  shared: String!
  partition: String!
  arrayJobId: Int!
  monitoringStatus: Int!
  state: JobState!
  tags: [Tag!]!
  resources: [Resource!]!
  concurrentJobs: JobLinkResultList
  footprint: [FootprintValue]
  energyFootprint: [EnergyFootprintValue]
  metaData: Any
  userData: User
}

type JobLink {
  id: ID!
  jobId: Int!
}

type Cluster {
  name: String!
  partitions: [String!]! # Slurm partitions
  subClusters: [SubCluster!]! # Hardware partitions/subclusters
}

type SubCluster {
  name: String!
  nodes: String!
  numberOfNodes: Int!
  processorType: String!
  socketsPerNode: Int!
  coresPerSocket: Int!
  threadsPerCore: Int!
  flopRateScalar: MetricValue!
  flopRateSimd: MetricValue!
  memoryBandwidth: MetricValue!
  topology: Topology!
  metricConfig: [MetricConfig!]!
  footprint: [String!]!
}

type FootprintValue {
  name: String!
  stat: String!
  value: Float!
}

type EnergyFootprintValue {
  hardware: String!
  metric: String!
  value: Float!
}

type MetricValue {
  name: String
  unit: Unit!
  value: Float!
}

type Topology {
  node: [Int!]
  socket: [[Int!]!]
  memoryDomain: [[Int!]!]
  die: [[Int!]!]
  core: [[Int!]!]
  accelerators: [Accelerator!]
}

type Accelerator {
  id: String!
  type: String!
  model: String!
}

type SubClusterConfig {
  name: String!
  peak: Float
  normal: Float
  caution: Float
  alert: Float
  remove: Boolean
}

type MetricConfig {
  name: String!
  unit: Unit!
  scope: MetricScope!
  aggregation: String!
  timestep: Int!
  peak: Float!
  normal: Float
  caution: Float!
  alert: Float!
  lowerIsBetter: Boolean
  subClusters: [SubClusterConfig!]!
}

type Tag {
  id: ID!
  type: String!
  name: String!
  scope: String!
}

type Resource {
  hostname: String!
  hwthreads: [Int!]
  accelerators: [String!]
  configuration: String
}

type JobMetricWithName {
  name: String!
  scope: MetricScope!
  metric: JobMetric!
}

type ClusterMetricWithName {
  name: String!
  unit: Unit
  timestep: Int!
  data: [NullableFloat!]!
}

type JobMetric {
  unit: Unit
  timestep: Int!
  series: [Series!]
  statisticsSeries: StatsSeries
}

type Series {
  hostname: String!
  id: String
  statistics: MetricStatistics
  data: [NullableFloat!]!
}

type StatsSeries {
  mean: [NullableFloat!]!
  median: [NullableFloat!]!
  min: [NullableFloat!]!
  max: [NullableFloat!]!
}

type NamedStatsWithScope {
  name: String!
  scope: MetricScope!
  stats: [ScopedStats!]!
}

type ScopedStats {
  hostname: String!
  id: String
  data: MetricStatistics!
}

type JobStats {
  id: Int!
  jobId: String!
  startTime: Int!
  duration: Int!
  cluster: String!
  subCluster: String!
  numNodes: Int!
  numHWThreads: Int
  numAccelerators: Int
  stats: [NamedStats!]!
}

type NamedStats {
  name: String!
  data: MetricStatistics!
}

type Unit {
  base: String!
  prefix: String
}

type MetricStatistics {
  avg: Float!
  min: Float!
  max: Float!
}

type MetricFootprints {
  metric: String!
  data: [NullableFloat!]!
}

type Footprints {
  timeWeights: TimeWeights!
  metrics: [MetricFootprints!]!
}

type TimeWeights {
  nodeHours: [NullableFloat!]!
  accHours: [NullableFloat!]!
  coreHours: [NullableFloat!]!
}

enum Aggregate {
  USER
  PROJECT
  CLUSTER
  SUBCLUSTER
}
enum SortByAggregate {
  TOTALWALLTIME
  TOTALJOBS
  TOTALUSERS
  TOTALNODES
  TOTALNODEHOURS
  TOTALCORES
  TOTALCOREHOURS
  TOTALACCS
  TOTALACCHOURS
}

type NodeMetrics {
  host: String!
  nodeState: String!
  metricHealth: String!
  subCluster: String!
  metrics: [JobMetricWithName!]!
}

type ClusterMetrics {
  nodeCount: Int!
  metrics: [ClusterMetricWithName!]!
}

type NodesResultList {
  items: [NodeMetrics!]!
  offset: Int
  limit: Int
  count: Int
  totalNodes: Int
  hasNextPage: Boolean
}

type ClusterSupport {
  cluster: String!
  subClusters: [String!]!
}

type GlobalMetricListItem {
  name: String!
  unit: Unit!
  scope: MetricScope!
  footprint: String
  availability: [ClusterSupport!]!
}

type Count {
  name: String!
  count: Int!
}

type User {
  username: String!
  name: String!
  email: String!
}

input MetricStatItem {
  metricName: String!
  range: FloatRange!
}

type Query {
  clusters: [Cluster!]! # List of all clusters
  tags: [Tag!]! # List of all tags
  globalMetrics: [GlobalMetricListItem!]!

  user(username: String!): User
  allocatedNodes(cluster: String!): [Count!]!

  ## Node Queries New
  node(id: ID!): Node
  nodes(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList!
  nodesWithMeta(filter: [NodeFilter!], order: OrderByInput): NodeStateResultList!
  nodeStates(filter: [NodeFilter!]): [NodeStates!]!
  nodeStatesTimed(filter: [NodeFilter!], type: String!): [NodeStatesTimed!]!

  job(id: ID!): Job
  jobMetrics(
    id: ID!
    metrics: [String!]
    scopes: [MetricScope!]
    resolution: Int
  ): [JobMetricWithName!]!

  jobStats(id: ID!, metrics: [String!]): [NamedStats!]!

  scopedJobStats(
    id: ID!
    metrics: [String!]
    scopes: [MetricScope!]
  ): [NamedStatsWithScope!]!

  jobs(
    filter: [JobFilter!]
    page: PageRequest
    order: OrderByInput
  ): JobResultList!

  jobsStatistics(
    filter: [JobFilter!]
    metrics: [String!]
    page: PageRequest
    sortBy: SortByAggregate
    groupBy: Aggregate
    numDurationBins: String
    numMetricBins: Int
  ): [JobsStatistics!]!

  jobsMetricStats(filter: [JobFilter!], metrics: [String!]): [JobStats!]!
  jobsFootprints(filter: [JobFilter!], metrics: [String!]!): Footprints

  rooflineHeatmap(
    filter: [JobFilter!]!
    rows: Int!
    cols: Int!
    minX: Float!
    minY: Float!
    maxX: Float!
    maxY: Float!
  ): [[Float!]!]!

  nodeMetrics(
    cluster: String!
    nodes: [String!]
    scopes: [MetricScope!]
    metrics: [String!]
    from: Time!
    to: Time!
  ): [NodeMetrics!]!

  nodeMetricsList(
    cluster: String!
    subCluster: String!
    stateFilter: String!
    nodeFilter: String!
    scopes: [MetricScope!]
    metrics: [String!]
    from: Time!
    to: Time!
    page: PageRequest
    resolution: Int
  ): NodesResultList!

  clusterMetrics(
    cluster: String!
    metrics: [String!]
    from: Time!
    to: Time!
  ): ClusterMetrics!
}

type Mutation {
  createTag(type: String!, name: String!, scope: String!): Tag!
  deleteTag(id: ID!): ID!
  addTagsToJob(job: ID!, tagIds: [ID!]!): [Tag!]!
  removeTagsFromJob(job: ID!, tagIds: [ID!]!): [Tag!]!
  removeTagFromList(tagIds: [ID!]!): [Int!]!

  updateConfiguration(name: String!, value: String!): String
}

type IntRangeOutput {
  from: Int!
  to: Int!
}
type TimeRangeOutput {
  range: String
  from: Time!
  to: Time!
}

input NodeFilter {
  hostname: StringInput
  cluster: StringInput
  subCluster: StringInput
  schedulerState: SchedulerState
  healthState: MonitoringState
  timeStart: Int
}

input JobFilter {
  tags: [ID!]
  dbId: [ID!]
  jobId: StringInput
  arrayJobId: Int
  user: StringInput
  project: StringInput
  jobName: StringInput
  cluster: StringInput
  subCluster: StringInput
  partition: StringInput
  duration: IntRange
  energy: FloatRange

  numNodes: IntRange
  numAccelerators: IntRange
  numHWThreads: IntRange

  startTime: TimeRange
  state: [JobState!]
  metricStats: [MetricStatItem!]
  shared: String
  schedule: String
  node: StringInput
}

input OrderByInput {
  field: String!
  type: String!
  order: SortDirectionEnum! = ASC
}

enum SortDirectionEnum {
  DESC
  ASC
}

input StringInput {
  eq: String
  neq: String
  contains: String
  startsWith: String
  endsWith: String
  in: [String!]
}

input IntRange {
  from: Int!
  to: Int!
}
input TimeRange {
  range: String
  from: Time
  to: Time
}

input FloatRange {
  from: Float!
  to: Float!
}

type NodeStateResultList {
  items: [Node!]!
  count: Int
}

type JobResultList {
  items: [Job!]!
  offset: Int
  limit: Int
  count: Int
  hasNextPage: Boolean
}

type JobLinkResultList {
  listQuery: String
  items: [JobLink!]!
  count: Int
}

type HistoPoint {
  count: Int!
  value: Int!
}

type MetricHistoPoints {
  metric: String!
  unit: String!
  stat: String
  data: [MetricHistoPoint!]
}

type MetricHistoPoint {
  bin: Int
  count: Int!
  min: Int
  max: Int
}

type JobsStatistics {
  id: ID! # If `groupBy` was used, ID of the user/project/cluster/subcluster
  name: String! # if User-Statistics: Given Name of Account (ID) Owner
  totalUsers: Int! # if *not* User-Statistics: Number of active users (based on running jobs)
  totalJobs: Int! # Number of jobs
  runningJobs: Int! # Number of running jobs
  shortJobs: Int! # Number of jobs with a duration of less than config'd ShortRunningJobsDuration
  totalWalltime: Int! # Sum of the duration of all matched jobs in hours
  totalNodes: Int! # Sum of the nodes of all matched jobs
  totalNodeHours: Int! # Sum of the node hours of all matched jobs
  totalCores: Int! # Sum of the cores of all matched jobs
  totalCoreHours: Int! # Sum of the core hours of all matched jobs
  totalAccs: Int! # Sum of the accs of all matched jobs
  totalAccHours: Int! # Sum of the gpu hours of all matched jobs
  histDuration: [HistoPoint!]! # value: hour, count: number of jobs with a rounded duration of value
  histNumNodes: [HistoPoint!]! # value: number of nodes, count: number of jobs with that number of nodes
  histNumCores: [HistoPoint!]! # value: number of cores, count: number of jobs with that number of cores
  histNumAccs: [HistoPoint!]! # value: number of accs, count: number of jobs with that number of accs
  histMetrics: [MetricHistoPoints!]! # metric: metricname, data array of histopoints: value: metric average bin, count: number of jobs with that metric average
}

input PageRequest {
  itemsPerPage: Int!
  page: Int!
}