7349260231
F1: the SDK applies its m_startOfData Partial rule to forward reads only; ours now does too (AggregateEvaluator.EvaluateAll + TimeWeightedAggregator). New reverse late-start parity tests through the SDK oracle (unit) and real SQL (conformance, both backends). F2: AggregateEvaluator.NeedsEarliestSample — the earliest-sample query runs only for forward reads of aggregates other than Average, and is bounded by the window end (SqlServer < @end, QuestDB ts < $2). F3: per-sample SDK parity extended to Range, Start, End, Delta, Min/Max ActualTime and PercentGood/Bad (oracle maps StatusAggregateCalculator); pre-existing definitional differences masked and marked #680. Docs: Historian.md Partial/SDK-parity sections, CLAUDE.md bullet.
163 lines
9.4 KiB
C#
163 lines
9.4 KiB
C#
using Opc.Ua;
|
|
using Opc.Ua.Server;
|
|
using Shouldly;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
|
|
|
/// <summary>
|
|
/// Drives the pinned OPC UA SDK's own Part 13 aggregate calculators (<c>Opc.Ua.Server</c> — the classes the SDK's
|
|
/// <c>AggregateManager</c> 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
|
|
/// <see cref="AggregateConfiguration"/> (TreatUncertainAsBad = false, PercentDataBad = PercentDataGood = 100,
|
|
/// UseSlopedExtrapolation = false), sloped interpolation, source timestamps.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Shared (source-linked) by <c>Sql.Common.Tests</c> and both SQL backends' integration suites.
|
|
/// </remarks>
|
|
public static class SdkAggregateOracle
|
|
{
|
|
/// <summary>One processed value: value (double / int / null), full status code, timestamp.</summary>
|
|
public readonly record struct Result(object? Value, uint StatusCode, DateTime Timestamp);
|
|
|
|
/// <summary>The server-default aggregate configuration (what <c>AggregateManager.GetDefaultConfiguration</c> returns).</summary>
|
|
public static AggregateConfiguration DefaultConfiguration => new()
|
|
{
|
|
TreatUncertainAsBad = false,
|
|
PercentDataBad = 100,
|
|
PercentDataGood = 100,
|
|
UseSlopedExtrapolation = false,
|
|
UseServerCapabilitiesDefaults = false,
|
|
};
|
|
|
|
/// <summary>Runs the SDK calculator for <paramref name="aggregate"/> over <paramref name="samples"/>.</summary>
|
|
/// <param name="aggregate">The aggregate.</param>
|
|
/// <param name="start">Request start (later than <paramref name="end"/> for a reverse read).</param>
|
|
/// <param name="end">Request end.</param>
|
|
/// <param name="interval">Processing interval.</param>
|
|
/// <param name="samples">Every stored sample of the tag, any order (sorted here).</param>
|
|
/// <returns>The SDK's processed values, in the order it returns them.</returns>
|
|
public static IReadOnlyList<Result> 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<Result>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
/// <param name="ours">Our processed values.</param>
|
|
/// <param name="sdk">The SDK's.</param>
|
|
/// <param name="label">Context for failure messages.</param>
|
|
/// <param name="compareCalculatedInterpolatedBits">Also compare the Calculated / Interpolated aggregate bits.</param>
|
|
/// <param name="compareCodeBits">Compare the severity + sub-code (false only for a documented status divergence).</param>
|
|
/// <param name="compareValues">Compare the values (false only for a documented value divergence).</param>
|
|
/// <param name="compareTimestamps">Compare the timestamps (false only for a documented timestamp divergence).</param>
|
|
/// <param name="comparePartialBit">Compare the Partial bit (false only for a documented divergence).</param>
|
|
public static void ShouldMatch(IReadOnlyList<DataValueSnapshot> ours, IReadOnlyList<Result> sdk, string label,
|
|
bool compareCalculatedInterpolatedBits = true, bool compareCodeBits = true, bool compareValues = true,
|
|
bool compareTimestamps = true, bool comparePartialBit = true)
|
|
{
|
|
var problems = new List<string>();
|
|
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"),
|
|
};
|
|
}
|