fix(aggregates): live PercentGood/DurationGood count Good time only (#809)
The frozen 1.5.378.182 StatusAggregateCalculator counts Uncertain regions as Good under TreatUncertainAsBad=false, so live AggregateFilter PercentGood / DurationGood disagreed with HistoryRead (Part 13 §5.4.3.31/§5.4.3.33, Mantis 0006166) whenever Uncertain data was present. Owner-approved 2026-11-11. - New app-owned GoodOnlyStatusAggregateCalculator subclasses the baseline and replaces only the PercentGood/DurationGood computation (Good regions only); CompatibilityAggregateFactory routes exactly those two IDs to it. The copied SDK files are untouched; the other 35 aggregates are byte-identical. - Golden test: the 64 changed vectors (38 PercentGood + 26 DurationGood) are exempted exactly — those whose baseline output differs from the same case's baseline TreatUncertainAsBad=true twin — and pinned to that twin (baseline evidence, not regenerated from the implementation); plus a full Good-only pin over all 352 PercentGood/DurationGood vectors and a hand-computed case. - Live-vs-history parity over Good/Uncertain/Bad mixes (no masks), with a discrimination check against the pre-#809 baseline; the booted-runtime facts test now expects 8000 ms / 40 %. - SdkAggregateOracle: Run/RunLive = the registered factory; RunSdkAsIs = the untouched baseline; its Part13GoodCalculator duplicate is removed. - README (recorded decision), ServerAggregateConfiguration remarks, docs/Historian.md and .claude/rules/historian.md updated. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
@@ -71,7 +71,7 @@ public abstract class QualityMixConformance : ConformanceBase
|
||||
/// <summary>Hand-computed Part 13 DurationBad of Q's eight intervals, seconds.</summary>
|
||||
private static readonly double[] QDurationBad = [3, 0, 0, 0, 5, 0, 0, 3];
|
||||
|
||||
/// <summary>What the 1.5.378.182 baseline answers for Q's PercentGood (Uncertain counted as Good, #796).</summary>
|
||||
/// <summary>What the untouched 1.5.378.182 baseline answers for Q's PercentGood (Uncertain counted as Good, #796; live before #809).</summary>
|
||||
private static readonly double[] QBaselinePercentGood = [70, 100, 100, 100, 50, 100, 100, 70];
|
||||
|
||||
/// <summary>
|
||||
@@ -182,7 +182,8 @@ public abstract class QualityMixConformance : ConformanceBase
|
||||
var pGood = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.PercentGood, Ct)).Samples;
|
||||
ShouldBeClose(Values(good), Values(pGood).Select(p => p * Ten.TotalMilliseconds / 100).ToArray(), "DurationGood vs PercentGood");
|
||||
|
||||
// The baseline (the live AggregateFilter calculator, #809) answers Q's DurationGood with the Uncertain time added.
|
||||
// The untouched baseline (the live AggregateFilter calculator before #809) answers Q's DurationGood with the
|
||||
// Uncertain time added; the live calculator now answers the Part 13 values above (SdkAggregateParityTests).
|
||||
var baseline = SdkAggregateOracle.RunSdkAsIs(HistoryAggregateType.DurationGood, At(0), At(80), Ten,
|
||||
Q.Select(r => (r.T, r.V, r.Status)));
|
||||
ShouldBeClose(baseline.Select(r => Convert.ToDouble(r.Value)).ToArray(),
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 BaselineAggregators = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.Aggregators;
|
||||
using Shouldly;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
@@ -18,17 +18,18 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
||||
/// <remarks>
|
||||
/// <para>Shared (source-linked) by <c>Sql.Common.Tests</c> and both SQL backends' integration suites.</para>
|
||||
/// <para>
|
||||
/// <b>PercentGood (and, since #820, DurationGood) are the aggregates not taken as-is (#796).</b> The 1.5.378.182 baseline changed
|
||||
/// <b>PercentGood / DurationGood (#796, #820, #809).</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
|
||||
/// (and DurationGood) through <see cref="Part13GoodCalculator"/>: 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 / DurationBad still count Bad regions only
|
||||
/// (unchanged by .182) and are compared as-is. <see cref="RunSdkAsIs"/> runs the baseline untouched, for the test that pins
|
||||
/// the divergence.
|
||||
/// PercentGood 0 <i>and</i> PercentBad 0), and so does ours. Since #809 the LIVE calculators follow Part 13 too:
|
||||
/// <c>CompatibilityAggregateFactory</c> serves those two through the application's
|
||||
/// <c>GoodOnlyStatusAggregateCalculator</c> (the same baseline region machinery — simple bounds, end-of-data 1 ms
|
||||
/// region, status and Partial bits — counting only Good regions), so <see cref="Run"/> / <see cref="RunLive"/> are
|
||||
/// exactly what a live AggregateFilter computes, for every aggregate. PercentBad / DurationBad count Bad regions only
|
||||
/// (unchanged by .182). <see cref="RunSdkAsIs"/> runs the untouched baseline (what live served before #809), for the
|
||||
/// tests that pin that divergence.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not <c>100 − PercentBad</c> under TreatUncertainAsBad = true: that flag also changes the region machinery
|
||||
@@ -54,8 +55,9 @@ public static class SdkAggregateOracle
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Runs the Part 13 oracle for <paramref name="aggregate"/> over <paramref name="samples"/>: the baseline calculator,
|
||||
/// except PercentGood / DurationGood, which count Good regions only (see the remarks, #796/#820).
|
||||
/// Runs the Part 13 oracle for <paramref name="aggregate"/> over <paramref name="samples"/>: the live calculator
|
||||
/// (<see cref="RunLive"/>) — the baseline, except PercentGood / DurationGood, which count Good regions only (see the
|
||||
/// remarks, #796/#820/#809).
|
||||
/// </summary>
|
||||
/// <param name="aggregate">The aggregate.</param>
|
||||
/// <param name="start">Request start (later than <paramref name="end"/> for a reverse read).</param>
|
||||
@@ -65,16 +67,29 @@ public static class SdkAggregateOracle
|
||||
/// <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 is HistoryAggregateType.PercentGood or HistoryAggregateType.DurationGood
|
||||
? RunCore(new LegacyCalculatorAdapter(new Part13GoodCalculator(AggregateId(aggregate),
|
||||
start, end, interval.TotalMilliseconds, false, DefaultConfiguration, Telemetry,
|
||||
usePercent: aggregate == HistoryAggregateType.PercentGood)), start, end, samples)
|
||||
: RunSdkAsIs(aggregate, start, end, interval, samples);
|
||||
RunLive(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).
|
||||
/// Runs exactly the calculator the server registers for live AggregateFilter monitored items
|
||||
/// (<c>CompatibilityAggregateFactory.Create</c>, the #809 Good-only PercentGood / DurationGood included).
|
||||
/// </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> RunLive(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);
|
||||
|
||||
/// <summary>
|
||||
/// Runs the untouched baseline (1.5.378.182) calculator for <paramref name="aggregate"/> exactly as it computes it,
|
||||
/// under <see cref="DefaultConfiguration"/> — including its PercentGood / DurationGood rule (Uncertain counted as
|
||||
/// Good) that neither HistoryRead nor, since #809, the live calculators use.
|
||||
/// </summary>
|
||||
/// <param name="aggregate">The aggregate.</param>
|
||||
/// <param name="start">Request start (later than <paramref name="end"/> for a reverse read).</param>
|
||||
@@ -86,8 +101,9 @@ public static class SdkAggregateOracle
|
||||
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);
|
||||
RunCore(new LegacyCalculatorAdapter(BaselineAggregators.CreateStandardCalculator(AggregateId(aggregate), start, end,
|
||||
interval.TotalMilliseconds, false, configuration ?? DefaultConfiguration, Telemetry)
|
||||
?? throw new ArgumentOutOfRangeException(nameof(aggregate))), start, end, samples);
|
||||
|
||||
private static IReadOnlyList<Result> RunCore(IAggregateCalculator calc, DateTime start, DateTime end,
|
||||
IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples)
|
||||
@@ -155,42 +171,7 @@ public static class SdkAggregateOracle
|
||||
problems.ShouldBeEmpty($"{label} differs from the SDK:\n" + string.Join("\n", problems));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The baseline <see cref="BaselineStatusAggregateCalculator"/> with PercentGood / DurationGood 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, the millisecond Duration unit) is the baseline's own code, run under the same configuration as
|
||||
/// PercentBad / DurationBad.
|
||||
/// </summary>
|
||||
private sealed class Part13GoodCalculator(NodeId aggregateId, DateTime startTime, DateTime endTime,
|
||||
double processingInterval, bool stepped, AggregateConfiguration configuration, ITelemetryContext telemetry,
|
||||
bool usePercent)
|
||||
: 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(usePercent ? good / total * 100 : good),
|
||||
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>
|
||||
/// <summary>The <see cref="CompatibilityAggregateFactory"/> adapter, for a baseline calculator built here.</summary>
|
||||
private sealed class LegacyCalculatorAdapter(BaselineAggregateCalculator calculator) : IAggregateCalculator
|
||||
{
|
||||
public NodeId AggregateId => calculator.AggregateId;
|
||||
|
||||
+102
-8
@@ -414,9 +414,10 @@ public sealed class SdkAggregateParityTests
|
||||
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:
|
||||
// #796, documented divergence of the untouched baseline (SdkAggregateOracle remarks; live follows Part 13 since
|
||||
// #809). 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)");
|
||||
@@ -524,15 +525,16 @@ public sealed class SdkAggregateParityTests
|
||||
public void An_Uncertain_first_sample_in_a_late_start_interval_makes_the_SDK_integrate_the_time_before_the_data(
|
||||
double from, double to)
|
||||
{
|
||||
// #821, pinned divergence (pending the #809/#821 owner decision — this test records, it does not choose).
|
||||
// #821, pinned divergence (pending the #821 owner decision — this test records, it does not choose).
|
||||
// Part 13 §5.4.3.3/§5.4.3.8: the interpolated-bounding signal is defined from the first sample only — 10 → 11 over
|
||||
// [5,12) (73.5) and 11 → 11 + 8/13 over [12,20) (4·(22 + 8/13)) ⇒ Total 163.96…, TimeAverage = Total ÷ 15 s.
|
||||
const double part13Total = 73.5 + 88 + 32d / 13;
|
||||
Pct(Ours(HistoryAggregateType.Total, from, to, 0, UncertainFirstLateStart)).ShouldBe(part13Total, 1e-9);
|
||||
Pct(Ours(HistoryAggregateType.TimeAverage, from, to, 0, UncertainFirstLateStart)).ShouldBe(part13Total / 15, 1e-9);
|
||||
|
||||
// The frozen 1.5.378.182 calculators — exactly what the server registers for live AggregateFilter items
|
||||
// (CompatibilityAggregateFactory; Sdk2AggregateCompatibilityTests proves the runtime manager serves the same code)
|
||||
// The frozen 1.5.378.182 calculators — for Total / TimeAverage exactly what the server registers for live
|
||||
// AggregateFilter items (CompatibilityAggregateFactory; Sdk2AggregateCompatibilityTests proves the runtime manager
|
||||
// serves the same code)
|
||||
// — integrate the uncovered [0,5) as a ramp 0 → 10 (+25) and divide TimeAverage by the WHOLE 20 s interval.
|
||||
Pct(AsIs(HistoryAggregateType.Total, from, to, UncertainFirstLateStart)).ShouldBe(part13Total + 25, 1e-9);
|
||||
Pct(AsIs(HistoryAggregateType.TimeAverage, from, to, UncertainFirstLateStart)).ShouldBe((part13Total + 25) / 20, 1e-9);
|
||||
@@ -557,16 +559,108 @@ public sealed class SdkAggregateParityTests
|
||||
Stock201(agg, from, to, GoodFirstLateStart).Single().Value!.Value.ShouldBe(expected, 1e-9);
|
||||
}
|
||||
|
||||
// #809 on the same input: DurationGood / PercentGood — Part 13 (ours) and stock SDK 2.0.1 count Good regions only
|
||||
// ([12,20) = 8 s); the frozen live calculators also count the Uncertain [5,12) as Good (15 s).
|
||||
// #809 on the same input: DurationGood / PercentGood — Part 13 (ours), stock SDK 2.0.1 and, since #809, the live
|
||||
// calculators count Good regions only ([12,20) = 8 s); the untouched baseline (live before #809) also counted the
|
||||
// Uncertain [5,12) as Good (15 s).
|
||||
Pct(Ours(HistoryAggregateType.DurationGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(8_000d, 1e-9);
|
||||
Stock201(HistoryAggregateType.DurationGood, from, to, UncertainFirstLateStart).Single().Value!.Value.ShouldBe(8_000d, 1e-9);
|
||||
Pct(Sdk(HistoryAggregateType.DurationGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(8_000d, 1e-9);
|
||||
Pct(AsIs(HistoryAggregateType.DurationGood, from, to, UncertainFirstLateStart)).ShouldBe(15_000d, 1e-9);
|
||||
Pct(Ours(HistoryAggregateType.PercentGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(40d, 1e-9);
|
||||
Stock201(HistoryAggregateType.PercentGood, from, to, UncertainFirstLateStart).Single().Value!.Value.ShouldBe(40d, 1e-9);
|
||||
Pct(Sdk(HistoryAggregateType.PercentGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(40d, 1e-9);
|
||||
Pct(AsIs(HistoryAggregateType.PercentGood, from, to, UncertainFirstLateStart)).ShouldBe(75d, 1e-9);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #809: the #810 QualityMix tag Q — Good / Uncertain / Bad samples and an outage marker, Uncertain regions starting
|
||||
/// at a sample, through a sloped bound and held into an outage, with Good samples before (−10) and after (+90) every
|
||||
/// window so no interval holds the start or the end of the data.
|
||||
/// </summary>
|
||||
private static readonly StoredSample[] UncertainMix =
|
||||
[
|
||||
S(-10, HistorianValue.FromDouble(10), Good),
|
||||
S(0, HistorianValue.FromDouble(10), Good),
|
||||
S(4, HistorianValue.FromDouble(12), Uncertain),
|
||||
S(7, HistorianValue.FromDouble(13), Bad),
|
||||
S(10, HistorianValue.FromDouble(14), Good),
|
||||
S(13, HistorianValue.FromDouble(15), Uncertain),
|
||||
S(20, HistorianValue.FromDouble(16), Uncertain),
|
||||
S(25, HistorianValue.FromDouble(17), Uncertain),
|
||||
S(33, HistorianValue.FromDouble(18), Uncertain),
|
||||
S(40, HistorianValue.FromDouble(20), Good),
|
||||
S(45, HistorianValue.Empty, BadNoCommunication),
|
||||
S(50, HistorianValue.FromDouble(22), Uncertain),
|
||||
S(56, HistorianValue.FromDouble(23), Good),
|
||||
S(64, HistorianValue.FromDouble(25), Uncertain),
|
||||
S(67, HistorianValue.FromDouble(26), Good),
|
||||
S(75, HistorianValue.FromDouble(27), Bad),
|
||||
S(78, HistorianValue.FromDouble(28), Good),
|
||||
S(90, HistorianValue.FromDouble(30), Good),
|
||||
];
|
||||
|
||||
/// <summary>Windows of whole intervals only (no truncated interval — ours flags it Partial, a documented divergence).</summary>
|
||||
public static TheoryData<double, double, double> WholeIntervalWindows => new()
|
||||
{
|
||||
{ 0, 80, 20 }, { 80, 0, 20 },
|
||||
{ 0, 80, 10 }, { 80, 0, 10 },
|
||||
{ 5, 80, 15 }, { 80, 5, 15 },
|
||||
{ 0, 60, 0 }, { 60, 0, 0 },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(WholeIntervalWindows))]
|
||||
public void Live_AggregateFilter_PercentGood_and_DurationGood_equal_HistoryRead_over_Uncertain_samples(
|
||||
double from, double to, double intervalS)
|
||||
{
|
||||
// #809: a live AggregateFilter monitored item and a HistoryRead-Processed (SqlServer / QuestDB share
|
||||
// TimeWeightedAggregator) answer PercentGood / DurationGood identically over Uncertain data — same values,
|
||||
// timestamps, status and Partial / Calculated bits, with NO documented-divergence mask. RunLive is exactly
|
||||
// CompatibilityAggregateFactory.Create, the calculator the server registers (Sdk2AggregateCompatibilityTests
|
||||
// proves the booted runtime manager serves the same code).
|
||||
var interval = intervalS > 0 ? TimeSpan.FromSeconds(intervalS) : (At(to) - At(from)).Duration();
|
||||
var failures = new List<string>();
|
||||
var discriminated = 0;
|
||||
foreach (var agg in new[] { HistoryAggregateType.PercentGood, HistoryAggregateType.DurationGood })
|
||||
foreach (var (name, data) in new[] { ("UncertainMix", UncertainMix), ("DenseUncertain", DenseUncertain) })
|
||||
{
|
||||
var rows = data.Select(x => (x.TimeUtc, x.Value, x.Status)).ToArray();
|
||||
var live = SdkAggregateOracle.RunLive(agg, At(from), At(to), interval, rows);
|
||||
try
|
||||
{
|
||||
SdkAggregateOracle.ShouldMatch(Ours(agg, from, to, intervalS, data), live, $"live {agg} {name} {from}→{to}/{intervalS}s");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add(ex.Message);
|
||||
}
|
||||
// The data must discriminate: the pre-#809 live calculator (the untouched baseline) counts the Uncertain
|
||||
// time as Good, so it disagrees somewhere — or this parity would pass against the old behaviour too.
|
||||
var before = SdkAggregateOracle.RunSdkAsIs(agg, At(from), At(to), interval, rows);
|
||||
if (before.Zip(live).Any(p => Math.Abs(Convert.ToDouble(p.First.Value) - Convert.ToDouble(p.Second.Value)) > 1e-9))
|
||||
discriminated++;
|
||||
}
|
||||
failures.ShouldBeEmpty(string.Join("\n", failures));
|
||||
discriminated.ShouldBe(4, "every aggregate × data set must differ from the pre-#809 live answer somewhere");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Live_AggregateFilter_PercentGood_and_DurationGood_are_the_hand_computed_Part_13_values()
|
||||
{
|
||||
// #809, hand-computed over UncertainMix's eight 10 s intervals (QualityMixConformance derives them region by
|
||||
// region): Good seconds 4, 3, 0, 0, 5, 4, 3, 2. The pre-#809 live answer added the Uncertain time
|
||||
// (7, 10, 10, 10, 5, 10, 10, 7 s).
|
||||
double[] goodSeconds = [4, 3, 0, 0, 5, 4, 3, 2];
|
||||
var rows = UncertainMix.Select(x => (x.TimeUtc, x.Value, x.Status)).ToArray();
|
||||
var ten = TimeSpan.FromSeconds(10);
|
||||
SdkAggregateOracle.RunLive(HistoryAggregateType.DurationGood, At(0), At(80), ten, rows)
|
||||
.Select(r => Convert.ToDouble(r.Value)).ShouldBe(goodSeconds.Select(g => g * 1000), tolerance: 1e-9);
|
||||
SdkAggregateOracle.RunLive(HistoryAggregateType.PercentGood, At(0), At(80), ten, rows)
|
||||
.Select(r => Convert.ToDouble(r.Value)).ShouldBe(goodSeconds.Select(g => g * 10), tolerance: 1e-9);
|
||||
SdkAggregateOracle.RunSdkAsIs(HistoryAggregateType.DurationGood, At(0), At(80), ten, rows)
|
||||
.Select(r => Convert.ToDouble(r.Value)).ShouldBe([7000d, 10000, 10000, 10000, 5000, 10000, 10000, 7000], tolerance: 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 =
|
||||
[
|
||||
|
||||
+8
-6
@@ -6,9 +6,10 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// #821 / #809 facts, through the BOOTED server's runtime <c>AggregateManager</c> — the calculators a live
|
||||
/// AggregateFilter monitored item gets, under the server's own default configuration. This pins current behaviour for
|
||||
/// the owner decision; it does not choose. The HistoryRead (Part 13) answers and SDK 2.0.1's stock answers for the
|
||||
/// same input are pinned beside it in <c>SdkAggregateParityTests</c>.
|
||||
/// AggregateFilter monitored item gets, under the server's own default configuration. The #821 Total / TimeAverage
|
||||
/// facts pin current behaviour for the owner decision; they do not choose. #809 is decided (2026-11-11): live
|
||||
/// PercentGood / DurationGood count Good time only, equal to HistoryRead. The HistoryRead (Part 13) answers and SDK
|
||||
/// 2.0.1's stock answers for the same input are pinned beside it in <c>SdkAggregateParityTests</c>.
|
||||
/// </summary>
|
||||
public sealed class LiveAggregateLateStartFactsTests
|
||||
{
|
||||
@@ -45,9 +46,10 @@ public sealed class LiveAggregateLateStartFactsTests
|
||||
const double part13Total = 73.5 + 88 + 32d / 13;
|
||||
One(ObjectIds.AggregateFunction_Total).ShouldBe(part13Total + 25, 1e-9); // 188.96…
|
||||
One(ObjectIds.AggregateFunction_TimeAverage).ShouldBe((part13Total + 25) / 20, 1e-9); // 9.448… (Part 13: 10.93…)
|
||||
// #809: the Uncertain [5,12) counts as Good live (HistoryRead: 8 000 ms / 40 %).
|
||||
One(ObjectIds.AggregateFunction_DurationGood).ShouldBe(15_000d, 1e-9);
|
||||
One(ObjectIds.AggregateFunction_PercentGood).ShouldBe(75d, 1e-9);
|
||||
// #809: the Uncertain [5,12) is not Good — only [12,20) is, live as in HistoryRead (8 000 ms / 40 %; before #809
|
||||
// the live calculator counted the Uncertain time too: 15 000 ms / 75 %).
|
||||
One(ObjectIds.AggregateFunction_DurationGood).ShouldBe(8_000d, 1e-9);
|
||||
One(ObjectIds.AggregateFunction_PercentGood).ShouldBe(40d, 1e-9);
|
||||
// DurationBad / PercentBad agree with HistoryRead: the 5 s before the data is Bad.
|
||||
One(ObjectIds.AggregateFunction_DurationBad).ShouldBe(5_000d, 1e-9);
|
||||
One(ObjectIds.AggregateFunction_PercentBad).ShouldBe(25d, 1e-9);
|
||||
|
||||
+146
-45
@@ -9,8 +9,20 @@ using ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates;
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
||||
|
||||
/// <summary>Compares all preserved aggregate factories with independently generated SDK 1.5.378.182 vectors.</summary>
|
||||
/// <remarks>
|
||||
/// #809 (owner-approved 2026-11-11): PercentGood / DurationGood no longer count Uncertain time as Good. The baseline
|
||||
/// vectors that changes are exempted EXACTLY — a PercentGood / DurationGood vector whose output differs from the same
|
||||
/// case computed by the baseline SDK with <c>TreatUncertainAsBad = true</c> (its "twin": there 1.5.378.182 already
|
||||
/// counted Good regions only) — and each is pinned to that twin instead. The twin is baseline evidence, not output of
|
||||
/// the application's calculator, so the pin never regenerates expectations from the implementation.
|
||||
/// </remarks>
|
||||
public sealed class Sdk2AggregateCompatibilityTests
|
||||
{
|
||||
private const string PercentGood = "AggregateFunction_PercentGood";
|
||||
private const string DurationGood = "AggregateFunction_DurationGood";
|
||||
private const int Issue809PercentGoodExemptions = 38;
|
||||
private const int Issue809DurationGoodExemptions = 26;
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
@@ -19,57 +31,145 @@ public sealed class Sdk2AggregateCompatibilityTests
|
||||
await using var rig = throughRuntimeManager
|
||||
? await ServerEventRig.StartAsync("AggregateBaseline", encrypted: false, TestContext.Current.CancellationToken)
|
||||
: null;
|
||||
var baseline = JsonSerializer.Deserialize<Baseline>(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "opcua-1.5.378.182-aggregates.json")), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
var epoch = new DateTime(baseline.EpochTicks, DateTimeKind.Utc);
|
||||
var baseline = LoadBaseline();
|
||||
var exemptions = Issue809Exemptions(baseline);
|
||||
var failures = new List<string>();
|
||||
foreach (var vector in baseline.Vectors)
|
||||
{
|
||||
var id = (NodeId)typeof(ObjectIds).GetProperty(vector.Aggregate)!.GetValue(null)!;
|
||||
var start = epoch.AddSeconds(vector.Reverse ? 70 : 0); var end = epoch.AddSeconds(vector.Reverse ? 0 : 70);
|
||||
var profile = vector.Configuration;
|
||||
var config = new AggregateConfiguration
|
||||
{
|
||||
TreatUncertainAsBad = profile?.TreatUncertainAsBad ?? vector.UncertainAsBad,
|
||||
PercentDataBad = profile?.PercentDataBad ?? 100, PercentDataGood = profile?.PercentDataGood ?? 100,
|
||||
UseSlopedExtrapolation = profile?.UseSlopedExtrapolation ?? false,
|
||||
UseServerCapabilitiesDefaults = profile?.UseServerCapabilitiesDefaults ?? false,
|
||||
};
|
||||
var calculator = throughRuntimeManager
|
||||
? rig!.Server.CurrentInstance.AggregateManager.CreateCalculator(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config)
|
||||
: CompatibilityAggregateFactory.Create(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config, telemetry);
|
||||
calculator.ShouldNotBeNull($"Runtime registration must serve {vector.Aggregate}.");
|
||||
var output = new List<Value>(); var accepted = new List<bool>(); string? error = null;
|
||||
try
|
||||
{
|
||||
var samples = baseline.Data[vector.DataSet].OrderBy(x => x.Seconds);
|
||||
foreach (var sample in vector.Reverse ? samples.Reverse() : samples)
|
||||
{
|
||||
var time = epoch.AddSeconds(sample.Seconds);
|
||||
accepted.Add(calculator.QueueRawValue(new DataValue(SdkValueBoundary.FromClr(Payload(sample)), sample.Status, time, time.AddTicks(sample.ServerOffsetTicks), sample.SourcePicoseconds, sample.ServerPicoseconds)));
|
||||
if (vector.Incremental) Drain(false);
|
||||
}
|
||||
Drain(true);
|
||||
}
|
||||
catch (Exception ex) { error = ex.GetType().FullName; }
|
||||
if (error != vector.Exception || !output.SequenceEqual(vector.Output) || (vector.QueueAccepted is { } expectedAccepted && !accepted.SequenceEqual(expectedAccepted)))
|
||||
{
|
||||
failures.Add($"{vector.Aggregate}/{vector.DataSet}/rev={vector.Reverse}/step={vector.Stepped}/uncertainBad={vector.UncertainAsBad}/incremental={vector.Incremental}: error {vector.Exception} vs {error}; expected {JsonSerializer.Serialize(vector.Output)} actual {JsonSerializer.Serialize(output)}");
|
||||
}
|
||||
void Drain(bool partial)
|
||||
{
|
||||
for (var guard = 0; guard < 100; guard++)
|
||||
{
|
||||
if (!calculator.TryGetProcessedValue(partial, out var value)) return;
|
||||
var payload = SdkValueBoundary.ToClr(value.WrappedValue);
|
||||
output.Add(new(partial, payload?.GetType().Name, Format(payload), value.StatusCode.Code, LegacyTimestampTicks(value.SourceTimestamp), LegacyTimestampTicks(value.ServerTimestamp), value.SourcePicoseconds, value.ServerPicoseconds));
|
||||
}
|
||||
throw new InvalidOperationException("Baseline calculator did not terminate.");
|
||||
}
|
||||
// #809: an exempted case is held to its baseline uncertain-as-bad twin; every other case to its own vector.
|
||||
var expected = exemptions.TryGetValue(vector, out var twin) ? twin.Output : vector.Output;
|
||||
if (Check(baseline, vector, expected, rig) is { } failure) failures.Add(failure);
|
||||
}
|
||||
baseline.Vectors.Length.ShouldBe(6144);
|
||||
baseline.Vectors.Select(x => x.Aggregate).Distinct().Count().ShouldBe(37);
|
||||
failures.ShouldBeEmpty("The application must retain baseline values, CLR types, complete status bits, timestamps and incremental processing.");
|
||||
exemptions.Keys.Count(x => x.Aggregate == PercentGood).ShouldBe(Issue809PercentGoodExemptions);
|
||||
exemptions.Keys.Count(x => x.Aggregate == DurationGood).ShouldBe(Issue809DurationGoodExemptions);
|
||||
failures.ShouldBeEmpty("The application must retain baseline values, CLR types, complete status bits, timestamps and incremental processing (#809 exemptions: the uncertain-as-bad twin).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #809 pin: the exemptions are exactly the PercentGood / DurationGood cases that counted Uncertain as Good, and
|
||||
/// every PercentGood / DurationGood case — exempt or not, whatever its configuration — now answers its baseline
|
||||
/// uncertain-as-bad twin (Good regions only).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Issue809_percent_and_duration_good_count_only_good_time()
|
||||
{
|
||||
var baseline = LoadBaseline();
|
||||
var exemptions = Issue809Exemptions(baseline);
|
||||
var failures = new List<string>();
|
||||
var checkedCount = 0;
|
||||
var twinned = 0;
|
||||
foreach (var vector in baseline.Vectors.Where(v => v.Aggregate is PercentGood or DurationGood))
|
||||
{
|
||||
// A data set with no uncertain-as-bad twin (the numeric-width edge sets) has no Uncertain region to move.
|
||||
var twin = UncertainAsBadTwin(baseline, vector);
|
||||
if (exemptions.ContainsKey(vector))
|
||||
EffectiveTreatUncertainAsBad(vector).ShouldBeFalse($"{Describe(vector)}: only an Uncertain-as-Good case may change.");
|
||||
if (Check(baseline, vector, twin?.Output ?? vector.Output, rig: null) is { } failure) failures.Add(failure);
|
||||
checkedCount++;
|
||||
if (twin is not null) twinned++;
|
||||
}
|
||||
checkedCount.ShouldBe(352);
|
||||
twinned.ShouldBeGreaterThan(exemptions.Count);
|
||||
exemptions.Count.ShouldBe(Issue809PercentGoodExemptions + Issue809DurationGoodExemptions);
|
||||
failures.ShouldBeEmpty("PercentGood / DurationGood must count Good regions only (Part 13 §5.4.3.31/§5.4.3.33, #809).");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #809 hand-computed pin, independent of the expected outputs in any fixture: the "uncertain" data set (0 s Good,
|
||||
/// 5 s Uncertain, 15 s Good, 25 s Bad, 35 s Good, 50 s Good) over 20 s intervals from 0 to 70 s, sloped, forward.
|
||||
/// [0,20): Good 0–5 and 15–20 = 10 s (the baseline said 20 s); [20,40): the bound at 20 slopes into the Bad sample
|
||||
/// (Uncertain), 25–35 Bad, 35–40 Good = 5 s (baseline 10 s); [40,60): 20 s; [60,70): all 10 s of the partial
|
||||
/// interval = 100 %.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(DurationGood, new[] { 10000d, 5000d, 20000d, 10000d })]
|
||||
[InlineData(PercentGood, new[] { 50d, 25d, 100d, 100d })]
|
||||
public void Issue809_uncertain_time_is_not_good_hand_computed(string aggregate, double[] expected)
|
||||
{
|
||||
var baseline = LoadBaseline();
|
||||
var vector = baseline.Vectors.Single(v => v.Aggregate == aggregate && v.DataSet == "uncertain" && !v.Reverse && !v.Stepped
|
||||
&& !v.UncertainAsBad && !v.Incremental && v.Configuration is null);
|
||||
var output = Run(baseline, vector, rig: null, out var error);
|
||||
error.ShouldBeNull();
|
||||
output.ShouldAllBe(x => x.Type == "Double");
|
||||
output.Select(x => double.Parse(x.Payload!, CultureInfo.InvariantCulture)).ShouldBe(expected);
|
||||
}
|
||||
|
||||
private static Baseline LoadBaseline() =>
|
||||
JsonSerializer.Deserialize<Baseline>(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "opcua-1.5.378.182-aggregates.json")), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
|
||||
|
||||
private static bool EffectiveTreatUncertainAsBad(Vector vector) => vector.Configuration?.TreatUncertainAsBad ?? vector.UncertainAsBad;
|
||||
|
||||
/// <summary>The same case computed by the baseline SDK with <c>TreatUncertainAsBad = true</c> and no explicit profile.</summary>
|
||||
private static Vector? UncertainAsBadTwin(Baseline baseline, Vector vector) =>
|
||||
baseline.Vectors.SingleOrDefault(v => v.Aggregate == vector.Aggregate && v.DataSet == vector.DataSet && v.Reverse == vector.Reverse
|
||||
&& v.Stepped == vector.Stepped && v.Incremental == vector.Incremental && v.UncertainAsBad && v.Configuration is null);
|
||||
|
||||
/// <summary>#809: the PercentGood / DurationGood vectors whose baseline output differs from their uncertain-as-bad twin.</summary>
|
||||
private static Dictionary<Vector, Vector> Issue809Exemptions(Baseline baseline) =>
|
||||
baseline.Vectors
|
||||
.Where(v => v.Aggregate is PercentGood or DurationGood)
|
||||
.Select(v => (Vector: v, Twin: UncertainAsBadTwin(baseline, v)))
|
||||
.Where(x => x.Twin is not null && !x.Vector.Output.SequenceEqual(x.Twin.Output))
|
||||
.ToDictionary<(Vector Vector, Vector? Twin), Vector, Vector>(x => x.Vector, x => x.Twin!, System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||||
|
||||
private static string Describe(Vector vector) =>
|
||||
$"{vector.Aggregate}/{vector.DataSet}/rev={vector.Reverse}/step={vector.Stepped}/uncertainBad={vector.UncertainAsBad}/incremental={vector.Incremental}/config={JsonSerializer.Serialize(vector.Configuration)}";
|
||||
|
||||
private static string? Check(Baseline baseline, Vector vector, Value[] expected, ServerEventRig? rig)
|
||||
{
|
||||
var output = Run(baseline, vector, rig, out var error);
|
||||
return error != vector.Exception || !output.SequenceEqual(expected) || (vector.QueueAccepted is { } expectedAccepted && !output.Accepted.SequenceEqual(expectedAccepted))
|
||||
? $"{Describe(vector)}: error {vector.Exception} vs {error}; expected {JsonSerializer.Serialize(expected)} actual {JsonSerializer.Serialize(output.ToArray())}"
|
||||
: null;
|
||||
}
|
||||
|
||||
private static RunOutput Run(Baseline baseline, Vector vector, ServerEventRig? rig, out string? error)
|
||||
{
|
||||
var telemetry = DefaultTelemetry.Create(_ => { });
|
||||
var epoch = new DateTime(baseline.EpochTicks, DateTimeKind.Utc);
|
||||
var id = (NodeId)typeof(ObjectIds).GetProperty(vector.Aggregate)!.GetValue(null)!;
|
||||
var start = epoch.AddSeconds(vector.Reverse ? 70 : 0); var end = epoch.AddSeconds(vector.Reverse ? 0 : 70);
|
||||
var profile = vector.Configuration;
|
||||
var config = new AggregateConfiguration
|
||||
{
|
||||
TreatUncertainAsBad = profile?.TreatUncertainAsBad ?? vector.UncertainAsBad,
|
||||
PercentDataBad = profile?.PercentDataBad ?? 100, PercentDataGood = profile?.PercentDataGood ?? 100,
|
||||
UseSlopedExtrapolation = profile?.UseSlopedExtrapolation ?? false,
|
||||
UseServerCapabilitiesDefaults = profile?.UseServerCapabilitiesDefaults ?? false,
|
||||
};
|
||||
var calculator = rig is not null
|
||||
? rig.Server.CurrentInstance.AggregateManager.CreateCalculator(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config)
|
||||
: CompatibilityAggregateFactory.Create(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config, telemetry);
|
||||
calculator.ShouldNotBeNull($"Runtime registration must serve {vector.Aggregate}.");
|
||||
var output = new RunOutput();
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
var samples = baseline.Data[vector.DataSet].OrderBy(x => x.Seconds);
|
||||
foreach (var sample in vector.Reverse ? samples.Reverse() : samples)
|
||||
{
|
||||
var time = epoch.AddSeconds(sample.Seconds);
|
||||
output.Accepted.Add(calculator.QueueRawValue(new DataValue(SdkValueBoundary.FromClr(Payload(sample)), sample.Status, time, time.AddTicks(sample.ServerOffsetTicks), sample.SourcePicoseconds, sample.ServerPicoseconds)));
|
||||
if (vector.Incremental) Drain(false);
|
||||
}
|
||||
Drain(true);
|
||||
}
|
||||
catch (Exception ex) { error = ex.GetType().FullName; }
|
||||
return output;
|
||||
|
||||
void Drain(bool partial)
|
||||
{
|
||||
for (var guard = 0; guard < 100; guard++)
|
||||
{
|
||||
if (!calculator.TryGetProcessedValue(partial, out var value)) return;
|
||||
var payload = SdkValueBoundary.ToClr(value.WrappedValue);
|
||||
output.Add(new(partial, payload?.GetType().Name, Format(payload), value.StatusCode.Code, LegacyTimestampTicks(value.SourceTimestamp), LegacyTimestampTicks(value.ServerTimestamp), value.SourcePicoseconds, value.ServerPicoseconds));
|
||||
}
|
||||
throw new InvalidOperationException("Baseline calculator did not terminate.");
|
||||
}
|
||||
}
|
||||
|
||||
// UA FILETIME zero is the old CLR unset sentinel, not a different historical instant.
|
||||
@@ -84,6 +184,7 @@ public sealed class Sdk2AggregateCompatibilityTests
|
||||
"Single" => float.Parse(sample.Payload!, CultureInfo.InvariantCulture),
|
||||
"Double" => double.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Int64" => long.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Boolean" => bool.Parse(sample.Payload!), _ => null };
|
||||
static string? Format(object? value) => value switch { null => null, double number => number.ToString("R", CultureInfo.InvariantCulture), float number => number.ToString("R", CultureInfo.InvariantCulture), IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() };
|
||||
private sealed class RunOutput : List<Value> { public List<bool> Accepted { get; } = []; }
|
||||
record Baseline(long EpochTicks, int IntervalMilliseconds, Dictionary<string,Sample[]> Data, Vector[] Vectors);
|
||||
record Sample(double Seconds,string Type,string? Payload,uint Status,ushort SourcePicoseconds = 0,ushort ServerPicoseconds = 0,long ServerOffsetTicks = 0);
|
||||
record Value(bool PartialRequested,string? Type,string? Payload,uint Status,long SourceTicks,long ServerTicks,ushort SourcePicoseconds,ushort ServerPicoseconds);
|
||||
|
||||
Reference in New Issue
Block a user