Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2AggregateCompatibilityTests.cs
T
Joseph Doherty ed05d50795 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
2026-11-11 12:05:34 -05:00

194 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using System.Text.Json;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
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)]
public async Task All_37_factories_preserve_the_6144_baseline_vectors(bool throughRuntimeManager)
{
await using var rig = throughRuntimeManager
? await ServerEventRig.StartAsync("AggregateBaseline", encrypted: false, TestContext.Current.CancellationToken)
: null;
var baseline = LoadBaseline();
var exemptions = Issue809Exemptions(baseline);
var failures = new List<string>();
foreach (var vector in baseline.Vectors)
{
// #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);
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.
private static long LegacyTimestampTicks(DateTimeUtc time) => time.IsNull ? DateTime.MinValue.Ticks : ((DateTime)time).Ticks;
static object? Payload(Sample sample) => sample.Type switch { "SByte" => sbyte.Parse(sample.Payload!, CultureInfo.InvariantCulture),
"Byte" => byte.Parse(sample.Payload!, CultureInfo.InvariantCulture),
"Int16" => short.Parse(sample.Payload!, CultureInfo.InvariantCulture),
"UInt16" => ushort.Parse(sample.Payload!, CultureInfo.InvariantCulture),
"Int32" => int.Parse(sample.Payload!, CultureInfo.InvariantCulture),
"UInt32" => uint.Parse(sample.Payload!, CultureInfo.InvariantCulture),
"UInt64" => ulong.Parse(sample.Payload!, CultureInfo.InvariantCulture),
"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);
record Vector(string Aggregate,string DataSet,bool Reverse,bool Stepped,bool UncertainAsBad,bool Incremental,Value[] Output,string? Exception, Configuration? Configuration = null,bool[]? QueueAccepted = null);
record Configuration(bool TreatUncertainAsBad,byte PercentDataBad,byte PercentDataGood,bool UseSlopedExtrapolation,bool UseServerCapabilitiesDefaults);
}