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:
+3
-3
@@ -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);
|
||||
}
|
||||
|
||||
+2
-1
@@ -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")]
|
||||
|
||||
+1
-1
@@ -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)]
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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());
|
||||
|
||||
+3
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user