test(historian): Part 13 PercentGood oracle + pin the 1.5.378.182 divergence (#796)

The 1.5.378.182 StatusAggregateCalculator (vendored as the application's
compatibility baseline) counts Uncertain regions as Good time under
TreatUncertainAsBad = false; 1.5.378.106 counted Good regions only, as Part 13
§5.4.3.31/§5.4.3.33 and Mantis 0006166 require. Our evaluator follows Part 13,
so the oracle moved, not the server: 10 SdkAggregateParityTests failed.

SdkAggregateOracle.Run now answers PercentGood through a subclass of the
baseline StatusAggregateCalculator that counts Good regions only (same bounds,
regions, status/Partial bits and configuration as PercentBad). RunSdkAsIs keeps
the untouched baseline. Rejected the proposed 100 - PercentBad under
TreatUncertainAsBad = true: that flag also changes the region machinery (the
end-of-data 1 ms rule), so it computes over a different total; pinned by a test.

New tests pin the divergence on hand-computed G/U/B/G (50 vs baseline 75) and
all-Uncertain (0/0 vs baseline 100) intervals, forward and reverse, plus a
data-starts-inside interval where oracle == baseline.

Sql.Common.Tests 265/275 -> 280/280.
This commit is contained in:
Joseph Doherty
2026-11-10 21:04:27 -05:00
parent b36e688f0a
commit bd6d3fc2c3
2 changed files with 205 additions and 7 deletions
@@ -1,6 +1,8 @@
using Opc.Ua; using Opc.Ua;
using Opc.Ua.Server; using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates; 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 Shouldly;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; 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. /// UseSlopedExtrapolation = false), sloped interpolation, source timestamps.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Shared (source-linked) by <c>Sql.Common.Tests</c> and both SQL backends' integration suites. /// <para>Shared (source-linked) by <c>Sql.Common.Tests</c> and both SQL backends' integration suites.</para>
/// <para>
/// <b>PercentGood is the one aggregate not taken as-is (#796).</b> The 1.5.378.182 baseline changed
/// <c>StatusAggregateCalculator.ComputeDurationGoodBad</c> (1.5.378.106 and earlier counted Good regions only):
/// with TreatUncertainAsBad = false it counts Uncertain regions as <b>Good</b> 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 <i>and</i> PercentBad 0), and so does ours. <see cref="Run"/> therefore answers PercentGood
/// through <see cref="Part13PercentGoodCalculator"/>: 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. <see cref="RunSdkAsIs"/> runs the baseline untouched, for the test that pins
/// the divergence.
/// </para>
/// <para>
/// Not <c>100 − PercentBad</c> under TreatUncertainAsBad = true: that flag also changes the region machinery
/// (<c>IsGood</c> 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 <c>SdkAggregateParityTests</c>.
/// </para>
/// </remarks> /// </remarks>
public static class SdkAggregateOracle public static class SdkAggregateOracle
{ {
@@ -32,20 +53,44 @@ public static class SdkAggregateOracle
UseServerCapabilitiesDefaults = false, UseServerCapabilitiesDefaults = false,
}; };
/// <summary>Runs the SDK calculator for <paramref name="aggregate"/> over <paramref name="samples"/>.</summary> /// <summary>
/// Runs the Part 13 oracle for <paramref name="aggregate"/> over <paramref name="samples"/>: the baseline calculator,
/// except PercentGood, which counts Good regions only (see the remarks, #796).
/// </summary>
/// <param name="aggregate">The aggregate.</param> /// <param name="aggregate">The aggregate.</param>
/// <param name="start">Request start (later than <paramref name="end"/> for a reverse read).</param> /// <param name="start">Request start (later than <paramref name="end"/> for a reverse read).</param>
/// <param name="end">Request end.</param> /// <param name="end">Request end.</param>
/// <param name="interval">Processing interval.</param> /// <param name="interval">Processing interval.</param>
/// <param name="samples">Every stored sample of the tag, any order (sorted here).</param> /// <param name="samples">Every stored sample of the tag, any order (sorted here).</param>
/// <returns>The SDK's processed values, in the order it returns them.</returns> /// <returns>The oracle's processed values, in the order it returns them.</returns>
public static IReadOnlyList<Result> Run(HistoryAggregateType aggregate, DateTime start, DateTime end, TimeSpan interval, public static IReadOnlyList<Result> 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);
/// <summary>
/// Runs the baseline (1.5.378.182) calculator for <paramref name="aggregate"/> exactly as it computes it, under
/// <see cref="DefaultConfiguration"/> — including its PercentGood rule (Uncertain counted as Good) that
/// <see cref="Run"/> deliberately does not use (#796).
/// </summary>
/// <param name="aggregate">The aggregate.</param>
/// <param name="start">Request start (later than <paramref name="end"/> for a reverse read).</param>
/// <param name="end">Request end.</param>
/// <param name="interval">Processing interval.</param>
/// <param name="samples">Every stored sample of the tag, any order (sorted here).</param>
/// <param name="configuration">The configuration (default <see cref="DefaultConfiguration"/>).</param>
/// <returns>The calculator's processed values, in the order it returns them.</returns>
public static IReadOnlyList<Result> 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<Result> RunCore(IAggregateCalculator calc, DateTime start, DateTime end,
IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples) 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. // The SDK expects values in the direction of the read: ascending forward, descending reverse.
var ordered = samples.OrderBy(s => s.Time).ToList(); 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)); problems.ShouldBeEmpty($"{label} differs from the SDK:\n" + string.Join("\n", problems));
} }
/// <summary>
/// The baseline <see cref="BaselineStatusAggregateCalculator"/> with PercentGood counting <b>Good</b> 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.
/// </summary>
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;
}
}
/// <summary>The <see cref="CompatibilityAggregateFactory"/> adapter, for a calculator built here.</summary>
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( private static DataValue ToDataValue(DateTime t, HistorianValue v, uint status) => new(
v.Kind switch v.Kind switch
{ {
@@ -367,6 +367,112 @@ public sealed class SdkAggregateParityTests
.ShouldBeTrue("an Uncertain sample must change some interval's Average when it is (wrongly) included"); .ShouldBeTrue("an Uncertain sample must change some interval's Average when it is (wrongly) included");
} }
/// <summary>
/// #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.
/// </summary>
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),
];
/// <summary>#796 / Mantis 0006166: an interval [0, 20) covered by Uncertain data only — PercentGood 0 and PercentBad 0.</summary>
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),
];
/// <summary>#796: data that starts inside [0, 20) at +5 — 5 s of no data, then 15 s Good.</summary>
private static readonly StoredSample[] StartsInside =
[
S(5, HistorianValue.FromDouble(10), Good),
S(25, HistorianValue.FromDouble(40), Good),
];
private static double Pct(IReadOnlyList<SdkAggregateOracle.Result> r) => Convert.ToDouble(r.Single().Value);
private static double Pct(IReadOnlyList<DataValueSnapshot> r) => Convert.ToDouble(r.Single().Value);
private static IReadOnlyList<SdkAggregateOracle.Result> 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);
}
/// <summary>#684: Good 10 / 30 with an Uncertain value BELOW the Good minimum (+4) and one ABOVE the maximum (+6).</summary> /// <summary>#684: Good 10 / 30 with an Uncertain value BELOW the Good minimum (+4) and one ABOVE the maximum (+6).</summary>
private static readonly StoredSample[] UncertainBeyond = private static readonly StoredSample[] UncertainBeyond =
[ [