test: expand original SDK aggregate edge vectors

This commit is contained in:
Joseph Doherty
2026-11-10 10:54:02 -05:00
parent aac7c88417
commit 8c422b9ccc
2 changed files with 186 additions and 12 deletions
@@ -1,6 +1,7 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Opc.Ua;
using Opc.Ua.Server;
@@ -60,33 +61,137 @@ var names = new string[]
"AggregateFunction_VarianceSample",
};
var vectors = new List<Vector>();
// Preserve the original seven-dataset grid, input ordering and expected values exactly.
foreach (var name in names)
foreach (var dataset in data)
foreach (var reverse in new[] { false, true })
foreach (var stepped in new[] { false, true })
foreach (var uncertainAsBad in new[] { false, true })
foreach (var incremental in new[] { false, true })
Run(name, dataset.Key, reverse, stepped, uncertainAsBad, incremental);
var edges = new Dictionary<string, Sample[]>
{
["edge-sbyte"] = Typed("SByte", ["-128", "-127", "-1", "0", "1", "126", "127", "2"]),
["edge-byte"] = Typed("Byte", ["0", "1", "127", "128", "254", "255", "3", "2"]),
["edge-int16"] = Typed("Int16", ["-32768", "-32767", "-1", "0", "1", "32766", "32767", "2"]),
["edge-uint16"] = Typed("UInt16", ["0", "1", "32767", "32768", "65534", "65535", "3", "2"]),
["edge-int32"] = Typed("Int32", ["-2147483648", "-2147483647", "-1", "0", "1", "2147483646", "2147483647", "2"]),
["edge-uint32"] = Typed("UInt32", ["0", "1", "2147483647", "2147483648", "4294967294", "4294967295", "3", "2"]),
["edge-int64"] = Typed("Int64", ["-9223372036854775808", "-9007199254740993", "-1", "0", "1", "9007199254740993", "9223372036854775806", "9223372036854775807"]),
["edge-uint64"] = Typed("UInt64", ["0", "1", "9007199254740991", "9007199254740993", "9223372036854775807", "9223372036854775808", "18446744073709551614", "18446744073709551615"]),
["edge-float"] = Typed("Single", ["-3.4028235E+38", "1E-45", "-0", "1.5", "3.4028235E+38", "NaN", "Infinity", "-Infinity"]),
["edge-double"] = Typed("Double", ["-1.5", "-0", "0.5", "1.5", "NaN", "Infinity", "-Infinity", "1.7976931348623157E+308"]),
// Interpolative/Delta casts back to the earlier sample's type: fractional, negative,
// overflowing and nonfinite Double values must exercise the original SDK conversion.
["edge-mixed-conversion"] =
[
new(-10, "Byte", "0", StatusCodes.Good), new(0, "Byte", "1", StatusCodes.Good),
new(5, "Double", "2.5", StatusCodes.Good), new(15, "SByte", "-128", StatusCodes.Good),
new(25, "Double", "255.5", StatusCodes.Good), new(35, "Single", "3.4028235E+38", StatusCodes.Good),
new(50, "Double", "Infinity", StatusCodes.Good), new(80, "UInt64", "18446744073709551615", StatusCodes.Good),
],
};
foreach (var dataset in edges) data.Add(dataset.Key, dataset.Value);
foreach (var name in names)
foreach (var dataset in edges)
foreach (var reverse in new[] { false, true })
foreach (var incremental in new[] { false, true })
Run(name, dataset.Key, reverse, false, false, incremental, Settings(false));
// Ties include an identical source timestamp+picoseconds pair; input order is stable.
// Distinct server ticks and picoseconds must survive raw-result selection independently.
data.Add("edge-duplicate-picoseconds",
[
Pico(-10, "1", 3, 19, 7), Pico(0, "2", 1, 23, 11), Pico(0, "3", 9999, 29, 13),
Pico(5, "4", 7, 31, 17), Pico(5, "5", 7, 37, 19), Pico(20, "6", 2, 41, 23),
Pico(20, "7", 9000, 43, 29), Pico(40, "8", 5, 47, 31), Pico(80, "9", 11, 53, 37),
]);
foreach (var name in names)
foreach (var reverse in new[] { false, true })
foreach (var incremental in new[] { false, true })
Run(name, "edge-duplicate-picoseconds", reverse, false, false, incremental, Settings(false));
var configurations = new[]
{
new AggregateSettings(false, 0, 0, false, false),
new AggregateSettings(false, 1, 99, true, false),
new AggregateSettings(true, 50, 50, false, false),
new AggregateSettings(true, 100, 0, true, false),
};
var configuredNames = new[]
{
"AggregateFunction_Interpolative", "AggregateFunction_TimeAverage", "AggregateFunction_TimeAverage2",
"AggregateFunction_Total", "AggregateFunction_Total2", "AggregateFunction_PercentGood", "AggregateFunction_PercentBad",
};
foreach (var name in configuredNames)
foreach (var dataset in new[] { "uncertain", "sparse" })
foreach (var config in configurations)
foreach (var reverse in new[] { false, true })
foreach (var incremental in new[] { false, true })
Run(name, dataset, reverse, false, config.TreatUncertainAsBad, incremental, config);
var assembly = typeof(Aggregators).Assembly.Location;
var assemblySha256 = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(assembly)));
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(args[0]))!);
// One vector per line keeps golden changes reviewable without pretty-printing every value.
using (var writer = new StreamWriter(new FileStream(args[0], FileMode.CreateNew, FileAccess.Write)))
{
writer.WriteLine("{");
writer.WriteLine($" \"sdk\":\"1.5.378.182\",\"assemblySha256\":\"{assemblySha256}\",");
writer.WriteLine($" \"epochTicks\":{epoch.Ticks},\"intervalMilliseconds\":20000,");
writer.WriteLine($" \"data\":{JsonSerializer.Serialize(data)},");
writer.WriteLine(" \"vectors\":[");
for (var i = 0; i < vectors.Count; i++)
writer.WriteLine(" " + JsonSerializer.Serialize(vectors[i]) + (i + 1 < vectors.Count ? "," : ""));
writer.WriteLine(" ]");
writer.WriteLine("}");
}
Console.WriteLine($"Generated {vectors.Count} vectors ({vectors.Count - 4144} focused additions) for {names.Length} aggregate IDs; baseline assembly SHA256 {assemblySha256}.");
Sample[] Typed(string type, string[] values) => offsets.Zip(values, (s, value) => new Sample(s, type, value, StatusCodes.Good)).ToArray();
static Sample Pico(double seconds, string value, ushort source, ushort server, long serverOffset) =>
new(seconds, "Int32", value, StatusCodes.Good)
{ SourcePicoseconds = source, ServerPicoseconds = server, ServerOffsetTicks = serverOffset };
static AggregateSettings Settings(bool uncertainAsBad) => new(uncertainAsBad, 100, 100, false, false);
void Run(string name, string dataset, bool reverse, bool stepped, bool uncertainAsBad, bool incremental,
AggregateSettings? settings = null)
{
var id = (NodeId)typeof(ObjectIds).GetField(name)!.GetValue(null)!;
var start = epoch.AddSeconds(reverse ? 70 : 0);
var end = epoch.AddSeconds(reverse ? 0 : 70);
var config = new AggregateConfiguration { TreatUncertainAsBad = uncertainAsBad, PercentDataBad = 100, PercentDataGood = 100, UseSlopedExtrapolation = false, UseServerCapabilitiesDefaults = false };
var cfg = settings ?? Settings(uncertainAsBad);
var config = new AggregateConfiguration
{
TreatUncertainAsBad = cfg.TreatUncertainAsBad, PercentDataBad = cfg.PercentDataBad,
PercentDataGood = cfg.PercentDataGood, UseSlopedExtrapolation = cfg.UseSlopedExtrapolation,
UseServerCapabilitiesDefaults = cfg.UseServerCapabilitiesDefaults,
};
var calculator = Aggregators.CreateStandardCalculator(id, start, end, 20_000, stepped, config, telemetry);
var output = new List<Value>();
var accepted = new List<bool>();
string? error = null;
try
{
var samples = dataset.Value.OrderBy(x => x.Seconds);
var samples = data[dataset].OrderBy(x => x.Seconds);
foreach (var sample in reverse ? samples.Reverse() : samples)
{
var time = epoch.AddSeconds(sample.Seconds);
calculator.QueueRawValue(new DataValue(new Variant(Payload(sample)!), sample.Status, time, time));
var value = new DataValue(new Variant(Payload(sample)!), sample.Status, time,
time.AddTicks(sample.ServerOffsetTicks ?? 0))
{
SourcePicoseconds = sample.SourcePicoseconds ?? 0,
ServerPicoseconds = sample.ServerPicoseconds ?? 0,
};
accepted.Add(calculator.QueueRawValue(value));
if (incremental) Drain(false);
}
Drain(true);
}
catch (Exception ex) { error = ex.GetType().FullName; }
vectors.Add(new(name, dataset.Key, reverse, stepped, uncertainAsBad, incremental, output.ToArray(), error));
vectors.Add(new(name, dataset, reverse, stepped, uncertainAsBad, incremental, output.ToArray(), error)
{ Configuration = settings, QueueAccepted = settings is null ? null : accepted.ToArray() });
void Drain(bool partial)
{
for (var guard = 0; guard < 100; guard++)
@@ -98,16 +203,19 @@ foreach (var incremental in new[] { false, true })
throw new InvalidOperationException("Baseline calculator did not terminate.");
}
}
var assembly = typeof(Aggregators).Assembly.Location;
var baseline = new { sdk = "1.5.378.182", assemblySha256 = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(assembly))), epochTicks = epoch.Ticks, intervalMilliseconds = 20_000, data, vectors };
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(args[0]))!);
File.WriteAllText(args[0], JsonSerializer.Serialize(baseline, new JsonSerializerOptions { WriteIndented = true }) + "\n");
Console.WriteLine($"Generated {vectors.Count} vectors for {names.Length} aggregate IDs; baseline assembly SHA256 {baseline.assemblySha256}.");
static object? Payload(Sample sample) => sample.Type switch
{
"Double" => double.Parse(sample.Payload!, CultureInfo.InvariantCulture),
"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),
"Int64" => long.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),
"Boolean" => bool.Parse(sample.Payload!),
_ => null,
};
@@ -119,6 +227,16 @@ static string? Format(object? value) => value switch
IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture),
_ => value.ToString(),
};
record Sample(double Seconds, string Type, string? Payload, uint Status);
record Sample(double Seconds, string Type, string? Payload, uint Status)
{
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ushort? SourcePicoseconds { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public ushort? ServerPicoseconds { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public long? ServerOffsetTicks { get; init; }
}
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);
record AggregateSettings(bool TreatUncertainAsBad, byte PercentDataBad, byte PercentDataGood, bool UseSlopedExtrapolation, bool UseServerCapabilitiesDefaults);
record Vector(string Aggregate, string DataSet, bool Reverse, bool Stepped, bool UncertainAsBad, bool Incremental, Value[] Output, string? Exception)
{
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public AggregateSettings? Configuration { get; init; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public bool[]? QueueAccepted { get; init; }
}
@@ -0,0 +1,56 @@
# Original SDK aggregate oracle
This standalone .NET 10 process generates aggregate expectations from the published
`OPCFoundation.NetStandard.Opc.Ua.Server` **1.5.378.182** package. Its local build and
package properties isolate it from the application's SDK 2 graph. It is outside the
solution; never add its baseline SDK assemblies to a candidate test or production process.
Expectations must come from this original SDK, never from the compatibility calculators.
Run from the repository root with a fresh output path:
```bash
dotnet run --project scripts/validation/opcua-aggregate-baseline/Baseline.csproj \
-c Release -- /tmp/opcua-original-aggregates-new.json
```
An existing output path is refused. The JSON records the original aggregate assembly's
SHA-256, epoch and processing interval. The expected assembly hash is
`d8d9706dea577858325730cd89896826ed82f7bfcf9503ccd0260311663a69f4`.
Output uses compact metadata and data, followed by one vector per line for reviewable diffs.
The first **4,144 vectors** retain the original seven datasets and complete grid over 37
aggregate IDs, forward/reverse reads, stepped/sloped interpolation, uncertain-as-bad and
incremental/final processing. Their parsed objects and the seven original input datasets
are unchanged. The request window is epoch +0 through +70 seconds, reversed for reverse
vectors, with a 20,000 ms processing interval.
The next **2,000 focused vectors** avoid expanding every dataset into the complete grid:
| Cases | Coverage |
|---:|---|
| 1,628 | Eleven numeric datasets across all 37 IDs, both directions and both processing modes: all eight integer widths, Single, Double and mixed-type conversion. Includes narrow limits, Int64/UInt64 values around 2^53 and 2^63 and their full ranges, fractional interpolation, signed zero, nonfinite values and overflow. |
| 148 | Duplicate timestamp ties across all 37 IDs, both directions and processing modes; nonzero source/server picoseconds, independent server timestamp offsets and an exact source timestamp/picosecond tie. |
| 224 | Interpolative, TimeAverage/2, Total/2 and PercentGood/Bad over uncertain and sparse data, both directions and processing modes. Four explicit profiles cover PercentDataBad/Good 0/0, 1/99, 50/50 and 100/0, both uncertainty policies and sloped extrapolation. |
New vectors use sloped interpolation (`Stepped=false`); their `Configuration` object records
all five effective aggregate settings. `UseServerCapabilitiesDefaults=false` records the
resolved configuration passed directly to the calculator. Existing vectors retain their
original fields: omitted configuration means the vector's uncertainty policy, 100/100
thresholds, and both extrapolation and server-capability defaults disabled.
New samples may carry `SourcePicoseconds`, `ServerPicoseconds` and `ServerOffsetTicks`;
absent fields mean zero. The server timestamp is epoch + `Seconds` + `ServerOffsetTicks`.
Samples are sorted stably by `Seconds`, then the whole sequence is reversed for a reverse
read, preserving a fully specified tie order. `QueueAccepted` records each attempted
queue result through the point of any exception.
Each output retains its CLR type, round-trip value spelling, complete numeric status,
source/server timestamp ticks, source/server picoseconds, and whether it came from an
incremental (`PartialRequested=false`) or final (`true`) drain. `Exception` retains the
exact original exception type and any outputs already emitted. NaN, infinities and signed
zero are stored as strings so JSON does not change their numeric representation.
When importing fresh evidence into the candidate fixture, first compare the original
4,144 parsed vectors, seven datasets and metadata with the previous oracle. Investigate
candidate mismatches; do not regenerate expectations from candidate output or adjust
original exception types to make the comparison pass.