1f88669304
New AggregateFamily.TimeWeighted serving TimeAverage, TimeAverage2, Total, Total2 and Interpolative, evaluated by the shared Sql.Common TimeWeightedAggregator for both SQL backends (identical results by construction). HistoryAggregateType gains TimeAverage2 + Total2 (appended); OtOpcUaNodeManager.MapAggregate maps their NodeIds. Gateway's mapper already refuses unknown defined members => BadAggregateNotSupported per node. Semantics (server defaults, client AggregateConfiguration ignored): TreatUncertainAsBad=false, PercentDataBad=PercentDataGood=100, UseSlopedExtrapolation=false. Sloped for Double/Int64, stepped for Boolean; a usable sample followed by a Bad one is held up to the outage (AtTime rule); stepped Uncertain extrapolation past the last sample. TimeAverage/Total use interpolated bounding (bridge outages); TimeAverage2/Total2 exclude Bad time. Bounding values: latest usable + latest any before start, first any + first usable at/after end (four TOP 1 / LIMIT 1 seeks). CLIENT-VISIBLE: Total on SqlServer/QuestDb was SUM of samples; it is now the time-weighted integral (TimeAverage x interval seconds), matching Gateway's Integral. Release note in docs/Historian.md. Trade-off (deviation from the preferred hybrid pushdown, allowed by the design): the time-weighted family is NOT pushed down. Each read streams the window's rows plus the four bounds (SQL Server: one five-result-set batch; QuestDB: bounds UNION ALL + window select) into the C# evaluator. Network is O(samples in window), memory O(buckets). Writing the per-segment interpolation/edge splitting twice in two SQL dialects and keeping them bit-identical was judged the bigger risk; this also makes the QuestDB lag/lead spike moot. Tests: 35 hand-computed TimeWeightedAggregator unit cases (each of 10 implementation mutations goes red); served-aggregate pin 13 -> 17; conformance sparse fixture (intervals wider and narrower than sample gaps) + an outage straddling both window edges (each of the four SQL seeks disabled in turn goes red); node-manager NodeId mapping test.
132 lines
6.2 KiB
C#
132 lines
6.2 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using static ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests.Fixture;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests;
|
|
|
|
/// <summary>
|
|
/// Direct tests of the aggregate evaluator over hand-built bucket rows for tag A, 20 s intervals from T0, window
|
|
/// [t0, t0+80s): B0 = {10, 20}; B1 = {Bad 30, 40}; B2 = {50}; B3 empty. Literals from the SqlServer
|
|
/// <c>ProcessedReadTests</c>.
|
|
/// </summary>
|
|
public sealed class AggregateEvaluatorTests
|
|
{
|
|
private static readonly DateTime Now = At(3600);
|
|
|
|
private static Dictionary<long, BucketRow> RowsA(bool maximum = false) => new()
|
|
{
|
|
[0] = new BucketRow
|
|
{
|
|
Bucket = 0, Total = 2, Usable = 2, Good = 2, Bad = 0, NonNumeric = 0, Avg = 15, Min = 10, Max = 20, Sum = 30,
|
|
First = HistorianValue.FromDouble(10), Last = HistorianValue.FromDouble(20),
|
|
Extreme = maximum ? 20 : 10, ExtremeTime = maximum ? At(10) : At(0),
|
|
},
|
|
[1] = new BucketRow
|
|
{
|
|
Bucket = 1, Total = 2, Usable = 1, Good = 1, Bad = 1, NonNumeric = 0, Avg = 40, Min = 40, Max = 40, Sum = 40,
|
|
First = HistorianValue.FromDouble(40), Last = HistorianValue.FromDouble(40), Extreme = 40, ExtremeTime = At(30),
|
|
},
|
|
[2] = new BucketRow
|
|
{
|
|
Bucket = 2, Total = 1, Usable = 1, Good = 1, Bad = 0, NonNumeric = 0, Avg = 50, Min = 50, Max = 50, Sum = 50,
|
|
First = HistorianValue.FromDouble(50), Last = HistorianValue.FromDouble(50), Extreme = 50, ExtremeTime = At(40),
|
|
},
|
|
};
|
|
|
|
private static IReadOnlyList<DataValueSnapshot> ReadA(HistoryAggregateType agg, bool maximum = false)
|
|
{
|
|
var plan = BucketPlan.Create(At(0), At(80), TimeSpan.FromSeconds(20));
|
|
var evaluated = RowsA(maximum).ToDictionary(kv => kv.Key,
|
|
kv => AggregateEvaluator.Evaluate(agg, kv.Value, plan.BucketStart(kv.Key), Now));
|
|
return plan.Assemble(evaluated, t => AggregateEvaluator.NoData(t, Now));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(HistoryAggregateType.Average, 15d, 40d, 50d)]
|
|
[InlineData(HistoryAggregateType.Minimum, 10d, 40d, 50d)]
|
|
[InlineData(HistoryAggregateType.Maximum, 20d, 40d, 50d)]
|
|
[InlineData(HistoryAggregateType.Start, 10d, 40d, 50d)]
|
|
[InlineData(HistoryAggregateType.End, 20d, 40d, 50d)]
|
|
[InlineData(HistoryAggregateType.Delta, 10d, 0d, 0d)]
|
|
[InlineData(HistoryAggregateType.Range, 10d, 0d, 0d)]
|
|
public void Numeric_aggregates_over_hand_built_bucket_rows(HistoryAggregateType agg, double b0, double b1, double b2)
|
|
{
|
|
var s = ReadA(agg);
|
|
s.Count.ShouldBe(4);
|
|
s.Select(x => x.Value).ShouldBe([b0, b1, b2, null]);
|
|
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, NoDataCalc]);
|
|
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(20), At(40), At(60)]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Count_counts_usable_samples_as_int()
|
|
{
|
|
var s = ReadA(HistoryAggregateType.Count);
|
|
s.Select(x => x.Value).ShouldBe([(object?)2, 1, 1, null]);
|
|
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, NoDataCalc]);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(HistoryAggregateType.PercentGood, 100d, 50d, 100d)]
|
|
[InlineData(HistoryAggregateType.PercentBad, 0d, 50d, 0d)]
|
|
public void Percent_aggregates_count_every_sample_and_stay_good(HistoryAggregateType agg, double b0, double b1, double b2)
|
|
{
|
|
var s = ReadA(agg);
|
|
s.Select(x => x.Value).ShouldBe([b0, b1, b2, null]);
|
|
s.Select(x => x.StatusCode).ShouldBe([GoodCalc, GoodCalc, GoodCalc, NoDataCalc]);
|
|
}
|
|
|
|
[Fact]
|
|
public void Actual_time_aggregates_stamp_the_sample_time()
|
|
{
|
|
var min = ReadA(HistoryAggregateType.MinimumActualTime);
|
|
min.Select(x => x.Value).ShouldBe([10d, 40d, 50d, null]);
|
|
min.Select(x => x.SourceTimestampUtc).ShouldBe([At(0), At(30), At(40), At(60)]);
|
|
min.Select(x => x.StatusCode).ShouldBe([GoodCalc, UncertainCalc, GoodCalc, NoDataCalc]);
|
|
var max = ReadA(HistoryAggregateType.MaximumActualTime, maximum: true);
|
|
max.Select(x => x.Value).ShouldBe([20d, 40d, 50d, null]);
|
|
max.Select(x => x.SourceTimestampUtc).ShouldBe([At(10), At(30), At(40), At(60)]);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_usable_non_numeric_sample_makes_a_numeric_aggregate_invalid_inputs_but_not_count()
|
|
{
|
|
var row = new BucketRow { Total = 2, Usable = 2, Good = 2, NonNumeric = 1, Avg = 1, First = HistorianValue.FromString("x"),
|
|
Last = HistorianValue.FromDouble(1) };
|
|
var avg = AggregateEvaluator.Evaluate(HistoryAggregateType.Average, row, At(0), Now);
|
|
avg.Value.ShouldBeNull();
|
|
avg.StatusCode.ShouldBe(InvalidInputsCalc);
|
|
AggregateEvaluator.Evaluate(HistoryAggregateType.Count, row, At(0), Now).Value.ShouldBe(2);
|
|
AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, row, At(0), Now).StatusCode.ShouldBe(InvalidInputsCalc);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_bucket_with_only_bad_samples_is_no_data_except_for_the_percentages()
|
|
{
|
|
var row = new BucketRow { Total = 2, Usable = 0, Good = 0, Bad = 2 };
|
|
var avg = AggregateEvaluator.Evaluate(HistoryAggregateType.Average, row, At(0), Now);
|
|
avg.Value.ShouldBeNull();
|
|
avg.StatusCode.ShouldBe(NoDataCalc);
|
|
AggregateEvaluator.Evaluate(HistoryAggregateType.PercentBad, row, At(0), Now).Value.ShouldBe(100d);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_unsupported_aggregate_throws_not_supported()
|
|
{
|
|
var ex = Should.Throw<HistoryAggregateNotSupportedException>(() =>
|
|
AggregateEvaluator.Evaluate(HistoryAggregateType.TimeAverage, RowsA()[0], At(0), Now));
|
|
ex.Aggregate.ShouldBe(HistoryAggregateType.TimeAverage);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_uncertain_sample_in_a_bucket_degrades_the_aggregate_to_uncertain()
|
|
{
|
|
// Two usable samples, one Good and one Uncertain, no Bad: the value is computed, the status is Uncertain.
|
|
var row = new BucketRow { Total = 2, Usable = 2, Good = 1, Bad = 0, Avg = 15, Min = 10, Max = 20, Sum = 30 };
|
|
var avg = AggregateEvaluator.Evaluate(HistoryAggregateType.Average, row, At(0), Now);
|
|
avg.Value.ShouldBe(15d);
|
|
avg.StatusCode.ShouldBe(UncertainCalc);
|
|
}
|
|
}
|