ed05d50795
The frozen 1.5.378.182 StatusAggregateCalculator counts Uncertain regions as Good under TreatUncertainAsBad=false, so live AggregateFilter PercentGood / DurationGood disagreed with HistoryRead (Part 13 §5.4.3.31/§5.4.3.33, Mantis 0006166) whenever Uncertain data was present. Owner-approved 2026-11-11. - New app-owned GoodOnlyStatusAggregateCalculator subclasses the baseline and replaces only the PercentGood/DurationGood computation (Good regions only); CompatibilityAggregateFactory routes exactly those two IDs to it. The copied SDK files are untouched; the other 35 aggregates are byte-identical. - Golden test: the 64 changed vectors (38 PercentGood + 26 DurationGood) are exempted exactly — those whose baseline output differs from the same case's baseline TreatUncertainAsBad=true twin — and pinned to that twin (baseline evidence, not regenerated from the implementation); plus a full Good-only pin over all 352 PercentGood/DurationGood vectors and a hand-computed case. - Live-vs-history parity over Good/Uncertain/Bad mixes (no masks), with a discrimination check against the pre-#809 baseline; the booted-runtime facts test now expects 8000 ms / 40 %. - SdkAggregateOracle: Run/RunLive = the registered factory; RunSdkAsIs = the untouched baseline; its Part13GoodCalculator duplicate is removed. - README (recorded decision), ServerAggregateConfiguration remarks, docs/Historian.md and .claude/rules/historian.md updated. Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
223 lines
14 KiB
C#
223 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 BaselineAggregators = ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates.Aggregators;
|
||
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 / DurationGood (#796, #820, #809).</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. Since #809 the LIVE calculators follow Part 13 too:
|
||
/// <c>CompatibilityAggregateFactory</c> serves those two through the application's
|
||
/// <c>GoodOnlyStatusAggregateCalculator</c> (the same baseline region machinery — simple bounds, end-of-data 1 ms
|
||
/// region, status and Partial bits — counting only Good regions), so <see cref="Run"/> / <see cref="RunLive"/> are
|
||
/// exactly what a live AggregateFilter computes, for every aggregate. PercentBad / DurationBad count Bad regions only
|
||
/// (unchanged by .182). <see cref="RunSdkAsIs"/> runs the untouched baseline (what live served before #809), for the
|
||
/// tests that pin that 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 live calculator
|
||
/// (<see cref="RunLive"/>) — the baseline, except PercentGood / DurationGood, which count Good regions only (see the
|
||
/// remarks, #796/#820/#809).
|
||
/// </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) =>
|
||
RunLive(aggregate, start, end, interval, samples);
|
||
|
||
/// <summary>
|
||
/// Runs exactly the calculator the server registers for live AggregateFilter monitored items
|
||
/// (<c>CompatibilityAggregateFactory.Create</c>, the #809 Good-only PercentGood / DurationGood included).
|
||
/// </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> RunLive(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);
|
||
|
||
/// <summary>
|
||
/// Runs the untouched baseline (1.5.378.182) calculator for <paramref name="aggregate"/> exactly as it computes it,
|
||
/// under <see cref="DefaultConfiguration"/> — including its PercentGood / DurationGood rule (Uncertain counted as
|
||
/// Good) that neither HistoryRead nor, since #809, the live calculators use.
|
||
/// </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(new LegacyCalculatorAdapter(BaselineAggregators.CreateStandardCalculator(AggregateId(aggregate), start, end,
|
||
interval.TotalMilliseconds, false, configuration ?? DefaultConfiguration, Telemetry)
|
||
?? throw new ArgumentOutOfRangeException(nameof(aggregate))), 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 <see cref="CompatibilityAggregateFactory"/> adapter, for a baseline 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.DurationGood => ObjectIds.AggregateFunction_DurationGood,
|
||
HistoryAggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad,
|
||
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"),
|
||
};
|
||
}
|