diff --git a/docs/Historian.md b/docs/Historian.md
index 6b2200e33..1d4a21705 100644
--- a/docs/Historian.md
+++ b/docs/Historian.md
@@ -1893,6 +1893,13 @@ timestamp, and the same treatment as the Min/Max-at-interval-start case above; m
And an interval holding a **Good String (or DateTime) sample** is `BadAggregateInvalidInputs` for Average/Minimum/Maximum/Range/
*ActualTime here (the documented #684 refusal rule — a Good non-numeric value is not a number to aggregate), while the SDK skips the
string and serves the numeric samples (e.g. Average 4 Good); an Uncertain non-numeric sample refuses nothing on either side.
+One more, found by the #810 Uncertain conformance data (kept deliberately): over the interval the data **starts inside** when the
+**first sample is Uncertain**, the SDK's `TimeAverage`/`Total` integrate the uncovered span before it as a ramp from 0 (QP: `Total`
+188.96 for 163.96 over the covered 15 s); ours integrates only the covered part (Part 13, as with a Good first sample, where the two
+agree). Pinned by `QualityMixConformance.Time_weighted_values_over_partial_data_cover_only_the_data` on both SQL backends. The same
+suite feeds PercentGood/PercentBad (and Count, Average, TimeAverage(2), Total(2), Start/End/Delta, Interpolative) Good/Uncertain/Bad
+mixes, all-Uncertain intervals (0 % Good **and** 0 % Bad, Mantis 0006166) and partial-data intervals, hand-computed and against the
+#796 oracle — the baseline calculator's Uncertain-as-Good PercentGood fails it.
**Pushdown trade-off.** Unlike the per-sample aggregates, the time-weighted family is **not** pushed down to SQL: the backend streams
the window's rows plus the four bounds (one batch of five result sets on SQL Server; a bounds `UNION ALL` + a window select on QuestDB)
diff --git a/tests/Drivers/Historian.Conformance/QualityMixConformance.cs b/tests/Drivers/Historian.Conformance/QualityMixConformance.cs
new file mode 100644
index 000000000..051f5b6ee
--- /dev/null
+++ b/tests/Drivers/Historian.Conformance/QualityMixConformance.cs
@@ -0,0 +1,262 @@
+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;
+
+///
+/// #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.
+///
+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;
+
+ ///
+ /// 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:
+ ///
+ /// - I0 [0,10): Good 0, Uncertain 4, Bad 7 ⇒ Good 4 s, Uncertain 3 s, Bad 3 s.
+ /// - I1 [10,20): Good 10, Uncertain 13 ⇒ Good 3 s, Uncertain 7 s.
+ /// - I2 [20,30): Uncertain 20, Uncertain 25 ⇒ all Uncertain (Mantis 0006166: 0 % Good AND 0 % Bad).
+ /// - 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.
+ /// - I4 [40,50): Good 40, BadNoCommunication outage marker 45 ⇒ Good 5 s, Bad 5 s.
+ /// - I5 [50,60): Uncertain 50, Good 56 ⇒ Uncertain 6 s, Good 4 s.
+ /// - 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.
+ /// - 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.
+ ///
+ ///
+ 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),
+ ];
+
+ /// Hand-computed Part 13 DurationGood of Q's eight intervals, seconds.
+ private static readonly double[] QDurationGood = [4, 3, 0, 0, 5, 4, 3, 2];
+
+ /// Hand-computed Part 13 DurationBad of Q's eight intervals, seconds.
+ private static readonly double[] QDurationBad = [3, 0, 0, 0, 5, 0, 0, 3];
+
+ /// What the 1.5.378.182 baseline answers for Q's PercentGood (Uncertain counted as Good, #796).
+ private static readonly double[] QBaselinePercentGood = [70, 100, 100, 100, 50, 100, 100, 70];
+
+ ///
+ /// 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.
+ ///
+ 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 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(
+ () => 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();
+ 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();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformance.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformance.cs
index d84c755eb..6c2c70e62 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformance.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.IntegrationTests/QuestDbConformance.cs
@@ -26,6 +26,13 @@ public sealed class QuestDbProcessedConformance(QuestDbFixture fx) : ProcessedCo
protected override Task CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
}
+[Collection(QuestDbCollection.Name)]
+public sealed class QuestDbQualityMixConformance(QuestDbFixture fx) : QualityMixConformance
+{
+ ///
+ protected override Task CreateBackendAsync() => QuestDbConformanceBackend.CreateAsync(fx);
+}
+
[Collection(QuestDbCollection.Name)]
public sealed class QuestDbQualityContractConformance(QuestDbFixture fx) : QualityContractConformance
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SqlServerConformance.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SqlServerConformance.cs
index c08d50eeb..3589994a6 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SqlServerConformance.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/SqlServerConformance.cs
@@ -26,6 +26,13 @@ public sealed class SqlServerProcessedConformance(SqlHistorianFixture fx) : Proc
protected override Task CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
}
+[Collection(SqlHistorianCollection.Name)]
+public sealed class SqlServerQualityMixConformance(SqlHistorianFixture fx) : QualityMixConformance
+{
+ ///
+ protected override Task CreateBackendAsync() => SqlServerConformanceBackend.CreateAsync(fx);
+}
+
[Collection(SqlHistorianCollection.Name)]
public sealed class SqlServerQualityContractConformance(SqlHistorianFixture fx) : QualityContractConformance
{