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.
This commit is contained in:
Joseph Doherty
2026-10-12 14:31:31 -04:00
parent 9ced6455ad
commit 44a03bedc0
5 changed files with 249 additions and 24 deletions
@@ -0,0 +1,158 @@
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);
}
}