diff --git a/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs b/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs index 0be20dfd0..c9e3818c1 100644 --- a/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs +++ b/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs @@ -1,6 +1,8 @@ using Opc.Ua; using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates; +using BaselineAggregateCalculator = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.AggregateCalculator; +using BaselineStatusAggregateCalculator = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.StatusAggregateCalculator; using Shouldly; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -14,7 +16,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; /// UseSlopedExtrapolation = false), sloped interpolation, source timestamps. /// /// -/// Shared (source-linked) by Sql.Common.Tests and both SQL backends' integration suites. +/// Shared (source-linked) by Sql.Common.Tests and both SQL backends' integration suites. +/// +/// PercentGood is the one aggregate not taken as-is (#796). The 1.5.378.182 baseline changed +/// StatusAggregateCalculator.ComputeDurationGoodBad (1.5.378.106 and earlier counted Good regions only): +/// with TreatUncertainAsBad = false it counts Uncertain regions as Good time (as Bad when true). Part 13 +/// keeps Uncertain out of both (§5.4.3.31 DurationGood counts Good regions; §5.4.3.33 PercentGood = +/// DurationGood ÷ interval; Mantis 0006166, closed "no change required": an all-Uncertain interval is +/// PercentGood 0 and PercentBad 0), and so does ours. therefore answers PercentGood +/// through : the SAME baseline calculator, region machinery (simple +/// bounds, end-of-data 1 ms region, status and Partial bits) and server-default configuration, counting only +/// Good regions — exactly what the pre-.182 oracle computed. PercentBad still counts Bad regions only (unchanged +/// by .182) and is compared as-is. runs the baseline untouched, for the test that pins +/// the divergence. +/// +/// +/// Not 100 − PercentBad under TreatUncertainAsBad = true: that flag also changes the region machinery +/// (IsGood drives the end-of-data 1 ms rule and the simple bounds), so after an Uncertain last sample the +/// region runs to the interval end instead of 1 ms and the total differs from the one PercentBad is computed +/// over. Pinned by SdkAggregateParityTests. +/// /// public static class SdkAggregateOracle { @@ -32,20 +53,44 @@ public static class SdkAggregateOracle UseServerCapabilitiesDefaults = false, }; - /// Runs the SDK calculator for over . + /// + /// Runs the Part 13 oracle for over : the baseline calculator, + /// except PercentGood, which counts Good regions only (see the remarks, #796). + /// /// The aggregate. /// Request start (later than for a reverse read). /// Request end. /// Processing interval. /// Every stored sample of the tag, any order (sorted here). - /// The SDK's processed values, in the order it returns them. + /// The oracle's processed values, in the order it returns them. public static IReadOnlyList Run(HistoryAggregateType aggregate, DateTime start, DateTime end, TimeSpan interval, + IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples) => + aggregate == HistoryAggregateType.PercentGood + ? RunCore(new LegacyCalculatorAdapter(new Part13PercentGoodCalculator(ObjectIds.AggregateFunction_PercentGood, + start, end, interval.TotalMilliseconds, false, DefaultConfiguration, Telemetry)), start, end, samples) + : RunSdkAsIs(aggregate, start, end, interval, samples); + + /// + /// Runs the baseline (1.5.378.182) calculator for exactly as it computes it, under + /// — including its PercentGood rule (Uncertain counted as Good) that + /// deliberately does not use (#796). + /// + /// The aggregate. + /// Request start (later than for a reverse read). + /// Request end. + /// Processing interval. + /// Every stored sample of the tag, any order (sorted here). + /// The configuration (default ). + /// The calculator's processed values, in the order it returns them. + public static IReadOnlyList RunSdkAsIs(HistoryAggregateType aggregate, DateTime start, DateTime end, + TimeSpan interval, IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples, + AggregateConfiguration? configuration = null) => + RunCore(CompatibilityAggregateFactory.Create(AggregateId(aggregate), start, end, interval.TotalMilliseconds, false, + configuration ?? DefaultConfiguration, Telemetry), start, end, samples); + + private static IReadOnlyList RunCore(IAggregateCalculator calc, DateTime start, DateTime end, IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples) { - var id = AggregateId(aggregate); - var ms = interval.TotalMilliseconds; - var cfg = DefaultConfiguration; - IAggregateCalculator calc = CompatibilityAggregateFactory.Create(id, start, end, ms, false, cfg, Telemetry); // The SDK expects values in the direction of the read: ascending forward, descending reverse. var ordered = samples.OrderBy(s => s.Time).ToList(); @@ -109,6 +154,53 @@ public static class SdkAggregateOracle problems.ShouldBeEmpty($"{label} differs from the SDK:\n" + string.Join("\n", problems)); } + /// + /// The baseline with PercentGood counting Good regions only — Part 13 + /// §5.4.3.31/§5.4.3.33, and the 1.5.378.106 rule. Everything else (bounds, regions, Partial, status) is the + /// baseline's own code, run under the same configuration as PercentBad. + /// + private sealed class Part13PercentGoodCalculator(NodeId aggregateId, DateTime startTime, DateTime endTime, + double processingInterval, bool stepped, AggregateConfiguration configuration, ITelemetryContext telemetry) + : BaselineStatusAggregateCalculator(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry) + { + protected override LegacyDataValue ComputeValue(TimeSlice slice) + { + var values = GetValuesWithSimpleBounds(slice); + if (values == null || values.Count == 0) return GetNoDataValue(slice); + + var regions = GetRegionsInValueSet(values, false, true); + double good = 0, total = 0; + foreach (var region in regions) + { + total += region.Duration; + if (StatusCode.IsGood(region.StatusCode)) good += region.Duration; + } + + var value = new LegacyDataValue + { + WrappedValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.SdkValueBoundary.FromClr(good / total * 100), + SourceTimestamp = GetTimestamp(slice), + ServerTimestamp = GetTimestamp(slice), + }; + value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated); + return value; + } + } + + /// The adapter, for a calculator built here. + private sealed class LegacyCalculatorAdapter(BaselineAggregateCalculator calculator) : IAggregateCalculator + { + public NodeId AggregateId => calculator.AggregateId; + public bool QueueRawValue(DataValue value) => calculator.QueueRawValue(LegacyDataValue.FromSdk(value)); + public bool HasEndTimePassed(DateTimeUtc currentTime) => calculator.HasEndTimePassed((DateTime)currentTime); + public bool TryGetProcessedValue(bool returnPartial, out DataValue value) + { + var processed = calculator.GetProcessedValue(returnPartial); + value = processed is null ? default : processed.ToSdk(); + return processed is not null; + } + } + private static DataValue ToDataValue(DateTime t, HistorianValue v, uint status) => new( v.Kind switch { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs index 598d6adba..fa8d21216 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs @@ -367,6 +367,112 @@ public sealed class SdkAggregateParityTests .ShouldBeTrue("an Uncertain sample must change some interval's Average when it is (wrongly) included"); } + /// + /// #796: one 20 s interval [0, 20) of 5 s regions Good / Uncertain / Bad / Good, a Good sample before it and one after + /// it (no bound or end-of-data effect): Part 13 PercentGood 50 (Uncertain is neither Good nor Bad), PercentBad 25. + /// + private static readonly StoredSample[] GoodUncertainBadGood = + [ + S(-5, HistorianValue.FromDouble(5), Good), + S(0, HistorianValue.FromDouble(10), Good), + S(5, HistorianValue.FromDouble(20), Uncertain), + S(10, HistorianValue.Empty, BadNoCommunication), + S(15, HistorianValue.FromDouble(30), Good), + S(25, HistorianValue.FromDouble(40), Good), + ]; + + /// #796 / Mantis 0006166: an interval [0, 20) covered by Uncertain data only — PercentGood 0 and PercentBad 0. + private static readonly StoredSample[] AllUncertain = + [ + S(-5, HistorianValue.FromDouble(5), Good), + S(0, HistorianValue.FromDouble(10), Uncertain), + S(10, HistorianValue.FromDouble(20), Uncertain), + S(25, HistorianValue.FromDouble(40), Good), + ]; + + /// #796: data that starts inside [0, 20) at +5 — 5 s of no data, then 15 s Good. + private static readonly StoredSample[] StartsInside = + [ + S(5, HistorianValue.FromDouble(10), Good), + S(25, HistorianValue.FromDouble(40), Good), + ]; + + private static double Pct(IReadOnlyList r) => Convert.ToDouble(r.Single().Value); + + private static double Pct(IReadOnlyList r) => Convert.ToDouble(r.Single().Value); + + private static IReadOnlyList AsIs(HistoryAggregateType agg, double from, double to, + StoredSample[] data, Opc.Ua.AggregateConfiguration? cfg = null) => + SdkAggregateOracle.RunSdkAsIs(agg, At(from), At(to), (At(to) - At(from)).Duration(), + data.Select(s => (s.TimeUtc, s.Value, s.Status)), cfg); + + [Theory] + [InlineData(0, 20)] + [InlineData(20, 0)] + public void PercentGood_keeps_Uncertain_out_of_Good_per_Part_13_where_the_baseline_SDK_counts_it_as_Good( + double from, double to) + { + // #796, documented divergence (SdkAggregateOracle remarks). Part 13 §5.4.3.31 / §5.4.3.33: PercentGood is the Good + // share of the interval. The 1.5.378.182 StatusAggregateCalculator, under the server's TreatUncertainAsBad = false, + // adds Uncertain regions to it (1.5.378.106 did not). Hand-computed over 4 × 5 s regions G / U / B / G: + // Good 10 s ⇒ 50 %, Bad 5 s ⇒ 25 %, Uncertain 5 s in neither; the baseline says 75 % Good. + Pct(AsIs(HistoryAggregateType.PercentGood, from, to, GoodUncertainBadGood)) + .ShouldBe(75d, 1e-9, "the baseline counts the Uncertain 5 s as Good — if this moves, revisit the oracle (#796)"); + Pct(Sdk(HistoryAggregateType.PercentGood, from, to, 0, GoodUncertainBadGood)).ShouldBe(50d, 1e-9); + Pct(Ours(HistoryAggregateType.PercentGood, from, to, 0, GoodUncertainBadGood)).ShouldBe(50d, 1e-9); + // PercentBad is unaffected by .182 and is the baseline's own answer. + Pct(Sdk(HistoryAggregateType.PercentBad, from, to, 0, GoodUncertainBadGood)).ShouldBe(25d, 1e-9); + Pct(Ours(HistoryAggregateType.PercentBad, from, to, 0, GoodUncertainBadGood)).ShouldBe(25d, 1e-9); + + // Mantis 0006166 ("Uncertain data does not affect the calculations"): all-Uncertain ⇒ 0 % Good AND 0 % Bad. + Pct(AsIs(HistoryAggregateType.PercentGood, from, to, AllUncertain)).ShouldBe(100d, 1e-9); + Pct(Sdk(HistoryAggregateType.PercentGood, from, to, 0, AllUncertain)).ShouldBe(0d, 1e-9); + Pct(Ours(HistoryAggregateType.PercentGood, from, to, 0, AllUncertain)).ShouldBe(0d, 1e-9); + Pct(Sdk(HistoryAggregateType.PercentBad, from, to, 0, AllUncertain)).ShouldBe(0d, 1e-9); + Pct(Ours(HistoryAggregateType.PercentBad, from, to, 0, AllUncertain)).ShouldBe(0d, 1e-9); + + // Everything but the value (timestamp, status, Partial, Calculated bits) is the baseline's own. + foreach (var data in new[] { GoodUncertainBadGood, AllUncertain, StartsInside }) + { + var oracle = Sdk(HistoryAggregateType.PercentGood, from, to, 0, data).Single(); + var baseline = AsIs(HistoryAggregateType.PercentGood, from, to, data).Single(); + (oracle.StatusCode, oracle.Timestamp).ShouldBe((baseline.StatusCode, baseline.Timestamp)); + } + } + + [Theory] + [InlineData(0, 20)] + [InlineData(20, 0)] + public void PercentGood_over_a_partial_interval_without_Uncertain_data_is_the_baseline(double from, double to) + { + // #796: with no Uncertain data the oracle IS the baseline (the change only moves Uncertain time); the time before + // the first sample is non-Good: 15 s Good of 20 s ⇒ 75 %, with the data-start Partial bit. + var oracle = Sdk(HistoryAggregateType.PercentGood, from, to, 0, StartsInside); + AsIs(HistoryAggregateType.PercentGood, from, to, StartsInside).ShouldBe(oracle); + Pct(oracle).ShouldBe(75d, 1e-9); + SdkAggregateOracle.ShouldMatch(Ours(HistoryAggregateType.PercentGood, from, to, 0, StartsInside), oracle, + $"PercentGood StartsInside {from}→{to}"); + } + + [Fact] + public void PercentGood_is_not_100_minus_PercentBad_under_TreatUncertainAsBad() + { + // #796: why the oracle counts Good regions instead of deriving 100 − PercentBad under TreatUncertainAsBad = true. + // The flag also changes the baseline's region machinery (IsGood drives the end-of-data 1 ms rule): after an + // Uncertain LAST sample the region to the BadNoData end bound is 1 ms under false but the rest of the interval + // under true — a different total from the one the server-config PercentBad is computed over. + // Good +0, Uncertain +10, nothing after; [0, 20): + // false: Good 10 000 ms + Uncertain 1 ms ⇒ PercentGood 10 000 / 10 001, PercentBad 0 (the 1 ms artifact; this + // end-of-data interval's value is a documented, masked divergence — ours runs it to the interval end); + // true: Good 10 000 ms + Uncertain-as-Bad 10 000 ms ⇒ 100 − 50 = 50. + StoredSample[] data = [S(0, HistorianValue.FromDouble(10), Good), S(10, HistorianValue.FromDouble(20), Uncertain)]; + Pct(Sdk(HistoryAggregateType.PercentGood, 0, 20, 0, data)).ShouldBe(10_000d / 10_001 * 100, 1e-9); + Pct(AsIs(HistoryAggregateType.PercentBad, 0, 20, data)).ShouldBe(0d, 1e-9); + var cfg = SdkAggregateOracle.DefaultConfiguration; + cfg.TreatUncertainAsBad = true; + (100 - Pct(AsIs(HistoryAggregateType.PercentBad, 0, 20, data, cfg))).ShouldBe(50d, 1e-9); + } + /// #684: Good 10 / 30 with an Uncertain value BELOW the Good minimum (+4) and one ABOVE the maximum (+6). private static readonly StoredSample[] UncertainBeyond = [