diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
index fc18bc634..77921b4f8 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.CLI/Commands/HistoryReadCommand.cs
@@ -48,7 +48,7 @@ public class HistoryReadCommand : CommandBase
///
/// Gets the optional aggregate name used when the operator wants processed history instead of raw samples.
///
- [CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, TimeAverage, TimeAverage2, Total, Total2, Interpolative, Range, Delta, MinimumActualTime, MaximumActualTime, PercentGood, PercentBad")]
+ [CommandOption("aggregate", Description = "Aggregate function: Average, Minimum, Maximum, Count, Start, End, TimeAverage, TimeAverage2, Total, Total2, Interpolative, Range, Delta, MinimumActualTime, MaximumActualTime, PercentGood, PercentBad, DurationGood, DurationBad")]
public string? Aggregate { get; init; }
///
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs
index 23b70edeb..197399a82 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Helpers/AggregateTypeMapper.cs
@@ -34,6 +34,8 @@ public static class AggregateTypeMapper
AggregateType.MaximumActualTime => ObjectIds.AggregateFunction_MaximumActualTime,
AggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood,
AggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad,
+ AggregateType.DurationGood => ObjectIds.AggregateFunction_DurationGood,
+ AggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad,
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, "Unknown AggregateType value.")
};
}
diff --git a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs
index 17dddc51b..683eb6093 100644
--- a/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs
+++ b/src/Client/ZB.MOM.WW.OtOpcUa.Client.Shared/Models/AggregateType.cs
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
///
-/// Aggregate functions for processed history reads — exactly the seventeen the server's SQL historian backends
+/// Aggregate functions for processed history reads — exactly the nineteen the server's SQL historian backends
/// serve (AggregateFamilies.Served; a parity test pins it). The Gateway backend serves a subset and refuses
/// the rest with BadAggregateNotSupported.
///
@@ -60,5 +60,11 @@ public enum AggregateType
PercentGood = 16,
/// Percentage of the interval with Bad data (Part 13 PercentBad).
- PercentBad = 17
+ PercentBad = 17,
+
+ /// Time of the interval with Good data, in milliseconds (Part 13 DurationGood).
+ DurationGood = 18,
+
+ /// Time of the interval with Bad data, in milliseconds (Part 13 DurationBad).
+ DurationBad = 19
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs
index ac02c2077..e3fd86a6f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbAggregateQueries.cs
@@ -57,7 +57,7 @@ internal static class QuestDbAggregateQueries
private static readonly string EmptyKind = ((byte)HistorianValueKind.Empty).ToString(CultureInfo.InvariantCulture);
///
- /// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here).
+ /// Average / Minimum / Maximum / Count / Range (DurationGood / DurationBad / PercentGood / PercentBad and Total are time-weighted, not here).
/// Family columns: 6 avg_value, 7 min_value, 8 max_value, 9 sum_value over the Good numeric samples
/// (DOUBLE, NULL when none — Part 13 / the SDK), then 10 unc_min, 11 unc_max over the Uncertain ones (the
/// Part 13 Minimum/Maximum/Range status rule) and 12 good_non_numeric_count.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs
index 172561b0f..eb640d0e3 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs
@@ -434,7 +434,7 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
///
public bool SupportsReverseEvents => true;
- // The seventeen AggregateFamilies.Served - shared with the other SQL backend.
+ // The nineteen AggregateFamilies.Served - shared with the other SQL backend.
///
public IReadOnlySet SupportedAggregates => AggregateFamilies.Served;
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs
index cac2cc02e..e83e63136 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/AggregateFamily.cs
@@ -16,7 +16,8 @@ public enum AggregateFamily
ActualTime,
///
- /// TimeAverage / TimeAverage2 / Total / Total2 / Interpolative, and the duration-based PercentGood / PercentBad:
+ /// TimeAverage / TimeAverage2 / Total / Total2 / Interpolative, and the duration-based DurationGood / DurationBad /
+ /// PercentGood / PercentBad:
/// Part 13 time-weighted, with bounding values.
/// The backend streams the window's samples plus the bounding rows and
/// evaluates them (identically for every backend).
@@ -38,12 +39,13 @@ public static class AggregateFamilies
HistoryAggregateType.MinimumActualTime or HistoryAggregateType.MaximumActualTime => AggregateFamily.ActualTime,
HistoryAggregateType.TimeAverage or HistoryAggregateType.TimeAverage2 or HistoryAggregateType.Total
or HistoryAggregateType.Total2 or HistoryAggregateType.Interpolative
- or HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad => AggregateFamily.TimeWeighted,
+ or HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad
+ or HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad => AggregateFamily.TimeWeighted,
_ => null,
};
///
- /// The seventeen aggregates the SQL backends serve — every member assigns a family, derived
+ /// The nineteen aggregates the SQL backends serve — every member assigns a family, derived
/// from it so the set and the dispatch can never disagree. Both SqlServerHistorianDataSource and
/// QuestDbHistorianDataSource return it as .
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/TimeWeightedAggregator.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/TimeWeightedAggregator.cs
index ab6a81c7a..a474a8ffe 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/TimeWeightedAggregator.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common/TimeWeightedAggregator.cs
@@ -4,8 +4,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
///
/// The OPC UA Part 13 time-weighted aggregates (): TimeAverage,
-/// TimeAverage2, Total, Total2, Interpolative and the duration-based PercentGood /
-/// PercentBad, evaluated over a time-ordered stream of stored samples. Both SQL backends fetch the same rows and feed them here, so the two produce identical results
+/// TimeAverage2, Total, Total2, Interpolative and the duration-based DurationGood /
+/// DurationBad / PercentGood / PercentBad, evaluated over a time-ordered stream of stored samples. Both SQL backends fetch the same rows and feed them here, so the two produce identical results
/// by construction.
///
///
@@ -50,6 +50,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
/// GetSimpleBound); time before the first sample is Bad; at the end of the data the last region runs to the
/// end of its own interval (Part 13; the SDK gives it 1 ms) and later intervals are BadNoData; an interval
/// ending before the first sample ⇒ BadNoData. Status Good; any value kind.
+/// - DurationGood / DurationBadThe same Good / Bad region time, in milliseconds (an
+/// OPC UA Duration, as the SDK reports it) — PercentGood / PercentBad × interval ÷ 100, with the same
+/// BadNoData, status and Partial rules (#820).
/// - InterpolativeThe interpolated-bounding signal's value at each interval start —
/// for a reverse read, at End − i·interval (Part 13 anchors a reverse read at its later time), emitted
/// latest first.
@@ -78,7 +81,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
/// interval the data starts inside in the read direction (forward: the first sample fed lies strictly within it;
/// reverse: the latest sample fed lies strictly within it, nothing after — the SDK's m_startOfData,
/// keyed on the first value it is fed); and, forward, the end-of-data interval of TimeAverage2 / Total2 /
-/// PercentGood / PercentBad (the SDK's m_endOfData, which it skips for an extrapolated TimeAverage / Total).
+/// PercentGood / PercentBad / DurationGood / DurationBad (the SDK's m_endOfData, which it skips for an extrapolated TimeAverage / Total).
/// The timestamp is the interval's (its start forward, its later edge
/// reverse); the value is a (Boolean = 0/1, Int64 as double).
///
@@ -358,14 +361,22 @@ public sealed class TimeWeightedAggregator
? Calc(_rawArea[i] / _usableTicks[i], status, endOfData)
: Calc(_rawArea[i] / TicksPerSecond, status, endOfData);
}
- case HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad:
+ case HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad
+ or HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad:
{
// No data in or around the interval (it ends before the first sample — the SDK's out-of-range test — or
- // starts after the end of the data) ⇒ BadNoData; otherwise the share of the interval's duration.
+ // starts after the end of the data) ⇒ BadNoData; otherwise the region time (Duration*, in milliseconds —
+ // an OPC UA Duration) or its share of the interval's duration (Percent*). Uncertain regions count toward
+ // neither (Part 13 §5.4.3.31-34, Mantis 0006166).
if (_firstSampleUtc is not { } first || hi < first.Ticks || (_dataEndTicks is { } de2 && lo > de2))
return AggregateEvaluator.NoData(stamp, now);
- var ticks = _aggregate == HistoryAggregateType.PercentGood ? _goodRegionTicks[i] : _badRegionTicks[i];
- return Calc(100d * ticks / duration, HistorianStatusCodes.Good, endOfData);
+ var ticks = _aggregate is HistoryAggregateType.PercentGood or HistoryAggregateType.DurationGood
+ ? _goodRegionTicks[i]
+ : _badRegionTicks[i];
+ var value = _aggregate is HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad
+ ? (double)ticks / TimeSpan.TicksPerMillisecond
+ : 100d * ticks / duration;
+ return Calc(value, HistorianStatusCodes.Good, endOfData);
}
default:
throw new HistoryAggregateNotSupportedException(_aggregate);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs
index 5843d882b..590e0f8ec 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs
@@ -47,7 +47,7 @@ internal static class SqlAggregateQueries
private const string NonNumericKinds = "4, 5";
///
- /// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here).
+ /// Average / Minimum / Maximum / Count / Range (DurationGood / DurationBad / PercentGood / PercentBad and Total are time-weighted, not here).
/// Family columns: 6 AvgValue, 7 MinValue, 8 MaxValue, 9 SumValue over the Good numeric samples (float,
/// NULL when none — Part 13 and the SDK compute these over Good values only), then 10 UncMin, 11 UncMax
/// over the Uncertain ones (the Part 13 Minimum/Maximum/Range status rule, ), and
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs
index 9dd0d75c3..3375ddd30 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs
@@ -359,7 +359,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
///
public bool SupportsReverseEvents => true;
- // The seventeen — shared with the other SQL backend.
+ // The nineteen — shared with the other SQL backend.
///
public IReadOnlySet SupportedAggregates => AggregateFamilies.Served;
@@ -463,7 +463,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
// bucket start (its later edge on a reverse read), except Minimum/MaximumActualTime (the sample's time; ties ⇒
// earliest) and Start/End. Every aggregate but Average sets the Partial bit on the bucket the tag's earliest
// sample lies strictly inside (forward) or its latest sample lies in (reverse: strictly inside).
- // PercentGood / PercentBad are duration-based (time-weighted family, below). Count counts Good samples (value-based status; 0/Good on an empty bucket inside the tag's
+ // DurationGood / DurationBad / PercentGood / PercentBad are duration-based (time-weighted family, below). Count counts Good samples (value-based status; 0/Good on an empty bucket inside the tag's
// data range), Start/End are the raw first/last sample of any quality, Delta is Uncertain only when the bucket's
// first or last sample is not usable (see ).
//
@@ -475,7 +475,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
// , which fills every interval (an empty one between bounds gets a value).
//
//
- // The aggregate is not one of the seventeen served.
+ // The aggregate is not one of the nineteen served.
// The window holds more than intervals.
///
public async Task ReadProcessedAsync(
diff --git a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs
index 0a98d1a20..1baea8723 100644
--- a/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs
+++ b/tests/Client/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/Models/ModelConstructionTests.cs
@@ -132,10 +132,12 @@ public class ModelConstructionTests
((int)ConnectionState.Reconnecting).ShouldBe(3);
}
- /// AggregateType holds the seventeen served aggregates (the parity test pins which).
+ /// AggregateType holds the nineteen served aggregates (the parity test pins which).
[Fact]
public void AggregateType_Enum_HasExpectedValues()
{
- Enum.GetValues().Length.ShouldBe(17);
+ Enum.GetValues().Length.ShouldBe(19);
+ ((int)AggregateType.DurationGood).ShouldBe(18);
+ ((int)AggregateType.DurationBad).ShouldBe(19);
}
}
\ No newline at end of file
diff --git a/tests/Drivers/Historian.Conformance/ProcessedConformance.cs b/tests/Drivers/Historian.Conformance/ProcessedConformance.cs
index 71858e26c..cb220b5b9 100644
--- a/tests/Drivers/Historian.Conformance/ProcessedConformance.cs
+++ b/tests/Drivers/Historian.Conformance/ProcessedConformance.cs
@@ -713,8 +713,6 @@ public abstract class ProcessedConformance : ConformanceBase
[InlineData(HistoryAggregateType.StartBound)]
[InlineData(HistoryAggregateType.EndBound)]
[InlineData(HistoryAggregateType.DeltaBounds)]
- [InlineData(HistoryAggregateType.DurationGood)]
- [InlineData(HistoryAggregateType.DurationBad)]
[InlineData(HistoryAggregateType.WorstQuality)]
[InlineData(HistoryAggregateType.NumberOfTransitions)]
[InlineData(HistoryAggregateType.AnnotationCount)]
diff --git a/tests/Drivers/Historian.Conformance/QualityMixConformance.cs b/tests/Drivers/Historian.Conformance/QualityMixConformance.cs
index 051f5b6ee..daf57deeb 100644
--- a/tests/Drivers/Historian.Conformance/QualityMixConformance.cs
+++ b/tests/Drivers/Historian.Conformance/QualityMixConformance.cs
@@ -11,7 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
/// 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.
+/// 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.
///
public abstract class QualityMixConformance : ConformanceBase
{
@@ -155,15 +156,52 @@ public abstract class QualityMixConformance : ConformanceBase
}
[Fact]
- public async Task DurationGood_and_DurationBad_are_refused_rather_than_answered_from_the_baseline_rule()
+ public async Task DurationGood_and_DurationBad_over_a_Good_Uncertain_Bad_mix_are_the_Part_13_region_times()
{
- // 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.
+ // #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();
- 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);
+ 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]
@@ -182,7 +220,8 @@ public abstract class QualityMixConformance : ConformanceBase
await B.SeedAsync("Q", Q);
await AssertMatchesOracle("Q", Rounded(Q), from, to, intervalS,
[
- HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad, HistoryAggregateType.Count,
+ 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,
@@ -192,15 +231,18 @@ public abstract class QualityMixConformance : ConformanceBase
[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. Left out, each a documented or pinned divergence:
+ // 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.Count,
+ HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad, HistoryAggregateType.DurationGood,
+ HistoryAggregateType.DurationBad, HistoryAggregateType.Count,
HistoryAggregateType.Average, HistoryAggregateType.Start, HistoryAggregateType.End, HistoryAggregateType.Delta,
HistoryAggregateType.Interpolative,
]);
@@ -229,6 +271,28 @@ public abstract class QualityMixConformance : ConformanceBase
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)
{
diff --git a/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs b/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs
index c9e3818c1..b791aa83b 100644
--- a/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs
+++ b/tests/Drivers/Historian.Conformance/SdkAggregateOracle.cs
@@ -18,16 +18,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
///
/// Shared (source-linked) by Sql.Common.Tests and both SQL backends' integration suites.
///
-/// PercentGood is the one aggregate not taken as-is (#796). The 1.5.378.182 baseline changed
+/// PercentGood (and, since #820, DurationGood) are the aggregates not taken as-is (#796). The 1.5.378.182 baseline changed
/// StatusAggregateCalculator.ComputeDurationGoodBad (1.5.378.106 and earlier counted Good regions only):
/// with TreatUncertainAsBad = false it counts Uncertain regions as Good time (as Bad when true). Part 13
/// keeps Uncertain out of both (§5.4.3.31 DurationGood counts Good regions; §5.4.3.33 PercentGood =
/// DurationGood ÷ interval; Mantis 0006166, closed "no change required": an all-Uncertain interval is
/// PercentGood 0 and PercentBad 0), and so does ours. therefore answers PercentGood
-/// through : the SAME baseline calculator, region machinery (simple
+/// (and DurationGood) through : the SAME baseline calculator, region machinery (simple
/// bounds, end-of-data 1 ms region, status and Partial bits) and server-default configuration, counting only
-/// Good regions — exactly what the pre-.182 oracle computed. PercentBad still counts Bad regions only (unchanged
-/// by .182) and is compared as-is. runs the baseline untouched, for the test that pins
+/// Good regions — exactly what the pre-.182 oracle computed. PercentBad / DurationBad still count Bad regions only
+/// (unchanged by .182) and are compared as-is. runs the baseline untouched, for the test that pins
/// the divergence.
///
///
@@ -55,7 +55,7 @@ public static class SdkAggregateOracle
///
/// Runs the Part 13 oracle for over : the baseline calculator,
- /// except PercentGood, which counts Good regions only (see the remarks, #796).
+ /// except PercentGood / DurationGood, which count Good regions only (see the remarks, #796/#820).
///
/// The aggregate.
/// Request start (later than for a reverse read).
@@ -65,9 +65,10 @@ public static class SdkAggregateOracle
/// The oracle's processed values, in the order it returns them.
public static IReadOnlyList Run(HistoryAggregateType aggregate, DateTime start, DateTime end, TimeSpan interval,
IEnumerable<(DateTime Time, HistorianValue Value, uint Status)> samples) =>
- aggregate == HistoryAggregateType.PercentGood
- ? RunCore(new LegacyCalculatorAdapter(new Part13PercentGoodCalculator(ObjectIds.AggregateFunction_PercentGood,
- start, end, interval.TotalMilliseconds, false, DefaultConfiguration, Telemetry)), start, end, samples)
+ aggregate is HistoryAggregateType.PercentGood or HistoryAggregateType.DurationGood
+ ? RunCore(new LegacyCalculatorAdapter(new Part13GoodCalculator(AggregateId(aggregate),
+ start, end, interval.TotalMilliseconds, false, DefaultConfiguration, Telemetry,
+ usePercent: aggregate == HistoryAggregateType.PercentGood)), start, end, samples)
: RunSdkAsIs(aggregate, start, end, interval, samples);
///
@@ -155,12 +156,14 @@ public static class SdkAggregateOracle
}
///
- /// The baseline with PercentGood counting Good regions only — Part 13
- /// §5.4.3.31/§5.4.3.33, and the 1.5.378.106 rule. Everything else (bounds, regions, Partial, status) is the
- /// baseline's own code, run under the same configuration as PercentBad.
+ /// The baseline with PercentGood / DurationGood counting Good
+ /// regions only — Part 13 §5.4.3.31/§5.4.3.33, and the 1.5.378.106 rule. Everything else (bounds, regions, Partial,
+ /// status, the millisecond Duration unit) is the baseline's own code, run under the same configuration as
+ /// PercentBad / DurationBad.
///
- private sealed class Part13PercentGoodCalculator(NodeId aggregateId, DateTime startTime, DateTime endTime,
- double processingInterval, bool stepped, AggregateConfiguration configuration, ITelemetryContext telemetry)
+ private sealed class Part13GoodCalculator(NodeId aggregateId, DateTime startTime, DateTime endTime,
+ double processingInterval, bool stepped, AggregateConfiguration configuration, ITelemetryContext telemetry,
+ bool usePercent)
: BaselineStatusAggregateCalculator(aggregateId, startTime, endTime, processingInterval, stepped, configuration, telemetry)
{
protected override LegacyDataValue ComputeValue(TimeSlice slice)
@@ -178,7 +181,7 @@ public static class SdkAggregateOracle
var value = new LegacyDataValue
{
- WrappedValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.SdkValueBoundary.FromClr(good / total * 100),
+ WrappedValue = ZB.MOM.WW.OtOpcUa.OpcUaServer.SdkValueBoundary.FromClr(usePercent ? good / total * 100 : good),
SourceTimestamp = GetTimestamp(slice),
ServerTimestamp = GetTimestamp(slice),
};
@@ -223,6 +226,8 @@ public static class SdkAggregateOracle
HistoryAggregateType.Count => ObjectIds.AggregateFunction_Count,
HistoryAggregateType.PercentGood => ObjectIds.AggregateFunction_PercentGood,
HistoryAggregateType.PercentBad => ObjectIds.AggregateFunction_PercentBad,
+ HistoryAggregateType.DurationGood => ObjectIds.AggregateFunction_DurationGood,
+ HistoryAggregateType.DurationBad => ObjectIds.AggregateFunction_DurationBad,
HistoryAggregateType.Start => ObjectIds.AggregateFunction_Start,
HistoryAggregateType.End => ObjectIds.AggregateFunction_End,
HistoryAggregateType.Delta => ObjectIds.AggregateFunction_Delta,
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ProcessedReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ProcessedReadTests.cs
index a63e46d0d..a47d3e4f8 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ProcessedReadTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/ProcessedReadTests.cs
@@ -10,21 +10,23 @@ public sealed class ProcessedReadTests
[Fact]
public void Every_enum_member_is_either_served_or_refused()
{
- // Seventeen served (#671 added TimeAverage, TimeAverage2, Interpolative and Total2, and moved Total to the
- // time-weighted family; #680 moved PercentGood/PercentBad there too — they are duration-based); anything appended to
- // the enum later falls to the refusal (total default).
- Enum.GetValues().Count(a => AggregateFamilies.FamilyOf(a) is not null).ShouldBe(17);
+ // Nineteen served (#671 added TimeAverage, TimeAverage2, Interpolative and Total2, and moved Total to the
+ // time-weighted family; #680 moved PercentGood/PercentBad there too — they are duration-based; #820 added
+ // DurationGood/DurationBad, the same region time); anything appended to the enum later falls to the refusal
+ // (total default).
+ Enum.GetValues().Count(a => AggregateFamilies.FamilyOf(a) is not null).ShouldBe(19);
Enum.GetValues().Where(a => AggregateFamilies.FamilyOf(a) == AggregateFamily.TimeWeighted)
.ShouldBe([HistoryAggregateType.Total, HistoryAggregateType.Interpolative, HistoryAggregateType.TimeAverage,
- HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad, HistoryAggregateType.TimeAverage2,
- HistoryAggregateType.Total2]);
+ HistoryAggregateType.DurationGood, HistoryAggregateType.DurationBad, HistoryAggregateType.PercentGood,
+ HistoryAggregateType.PercentBad, HistoryAggregateType.TimeAverage2, HistoryAggregateType.Total2],
+ ignoreOrder: true);
}
[Fact]
public void Served_is_exactly_the_members_with_a_family()
{
// #682: the advertised set and the dispatch are one definition — a member is served iff it has a family.
- AggregateFamilies.Served.Count.ShouldBe(17);
+ AggregateFamilies.Served.Count.ShouldBe(19);
foreach (var a in Enum.GetValues())
AggregateFamilies.Served.Contains(a).ShouldBe(AggregateFamilies.FamilyOf(a) is not null, a.ToString());
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs
index fa8d21216..d7e768fc2 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/SdkAggregateParityTests.cs
@@ -213,7 +213,8 @@ public sealed class SdkAggregateParityTests
///
/// Applies the documented divergences listed on the class (other than truncation): strips the Calculated bit from our
/// BadNoData; maps our generic-Bad TimeAverage2/Total2 outage interval to the SDK's BadNoData; takes the
- /// SDK's value for the end-of-data interval of TimeAverage2/Total2/PercentGood/PercentBad (its 1 ms artifact).
+ /// SDK's value for the end-of-data interval of TimeAverage2/Total2/PercentGood/PercentBad/DurationGood/DurationBad
+ /// (its 1 ms artifact).
///
private static IReadOnlyList MaskDocumented(HistoryAggregateType agg, IReadOnlyList ours,
IReadOnlyList sdk, double from, double to, double intervalS, StoredSample[] data)
@@ -225,7 +226,8 @@ public sealed class SdkAggregateParityTests
var dataEnds = endsInWindow && data.Single(x => x.TimeUtc.Ticks == last).IsUsable;
var outageEnds = endsInWindow && !dataEnds;
var boolean = data.Any(x => x.Value.Kind == HistorianValueKind.Boolean);
- var percent = agg is HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad;
+ var percent = agg is HistoryAggregateType.PercentGood or HistoryAggregateType.PercentBad
+ or HistoryAggregateType.DurationGood or HistoryAggregateType.DurationBad;
for (long i = 0; i < plan.BucketCount; i++)
{
var k = (int)(plan.Reversed ? plan.BucketCount - 1 - i : i);
@@ -244,7 +246,7 @@ public sealed class SdkAggregateParityTests
&& 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)
+ if (dataEnds && (twoVariant || percent)
&& last >= plan.LowerTicks(i) && last < plan.UpperTicks(i) && sdk[k].Value is not null)
list[k] = list[k] with { Value = sdk[k].Value };
// An outage marker as the last sample: ours runs the outage to the window end (no end-of-data Partial on the
@@ -290,7 +292,7 @@ public sealed class SdkAggregateParityTests
{
HistoryAggregateType.TimeAverage, HistoryAggregateType.TimeAverage2, HistoryAggregateType.Total,
HistoryAggregateType.Total2, HistoryAggregateType.Interpolative, HistoryAggregateType.PercentGood,
- HistoryAggregateType.PercentBad,
+ HistoryAggregateType.PercentBad, HistoryAggregateType.DurationGood, HistoryAggregateType.DurationBad,
})
foreach (var (name, data) in new[]
{
@@ -473,6 +475,98 @@ public sealed class SdkAggregateParityTests
(100 - Pct(AsIs(HistoryAggregateType.PercentBad, 0, 20, data, cfg))).ShouldBe(50d, 1e-9);
}
+ ///
+ /// #821: data that starts inside [0, 20) at +5 with an Uncertain first sample — the #810 QualityMix tag QP:
+ /// Uncertain 10 at +5, Good 11 at +12, Uncertain 12 at +25, nothing after.
+ ///
+ private static readonly StoredSample[] UncertainFirstLateStart =
+ [
+ S(5, HistorianValue.FromDouble(10), Uncertain),
+ S(12, HistorianValue.FromDouble(11), Good),
+ S(25, HistorianValue.FromDouble(12), Uncertain),
+ ];
+
+ /// The control: the same data with a Good first sample.
+ private static readonly StoredSample[] GoodFirstLateStart =
+ [S(5, HistorianValue.FromDouble(10), Good), .. UncertainFirstLateStart[1..]];
+
+ ///
+ /// Runs SDK 2.0.1's OWN stock calculator (, not the frozen 1.5.378.182 copy
+ /// the server registers) under the server-default configuration — for the #809/#821 facts only.
+ ///
+ private static List<(double? Value, uint Status)> Stock201(HistoryAggregateType agg, double from, double to,
+ StoredSample[] data)
+ {
+ var id = agg switch
+ {
+ HistoryAggregateType.Total => Opc.Ua.ObjectIds.AggregateFunction_Total,
+ HistoryAggregateType.TimeAverage => Opc.Ua.ObjectIds.AggregateFunction_TimeAverage,
+ HistoryAggregateType.DurationGood => Opc.Ua.ObjectIds.AggregateFunction_DurationGood,
+ HistoryAggregateType.PercentGood => Opc.Ua.ObjectIds.AggregateFunction_PercentGood,
+ _ => throw new ArgumentOutOfRangeException(nameof(agg)),
+ };
+ var calc = Opc.Ua.Server.Aggregators.CreateStandardCalculator(id, At(from), At(to),
+ (At(to) - At(from)).Duration().TotalMilliseconds, false, SdkAggregateOracle.DefaultConfiguration,
+ Opc.Ua.DefaultTelemetry.Create(_ => { }))!;
+ var ordered = data.OrderBy(s => s.TimeUtc).ToList();
+ if (to < from) ordered.Reverse();
+ foreach (var s in ordered)
+ calc.QueueRawValue(new Opc.Ua.DataValue(Opc.Ua.Variant.From(s.Value.Number), s.Status, s.TimeUtc, s.TimeUtc));
+ var results = new List<(double?, uint)>();
+ while (calc.TryGetProcessedValue(true, out var dv))
+ results.Add((dv.WrappedValue.TryGetValue(out double d) ? d : null, dv.StatusCode.Code));
+ return results;
+ }
+
+ [Theory]
+ [InlineData(0, 20)]
+ [InlineData(20, 0)]
+ public void An_Uncertain_first_sample_in_a_late_start_interval_makes_the_SDK_integrate_the_time_before_the_data(
+ double from, double to)
+ {
+ // #821, pinned divergence (pending the #809/#821 owner decision — this test records, it does not choose).
+ // Part 13 §5.4.3.3/§5.4.3.8: the interpolated-bounding signal is defined from the first sample only — 10 → 11 over
+ // [5,12) (73.5) and 11 → 11 + 8/13 over [12,20) (4·(22 + 8/13)) ⇒ Total 163.96…, TimeAverage = Total ÷ 15 s.
+ const double part13Total = 73.5 + 88 + 32d / 13;
+ Pct(Ours(HistoryAggregateType.Total, from, to, 0, UncertainFirstLateStart)).ShouldBe(part13Total, 1e-9);
+ Pct(Ours(HistoryAggregateType.TimeAverage, from, to, 0, UncertainFirstLateStart)).ShouldBe(part13Total / 15, 1e-9);
+
+ // The frozen 1.5.378.182 calculators — exactly what the server registers for live AggregateFilter items
+ // (CompatibilityAggregateFactory; Sdk2AggregateCompatibilityTests proves the runtime manager serves the same code)
+ // — integrate the uncovered [0,5) as a ramp 0 → 10 (+25) and divide TimeAverage by the WHOLE 20 s interval.
+ Pct(AsIs(HistoryAggregateType.Total, from, to, UncertainFirstLateStart)).ShouldBe(part13Total + 25, 1e-9);
+ Pct(AsIs(HistoryAggregateType.TimeAverage, from, to, UncertainFirstLateStart)).ShouldBe((part13Total + 25) / 20, 1e-9);
+ // SDK 2.0.1's own stock calculators answer the same: the upgrade did not change this.
+ Stock201(HistoryAggregateType.Total, from, to, UncertainFirstLateStart).Single().Value!.Value
+ .ShouldBe(part13Total + 25, 1e-9);
+ Stock201(HistoryAggregateType.TimeAverage, from, to, UncertainFirstLateStart).Single().Value!.Value
+ .ShouldBe((part13Total + 25) / 20, 1e-9);
+ // Status agrees everywhere (Uncertain — an Uncertain value is used and the interval is not fully covered).
+ (AsIs(HistoryAggregateType.Total, from, to, UncertainFirstLateStart).Single().StatusCode & StatusBits.CodeBitsMask)
+ .ShouldBe(HistorianStatusCodes.UncertainDataSubNormal);
+ (Ours(HistoryAggregateType.Total, from, to, 0, UncertainFirstLateStart).Single().StatusCode & StatusBits.CodeBitsMask)
+ .ShouldBe(HistorianStatusCodes.UncertainDataSubNormal);
+
+ // Control: the SAME data with a Good first sample — all three agree on the Part 13 answer, so the trigger is the
+ // Uncertain first sample, not the late start.
+ foreach (var agg in new[] { HistoryAggregateType.Total, HistoryAggregateType.TimeAverage })
+ {
+ var expected = agg == HistoryAggregateType.Total ? part13Total : part13Total / 15;
+ Pct(Ours(agg, from, to, 0, GoodFirstLateStart)).ShouldBe(expected, 1e-9);
+ Pct(AsIs(agg, from, to, GoodFirstLateStart)).ShouldBe(expected, 1e-9);
+ Stock201(agg, from, to, GoodFirstLateStart).Single().Value!.Value.ShouldBe(expected, 1e-9);
+ }
+
+ // #809 on the same input: DurationGood / PercentGood — Part 13 (ours) and stock SDK 2.0.1 count Good regions only
+ // ([12,20) = 8 s); the frozen live calculators also count the Uncertain [5,12) as Good (15 s).
+ Pct(Ours(HistoryAggregateType.DurationGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(8_000d, 1e-9);
+ Stock201(HistoryAggregateType.DurationGood, from, to, UncertainFirstLateStart).Single().Value!.Value.ShouldBe(8_000d, 1e-9);
+ Pct(AsIs(HistoryAggregateType.DurationGood, from, to, UncertainFirstLateStart)).ShouldBe(15_000d, 1e-9);
+ Pct(Ours(HistoryAggregateType.PercentGood, from, to, 0, UncertainFirstLateStart)).ShouldBe(40d, 1e-9);
+ Stock201(HistoryAggregateType.PercentGood, from, to, UncertainFirstLateStart).Single().Value!.Value.ShouldBe(40d, 1e-9);
+ Pct(AsIs(HistoryAggregateType.PercentGood, from, to, UncertainFirstLateStart)).ShouldBe(75d, 1e-9);
+ }
+
/// #684: Good 10 / 30 with an Uncertain value BELOW the Good minimum (+4) and one ABOVE the maximum (+6).
private static readonly StoredSample[] UncertainBeyond =
[
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/TimeWeightedAggregatorTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/TimeWeightedAggregatorTests.cs
index 6965e13d0..6bb87e2d8 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/TimeWeightedAggregatorTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/TimeWeightedAggregatorTests.cs
@@ -274,6 +274,33 @@ public sealed class TimeWeightedAggregatorTests
bad.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalcPartial, NoDataCalc]);
}
+ [Fact]
+ public void Duration_aggregates_are_the_percent_regions_in_milliseconds()
+ {
+ // #820: DurationGood / DurationBad are the same Good / Bad region time as PercentGood / PercentBad, as an OPC UA
+ // Duration (ms, the SDK's unit), with the same statuses and Partial bits — tag A over [0,80) at 20 s.
+ var good = Run(HistoryAggregateType.DurationGood, 0, 80, 20, A);
+ Values(good, 20_000, 10_000, 20_000, null);
+ good.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalcPartial, NoDataCalc]);
+ good.Where(x => x.Value is not null).ShouldAllBe(x => x.Value is double);
+ var bad = Run(HistoryAggregateType.DurationBad, 0, 80, 20, A);
+ Values(bad, 0, 10_000, 0, null);
+ bad.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalcPartial, NoDataCalc]);
+
+ // Uncertain time counts toward neither (Mantis 0006166): −3 G, +4 outage, +6 U, +12 G, +30 G over [0,20) at 10 s ⇒
+ // B0 [0,4) Uncertain (bound into the outage), [4,6) Bad, [6,10) Uncertain; B1 [10,12) Uncertain, [12,20) Good.
+ StoredSample[] s =
+ [
+ S(-3, HistorianValue.FromDouble(10), Good), S(4, HistorianValue.Empty, BadNoCommunication),
+ S(6, HistorianValue.FromDouble(30), Uncertain), S(12, HistorianValue.FromDouble(3), Good),
+ S(30, HistorianValue.FromDouble(3), Good),
+ ];
+ Values(Run(HistoryAggregateType.DurationGood, 0, 20, 10, s), 0, 8_000);
+ Values(Run(HistoryAggregateType.DurationBad, 0, 20, 10, s), 2_000, 0);
+ // A truncated interval measures its own (shorter) span: [10,15) holds [10,12) Uncertain + [12,15) Good.
+ Values(Run(HistoryAggregateType.DurationGood, 0, 15, 10, s), 0, 3_000);
+ }
+
[Fact]
public void With_no_end_bound_the_last_region_runs_to_the_interval_end()
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs
index 21f9eb95d..1fdbc870f 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/HistorianBackendRegistryTests.cs
@@ -130,7 +130,7 @@ public sealed class HistorianBackendRegistryTests
o.TablePrefix.ShouldBe("rig_");
}
- /// The seventeen aggregates the SQL backends serve, spelled out (not derived) so a drift in either the
+ /// The nineteen aggregates the SQL backends serve, spelled out (not derived) so a drift in either the
/// family table or a backend's declaration fails here (#682).
private static readonly HistoryAggregateType[] SqlServed =
[
@@ -139,6 +139,7 @@ public sealed class HistorianBackendRegistryTests
HistoryAggregateType.MinimumActualTime, HistoryAggregateType.MaximumActualTime, HistoryAggregateType.TimeAverage,
HistoryAggregateType.TimeAverage2, HistoryAggregateType.Total, HistoryAggregateType.Total2,
HistoryAggregateType.Interpolative, HistoryAggregateType.PercentGood, HistoryAggregateType.PercentBad,
+ HistoryAggregateType.DurationGood, HistoryAggregateType.DurationBad,
];
[Fact]
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests.cs
new file mode 100644
index 000000000..6dffd4484
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests.cs
@@ -0,0 +1,55 @@
+using Opc.Ua;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
+
+///
+/// #821 / #809 facts, through the BOOTED server's runtime AggregateManager — the calculators a live
+/// AggregateFilter monitored item gets, under the server's own default configuration. This pins current behaviour for
+/// the owner decision; it does not choose. The HistoryRead (Part 13) answers and SDK 2.0.1's stock answers for the
+/// same input are pinned beside it in SdkAggregateParityTests.
+///
+public sealed class LiveAggregateLateStartFactsTests
+{
+ private static readonly DateTime T0 = new(2026, 11, 11, 8, 0, 0, DateTimeKind.Utc);
+
+ /// The #810 QualityMix tag QP: Uncertain 10 at +5, Good 11 at +12, Uncertain 12 at +25, nothing after.
+ private static readonly (double Seconds, double Value, uint Status)[] Qp =
+ [
+ (5, 10, 0x40000000u), (12, 11, 0x00000000u), (25, 12, 0x40000000u), // Uncertain, Good, Uncertain
+ ];
+
+ [Fact]
+ public async Task Live_AggregateFilter_calculators_on_an_Uncertain_first_late_start_interval()
+ {
+ await using var rig = await ServerEventRig.StartAsync("AggregateLateStart", encrypted: false,
+ TestContext.Current.CancellationToken);
+ var manager = rig.Server.CurrentInstance.AggregateManager;
+ var config = manager.GetDefaultConfiguration(NodeId.Null);
+ config.TreatUncertainAsBad.ShouldBeFalse("the server pins TreatUncertainAsBad = false (#782)");
+
+ double One(NodeId aggregate)
+ {
+ var calc = manager.CreateCalculator(aggregate, T0, T0.AddSeconds(20), 20_000, false, config);
+ calc.ShouldNotBeNull();
+ foreach (var (s, v, st) in Qp)
+ calc.QueueRawValue(new DataValue(Variant.From(v), st, T0.AddSeconds(s), T0.AddSeconds(s)));
+ calc.TryGetProcessedValue(true, out var dv).ShouldBeTrue();
+ dv.WrappedValue.TryGetValue(out double d).ShouldBeTrue();
+ return d;
+ }
+
+ // Part 13 (HistoryRead on SqlServer/QuestDB): Total = 73.5 + 88 + 32/13 = 163.96…, TimeAverage = Total ÷ 15 s.
+ // The live calculators integrate the uncovered [0,5) as a ramp from 0 (+25) and divide by the whole 20 s.
+ const double part13Total = 73.5 + 88 + 32d / 13;
+ One(ObjectIds.AggregateFunction_Total).ShouldBe(part13Total + 25, 1e-9); // 188.96…
+ One(ObjectIds.AggregateFunction_TimeAverage).ShouldBe((part13Total + 25) / 20, 1e-9); // 9.448… (Part 13: 10.93…)
+ // #809: the Uncertain [5,12) counts as Good live (HistoryRead: 8 000 ms / 40 %).
+ One(ObjectIds.AggregateFunction_DurationGood).ShouldBe(15_000d, 1e-9);
+ One(ObjectIds.AggregateFunction_PercentGood).ShouldBe(75d, 1e-9);
+ // DurationBad / PercentBad agree with HistoryRead: the 5 s before the data is Bad.
+ One(ObjectIds.AggregateFunction_DurationBad).ShouldBe(5_000d, 1e-9);
+ One(ObjectIds.AggregateFunction_PercentBad).ShouldBe(25d, 1e-9);
+ }
+}