From e7b485c0a2646443c7715d0f75d3e199a1f888ec Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 11 Nov 2026 01:28:54 -0500 Subject: [PATCH] feat(historian): serve DurationGood/DurationBad on the SQL backends; pin the #821 late-start facts (#820, #821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 --- .../Commands/HistoryReadCommand.cs | 2 +- .../Helpers/AggregateTypeMapper.cs | 2 + .../Models/AggregateType.cs | 10 +- .../QuestDbAggregateQueries.cs | 2 +- .../QuestDbHistorianDataSource.cs | 2 +- .../AggregateFamily.cs | 8 +- .../TimeWeightedAggregator.cs | 25 +++-- .../SqlAggregateQueries.cs | 2 +- .../SqlServerHistorianDataSource.cs | 6 +- .../Models/ModelConstructionTests.cs | 6 +- .../ProcessedConformance.cs | 2 - .../QualityMixConformance.cs | 84 +++++++++++++-- .../SdkAggregateOracle.cs | 33 +++--- .../ProcessedReadTests.cs | 16 +-- .../SdkAggregateParityTests.cs | 102 +++++++++++++++++- .../TimeWeightedAggregatorTests.cs | 27 +++++ .../HistorianBackendRegistryTests.cs | 3 +- .../LiveAggregateLateStartFactsTests.cs | 55 ++++++++++ 18 files changed, 328 insertions(+), 59 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/LiveAggregateLateStartFactsTests.cs 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); + } +}