feat(historian): Part 13 time-weighted aggregates on SqlServer + QuestDb (#671)

New AggregateFamily.TimeWeighted serving TimeAverage, TimeAverage2, Total,
Total2 and Interpolative, evaluated by the shared Sql.Common
TimeWeightedAggregator for both SQL backends (identical results by
construction). HistoryAggregateType gains TimeAverage2 + Total2 (appended);
OtOpcUaNodeManager.MapAggregate maps their NodeIds. Gateway's mapper already
refuses unknown defined members => BadAggregateNotSupported per node.

Semantics (server defaults, client AggregateConfiguration ignored):
TreatUncertainAsBad=false, PercentDataBad=PercentDataGood=100,
UseSlopedExtrapolation=false. Sloped for Double/Int64, stepped for Boolean;
a usable sample followed by a Bad one is held up to the outage (AtTime rule);
stepped Uncertain extrapolation past the last sample. TimeAverage/Total use
interpolated bounding (bridge outages); TimeAverage2/Total2 exclude Bad time.
Bounding values: latest usable + latest any before start, first any + first
usable at/after end (four TOP 1 / LIMIT 1 seeks).

CLIENT-VISIBLE: Total on SqlServer/QuestDb was SUM of samples; it is now the
time-weighted integral (TimeAverage x interval seconds), matching Gateway's
Integral. Release note in docs/Historian.md.

Trade-off (deviation from the preferred hybrid pushdown, allowed by the
design): the time-weighted family is NOT pushed down. Each read streams the
window's rows plus the four bounds (SQL Server: one five-result-set batch;
QuestDB: bounds UNION ALL + window select) into the C# evaluator. Network is
O(samples in window), memory O(buckets). Writing the per-segment
interpolation/edge splitting twice in two SQL dialects and keeping them
bit-identical was judged the bigger risk; this also makes the QuestDB
lag/lead spike moot.

Tests: 35 hand-computed TimeWeightedAggregator unit cases (each of 10
implementation mutations goes red); served-aggregate pin 13 -> 17;
conformance sparse fixture (intervals wider and narrower than sample gaps)
+ an outage straddling both window edges (each of the four SQL seeks
disabled in turn goes red); node-manager NodeId mapping test.
This commit is contained in:
Joseph Doherty
2026-10-11 08:44:26 -04:00
parent db6a53036b
commit 1f88669304
17 changed files with 1118 additions and 37 deletions
@@ -292,6 +292,34 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
await host.DisposeAsync();
}
/// <summary>#671: the Part 13 TimeAverage2 / Total2 aggregate NodeIds map to their own members.</summary>
[Theory]
[InlineData("TimeAverage2")]
[InlineData("Total2")]
public async Task Processed_TimeAverage2_and_Total2_dispatch_to_the_source(string name)
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/tw", parentFolderNodeId: null, displayName: "T", dataType: "Float",
writable: false, historianTagname: "WW.Tw", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/tw")!.NodeId;
var (aggregateId, expected) = name == "Total2"
? (ObjectIds.AggregateFunction_Total2, HistoryAggregateType.Total2)
: (ObjectIds.AggregateFunction_TimeAverage2, HistoryAggregateType.TimeAverage2);
InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { aggregateId },
}, nodeId);
fake.LastCall.ShouldBe("Processed");
fake.LastAggregate.ShouldBe(expected);
await host.DisposeAsync();
}
/// <summary>#285: a backend that cannot serve a mapped aggregate answers BadAggregateNotSupported
/// (not the generic BadHistoryOperationUnsupported a plain throw produces).</summary>
[Fact]