Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/Sdk2AggregateCompatibilityTests.cs
T
Joseph Doherty 8a0032bfdf fix(aggregates): live TimeAverage/Total leave the span before an Uncertain first value out (#821)
When the data starts inside an interval (no start bound) and its first value is
Uncertain, the frozen 1.5.378.182 AverageAggregateCalculator integrated the
uncovered span as a ramp from 0 and divided TimeAverage by the whole interval:
GetRegionsInValueSet (ignoreBadData, sloped) overwrites the BadNoData start
region's status with UncertainDataSubNormal in its one branch without a not-Bad
guard. Live AggregateFilter therefore disagreed with HistoryRead and Part 13
§5.4.3.6/§5.4.3.8 ("No Start Bound: Calculate Partial Interval"); QP: Total
188.96 vs 163.96, TimeAverage 9.448 vs 10.931. SDK 2.0.1 stock does the same.
Owner-approved 2026-11-11.

- New app-owned CoveredTimeAverageAggregateCalculator subclasses the baseline
  and recomputes only TimeAverage/Total, restoring the Bad status of a region
  that starts at a Bad point (only the start bound can). Only such an interval's
  value changes; status, timestamps, type and bits are the baseline's. Stepped
  and TreatUncertainAsBad=true never reached the defect. CompatibilityAggregate-
  Factory routes exactly those two IDs; copied SDK files untouched.
- Golden test: 0 fixture vectors change (every data set has a usable value at
  -10 s), asserted. New sweep over late-start variants of every data set (cut
  +5 s / +25 s, first non-Bad sample Uncertain, all directions/stepping/configs/
  incremental): 150 / 142 of 384 change, each pinned to a baseline-derived
  oracle (untouched baseline's value with that sample Good, its status/bits on
  the variant itself) - never our calculator. Plus a hand-computed case.
- Live-vs-HistoryRead parity (TimeWeightedAggregator) forward + reverse, no
  masks, with discrimination against the pre-#821 baseline; QualityMix now
  compares QP's TimeAverage/Total with the live oracle on both SQL backends;
  booted-runtime facts test expects 163.96 / 10.93.
- README recorded decision, ServerAggregateConfiguration remarks, oracle
  remarks, docs/Historian.md and .claude/rules/historian.md updated.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 12:30:01 -05:00

324 lines
22 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.
/// <para>
/// #821 (owner-approved 2026-11-11): TimeAverage / Total no longer integrate the span before an Uncertain first value
/// when there is no start bound. No fixture vector changes (every data set has a usable value at −10 s, before every
/// window, so no fixture interval lacks a start bound) — all 384 TimeAverage / Total vectors stay held to their own
/// output. The change is pinned instead over late-start variants of every data set against a baseline-derived oracle
/// (<see cref="Issue821_late_start_with_an_uncertain_first_sample_integrates_only_the_covered_part"/>) and by hand.
/// </para>
/// </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;
private const string TimeAverage = "AggregateFunction_TimeAverage";
private const string Total = "AggregateFunction_Total";
/// <summary>#821: fixture vectors changed — none (every data set has a usable value before the window; class remarks).</summary>
private const int Issue821FixtureExemptions = 0;
private const int Issue821Cut5Changed = 150;
private const int Issue821Cut25Changed = 142;
[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);
// #821: no TimeAverage / Total fixture vector is exempt — all 384 are held to their own baseline output above.
exemptions.Keys.Count(x => x.Aggregate is TimeAverage or Total).ShouldBe(Issue821FixtureExemptions);
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);
}
/// <summary>
/// #821 pin over late-start variants of every fixture data set (the fixture itself has none — see the class remarks):
/// each TimeAverage / Total vector's data set cut at <paramref name="cutSeconds"/> (no usable value before the data
/// starts, so no start bound) with its first remaining non-Bad sample made Uncertain, run through the application's
/// calculator under the vector's own direction, stepping, configuration and incremental draining.
/// <para>
/// The oracle is the untouched BASELINE, never the application's calculator: under TreatUncertainAsBad = false
/// the baseline treats an Uncertain value exactly like a Good one except for statuses, and with a Good first
/// value it already leaves the uncovered span out (the #821 control). So the expected value of each interval is
/// the baseline's on the same variant with that first sample Good, and the expected status, timestamps and bits
/// the baseline's on the variant itself (the region before the data is non-Good either way) — except an
/// interval the Good twin answers Bad (nothing covered: only the uncovered span ever fed it), which is the twin
/// whole. Stepped variables and TreatUncertainAsBad = true never reached the baseline's defect and must be the
/// baseline byte for byte.
/// </para>
/// </summary>
[Theory]
[InlineData(5, Issue821Cut5Changed)]
[InlineData(25, Issue821Cut25Changed)]
public void Issue821_late_start_with_an_uncertain_first_sample_integrates_only_the_covered_part(double cutSeconds, int expectedChanged)
{
var baseline = LoadBaseline();
var failures = new List<string>();
var changed = 0;
// Why the fixture needs no #821 exemption: every data set with a usable value has one before the window (−10 s), so
// no fixture interval lacks a start bound; the variants below remove it.
baseline.Data.Values.Where(d => d.Any(s => !StatusCode.IsBad(s.Status)))
.ShouldAllBe(d => d.Any(s => s.Seconds < 0 && !StatusCode.IsBad(s.Status)));
var vectors = baseline.Vectors.Where(v => v.Aggregate is TimeAverage or Total).ToList();
foreach (var vector in vectors)
{
var (variant, goodTwin) = LateStartVariant(baseline.Data[vector.DataSet], cutSeconds);
var asIs = Run(baseline, vector, rig: null, out var asIsError, variant, baselineAsIs: true);
var twin = Run(baseline, vector, rig: null, out var twinError, goodTwin, baselineAsIs: true);
var actual = Run(baseline, vector, rig: null, out var error, variant);
asIsError.ShouldBeNull(Describe(vector));
twinError.ShouldBeNull(Describe(vector));
twin.Count.ShouldBe(asIs.Count, Describe(vector));
Value[] expected = EffectiveTreatUncertainAsBad(vector) || vector.Stepped
? [.. asIs]
: [.. asIs.Zip(twin, (a, g) =>
{
g.SourceTicks.ShouldBe(a.SourceTicks, Describe(vector));
return StatusCode.IsBad(g.Status) ? g : a with { Type = g.Type, Payload = g.Payload };
})];
if (!expected.SequenceEqual(asIs)) changed++;
if (error is not null || !actual.SequenceEqual(expected))
failures.Add($"{Describe(vector)} cut {cutSeconds}: error {error}; expected {JsonSerializer.Serialize(expected)} actual {JsonSerializer.Serialize(actual.ToArray())}");
}
vectors.Count.ShouldBe(384);
failures.ShouldBeEmpty("Live TimeAverage / Total must leave the span before an Uncertain first value out (Part 13 'No Start Bound: Calculate Partial Interval', #821).");
changed.ShouldBe(expectedChanged, "the late-start variants the #821 override changes from the untouched baseline");
}
/// <summary>
/// #821 hand-computed pin, independent of every calculator: the "uncertain" data set from +5 (Uncertain 100 at 5,
/// Good 18 at 15, Bad at 25, Good 19 at 35, …), forward, sloped, [0,20). Covered [5,20): 100 → 18 over 10 s = 590,
/// then 18 → the bound at 20 (sloped 18 → 19 across the Bad sample: 18.25) over 5 s = 90.625 ⇒ Total 680.625,
/// TimeAverage 680.625 ÷ 15 = 45.375. The baseline added the ramp 0 → 100 over [0,5) (+250): 930.625 and 46.53125.
/// Uncertain either way (an Uncertain value is used, a Bad one skipped, the interval not covered).
/// </summary>
[Theory]
[InlineData(Total, 680.625, 930.625)]
[InlineData(TimeAverage, 45.375, 46.53125)]
public void Issue821_late_start_hand_computed(string aggregate, double part13, double baselineValue)
{
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 (variant, _) = LateStartVariant(baseline.Data["uncertain"], 5);
variant[0].Status.ShouldBe(0x40A40000u, "the data set's own Uncertain 100 at +5");
var output = Run(baseline, vector, rig: null, out var error, variant);
var asIs = Run(baseline, vector, rig: null, out _, variant, baselineAsIs: true);
error.ShouldBeNull();
double.Parse(output[0].Payload!, CultureInfo.InvariantCulture).ShouldBe(part13, 1e-9);
double.Parse(asIs[0].Payload!, CultureInfo.InvariantCulture).ShouldBe(baselineValue, 1e-9);
(output[0].Status & 0xFFFF0000u).ShouldBe(StatusCodes.UncertainDataSubNormal.Code);
output[0].Status.ShouldBe(asIs[0].Status);
output.Skip(1).ShouldBe(asIs.Skip(1), "only the late-start interval changes");
}
/// <summary>
/// A data set cut at <paramref name="cutSeconds"/> with its first non-Bad sample Uncertain (kept Uncertain if it already
/// is), and the same with that sample Good.
/// </summary>
private static (Sample[] Variant, Sample[] GoodTwin) LateStartVariant(Sample[] data, double cutSeconds)
{
var cut = data.Where(s => s.Seconds >= cutSeconds).OrderBy(s => s.Seconds).ToArray();
var first = Array.FindIndex(cut, s => !StatusCode.IsBad(s.Status));
if (first < 0) return (cut, cut);
var variant = cut.ToArray();
var good = cut.ToArray();
variant[first] = cut[first] with { Status = StatusCode.IsUncertain(cut[first].Status) ? cut[first].Status : 0x40A40000u };
good[first] = cut[first] with { Status = 0u };
return (variant, good);
}
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,
Sample[]? data = null, bool baselineAsIs = false)
{
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)
: baselineAsIs
? new BaselineAdapter(Aggregators.CreateStandardCalculator(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config, telemetry)!)
: 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 = (data ?? 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; } = []; }
/// <summary>The untouched baseline calculator behind the SDK 2 protocol (what live served before #809 / #821).</summary>
private sealed class BaselineAdapter(AggregateCalculator calculator) : Opc.Ua.Server.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;
}
}
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);
}