44a03bedc0
AVEVA answers one extra row for the interval before the window; it was mapped as a result stamped before StartTime (N+1 values). Rows whose interval lies outside the requested span (either direction) are now dropped after the drained stream. An Uncertain (opc 64-191) interval kept no value (opc >= 192 was the only data test); it now carries its value as UncertainDataSubNormal | Calculated. Docs: AVEVA's TWA excludes NULL-gap time while the SQL backends bridge it; end-to-end per-interval values also need historiangw#27.
159 lines
6.9 KiB
C#
159 lines
6.9 KiB
C#
using Google.Protobuf.WellKnownTypes;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using Shouldly;
|
||
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;
|
||
|
||
/// <summary>
|
||
/// #685: the AVEVA-native <c>TimeAverage</c> / <c>Total</c> rows. The fake emits the REAL wire shape captured by the live
|
||
/// AVEVA gate (2026-10-12): AVEVA always adds one row for the interval BEFORE the window (<c>[start − interval, start)</c>,
|
||
/// carrying a real value when data exists there, a no-data row otherwise — and, under historiangw#27, starting days
|
||
/// early), and marks an interval holding a NULL/Bad sample <c>opc_quality = 64</c> while still supplying its value.
|
||
/// Every row carries a <c>value</c> on the wire (proto3 non-optional double; a no-data row is 0 + a Bad OPC quality).
|
||
/// </summary>
|
||
public sealed class GatewayNativeAggregateRowsTests
|
||
{
|
||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||
|
||
/// <summary>2026-10-12 18:06:00 UTC — the live gate's tag <c>HistGW.LiveTest.Gate681</c>.</summary>
|
||
private static readonly DateTime T0 = new(2026, 10, 12, 18, 6, 0, DateTimeKind.Utc);
|
||
|
||
private static readonly TimeSpan I20 = TimeSpan.FromSeconds(20);
|
||
|
||
private const uint GoodCalc = 0x00000401u;
|
||
private const uint UncertainCalc = 0x40A40401u; // UncertainDataSubNormal + Calculated, as the SQL backends' aggregates
|
||
private const uint NoDataCalc = 0x809B0401u;
|
||
|
||
private static HistorianAggregateSample Row(DateTime? start, DateTime? end, double value, uint opc) => new()
|
||
{
|
||
Tag = "T",
|
||
StartTime = start is { } s ? Timestamp.FromDateTime(s) : null,
|
||
EndTime = end is { } e ? Timestamp.FromDateTime(e) : null,
|
||
Value = value,
|
||
OpcQuality = opc,
|
||
};
|
||
|
||
private static async Task<IReadOnlyList<DataValueSnapshot>> Read(IReadOnlyList<HistorianAggregateSample> wire,
|
||
DateTime start, DateTime end, HistoryAggregateType aggregate = HistoryAggregateType.TimeAverage)
|
||
{
|
||
var fake = new FakeHistorianGatewayClient { AggregateSamples = wire };
|
||
var ds = new GatewayHistorianDataSource(fake, NullLogger<GatewayHistorianDataSource>.Instance);
|
||
return (await ds.ReadProcessedAsync("T", start, end, I20, aggregate, Ct)).Samples;
|
||
}
|
||
|
||
// ---- D1: rows outside the requested window -----------------------------------------------------------------------
|
||
|
||
[Fact]
|
||
public async Task The_leading_pre_window_row_carrying_a_real_value_is_dropped()
|
||
{
|
||
// Live: TimeAverage over [18:06:20, 18:07:20) returned "18:06:00 22.5 Good" first — the interval before the window.
|
||
var start = T0.AddSeconds(20);
|
||
var wire = new[]
|
||
{
|
||
Row(T0, start, 22.5, 192),
|
||
Row(start, start + I20, 30, 192),
|
||
Row(start + I20, start + 2 * I20, 3, 64),
|
||
Row(start + 2 * I20, start + 3 * I20, 1, 192),
|
||
};
|
||
var s = await Read(wire, start, start + 3 * I20);
|
||
s.Select(x => x.SourceTimestampUtc).ShouldBe(new DateTime?[] { start, start + I20, start + 2 * I20 });
|
||
s[0].Value.ShouldBe(30d);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_no_data_pre_window_row_is_dropped_rather_than_answered_BadNoData()
|
||
{
|
||
var wire = new[] { Row(T0 - I20, T0, 0, 0), Row(T0, T0 + I20, 22.5, 192) };
|
||
var s = await Read(wire, T0, T0 + I20, HistoryAggregateType.Total);
|
||
s.Count.ShouldBe(1);
|
||
s[0].SourceTimestampUtc.ShouldBe(T0);
|
||
s[0].StatusCode.ShouldBe(GoodCalc);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_pre_window_row_starting_days_early_is_dropped()
|
||
{
|
||
// historiangw#27: the interval reaches AVEVA 10,000× too large, so the leading row starts days before the window.
|
||
var wire = new[] { Row(T0.AddSeconds(-200_000), T0, 0, 0), Row(T0, T0.AddMinutes(2), 11.727, 64) };
|
||
var s = await Read(wire, T0, T0.AddMinutes(2));
|
||
s.Count.ShouldBe(1);
|
||
s[0].SourceTimestampUtc.ShouldBe(T0);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task A_row_whose_interval_starts_at_or_after_the_window_end_is_dropped()
|
||
{
|
||
var wire = new[] { Row(T0, T0 + I20, 1, 192), Row(T0 + I20, T0 + 2 * I20, 2, 192) };
|
||
var s = await Read(wire, T0, T0 + I20);
|
||
s.Count.ShouldBe(1);
|
||
s[0].Value.ShouldBe(1d);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Without_a_start_time_a_row_is_placed_by_its_end_time()
|
||
{
|
||
// Mapper fallback: no StartTime ⇒ stamped with its EndTime. The pre-window row then ENDS at the window start (out);
|
||
// an in-window row ends inside (lo, hi] (in).
|
||
var wire = new[] { Row(null, T0, 5, 192), Row(null, T0 + I20, 6, 192), Row(null, T0 + 2 * I20, 7, 192) };
|
||
var s = await Read(wire, T0, T0 + 2 * I20);
|
||
s.Select(x => x.Value).ShouldBe(new object?[] { 6d, 7d });
|
||
}
|
||
|
||
[Fact]
|
||
public async Task The_window_filter_is_direction_agnostic()
|
||
{
|
||
// A reverse request (start > end) covers the same span (end, start]; a row for the interval before the span is out.
|
||
var wire = new[] { Row(T0 - I20, T0, 9, 192), Row(T0, T0 + I20, 1, 192), Row(T0 + I20, T0 + 2 * I20, 2, 192) };
|
||
var s = await Read(wire, T0 + 2 * I20, T0);
|
||
s.Select(x => x.Value).ShouldBe(new object?[] { 1d, 2d });
|
||
}
|
||
|
||
// ---- D2: an Uncertain row keeps its value ------------------------------------------------------------------------
|
||
|
||
[Fact]
|
||
public async Task An_Uncertain_row_keeps_its_value_with_an_Uncertain_Calculated_status()
|
||
{
|
||
// Live: interval 18:06:40 holds the NULL sample ⇒ AVEVA TWA 3 / Integral 30 with opc_quality 64.
|
||
var at = T0.AddSeconds(40);
|
||
var s = await Read([Row(at, at + I20, 3, 64)], at, at + I20);
|
||
s.Single().Value.ShouldBe(3d);
|
||
s.Single().StatusCode.ShouldBe(UncertainCalc);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(64u)]
|
||
[InlineData(68u)]
|
||
[InlineData(191u)]
|
||
[InlineData(0x1_0040u)] // only the low byte is the OPC DA quality (as the raw mapping reads it)
|
||
public async Task Every_Uncertain_family_quality_keeps_its_value(uint opc)
|
||
{
|
||
var s = await Read([Row(T0, T0 + I20, 4.5, opc)], T0, T0 + I20);
|
||
s.Single().Value.ShouldBe(4.5d);
|
||
s.Single().StatusCode.ShouldBe(UncertainCalc);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(0u)]
|
||
[InlineData(1u)]
|
||
[InlineData(63u)]
|
||
public async Task A_Bad_family_row_is_BadNoData_with_no_value_although_the_wire_carries_one(uint opc)
|
||
{
|
||
var s = await Read([Row(T0, T0 + I20, 0, opc)], T0, T0 + I20);
|
||
s.Single().Value.ShouldBeNull();
|
||
s.Single().StatusCode.ShouldBe(NoDataCalc);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(192u)]
|
||
[InlineData(216u)]
|
||
public async Task A_Good_family_row_is_Good_Calculated(uint opc)
|
||
{
|
||
var s = await Read([Row(T0, T0 + I20, 2.5, opc)], T0, T0 + I20);
|
||
s.Single().Value.ShouldBe(2.5d);
|
||
s.Single().StatusCode.ShouldBe(GoodCalc);
|
||
}
|
||
}
|