38d37f5c23
SqlServerHistorianDataSource implements IHistorianDataSource over the T8 schema: raw forward/reverse/point with the 0 = unbounded sentinel; AtTime in one statement (TimeRow TVP + two OUTER APPLYs) with linear/stepped/no-prior rules and no interpolation across a Bad neighbour or Empty outage marker; events with a maxEvents+1 truncation probe and a non-null continuation point; processed aggregates pushed down to T-SQL (SqlAggregateQueries) with nanosecond-exact bucket edges, Calculated bits on every result, Uncertain on any non-Good input, BadAggregateInvalidInputs for numeric aggregates over String/DateTime, a 100 000-bucket cap and HistoryAggregateNotSupportedException for the rest. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
185 lines
7.9 KiB
C#
185 lines
7.9 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
|
|
|
/// <summary>AtTime rules (spec §4): exact, linear, stepped, no-prior, Bad neighbour (no bridging).</summary>
|
|
[Collection(SqlHistorianCollection.Name)]
|
|
public sealed class AtTimeReadTests(SqlHistorianFixture fx)
|
|
{
|
|
private static readonly CancellationToken Ct = CancellationToken.None;
|
|
private static DateTime At(double s) => ReadTestDb.At(s);
|
|
|
|
// Hand-computed status literals: Interpolated = InfoType DataValue 0x400 | Interpolated 0x2.
|
|
private const uint GoodInterpolated = 0x00000402u;
|
|
private const uint UncertainInterpolated = 0x40A40402u; // UncertainDataSubNormal | 0x402
|
|
private const uint BadNoData = 0x809B0000u;
|
|
|
|
[Fact]
|
|
public async Task Interpolated_bits_are_the_SDK_AggregateBits_Interpolated()
|
|
{
|
|
var code = new Opc.Ua.StatusCode(GoodInterpolated);
|
|
code.AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Interpolated);
|
|
Opc.Ua.StatusCode.IsGood(code).ShouldBeTrue();
|
|
new Opc.Ua.StatusCode(UncertainInterpolated).CodeBits.ShouldBe(Opc.Ua.StatusCodes.UncertainDataSubNormal);
|
|
await Task.CompletedTask;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Exact_hit_returns_the_stored_value_and_status()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
await db.SeedAAsync();
|
|
var s = (await db.Source().ReadAtTimeAsync("A", [At(10), At(20)], Ct)).Samples;
|
|
s[0].Value.ShouldBe(20d);
|
|
s[0].StatusCode.ShouldBe(0u);
|
|
s[0].SourceTimestampUtc.ShouldBe(At(10));
|
|
s[0].ServerTimestampUtc.ShouldBe(At(11)); // the stored ServerTimeUtc
|
|
s[1].Value.ShouldBe(30d); // a stored Bad sample comes back as stored
|
|
s[1].StatusCode.ShouldBe(0x80000000u);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Between_two_good_doubles_is_linearly_interpolated_good()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
await db.SeedAAsync();
|
|
var s = (await db.Source().ReadAtTimeAsync("A", [At(5), At(2.5), At(32.5)], Ct)).Samples;
|
|
s[0].Value.ShouldBe(15d); // 10 + (20-10) * 5/10
|
|
s[1].Value.ShouldBe(12.5d); // 10 + 10 * 2.5/10
|
|
s[2].Value.ShouldBe(42.5d); // 40 + 10 * 2.5/10
|
|
s.ShouldAllBe(x => x.StatusCode == GoodInterpolated);
|
|
s[0].SourceTimestampUtc.ShouldBe(At(5)); // stamped at the requested (sub-ms) time
|
|
}
|
|
|
|
[Fact]
|
|
public async Task No_previous_sample_is_BadNoData()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
await db.SeedAAsync();
|
|
var s = (await db.Source().ReadAtTimeAsync("A", [At(0).AddTicks(-1)], Ct)).Samples.Single();
|
|
s.Value.ShouldBeNull();
|
|
s.StatusCode.ShouldBe(BadNoData);
|
|
s.SourceTimestampUtc.ShouldBe(At(0).AddTicks(-1));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_bad_next_neighbour_holds_the_previous_value_uncertain()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
await db.SeedAAsync();
|
|
var s = (await db.Source().ReadAtTimeAsync("A", [At(15)], Ct)).Samples.Single();
|
|
s.Value.ShouldBe(20d); // NOT 25 (interpolating toward the Bad 30)
|
|
s.StatusCode.ShouldBe(UncertainInterpolated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_bad_previous_neighbour_yields_no_value_uncertain()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
await db.SeedAAsync();
|
|
var s = (await db.Source().ReadAtTimeAsync("A", [At(25)], Ct)).Samples.Single();
|
|
s.Value.ShouldBeNull(); // NOT 35 (30→40) and NOT 30 (a Bad value)
|
|
s.StatusCode.ShouldBe(UncertainInterpolated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Interpolation_never_bridges_an_outage_marker()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
await db.SeedAsync("M",
|
|
(At(0), HistorianValue.FromDouble(10), ReadTestDb.Good),
|
|
(At(5), HistorianValue.Empty, ReadTestDb.BadNoCommunication),
|
|
(At(10), HistorianValue.FromDouble(30), ReadTestDb.Good));
|
|
var s = (await db.Source().ReadAtTimeAsync("M", [At(2.5), At(7.5), At(5)], Ct)).Samples;
|
|
s[0].Value.ShouldBe(10d); // bridging 10→30 would give 15
|
|
s[0].StatusCode.ShouldBe(UncertainInterpolated);
|
|
s[1].Value.ShouldBeNull(); // bridging would give 25
|
|
s[1].StatusCode.ShouldBe(UncertainInterpolated);
|
|
s[2].Value.ShouldBeNull(); // exact hit on the marker: as stored
|
|
s[2].StatusCode.ShouldBe(0x80310000u);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task After_the_last_sample_the_value_is_stepped()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
await db.SeedAAsync();
|
|
var s = (await db.Source().ReadAtTimeAsync("A", [At(45)], Ct)).Samples.Single();
|
|
s.Value.ShouldBe(50d);
|
|
s.StatusCode.ShouldBe(GoodInterpolated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_uncertain_neighbour_makes_the_interpolation_uncertain()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
await db.SeedAsync("U",
|
|
(At(0), HistorianValue.FromDouble(10), ReadTestDb.Uncertain),
|
|
(At(10), HistorianValue.FromDouble(20), ReadTestDb.Good));
|
|
var s = (await db.Source().ReadAtTimeAsync("U", [At(5)], Ct)).Samples.Single();
|
|
s.Value.ShouldBe(15d);
|
|
s.StatusCode.ShouldBe(UncertainInterpolated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Int64_is_interpolated_and_rounded_keeping_its_type()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
await db.SeedAsync("I",
|
|
(At(0), HistorianValue.FromInt64(10), ReadTestDb.Good),
|
|
(At(10), HistorianValue.FromInt64(15), ReadTestDb.Good));
|
|
var s = (await db.Source().ReadAtTimeAsync("I", [At(5), At(2)], Ct)).Samples;
|
|
s[0].Value.ShouldBe(13L); // 12.5 ⇒ 13 (away from zero)
|
|
s[1].Value.ShouldBe(11L); // 11.0
|
|
s[0].StatusCode.ShouldBe(GoodInterpolated);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Boolean_string_and_datetime_are_stepped_from_the_previous_sample()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
var d0 = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddTicks(3);
|
|
await db.SeedAsync("B",
|
|
(At(0), HistorianValue.FromBoolean(true), ReadTestDb.Good),
|
|
(At(10), HistorianValue.FromBoolean(false), ReadTestDb.Good));
|
|
await db.SeedAsync("S",
|
|
(At(0), HistorianValue.FromString("alpha"), ReadTestDb.Uncertain),
|
|
(At(10), HistorianValue.FromString("beta"), ReadTestDb.Good));
|
|
await db.SeedAsync("D",
|
|
(At(0), HistorianValue.FromDateTime(d0), ReadTestDb.Good),
|
|
(At(10), HistorianValue.FromDateTime(d0.AddDays(1)), ReadTestDb.Good));
|
|
var src = db.Source();
|
|
var b = (await src.ReadAtTimeAsync("B", [At(9)], Ct)).Samples.Single();
|
|
b.Value.ShouldBe(true);
|
|
b.StatusCode.ShouldBe(GoodInterpolated);
|
|
var s = (await src.ReadAtTimeAsync("S", [At(5)], Ct)).Samples.Single();
|
|
s.Value.ShouldBe("alpha");
|
|
s.StatusCode.ShouldBe(0x40000402u); // the previous sample's code (Uncertain) + Interpolated
|
|
(await src.ReadAtTimeAsync("D", [At(5)], Ct)).Samples.Single().Value.ShouldBe(d0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Result_matches_request_length_and_order_including_duplicates()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
await db.SeedAAsync();
|
|
var s = (await db.Source().ReadAtTimeAsync("A", [At(5), At(-1), At(5), At(10)], Ct)).Samples;
|
|
s.Count.ShouldBe(4);
|
|
s.Select(x => x.Value).ShouldBe([15d, null, 15d, 20d]);
|
|
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(5), At(-1), At(5), At(10)]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Empty_request_and_unknown_tag()
|
|
{
|
|
var db = await ReadTestDb.CreateAsync(fx);
|
|
var src = db.Source();
|
|
(await src.ReadAtTimeAsync("A", [], Ct)).Samples.ShouldBeEmpty();
|
|
var s = (await src.ReadAtTimeAsync("nope", [At(1), At(2)], Ct)).Samples;
|
|
s.Count.ShouldBe(2);
|
|
s.ShouldAllBe(x => x.StatusCode == BadNoData && x.Value == null);
|
|
}
|
|
}
|