fix(historian): T10 review — tick-exact bucketing over the full DateTime range; AtTime outage keeps its Bad code
Important 1: bucket offsets are now 100 ns ticks as bigint
(DATEDIFF_BIG(microsecond)*10 plus the sub-microsecond tick digits), so a
1601→9999 window no longer overflows (SQL 535) and nothing scales the interval
to nanoseconds in C#. Interval wider than the window clamps to one bucket and
the ceiling division cannot overflow. Exactness to the tick is unchanged.
Important 2 (spec §4 ruling d6afb394): a Bad previous neighbour means the time
is inside an outage — null value with that neighbour's Bad code, never
Uncertain. Usable previous + Bad next still holds the previous value Uncertain.
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+46
@@ -158,6 +158,52 @@ public sealed class ProcessedReadTests(SqlHistorianFixture fx)
|
||||
.Samples.Single().Value.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_whole_range_single_interval_from_1601_to_9999_does_not_overflow()
|
||||
{
|
||||
// 1601-01-01 is the OPC UA minimum DateTime. A nanosecond DATEDIFF_BIG overflows past ≈292 years (SQL 535),
|
||||
// and a ns-scaled interval would wrap a long; both must be gone.
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var start = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var end = new DateTime(9999, 12, 31, 0, 0, 0, DateTimeKind.Utc);
|
||||
var src = db.Source();
|
||||
var avg = (await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Average, Ct)).Samples.Single();
|
||||
avg.Value.ShouldBe(30d); // usable {10, 20, 40, 50}
|
||||
avg.StatusCode.ShouldBe(UncertainCalc);
|
||||
avg.SourceTimestampUtc.ShouldBe(start);
|
||||
(await src.ReadProcessedAsync("A", start, end, TimeSpan.Zero, HistoryAggregateType.Count, Ct))
|
||||
.Samples.Single().Value.ShouldBe(4);
|
||||
// Also a whole-DateTime-range window (0001 → 9999-12-31T23:59:59.9999999).
|
||||
(await src.ReadProcessedAsync("A", DateTime.MinValue, DateTime.MaxValue, TimeSpan.Zero, HistoryAggregateType.Total, Ct))
|
||||
.Samples.Single().Value.ShouldBe(120d);
|
||||
// An interval wider than the window (here TimeSpan.MaxValue) is the same single interval, no overflow.
|
||||
(await src.ReadProcessedAsync("A", start, end, TimeSpan.MaxValue, HistoryAggregateType.Total, Ct))
|
||||
.Samples.Single().Value.ShouldBe(120d);
|
||||
src.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_weekly_interval_over_four_centuries_lands_samples_in_the_right_week()
|
||||
{
|
||||
// Hand-computed (calendar arithmetic, independent of SQL): 1601-01-01 → 2027-01-01 is 155 593 days =
|
||||
// 22 227.57 weeks ⇒ 22 228 intervals (under the 100k cap). 2026-10-09 08:00 is 155 498 days in ⇒ week
|
||||
// index 22 215, which starts Monday 2026-10-05 00:00; every fixture sample (t0 … t0+40 s) is in it.
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync();
|
||||
var start = new DateTime(1601, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var end = new DateTime(2027, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var s = (await db.Source().ReadProcessedAsync("A", start, end, TimeSpan.FromDays(7), HistoryAggregateType.Average, Ct)).Samples;
|
||||
|
||||
s.Count.ShouldBe(22_228);
|
||||
var hit = s[22_215];
|
||||
hit.SourceTimestampUtc.ShouldBe(new DateTime(2026, 10, 5, 0, 0, 0, DateTimeKind.Utc));
|
||||
hit.Value.ShouldBe(30d);
|
||||
hit.StatusCode.ShouldBe(UncertainCalc);
|
||||
s.Where((_, i) => i != 22_215).ShouldAllBe(x => x.StatusCode == NoDataCalc);
|
||||
s[^1].SourceTimestampUtc.ShouldBe(new DateTime(2026, 12, 28, 0, 0, 0, DateTimeKind.Utc)); // partial last week
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_reverse_window_is_computed_forward_and_returned_descending()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user