b61975f7d7
TimeAverage/Total: Uncertain when a Bad value is skipped, an Uncertain value is used, the value is extrapolated or the interval is not covered; an interval wholly inside an outage bridges (Uncertain); Total integrates only the covered part. TimeAverage2/Total2 stop at the end of the data (Partial, then BadNoData). PercentGood/PercentBad become duration-based and move to the time-weighted family (now subject to MaxTimeWeightedRawRows). Reverse reads set Partial on the interval holding the latest sample when nothing follows (SDK m_startOfData). Parity tests gain end-of-data and outage datasets; pre-existing #680 masks removed.
328 lines
20 KiB
C#
328 lines
20 KiB
C#
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
|
||
using static ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.Fixture;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
|
||
|
||
/// <summary>
|
||
/// #677: our processed reads against the pinned SDK's own Part 13 aggregate calculators (<see cref="SdkAggregateOracle"/>),
|
||
/// forward AND reverse, bucket by bucket — timestamps, values and status. The per-sample aggregates are bucketed with
|
||
/// <see cref="BucketPlan.IndexOf"/> (the C# statement of the backends' SQL bucket math; the integration suites run the
|
||
/// same comparison through the real SQL) and evaluated by <see cref="AggregateEvaluator.EvaluateAll"/>; the
|
||
/// time-weighted ones by <see cref="TimeWeightedAggregator"/>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Deliberate, documented divergences the datasets avoid (they are not anchoring questions):
|
||
/// <list type="bullet">
|
||
/// <item>an <b>empty</b> interval inside the data range: the SDK's Count answers 0/Good, ours <c>BadNoData</c>;</item>
|
||
/// <item><b>Uncertain</b> samples in a per-sample aggregate: the SDK's Average/Count/Min/Max use Good samples only,
|
||
/// ours every usable (Good + Uncertain) sample (TreatUncertainAsBad = false applied consistently);</item>
|
||
/// <item>the per-sample <b>Calculated / MultipleValues</b> info bits (the SDK's MinMax sets Calculated only when the
|
||
/// extreme is not at the interval start, and flags ties) — those comparisons check code bits + Partial only;</item>
|
||
/// <item>a <b>truncated</b> interval: ours sets Partial on a non-Bad time-weighted result (Part 13 §5.3.3.2 Case 3
|
||
/// requires it; the SDK does not), so the non-multiple windows mask that one bucket's Partial bit;</item>
|
||
/// <item>a time-weighted <c>BadNoData</c> carries the Calculated bit in ours (every processed status does), not in the
|
||
/// SDK's — masked by <see cref="MaskDocumented"/>;</item>
|
||
/// <item><b>TimeAverage2 / Total2 over an interval wholly inside an outage:</b> ours generic <c>Bad</c> (data, but no
|
||
/// non-Bad time — Part 13's PercentData rule), the SDK <c>BadNoData</c> — masked by <see cref="MaskDocumented"/>;</item>
|
||
/// <item><b>the end-of-data interval</b> (the last sample inside it, nothing after) of TimeAverage2 / Total2 /
|
||
/// PercentGood / PercentBad: the SDK gives the region after the last sample a 1 ms duration (an artifact of its
|
||
/// <c>GetRegionsInValueSet</c>); Part 13 ends TimeAverage2/Total2 at the last sample and runs the PercentGood/Bad
|
||
/// region to the interval end, which ours does (#680; pinned by <c>TimeWeightedAggregatorTests</c>). That
|
||
/// interval's value is not compared; its status, Partial bit and timestamp are.</item>
|
||
/// <item><b>TimeAverage / Total, reverse, a sample exactly on an interval's later edge:</b> the SDK's backward
|
||
/// <c>UpdateSlice</c> files it as the early bound (below), so the interval takes the status of the segment beyond
|
||
/// it (Uncertain when that segment skips a Bad sample); ours keeps the interval's own segments — masked by
|
||
/// <see cref="MaskDocumented"/>;</item>
|
||
/// <item><b>Interpolative, reverse, a sample exactly at the point:</b> the SDK's backward <c>UpdateSlice</c> files
|
||
/// a sample AT the point as the early bound (<c>>= 0</c>), so it interpolates from it to the next Good sample —
|
||
/// same value, but the Interpolated bit, and Uncertain when a Bad sample sits between. Ours returns the raw
|
||
/// sample with its own status in both directions (as the SDK itself does forward). Reverse Interpolative
|
||
/// therefore compares timestamps and values (the anchoring question) but not status. (The SDK's asymmetry —
|
||
/// forward it returns the raw sample — is its defect; ours is the same in both directions.)</item>
|
||
/// </list>
|
||
/// </remarks>
|
||
public sealed class SdkAggregateParityTests
|
||
{
|
||
private static readonly DateTime Now = At(3600);
|
||
|
||
/// <summary>Dense: every 2.5 s from −10 to +100, values cycling, one Bad sample at +22.5 — no empty interval.</summary>
|
||
private static readonly StoredSample[] Dense = Enumerable.Range(0, 45)
|
||
.Select(i => (t: -10 + i * 2.5, i))
|
||
.Select(x => S(x.t, HistorianValue.FromDouble(10 + (x.i * 7 % 13)), x.t == 22.5 ? Bad : Good))
|
||
.ToArray();
|
||
|
||
/// <summary>Tag A with Good bounds on both sides, plus an Uncertain sample and an outage marker further on.</summary>
|
||
private static readonly StoredSample[] Bounded =
|
||
[
|
||
S(-10, HistorianValue.FromDouble(5), Good),
|
||
.. A,
|
||
S(55, HistorianValue.FromDouble(70), Uncertain),
|
||
S(62, HistorianValue.Empty, BadNoCommunication),
|
||
S(71, HistorianValue.FromDouble(30), Good),
|
||
S(100, HistorianValue.FromDouble(60), Good),
|
||
];
|
||
|
||
/// <summary>
|
||
/// #680: data that ENDS inside every window (nothing after +52.5): every 2.5 s from −10, values cycling, a Bad
|
||
/// sample at +22.5 and an Uncertain one at +40 — the end-of-data rules (TimeAverage2/Total2 stop at the last
|
||
/// sample, Partial, then BadNoData; TimeAverage/Total extrapolate, Uncertain).
|
||
/// </summary>
|
||
private static readonly StoredSample[] EndsInside = Enumerable.Range(0, 26)
|
||
.Select(i => (t: -10 + i * 2.5, i))
|
||
.Select(x => S(x.t, HistorianValue.FromDouble(10 + (x.i * 7 % 13)),
|
||
x.t == 22.5 ? Bad : x.t == 40 ? Uncertain : Good))
|
||
.ToArray();
|
||
|
||
/// <summary>
|
||
/// #680: an outage +18 … +45 that wholly contains an interval of every window ([20,40), [20,35), [23,43), (20,40]):
|
||
/// TimeAverage/Total bridge it (Uncertain); TimeAverage2/Total2 have no non-Bad time there.
|
||
/// </summary>
|
||
private static readonly StoredSample[] OutageInside =
|
||
[
|
||
S(-10, HistorianValue.FromDouble(5), Good),
|
||
S(0, HistorianValue.FromDouble(10), Good),
|
||
S(15, HistorianValue.FromDouble(20), Good),
|
||
S(18, HistorianValue.Empty, BadNoCommunication),
|
||
S(45, HistorianValue.FromDouble(30), Good),
|
||
S(60, HistorianValue.FromDouble(40), Good),
|
||
S(100, HistorianValue.FromDouble(40), Good),
|
||
];
|
||
|
||
/// <summary>Data that starts inside the window (no sample before +7) and runs past its end.</summary>
|
||
private static readonly StoredSample[] LateStart = Enumerable.Range(0, 20)
|
||
.Select(i => S(7 + i * 5, HistorianValue.FromDouble(i % 2 == 0 ? 20 : 40), Good))
|
||
.ToArray();
|
||
|
||
public static TheoryData<double, double, double> Windows => new()
|
||
{
|
||
{ 0, 80, 20 }, { 80, 0, 20 }, // whole intervals (samples sit on the edges: the inclusivity question)
|
||
{ 0, 50, 20 }, { 50, 0, 20 }, // a truncated interval: latest forward, EARLIEST reverse
|
||
{ 5, 80, 15 }, { 80, 5, 15 }, // a misaligned window
|
||
{ 3, 77, 20 }, { 77, 3, 20 }, // misaligned and truncated
|
||
{ 0, 60, 0 }, { 60, 0, 0 }, // one interval
|
||
};
|
||
|
||
private static IReadOnlyList<DataValueSnapshot> Ours(HistoryAggregateType agg, double from, double to, double intervalS,
|
||
StoredSample[] data)
|
||
{
|
||
var plan = BucketPlan.Create(At(from), At(to), TimeSpan.FromSeconds(intervalS));
|
||
if (AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted)
|
||
return TimeWeightedAggregator.Evaluate(agg, plan, data, Now);
|
||
return AggregateEvaluator.EvaluateAll(agg, plan, InMemoryRows(plan, data, agg == HistoryAggregateType.MaximumActualTime),
|
||
data.Min(s => s.TimeUtc), Now);
|
||
}
|
||
|
||
private static IReadOnlyList<SdkAggregateOracle.Result> Sdk(HistoryAggregateType agg, double from, double to,
|
||
double intervalS, StoredSample[] data)
|
||
{
|
||
var interval = intervalS > 0 ? TimeSpan.FromSeconds(intervalS) : (At(to) - At(from)).Duration();
|
||
return SdkAggregateOracle.Run(agg, At(from), At(to), interval, data.Select(s => (s.TimeUtc, s.Value, s.Status)));
|
||
}
|
||
|
||
/// <summary>Masks the Partial bit of our one truncated interval (a documented divergence: ours flags it, the SDK does not).</summary>
|
||
private static IReadOnlyList<DataValueSnapshot> MaskTruncation(IReadOnlyList<DataValueSnapshot> ours, double from,
|
||
double to, double intervalS)
|
||
{
|
||
var plan = BucketPlan.Create(At(from), At(to), TimeSpan.FromSeconds(intervalS));
|
||
var list = ours.ToList();
|
||
for (long i = 0; i < plan.BucketCount; i++)
|
||
{
|
||
if (!plan.IsTruncated(i)) continue;
|
||
var k = (int)(plan.Reversed ? plan.BucketCount - 1 - i : i);
|
||
list[k] = list[k] with { StatusCode = list[k].StatusCode & ~HistorianInfoBits.HistorianPartial };
|
||
}
|
||
return list;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Applies the documented divergences listed on the class (other than truncation): strips the Calculated bit from our
|
||
/// <c>BadNoData</c>; maps our generic-Bad TimeAverage2/Total2 outage interval to the SDK's <c>BadNoData</c>; takes the
|
||
/// SDK's value for the end-of-data interval of TimeAverage2/Total2/PercentGood/PercentBad (its 1 ms artifact).
|
||
/// </summary>
|
||
private static IReadOnlyList<DataValueSnapshot> MaskDocumented(HistoryAggregateType agg, IReadOnlyList<DataValueSnapshot> ours,
|
||
IReadOnlyList<SdkAggregateOracle.Result> sdk, double from, double to, double intervalS, StoredSample[] data)
|
||
{
|
||
var plan = BucketPlan.Create(At(from), At(to), TimeSpan.FromSeconds(intervalS));
|
||
var list = ours.ToList();
|
||
var last = data.Max(x => x.TimeUtc).Ticks;
|
||
var dataEnds = last < Math.Max(At(from).Ticks, At(to).Ticks) && data.Single(x => x.TimeUtc.Ticks == last).IsUsable;
|
||
for (long i = 0; i < plan.BucketCount; i++)
|
||
{
|
||
var k = (int)(plan.Reversed ? plan.BucketCount - 1 - i : i);
|
||
if (k >= sdk.Count) continue; // the count mismatch is reported by ShouldMatch
|
||
var code = list[k].StatusCode & StatusBits.CodeBitsMask;
|
||
if (code == HistorianStatusCodes.BadNoData)
|
||
list[k] = list[k] with { StatusCode = list[k].StatusCode & ~StatusBits.HistorianBitsMask };
|
||
var twoVariant = agg is HistoryAggregateType.TimeAverage2 or HistoryAggregateType.Total2;
|
||
if (twoVariant && code == HistorianStatusCodes.Bad && (sdk[k].StatusCode & StatusBits.CodeBitsMask) == HistorianStatusCodes.BadNoData)
|
||
list[k] = list[k] with { StatusCode = HistorianStatusCodes.BadNoData };
|
||
// Reverse TimeAverage/Total: the SDK's backward UpdateSlice files a sample exactly on the interval's later edge
|
||
// as the early bound, so the interval inherits the status of the segment beyond it (the root cause of the
|
||
// reverse Interpolative divergence). Ours keeps the interval's own segments.
|
||
if (plan.Reversed && agg is HistoryAggregateType.TimeAverage or HistoryAggregateType.Total
|
||
&& data.Any(x => x.TimeUtc.Ticks == plan.UpperTicks(i))
|
||
&& code == HistorianStatusCodes.Good
|
||
&& (sdk[k].StatusCode & StatusBits.CodeBitsMask) == HistorianStatusCodes.UncertainDataSubNormal)
|
||
list[k] = list[k] with { StatusCode = (list[k].StatusCode & ~StatusBits.CodeBitsMask) | HistorianStatusCodes.UncertainDataSubNormal };
|
||
if (dataEnds && (twoVariant || agg is HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad)
|
||
&& last >= plan.LowerTicks(i) && last < plan.UpperTicks(i) && sdk[k].Value is not null)
|
||
list[k] = list[k] with { Value = sdk[k].Value };
|
||
}
|
||
return list;
|
||
}
|
||
|
||
[Theory]
|
||
[MemberData(nameof(Windows))]
|
||
public void Time_weighted_aggregates_match_the_SDK(double from, double to, double intervalS)
|
||
{
|
||
var failures = new List<string>();
|
||
foreach (var agg in new[]
|
||
{
|
||
HistoryAggregateType.TimeAverage, HistoryAggregateType.TimeAverage2, HistoryAggregateType.Total,
|
||
HistoryAggregateType.Total2, HistoryAggregateType.Interpolative, HistoryAggregateType.PercentGood,
|
||
HistoryAggregateType.PercentBad,
|
||
})
|
||
foreach (var (name, data) in new[]
|
||
{
|
||
("Bounded", Bounded), ("Dense", Dense), ("EndsInside", EndsInside), ("OutageInside", OutageInside),
|
||
})
|
||
{
|
||
try
|
||
{
|
||
var sdk = Sdk(agg, from, to, intervalS, data);
|
||
var ours = MaskDocumented(agg, MaskTruncation(Ours(agg, from, to, intervalS, data), from, to, intervalS),
|
||
sdk, from, to, intervalS, data);
|
||
SdkAggregateOracle.ShouldMatch(ours, sdk, $"{agg} {name} {from}→{to}/{intervalS}s",
|
||
compareCalculatedInterpolatedBits: !(agg == HistoryAggregateType.Interpolative && from > to),
|
||
compareCodeBits: !(agg == HistoryAggregateType.Interpolative && from > to));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
failures.Add(ex.Message);
|
||
}
|
||
}
|
||
failures.ShouldBeEmpty(string.Join("\n", failures));
|
||
}
|
||
|
||
[Theory]
|
||
[MemberData(nameof(Windows))]
|
||
public void Per_sample_aggregates_match_the_SDK(double from, double to, double intervalS)
|
||
{
|
||
var failures = new List<string>();
|
||
foreach (var agg in PerSample)
|
||
{
|
||
try
|
||
{
|
||
SdkAggregateOracle.ShouldMatch(Ours(agg, from, to, intervalS, Dense), Sdk(agg, from, to, intervalS, Dense),
|
||
$"{agg} Dense {from}→{to}/{intervalS}s", compareCalculatedInterpolatedBits: false,
|
||
// pre-existing, #680: the SDK's Start/End return the raw first/last sample (any quality) with its own
|
||
// timestamp and stored status; ours return the first/last USABLE value stamped with the interval, under the
|
||
// per-sample status rule. The SDK's Delta is Good even when a Bad sample sits in the interval; ours is
|
||
// Uncertain. Values are compared wherever the SDK's answer is not Bad.
|
||
compareTimestamps: !StartOrEnd(agg),
|
||
compareCodeBits: !StartOrEnd(agg) && agg != HistoryAggregateType.Delta);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
failures.Add(ex.Message);
|
||
}
|
||
}
|
||
failures.ShouldBeEmpty(string.Join("\n", failures));
|
||
}
|
||
|
||
private static readonly HistoryAggregateType[] PerSample =
|
||
[
|
||
HistoryAggregateType.Average, HistoryAggregateType.Minimum, HistoryAggregateType.Maximum,
|
||
HistoryAggregateType.Count, HistoryAggregateType.Range, HistoryAggregateType.Start, HistoryAggregateType.End,
|
||
HistoryAggregateType.Delta, HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime,
|
||
];
|
||
|
||
private static bool StartOrEnd(HistoryAggregateType agg) =>
|
||
agg is HistoryAggregateType.Start or HistoryAggregateType.End;
|
||
|
||
[Theory]
|
||
[InlineData(HistoryAggregateType.TimeAverage)]
|
||
[InlineData(HistoryAggregateType.TimeAverage2)]
|
||
[InlineData(HistoryAggregateType.Total)]
|
||
[InlineData(HistoryAggregateType.Total2)]
|
||
[InlineData(HistoryAggregateType.Minimum)]
|
||
[InlineData(HistoryAggregateType.Maximum)]
|
||
[InlineData(HistoryAggregateType.Count)]
|
||
[InlineData(HistoryAggregateType.Range)]
|
||
[InlineData(HistoryAggregateType.Delta)]
|
||
[InlineData(HistoryAggregateType.Start)]
|
||
[InlineData(HistoryAggregateType.Average)]
|
||
public void A_reverse_read_over_data_starting_inside_the_window_sets_no_start_of_data_Partial(HistoryAggregateType agg)
|
||
{
|
||
// Review F1: the SDK applies its start-of-data rule only on a FORWARD read (a reverse read keys m_startOfData on the
|
||
// first value it is fed — the latest — which is past the window here). Reverse 40 → 0 at 10 s, first sample +7:
|
||
// (0,10] holds the start of the data but is NOT Partial.
|
||
var tw = AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted;
|
||
var ours = Ours(agg, 40, 0, 10, LateStart);
|
||
SdkAggregateOracle.ShouldMatch(ours, Sdk(agg, 40, 0, 10, LateStart), $"{agg} reverse LateStart",
|
||
compareCalculatedInterpolatedBits: tw,
|
||
// pre-existing, #680: Start's raw-sample timestamp/status (see Per_sample_aggregates_match_the_SDK).
|
||
compareTimestamps: !StartOrEnd(agg),
|
||
compareCodeBits: !StartOrEnd(agg) && agg != HistoryAggregateType.Delta);
|
||
ours.ShouldAllBe(x => (x.StatusCode & HistorianInfoBits.HistorianPartial) == 0);
|
||
}
|
||
|
||
[Theory]
|
||
[InlineData(HistoryAggregateType.TimeAverage)]
|
||
[InlineData(HistoryAggregateType.TimeAverage2)]
|
||
[InlineData(HistoryAggregateType.Total)]
|
||
[InlineData(HistoryAggregateType.Total2)]
|
||
[InlineData(HistoryAggregateType.Minimum)]
|
||
[InlineData(HistoryAggregateType.Maximum)]
|
||
[InlineData(HistoryAggregateType.Count)]
|
||
[InlineData(HistoryAggregateType.Average)]
|
||
public void The_interval_the_data_starts_inside_matches_the_SDK_Partial_bit(HistoryAggregateType agg)
|
||
{
|
||
// Forward [0,40) at 10 s: the data starts at +7, inside [0,10) — the SDK sets Partial there (m_startOfData) for
|
||
// every calculator but plain Average; [10,20) … are fully covered.
|
||
var tw = AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted;
|
||
var ours = Ours(agg, 0, 40, 10, LateStart);
|
||
SdkAggregateOracle.ShouldMatch(ours, Sdk(agg, 0, 40, 10, LateStart), $"{agg} LateStart",
|
||
compareCalculatedInterpolatedBits: tw);
|
||
(ours[0].StatusCode & HistorianInfoBits.HistorianPartial).ShouldBe(
|
||
agg == HistoryAggregateType.Average ? 0u : HistorianInfoBits.HistorianPartial);
|
||
ours.Skip(1).ShouldAllBe(x => (x.StatusCode & HistorianInfoBits.HistorianPartial) == 0);
|
||
}
|
||
|
||
/// <summary>The per-bucket statistics the backends' SQL computes, built in memory with <see cref="BucketPlan.IndexOf"/>.</summary>
|
||
internal static IEnumerable<BucketRow> InMemoryRows(BucketPlan plan, IEnumerable<StoredSample> samples, bool maximum = false)
|
||
{
|
||
foreach (var g in samples.Select(s => (s, b: plan.IndexOf(s.TimeUtc))).Where(x => x.b is not null)
|
||
.GroupBy(x => x.b!.Value).OrderBy(g => g.Key))
|
||
{
|
||
var all = g.Select(x => x.s).OrderBy(s => s.TimeUtc).ToList();
|
||
var usable = all.Where(s => s.IsUsable).ToList();
|
||
var numeric = usable.Where(s => s.Value.Kind is HistorianValueKind.Double or HistorianValueKind.Int64
|
||
or HistorianValueKind.Boolean).ToList();
|
||
// ActualTime: the extreme usable value, ties ⇒ earliest (the SQL's ROW_NUMBER order).
|
||
var extreme = numeric.OrderBy(s => maximum ? -s.Value.Number : s.Value.Number).ThenBy(s => s.TimeUtc)
|
||
.Cast<StoredSample?>().FirstOrDefault();
|
||
yield return new BucketRow
|
||
{
|
||
Bucket = g.Key,
|
||
Total = all.Count,
|
||
Usable = usable.Count,
|
||
Good = all.Count(s => s.IsGood),
|
||
Bad = all.Count - usable.Count,
|
||
NonNumeric = usable.Count - numeric.Count,
|
||
Avg = numeric.Count == 0 ? null : numeric.Average(s => s.Value.Number),
|
||
Min = numeric.Count == 0 ? null : numeric.Min(s => s.Value.Number),
|
||
Max = numeric.Count == 0 ? null : numeric.Max(s => s.Value.Number),
|
||
Sum = numeric.Count == 0 ? null : numeric.Sum(s => s.Value.Number),
|
||
First = usable.Count == 0 ? HistorianValue.Empty : usable[0].Value,
|
||
Last = usable.Count == 0 ? HistorianValue.Empty : usable[^1].Value,
|
||
Extreme = extreme?.Value.Number,
|
||
ExtremeTime = extreme?.TimeUtc,
|
||
};
|
||
}
|
||
}
|
||
}
|