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 =
[