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:
@@ -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
|
||||
{
|
||||
|
||||
+106
@@ -367,6 +367,112 @@ public sealed class SdkAggregateParityTests
|
||||
.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>
|
||||
private static readonly StoredSample[] UncertainBeyond =
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user