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:
@@ -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>.
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
+18
-7
@@ -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
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+9
-7
@@ -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());
|
||||
}
|
||||
|
||||
+98
-4
@@ -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 =
|
||||
[
|
||||
|
||||
+27
@@ -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()
|
||||
{
|
||||
|
||||
+2
-1
@@ -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]
|
||||
|
||||
+55
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user