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;
///
/// 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.
///
/// PercentGood is the one aggregate not taken as-is (#796). The 1.5.378.182 baseline changed
/// StatusAggregateCalculator.ComputeDurationGoodBad (1.5.378.106 and earlier counted Good regions only):
/// with TreatUncertainAsBad = false it counts Uncertain regions as Good 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 and PercentBad 0), and so does ours. therefore answers PercentGood
/// through : 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. runs the baseline untouched, for the test that pins
/// the divergence.
///
///
/// Not 100 − PercentBad under TreatUncertainAsBad = true: that flag also changes the region machinery
/// (IsGood 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 SdkAggregateParityTests.
///
///
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 Part 13 oracle for over : the baseline calculator,
/// except PercentGood, which counts Good regions only (see the remarks, #796).
///
/// 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 oracle'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) =>
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);
///
/// Runs the baseline (1.5.378.182) calculator for exactly as it computes it, under
/// — including its PercentGood rule (Uncertain counted as Good) that
/// deliberately does not use (#796).
///
/// 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 configuration (default ).
/// The calculator's processed values, in the order it returns them.
public static IReadOnlyList 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 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();
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));
}
///
/// The baseline with PercentGood counting Good 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.
///
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;
}
}
/// The adapter, for a calculator built here.
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"),
};
}