bd6d3fc2c3
The 1.5.378.182 StatusAggregateCalculator (vendored as the application's compatibility baseline) counts Uncertain regions as Good time under TreatUncertainAsBad = false; 1.5.378.106 counted Good regions only, as Part 13 §5.4.3.31/§5.4.3.33 and Mantis 0006166 require. Our evaluator follows Part 13, so the oracle moved, not the server: 10 SdkAggregateParityTests failed. SdkAggregateOracle.Run now answers PercentGood through a subclass of the baseline StatusAggregateCalculator that counts Good regions only (same bounds, regions, status/Partial bits and configuration as PercentBad). RunSdkAsIs keeps the untouched baseline. Rejected the proposed 100 - PercentBad under TreatUncertainAsBad = true: that flag also changes the region machinery (the end-of-data 1 ms rule), so it computes over a different total; pinned by a test. New tests pin the divergence on hand-computed G/U/B/G (50 vs baseline 75) and all-Uncertain (0/0 vs baseline 100) intervals, forward and reverse, plus a data-starts-inside interval where oracle == baseline. Sql.Common.Tests 265/275 -> 280/280.
237 lines
14 KiB
C#
237 lines
14 KiB
C#
using Opc.Ua;
|
||
using Opc.Ua.Server;
|
||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates;
|
||
using BaselineAggregateCalculator = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.AggregateCalculator;
|
||
using BaselineStatusAggregateCalculator = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.StatusAggregateCalculator;
|
||
using Shouldly;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
||
|
||
/// <summary>
|
||
/// 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
|
||
/// <see cref="AggregateConfiguration"/> (TreatUncertainAsBad = false, PercentDataBad = PercentDataGood = 100,
|
||
/// UseSlopedExtrapolation = false), sloped interpolation, source timestamps.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>Shared (source-linked) by <c>Sql.Common.Tests</c> and both SQL backends' integration suites.</para>
|
||
/// <para>
|
||
/// <b>PercentGood is the one aggregate not taken as-is (#796).</b> The 1.5.378.182 baseline changed
|
||
/// <c>StatusAggregateCalculator.ComputeDurationGoodBad</c> (1.5.378.106 and earlier counted Good regions only):
|
||
/// with TreatUncertainAsBad = false it counts Uncertain regions as <b>Good</b> time (as Bad when true). Part 13
|
||
/// keeps Uncertain out of both (§5.4.3.31 DurationGood counts Good regions; §5.4.3.33 PercentGood =
|
||
/// DurationGood ÷ interval; Mantis 0006166, closed "no change required": an all-Uncertain interval is
|
||
/// PercentGood 0 <i>and</i> PercentBad 0), and so does ours. <see cref="Run"/> therefore answers PercentGood
|
||
/// through <see cref="Part13PercentGoodCalculator"/>: the SAME baseline calculator, region machinery (simple
|
||
/// bounds, end-of-data 1 ms region, status and Partial bits) and server-default configuration, counting only
|
||
/// Good regions — exactly what the pre-.182 oracle computed. PercentBad still counts Bad regions only (unchanged
|
||
/// by .182) and is compared as-is. <see cref="RunSdkAsIs"/> runs the baseline untouched, for the test that pins
|
||
/// the divergence.
|
||
/// </para>
|
||
/// <para>
|
||
/// Not <c>100 − PercentBad</c> under TreatUncertainAsBad = true: that flag also changes the region machinery
|
||
/// (<c>IsGood</c> drives the end-of-data 1 ms rule and the simple bounds), so after an Uncertain last sample the
|
||
/// region runs to the interval end instead of 1 ms and the total differs from the one PercentBad is computed
|
||
/// over. Pinned by <c>SdkAggregateParityTests</c>.
|
||
/// </para>
|
||
/// </remarks>
|
||
public static class SdkAggregateOracle
|
||
{
|
||
private static readonly ITelemetryContext Telemetry = DefaultTelemetry.Create(_ => { });
|
||
/// <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 Part 13 oracle for <paramref name="aggregate"/> over <paramref name="samples"/>: the baseline calculator,
|
||
/// except PercentGood, which counts Good regions only (see the remarks, #796).
|
||
/// </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 oracle'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) =>
|
||
aggregate == HistoryAggregateType.PercentGood
|
||
? RunCore(new LegacyCalculatorAdapter(new Part13PercentGoodCalculator(ObjectIds.AggregateFunction_PercentGood,
|
||
start, end, interval.TotalMilliseconds, false, DefaultConfiguration, Telemetry)), start, end, samples)
|
||
: RunSdkAsIs(aggregate, start, end, interval, samples);
|
||
|
||
/// <summary>
|
||
/// Runs the baseline (1.5.378.182) calculator for <paramref name="aggregate"/> exactly as it computes it, under
|
||
/// <see cref="DefaultConfiguration"/> — including its PercentGood rule (Uncertain counted as Good) that
|
||
/// <see cref="Run"/> deliberately does not use (#796).
|
||
/// </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>
|
||
/// <param name="configuration">The configuration (default <see cref="DefaultConfiguration"/>).</param>
|
||
/// <returns>The calculator's processed values, in the order it returns them.</returns>
|
||
public static IReadOnlyList<Result> RunSdkAsIs(HistoryAggregateType aggregate, DateTime start, DateTime end,
|
||
TimeSpan interval, IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples,
|
||
AggregateConfiguration? configuration = null) =>
|
||
RunCore(CompatibilityAggregateFactory.Create(AggregateId(aggregate), start, end, interval.TotalMilliseconds, false,
|
||
configuration ?? DefaultConfiguration, Telemetry), start, end, samples);
|
||
|
||
private static IReadOnlyList<Result> RunCore(IAggregateCalculator calc, DateTime start, DateTime end,
|
||
IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples)
|
||
{
|
||
|
||
// 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++)
|
||
{
|
||
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;
|
||
}
|
||
|
||
/// <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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// The baseline <see cref="BaselineStatusAggregateCalculator"/> with PercentGood counting <b>Good</b> regions only — Part 13
|
||
/// §5.4.3.31/§5.4.3.33, and the 1.5.378.106 rule. Everything else (bounds, regions, Partial, status) is the
|
||
/// baseline's own code, run under the same configuration as PercentBad.
|
||
/// </summary>
|
||
private sealed class Part13PercentGoodCalculator(NodeId aggregateId, DateTime startTime, DateTime endTime,
|
||
double processingInterval, bool stepped, AggregateConfiguration configuration, ITelemetryContext telemetry)
|
||
: BaselineStatusAggregateCalculator(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
|
||
{
|
||
protected override LegacyDataValue ComputeValue(TimeSlice slice)
|
||
{
|
||
var values = GetValuesWithSimpleBounds(slice);
|
||
if (values == null || values.Count == 0) return GetNoDataValue(slice);
|
||
|
||
var regions = GetRegionsInValueSet(values, false, true);
|
||
double good = 0, total = 0;
|
||
foreach (var region in regions)
|
||
{
|
||
total += region.Duration;
|
||
if (StatusCode.IsGood(region.StatusCode)) good += region.Duration;
|
||
}
|
||
|
||
var value = new LegacyDataValue
|
||
{
|
||
WrappedValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.SdkValueBoundary.FromClr(good / total * 100),
|
||
SourceTimestamp = GetTimestamp(slice),
|
||
ServerTimestamp = GetTimestamp(slice),
|
||
};
|
||
value.StatusCode = value.StatusCode.WithAggregateBits(AggregateBits.Calculated);
|
||
return value;
|
||
}
|
||
}
|
||
|
||
/// <summary>The <see cref="CompatibilityAggregateFactory"/> adapter, for a calculator built here.</summary>
|
||
private sealed class LegacyCalculatorAdapter(BaselineAggregateCalculator calculator) : IAggregateCalculator
|
||
{
|
||
public NodeId AggregateId => calculator.AggregateId;
|
||
public bool QueueRawValue(DataValue value) => calculator.QueueRawValue(LegacyDataValue.FromSdk(value));
|
||
public bool HasEndTimePassed(DateTimeUtc currentTime) => calculator.HasEndTimePassed((DateTime)currentTime);
|
||
public bool TryGetProcessedValue(bool returnPartial, out DataValue value)
|
||
{
|
||
var processed = calculator.GetProcessedValue(returnPartial);
|
||
value = processed is null ? default : processed.ToSdk();
|
||
return processed is not null;
|
||
}
|
||
}
|
||
|
||
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"),
|
||
};
|
||
}
|