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; /// /// 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 /// ProcessedReadTests. /// public sealed class AggregateEvaluatorTests { private static readonly DateTime Now = At(3600); private static Dictionary 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 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.Total, 30d, 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(() => 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); } }