fix(historian): refuse native Gateway TimeAverage/Total until historiangw#27 (lmxopcua#746)

AVEVA's native TimeAverage / Total are answered with Good-status wrong values
while the gateway sends the interval in the wrong unit (historiangw#27): one
whole-span value on the earliest interval, the rest absent. Nothing on the
wire marks the answer as degraded.

Both are now refused BadAggregateNotSupported (unsent, not counted as a
backend failure, not advertised in AggregateFunctions) unless
ServerHistorian:Gateway:EnableNativeTimeWeighted is true. The default
Gateway set is the ten raw-evaluated aggregates
(GatewayHistorianDataSource.RawEvaluatedAggregates); ServedAggregates stays
the 12 the backend can serve with the flag on. The native-path tests opt in.

Also documents the ServerHistorian:Gateway sub-section (EventDrainBudget from
the previous commit) and the events drain in docs/Historian.md.

Rebased from the unpushed remed/lane-H2 branch (originally authored 2026-10-22
in session_01Ub9hGmYLQvn9XETzw7CMsT) onto current master.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-10-22 16:30:19 -04:00
parent 24609ececa
commit 0b85c499e3
11 changed files with 157 additions and 16 deletions
@@ -35,7 +35,7 @@ public sealed class GatewayHistorianDataSourceTests
public async Task ReadProcessed_uses_aggregate_mode_mapping()
{
var fake = new FakeHistorianGatewayClient();
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, enableNativeTimeWeighted: true);
await ds.ReadProcessedAsync("T", Hour0, Hour0.AddHours(1), TimeSpan.FromSeconds(60), HistoryAggregateType.Total, TestContext.Current.CancellationToken); // an empty window sends nothing
Assert.Equal(RetrievalMode.Integral, fake.LastAggregateMode);
Assert.Equal(TimeSpan.FromSeconds(60), fake.LastAggregateInterval);
@@ -53,7 +53,7 @@ public sealed class GatewayHistorianDataSourceTests
new HistorianAggregateSample { OpcQuality = 0, StartTime = Timestamp.FromDateTime(start.AddMinutes(1)), EndTime = Timestamp.FromDateTime(start.AddMinutes(2)) },
},
};
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, enableNativeTimeWeighted: true);
var r = await ds.ReadProcessedAsync("T", start, start.AddMinutes(2), TimeSpan.FromMinutes(1), HistoryAggregateType.TimeAverage, TestContext.Current.CancellationToken);
Assert.Equal(new DateTime?[] { start, start.AddMinutes(1) }, r.Samples.Select(x => x.SourceTimestampUtc).ToArray());
Assert.Equal(new[] { 0x00000401u, 0x809B0401u }, r.Samples.Select(x => x.StatusCode).ToArray());
@@ -63,7 +63,7 @@ public sealed class GatewayHistorianDataSourceTests
public async Task ReadProcessed_TimeAverage_requests_TimeWeightedAverage()
{
var fake = new FakeHistorianGatewayClient();
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, enableNativeTimeWeighted: true);
await ds.ReadProcessedAsync("T", Hour0, Hour0.AddHours(1), TimeSpan.FromSeconds(60), HistoryAggregateType.TimeAverage, TestContext.Current.CancellationToken); // an empty window sends nothing
Assert.Equal(RetrievalMode.TimeWeightedAverage, fake.LastAggregateMode);
}
@@ -18,7 +18,8 @@ public sealed class GatewayMissingTagTests
public override DateTimeOffset GetUtcNow() => new(Current);
}
private static GatewayHistorianDataSource Source(FakeHistorianGatewayClient fake, Clock? clock = null, bool native = false) =>
new(fake, NullLogger<GatewayHistorianDataSource>.Instance, timeProvider: clock, atTimeFromRaw: !native);
new(fake, NullLogger<GatewayHistorianDataSource>.Instance, timeProvider: clock, atTimeFromRaw: !native,
enableNativeTimeWeighted: true);
[Theory]
[InlineData("Raw")]
@@ -77,7 +77,7 @@ public sealed class GatewayNativeAggregateGridTests
};
private static GatewayHistorianDataSource Source(FakeHistorianGatewayClient fake) =>
new(fake, NullLogger<GatewayHistorianDataSource>.Instance);
new(fake, NullLogger<GatewayHistorianDataSource>.Instance, enableNativeTimeWeighted: true);
[Theory]
[InlineData(HistoryAggregateType.TimeAverage, RetrievalMode.TimeWeightedAverage)]
@@ -40,7 +40,7 @@ public sealed class GatewayNativeAggregateRowsTests
DateTime start, DateTime end, HistoryAggregateType aggregate = HistoryAggregateType.TimeAverage)
{
var fake = new FakeHistorianGatewayClient { AggregateSamples = wire };
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, enableNativeTimeWeighted: true);
return (await ds.ReadProcessedAsync("T", start, end, I20, aggregate, Ct)).Samples;
}
@@ -0,0 +1,85 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
/// <summary>
/// AVEVA's native TimeAverage / Total answer Good-status wrong values until the gateway's interval fix
/// (historiangw#27) is live-verified, so they are refused <c>BadAggregateNotSupported</c> unless
/// <c>ServerHistorian:Gateway:EnableNativeTimeWeighted</c> opts back in.
/// </summary>
public sealed class GatewayNativeTimeWeightedGateTests
{
private static readonly DateTime T0 = new(2026, 10, 22, 0, 0, 0, DateTimeKind.Utc);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Theory]
[InlineData(HistoryAggregateType.TimeAverage)]
[InlineData(HistoryAggregateType.Total)]
public async Task By_default_the_native_time_weighted_pair_is_refused_unsent_and_unadvertised(HistoryAggregateType a)
{
var fake = new FakeHistorianGatewayClient();
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
ds.SupportedAggregates.ShouldNotContain(a);
await Should.ThrowAsync<HistoryAggregateNotSupportedException>(
() => ds.ReadProcessedAsync("T", T0, T0.AddHours(1), TimeSpan.FromMinutes(1), a, Ct));
fake.ReadAggregateCallCount.ShouldBe(0);
fake.TagExistsCallCount.ShouldBe(0);
ds.GetHealthSnapshot().TotalQueries.ShouldBe(0, "a refused aggregate is a client request, not a backend read");
// Positive control: a raw-evaluated per-sample aggregate on the same source is still served.
ds.SupportedAggregates.ShouldContain(HistoryAggregateType.Average);
var avg = await ds.ReadProcessedAsync("T", T0, T0.AddHours(1), TimeSpan.FromMinutes(30), HistoryAggregateType.Average, Ct);
avg.Samples.Count.ShouldBe(2);
fake.ReadRawCallCount.ShouldBeGreaterThan(0);
}
[Theory]
[InlineData(HistoryAggregateType.TimeAverage)]
[InlineData(HistoryAggregateType.Total)]
public async Task Opting_in_serves_and_advertises_the_native_pair(HistoryAggregateType a)
{
var fake = new FakeHistorianGatewayClient();
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, enableNativeTimeWeighted: true);
ds.SupportedAggregates.ShouldBe(GatewayHistorianDataSource.ServedAggregates);
await ds.ReadProcessedAsync("T", T0, T0.AddHours(1), TimeSpan.FromMinutes(1), a, Ct);
fake.ReadAggregateCallCount.ShouldBeGreaterThan(0);
}
[Fact]
public void The_default_set_is_the_ten_raw_evaluated_aggregates()
{
var ds = new GatewayHistorianDataSource(new FakeHistorianGatewayClient(), NullLogger<GatewayHistorianDataSource>.Instance);
ds.SupportedAggregates.Count.ShouldBe(10);
ds.SupportedAggregates.ShouldBe(GatewayHistorianDataSource.RawEvaluatedAggregates);
GatewayHistorianDataSource.ServedAggregates.Except(ds.SupportedAggregates).OrderBy(x => x)
.ShouldBe([HistoryAggregateType.Total, HistoryAggregateType.TimeAverage]);
}
[Theory]
[InlineData(null, false)]
[InlineData("false", false)]
[InlineData("true", true)]
public void The_flag_binds_from_configuration_and_reaches_the_data_source(string? configured, bool expected)
{
var values = new Dictionary<string, string?>
{
["ServerHistorian:Enabled"] = "true",
["ServerHistorian:Endpoint"] = "https://localhost:5222",
["ServerHistorian:ApiKey"] = "histgw_x_y",
};
if (configured is not null) values["ServerHistorian:Gateway:EnableNativeTimeWeighted"] = configured;
var opts = new ConfigurationBuilder().AddInMemoryCollection(values).Build()
.GetSection(ServerHistorianOptions.SectionName).Get<ServerHistorianOptions>()!;
using var services = new ServiceCollection().BuildServiceProvider();
var ds = Assert.IsType<GatewayHistorianDataSource>(GatewayHistorian.CreateDataSource(opts, services));
ds.SupportedAggregates.Contains(HistoryAggregateType.TimeAverage).ShouldBe(expected);
}
}
@@ -572,7 +572,7 @@ public sealed class GatewayRawAggregateTests
{
var fake = Store(A);
var ds = Source(fake);
if (GatewayHistorianDataSource.ServedAggregates.Contains(agg))
if (ds.SupportedAggregates.Contains(agg))
{
await ds.ReadProcessedAsync("A", At(0), At(40), TimeSpan.FromSeconds(20), agg, Ct);
(fake.ReadRawCallCount + fake.ReadAggregateCallCount).ShouldBeGreaterThan(0, agg.ToString());
@@ -51,8 +51,10 @@ public sealed class AggregateModeMapperTests
}
Assert.Equal(12, GatewayHistorianDataSource.ServedAggregates.Count);
// The native pair is opt-in (ServerHistorian:Gateway:EnableNativeTimeWeighted) — see GatewayNativeTimeWeightedGateTests.
var source = new GatewayHistorianDataSource(
new FakeHistorianGatewayClient(), Microsoft.Extensions.Logging.Abstractions.NullLogger<GatewayHistorianDataSource>.Instance);
new FakeHistorianGatewayClient(), Microsoft.Extensions.Logging.Abstractions.NullLogger<GatewayHistorianDataSource>.Instance,
enableNativeTimeWeighted: true);
Assert.Same(GatewayHistorianDataSource.ServedAggregates, source.SupportedAggregates);
}