using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
///
/// Drives the application's versioned 1.5.378.182 Part 13 compatibility calculators — the same factories
/// the upgraded server registers for live aggregates 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
{
private static readonly ITelemetryContext Telemetry = DefaultTelemetry.Create(_ => { });
/// 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 = CompatibilityAggregateFactory.Create(id, start, end, ms, false, cfg, Telemetry);
// 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++)
{
if (!calc.TryGetProcessedValue(true, out var dv)) break;
results.Add(new Result(dv.WrappedValue.AsBoxedObject(Variant.BoxingBehavior.Legacy), dv.StatusCode.Code, (DateTime)dv.SourceTimestamp));
}
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(
v.Kind switch
{
HistorianValueKind.Empty => Variant.Null,
HistorianValueKind.Int64 => Variant.From(v.Integer),
HistorianValueKind.Boolean => Variant.From(v.Number != 0),
HistorianValueKind.String => v.Text is { } text ? Variant.From(text) : Variant.Null,
_ => Variant.From(v.Number),
},
status, t, 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"),
};
}