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:
+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