Merge feat/671-time-weighted-aggregates: Part 13 time-weighted aggregates (#671)

This commit is contained in:
Joseph Doherty
2026-10-11 09:10:52 -04:00
29 changed files with 1502 additions and 47 deletions
@@ -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 (&lt;= 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()
{
@@ -75,6 +75,10 @@ internal sealed class QuestDbConformanceBackend : IConformanceBackend
public IHistorianDataSource CreateDataSource(TimeProvider? clock = null) =>
new QuestDbHistorianDataSource(Conn, Boot, NewCatalog(), timeProvider: clock);
/// <inheritdoc/>
public IHistorianDataSource CreateDataSource(int maxTimeWeightedRawRows) =>
new QuestDbHistorianDataSource(Conn, Boot, NewCatalog(), maxTimeWeightedRawRows: maxTimeWeightedRawRows);
/// <inheritdoc/>
/// <remarks>The T4 live-value writer over a fresh catalog (fresh positive cache) and this instance's WAL wait.</remarks>
public IHistorianValueWriter CreateValueWriter(Func<DateTime>? utcNow = null) =>
@@ -90,8 +90,8 @@ public sealed class ReadTests(QuestDbFixture fx) : IAsyncLifetime
max.Value.ShouldBe(50d);
max.SourceTimestampUtc.ShouldBe(_b.Round(At(40)));
// The extreme DateTime range: MinValue/MaxValue are nudged (never bound as ±infinity, which would match nothing).
(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);
// A pre-1970 window holds no data and is not an error.
(await src.ReadProcessedAsync("A", start, new DateTime(1969, 1, 1, 0, 0, 0, DateTimeKind.Utc), TimeSpan.FromDays(3650),
HistoryAggregateType.Count, Ct)).Samples.ShouldAllBe(x => x.StatusCode == NoDataCalc);
@@ -247,7 +247,7 @@ public sealed class ReadTests(QuestDbFixture fx) : IAsyncLifetime
await Should.ThrowAsync<HistorianSchemaMissingException>(() => src.ReadEventsAsync(null, At(0), At(5), 0, Ct));
// Client-request errors are thrown before any I/O and are not backend failures.
await Should.ThrowAsync<HistoryAggregateNotSupportedException>(() =>
src.ReadProcessedAsync("A", At(0), At(5), TimeSpan.Zero, HistoryAggregateType.Interpolative, Ct));
src.ReadProcessedAsync("A", At(0), At(5), TimeSpan.Zero, HistoryAggregateType.StandardDeviationPopulation, Ct));
await Should.ThrowAsync<ArgumentOutOfRangeException>(() =>
src.ReadProcessedAsync("A", At(0), At(100), TimeSpan.FromTicks(10), HistoryAggregateType.Count, Ct));
@@ -46,7 +46,6 @@ public sealed class AggregateEvaluatorTests
[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)]
@@ -10,7 +10,11 @@ public sealed class ProcessedReadTests
[Fact]
public void Every_enum_member_is_either_served_or_refused()
{
// Thirteen served; anything appended to the enum later falls to the refusal (total default).
Enum.GetValues<HistoryAggregateType>().Count(a => AggregateFamilies.FamilyOf(a) is not null).ShouldBe(13);
// Seventeen served (#671 added TimeAverage, TimeAverage2, Interpolative and Total2, and moved Total to the
// time-weighted family); anything appended to the enum later falls to the refusal (total default).
Enum.GetValues<HistoryAggregateType>().Count(a => AggregateFamilies.FamilyOf(a) is not null).ShouldBe(17);
Enum.GetValues<HistoryAggregateType>().Where(a => AggregateFamilies.FamilyOf(a) == AggregateFamily.TimeWeighted)
.ShouldBe([HistoryAggregateType.Total, HistoryAggregateType.Interpolative, HistoryAggregateType.TimeAverage,
HistoryAggregateType.TimeAverage2, HistoryAggregateType.Total2]);
}
}
@@ -39,6 +39,14 @@ public sealed class StatusCodeOracleTests
HistorianInfoBits.WithCalculated(interpolatedGood).ShouldBe(0x400u | HistorianInfoBits.HistorianCalculated);
}
[Fact]
public void Partial_bit_matches_the_SDK() =>
((uint)Opc.Ua.AggregateBits.Partial).ShouldBe(HistorianInfoBits.HistorianPartial);
[Fact]
public void Generic_bad_matches_the_SDK() =>
HistorianStatusCodes.Bad.ShouldBe(Opc.Ua.StatusCodes.Bad);
[Fact]
public void Info_type_is_data_value() =>
HistorianInfoBits.InfoTypeDataValue.ShouldBe(0x400u);
@@ -0,0 +1,430 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using static ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.Fixture;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
/// <summary>
/// The Part 13 time-weighted aggregates (#671) over hand-built sample streams. Every literal is hand-computed in the
/// comment above it (areas in value·seconds; "raw" = the simple-bounding signal, "bridged" = interpolated bounding).
/// </summary>
public sealed class TimeWeightedAggregatorTests
{
private static readonly DateTime Now = At(3600);
private const uint GoodRaw = 0x00000000u;
private const uint NoData = 0x809B0000u;
private const uint InvalidInputs = 0x80D60000u;
private const uint BadCalc = 0x80000401u; // generic Bad | Calculated (an all-outage interval)
private const uint GoodCalcPartial = 0x00000405u; // Good | Calculated | Partial (truncated last interval)
private static IReadOnlyList<DataValueSnapshot> Run(HistoryAggregateType agg, double from, double to, double intervalS,
params StoredSample[] samples) =>
TimeWeightedAggregator.Evaluate(agg, BucketPlan.Create(At(from), At(to), TimeSpan.FromSeconds(intervalS)), samples, Now);
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}");
}
}
// ---------------------------------------------------------------- tag A: +0=10 G, +10=20 G, +20=Bad, +30=40 G, +40=50 G
// Window [0,80), 20 s buckets. Raw: [0,10) 10→20 Good (150); [10,20) 20 held into the outage, Uncertain (200);
// [20,30) Bad; [30,40) 40→50 Good (450); [40,80) 50 extrapolated, Uncertain (2000).
// Bridged: [0,10) 150; [10,30) 20→40 = 250 in B0 + 350 in B1; [30,40) 450; [40,80) 50 held.
[Fact]
public void TimeAverage_bridges_the_outage_on_tag_A()
{
// B0 (150+250)/20 = 20; B1 (350+450)/20 = 40; B2, B3 = 50. Status (SDK rule, Uncertain time counts as Good):
// B0 is non-Bad throughout (held-into-outage time is Uncertain) ⇒ Good; B1 has the Bad [20,30) ⇒ Uncertain;
// B2, B3 are extrapolated (Uncertain) throughout ⇒ Good.
var s = Run(HistoryAggregateType.TimeAverage, 0, 80, 20, A);
Values(s, 20, 40, 50, 50);
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, GoodCalc]);
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40), At(60)]);
}
[Fact]
public void Total_is_TimeAverage_times_interval_seconds_on_tag_A()
{
// 20×20, 40×20, 50×20, 50×20.
Values(Run(HistoryAggregateType.Total, 0, 80, 20, A), 400, 800, 1000, 1000);
}
[Fact]
public void TimeAverage2_excludes_the_bad_interval_on_tag_A()
{
// B0 350/20 = 17.5; B1 450/10 = 45 (the Bad [20,30) excluded); B2, B3 = 50.
var s = Run(HistoryAggregateType.TimeAverage2, 0, 80, 20, A);
Values(s, 17.5, 45, 50, 50);
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, GoodCalc]);
}
[Fact]
public void Total2_integrates_only_the_non_bad_data_on_tag_A()
{
// B0 150+200 = 350; B1 450; B2, B3 1000.
Values(Run(HistoryAggregateType.Total2, 0, 80, 20, A), 350, 450, 1000, 1000);
}
[Fact]
public void Interpolative_on_tag_A()
{
// +0 exact Good 10 (raw status); +20 sits on the Bad sample ⇒ bridged 20→40 at the midpoint = 30, Uncertain;
// +40 exact Good 50; +60 past the last sample ⇒ 50 extrapolated, Uncertain.
var s = Run(HistoryAggregateType.Interpolative, 0, 80, 20, A);
Values(s, 10, 30, 50, 50);
s.Select(x => x.StatusCode).ShouldBe([GoodRaw, UncertainInterpolated, GoodRaw, UncertainInterpolated]);
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40), At(60)]);
s.ShouldAllBe(x => x.ServerTimestampUtc == Now);
}
// ---------------------------------------------------------------- bounding values on both sides, crossing segments
private static readonly StoredSample[] Bounded =
[
S(-10, HistorianValue.FromDouble(0), Good), // bound before the window
S(10, HistorianValue.FromDouble(20), Good),
S(30, HistorianValue.FromDouble(20), Good), // bound after the window
];
[Theory]
// Window [0,20), 10 s. Segment -10..10 (0→20) crosses the window start: B0 [0,10) = 10→20, mean 15.
// B1 [10,20) = 20 flat (crossing the window end into the bound). All Good.
[InlineData(HistoryAggregateType.TimeAverage, 15d, 20d)]
[InlineData(HistoryAggregateType.TimeAverage2, 15d, 20d)]
[InlineData(HistoryAggregateType.Total, 150d, 200d)]
[InlineData(HistoryAggregateType.Total2, 150d, 200d)]
public void Bounding_values_before_and_after_the_window_are_used(HistoryAggregateType agg, double b0, double b1)
{
var s = Run(agg, 0, 20, 10, Bounded);
Values(s, b0, b1);
s.ShouldAllBe(x => x.StatusCode == GoodCalc);
}
[Fact]
public void Interpolative_interpolates_from_the_bound_before_the_window()
{
// +0: 0 + (20−0)·(10/20) = 10, Good both ends; +10 exact Good 20.
var s = Run(HistoryAggregateType.Interpolative, 0, 20, 10, Bounded);
Values(s, 10, 20);
s.Select(x => x.StatusCode).ShouldBe([GoodInterpolated, GoodRaw]);
}
[Fact]
public void A_segment_spanning_several_buckets_is_split_at_every_edge()
{
// One segment 0..40 (0→40), window [0,40), 10 s: bucket means 5, 15, 25, 35; Total2 50, 150, 250, 350.
StoredSample[] line = [S(0, HistorianValue.FromDouble(0), Good), S(40, HistorianValue.FromDouble(40), Good)];
Values(Run(HistoryAggregateType.TimeAverage2, 0, 40, 10, line), 5, 15, 25, 35);
Values(Run(HistoryAggregateType.Total2, 0, 40, 10, line), 50, 150, 250, 350);
Values(Run(HistoryAggregateType.Interpolative, 0, 40, 10, line), 0, 10, 20, 30);
}
// ---------------------------------------------------------------- empty buckets between bounds
[Fact]
public void An_empty_bucket_with_bounds_on_both_sides_is_interpolated_not_no_data()
{
// -5 = 0, +25 = 30 (slope 1/s); window [0,20), 10 s holds no sample at all.
StoredSample[] sparse = [S(-5, HistorianValue.FromDouble(0), Good), S(25, HistorianValue.FromDouble(30), Good)];
var interp = Run(HistoryAggregateType.Interpolative, 0, 20, 10, sparse);
Values(interp, 5, 15);
interp.ShouldAllBe(x => x.StatusCode == GoodInterpolated);
// B0 5→15 mean 10; B1 15→25 mean 20.
var avg = Run(HistoryAggregateType.TimeAverage, 0, 20, 10, sparse);
Values(avg, 10, 20);
avg.ShouldAllBe(x => x.StatusCode == GoodCalc);
Values(Run(HistoryAggregateType.Total, 0, 20, 10, sparse), 100, 200);
}
[Fact]
public void Past_the_last_sample_the_value_is_extrapolated_stepped()
{
// -5 = 7 and nothing later: held at 7 over [0,10). The extrapolated time is Uncertain, which the SDK rule
// (TreatUncertainAsBad = false) counts as Good time ⇒ the interval is Good; the Interpolative point is Uncertain.
StoredSample[] one = [S(-5, HistorianValue.FromDouble(7), Good)];
var s = Run(HistoryAggregateType.TimeAverage2, 0, 10, 0, one).Single();
((double)s.Value!).ShouldBe(7d);
s.StatusCode.ShouldBe(GoodCalc);
var i = Run(HistoryAggregateType.Interpolative, 0, 10, 0, one).Single();
((double)i.Value!).ShouldBe(7d);
i.StatusCode.ShouldBe(UncertainInterpolated);
}
[Theory]
[InlineData(HistoryAggregateType.TimeAverage)]
[InlineData(HistoryAggregateType.TimeAverage2)]
[InlineData(HistoryAggregateType.Total)]
[InlineData(HistoryAggregateType.Total2)]
public void No_data_at_all_is_BadNoData_with_the_calculated_bit(HistoryAggregateType agg)
{
var s = Run(agg, 0, 20, 10);
s.ShouldAllBe(x => x.StatusCode == NoDataCalc && x.Value == null);
}
[Fact]
public void Interpolative_with_no_data_is_plain_BadNoData()
{
// The only sample is at +15: nothing at or before either bucket start (+0, +10).
var s = Run(HistoryAggregateType.Interpolative, 0, 20, 10, S(15, HistorianValue.FromDouble(1), Good));
s.ShouldAllBe(x => x.StatusCode == NoData && x.Value == null);
}
// ---------------------------------------------------------------- Bad intervals: TimeAverage vs TimeAverage2/Total2
private static readonly StoredSample[] Outage =
[
S(0, HistorianValue.FromDouble(10), Good),
S(5, HistorianValue.Empty, BadNoCommunication),
S(15, HistorianValue.FromDouble(30), Good),
S(25, HistorianValue.FromDouble(30), Good), // bound after the window
];
[Fact]
public void TimeAverage2_and_Total2_exclude_the_bad_interval()
{
// Window [0,20) one bucket. Raw: [0,5) 10 held into the outage (50, Uncertain); [5,15) Bad; [15,20) 30 Good (150).
// TimeAverage2 = 200 / 10 s = 20; Total2 = 200.
var ta2 = Run(HistoryAggregateType.TimeAverage2, 0, 20, 0, Outage).Single();
((double)ta2.Value!).ShouldBe(20d);
ta2.StatusCode.ShouldBe(UncertainCalc);
((double)Run(HistoryAggregateType.Total2, 0, 20, 0, Outage).Single().Value!).ShouldBe(200d);
}
[Fact]
public void TimeAverage_and_Total_interpolate_across_the_bad_interval()
{
// Bridged: [0,15) 10→30, mean 20 ⇒ 300; [15,20) 30 ⇒ 150. TimeAverage = 450/20 = 22.5; Total = 22.5 × 20 = 450.
var ta = Run(HistoryAggregateType.TimeAverage, 0, 20, 0, Outage).Single();
((double)ta.Value!).ShouldBe(22.5d);
ta.StatusCode.ShouldBe(UncertainCalc);
((double)Run(HistoryAggregateType.Total, 0, 20, 0, Outage).Single().Value!).ShouldBe(450d);
}
[Fact]
public void An_interval_entirely_inside_an_outage_is_Bad_but_Interpolative_bridges_it()
{
// Window [10,15): raw is Bad throughout ⇒ data but no non-Bad time ⇒ generic Bad (the SDK rule; BadNoData is
// reserved for no data at all). Interpolative at +10 = 10 + 20·(10/15).
var ta = Run(HistoryAggregateType.TimeAverage, 10, 15, 0, Outage).Single();
ta.StatusCode.ShouldBe(BadCalc);
ta.Value.ShouldBeNull();
Run(HistoryAggregateType.TimeAverage2, 10, 15, 0, Outage).Single().StatusCode.ShouldBe(BadCalc);
Run(HistoryAggregateType.Total2, 10, 15, 0, Outage).Single().StatusCode.ShouldBe(BadCalc);
var i = Run(HistoryAggregateType.Interpolative, 10, 15, 0, Outage).Single();
((double)i.Value!).ShouldBe(10d + 20d * 10d / 15d, 1e-9);
i.StatusCode.ShouldBe(UncertainInterpolated);
}
[Fact]
public void Interpolative_on_a_segment_that_runs_into_an_outage_is_uncertain()
{
// +2 lies between the Good +0 and the Bad +5: bridged 10→30 over [0,15) ⇒ 10 + 20·(2/15); the simple-bounding
// signal there is held into an outage, so the point is Uncertain even though both bridging ends are Good.
var i = Run(HistoryAggregateType.Interpolative, 2, 20, 0, Outage).Single();
((double)i.Value!).ShouldBe(10d + 20d * 2d / 15d, 1e-9);
i.StatusCode.ShouldBe(UncertainInterpolated);
}
[Fact]
public void The_latest_usable_bound_is_found_behind_a_bad_bound()
{
// -10 = 0 G, -5 Bad, +10 = 20 G, +30 = 20 G. Window [0,20) one bucket.
// Bridged: -10..10 0→20 ⇒ [0,10) 10→20 (150); [10,20) 20 (200) ⇒ TimeAverage = 17.5.
// Raw: [-5,10) Bad; [10,20) 20 Good (200) ⇒ TimeAverage2 = 20. Good 10 s of 20 ⇒ Uncertain.
StoredSample[] s =
[
S(-10, HistorianValue.FromDouble(0), Good), S(-5, HistorianValue.Empty, BadNoCommunication),
S(10, HistorianValue.FromDouble(20), Good), S(30, HistorianValue.FromDouble(20), Good),
];
((double)Run(HistoryAggregateType.TimeAverage, 0, 20, 0, s).Single().Value!).ShouldBe(17.5d);
var ta2 = Run(HistoryAggregateType.TimeAverage2, 0, 20, 0, s).Single();
((double)ta2.Value!).ShouldBe(20d);
ta2.StatusCode.ShouldBe(UncertainCalc);
var i = Run(HistoryAggregateType.Interpolative, 0, 20, 0, s).Single();
((double)i.Value!).ShouldBe(10d); // inside the outage, bridged -10..10 at 0
i.StatusCode.ShouldBe(UncertainInterpolated);
}
// ---------------------------------------------------------------- stepped Boolean, Uncertain, invalid inputs
private static readonly StoredSample[] Switch =
[
S(0, HistorianValue.FromBoolean(true), Good),
S(5, HistorianValue.FromBoolean(false), Good),
S(15, HistorianValue.FromBoolean(true), Good),
S(25, HistorianValue.FromBoolean(true), Good), // bound after the window
];
[Fact]
public void Booleans_are_stepped_not_sloped()
{
// Window [0,20), 10 s. B0: 1 over [0,5), 0 over [5,10) ⇒ 0.5 (sloped would give 0.375). B1: 0 over [10,15),
// 1 over [15,20) ⇒ 0.5. All Good. Total: 5 and 5.
var s = Run(HistoryAggregateType.TimeAverage, 0, 20, 10, Switch);
Values(s, 0.5, 0.5);
s.ShouldAllBe(x => x.StatusCode == GoodCalc);
Values(Run(HistoryAggregateType.Total, 0, 20, 10, Switch), 5, 5);
// Interpolative at +10: stepped from +5 (false) ⇒ 0, Good.
var i = Run(HistoryAggregateType.Interpolative, 0, 20, 10, Switch);
Values(i, 1, 0);
i.Select(x => x.StatusCode).ShouldBe([GoodRaw, GoodInterpolated]);
}
[Fact]
public void Uncertain_time_counts_as_good_time()
{
// TreatUncertainAsBad = false (the SDK's time-based status rule): Uncertain time counts toward PercentDataGood.
// 0 = 4 Uncertain, 10 = 6 Good, 20 = 6 Good: [0,10) sloped 4→6 ⇒ mean 5; [10,20) 6. Window [0,20), 10 s.
StoredSample[] s =
[
S(0, HistorianValue.FromDouble(4), Uncertain), S(10, HistorianValue.FromDouble(6), Good),
S(20, HistorianValue.FromDouble(6), Good),
];
var r = Run(HistoryAggregateType.TimeAverage2, 0, 20, 10, s);
Values(r, 5, 6);
r.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc]);
}
[Fact]
public void Int64_samples_are_sloped_like_doubles()
{
StoredSample[] s = [S(0, HistorianValue.FromInt64(0), Good), S(10, HistorianValue.FromInt64(10), Good)];
Values(Run(HistoryAggregateType.TimeAverage2, 0, 10, 0, s), 5);
}
[Theory]
[InlineData(HistoryAggregateType.TimeAverage)]
[InlineData(HistoryAggregateType.TimeAverage2)]
[InlineData(HistoryAggregateType.Total)]
[InlineData(HistoryAggregateType.Total2)]
public void A_usable_string_sample_makes_the_interval_invalid_inputs(HistoryAggregateType agg)
{
StoredSample[] s = [S(0, HistorianValue.FromString("a"), Good), S(10, HistorianValue.FromString("b"), Good)];
var r = Run(agg, 0, 20, 0, s).Single();
r.StatusCode.ShouldBe(InvalidInputsCalc);
r.Value.ShouldBeNull();
}
[Fact]
public void Interpolative_on_a_string_is_invalid_inputs()
{
StoredSample[] s = [S(0, HistorianValue.FromString("a"), Good), S(10, HistorianValue.FromString("b"), Good)];
Run(HistoryAggregateType.Interpolative, 0, 20, 10, s).ShouldAllBe(x => x.StatusCode == InvalidInputs);
}
// ---------------------------------------------------------------- plan shape
[Fact]
public void A_reverse_window_is_computed_forward_and_returned_descending()
{
var s = TimeWeightedAggregator.Evaluate(HistoryAggregateType.TimeAverage2,
BucketPlan.Create(At(80), At(0), TimeSpan.FromSeconds(20)), A, Now);
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(60), At(40), At(20), At(0)]);
Values(s, 50, 50, 45, 17.5);
}
[Fact]
public void A_truncated_last_bucket_uses_its_own_duration_for_Total_and_is_Partial()
{
// Window [0,50), 20 s: B2 = [40,50) is 10 s of the extrapolated 50 ⇒ TimeAverage 50, Total 500.
var s = Run(HistoryAggregateType.Total, 0, 50, 20, A);
Values(s, 400, 800, 500);
// Only the truncated interval carries the Partial bit.
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalcPartial]);
}
[Fact]
public void An_outage_with_no_later_sample_is_Bad_to_the_window_end_not_no_data()
{
// 0 = 10 G, then an outage marker at +5 and nothing after: [10,20) lies wholly inside the still-open outage.
StoredSample[] open = [S(0, HistorianValue.FromDouble(10), Good), S(5, HistorianValue.Empty, BadNoCommunication)];
Run(HistoryAggregateType.TimeAverage2, 10, 20, 0, open).Single().StatusCode.ShouldBe(BadCalc);
}
[Fact]
public void A_bad_or_no_data_truncated_interval_carries_no_partial_bit()
{
// Window [0,15), 10 s: B1 = [10,15) is inside the outage ⇒ Bad without Partial.
Run(HistoryAggregateType.TimeAverage2, 0, 15, 10, Outage)[1].StatusCode.ShouldBe(BadCalc);
Run(HistoryAggregateType.TimeAverage2, 0, 15, 10)[1].StatusCode.ShouldBe(NoDataCalc);
}
// ---------------------------------------------------------------- reverse Interpolative (Part 13: anchored at the later time)
[Fact]
public void A_reverse_Interpolative_read_is_anchored_at_its_later_time()
{
// Request At(20) → At(0), 20 s: one point, at At(20) (not At(0)). Bounded: segment 10..30 is 20 flat, Good.
// A forward read of the same window would give the value at At(0) = 10.
var s = TimeWeightedAggregator.Evaluate(HistoryAggregateType.Interpolative,
BucketPlan.Create(At(20), At(0), TimeSpan.FromSeconds(20)), Bounded, Now);
s.Single().SourceTimestampUtc.ShouldBe(At(20));
((double)s.Single().Value!).ShouldBe(20d);
s.Single().StatusCode.ShouldBe(GoodInterpolated);
}
[Fact]
public void A_reverse_Interpolative_read_steps_back_from_the_later_time_latest_first()
{
// Tag A, At(80) → At(0), 20 s: points 80, 60, 40, 20 (latest first). 80 and 60 are past the last sample ⇒ 50
// extrapolated, Uncertain; 40 exact Good 50; 20 sits on the Bad sample ⇒ bridged 30, Uncertain.
var s = TimeWeightedAggregator.Evaluate(HistoryAggregateType.Interpolative,
BucketPlan.Create(At(80), At(0), TimeSpan.FromSeconds(20)), A, Now);
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(80), At(60), At(40), At(20)]);
Values(s, 50, 50, 50, 30);
s.Select(x => x.StatusCode).ShouldBe([UncertainInterpolated, UncertainInterpolated, GoodRaw, UncertainInterpolated]);
}
[Fact]
public void A_reverse_Interpolative_read_with_a_partial_interval_ends_inside_the_window()
{
// At(25) → At(0), 10 s: three intervals ⇒ points 25, 15, 5 (never before the window start).
// Bounded: -10..10 slope 1/s from 0 ⇒ +5 = 15; 10..30 flat 20 ⇒ +15 = 20, +25 = 20.
var s = TimeWeightedAggregator.Evaluate(HistoryAggregateType.Interpolative,
BucketPlan.Create(At(25), At(0), TimeSpan.FromSeconds(10)), Bounded, Now);
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(25), At(15), At(5)]);
Values(s, 20, 20, 15);
}
// ---------------------------------------------------------------- raw-row cap helpers
[Theory]
[InlineData(1_000_000, 1_000_001)]
[InlineData(1, 2)]
[InlineData(0, int.MaxValue)]
[InlineData(-5, int.MaxValue)]
[InlineData(int.MaxValue, int.MaxValue)]
public void The_window_fetch_limit_is_one_past_the_cap(int cap, int expected) =>
TimeWeightedAggregator.WindowFetchLimit(cap).ShouldBe(expected);
[Fact]
public void The_window_row_check_fails_only_past_a_positive_cap()
{
TimeWeightedAggregator.CheckWindowRows(4, 4, "T");
TimeWeightedAggregator.CheckWindowRows(5_000_000, 0, "T");
var ex = Should.Throw<HistoryReadLimitExceededException>(() => TimeWeightedAggregator.CheckWindowRows(5, 4, "T"));
ex.Limit.ShouldBe(4);
}
[Fact]
public void A_duplicate_bound_is_ignored()
{
// The same row fetched as both "latest usable" and "latest any" bound.
var dup = new[] { Bounded[0], Bounded[0], Bounded[1], Bounded[2], Bounded[2] };
Values(Run(HistoryAggregateType.TimeAverage2, 0, 20, 10, dup), 15, 20);
}
[Fact]
public void A_non_time_weighted_aggregate_is_refused()
{
Should.Throw<HistoryAggregateNotSupportedException>(() =>
new TimeWeightedAggregator(HistoryAggregateType.Average, BucketPlan.Create(At(0), At(10), TimeSpan.Zero)));
}
}
@@ -44,8 +44,9 @@ internal sealed class ReadTestDb
}
/// <summary>A fresh data source (fresh catalog cache) over this schema; <paramref name="clock"/> is its "now".</summary>
public SqlServerHistorianDataSource Source(TimeProvider? clock = null) =>
new(Conn, Boot, new SqlServerTagCatalog(Conn, Boot), timeProvider: clock);
public SqlServerHistorianDataSource Source(TimeProvider? clock = null,
int maxTimeWeightedRawRows = TimeWeightedAggregator.DefaultMaxWindowRows) =>
new(Conn, Boot, new SqlServerTagCatalog(Conn, Boot), timeProvider: clock, maxTimeWeightedRawRows: maxTimeWeightedRawRows);
/// <summary>Inserts a tag row and the given samples. ServerTimeUtc = source time + 1 s (so tests can tell them apart).</summary>
public async Task<int> SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples)
@@ -25,6 +25,9 @@ internal sealed class SqlServerConformanceBackend : IConformanceBackend
/// <inheritdoc/>
public IHistorianDataSource CreateDataSource(TimeProvider? clock = null) => _db.Source(clock);
/// <inheritdoc/>
public IHistorianDataSource CreateDataSource(int maxTimeWeightedRawRows) => _db.Source(null, maxTimeWeightedRawRows);
/// <inheritdoc/>
public IHistorianValueWriter CreateValueWriter(Func<DateTime>? utcNow = null) =>
new SqlServerHistorianValueWriter(_db.Conn, _db.Boot, new SqlServerTagCatalog(_db.Conn, _db.Boot), utcNow: utcNow);