Merge feat/671-time-weighted-aggregates: Part 13 time-weighted aggregates (#671)
This commit is contained in:
@@ -22,6 +22,10 @@ public interface IConformanceBackend : IAsyncDisposable
|
||||
/// <param name="clock">Its "now"; null means the system clock.</param>
|
||||
IHistorianDataSource CreateDataSource(TimeProvider? clock = null);
|
||||
|
||||
/// <summary>A fresh data source whose time-weighted raw-row cap (<c>ServerHistorian:MaxTimeWeightedRawRows</c>) is <paramref name="maxTimeWeightedRawRows"/>.</summary>
|
||||
/// <param name="maxTimeWeightedRawRows">The cap (<= 0 = unbounded).</param>
|
||||
IHistorianDataSource CreateDataSource(int maxTimeWeightedRawRows);
|
||||
|
||||
/// <summary>A fresh live-value writer over this backend's store.</summary>
|
||||
/// <param name="utcNow">The clock used to stamp values that carry no timestamp; null means the system clock.</param>
|
||||
IHistorianValueWriter CreateValueWriter(Func<DateTime>? utcNow = null);
|
||||
|
||||
@@ -39,7 +39,6 @@ public abstract class ProcessedConformance : ConformanceBase
|
||||
[InlineData(HistoryAggregateType.Average, 15d, 40d, 50d)]
|
||||
[InlineData(HistoryAggregateType.Minimum, 10d, 40d, 50d)]
|
||||
[InlineData(HistoryAggregateType.Maximum, 20d, 40d, 50d)]
|
||||
[InlineData(HistoryAggregateType.Total, 30d, 40d, 50d)]
|
||||
[InlineData(HistoryAggregateType.Start, 10d, 40d, 50d)]
|
||||
[InlineData(HistoryAggregateType.End, 20d, 40d, 50d)]
|
||||
[InlineData(HistoryAggregateType.Delta, 10d, 0d, 0d)]
|
||||
@@ -121,8 +120,8 @@ public abstract class ProcessedConformance : ConformanceBase
|
||||
(edge, HistorianValue.FromDouble(4), Good), // first tick of B1
|
||||
(At(40).AddTicks(-B.ResolutionTicks), HistorianValue.FromDouble(8), Good), // last tick of B1
|
||||
(At(40), HistorianValue.FromDouble(2000), Good)); // == end: excluded
|
||||
var s = (await B.CreateDataSource().ReadProcessedAsync("E", At(0), At(40), Twenty, HistoryAggregateType.Total, Ct)).Samples;
|
||||
s.Select(x => x.Value).ShouldBe([3d, 12d]);
|
||||
var s = (await B.CreateDataSource().ReadProcessedAsync("E", At(0), At(40), Twenty, HistoryAggregateType.Average, Ct)).Samples;
|
||||
s.Select(x => x.Value).ShouldBe([1.5d, 6d]);
|
||||
s.ShouldAllBe(x => x.StatusCode == GoodCalc);
|
||||
}
|
||||
|
||||
@@ -164,11 +163,15 @@ public abstract class ProcessedConformance : ConformanceBase
|
||||
(await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Count, Ct))
|
||||
.Samples.Single().Value.ShouldBe(4);
|
||||
// Also a whole-DateTime-range window (0001 → 9999-12-31T23:59:59.9999999).
|
||||
(await src.ReadProcessedAsync("A", DateTime.MinValue, DateTime.MaxValue, TimeSpan.Zero, HistoryAggregateType.Total, Ct))
|
||||
.Samples.Single().Value.ShouldBe(120d);
|
||||
(await src.ReadProcessedAsync("A", DateTime.MinValue, DateTime.MaxValue, TimeSpan.Zero, HistoryAggregateType.Count, Ct))
|
||||
.Samples.Single().Value.ShouldBe(4);
|
||||
// An interval wider than the window (here TimeSpan.MaxValue) is the same single interval, no overflow.
|
||||
(await src.ReadProcessedAsync("A", start, end, TimeSpan.MaxValue, HistoryAggregateType.Total, Ct))
|
||||
.Samples.Single().Value.ShouldBe(120d);
|
||||
(await src.ReadProcessedAsync("A", start, end, TimeSpan.MaxValue, HistoryAggregateType.Count, Ct))
|
||||
.Samples.Single().Value.ShouldBe(4);
|
||||
// The time-weighted path over the whole range: ≈ the extrapolated 50 that dominates eight millennia.
|
||||
var tw = (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.TimeAverage2, Ct)).Samples.Single();
|
||||
((double)tw.Value!).ShouldBe(50d, 1e-6);
|
||||
tw.StatusCode.ShouldBe(UncertainCalc);
|
||||
src.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
@@ -303,7 +306,8 @@ public abstract class ProcessedConformance : ConformanceBase
|
||||
async Task<DataValueSnapshot> One(HistoryAggregateType a) =>
|
||||
(await src.ReadProcessedAsync("B", At(0), At(20), Twenty, a, Ct)).Samples.Single();
|
||||
(await One(HistoryAggregateType.Average)).Value.ShouldBe(0.75d);
|
||||
(await One(HistoryAggregateType.Total)).Value.ShouldBe(3d);
|
||||
// Total is time-weighted (#671): stepped 1 over [0,5), 0 over [5,10), 1 over [10,20) ⇒ 15 value·s.
|
||||
(await One(HistoryAggregateType.Total)).Value.ShouldBe(15d);
|
||||
(await One(HistoryAggregateType.Start)).Value.ShouldBe(true);
|
||||
(await One(HistoryAggregateType.Count)).Value.ShouldBe(4);
|
||||
}
|
||||
@@ -317,8 +321,6 @@ public abstract class ProcessedConformance : ConformanceBase
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HistoryAggregateType.Interpolative)]
|
||||
[InlineData(HistoryAggregateType.TimeAverage)]
|
||||
[InlineData(HistoryAggregateType.StartBound)]
|
||||
[InlineData(HistoryAggregateType.EndBound)]
|
||||
[InlineData(HistoryAggregateType.DeltaBounds)]
|
||||
@@ -338,6 +340,174 @@ public abstract class ProcessedConformance : ConformanceBase
|
||||
src.GetHealthSnapshot().TotalQueries.ShouldBe(0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------ Time-weighted (#671)
|
||||
|
||||
private const uint GoodRaw = 0x00000000u;
|
||||
private const uint GoodInterp = 0x00000402u; // Good | InfoType 0x400 | Interpolated 0x2
|
||||
private const uint UncertainInterp = 0x40A40402u; // UncertainDataSubNormal | 0x402
|
||||
private const uint NoData = 0x809B0000u;
|
||||
private const uint BadCalc = 0x80000401u; // generic Bad | Calculated (an all-outage interval)
|
||||
|
||||
/// <summary>
|
||||
/// Sparse tag W: −30 = 0 G (bound before the window), +20 = 40 G, +30 = outage marker, +50 = 20 G, +100 = 20 G
|
||||
/// (bound after the window). Window [t0, t0+80s). Simple-bounding signal: [−30,20) 0→40 sloped Good (24 at +0,
|
||||
/// 32 at +10); [20,30) 40 held into the outage, Uncertain; [30,50) Bad; [50,100) 20 Good. Interpolated bounding:
|
||||
/// the same, but [20,50) is sloped 40→20 straight across the outage (100/3 at +30, 80/3 at +40).
|
||||
/// </summary>
|
||||
private Task SeedWAsync() => B.SeedAsync("W",
|
||||
(At(-30), HistorianValue.FromDouble(0), Good),
|
||||
(At(20), HistorianValue.FromDouble(40), Good),
|
||||
(At(30), HistorianValue.Empty, BadNoCommunication),
|
||||
(At(50), HistorianValue.FromDouble(20), Good),
|
||||
(At(100), HistorianValue.FromDouble(20), Good));
|
||||
|
||||
private async Task<IReadOnlyList<DataValueSnapshot>> ReadW(HistoryAggregateType agg, double intervalS) =>
|
||||
(await B.CreateDataSource().ReadProcessedAsync("W", At(0), At(80), TimeSpan.FromSeconds(intervalS), agg, Ct)).Samples;
|
||||
|
||||
private static void Values(IReadOnlyList<DataValueSnapshot> s, params double?[] expected)
|
||||
{
|
||||
s.Count.ShouldBe(expected.Length);
|
||||
for (var i = 0; i < expected.Length; i++)
|
||||
{
|
||||
if (expected[i] is { } e) ((double)s[i].Value!).ShouldBe(e, 1e-9, $"bucket {i}");
|
||||
else s[i].Value.ShouldBeNull($"bucket {i}");
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// 40 s intervals (wider than the sample gaps). B0 [0,40): raw [0,20) 24→40 = 640 Good + [20,30) 40 = 400 Uncertain,
|
||||
// [30,40) Bad; bridged 640 + [20,40) 40→80/3 = 2000/3. B1 [40,80): raw [50,80) 20 = 600 Good, [40,50) Bad; bridged
|
||||
// [40,50) 80/3→20 = 700/3 + 600.
|
||||
[InlineData(HistoryAggregateType.TimeAverage, 98d / 3d, 125d / 6d)] // (3920/3)/40, (2500/3)/40
|
||||
[InlineData(HistoryAggregateType.Total, 3920d / 3d, 2500d / 3d)] // TimeAverage × 40 s
|
||||
[InlineData(HistoryAggregateType.TimeAverage2, 104d / 3d, 20d)] // 1040/30 s, 600/30 s
|
||||
[InlineData(HistoryAggregateType.Total2, 1040d, 600d)]
|
||||
public async Task Time_weighted_aggregates_over_a_sparse_tag_with_wide_intervals(HistoryAggregateType agg, double b0, double b1)
|
||||
{
|
||||
await SeedWAsync();
|
||||
var s = await ReadW(agg, 40);
|
||||
Values(s, b0, b1);
|
||||
// Neither interval is Good throughout (an outage in each) ⇒ Uncertain.
|
||||
s.Select(x => x.StatusCode).ShouldBe([UncertainCalc, UncertainCalc]);
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(0)), B.Round(At(40))]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interpolative_over_a_sparse_tag_with_wide_intervals()
|
||||
{
|
||||
await SeedWAsync();
|
||||
var s = await ReadW(HistoryAggregateType.Interpolative, 40);
|
||||
// +0: from the bound before the window, 0 + 40·(30/50) = 24, Good. +40: inside the outage, bridged 80/3, Uncertain.
|
||||
Values(s, 24, 80d / 3d);
|
||||
s.Select(x => x.StatusCode).ShouldBe([GoodInterp, UncertainInterp]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TimeAverage2_over_a_sparse_tag_with_narrow_intervals()
|
||||
{
|
||||
await SeedWAsync();
|
||||
// 10 s intervals (narrower than the gaps): [0,10) 24→32 = 28; [10,20) 32→40 = 36; [20,30) 40 held up to the
|
||||
// outage (Uncertain time, which counts as Good: TreatUncertainAsBad = false); [30,40) and [40,50) inside the outage
|
||||
// ⇒ generic Bad (data, but none non-Bad); [50,60) … [70,80) the flat 20 up to the bound after, Good.
|
||||
var s = await ReadW(HistoryAggregateType.TimeAverage2, 10);
|
||||
Values(s, 28, 36, 40, null, null, 20, 20, 20);
|
||||
s.Select(x => x.StatusCode).ShouldBe(
|
||||
[GoodCalc, GoodCalc, GoodCalc, BadCalc, BadCalc, GoodCalc, GoodCalc, GoodCalc]);
|
||||
// Total2 of the first interval: 28 × 10 s.
|
||||
((double)(await ReadW(HistoryAggregateType.Total2, 10))[0].Value!).ShouldBe(280d, 1e-9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Interpolative_over_a_sparse_tag_with_narrow_intervals()
|
||||
{
|
||||
await SeedWAsync();
|
||||
var s = await ReadW(HistoryAggregateType.Interpolative, 10);
|
||||
// +0 24, +10 32 (sloped from the bound); +20 exact 40 (raw Good); +30 ON the outage marker ⇒ bridged 100/3;
|
||||
// +40 bridged 80/3; +50 exact 20; +60, +70 on the Good 20→20 segment to the bound after.
|
||||
Values(s, 24, 32, 40, 100d / 3d, 80d / 3d, 20, 20, 20);
|
||||
s.Select(x => x.StatusCode).ShouldBe(
|
||||
[GoodInterp, GoodInterp, GoodRaw, UncertainInterp, UncertainInterp, GoodRaw, GoodInterp, GoodInterp]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reverse_Interpolative_read_is_anchored_at_its_later_time()
|
||||
{
|
||||
await SeedWAsync();
|
||||
// At(80) → At(0), 40 s: points At(80) (on the Good 20→20 segment to the bound at +100 ⇒ 20) and At(40) (inside
|
||||
// the outage ⇒ bridged 80/3, Uncertain), latest first. A forward read would answer At(0) and At(40) instead.
|
||||
var s = (await B.CreateDataSource().ReadProcessedAsync("W", At(80), At(0), TimeSpan.FromSeconds(40),
|
||||
HistoryAggregateType.Interpolative, Ct)).Samples;
|
||||
s.Select(x => x.SourceTimestampUtc).ShouldBe([B.Round(At(80)), B.Round(At(40))]);
|
||||
Values(s, 20, 80d / 3d);
|
||||
s.Select(x => x.StatusCode).ShouldBe([GoodInterp, UncertainInterp]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_time_weighted_raw_row_cap_fails_the_read_past_the_cap_only()
|
||||
{
|
||||
// Tag A holds five samples in [0,80); W holds three in [0,80) plus two bound rows outside it.
|
||||
await ConformanceData.SeedAAsync(B);
|
||||
await SeedWAsync();
|
||||
var atCap = B.CreateDataSource(5);
|
||||
(await atCap.ReadProcessedAsync("A", At(0), At(80), Twenty, HistoryAggregateType.Total, Ct)).Samples.Count.ShouldBe(4);
|
||||
|
||||
var under = B.CreateDataSource(4);
|
||||
var ex = await Should.ThrowAsync<HistoryReadLimitExceededException>(
|
||||
() => under.ReadProcessedAsync("A", At(0), At(80), Twenty, HistoryAggregateType.Total, Ct));
|
||||
ex.Limit.ShouldBe(4);
|
||||
// Pushed-down (per-sample) aggregates are not capped; the bound rows outside the window do not count.
|
||||
(await under.ReadProcessedAsync("A", At(0), At(80), Twenty, HistoryAggregateType.Average, Ct)).Samples.Count.ShouldBe(4);
|
||||
(await B.CreateDataSource(3).ReadProcessedAsync("W", At(0), At(80), Twenty, HistoryAggregateType.TimeAverage, Ct))
|
||||
.Samples.Count.ShouldBe(4);
|
||||
// A client-request limit, not a backend fault.
|
||||
under.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||
// Non-positive = unbounded.
|
||||
(await B.CreateDataSource(0).ReadProcessedAsync("A", At(0), At(80), Twenty, HistoryAggregateType.Total, Ct))
|
||||
.Samples.Count.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Outages_straddling_both_window_edges_use_the_four_bound_seeks()
|
||||
{
|
||||
// −10 = 0 G, −5 outage, +10 = 20 G, +20 outage, +30 = 40 G; window [0,20). The four seeks return −10 (latest
|
||||
// usable before), −5 (latest before), +20 (first at/after end) and +30 (first usable at/after end).
|
||||
await B.SeedAsync("O",
|
||||
(At(-10), HistorianValue.FromDouble(0), Good),
|
||||
(At(-5), HistorianValue.Empty, BadNoCommunication),
|
||||
(At(10), HistorianValue.FromDouble(20), Good),
|
||||
(At(20), HistorianValue.Empty, BadNoCommunication),
|
||||
(At(30), HistorianValue.FromDouble(40), Good));
|
||||
var src = B.CreateDataSource();
|
||||
async Task<IReadOnlyList<DataValueSnapshot>> Read(HistoryAggregateType a, double intervalS) =>
|
||||
(await src.ReadProcessedAsync("O", At(0), At(20), TimeSpan.FromSeconds(intervalS), a, Ct)).Samples;
|
||||
|
||||
// Bridged: −10..10 0→20 ⇒ [0,10) mean 15 (150); 10..30 20→40 ⇒ [10,20) mean 25 (250). 400 / 20 = 20.
|
||||
// (Without the usable-before seek: 250/10 = 25; without the usable-after seek: [10,20) held 20 ⇒ 17.5.)
|
||||
var ta = (await Read(HistoryAggregateType.TimeAverage, 0)).Single();
|
||||
((double)ta.Value!).ShouldBe(20d, 1e-9);
|
||||
ta.StatusCode.ShouldBe(UncertainCalc);
|
||||
// Simple bounding: [0,10) inside the outage from −5; [10,20) 20 held into the outage at +20 ⇒ 200 / 10 s = 20.
|
||||
// (Without the any-before seek: (150+200)/20 = 17.5; without the any-after seek: [10,20) 20→30 ⇒ 25.)
|
||||
var ta2 = (await Read(HistoryAggregateType.TimeAverage2, 0)).Single();
|
||||
((double)ta2.Value!).ShouldBe(20d, 1e-9);
|
||||
ta2.StatusCode.ShouldBe(UncertainCalc);
|
||||
// +0 inside the outage ⇒ bridged 10, Uncertain; +10 exact 20, raw Good.
|
||||
var i = await Read(HistoryAggregateType.Interpolative, 10);
|
||||
Values(i, 10, 20);
|
||||
i.Select(x => x.StatusCode).ShouldBe([UncertainInterp, GoodRaw]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Time_weighted_aggregates_on_an_unknown_tag_are_BadNoData()
|
||||
{
|
||||
var src = B.CreateDataSource();
|
||||
(await src.ReadProcessedAsync("nope", At(0), At(40), Twenty, HistoryAggregateType.TimeAverage, Ct))
|
||||
.Samples.ShouldAllBe(x => x.StatusCode == NoDataCalc);
|
||||
(await src.ReadProcessedAsync("nope", At(0), At(40), Twenty, HistoryAggregateType.Interpolative, Ct))
|
||||
.Samples.ShouldAllBe(x => x.StatusCode == NoData);
|
||||
src.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_bucket_count_is_capped_at_100000()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user