using Opc.Ua; using Opc.Ua.Server; using Shouldly; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance; /// /// Drives the pinned OPC UA SDK's own Part 13 aggregate calculators (Opc.Ua.Server — the classes the SDK's /// AggregateManager registers) in-process over a sample set, so our processed reads can be compared with the /// reference implementation bucket by bucket (#677). The calculators run under the server's default /// (TreatUncertainAsBad = false, PercentDataBad = PercentDataGood = 100, /// UseSlopedExtrapolation = false), sloped interpolation, source timestamps. /// /// /// Shared (source-linked) by Sql.Common.Tests and both SQL backends' integration suites. /// public static class SdkAggregateOracle { /// One processed value: value (double / int / null), full status code, timestamp. public readonly record struct Result(object? Value, uint StatusCode, DateTime Timestamp); /// The server-default aggregate configuration (what AggregateManager.GetDefaultConfiguration returns). public static AggregateConfiguration DefaultConfiguration => new() { TreatUncertainAsBad = false, PercentDataBad = 100, PercentDataGood = 100, UseSlopedExtrapolation = false, UseServerCapabilitiesDefaults = false, }; /// Runs the SDK calculator for over . /// The aggregate. /// Request start (later than for a reverse read). /// Request end. /// Processing interval. /// Every stored sample of the tag, any order (sorted here). /// The SDK's processed values, in the order it returns them. public static IReadOnlyList Run(HistoryAggregateType aggregate, DateTime start, DateTime end, TimeSpan interval, IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples) { var id = AggregateId(aggregate); var ms = interval.TotalMilliseconds; var cfg = DefaultConfiguration; IAggregateCalculator calc = aggregate switch { HistoryAggregateType.Average or HistoryAggregateType.TimeAverage or HistoryAggregateType.TimeAverage2 or HistoryAggregateType.Total or HistoryAggregateType.Total2 => new AverageAggregateCalculator(id, start, end, ms, false, cfg, null), HistoryAggregateType.Minimum or HistoryAggregateType.Maximum or HistoryAggregateType.Range or HistoryAggregateType.MinimumActualTime or HistoryAggregateType.MaximumActualTime => new MinMaxAggregateCalculator(id, start, end, ms, false, cfg, null), HistoryAggregateType.Count => new CountAggregateCalculator(id, start, end, ms, false, cfg, null), HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad => new StatusAggregateCalculator(id, start, end, ms, false, cfg, null), HistoryAggregateType.Start or HistoryAggregateType.End or HistoryAggregateType.Delta => new StartEndAggregateCalculator(id, start, end, ms, false, cfg, null), HistoryAggregateType.Interpolative => new AggregateCalculator(id, start, end, ms, false, cfg, null), _ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "no SDK calculator mapped"), }; // The SDK expects values in the direction of the read: ascending forward, descending reverse. var ordered = samples.OrderBy(s => s.Time).ToList(); if (end < start) ordered.Reverse(); foreach (var s in ordered) calc.QueueRawValue(ToDataValue(s.Time, s.Value, s.Status)); var results = new List(); for (var guard = 0; guard < 100_000; guard++) { var dv = calc.GetProcessedValue(true); if (dv is null) break; results.Add(new Result(dv.Value, dv.StatusCode.Code, DateTime.SpecifyKind(dv.SourceTimestamp, DateTimeKind.Utc))); } return results; } /// /// Asserts our snapshots equal the SDK's: same count, same timestamps, same status code bits (severity + sub-code) /// and Partial bit (and optionally the Calculated / Interpolated bits), same value (doubles to 1e-9; ints exactly; /// null on a Bad status). A failure lists every differing interval side by side. /// /// Our processed values. /// The SDK's. /// Context for failure messages. /// Also compare the Calculated / Interpolated aggregate bits. /// Compare the severity + sub-code (false only for a documented status divergence). /// Compare the values (false only for a documented value divergence). /// Compare the timestamps (false only for a documented timestamp divergence). /// Compare the Partial bit (false only for a documented divergence). public static void ShouldMatch(IReadOnlyList ours, IReadOnlyList sdk, string label, bool compareCalculatedInterpolatedBits = true, bool compareCodeBits = true, bool compareValues = true, bool compareTimestamps = true, bool comparePartialBit = true) { var problems = new List(); if (ours.Count != sdk.Count) problems.Add($"interval count: ours {ours.Count}, sdk {sdk.Count}"); for (var i = 0; i < Math.Min(ours.Count, sdk.Count); i++) { var o = ours[i]; var s = sdk[i]; var bitMask = (comparePartialBit ? 0x4u : 0u) | (compareCodeBits ? 0xFFFF0000u : 0u) | (compareCalculatedInterpolatedBits ? 0x3u : 0u); var same = (!compareTimestamps || o.SourceTimestampUtc == s.Timestamp) && (o.StatusCode & bitMask) == (s.StatusCode & bitMask); if (!compareValues || (!compareCodeBits && StatusCode.IsBad(s.StatusCode))) { // Values not compared (or the SDK answered Bad under a status this comparison does not check). } else if (same && !StatusCode.IsBad(s.StatusCode)) { same = s.Value switch { null => o.Value is null, int n => o.Value is not null && Convert.ToInt32(o.Value) == n, _ => o.Value is not null && Math.Abs(Convert.ToDouble(o.Value) - Convert.ToDouble(s.Value)) <= 1e-9, }; } else if (same && compareCodeBits) same = o.Value is null; if (!same) problems.Add($"[{i}] ours {o.SourceTimestampUtc:HH:mm:ss.fffffff} {o.Value ?? "null"} 0x{o.StatusCode:X8}" + $" | sdk {s.Timestamp:HH:mm:ss.fffffff} {s.Value ?? "null"} 0x{s.StatusCode:X8}"); } problems.ShouldBeEmpty($"{label} differs from the SDK:\n" + string.Join("\n", problems)); } private static DataValue ToDataValue(DateTime t, HistorianValue v, uint status) => new() { WrappedValue = v.Kind switch { HistorianValueKind.Empty => Variant.Null, HistorianValueKind.Int64 => new Variant(v.Integer), HistorianValueKind.Boolean => new Variant(v.Number != 0), HistorianValueKind.String => new Variant(v.Text), _ => new Variant(v.Number), }, StatusCode = status, SourceTimestamp = t, ServerTimestamp = t, }; private static NodeId AggregateId(HistoryAggregateType a) => a switch { HistoryAggregateType.Average => ObjectIds.AggregateFunction_Average, HistoryAggregateType.Minimum => ObjectIds.AggregateFunction_Minimum, HistoryAggregateType.Maximum => ObjectIds.AggregateFunction_Maximum, HistoryAggregateType.Range => ObjectIds.AggregateFunction_Range, HistoryAggregateType.MinimumActualTime => ObjectIds.AggregateFunction_MinimumActualTime, HistoryAggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime, HistoryAggregateType.Count => ObjectIds.AggregateFunction_Count, HistoryAggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood, HistoryAggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad, HistoryAggregateType.Start => ObjectIds.AggregateFunction_Start, HistoryAggregateType.End => ObjectIds.AggregateFunction_End, HistoryAggregateType.Delta => ObjectIds.AggregateFunction_Delta, HistoryAggregateType.TimeAverage => ObjectIds.AggregateFunction_TimeAverage, HistoryAggregateType.TimeAverage2 => ObjectIds.AggregateFunction_TimeAverage2, HistoryAggregateType.Total => ObjectIds.AggregateFunction_Total, HistoryAggregateType.Total2 => ObjectIds.AggregateFunction_Total2, HistoryAggregateType.Interpolative => ObjectIds.AggregateFunction_Interpolative, _ => throw new ArgumentOutOfRangeException(nameof(a), a, "no SDK aggregate id mapped"), }; }