68a1f31397
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.
190 lines
8.6 KiB
C#
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);
|
|
}
|
|
}
|