feat(historian): support Total aggregate (client-side Average x interval-seconds)

This commit is contained in:
Joseph Doherty
2026-06-16 05:24:56 -04:00
parent 5c5aaef609
commit 5e27b5f708
2 changed files with 94 additions and 13 deletions
@@ -463,22 +463,60 @@ public sealed class WonderwareHistorianClientTests
}
/// <summary>
/// (5) <see cref="HistoryAggregateType.Total"/> must throw
/// <see cref="NotSupportedException"/> because Wonderware AnalogSummary has no Total
/// aggregate column.
/// (5) <see cref="HistoryAggregateType.Total"/> is derived client-side as the
/// time-weighted Average multiplied by the interval duration in seconds, because the
/// Wonderware AnalogSummary query exposes no Total column. The client must issue the
/// wire request with the Average column and scale every returned bucket value by
/// <c>interval.TotalSeconds</c>, carrying the bucket's quality and timestamp through.
/// </summary>
[Fact]
public async Task ReadProcessedAsync_TotalAggregate_ThrowsNotSupported()
public async Task ReadProcessedAsync_TotalAggregate_ReturnsAverageTimesIntervalSeconds()
{
await using var server = new FakeSidecarServer(Secret);
var bucketTs = new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc);
string? requestedColumn = null;
await using var server = new FakeSidecarServer(Secret)
{
OnReadProcessed = req =>
{
// Capture the column the client asked for: Total must be requested as Average.
requestedColumn = req.AggregateColumn;
return new ReadProcessedReply
{
Success = true,
Buckets =
[
// One Good Average bucket of 2.0; with a 60s interval the derived
// Total is 2.0 * 60 = 120.0.
new HistorianAggregateSampleDto { Value = 2.0, TimestampUtcTicks = bucketTs.Ticks },
// A null (unavailable) Average bucket must stay BadNoData / null.
new HistorianAggregateSampleDto { Value = null, TimestampUtcTicks = bucketTs.AddMinutes(1).Ticks },
],
};
},
};
await server.StartAsync();
await using var client = TcpClientFor(server);
var result = await client.ReadProcessedAsync("Tank.Level",
new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 4, 29, 0, 2, 0, DateTimeKind.Utc),
TimeSpan.FromMinutes(1), HistoryAggregateType.Total, CancellationToken.None);
await Should.ThrowAsync<NotSupportedException>(() =>
client.ReadProcessedAsync("Tag",
DateTime.UtcNow, DateTime.UtcNow, TimeSpan.FromMinutes(1),
HistoryAggregateType.Total, CancellationToken.None));
// The wire request asks for the Average column — Total has no AnalogSummary column.
requestedColumn.ShouldBe("Average");
result.Samples.Count.ShouldBe(2);
// Total = Average (2.0) x interval-seconds (60) = 120.0, quality + timestamp carried.
result.Samples[0].StatusCode.ShouldBe(0x00000000u); // Good
result.Samples[0].Value.ShouldBe(120.0);
result.Samples[0].SourceTimestampUtc.ShouldBe(bucketTs);
// Null Average bucket → still BadNoData / null after scaling.
result.Samples[1].StatusCode.ShouldBe(0x800E0000u); // BadNoData
result.Samples[1].Value.ShouldBeNull();
result.Samples[1].SourceTimestampUtc.ShouldBe(bucketTs.AddMinutes(1));
}
/// <summary>