Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common.Tests/BucketPlanTests.cs
T
Joseph Doherty dbfe2fc4de fix(historian): #680 lane B — per-sample Count/Start/End/Delta per Part 13 + forward end-of-data Partial
Count: Good samples only, value-based status (all Bad => generic Bad), 0/Good on an
empty interval inside the tag's data range [first, last] (both directions).
Start/End: the raw first/last sample of any quality (own value, status, timestamp).
Delta: Uncertain only when the interval's first or last sample is not usable.
Forward end-of-data Partial (SDK m_endOfData, earlier edge inclusive) for every
per-sample aggregate but Average (PercentGood/Bad left to lane A).
Backends push down first/last-any-quality columns and fetch the tag's data range
(earliest <= window end, latest >= window start) only when NeedsDataRange.
SDK parity masks for Start/End/Delta/Count removed; new parity datasets.
2026-10-11 16:38:19 -04:00

198 lines
9.3 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
public sealed class BucketPlanTests
{
[Fact]
public void BucketPlan_rejects_more_than_100k_buckets()
{
var ex = Should.Throw<ArgumentOutOfRangeException>(() =>
BucketPlan.Create(DateTime.UnixEpoch, DateTime.UnixEpoch.AddSeconds(100_001), TimeSpan.FromSeconds(1)));
ex.ParamName.ShouldBe("interval");
}
[Fact]
public void Exactly_100k_one_tick_buckets_are_allowed()
{
var p = BucketPlan.Create(At(0), At(0).AddTicks(100_000), TimeSpan.FromTicks(1));
p.BucketCount.ShouldBe(100_000);
p.IntervalTicks.ShouldBe(1);
}
[Fact]
public void A_partial_last_interval_is_its_own_bucket()
{
var p = BucketPlan.Create(At(0), At(50), TimeSpan.FromSeconds(20));
p.BucketCount.ShouldBe(3);
p.BucketTimestamp(2).ShouldBe(At(40));
p.BucketTimestamp(2).Kind.ShouldBe(DateTimeKind.Utc);
p.IsTruncated(2).ShouldBeTrue();
p.IsTruncated(1).ShouldBeFalse();
}
[Fact]
public void Zero_or_oversized_interval_is_one_bucket_and_an_empty_window_none()
{
BucketPlan.Create(At(0), At(80), TimeSpan.Zero).BucketCount.ShouldBe(1);
var wide = BucketPlan.Create(At(0), At(80), TimeSpan.FromDays(1));
wide.BucketCount.ShouldBe(1);
wide.IntervalTicks.ShouldBe(80 * TimeSpan.TicksPerSecond);
BucketPlan.Create(At(0), At(0), TimeSpan.FromSeconds(1)).BucketCount.ShouldBe(0);
}
[Fact]
public void A_reverse_window_is_anchored_at_its_later_time_and_stamped_with_later_edges()
{
// #677 (Part 13 / SDK CreateSlice + GetTimestamp): At(80) → At(0), 20 s ⇒ (60,80], (40,60], (20,40], (0,20],
// each stamped with its later edge, latest first.
var p = BucketPlan.Create(At(80), At(0), TimeSpan.FromSeconds(20));
p.Reversed.ShouldBeTrue();
p.StartUtc.ShouldBe(At(0));
p.EndUtc.ShouldBe(At(80));
var s = p.Assemble(new Dictionary<long, DataValueSnapshot>(), t => new DataValueSnapshot(null, 0, t, t));
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(80), At(60), At(40), At(20)]);
}
[Fact]
public void A_reverse_window_puts_the_partial_interval_at_its_earliest_end()
{
// At(50) → At(0), 20 s: (30,50], (10,30], (0,10] — the truncated bucket is the EARLIEST (the old layout truncated
// the latest: [0,20), [20,40), [40,50)).
var p = BucketPlan.Create(At(50), At(0), TimeSpan.FromSeconds(20));
p.BucketCount.ShouldBe(3);
p.LowerTicks(0).ShouldBe(At(0).Ticks);
p.UpperTicks(0).ShouldBe(At(10).Ticks);
p.IsTruncated(0).ShouldBeTrue();
p.LowerTicks(2).ShouldBe(At(30).Ticks);
p.UpperTicks(2).ShouldBe(At(50).Ticks);
p.IsTruncated(2).ShouldBeFalse();
var s = p.Assemble(new Dictionary<long, DataValueSnapshot>(), t => new DataValueSnapshot(null, 0, t, t));
s.Select(x => x.SourceTimestampUtc).ShouldBe([At(50), At(30), At(10)]);
}
[Fact]
public void Forward_buckets_are_start_inclusive_and_reverse_buckets_later_edge_inclusive()
{
var fwd = BucketPlan.Create(At(0), At(50), TimeSpan.FromSeconds(20));
fwd.IndexOf(At(0)).ShouldBe(0);
fwd.IndexOf(At(20)).ShouldBe(1);
fwd.IndexOf(At(20).AddTicks(-1)).ShouldBe(0);
fwd.IndexOf(At(49.9999999)).ShouldBe(2);
fwd.IndexOf(At(50)).ShouldBeNull();
fwd.IndexOf(At(0).AddTicks(-1)).ShouldBeNull();
// (0,10] (10,30] (30,50]: the later time is in the window, the earlier one is not (as a reverse raw read).
var rev = BucketPlan.Create(At(50), At(0), TimeSpan.FromSeconds(20));
rev.IndexOf(At(50)).ShouldBe(2);
rev.IndexOf(At(30)).ShouldBe(1);
rev.IndexOf(At(30).AddTicks(1)).ShouldBe(2);
rev.IndexOf(At(10)).ShouldBe(0);
rev.IndexOf(At(10).AddTicks(1)).ShouldBe(1);
rev.IndexOf(At(0).AddTicks(1)).ShouldBe(0);
rev.IndexOf(At(0)).ShouldBeNull();
rev.IndexOf(At(50).AddTicks(1)).ShouldBeNull();
}
[Fact]
public void The_backend_bucket_formula_matches_IndexOf_in_both_directions()
{
// b = (t − start + adjust) / interval and b = (unixTicks(t) − base) / interval — what the SQL computes.
foreach (var p in new[]
{
BucketPlan.Create(At(0), At(50), TimeSpan.FromSeconds(20)),
BucketPlan.Create(At(50), At(0), TimeSpan.FromSeconds(20)),
BucketPlan.Create(At(60), At(0), TimeSpan.FromSeconds(20)),
BucketPlan.Create(At(0).AddTicks(70), At(0), TimeSpan.FromTicks(3)),
})
{
for (var t = p.StartUtc.Ticks - 2; t <= p.EndUtc.Ticks + 2; t += Math.Max(1, (p.EndUtc.Ticks - p.StartUtc.Ticks) / 997))
{
var at = new DateTime(t, DateTimeKind.Utc);
if (p.IndexOf(at) is not { } idx) continue;
((t - p.StartUtc.Ticks + p.BucketIndexAdjustTicks) / p.IntervalTicks).ShouldBe(idx);
(((at - DateTime.UnixEpoch).Ticks - p.BucketBaseUnixTicks) / p.IntervalTicks).ShouldBe(idx);
p.LowerTicks(idx).ShouldBeLessThanOrEqualTo(t);
p.UpperTicks(idx).ShouldBeGreaterThanOrEqualTo(t);
}
}
}
[Fact]
public void Data_starts_inside_only_strictly_between_the_bucket_edges()
{
var p = BucketPlan.Create(At(0), At(40), TimeSpan.FromSeconds(20));
p.DataStartsInside(0, At(5)).ShouldBeTrue();
p.DataStartsInside(1, At(5)).ShouldBeFalse();
p.DataStartsInside(1, At(20)).ShouldBeFalse(); // on the edge: data covers the whole interval
p.DataStartsInside(0, null).ShouldBeFalse();
p.DataStartsInside(0, At(-5)).ShouldBeFalse();
}
[Fact]
public void Data_ends_inside_from_the_interval_start_inclusive_to_its_end_exclusive()
{
var p = BucketPlan.Create(At(0), At(40), TimeSpan.FromSeconds(20));
p.DataEndsInside(0, At(5)).ShouldBeTrue();
p.DataEndsInside(1, At(20)).ShouldBeTrue(); // on the earlier edge: the SDK's m_endOfData >= startTime
p.DataEndsInside(0, At(20)).ShouldBeFalse();
p.DataEndsInside(1, At(40)).ShouldBeFalse();
p.DataEndsInside(0, null).ShouldBeFalse();
}
[Fact]
public void The_data_range_includes_both_of_its_edges()
{
var p = BucketPlan.Create(At(0), At(60), TimeSpan.FromSeconds(20));
p.WithinDataRange(0, At(10), At(30)).ShouldBeTrue();
p.WithinDataRange(1, At(10), At(30)).ShouldBeTrue();
p.WithinDataRange(2, At(10), At(30)).ShouldBeFalse(); // [40,60) is after the last sample
p.WithinDataRange(2, At(10), At(40)).ShouldBeTrue(); // last sample on the earlier edge
p.WithinDataRange(0, At(20), At(30)).ShouldBeTrue(); // first sample on the later edge
p.WithinDataRange(0, At(21), At(30)).ShouldBeFalse();
p.WithinDataRange(0, null, At(30)).ShouldBeFalse();
p.WithinDataRange(0, At(10), null).ShouldBeFalse();
}
[Fact]
public void The_data_range_is_needed_for_forward_reads_of_aggregates_other_than_Average_and_for_Count()
{
// Review F2 (#677) / #680: backends skip the earliest/latest-sample queries when their answer cannot change a
// result — the start/end-of-data Partial rules are forward-only and not Average's; Count's in-range empty interval
// (0, not BadNoData) applies in both directions.
var fwd = BucketPlan.Create(At(0), At(40), TimeSpan.FromSeconds(10));
var rev = BucketPlan.Create(At(40), At(0), TimeSpan.FromSeconds(10));
AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Count, fwd).ShouldBeTrue();
AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Minimum, fwd).ShouldBeTrue();
AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Average, fwd).ShouldBeFalse();
AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Count, rev).ShouldBeTrue();
AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Minimum, rev).ShouldBeFalse();
AggregateEvaluator.NeedsDataRange(HistoryAggregateType.Count, BucketPlan.Create(At(0), At(0), TimeSpan.Zero))
.ShouldBeFalse();
}
[Fact]
public void Assemble_fills_missing_buckets_with_the_empty_factory()
{
var p = BucketPlan.Create(At(0), At(60), TimeSpan.FromSeconds(20));
var hit = new DataValueSnapshot(7d, 0, At(20), At(20));
var s = p.Assemble(new Dictionary<long, DataValueSnapshot> { [1] = hit }, t => new DataValueSnapshot(null, 1u, t, t));
s.Select(x => x.Value).ShouldBe([null, (object?)7d, null]);
s[0].SourceTimestampUtc.ShouldBe(At(0));
s[2].SourceTimestampUtc.ShouldBe(At(40));
}
[Fact]
public void BucketBaseUnixTicks_is_the_start_forward()
=> BucketPlan.Create(DateTime.UnixEpoch.AddTicks(1234567), DateTime.UnixEpoch.AddSeconds(10), TimeSpan.FromSeconds(1))
.BucketBaseUnixTicks.ShouldBe(1234567L);
[Fact]
public void StartUnixTicks_is_ticks_since_the_unix_epoch()
=> BucketPlan.Create(DateTime.UnixEpoch.AddTicks(1234567), DateTime.UnixEpoch.AddSeconds(10), TimeSpan.Zero)
.StartUnixTicks.ShouldBe(1234567L);
}