Files
lmxopcua/tests/Drivers/Historian.Conformance/QualityMixConformance.cs
T
Joseph Doherty ed05d50795 fix(aggregates): live PercentGood/DurationGood count Good time only (#809)
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
2026-11-11 12:05:34 -05:00

328 lines
21 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 untouched 1.5.378.182 baseline answers for Q's PercentGood (Uncertain counted as Good, #796; live before #809).</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 untouched baseline (the live AggregateFilter calculator before #809) answers Q's DurationGood with the
// Uncertain time added; the live calculator now answers the Part 13 values above (SdkAggregateParityTests).
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();
}
}