37390139fe
Gateway HistoryRead-AtTime passed AVEVA's own interpolation through: live it answered the previous value as Good at a stored NULL's own time, null + Bad at a Good sample right after a NULL, a drifted 99.99999999944 for a stored 100, and no Interpolated bit. It is now served from raw gateway reads and evaluated with Sql.Common's AtTimeEvaluator, as the SQL backends do (Part 11 §6.5.4). GatewayAtTimeReader: one <=1,024-row forward window read [first-1ms, last+1ms] for the batch, a walk-back (density-seeded, x4 growth, whole-remainder shortcut, bisects dense history) for the first time's prev, and an 18-row probe for the last time's next; truncated window reads continue or jump with a density-sized margin. All bounds whole ms; a row counts only strictly inside its request (AVEVA's boundary row never a sample); Bad rows value-less (#690); never past now + 1 day; strict-gateway ceiling learned (#691.3) without re-probing. 10 dense times = 1 read (exact) / 3 (interpolated); 10 hourly = 12. Kill switch ServerHistorian:GatewayAtTimeFromRaw (default true; false = the old AVEVA passthrough). Known limit: the wire carries no data type, so numeric tags interpolate as Double (Boolean/integer nodes are not stepped/rounded as on the SQL backends).
162 lines
8.8 KiB
C#
162 lines
8.8 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Xunit;
|
|
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;
|
|
|
|
public sealed class GatewayHistorianDataSourceTests
|
|
{
|
|
[Fact]
|
|
public async Task ReadRaw_maps_samples_and_passes_args()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient
|
|
{
|
|
RawSamples = new[]
|
|
{
|
|
new HistorianSample { Tag = "T", NumericValue = 1.0, OpcQuality = 192, Timestamp = Ts(2026, 1, 1, 0, 0, 0) },
|
|
new HistorianSample { Tag = "T", NumericValue = 2.0, OpcQuality = 0, Timestamp = Ts(2026, 1, 1, 0, 0, 1) },
|
|
},
|
|
};
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var r = await ds.ReadRawAsync("T", start, start.AddMinutes(5), 100, TestContext.Current.CancellationToken);
|
|
Assert.Equal(2, r.Samples.Count);
|
|
Assert.Equal(0x80000000u, r.Samples[1].StatusCode); // Bad from quality 0
|
|
Assert.Equal("T", fake.LastReadRawTag);
|
|
// #687: asked from 1 ms before the window with room for AVEVA's boundary row (GatewayRawBoundaryRowTests).
|
|
Assert.Equal(100 + RawAggregateReader.PreWindowAllowance, fake.LastReadRawMaxValues);
|
|
}
|
|
|
|
private static readonly DateTime Hour0 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
|
|
[Fact]
|
|
public async Task ReadProcessed_uses_aggregate_mode_mapping()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
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);
|
|
}
|
|
|
|
[Fact] // #681: the native buckets are stamped with the interval START and carry the Calculated bits
|
|
public async Task ReadProcessed_native_buckets_are_stamped_at_the_interval_start_with_the_Calculated_bit()
|
|
{
|
|
var start = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var fake = new FakeHistorianGatewayClient
|
|
{
|
|
AggregateSamples = new[]
|
|
{
|
|
new HistorianAggregateSample { Value = 7.0, OpcQuality = 192, StartTime = Timestamp.FromDateTime(start), EndTime = Timestamp.FromDateTime(start.AddMinutes(1)) },
|
|
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 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());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadProcessed_TimeAverage_requests_TimeWeightedAverage()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
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);
|
|
}
|
|
|
|
[Theory] // #681 (was #679 BadAggregateNotSupported): served from a raw read, never a native retrieval mode
|
|
[InlineData(HistoryAggregateType.Average)]
|
|
[InlineData(HistoryAggregateType.Count)]
|
|
[InlineData(HistoryAggregateType.Minimum)]
|
|
[InlineData(HistoryAggregateType.Maximum)]
|
|
public async Task ReadProcessed_per_sample_aggregate_reads_raw_not_a_retrieval_mode(HistoryAggregateType aggregate)
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
await ds.ReadProcessedAsync("T", t0, t0.AddMinutes(1), TimeSpan.FromSeconds(60), aggregate, TestContext.Current.CancellationToken);
|
|
Assert.Equal(0, fake.ReadAggregateCallCount);
|
|
Assert.True(fake.ReadRawCallCount >= 1); // the window, plus the earlier-data probe when the range matters
|
|
}
|
|
|
|
[Theory] // no AVEVA mode and not a per-sample aggregate: not supported, gateway never called, no failure recorded
|
|
[InlineData(HistoryAggregateType.TimeAverage2)]
|
|
[InlineData(HistoryAggregateType.PercentGood)]
|
|
public async Task ReadProcessed_unsupported_aggregate_throws_without_calling_gateway_or_recording_failure(HistoryAggregateType aggregate)
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
await Assert.ThrowsAsync<HistoryAggregateNotSupportedException>(() => ds.ReadProcessedAsync(
|
|
"T", default, default, TimeSpan.FromSeconds(60), aggregate, TestContext.Current.CancellationToken));
|
|
Assert.Equal(0, fake.ReadAggregateCallCount);
|
|
Assert.Equal(0, fake.ReadRawCallCount);
|
|
var h = ds.GetHealthSnapshot();
|
|
Assert.Equal(0, h.TotalFailures);
|
|
Assert.Equal(0, h.TotalQueries);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAtTime_aligns_one_snapshot_per_timestamp_with_gaps_Bad()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
var t1 = t0.AddSeconds(1);
|
|
fake.AtTimeSamples = new[] { new HistorianSample { NumericValue = 5.0, OpcQuality = 192, Timestamp = Timestamp.FromDateTime(t0) } };
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, atTimeFromRaw: false); // #693 kill switch
|
|
var r = await ds.ReadAtTimeAsync("T", new[] { t0, t1 }, TestContext.Current.CancellationToken);
|
|
Assert.Equal(2, r.Samples.Count); // exactly one per requested ts, in order
|
|
Assert.Equal(5.0, r.Samples[0].Value);
|
|
Assert.Equal(0x80000000u, r.Samples[1].StatusCode); // gap → Bad at requested ts
|
|
}
|
|
|
|
[Fact] // #690: an at-time NULL (histsdk: value 0 + Bad) is null + Bad, like the SQL backends' AtTime of a Bad sample
|
|
public async Task ReadAtTime_of_a_NULL_row_carries_no_value()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
fake.AtTimeSamples = new[]
|
|
{
|
|
new HistorianSample { NumericValue = 0, OpcQuality = 0, Quality = 1, Timestamp = Timestamp.FromDateTime(t0) },
|
|
};
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance, atTimeFromRaw: false); // #693 kill switch
|
|
var r = await ds.ReadAtTimeAsync("T", new[] { t0 }, TestContext.Current.CancellationToken);
|
|
Assert.Null(r.Samples[0].Value);
|
|
Assert.Equal(0x80000000u, r.Samples[0].StatusCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Empty_window_is_not_a_fault()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient { RawSamples = Array.Empty<HistorianSample>() };
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
var r = await ds.ReadRawAsync("T", default, default, 10, TestContext.Current.CancellationToken);
|
|
Assert.Empty(r.Samples); // GoodNoData-empty, no throw
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Disposing_data_source_disposes_client()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
await ds.DisposeAsync();
|
|
Assert.Equal(1, fake.DisposeCallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Dispose_via_sync_path_disposes_client()
|
|
{
|
|
var fake = new FakeHistorianGatewayClient();
|
|
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
|
((IDisposable)ds).Dispose();
|
|
Assert.Equal(1, fake.DisposeCallCount);
|
|
}
|
|
|
|
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
|
|
private static Timestamp Ts(int y, int mo, int d, int h, int mi, int s)
|
|
=> Timestamp.FromDateTime(new DateTime(y, mo, d, h, mi, s, DateTimeKind.Utc));
|
|
}
|