diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs index a099a168..fa10b21b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlAggregateQueries.cs @@ -18,17 +18,21 @@ internal enum SqlAggregateFamily /// /// Push-down T-SQL for processed (aggregate) reads (spec §4 "Processed"). Every query takes /// @id (int TagId), @start/@end (datetime2(7), the forward window -/// [start, end)) and @intervalNs (bigint bucket width in nanoseconds), and returns one row +/// [start, end)) and @intervalTicks (bigint bucket width in 100 ns ticks), and returns one row /// per non-empty bucket. The caller generates every interval and fills the empty ones. /// /// /// -/// Bucketing. b = DATEDIFF_BIG(nanosecond, @start, SourceTimeUtc) / @intervalNs. The spec -/// sketched millisecond, but DATEDIFF counts unit boundaries crossed, not elapsed -/// time, so with a non-millisecond-aligned @start a sample one tick before a bucket edge would -/// land in the next bucket. datetime2(7) values are whole multiples of 100 ns, so the nanosecond -/// boundary count IS the exact elapsed time and the integer division gives start-inclusive / -/// end-exclusive buckets to the tick. (Range limit: ≈292 years per window.) +/// Bucketing. b = elapsedTicks / @intervalTicks, where +/// elapsedTicks = DATEDIFF_BIG(microsecond, @start, t) * 10 + tick-of-microsecond(t) − tick-of-microsecond(@start) +/// and tick-of-microsecond(x) = (DATEPART(nanosecond, x) / 100) % 10. The spec sketched +/// millisecond, but DATEDIFF counts unit boundaries crossed, not elapsed time, so with a +/// non-millisecond-aligned @start a sample one tick before a bucket edge would land in the next +/// bucket. Counting microsecond boundaries and correcting by the two sub-microsecond tick digits gives +/// the exact elapsed time in 100 ns ticks (10·(⌊t/10⌋ − ⌊s/10⌋) + t mod 10 − s mod 10 = t − s), so the +/// integer division is start-inclusive / end-exclusive to the tick. Unlike a nanosecond DATEDIFF_BIG +/// (which overflows a bigint past ≈292 years, SQL error 535), the microsecond count over the whole +/// datetime2 range (0001–9999, ≈3.2e17 µs, ≈3.2e18 ticks) fits a bigint. /// /// /// Sample classes (status stored bit-for-bit in an int, so the severity bits are the sign @@ -114,7 +118,8 @@ internal static class SqlAggregateQueries /// The s CTE: the window's samples with bucket index and the usable/good flags. private static string Classified(string sample) => - "WITH s AS (SELECT DATEDIFF_BIG(nanosecond, @start, SourceTimeUtc) / @intervalNs AS b, " + + "WITH s AS (SELECT (DATEDIFF_BIG(microsecond, @start, SourceTimeUtc) * 10 " + + "+ (DATEPART(nanosecond, SourceTimeUtc) / 100) % 10 - (DATEPART(nanosecond, @start) / 100) % 10) / @intervalTicks AS b, " + "SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, " + "CASE WHEN StatusCode >= 0 AND ValueKind <> 0 THEN 1 ELSE 0 END AS u, " + "CASE WHEN StatusCode >= 0 AND StatusCode < 1073741824 AND ValueKind <> 0 THEN 1 ELSE 0 END AS g " + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs index 5ef1d4e7..8d44126e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs @@ -33,7 +33,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; /// or a driver's Bad publish) and is Bad whatever its code. Raw returns every sample as stored, outage /// markers included. Aggregates exclude Bad samples from the math, report the interval Uncertain when /// any non-Good sample is present, and count them in PercentBad. AtTime never interpolates across a Bad -/// neighbour (see ). +/// neighbour and reports a time inside an outage with the outage's Bad code (see ). /// /// public sealed class SqlServerHistorianDataSource : IHistorianDataSource @@ -41,6 +41,9 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource /// Spec §9.7: a processed read covering more intervals than this is refused. public const int MaxProcessedBuckets = 100_000; + /// Severity + sub-code (top 16 bits) of a status, i.e. the code without info bits. + private const uint StatusCodeBitsMask = 0xFFFF0000u; + private const string SampleColumns = "SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, ServerTimeUtc"; private readonly SqlHistorianConnection _conn; @@ -136,9 +139,11 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource /// /// p at exactly T ⇒ the stored value and status, unchanged. /// No p ⇒ BadNoData, null value. - /// p or n Bad (incl. an Empty outage marker) ⇒ UncertainDataSubNormal + - /// Interpolated bits, and NO interpolation across it: the value is p's held value when p is - /// usable (data held up to the outage), else null (inside an outage). + /// p Bad (incl. an Empty outage marker) ⇒ T lies inside an outage: null value + /// carrying p's Bad status code (info bits stripped) — never better than its source, per the + /// quality contract (spec §4 as amended after the T10 review). + /// p usable and n Bad ⇒ p's value held (stepped, NOT interpolated into the outage) + /// with UncertainDataSubNormal + Interpolated bits. /// Double/Int64 with a usable n ⇒ linear interpolation of NumValue (Int64 rounded to the /// nearest integer, ties away from zero, keeping the type); status Good + Interpolated when both /// neighbours are Good, else UncertainDataSubNormal + Interpolated (never better than the @@ -208,8 +213,13 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource if (p.TimeUtc == t) return new DataValueSnapshot(p.Value.ToObject(), p.Status, t, serverTime); - if (!p.IsUsable || next is { IsUsable: false }) - return new DataValueSnapshot(p.IsUsable ? p.Value.ToObject() : null, + // Inside an outage: report the outage's own Bad code, never an upgraded Uncertain. + if (!p.IsUsable) + return new DataValueSnapshot(null, p.Status & StatusCodeBitsMask, t, serverTime); + + // Usable previous, Bad next: hold the previous value up to the outage; do not interpolate into it. + if (next is { IsUsable: false }) + return new DataValueSnapshot(p.Value.ToObject(), HistorianInfoBits.WithInterpolated(SqlHistorianStatusCodes.UncertainDataSubNormal), t, serverTime); if (next is { } n && p.Value.Kind is HistorianValueKind.Double or HistorianValueKind.Int64 @@ -328,8 +338,11 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource var reverse = endUtc < startUtc; var (start, end) = reverse ? (endUtc, startUtc) : (startUtc, endUtc); var windowTicks = end.Ticks - start.Ticks; - var intervalTicks = interval > TimeSpan.Zero ? interval.Ticks : windowTicks; - long bucketCount = windowTicks == 0 ? 0 : (windowTicks + intervalTicks - 1) / intervalTicks; + // Everything stays in 100 ns ticks (≤ ≈3.2e18 across 0001–9999, inside a long) — no ns scaling that could + // wrap. An interval wider than the window is one bucket, so clamp it to the window; the ceiling division + // is written without "+ interval - 1" so a TimeSpan near MaxValue cannot overflow either. + var intervalTicks = interval > TimeSpan.Zero ? Math.Min(interval.Ticks, windowTicks) : windowTicks; + long bucketCount = windowTicks == 0 ? 0 : windowTicks / intervalTicks + (windowTicks % intervalTicks == 0 ? 0 : 1); if (bucketCount > MaxProcessedBuckets) throw new ArgumentOutOfRangeException(nameof(interval), bucketCount, $"A processed read may cover at most {MaxProcessedBuckets} intervals; this window holds {bucketCount}."); @@ -353,7 +366,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource cmd.Parameters.Add(new SqlParameter("@id", SqlDbType.Int) { Value = id }); SqlHistorianConnection.AddDateTime2(cmd, "@start", DateTime.SpecifyKind(start, DateTimeKind.Utc)); SqlHistorianConnection.AddDateTime2(cmd, "@end", DateTime.SpecifyKind(end, DateTimeKind.Utc)); - cmd.Parameters.Add(new SqlParameter("@intervalNs", SqlDbType.BigInt) { Value = intervalTicks * 100L }); + cmd.Parameters.Add(new SqlParameter("@intervalTicks", SqlDbType.BigInt) { Value = intervalTicks }); await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/AtTimeReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/AtTimeReadTests.cs index 90c28571..8f72ac3f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/AtTimeReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/AtTimeReadTests.cs @@ -4,7 +4,11 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests; -/// AtTime rules (spec §4): exact, linear, stepped, no-prior, Bad neighbour (no bridging). +/// +/// AtTime rules (spec §4, as amended after the T10 review): exact, linear, stepped, no-prior, and the two +/// Bad-neighbour cases — inside an outage (Bad previous) ⇒ null with the outage's own Bad code; before an +/// outage (usable previous, Bad next) ⇒ previous value held, Uncertain. Never bridges. +/// [Collection(SqlHistorianCollection.Name)] public sealed class AtTimeReadTests(SqlHistorianFixture fx) { @@ -75,13 +79,14 @@ public sealed class AtTimeReadTests(SqlHistorianFixture fx) } [Fact] - public async Task A_bad_previous_neighbour_yields_no_value_uncertain() + public async Task A_bad_previous_neighbour_is_inside_an_outage_and_keeps_its_bad_code() { var db = await ReadTestDb.CreateAsync(fx); await db.SeedAAsync(); var s = (await db.Source().ReadAtTimeAsync("A", [At(25)], Ct)).Samples.Single(); - s.Value.ShouldBeNull(); // NOT 35 (30→40) and NOT 30 (a Bad value) - s.StatusCode.ShouldBe(UncertainInterpolated); + s.Value.ShouldBeNull(); // NOT 35 (30→40) and NOT 30 (a Bad value) + s.StatusCode.ShouldBe(0x80000000u); // the previous sample's Bad code — never upgraded to Uncertain + s.SourceTimestampUtc.ShouldBe(At(25)); } [Fact] @@ -96,7 +101,7 @@ public sealed class AtTimeReadTests(SqlHistorianFixture fx) s[0].Value.ShouldBe(10d); // bridging 10→30 would give 15 s[0].StatusCode.ShouldBe(UncertainInterpolated); s[1].Value.ShouldBeNull(); // bridging would give 25 - s[1].StatusCode.ShouldBe(UncertainInterpolated); + s[1].StatusCode.ShouldBe(0x80310000u); // inside the outage: BadNoCommunication, as the marker says s[2].Value.ShouldBeNull(); // exact hit on the marker: as stored s[2].StatusCode.ShouldBe(0x80310000u); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ProcessedReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ProcessedReadTests.cs index 837577fd..fda8e1d1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ProcessedReadTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests/ProcessedReadTests.cs @@ -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() {