feat(historian): serve DurationGood/DurationBad on the SQL backends; pin the #821 late-start facts (#820, #821)

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-11-11 01:28:54 -05:00
parent 5bbe1e3c7a
commit e7b485c0a2
18 changed files with 328 additions and 59 deletions
@@ -48,7 +48,7 @@ public class HistoryReadCommand : CommandBase
/// <summary>
/// Gets the optional aggregate name used when the operator wants processed history instead of raw samples.
/// </summary>
[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; }
/// <summary>
@@ -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.")
};
}
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.OtOpcUa.Client.Shared.Models;
/// <summary>
/// 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 (<c>AggregateFamilies.Served</c>; a parity test pins it). The Gateway backend serves a subset and refuses
/// the rest with <c>BadAggregateNotSupported</c>.
/// </summary>
@@ -60,5 +60,11 @@ public enum AggregateType
PercentGood = 16,
/// <summary>Percentage of the interval with Bad data (Part 13 PercentBad).</summary>
PercentBad = 17
PercentBad = 17,
/// <summary>Time of the interval with Good data, in milliseconds (Part 13 DurationGood).</summary>
DurationGood = 18,
/// <summary>Time of the interval with Bad data, in milliseconds (Part 13 DurationBad).</summary>
DurationBad = 19
}
@@ -57,7 +57,7 @@ internal static class QuestDbAggregateQueries
private static readonly string EmptyKind = ((byte)HistorianValueKind.Empty).ToString(CultureInfo.InvariantCulture);
/// <summary>
/// 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: <c>6 avg_value, 7 min_value, 8 max_value, 9 sum_value</c> over the <b>Good</b> numeric samples
/// (DOUBLE, NULL when none — Part 13 / the SDK), then <c>10 unc_min, 11 unc_max</c> over the Uncertain ones (the
/// Part 13 Minimum/Maximum/Range status rule) and <c>12 good_non_numeric_count</c>.
@@ -434,7 +434,7 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
/// <inheritdoc />
public bool SupportsReverseEvents => true;
// The seventeen AggregateFamilies.Served - shared with the other SQL backend.
// The nineteen AggregateFamilies.Served - shared with the other SQL backend.
/// <inheritdoc />
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => AggregateFamilies.Served;
@@ -16,7 +16,8 @@ public enum AggregateFamily
ActualTime,
/// <summary>
/// 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 <see cref="TimeWeightedAggregator"/>
/// 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,
};
/// <summary>
/// The seventeen aggregates the SQL backends serve — every member <see cref="FamilyOf"/> assigns a family, derived
/// The nineteen aggregates the SQL backends serve — every member <see cref="FamilyOf"/> assigns a family, derived
/// from it so the set and the dispatch can never disagree. Both <c>SqlServerHistorianDataSource</c> and
/// <c>QuestDbHistorianDataSource</c> return it as <see cref="IHistorianDataSource.SupportedAggregates"/>.
/// </summary>
@@ -4,8 +4,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
/// <summary>
/// The OPC UA Part 13 time-weighted aggregates (<see cref="AggregateFamily.TimeWeighted"/>): <c>TimeAverage</c>,
/// <c>TimeAverage2</c>, <c>Total</c>, <c>Total2</c>, <c>Interpolative</c> and the duration-based <c>PercentGood</c> /
/// <c>PercentBad</c>, 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
/// <c>TimeAverage2</c>, <c>Total</c>, <c>Total2</c>, <c>Interpolative</c> and the duration-based <c>DurationGood</c> /
/// <c>DurationBad</c> / <c>PercentGood</c> / <c>PercentBad</c>, 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.
/// </summary>
/// <remarks>
@@ -50,6 +50,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
/// <c>GetSimpleBound</c>); 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 <c>BadNoData</c>; an interval
/// ending before the first sample ⇒ <c>BadNoData</c>. Status Good; any value kind.</description></item>
/// <item><term>DurationGood / DurationBad</term><description>The same Good / Bad region time, in milliseconds (an
/// OPC UA <c>Duration</c>, as the SDK reports it) — PercentGood / PercentBad × interval ÷ 100, with the same
/// <c>BadNoData</c>, status and Partial rules (#820).</description></item>
/// <item><term>Interpolative</term><description>The interpolated-bounding signal's value at each interval start —
/// for a reverse read, at <c>End − i·interval</c> (Part 13 anchors a reverse read at its later time), emitted
/// latest first.</description></item>
@@ -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 <c>m_startOfData</c>,
/// keyed on the first value it is fed); and, forward, the end-of-data interval of TimeAverage2 / Total2 /
/// PercentGood / PercentBad (the SDK's <c>m_endOfData</c>, which it skips for an extrapolated TimeAverage / Total).
/// PercentGood / PercentBad / DurationGood / DurationBad (the SDK's <c>m_endOfData</c>, which it skips for an extrapolated TimeAverage / Total).
/// The timestamp is the interval's <see cref="BucketPlan.BucketTimestamp"/> (its start forward, its later edge
/// reverse); the value is a <see cref="double"/> (Boolean = 0/1, Int64 as double).
/// </para>
@@ -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);
@@ -47,7 +47,7 @@ internal static class SqlAggregateQueries
private const string NonNumericKinds = "4, 5";
/// <summary>
/// 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: <c>6 AvgValue, 7 MinValue, 8 MaxValue, 9 SumValue</c> over the <b>Good</b> numeric samples (float,
/// NULL when none — Part 13 and the SDK compute these over Good values only), then <c>10 UncMin, 11 UncMax</c>
/// over the Uncertain ones (the Part 13 Minimum/Maximum/Range status rule, <see cref="AggregateEvaluator"/>), and
@@ -359,7 +359,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
/// <inheritdoc />
public bool SupportsReverseEvents => true;
// <remarks>The seventeen <see cref="AggregateFamilies.Served"/> — shared with the other SQL backend.</remarks>
// <remarks>The nineteen <see cref="AggregateFamilies.Served"/> — shared with the other SQL backend.</remarks>
/// <inheritdoc />
public IReadOnlySet<HistoryAggregateType> 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 <see cref="AggregateEvaluator"/>).
// </para>
@@ -475,7 +475,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
// <see cref="TimeWeightedAggregator"/>, which fills every interval (an empty one between bounds gets a value).
// </para>
// </remarks>
// <exception cref="HistoryAggregateNotSupportedException">The aggregate is not one of the seventeen served.</exception>
// <exception cref="HistoryAggregateNotSupportedException">The aggregate is not one of the nineteen served.</exception>
// <exception cref="ArgumentOutOfRangeException">The window holds more than <see cref="MaxProcessedBuckets"/> intervals.</exception>
/// <inheritdoc />
public async Task<HistoryReadResult> ReadProcessedAsync(
@@ -132,10 +132,12 @@ public class ModelConstructionTests
((int)ConnectionState.Reconnecting).ShouldBe(3);
}
/// <summary>AggregateType holds the seventeen served aggregates (the parity test pins which).</summary>
/// <summary>AggregateType holds the nineteen served aggregates (the parity test pins which).</summary>
[Fact]
public void AggregateType_Enum_HasExpectedValues()
{
Enum.GetValues<AggregateType>().Length.ShouldBe(17);
Enum.GetValues<AggregateType>().Length.ShouldBe(19);
((int)AggregateType.DurationGood).ShouldBe(18);
((int)AggregateType.DurationBad).ShouldBe(19);
}
}
@@ -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)]
@@ -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.
/// </summary>
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<HistoryAggregateNotSupportedException>(
() => 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)
{
@@ -18,16 +18,16 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
/// <remarks>
/// <para>Shared (source-linked) by <c>Sql.Common.Tests</c> and both SQL backends' integration suites.</para>
/// <para>
/// <b>PercentGood is the one aggregate not taken as-is (#796).</b> The 1.5.378.182 baseline changed
/// <b>PercentGood (and, since #820, DurationGood) are the aggregates not taken as-is (#796).</b> The 1.5.378.182 baseline changed
/// <c>StatusAggregateCalculator.ComputeDurationGoodBad</c> (1.5.378.106 and earlier counted Good regions only):
/// with TreatUncertainAsBad = false it counts Uncertain regions as <b>Good</b> 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 <i>and</i> PercentBad 0), and so does ours. <see cref="Run"/> therefore answers PercentGood
/// through <see cref="Part13PercentGoodCalculator"/>: the SAME baseline calculator, region machinery (simple
/// (and DurationGood) through <see cref="Part13GoodCalculator"/>: 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. <see cref="RunSdkAsIs"/> 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. <see cref="RunSdkAsIs"/> runs the baseline untouched, for the test that pins
/// the divergence.
/// </para>
/// <para>
@@ -55,7 +55,7 @@ public static class SdkAggregateOracle
/// <summary>
/// Runs the Part 13 oracle for <paramref name="aggregate"/> over <paramref name="samples"/>: 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).
/// </summary>
/// <param name="aggregate">The aggregate.</param>
/// <param name="start">Request start (later than <paramref name="end"/> for a reverse read).</param>
@@ -65,9 +65,10 @@ public static class SdkAggregateOracle
/// <returns>The oracle's processed values, in the order it returns them.</returns>
public static IReadOnlyList<Result> 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);
/// <summary>
@@ -155,12 +156,14 @@ public static class SdkAggregateOracle
}
/// <summary>
/// The baseline <see cref="BaselineStatusAggregateCalculator"/> with PercentGood counting <b>Good</b> 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 <see cref="BaselineStatusAggregateCalculator"/> with PercentGood / DurationGood counting <b>Good</b>
/// 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.
/// </summary>
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,
@@ -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<HistoryAggregateType>().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<HistoryAggregateType>().Count(a => AggregateFamilies.FamilyOf(a) is not null).ShouldBe(19);
Enum.GetValues<HistoryAggregateType>().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<HistoryAggregateType>())
AggregateFamilies.Served.Contains(a).ShouldBe(AggregateFamilies.FamilyOf(a) is not null, a.ToString());
}
@@ -213,7 +213,8 @@ public sealed class SdkAggregateParityTests
/// <summary>
/// Applies the documented divergences listed on the class (other than truncation): strips the Calculated bit from our
/// <c>BadNoData</c>; maps our generic-Bad TimeAverage2/Total2 outage interval to the SDK's <c>BadNoData</c>; 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).
/// </summary>
private static IReadOnlyList<DataValueSnapshot> MaskDocumented(HistoryAggregateType agg, IReadOnlyList<DataValueSnapshot> ours,
IReadOnlyList<SdkAggregateOracle.Result> 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);
}
/// <summary>
/// #821: data that starts inside [0, 20) at +5 with an <b>Uncertain</b> first sample — the #810 QualityMix tag QP:
/// Uncertain 10 at +5, Good 11 at +12, Uncertain 12 at +25, nothing after.
/// </summary>
private static readonly StoredSample[] UncertainFirstLateStart =
[
S(5, HistorianValue.FromDouble(10), Uncertain),
S(12, HistorianValue.FromDouble(11), Good),
S(25, HistorianValue.FromDouble(12), Uncertain),
];
/// <summary>The <see cref="UncertainFirstLateStart"/> control: the same data with a <b>Good</b> first sample.</summary>
private static readonly StoredSample[] GoodFirstLateStart =
[S(5, HistorianValue.FromDouble(10), Good), .. UncertainFirstLateStart[1..]];
/// <summary>
/// Runs SDK 2.0.1's OWN stock calculator (<see cref="Opc.Ua.Server.Aggregators"/>, not the frozen 1.5.378.182 copy
/// the server registers) under the server-default configuration — for the #809/#821 facts only.
/// </summary>
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);
}
/// <summary>#684: Good 10 / 30 with an Uncertain value BELOW the Good minimum (+4) and one ABOVE the maximum (+6).</summary>
private static readonly StoredSample[] UncertainBeyond =
[
@@ -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()
{
@@ -130,7 +130,7 @@ public sealed class HistorianBackendRegistryTests
o.TablePrefix.ShouldBe("rig_");
}
/// <summary>The seventeen aggregates the SQL backends serve, spelled out (not derived) so a drift in either the
/// <summary>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).</summary>
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]
@@ -0,0 +1,55 @@
using Opc.Ua;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// #821 / #809 facts, through the BOOTED server's runtime <c>AggregateManager</c> — 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 <c>SdkAggregateParityTests</c>.
/// </summary>
public sealed class LiveAggregateLateStartFactsTests
{
private static readonly DateTime T0 = new(2026, 11, 11, 8, 0, 0, DateTimeKind.Utc);
/// <summary>The #810 QualityMix tag QP: Uncertain 10 at +5, Good 11 at +12, Uncertain 12 at +25, nothing after.</summary>
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);
}
}