Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayNativeAggregateRowsTests.cs
T
Joseph Doherty 44a03bedc0 fix(historian): #685 Gateway native TimeAverage/Total — drop AVEVA's pre-window row, keep Uncertain values
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.
2026-10-12 15:14:56 -04:00

159 lines
6.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}