using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; /// /// 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. /// 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[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); } }