diff --git a/scripts/validation/opcua-aggregate-baseline/Program.cs b/scripts/validation/opcua-aggregate-baseline/Program.cs index 25eb9209..b7d53347 100644 --- a/scripts/validation/opcua-aggregate-baseline/Program.cs +++ b/scripts/validation/opcua-aggregate-baseline/Program.cs @@ -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(); +// 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 +{ + ["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(); + var accepted = new List(); 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; } +} diff --git a/scripts/validation/opcua-aggregate-baseline/README.md b/scripts/validation/opcua-aggregate-baseline/README.md new file mode 100644 index 00000000..1eb8c867 --- /dev/null +++ b/scripts/validation/opcua-aggregate-baseline/README.md @@ -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.