Files
lmxopcua/tests/Drivers/Historian.Conformance/QualityMixConformance.cs
T
Joseph Doherty e7b485c0a2 feat(historian): serve DurationGood/DurationBad on the SQL backends; pin the #821 late-start facts (#820, #821)
#820: SqlServer + QuestDB now serve Part 13 DurationGood / DurationBad from the
same Good / Bad region totals TimeWeightedAggregator already computes for
PercentGood / PercentBad — reported in milliseconds (OPC UA Duration, the SDK's
unit), Uncertain time in neither (Mantis 0006166), same BadNoData / status /
Partial rules. AggregateFamilies.Served grows 17 -> 19, so i=11201 and the
up-front refusal follow automatically; Gateway still refuses both (explicit
served set, unchanged). Client.Shared AggregateType / mapper / CLI gain both.
The #796 oracle answers DurationGood through the Good-only subclass as well.
QualityMix conformance asserts both directly (Q and QP, hand values, ms, and
= PercentGood x interval), replaces the "refused up front" test, and adds
Duration* to the forward/reverse/truncated oracle comparisons.

#821 (facts only, no decision): SdkAggregateParityTests pins an Uncertain-first
late-start interval (QP over [0,20), both directions) against ours (Part 13
Total 163.96, TimeAverage 10.93), the frozen 1.5.378.182 calculators (188.96,
9.448), SDK 2.0.1's stock calculators (188.96, 9.448) and a Good-first control
(all 163.96); LiveAggregateLateStartFactsTests pins the same through the booted
server's runtime AggregateManager (live AggregateFilter) incl. DurationGood
15000 / PercentGood 75 there vs 8000 / 40 in HistoryRead (#809). QualityMix
adds the reverse late-start variant (reverse TW pin + reverse oracle windows).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 01:28:54 -05:00

327 lines
20 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. Since #820 the SQL
/// backends serve DurationGood / DurationBad (the same region times, in ms) and this suite asserts them directly.
/// </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_over_a_Good_Uncertain_Bad_mix_are_the_Part_13_region_times()
{
// #820: served on both SQL backends from the same region totals as PercentGood / PercentBad. Part 13
// §5.4.3.31/32: the Good / Bad region time of the interval as an OPC UA Duration (milliseconds); Uncertain regions
// count toward neither (Mantis 0006166) — never the 1.5.378.182 baseline rule that counts Uncertain time as Good.
await B.SeedAsync("Q", Q);
var src = B.CreateDataSource();
src.SupportedAggregates.ShouldContain(HistoryAggregateType.DurationGood);
src.SupportedAggregates.ShouldContain(HistoryAggregateType.DurationBad);
var good = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.DurationGood, Ct)).Samples;
var bad = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.DurationBad, Ct)).Samples;
ShouldBeClose(Values(good), QDurationGood.Select(d => d * 1000).ToArray(), "DurationGood");
ShouldBeClose(Values(bad), QDurationBad.Select(d => d * 1000).ToArray(), "DurationBad");
good.ShouldAllBe(x => x.Value is double);
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 the whole 10 s.
Values(good).Zip(Values(bad), (g, b) => Math.Round(g + b, 6)).ShouldBe([7000d, 3000d, 0d, 0d, 10000d, 4000d, 3000d, 5000d]);
// DurationGood / DurationBad = PercentGood / PercentBad × interval ÷ 100, interval by interval.
var pGood = (await src.ReadProcessedAsync("Q", At(0), At(80), Ten, HistoryAggregateType.PercentGood, Ct)).Samples;
ShouldBeClose(Values(good), Values(pGood).Select(p => p * Ten.TotalMilliseconds / 100).ToArray(), "DurationGood vs PercentGood");
// The baseline (the live AggregateFilter calculator, #809) answers Q's DurationGood with the Uncertain time added.
var baseline = SdkAggregateOracle.RunSdkAsIs(HistoryAggregateType.DurationGood, At(0), At(80), Ten,
Q.Select(r => (r.T, r.V, r.Status)));
ShouldBeClose(baseline.Select(r => Convert.ToDouble(r.Value)).ToArray(),
QBaselinePercentGood.Select(p => p * 100).ToArray(), "baseline DurationGood (#809)");
}
[Fact]
public async Task DurationGood_and_DurationBad_over_partial_data_count_no_data_as_Bad_and_Uncertain_as_neither()
{
// #820 over QP: [0,20) 5 s no data (Bad), Uncertain 7 s, Good 8 s; [20,40) all Uncertain; outside the data BadNoData.
await B.SeedAsync("QP", QP);
var src = B.CreateDataSource();
var good = (await src.ReadProcessedAsync("QP", At(-20), At(60), Twenty, HistoryAggregateType.DurationGood, Ct)).Samples;
var bad = (await src.ReadProcessedAsync("QP", At(-20), At(60), Twenty, HistoryAggregateType.DurationBad, Ct)).Samples;
good.Select(x => x.Value).ShouldBe([null, 8000d, 0d, null]);
bad.Select(x => x.Value).ShouldBe([null, 5000d, 0d, null]);
var statuses = new[] { NoDataCalc, GoodCalc | Partial, GoodCalc | Partial, NoDataCalc };
good.Select(x => x.StatusCode).ShouldBe(statuses);
bad.Select(x => x.StatusCode).ShouldBe(statuses);
}
[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.DurationGood,
HistoryAggregateType.DurationBad, 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)]
[InlineData(60, -20, 20)]
[InlineData(60, -20, 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, forward and reverse (#821). 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.DurationGood,
HistoryAggregateType.DurationBad, 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");
}
[Fact]
public async Task Time_weighted_values_over_partial_data_cover_only_the_data_in_a_reverse_read()
{
// #821: the reverse-order variant of the late-start case above — (0,20] read 20 → 0. The same Part 13 integral and
// average over the covered [5,20); Uncertain; NOT Partial (a reverse read keys start-of-data on the latest sample,
// which is past the window — the SDK's rule, SdkAggregateParityTests); stamped with the later edge (#677). The
// baseline's reverse answer carries the same +25 ramp divergence.
await B.SeedAsync("QP", QP);
var src = B.CreateDataSource();
const double total = 73.5 + 88 + 32d / 13;
var tot = (await src.ReadProcessedAsync("QP", At(20), At(0), Twenty, HistoryAggregateType.Total, Ct)).Samples.Single();
((double)tot.Value!).ShouldBe(total, 1e-6);
tot.StatusCode.ShouldBe(UncertainCalc);
tot.SourceTimestampUtc.ShouldBe(B.Round(At(20)));
var avg = (await src.ReadProcessedAsync("QP", At(20), At(0), Twenty, HistoryAggregateType.TimeAverage, Ct)).Samples.Single();
((double)avg.Value!).ShouldBe(total / 15, 1e-6);
avg.StatusCode.ShouldBe(UncertainCalc);
var sdk = SdkAggregateOracle.Run(HistoryAggregateType.Total, B.Round(At(20)), B.Round(At(0)), Twenty, Rounded(QP)).Single();
Convert.ToDouble(sdk.Value).ShouldBe(total + 25, 1e-6, "baseline divergence (reverse) — #821");
}
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();
}
}