Files
lmxopcua/tests/Drivers/Historian.Conformance/QualityMixConformance.cs
T
Joseph Doherty 87b0c18fb3 test(historian): Uncertain/partial-data conformance datasets for PercentGood/PercentBad (#810)
QualityMixConformance (SqlServer + QuestDB): tag Q mixes Good/Uncertain/Bad
regions across eight 10 s intervals (two all-Uncertain - Mantis 0006166 0%/0% -
one through an Uncertain bound, plus Uncertain bounds from a sloped non-Good end
and from a hold into an outage); tag QP has data starting and ending inside
intervals. PercentGood/PercentBad are asserted against hand-derived Part 13
DurationGood/DurationBad, the oracle is checked against the same hand values,
and Count/Average/TimeAverage(2)/Total(2)/Start/End/Delta/Interpolative are
compared with the #796 oracle forward, reverse and truncated.

Teeth: with SdkAggregateOracle.Run answering PercentGood from the baseline
(Uncertain as Good), 8 tests fail per backend; all pass with the fixed oracle.

Records a new SDK divergence: with an Uncertain first sample the baseline
TimeAverage/Total integrate the pre-data span as a ramp from 0; ours covers
only the data (Part 13) - pinned by hand.
2026-11-10 22:58:09 -05:00

263 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
/// <summary>
/// #810: processed reads over Good / Uncertain / Bad mixes. Before this suite no conformance dataset fed
/// PercentGood an Uncertain region, so the suites passed against the 1.5.378.182 baseline calculator that counts
/// Uncertain time as Good (#796) as well as against the Part 13 oracle. Every expectation here is derived by hand from
/// Part 13 (§5.4.3.31-34: DurationGood / DurationBad are the Good / Bad region time of the interval, PercentGood /
/// PercentBad that time ÷ the interval; Uncertain regions count toward neither — Mantis 0006166 — and the time before
/// the first sample is Bad, no data), and the oracle itself is checked against the same hand values.
/// </summary>
public abstract class QualityMixConformance : ConformanceBase
{
private static readonly CancellationToken Ct = CancellationToken.None;
private static readonly TimeSpan Ten = TimeSpan.FromSeconds(10);
private static readonly TimeSpan Twenty = TimeSpan.FromSeconds(20);
private const uint GoodCalc = 0x00000401u; // Good | InfoType DataValue 0x400 | Calculated 0x1
private const uint NoDataCalc = 0x809B0401u; // BadNoData | Calculated
private const uint UncertainCalc = 0x40A40401u; // UncertainDataSubNormal | Calculated
private const uint Partial = 0x4u;
/// <summary>
/// Tag Q, Double, 10 s intervals over [0, 80) — a Good sample before the window (−10) and after it (+90), so no
/// interval holds the start or the end of the data. Stepped status regions, each with the status of the sample that
/// starts it; the region before an interval's first own sample has the simple bounding value's status:
/// <list type="bullet">
/// <item>I0 [0,10): Good 0, Uncertain 4, Bad 7 ⇒ Good 4 s, Uncertain 3 s, Bad 3 s.</item>
/// <item>I1 [10,20): Good 10, Uncertain 13 ⇒ Good 3 s, Uncertain 7 s.</item>
/// <item>I2 [20,30): Uncertain 20, Uncertain 25 ⇒ all Uncertain (Mantis 0006166: 0 % Good AND 0 % Bad).</item>
/// <item>I3 [30,40): no sample at 30 — the bound slopes Uncertain 25 → Uncertain 33 (Uncertain); Uncertain 33
/// ⇒ all Uncertain again, this time through the bound.</item>
/// <item>I4 [40,50): Good 40, BadNoCommunication outage marker 45 ⇒ Good 5 s, Bad 5 s.</item>
/// <item>I5 [50,60): Uncertain 50, Good 56 ⇒ Uncertain 6 s, Good 4 s.</item>
/// <item>I6 [60,70): the bound slopes Good 56 → Uncertain 64 (a non-Good end ⇒ Uncertain) for 4 s, Uncertain 64
/// for 3 s, Good 67 for 3 s.</item>
/// <item>I7 [70,80): the bound holds Good 67 into the outage at 75 (⇒ Uncertain) for 5 s, Bad 75 for 3 s, Good 78
/// for 2 s.</item>
/// </list>
/// </summary>
private static readonly (DateTime T, HistorianValue V, uint Status)[] Q =
[
(At(-10), HistorianValue.FromDouble(10), Good),
(At(0), HistorianValue.FromDouble(10), Good),
(At(4), HistorianValue.FromDouble(12), Uncertain),
(At(7), HistorianValue.FromDouble(13), Bad),
(At(10), HistorianValue.FromDouble(14), Good),
(At(13), HistorianValue.FromDouble(15), Uncertain),
(At(20), HistorianValue.FromDouble(16), Uncertain),
(At(25), HistorianValue.FromDouble(17), Uncertain),
(At(33), HistorianValue.FromDouble(18), Uncertain),
(At(40), HistorianValue.FromDouble(20), Good),
(At(45), HistorianValue.Empty, BadNoCommunication),
(At(50), HistorianValue.FromDouble(22), Uncertain),
(At(56), HistorianValue.FromDouble(23), Good),
(At(64), HistorianValue.FromDouble(25), Uncertain),
(At(67), HistorianValue.FromDouble(26), Good),
(At(75), HistorianValue.FromDouble(27), Bad),
(At(78), HistorianValue.FromDouble(28), Good),
(At(90), HistorianValue.FromDouble(30), Good),
];
/// <summary>Hand-computed Part 13 DurationGood of Q's eight intervals, seconds.</summary>
private static readonly double[] QDurationGood = [4, 3, 0, 0, 5, 4, 3, 2];
/// <summary>Hand-computed Part 13 DurationBad of Q's eight intervals, seconds.</summary>
private static readonly double[] QDurationBad = [3, 0, 0, 0, 5, 0, 0, 3];
/// <summary>What the 1.5.378.182 baseline answers for Q's PercentGood (Uncertain counted as Good, #796).</summary>
private static readonly double[] QBaselinePercentGood = [70, 100, 100, 100, 50, 100, 100, 70];
/// <summary>
/// Tag QP, partial data, 20 s intervals over [−20, 60): the data starts at +5 and ends at +25 (nothing after).
/// [−20,0) ends before the first sample ⇒ BadNoData. [0,20): 5 s no data (Bad), Uncertain 5 for 7 s, Good 12 for
/// 8 s ⇒ PercentGood 40, PercentBad 25, Partial (the data starts inside). [20,40): the bound slopes Good 12 →
/// Uncertain 25 (Uncertain) for 5 s, then the last sample's Uncertain region runs to the interval end ⇒ PercentGood 0,
/// PercentBad 0, Partial (the data ends inside). [40,60): after the end of the data ⇒ BadNoData.
/// </summary>
private static readonly (DateTime T, HistorianValue V, uint Status)[] QP =
[
(At(5), HistorianValue.FromDouble(10), Uncertain),
(At(12), HistorianValue.FromDouble(11), Good),
(At(25), HistorianValue.FromDouble(12), Uncertain),
];
private (DateTime, HistorianValue, uint)[] Rounded((DateTime T, HistorianValue V, uint Status)[] rows) =>
rows.Select(r => (B.Round(r.T), r.V, r.Status)).ToArray();
private static double[] Values(IEnumerable<DataValueSnapshot> s) => s.Select(x => Convert.ToDouble(x.Value)).ToArray();
private static void ShouldBeClose(double[] actual, double[] expected, string label)
{
actual.Length.ShouldBe(expected.Length, label);
for (var i = 0; i < expected.Length; i++) actual[i].ShouldBe(expected[i], 1e-6, $"{label} [{i}]");
}
[Fact]
public async Task PercentGood_and_PercentBad_over_a_Good_Uncertain_Bad_mix_are_the_Part_13_region_shares()
{
await B.SeedAsync("Q", Q);
var src = B.CreateDataSource();
var good = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.PercentGood, Ct)).Samples;
var bad = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.PercentBad, Ct)).Samples;
// Part 13 §5.4.3.33/34: PercentGood = DurationGood ÷ interval, PercentBad = DurationBad ÷ interval.
var expectedGood = QDurationGood.Select(d => 100 * d / Ten.TotalSeconds).ToArray();
var expectedBad = QDurationBad.Select(d => 100 * d / Ten.TotalSeconds).ToArray();
ShouldBeClose(Values(good), expectedGood, "PercentGood");
ShouldBeClose(Values(bad), expectedBad, "PercentBad");
good.Select(x => x.StatusCode).ShouldAllBe(c => c == GoodCalc);
bad.Select(x => x.StatusCode).ShouldAllBe(c => c == GoodCalc);
good.Select(x => x.SourceTimestampUtc).ShouldBe(Enumerable.Range(0, 8).Select(i => (DateTime?)B.Round(At(i * 10))));
// Uncertain time is neither Good nor Bad: only I4 (no Uncertain region) adds up to 100.
Values(good).Zip(Values(bad), (g, b) => Math.Round(g + b, 6)).ShouldBe([70d, 30d, 0d, 0d, 100d, 40d, 30d, 50d]);
((double)good[2].Value! + (double)bad[2].Value!).ShouldBe(0d, 1e-9, "Mantis 0006166: all-Uncertain ⇒ 0 % and 0 %");
((double)good[3].Value! + (double)bad[3].Value!).ShouldBe(0d, 1e-9, "all-Uncertain through the bound");
}
[Fact]
public void The_hand_computed_Q_values_are_the_Part_13_oracle_and_not_the_baseline()
{
// The oracle the parity tests below compare against must agree with the hand derivation above; the baseline must
// not (it counts the Uncertain regions as Good). Backend-independent, but kept beside the data it checks.
var rows = Q.Select(r => (r.T, r.V, r.Status)).ToArray();
var oracleGood = SdkAggregateOracle.Run(HistoryAggregateType.PercentGood, At(0), At(80), Ten, rows);
var oracleBad = SdkAggregateOracle.Run(HistoryAggregateType.PercentBad, At(0), At(80), Ten, rows);
ShouldBeClose(oracleGood.Select(r => Convert.ToDouble(r.Value)).ToArray(),
QDurationGood.Select(d => 100 * d / Ten.TotalSeconds).ToArray(), "oracle PercentGood");
ShouldBeClose(oracleBad.Select(r => Convert.ToDouble(r.Value)).ToArray(),
QDurationBad.Select(d => 100 * d / Ten.TotalSeconds).ToArray(), "oracle PercentBad");
var baseline = SdkAggregateOracle.RunSdkAsIs(HistoryAggregateType.PercentGood, At(0), At(80), Ten, rows);
ShouldBeClose(baseline.Select(r => Convert.ToDouble(r.Value)).ToArray(), QBaselinePercentGood,
"baseline PercentGood (#796) — if this moves, revisit SdkAggregateOracle");
}
[Fact]
public async Task PercentGood_and_PercentBad_over_partial_data_count_no_data_as_Bad_and_Uncertain_as_neither()
{
await B.SeedAsync("QP", QP);
var src = B.CreateDataSource();
var good = (await src.ReadProcessedAsync("QP", At(-20), At(60), Twenty, HistoryAggregateType.PercentGood, Ct)).Samples;
var bad = (await src.ReadProcessedAsync("QP", At(-20), At(60), Twenty, HistoryAggregateType.PercentBad, Ct)).Samples;
good.Select(x => x.Value).ShouldBe([null, 40d, 0d, null]);
bad.Select(x => x.Value).ShouldBe([null, 25d, 0d, null]);
// Start-of-data and end-of-data intervals are Partial; outside the data range BadNoData.
var statuses = new[] { NoDataCalc, GoodCalc | Partial, GoodCalc | Partial, NoDataCalc };
good.Select(x => x.StatusCode).ShouldBe(statuses);
bad.Select(x => x.StatusCode).ShouldBe(statuses);
}
[Fact]
public async Task DurationGood_and_DurationBad_are_refused_rather_than_answered_from_the_baseline_rule()
{
// Part 13 DurationGood / DurationBad are not served by the SQL backends (#682 advertises 17 aggregates); they
// must stay refused up front — never answered by a calculator that counts Uncertain time as Good.
await B.SeedAsync("Q", Q);
var src = B.CreateDataSource();
foreach (var agg in new[] { HistoryAggregateType.DurationGood, HistoryAggregateType.DurationBad })
(await Should.ThrowAsync<HistoryAggregateNotSupportedException>(
() => src.ReadProcessedAsync("Q", At(0), At(80), Ten, agg, Ct))).Aggregate.ShouldBe(agg);
}
[Theory]
[InlineData(0, 80, 10)]
[InlineData(80, 0, 10)]
[InlineData(0, 80, 20)]
[InlineData(3, 77, 10)]
[InlineData(77, 3, 10)]
public async Task Processed_reads_over_a_Good_Uncertain_Bad_mix_match_the_Part_13_oracle(double from, double to,
double intervalS)
{
// The real backend against SdkAggregateOracle over Q — forward, reverse, aligned and truncated windows — for every
// served aggregate whose Uncertain handling the data reaches. Minimum / Maximum / Range / *ActualTime are left out:
// Q has Uncertain values beyond the Good extremes, where Part 13 (ours) says Uncertain and the SDK Good (#684,
// documented in SdkAggregateParityTests).
await B.SeedAsync("Q", Q);
await AssertMatchesOracle("Q", Rounded(Q), from, to, intervalS,
[
HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad, HistoryAggregateType.Count,
HistoryAggregateType.Average, HistoryAggregateType.TimeAverage, HistoryAggregateType.TimeAverage2,
HistoryAggregateType.Total, HistoryAggregateType.Total2, HistoryAggregateType.Start, HistoryAggregateType.End,
HistoryAggregateType.Delta, HistoryAggregateType.Interpolative,
]);
}
[Theory]
[InlineData(-20, 60, 20)]
[InlineData(-20, 60, 10)]
public async Task Processed_reads_over_partial_data_match_the_Part_13_oracle(double from, double to, double intervalS)
{
// QP's start-of-data and end-of-data intervals against the oracle. Left out, each a documented or pinned divergence:
// TimeAverage2 / Total2 (the SDK's 1 ms end-of-data region), the min/max family (the SDK sets Partial on a Bad
// result), and TimeAverage / Total (pinned by Time_weighted_values_over_partial_data_cover_only_the_data below).
await B.SeedAsync("QP", QP);
await AssertMatchesOracle("QP", Rounded(QP), from, to, intervalS,
[
HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad, HistoryAggregateType.Count,
HistoryAggregateType.Average, HistoryAggregateType.Start, HistoryAggregateType.End, HistoryAggregateType.Delta,
HistoryAggregateType.Interpolative,
]);
}
[Fact]
public async Task Time_weighted_values_over_partial_data_cover_only_the_data()
{
// Part 13 §5.4.3.3/§5.4.3.8 over QP's [0,20) (data from +5): the interpolated-bounding signal is defined from +5
// only — 10 → 11 over [5,12) (area 73.5) and 11 → 11 + 8/13 over [12,20) (area 4·(22 + 8/13)) — so
// Total = 73.5 + 88 + 32/13 and TimeAverage = Total ÷ 15 s; Uncertain (an Uncertain value used, not fully covered),
// Partial (the data starts inside). Divergence found by #810: the 1.5.378.182 baseline, whose first sample is
// Uncertain here, integrates the uncovered [0,5) as a ramp from 0 (Total +25) — so these are pinned by hand, not
// against the oracle.
await B.SeedAsync("QP", QP);
var src = B.CreateDataSource();
const double total = 73.5 + 88 + 32d / 13;
var tot = (await src.ReadProcessedAsync("QP", At(0), At(20), Twenty, HistoryAggregateType.Total, Ct)).Samples.Single();
((double)tot.Value!).ShouldBe(total, 1e-6);
tot.StatusCode.ShouldBe(UncertainCalc | Partial);
var avg = (await src.ReadProcessedAsync("QP", At(0), At(20), Twenty, HistoryAggregateType.TimeAverage, Ct)).Samples.Single();
((double)avg.Value!).ShouldBe(total / 15, 1e-6);
avg.StatusCode.ShouldBe(UncertainCalc | Partial);
var sdk = SdkAggregateOracle.Run(HistoryAggregateType.Total, B.Round(At(0)), B.Round(At(20)), Twenty, Rounded(QP)).Single();
Convert.ToDouble(sdk.Value).ShouldBe(total + 25, 1e-6, "baseline divergence — if this moves, compare QP's TimeAverage/Total to the oracle");
}
private async Task AssertMatchesOracle(string tag, (DateTime, HistorianValue, uint)[] rows, double from, double to,
double intervalS, HistoryAggregateType[] aggregates)
{
var src = B.CreateDataSource();
var interval = TimeSpan.FromSeconds(intervalS);
var (start, end) = (B.Round(At(from)), B.Round(At(to)));
var plan = BucketPlan.Create(start, end, interval);
var problems = new List<string>();
foreach (var agg in aggregates)
{
var ours = (await src.ReadProcessedAsync(tag, At(from), At(to), interval, agg, Ct)).Samples.ToList();
if (AggregateFamilies.FamilyOf(agg) == AggregateFamily.TimeWeighted)
{
// Documented divergence (ProcessedConformance): ours flags the truncated interval Partial, the SDK does not.
for (long i = 0; i < plan.BucketCount; i++)
if (plan.IsTruncated(i))
{
var k = (int)(plan.Reversed ? plan.BucketCount - 1 - i : i);
ours[k] = ours[k] with { StatusCode = ours[k].StatusCode & ~Partial };
}
}
// Calculated / Interpolated bits are not compared: a BadNoData carries Calculated here, not in the SDK
// (documented); the hand-computed tests above pin those bits.
var failure = Record.Exception(() => SdkAggregateOracle.ShouldMatch(ours,
SdkAggregateOracle.Run(agg, start, end, interval, rows), $"{tag} {agg} {from}→{to}/{intervalS}s",
compareCalculatedInterpolatedBits: false));
if (failure is not null) problems.Add(failure.Message);
}
problems.ShouldBeEmpty();
}
}