feat(kpi): K10 — KpiSeriesBucketer last-per-bucket downsampler
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Kpi;
|
||||
|
||||
/// <summary>
|
||||
/// Pure, deterministic downsampling helper for KPI series charting (M6 "KPI History & Trends").
|
||||
/// Reduces a raw <see cref="KpiSeriesPoint"/> series to at most <c>maxPoints</c> 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.
|
||||
/// </summary>
|
||||
public static class KpiSeriesBucketer
|
||||
{
|
||||
/// <summary>
|
||||
/// Reduces <paramref name="raw"/> to at most <paramref name="maxPoints"/> points by dividing
|
||||
/// the window <c>[<paramref name="fromUtc"/>, <paramref name="toUtc"/>]</c> into equal-width
|
||||
/// time buckets and keeping the <em>last</em> raw point (highest
|
||||
/// <see cref="KpiSeriesPoint.BucketStartUtc"/>) within each non-empty bucket.
|
||||
/// Empty buckets are omitted — no gap-filling.
|
||||
/// </summary>
|
||||
/// <param name="raw">
|
||||
/// Input series, assumed to be sorted ascending by <see cref="KpiSeriesPoint.BucketStartUtc"/>.
|
||||
/// If not sorted, the point with the largest timestamp within each bucket is selected.
|
||||
/// If <c>null</c> or empty, an empty list is returned.
|
||||
/// </param>
|
||||
/// <param name="fromUtc">UTC start of the query window (inclusive).</param>
|
||||
/// <param name="toUtc">UTC end of the query window (inclusive on the right edge).</param>
|
||||
/// <param name="maxPoints">Maximum number of output points. Must be ≥ 2.</param>
|
||||
/// <returns>
|
||||
/// An <see cref="IReadOnlyList{T}"/> of at most <paramref name="maxPoints"/> bucketed points,
|
||||
/// ordered by <see cref="KpiSeriesPoint.BucketStartUtc"/> ascending.
|
||||
/// Returns <paramref name="raw"/> unchanged (same reference) when
|
||||
/// <c>raw.Count <= maxPoints</c>.
|
||||
/// </returns>
|
||||
/// <exception cref="ArgumentOutOfRangeException">
|
||||
/// Thrown when <paramref name="maxPoints"/> < 2 or
|
||||
/// <paramref name="toUtc"/> <= <paramref name="fromUtc"/>.
|
||||
/// These are caller programming errors — a chart needs at least two points and a
|
||||
/// non-degenerate window.
|
||||
/// </exception>
|
||||
public static IReadOnlyList<KpiSeriesPoint> Bucket(
|
||||
IReadOnlyList<KpiSeriesPoint> 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<KpiSeriesPoint>();
|
||||
|
||||
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<KpiSeriesPoint>(maxPoints);
|
||||
for (int i = 0; i < maxPoints; i++)
|
||||
{
|
||||
if (occupied[i])
|
||||
result.Add(best[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user