Files
lmxopcua/tests/Drivers/Historian.Conformance/AtTimeConformance.cs
T
Joseph Doherty 68a1f31397 fix(historian): #693 review — bisecting walk-back, small jump reads, read cap, DateTime/NaN hints
H1: the walk-back reset its width after a full segment and discarded a full
whole-remainder read, so a dense tag that stopped reporting weeks ago gave up
after 65 reads (BadTooManyOperations). It now gallops (x4, whole-remainder read
after two empties) and then BISECTS once any stored row is known below — a full
segment's (incl. a full whole-remainder read's) last row, or an earlier window
read's last row: (mid, hi] empty => hi = mid; full => known = its last row;
complete with rows => done; one density-aimed segment after a full read until an
aimed segment comes back empty; below 2 ms the whole stretch from the known row
(never a 100 ns step). Stopped 1 h..365 d with 5 y history: 6-18 reads at
1/min, 21-25 at 1 Hz, 25-31 at 10 Hz.
M1: a jump read asks for 64 rows (1,000 times over 10 y: ~66k rows, was ~1M).
L1: per-request read cap 64 + 4 x distinct times with a clear message; a strict
ceiling below 4 rows is refused naming the ceiling.
L2: DateTime hints are stepped. L3: under an integer hint a NaN/inf or
out-of-Int64-range value is unusable, never saturated Good.
L5: conformance Int64 AtTime asserts the type. L4: docs name the backend
differences. Nit: the window read end is clamped to now + 1 day.

Reviewer harnesses (Scratch693Fuzz/Cost, not committed): 0 differential diffs
over 2,400 cases.
2026-10-13 01:09:54 -04:00

190 lines
8.6 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
/// <summary>
/// AtTime rules (spec §4, as amended after the T10 review): exact, linear, stepped, no-prior, and the two
/// Bad-neighbour cases — inside an outage (Bad previous) ⇒ null with the outage's own Bad code; before an
/// outage (usable previous, Bad next) ⇒ previous value held, Uncertain. Never bridges.
/// </summary>
public abstract class AtTimeConformance : ConformanceBase
{
private static readonly CancellationToken Ct = CancellationToken.None;
// 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()
{
await ConformanceData.SeedAAsync(B);
var s = (await B.CreateDataSource().ReadAtTimeAsync("A", [At(10), At(20)], Ct)).Samples;
s[0].Value.ShouldBe(20d);
s[0].StatusCode.ShouldBe(0u);
s[0].SourceTimestampUtc.ShouldBe(At(10)); // the requested time itself, never rounded (M3)
s[0].ServerTimestampUtc.ShouldBe(B.Round(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()
{
await ConformanceData.SeedAAsync(B);
var s = (await B.CreateDataSource().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-µs) time itself, never rounded (M3)
}
[Fact]
public async Task No_previous_sample_is_BadNoData()
{
await ConformanceData.SeedAAsync(B);
var before = At(0).AddTicks(-B.ResolutionTicks);
var s = (await B.CreateDataSource().ReadAtTimeAsync("A", [before], Ct)).Samples.Single();
s.Value.ShouldBeNull();
s.StatusCode.ShouldBe(BadNoData);
s.SourceTimestampUtc.ShouldBe(before);
}
[Fact]
public async Task A_bad_next_neighbour_holds_the_previous_value_uncertain()
{
await ConformanceData.SeedAAsync(B);
var s = (await B.CreateDataSource().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_is_inside_an_outage_and_keeps_its_bad_code()
{
await ConformanceData.SeedAAsync(B);
var s = (await B.CreateDataSource().ReadAtTimeAsync("A", [At(25)], Ct)).Samples.Single();
s.Value.ShouldBeNull(); // NOT 35 (30→40) and NOT 30 (a Bad value)
s.StatusCode.ShouldBe(0x80000000u); // the previous sample's Bad code — never upgraded to Uncertain
s.SourceTimestampUtc.ShouldBe(At(25));
}
[Fact]
public async Task Interpolation_never_bridges_an_outage_marker()
{
await B.SeedAsync("M",
(At(0), HistorianValue.FromDouble(10), Good),
(At(5), HistorianValue.Empty, BadNoCommunication),
(At(10), HistorianValue.FromDouble(30), Good));
var s = (await B.CreateDataSource().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(0x80310000u); // inside the outage: BadNoCommunication, as the marker says
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()
{
await ConformanceData.SeedAAsync(B);
var s = (await B.CreateDataSource().ReadAtTimeAsync("A", [At(45)], Ct)).Samples.Single();
s.Value.ShouldBe(50d);
s.StatusCode.ShouldBe(GoodInterpolated);
}
[Fact]
public async Task A_time_after_now_stepped_from_the_last_sample_is_uncertain()
{
await ConformanceData.SeedAAsync(B); // last sample +40 = 50, Good
var src = B.CreateDataSource(new ConformanceClock(new DateTimeOffset(At(42))));
var s = (await src.ReadAtTimeAsync("A", [At(41), At(45)], Ct)).Samples;
s[0].Value.ShouldBe(50d);
s[0].StatusCode.ShouldBe(GoodInterpolated); // after the last sample but not after now: held Good
s[1].Value.ShouldBe(50d);
s[1].StatusCode.ShouldBe(UncertainInterpolated); // after now: the value is not known to still hold
}
[Fact]
public async Task An_uncertain_neighbour_makes_the_interpolation_uncertain()
{
await B.SeedAsync("U",
(At(0), HistorianValue.FromDouble(10), Uncertain),
(At(10), HistorianValue.FromDouble(20), Good));
var s = (await B.CreateDataSource().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()
{
await B.SeedAsync("I",
(At(0), HistorianValue.FromInt64(10), Good),
(At(10), HistorianValue.FromInt64(15), Good));
var s = (await B.CreateDataSource().ReadAtTimeAsync("I", [At(5), At(2)], Ct)).Samples;
s[0].Value.ShouldBe(13L); // 12.5 ⇒ 13 (away from zero)
s[0].Value.ShouldBeOfType<long>(); // ShouldBe compares numbers across types — the type needs its own check (#693)
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 d0 = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddTicks(3 * B.ResolutionTicks);
await B.SeedAsync("B",
(At(0), HistorianValue.FromBoolean(true), Good),
(At(10), HistorianValue.FromBoolean(false), Good));
await B.SeedAsync("S",
(At(0), HistorianValue.FromString("alpha"), Uncertain),
(At(10), HistorianValue.FromString("beta"), Good));
await B.SeedAsync("D",
(At(0), HistorianValue.FromDateTime(d0), Good),
(At(10), HistorianValue.FromDateTime(d0.AddDays(1)), Good));
var src = B.CreateDataSource();
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()
{
await ConformanceData.SeedAAsync(B);
var s = (await B.CreateDataSource().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 src = B.CreateDataSource();
(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);
}
}