56bb4855bb
M1: the time-weighted family (incl. Total, formerly a pushed-down SUM) streams raw window rows into the C# evaluator. New shared knob ServerHistorian:MaxTimeWeightedRawRows (default 1,000,000; <=0 unbounded with a Validate warning) flows through HistorianBackendRegistry into both providers. The window query is capped at cap+1 (SQL Server TOP (@maxRows), QuestDB LIMIT $4); one row past the cap throws the new Core.Abstractions HistoryReadLimitExceededException, which the node manager maps to BadTooManyOperations for that node. Not counted as a backend health failure; bound rows are not counted; per-sample aggregates are not capped. M2: reverse Interpolative is anchored at the later time (Part 13): points at End - i*interval, emitted latest first. 10:00->09:00 @1h yields 10:00. L1: Partial bit (0x4, = Opc.Ua.AggregateBits.Partial) on non-Bad results of a truncated last interval (time-weighted family; per-sample family is a follow-up). L2/L3: aligned with the SDK AggregateCalculator.GetTimeBasedStatusCode (decompiled from Opc.Ua.Server 1.5.378.106): TreatUncertainAsBad=false counts Uncertain time (Uncertain samples, held-into-outage, extrapolation) as Good time; data but no non-Bad time => generic Bad (0x80000000); BadNoData only when there is no data at all. Consequence: a live tag's latest interval reads Good (documented). L5: release note no longer claims Total matches the Gateway Integral exactly (AVEVA's divisor is unverified); it notes Total is now evaluated in C# and subject to the cap. Tests: unit (reverse Interpolative x3, Partial, Bad vs BadNoData incl. an open-ended outage, cap helpers, SDK bit oracles); conformance (reverse Interpolative, raw-row cap at/over/0, bounds not counted, health unaffected); node manager BadTooManyOperations; options default + warning. Each new behaviour falsified by mutation (aggregator x9, data-source cap checks on both backends against the real DBs, node-manager catch).
159 lines
10 KiB
C#
159 lines
10 KiB
C#
using System.Globalization;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
|
||
|
||
/// <summary>
|
||
/// Push-down QuestDB SQL for processed (aggregate) reads (spec §3 "Processed", T0 Q8-confirmed forms), one query
|
||
/// per <see cref="AggregateFamily"/>, mirroring the SQL Server backend's <c>SqlAggregateQueries</c> family split and
|
||
/// column order. Every query takes five positional parameters:
|
||
/// <list type="number">
|
||
/// <item><c>$1</c> — the (rounded) window start in 100 ns ticks since the Unix epoch
|
||
/// (<see cref="BucketPlan.StartUnixTicks"/>, <c>Bigint</c>);</item>
|
||
/// <item><c>$2</c> — the bucket width in ticks (<see cref="BucketPlan.IntervalTicks"/>, <c>Bigint</c>);</item>
|
||
/// <item><c>$3</c> — the tag (<c>Varchar</c>);</item>
|
||
/// <item><c>$4</c>/<c>$5</c> — the forward window <c>[start, end)</c> (<c>TimestampTz</c> via
|
||
/// <see cref="QuestDbConnection.BindTimestamp"/>).</item>
|
||
/// </list>
|
||
/// Each returns one row per <b>non-empty</b> bucket; the caller fills the empty ones.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Bucketing.</b> <c>b = (cast(ts AS LONG) * 10 − $1) / $2</c>. <c>cast(ts AS LONG)</c> is µs since 1970, so
|
||
/// <c>× 10</c> is ticks since 1970; both it and <c>$1</c> are µs-aligned (R6), so the integer division is
|
||
/// start-inclusive / end-exclusive to the tick, exactly like SQL Server's. Every row is at or after the start,
|
||
/// so the dividend is never negative (no truncation-toward-zero surprise), and nothing overflows before 9999.
|
||
/// <c>SAMPLE BY</c> is deliberately not used: its alignment rules differ from ours.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Sample classes (S6).</b> The status is stored as the <b>unsigned</b> 32-bit code in a <c>LONG</c>, so
|
||
/// "not Bad" is <c>status < 0x80000000</c> (not T-SQL's sign test). <i>usable</i> = not Bad AND
|
||
/// <c>kind <> 0</c> (an Empty sample is an outage marker and counts as Bad whatever its code);
|
||
/// <i>good</i> = usable AND <c>status < 0x40000000</c>; <i>bad</i> = not usable; <i>non-numeric</i> = usable
|
||
/// String/DateTime. The literals are generated from <see cref="StatusBits"/> and <see cref="HistorianValueKind"/>.
|
||
/// </para>
|
||
/// <para>Common result columns (ordinals 0–5): <c>b, total_count, usable_count, good_count, bad_count,
|
||
/// non_numeric_count</c>. Family columns follow from <see cref="FirstFamilyColumn"/>.</para>
|
||
/// </remarks>
|
||
internal static class QuestDbAggregateQueries
|
||
{
|
||
/// <summary>Ordinal of the first family-specific column.</summary>
|
||
public const int FirstFamilyColumn = 6;
|
||
|
||
/// <summary><c>status < SeverityBadBit</c>, as a decimal LONG literal.</summary>
|
||
internal static readonly string NotBadLimit = StatusBits.SeverityBadBit.ToString(CultureInfo.InvariantCulture);
|
||
|
||
/// <summary><c>status < SeverityUncertainBit</c>, as a decimal LONG literal.</summary>
|
||
internal static readonly string GoodLimit = StatusBits.SeverityUncertainBit.ToString(CultureInfo.InvariantCulture);
|
||
|
||
/// <summary>The <c>kind</c> values of String and DateTime: usable but not numeric.</summary>
|
||
internal static readonly string NonNumericKinds =
|
||
((byte)HistorianValueKind.String).ToString(CultureInfo.InvariantCulture) + ", " +
|
||
((byte)HistorianValueKind.DateTime).ToString(CultureInfo.InvariantCulture);
|
||
|
||
private static readonly string EmptyKind = ((byte)HistorianValueKind.Empty).ToString(CultureInfo.InvariantCulture);
|
||
|
||
/// <summary>
|
||
/// Average / Minimum / Maximum / Total / Count / Range / PercentGood / PercentBad.
|
||
/// Family columns: <c>6 avg_value, 7 min_value, 8 max_value, 9 sum_value</c> (DOUBLE, NULL when no usable sample).
|
||
/// </summary>
|
||
/// <param name="sample">Prefixed sample table name.</param>
|
||
/// <returns>The SQL text.</returns>
|
||
public static string Simple(string sample) =>
|
||
"SELECT b, " + CommonCounts() + ", " +
|
||
"avg(CASE WHEN u = 1 THEN num_value END) AS avg_value, " +
|
||
"min(CASE WHEN u = 1 THEN num_value END) AS min_value, " +
|
||
"max(CASE WHEN u = 1 THEN num_value END) AS max_value, " +
|
||
"sum(CASE WHEN u = 1 THEN num_value END) AS sum_value " +
|
||
"FROM (" + Classified(sample) + ") GROUP BY b ORDER BY b";
|
||
|
||
/// <summary>
|
||
/// Start / End / Delta: the first and last usable sample of each bucket by <c>ts</c>.
|
||
/// Family columns: <c>6..10</c> first sample (<c>kind, num_value, int_value, str_value, ts</c>), <c>11..15</c> last
|
||
/// sample (same order). A bucket without a usable sample has NULL <c>ts</c> but <c>kind = 0</c> (BYTE has no NULL),
|
||
/// so the reader keys absence on the NULL time.
|
||
/// </summary>
|
||
/// <param name="sample">Prefixed sample table name.</param>
|
||
/// <returns>The SQL text.</returns>
|
||
public static string StartEnd(string sample) =>
|
||
"WITH s AS (" + Classified(sample) + "), " +
|
||
"st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b), " +
|
||
"f AS (SELECT b, kind, num_value, int_value, str_value, ts, " +
|
||
"row_number() OVER (PARTITION BY b ORDER BY ts ASC) AS rf, " +
|
||
"row_number() OVER (PARTITION BY b ORDER BY ts DESC) AS rl FROM s WHERE u = 1) " +
|
||
"SELECT st.*, " +
|
||
"x.kind, x.num_value, x.int_value, x.str_value, x.ts, " +
|
||
"y.kind, y.num_value, y.int_value, y.str_value, y.ts " +
|
||
"FROM st LEFT JOIN (SELECT * FROM f WHERE rf = 1) x ON x.b = st.b " +
|
||
"LEFT JOIN (SELECT * FROM f WHERE rl = 1) y ON y.b = st.b ORDER BY st.b";
|
||
|
||
/// <summary>
|
||
/// MinimumActualTime / MaximumActualTime: the usable sample with the smallest (largest) <c>num_value</c>, ties
|
||
/// broken by the earliest <c>ts</c>. Family columns: <c>6 num_value, 7 ts</c> (NULL when the bucket has no usable
|
||
/// numeric sample).
|
||
/// </summary>
|
||
/// <param name="sample">Prefixed sample table name.</param>
|
||
/// <param name="maximum">True for MaximumActualTime.</param>
|
||
/// <returns>The SQL text.</returns>
|
||
public static string ActualTime(string sample, bool maximum) =>
|
||
"WITH s AS (" + Classified(sample) + "), " +
|
||
"st AS (SELECT b, " + CommonCounts() + " FROM s GROUP BY b), " +
|
||
"r AS (SELECT b, num_value, ts, " +
|
||
"row_number() OVER (PARTITION BY b ORDER BY num_value " + (maximum ? "DESC" : "ASC") + ", ts ASC) AS rn " +
|
||
"FROM s WHERE u = 1 AND num_value IS NOT NULL) " +
|
||
"SELECT st.*, r.num_value, r.ts " +
|
||
"FROM st LEFT JOIN (SELECT * FROM r WHERE rn = 1) r ON r.b = st.b ORDER BY st.b";
|
||
|
||
/// <summary>
|
||
/// Time-weighted family (#671), bounds statement — <b>not</b> pushed down (see <see cref="TimeWeightedWindow"/>).
|
||
/// Four <c>LIMIT 1</c> seeks <c>UNION ALL</c>-ed, each tagged with <c>k</c>: 0 = latest usable sample before the
|
||
/// start, 1 = latest sample of any quality before the start, 2 = first sample of any quality at or after the end,
|
||
/// 3 = first usable one at or after the end. Parameters: <c>$1</c> tag, <c>$2</c>/<c>$3</c> the forward window
|
||
/// start / end. Columns: <c>k</c>, then <c>ts, kind, num_value, int_value, str_value, status, server_ts</c>.
|
||
/// </summary>
|
||
/// <param name="sample">Prefixed sample table name.</param>
|
||
/// <returns>The SQL text.</returns>
|
||
public static string TimeWeightedBounds(string sample)
|
||
{
|
||
var usable = $" AND status < {NotBadLimit} AND kind <> {EmptyKind}";
|
||
string Seek(int k, string cmp, string filter, string order) =>
|
||
$"SELECT * FROM (SELECT {k.ToString(CultureInfo.InvariantCulture)} AS k, {SampleColumns} FROM {sample} " +
|
||
$"WHERE tag = $1 AND ts {cmp}{filter} AND NOT deleted ORDER BY ts {order} LIMIT 1)";
|
||
return Seek(0, "< $2", usable, "DESC") + " UNION ALL " + Seek(1, "< $2", "", "DESC") + " UNION ALL " +
|
||
Seek(2, ">= $3", "", "ASC") + " UNION ALL " + Seek(3, ">= $3", usable, "ASC");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Time-weighted family (#671), window statement: the live samples in <c>[$2, $3)</c> for tag <c>$1</c>, ascending,
|
||
/// at most <c>$4</c> (the raw-row cap + 1).
|
||
/// Columns: <c>ts, kind, num_value, int_value, str_value, status, server_ts</c>. The caller feeds the before-bounds,
|
||
/// these rows and the after-bounds, in that order, into <see cref="TimeWeightedAggregator"/> — the same evaluator
|
||
/// the SQL Server backend uses, so the two agree exactly. Trade-off: the window's rows travel to the server rather
|
||
/// than one row per bucket; Part 13's segment interpolation and bucket-edge splitting are then written once
|
||
/// instead of twice in two SQL dialects that must stay bit-identical.
|
||
/// </summary>
|
||
/// <param name="sample">Prefixed sample table name.</param>
|
||
/// <returns>The SQL text.</returns>
|
||
public static string TimeWeightedWindow(string sample) =>
|
||
$"SELECT {SampleColumns} FROM {sample} WHERE tag = $1 AND ts >= $2 AND ts < $3 AND NOT deleted ORDER BY ts ASC LIMIT $4";
|
||
|
||
/// <summary>The row columns the time-weighted statements return (after <c>k</c> for the bounds).</summary>
|
||
private const string SampleColumns = "ts, kind, num_value, int_value, str_value, status, server_ts";
|
||
|
||
/// <summary>The bucket-index expression (exposed for tests).</summary>
|
||
internal const string BucketExpression = "(cast(ts AS LONG) * 10 - $1) / $2";
|
||
|
||
/// <summary>The classified subquery <c>s</c>: the window's live samples with bucket index and the usable/good flags.</summary>
|
||
private static string Classified(string sample) =>
|
||
"SELECT " + BucketExpression + " AS b, ts, kind, num_value, int_value, str_value, " +
|
||
$"CASE WHEN status < {NotBadLimit} AND kind <> {EmptyKind} THEN 1 ELSE 0 END AS u, " +
|
||
$"CASE WHEN status < {GoodLimit} AND kind <> {EmptyKind} THEN 1 ELSE 0 END AS g " +
|
||
$"FROM {sample} WHERE tag = $3 AND ts >= $4 AND ts < $5 AND NOT deleted";
|
||
|
||
/// <summary>The five common count columns (after <c>b</c>).</summary>
|
||
private static string CommonCounts() =>
|
||
"count() AS total_count, sum(u) AS usable_count, sum(g) AS good_count, count() - sum(u) AS bad_count, " +
|
||
$"sum(CASE WHEN u = 1 AND kind IN ({NonNumericKinds}) THEN 1 ELSE 0 END) AS non_numeric_count";
|
||
}
|