feat(historian): #677 Part 13/SDK reverse anchoring, start-of-data Partial, AggregateConfiguration, IntegralDivisor

- BucketPlan: reverse processed reads anchored at the later time for every
  aggregate: window (end, start], partial interval earliest, results stamped
  with the later edge, latest first (SDK CreateSlice/GetTimestamp/backward
  GetValues). SqlServer adds @bucketAdjustTicks + flips the window predicate;
  QuestDB binds BucketBaseUnixTicks. IndexOf is the C# statement of the math.
- Partial bit on the interval the tag's data starts inside (SDK m_startOfData),
  every aggregate but Average; per-sample reads fetch the earliest sample.
- Node manager: non-default client AggregateConfiguration =>
  BadAggregateConfigurationRejected per node (ServerAggregateConfiguration,
  pinned to the SDK AggregateManager defaults).
- GatewayTagProvisioner sends IntegralDivisor = 1.
- SDK parity oracle (Opc.Ua.Server calculators) in unit + both backends'
  conformance suites, forward and reverse; remaining divergences documented.
- docs/Historian.md + CLAUDE.md release note (client-visible).
This commit is contained in:
Joseph Doherty
2026-10-11 11:26:00 -04:00
parent 825393299e
commit 27ca6cd791
24 changed files with 1134 additions and 135 deletions
@@ -277,6 +277,94 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
await host.DisposeAsync();
}
/// <summary>
/// #677: a client <c>AggregateConfiguration</c> that differs from the server defaults is refused per node with
/// <c>BadAggregateConfigurationRejected</c> (it used to be silently ignored); the source is never invoked.
/// </summary>
[Theory]
[InlineData(true, (byte)100, (byte)100, false)] // TreatUncertainAsBad
[InlineData(false, (byte)50, (byte)100, false)] // PercentDataBad
[InlineData(false, (byte)100, (byte)80, false)] // PercentDataGood
[InlineData(false, (byte)100, (byte)100, true)] // UseSlopedExtrapolation
[InlineData(false, (byte)0, (byte)0, false)] // an all-zero structure without UseServerCapabilitiesDefaults
public async Task Processed_with_a_non_default_aggregate_configuration_is_rejected(
bool treatUncertainAsBad, byte percentBad, byte percentGood, bool sloped)
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/cfg", parentFolderNodeId: null, displayName: "Cfg", dataType: "Float",
writable: false, historianTagname: "WW.Cfg", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/cfg")!.NodeId;
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_TimeAverage },
AggregateConfiguration = new AggregateConfiguration
{
UseServerCapabilitiesDefaults = false, TreatUncertainAsBad = treatUncertainAsBad,
PercentDataBad = percentBad, PercentDataGood = percentGood, UseSlopedExtrapolation = sloped,
},
}, nodeId);
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateConfigurationRejected);
fake.LastCall.ShouldBeNull();
await host.DisposeAsync();
}
/// <summary>
/// #677: <c>UseServerCapabilitiesDefaults</c> (whatever the other fields say), or a configuration spelling out exactly
/// the server defaults, is served.
/// </summary>
[Theory]
[InlineData(true, true, (byte)0, (byte)0, true)]
[InlineData(false, false, (byte)100, (byte)100, false)]
public async Task Processed_with_the_server_default_aggregate_configuration_is_served(
bool useServerDefaults, bool treatUncertainAsBad, byte percentBad, byte percentGood, bool sloped)
{
var (host, server) = await BootAsync();
var nm = server.NodeManager!;
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
nm.HistorianDataSource = fake;
nm.EnsureVariable("eq-1/cfg-ok", parentFolderNodeId: null, displayName: "CfgOk", dataType: "Float",
writable: false, historianTagname: "WW.CfgOk", realm: AddressSpaceRealm.Uns);
var nodeId = nm.TryGetVariable("eq-1/cfg-ok")!.NodeId;
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
{
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_TimeAverage },
AggregateConfiguration = new AggregateConfiguration
{
UseServerCapabilitiesDefaults = useServerDefaults, TreatUncertainAsBad = treatUncertainAsBad,
PercentDataBad = percentBad, PercentDataGood = percentGood, UseSlopedExtrapolation = sloped,
},
}, nodeId);
StatusCode.IsGood(errors[0].StatusCode).ShouldBeTrue();
fake.LastCall.ShouldBe("Processed");
fake.LastAggregate.ShouldBe(HistoryAggregateType.TimeAverage);
await host.DisposeAsync();
}
/// <summary>#677: the server's declared aggregate defaults are the SDK's own server defaults.</summary>
[Fact]
public void The_server_aggregate_defaults_agree_with_the_SDK_AggregateManager()
{
var sdk = new Opc.Ua.Server.AggregateManager(null!).GetDefaultConfiguration(null);
var ours = ServerAggregateConfiguration.Defaults;
ours.TreatUncertainAsBad.ShouldBe(sdk.TreatUncertainAsBad);
ours.PercentDataBad.ShouldBe(sdk.PercentDataBad);
ours.PercentDataGood.ShouldBe(sdk.PercentDataGood);
ours.UseSlopedExtrapolation.ShouldBe(sdk.UseSlopedExtrapolation);
ServerAggregateConfiguration.IsServable(sdk).ShouldBeTrue();
ServerAggregateConfiguration.IsServable(null).ShouldBeTrue();
}
/// <summary>#285: a Part 13 aggregate beyond the original five (Interpolative) is forwarded to the
/// backend as its own <see cref="HistoryAggregateType"/> member.</summary>
[Fact]