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.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.
/// </summary>
/// <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>
public static class SdkAggregateOracle
{
@@ -32,20 +53,44 @@ public static class SdkAggregateOracle
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="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>
/// <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,
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)
{
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));
}
/// <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(
v.Kind switch
{