diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiSeriesBucketer.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiSeriesBucketer.cs new file mode 100644 index 00000000..78e87ba7 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Types/Kpi/KpiSeriesBucketer.cs @@ -0,0 +1,109 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; + +/// +/// Pure, deterministic downsampling helper for KPI series charting (M6 "KPI History & Trends"). +/// Reduces a raw series to at most maxPoints points using +/// last-value-per-bucket / gauge semantics — suitable for step/area charts where the most +/// recent value in a window best represents that window. +/// +public static class KpiSeriesBucketer +{ + /// + /// Reduces to at most points by dividing + /// the window [, ] into equal-width + /// time buckets and keeping the last raw point (highest + /// ) within each non-empty bucket. + /// Empty buckets are omitted — no gap-filling. + /// + /// + /// Input series, assumed to be sorted ascending by . + /// If not sorted, the point with the largest timestamp within each bucket is selected. + /// If null or empty, an empty list is returned. + /// + /// UTC start of the query window (inclusive). + /// UTC end of the query window (inclusive on the right edge). + /// Maximum number of output points. Must be ≥ 2. + /// + /// An of at most bucketed points, + /// ordered by ascending. + /// Returns unchanged (same reference) when + /// raw.Count <= maxPoints. + /// + /// + /// Thrown when < 2 or + /// <= . + /// These are caller programming errors — a chart needs at least two points and a + /// non-degenerate window. + /// + public static IReadOnlyList Bucket( + IReadOnlyList raw, + DateTime fromUtc, + DateTime toUtc, + int maxPoints) + { + if (maxPoints < 2) + throw new ArgumentOutOfRangeException(nameof(maxPoints), + maxPoints, "maxPoints must be >= 2."); + + if (toUtc <= fromUtc) + throw new ArgumentOutOfRangeException(nameof(toUtc), + toUtc, "toUtc must be strictly greater than fromUtc."); + + // Normal runtime case — empty or short series: return as-is. + if (raw is null || raw.Count == 0) + return Array.Empty(); + + if (raw.Count <= maxPoints) + return raw; + + // Divide the window into maxPoints equal-width buckets. + // Each bucket covers [bucketStart, bucketStart + bucketWidth). + // The right edge (toUtc) belongs to the last bucket to avoid overflow. + double windowTicks = (double)(toUtc.Ticks - fromUtc.Ticks); + double bucketWidthTicks = windowTicks / maxPoints; + + // For each bucket, track the candidate point: the one with the + // maximum BucketStartUtc (last value within the bucket). + // We use a fixed-size array indexed by bucket number. + // Nullable KpiSeriesPoint[] with 'hasValue' flags is fine since the + // struct is small. + var best = new KpiSeriesPoint[maxPoints]; + var occupied = new bool[maxPoints]; + + foreach (var point in raw) + { + long offsetTicks = point.BucketStartUtc.Ticks - fromUtc.Ticks; + + // Skip points outside [fromUtc, toUtc]. + if (offsetTicks < 0 || point.BucketStartUtc > toUtc) + continue; + + // Compute bucket index; clamp to last bucket so toUtc itself + // doesn't overflow to index maxPoints. + int bucketIndex = (int)(offsetTicks / bucketWidthTicks); + if (bucketIndex >= maxPoints) + bucketIndex = maxPoints - 1; + + // Keep the point with the highest timestamp in this bucket + // (last-value semantics; if ties, keep first encountered — stable). + if (!occupied[bucketIndex] || + point.BucketStartUtc > best[bucketIndex].BucketStartUtc) + { + best[bucketIndex] = new KpiSeriesPoint( + fromUtc + TimeSpan.FromTicks((long)(bucketIndex * bucketWidthTicks)), + point.Value); + occupied[bucketIndex] = true; + } + } + + // Collect non-empty buckets in order. + var result = new List(maxPoints); + for (int i = 0; i < maxPoints; i++) + { + if (occupied[i]) + result.Add(best[i]); + } + + return result; + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs new file mode 100644 index 00000000..1f43a86c --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Kpi/KpiSeriesBucketerTests.cs @@ -0,0 +1,302 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi; + +namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Kpi; + +/// Tests for . +public class KpiSeriesBucketerTests +{ + // Fixed epoch used throughout so tests are fully deterministic. + private static readonly DateTime Epoch = + new(2026, 6, 15, 0, 0, 0, DateTimeKind.Utc); + + private static DateTime T(int minutesFromEpoch) => + Epoch.AddMinutes(minutesFromEpoch); + + // ----------------------------------------------------------------------- + // Guard / invalid-argument contracts + // ----------------------------------------------------------------------- + + [Fact] + public void Bucket_MaxPointsLessThan2_ThrowsArgumentOutOfRangeException() + { + var raw = new[] { new KpiSeriesPoint(T(0), 1.0) }; + + Assert.Throws(() => + KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 1)); + } + + [Fact] + public void Bucket_MaxPointsZero_ThrowsArgumentOutOfRangeException() + { + var raw = new[] { new KpiSeriesPoint(T(0), 1.0) }; + + Assert.Throws(() => + KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 0)); + } + + [Fact] + public void Bucket_ToUtcEqualFromUtc_ThrowsArgumentOutOfRangeException() + { + var raw = new[] { new KpiSeriesPoint(T(0), 1.0) }; + + Assert.Throws(() => + KpiSeriesBucketer.Bucket(raw, T(30), T(30), maxPoints: 5)); + } + + [Fact] + public void Bucket_ToUtcBeforeFromUtc_ThrowsArgumentOutOfRangeException() + { + var raw = new[] { new KpiSeriesPoint(T(0), 1.0) }; + + Assert.Throws(() => + KpiSeriesBucketer.Bucket(raw, T(60), T(0), maxPoints: 5)); + } + + // ----------------------------------------------------------------------- + // Empty / null raw series + // ----------------------------------------------------------------------- + + [Fact] + public void Bucket_NullRaw_ReturnsEmptyList() + { + var result = KpiSeriesBucketer.Bucket(null!, T(0), T(60), maxPoints: 10); + + Assert.Empty(result); + } + + [Fact] + public void Bucket_EmptyRaw_ReturnsEmptyList() + { + var result = KpiSeriesBucketer.Bucket( + Array.Empty(), T(0), T(60), maxPoints: 10); + + Assert.Empty(result); + } + + // ----------------------------------------------------------------------- + // raw.Count <= maxPoints → returned unchanged (same reference) + // ----------------------------------------------------------------------- + + [Fact] + public void Bucket_RawCountEqualsMaxPoints_ReturnsSameReference() + { + var raw = new[] + { + new KpiSeriesPoint(T(0), 1.0), + new KpiSeriesPoint(T(10), 2.0), + new KpiSeriesPoint(T(20), 3.0), + }; + + var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3); + + Assert.Same(raw, result); + } + + [Fact] + public void Bucket_RawCountLessThanMaxPoints_ReturnsSameReference() + { + var raw = new[] + { + new KpiSeriesPoint(T(0), 5.0), + new KpiSeriesPoint(T(5), 6.0), + }; + + var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 10); + + Assert.Same(raw, result); + } + + [Fact] + public void Bucket_SinglePoint_ReturnsSameReference() + { + var raw = new[] { new KpiSeriesPoint(T(30), 42.0) }; + + var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 5); + + Assert.Same(raw, result); + } + + // ----------------------------------------------------------------------- + // Downsampling: correct bucket count and last-value selection + // ----------------------------------------------------------------------- + + [Fact] + public void Bucket_MorePointsThanMaxPoints_ReducesToMaxPointsBuckets() + { + // 60-minute window / 3 buckets → 20 min each. + // Plant exactly one point in each bucket; all three buckets occupied. + var raw = new[] + { + new KpiSeriesPoint(T(5), 10.0), // bucket 0: [0, 20) + new KpiSeriesPoint(T(25), 20.0), // bucket 1: [20, 40) + new KpiSeriesPoint(T(45), 30.0), // bucket 2: [40, 60] + }; + + var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3); + + Assert.Equal(3, result.Count); + Assert.Equal(10.0, result[0].Value); + Assert.Equal(20.0, result[1].Value); + Assert.Equal(30.0, result[2].Value); + } + + [Fact] + public void Bucket_MultiplePointsInSameBucket_LastValueWins() + { + // 60-minute window / 2 buckets → 30 min each. + // Three points all land in bucket 0; the last (T(25)) should win. + var raw = new[] + { + new KpiSeriesPoint(T(5), 1.0), + new KpiSeriesPoint(T(15), 2.0), + new KpiSeriesPoint(T(25), 99.0), // latest in bucket 0 → wins + new KpiSeriesPoint(T(35), 5.0), // bucket 1 + }; + + var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2); + + Assert.Equal(2, result.Count); + Assert.Equal(99.0, result[0].Value); // last in bucket 0 + Assert.Equal(5.0, result[1].Value); // only point in bucket 1 + } + + [Fact] + public void Bucket_BucketStartUtc_IsSetToBucketStartNotRawPointTimestamp() + { + // 60-minute window / 3 buckets → 20 min each. + // Output BucketStartUtc must be the bucket boundary, not the raw point's time. + // raw.Count (4) > maxPoints (3) ensures the downsampling path runs. + var raw = new[] + { + new KpiSeriesPoint(T(7), 1.0), // inside bucket 0: [0, 20) + new KpiSeriesPoint(T(22), 2.0), // inside bucket 1: [20, 40) + new KpiSeriesPoint(T(35), 3.0), // inside bucket 1: later → wins bucket 1 + new KpiSeriesPoint(T(55), 4.0), // inside bucket 2: [40, 60] + }; + + var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 3); + + // Bucket boundaries: 0 min, 20 min, 40 min + Assert.Equal(3, result.Count); + Assert.Equal(T(0), result[0].BucketStartUtc); + Assert.Equal(T(20), result[1].BucketStartUtc); + Assert.Equal(T(40), result[2].BucketStartUtc); + } + + // ----------------------------------------------------------------------- + // Right-edge: point exactly at toUtc lands in the last bucket + // ----------------------------------------------------------------------- + + [Fact] + public void Bucket_PointAtToUtc_LandsInLastBucket() + { + // 60-minute window / 3 buckets → 20 min each. + // A point exactly at T(60) = toUtc must go to bucket 2, not overflow. + var raw = new[] + { + new KpiSeriesPoint(T(10), 1.0), + new KpiSeriesPoint(T(30), 2.0), + new KpiSeriesPoint(T(60), 99.0), // right edge — must land in last bucket + }; + + // raw.Count (3) == maxPoints (3) so it normally returns as-is; + // use maxPoints=2 to force downsampling and expose the edge behaviour. + // Window: [T(0), T(60)], 2 buckets → 30 min each. + // T(10) → bucket 0, T(30) → bucket 1, T(60) → bucket 1 (last). + var raw2 = new[] + { + new KpiSeriesPoint(T(10), 5.0), + new KpiSeriesPoint(T(35), 6.0), + new KpiSeriesPoint(T(60), 7.0), // exactly toUtc → bucket 1 + }; + + var result = KpiSeriesBucketer.Bucket(raw2, T(0), T(60), maxPoints: 2); + + Assert.Equal(2, result.Count); + // Bucket 1 holds both T(35) and T(60); T(60) is later → wins. + Assert.Equal(7.0, result[1].Value); + } + + // ----------------------------------------------------------------------- + // Empty buckets omitted — no gap-filling + // ----------------------------------------------------------------------- + + [Fact] + public void Bucket_GapInRaw_EmptyBucketsOmitted() + { + // 60-minute window / 4 buckets → 15 min each. + // Populate only buckets 0 and 3; buckets 1 and 2 are empty. + // Expect 2 output points, not 4. + var raw = new[] + { + new KpiSeriesPoint(T(5), 10.0), // bucket 0: [0, 15) + new KpiSeriesPoint(T(50), 20.0), // bucket 3: [45, 60] + }; + + // raw.Count (2) < maxPoints (4), so normally returns same reference. + // To test the gap-omission path we need raw.Count > maxPoints and a gap. + var raw2 = new[] + { + new KpiSeriesPoint(T(5), 10.0), + new KpiSeriesPoint(T(6), 11.0), + new KpiSeriesPoint(T(50), 20.0), + new KpiSeriesPoint(T(51), 21.0), + }; + + // 4 raw points, maxPoints=4 → returns same reference (no downsampling). + // Use maxPoints=3 to trigger the downsampler. + // 60-minute window / 3 buckets → 20 min each. + // T(5) → bucket 0, T(6) → bucket 0, T(50) → bucket 2, T(51) → bucket 2. + // Bucket 1 is empty → 2 output points. + var result = KpiSeriesBucketer.Bucket(raw2, T(0), T(60), maxPoints: 3); + + Assert.Equal(2, result.Count); + Assert.Equal(11.0, result[0].Value); // last in bucket 0 + Assert.Equal(21.0, result[1].Value); // last in bucket 2 + } + + // ----------------------------------------------------------------------- + // Points outside [fromUtc, toUtc] are ignored + // ----------------------------------------------------------------------- + + [Fact] + public void Bucket_PointsOutsideWindow_AreIgnored() + { + // Window [T(10), T(50)], 2 buckets. + // Points at T(0) and T(60) are outside — should not appear in output. + var raw = new[] + { + new KpiSeriesPoint(T(0), 999.0), // before window + new KpiSeriesPoint(T(20), 1.0), // inside bucket 0: [T(10), T(30)) + new KpiSeriesPoint(T(40), 2.0), // inside bucket 1: [T(30), T(50)] + new KpiSeriesPoint(T(60), 999.0), // after window + }; + + var result = KpiSeriesBucketer.Bucket(raw, T(10), T(50), maxPoints: 2); + + Assert.Equal(2, result.Count); + Assert.Equal(1.0, result[0].Value); + Assert.Equal(2.0, result[1].Value); + } + + // ----------------------------------------------------------------------- + // Two-point minimum — maxPoints == 2 works correctly + // ----------------------------------------------------------------------- + + [Fact] + public void Bucket_MaxPointsExactly2_ProducesAtMostTwoBuckets() + { + var raw = Enumerable + .Range(0, 10) + .Select(i => new KpiSeriesPoint(T(i * 6), (double)i)) + .ToArray(); + + // 60-minute window / 2 buckets → 30 min each. + var result = KpiSeriesBucketer.Bucket(raw, T(0), T(60), maxPoints: 2); + + Assert.Equal(2, result.Count); + // Bucket 0: T(0)–T(29) → last is T(24) (value 4); bucket 1: T(30)–T(60) → last is T(54) (value 9). + Assert.Equal(4.0, result[0].Value); + Assert.Equal(9.0, result[1].Value); + } +}