using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer; /// /// Push-down T-SQL for processed (aggregate) reads (spec §4 "Processed"), one query per /// ( picks it). Every query takes /// @id (int TagId), @start/@end (datetime2(7), the earlier / later window edges — /// /), @intervalTicks (bigint bucket width in /// 100 ns ticks) and @bucketAdjustTicks (bigint, ), and returns /// one row per non-empty bucket (ascending bucket index). The caller generates every interval and fills the /// empty ones. /// /// /// /// Bucketing. b = (elapsedTicks + @bucketAdjustTicks) / @intervalTicks (#677: the adjust is 0 for a /// forward read and re-anchors a reverse read's grid at its later time — is the C# /// statement of the same math). The window is [@start, @end) forward and (@start, @end] reverse (a /// reverse read includes its later time), selected by the reverse argument. elapsedTicks is /// 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 /// and bit 30): usable = severity Good or Uncertain (StatusCode >= 0) AND a stored value /// (ValueKind <> 0) — an Empty sample is an outage marker and counts as Bad whatever its /// code; good = usable with severity Good (StatusCode < 0x40000000); bad = not /// usable. Average/Minimum/Maximum/Range/*ActualTime value columns are computed over good samples only /// (#684); Start/End/Delta picks over usable samples (or every sample). /// /// Common result columns (ordinals 0–5): Bucket, TotalCount, UsableCount, GoodCount, BadCount, /// NonNumericCount (usable String/DateTime samples). Family columns follow. /// internal static class SqlAggregateQueries { /// Ordinal of the first family-specific column. public const int FirstFamilyColumn = 6; /// ValueKind of String (4) and DateTime (5): usable but not numeric. private const string NonNumericKinds = "4, 5"; /// /// Average / Minimum / Maximum / Count / Range (PercentGood / PercentBad and Total are time-weighted, not here). /// Family columns: 6 AvgValue, 7 MinValue, 8 MaxValue, 9 SumValue over the Good numeric samples (float, /// NULL when none — #684: Part 13 and the SDK compute these over Good values only), then 10 UncMin, 11 UncMax /// over the Uncertain ones (the Part 13 Minimum/Maximum/Range status rule, ), and /// 12 GoodNonNumericCount. /// /// Schema-qualified Sample table name. /// The SQL text. /// True for a reverse read (window (@start, @end]). public static string Simple(string sample, bool reverse = false) => Classified(sample, reverse) + "SELECT b, " + CommonCounts() + ", " + "AVG(CASE WHEN g = 1 THEN NumValue END) AS AvgValue, " + "MIN(CASE WHEN g = 1 THEN NumValue END) AS MinValue, " + "MAX(CASE WHEN g = 1 THEN NumValue END) AS MaxValue, " + "SUM(CASE WHEN g = 1 THEN NumValue END) AS SumValue, " + UncertainExtremes() + " " + "FROM s GROUP BY b ORDER BY b;"; /// Ordinal of the StartEnd family's first-sample-of-any-quality columns (). public const int FirstAnyColumn = FirstFamilyColumn + 10; /// /// Start / End / Delta: the first and last usable sample of each bucket by SourceTimeUtc (Delta's value), and /// the first and last sample of any quality (Start / End, and Delta's status — #680). /// Family columns: 6..10 first usable sample (ValueKind, NumValue, IntValue, StrValue, SourceTimeUtc), /// 11..15 last usable sample (same order) — all NULL when the bucket has no usable sample; then /// 16..21 the first sample of any quality (SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, /// StatusCode) and 22..27 the last (same order) — present for every returned (non-empty) bucket. /// /// /// /// Aggregate, then join back on the key (#683). One GROUP BY over the window computes the counts and /// each bucket's four pick TIMES — MIN/MAX(SourceTimeUtc) over every sample (any quality) and over /// the usable ones — and each pick's columns are then fetched by a seek on the clustered key /// (TagId, SourceTimeUtc) (unique, so one row per pick; a NULL usable time joins nothing ⇒ NULL columns, /// as before). Output is identical to the previous windowed form (one ROW_NUMBER pass + conditional /// MAX, live-gate 8 F1) — EXCEPT both ways is empty on 6M rows, forward and reverse — without that /// form's four window sorts, which spilled: on 6M rows / 30 days, 30 d @ 1 h ~3.4 s vs 18–32 s, 7 d @ 1 d /// ~0.8 s vs 5.2–6.1 s, 1 d @ 1 min ~0.16 s vs 0.31–0.40 s (docs/Historian.md). /// /// /// ⚠️ The key seeks are separate reads of the table within one statement, so under READ COMMITTED a sample /// deleted (HistoryUpdate DeleteRaw/DeleteAtTime) between the aggregate and its seek leaves that pick NULL while /// the counts still include it. The reader maps a NULL pick to "absent" (ReadStoredOrNull, live-gate 8 F1) /// and the evaluator answers that bucket's Start/End/Delta BadNoData — transient, until the next read. A /// sample replaced in between is picked with its new value (same time); a sample inserted in between is in /// neither half (the pick times come from the aggregate). /// /// /// Schema-qualified Sample table name. /// The SQL text. /// True for a reverse read (window (@start, @end]). public static string StartEnd(string sample, bool reverse = false) { // A pick's columns from its joined row, in the established order: usable picks (kind, num, int, str, time), any- // quality picks (time, kind, num, int, str, status). static string Usable(string a) => $"{a}.ValueKind, {a}.NumValue, {a}.IntValue, {a}.StrValue, {a}.SourceTimeUtc"; static string Any(string a) => $"{a}.SourceTimeUtc, {a}.ValueKind, {a}.NumValue, {a}.IntValue, {a}.StrValue, {a}.StatusCode"; string Join(string a, string time) => $"LEFT JOIN {sample} {a} ON {a}.TagId = @id AND {a}.SourceTimeUtc = st.{time} "; return Classified(sample, reverse) + ", st AS (SELECT b, " + CommonCounts() + ", " + "MIN(CASE WHEN u = 1 THEN SourceTimeUtc END) AS fu, MAX(CASE WHEN u = 1 THEN SourceTimeUtc END) AS lu, " + "MIN(SourceTimeUtc) AS fa, MAX(SourceTimeUtc) AS la FROM s GROUP BY b) " + "SELECT st.b, st.TotalCount, st.UsableCount, st.GoodCount, st.BadCount, st.NonNumericCount, " + Usable("x") + ", " + Usable("y") + ", " + Any("p") + ", " + Any("q") + " FROM st " + Join("x", "fu") + Join("y", "lu") + Join("p", "fa") + Join("q", "la") + "ORDER BY st.b;"; } /// /// MinimumActualTime / MaximumActualTime: the Good sample (#684) with the smallest (largest) NumValue, /// ties broken by the earliest SourceTimeUtc. Family columns: 6 NumValue, 7 SourceTimeUtc /// (NULL when the bucket has no Good numeric sample), then 8 UncMin, 9 UncMax, 10 GoodNonNumericCount as in . /// /// /// Deliberately still two references to s (counts + the ranked extreme), unlike : /// every single-scan form measured (a ROW_NUMBER or windowed MIN/MAX over every row) was /// 1.2–2× slower on day-plus windows than ranking only the filtered usable rows. Live-gate 8 F1's inconsistency — /// a sample committed between the two scans, counted but not ranked — is instead absorbed by the reader: a /// NULL extreme in a bucket with usable samples evaluates to BadNoData (never a value-less Good). /// /// Schema-qualified Sample table name. /// True for MaximumActualTime. /// The SQL text. /// True for a reverse read (window (@start, @end]). public static string ActualTime(string sample, bool maximum, bool reverse = false) => Classified(sample, reverse) + ", st AS (SELECT b, " + CommonCounts() + ", " + UncertainExtremes() + " FROM s GROUP BY b)" + ", r AS (SELECT b, NumValue, SourceTimeUtc, " + "ROW_NUMBER() OVER (PARTITION BY b ORDER BY NumValue " + (maximum ? "DESC" : "ASC") + ", SourceTimeUtc ASC) AS rn " + "FROM s WHERE g = 1 AND NumValue IS NOT NULL) " + "SELECT st.b, st.TotalCount, st.UsableCount, st.GoodCount, st.BadCount, st.NonNumericCount, " + "r.NumValue, r.SourceTimeUtc, st.UncMin, st.UncMax, st.GoodNonNumericCount " + "FROM st LEFT JOIN r ON r.b = st.b AND r.rn = 1 ORDER BY st.b;"; /// /// Time-weighted family (#671: TimeAverage / TimeAverage2 / Total / Total2 / Interpolative) — not pushed down. /// One batch of five result sets, each SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode: /// (1) the latest usable sample before @start, (2) the latest sample of any quality before @start, /// (3) the samples in [@start, @end) ascending, at most @maxRows (the raw-row cap + 1), (4) the first sample of any quality at or after /// @end, (5) the first usable one at or after @end. Each bound is a TOP 1 seek on the /// (TagId, SourceTimeUtc) key. The caller streams them, in that order, into /// (shared with QuestDB, so both backends agree exactly). /// /// /// Trade-off: the window's rows travel to the server instead of one row per bucket. Part 13's per-segment /// interpolation, bucket-edge splitting and the two bounding-value definitions would otherwise have to be written /// twice (T-SQL and QuestDB SQL) and kept bit-identical; evaluating once in C# keeps the backends equal by /// construction. Memory stays O(buckets): the rows are streamed, never materialised. /// /// Schema-qualified Sample table name. /// The SQL batch. public static string TimeWeighted(string sample) { const string cols = "SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode"; const string usable = " AND StatusCode >= 0 AND ValueKind <> 0"; var key = $"FROM {sample} WHERE TagId = @id AND "; return $"SELECT TOP 1 {cols} {key}SourceTimeUtc < @start{usable} ORDER BY SourceTimeUtc DESC; " + $"SELECT TOP 1 {cols} {key}SourceTimeUtc < @start ORDER BY SourceTimeUtc DESC; " + $"SELECT TOP (@maxRows) {cols} {key}SourceTimeUtc >= @start AND SourceTimeUtc < @end ORDER BY SourceTimeUtc ASC; " + $"SELECT TOP 1 {cols} {key}SourceTimeUtc >= @end ORDER BY SourceTimeUtc ASC; " + $"SELECT TOP 1 {cols} {key}SourceTimeUtc >= @end{usable} ORDER BY SourceTimeUtc ASC;"; } /// /// The tag's data range as one row of two columns (each NULL when none): 0 the earliest stored sample /// of any quality at or before the window's later edge, 1 the latest at or after its earlier edge. Used by the /// start-of-data (#677) and end-of-data (#680) Partial rules and by Count's in-range empty interval (#680) — only when /// says the answer can change a result. Each is a MIN / /// MAX over the (TagId, SourceTimeUtc) key — a single seek — bounded by the window (a sample past the /// later edge cannot start the data inside it, nor one before the earlier edge end it there; a NULL then means "the /// data range does not reach the window"). /// /// Schema-qualified Sample table name. /// The SQL text. public static string DataRange(string sample) => $"SELECT (SELECT MIN(SourceTimeUtc) FROM {sample} WHERE TagId = @id AND SourceTimeUtc <= @end), " + $"(SELECT MAX(SourceTimeUtc) FROM {sample} WHERE TagId = @id AND SourceTimeUtc >= @start);"; /// The s CTE: the window's samples with bucket index and the usable/good flags. private static string Classified(string sample, bool reverse) => "WITH s AS (SELECT (DATEDIFF_BIG(microsecond, @start, SourceTimeUtc) * 10 " + "+ (DATEPART(nanosecond, SourceTimeUtc) / 100) % 10 - (DATEPART(nanosecond, @start) / 100) % 10 " + "+ @bucketAdjustTicks) / @intervalTicks AS b, " + "SourceTimeUtc, ValueKind, NumValue, IntValue, StrValue, StatusCode, " + "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 " + $"FROM {sample} WHERE TagId = @id AND " + (reverse ? "SourceTimeUtc > @start AND SourceTimeUtc <= @end) " : "SourceTimeUtc >= @start AND SourceTimeUtc < @end) "); /// /// The minimum and maximum Uncertain (usable, not Good) numeric value of the bucket — UncMin, UncMax /// (#684). Not result values: Part 13 makes Minimum/Maximum/Range Uncertain when one lies beyond the Good extreme. /// Then GoodNonNumericCount, the Good String/DateTime samples (the Good-value aggregates' refusal check). /// private static string UncertainExtremes() => "MIN(CASE WHEN u = 1 AND g = 0 THEN NumValue END) AS UncMin, MAX(CASE WHEN u = 1 AND g = 0 THEN NumValue END) AS UncMax, " + $"SUM(CASE WHEN g = 1 AND ValueKind IN ({NonNumericKinds}) THEN 1 ELSE 0 END) AS GoodNonNumericCount"; /// The five common count columns (after b). private static string CommonCounts() => "COUNT(*) AS TotalCount, SUM(u) AS UsableCount, SUM(g) AS GoodCount, COUNT(*) - SUM(u) AS BadCount, " + $"SUM(CASE WHEN u = 1 AND ValueKind IN ({NonNumericKinds}) THEN 1 ELSE 0 END) AS NonNumericCount"; }