fix(historian): #677 review — start-of-data Partial forward-only, bounded/skipped earliest-sample query, wider SDK parity
F1: the SDK applies its m_startOfData Partial rule to forward reads only; ours now does too (AggregateEvaluator.EvaluateAll + TimeWeightedAggregator). New reverse late-start parity tests through the SDK oracle (unit) and real SQL (conformance, both backends). F2: AggregateEvaluator.NeedsEarliestSample — the earliest-sample query runs only for forward reads of aggregates other than Average, and is bounded by the window end (SqlServer < @end, QuestDB ts < $2). F3: per-sample SDK parity extended to Range, Start, End, Delta, Min/Max ActualTime and PercentGood/Bad (oracle maps StatusAggregateCalculator); pre-existing definitional differences masked and marked #680. Docs: Historian.md Partial/SDK-parity sections, CLAUDE.md bullet.
This commit is contained in:
@@ -150,13 +150,14 @@ internal static class QuestDbAggregateQueries
|
||||
internal const string BucketExpression = "(cast(ts AS LONG) * 10 - $1) / $2";
|
||||
|
||||
/// <summary>
|
||||
/// The tag's earliest live sample (any quality): the start-of-data Partial rule (#677). Parameter <c>$1</c> tag.
|
||||
/// Column: <c>ts</c>; no row when the tag has none.
|
||||
/// The tag's earliest live sample (any quality) before the window end: the start-of-data Partial rule (#677; forward
|
||||
/// reads only, so the forward window's exclusive end bounds the scan). Parameters <c>$1</c> tag, <c>$2</c> the window
|
||||
/// end. Column: <c>ts</c>; no row when the tag has none before the end.
|
||||
/// </summary>
|
||||
/// <param name="sample">Prefixed sample table name.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
public static string EarliestSample(string sample) =>
|
||||
$"SELECT ts FROM {sample} WHERE tag = $1 AND NOT deleted ORDER BY ts ASC LIMIT 1";
|
||||
$"SELECT ts FROM {sample} WHERE tag = $1 AND ts < $2 AND NOT deleted ORDER BY ts ASC LIMIT 1";
|
||||
|
||||
/// <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, bool reverse) =>
|
||||
|
||||
+5
-2
@@ -489,10 +489,13 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
rows.Add(ReadBucket(reader, family));
|
||||
}
|
||||
// The tag's earliest live sample: the start-of-data Partial rule (#677).
|
||||
await using (var cmd = _conn.CreateCommand(conn, QuestDbAggregateQueries.EarliestSample(sample)))
|
||||
// The tag's earliest live sample before the window end: the start-of-data Partial rule (#677) — forward
|
||||
// reads of an aggregate other than Average only; skipped otherwise.
|
||||
if (AggregateEvaluator.NeedsEarliestSample(aggregate, plan))
|
||||
{
|
||||
await using var cmd = _conn.CreateCommand(conn, QuestDbAggregateQueries.EarliestSample(sample));
|
||||
QuestDbConnection.BindText(cmd, fullReference);
|
||||
QuestDbConnection.BindTimestamp(cmd, plan.EndUtc);
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (await reader.ReadAsync(cancellationToken).ConfigureAwait(false) && !reader.IsDBNull(0))
|
||||
earliest = QuestDbConnection.ReadUtc(reader, 0);
|
||||
|
||||
@@ -80,11 +80,26 @@ public static class AggregateEvaluator
|
||||
/// <returns>Whether the start-of-data Partial rule applies.</returns>
|
||||
public static bool SetsStartOfDataPartial(HistoryAggregateType aggregate) => aggregate != HistoryAggregateType.Average;
|
||||
|
||||
/// <summary>
|
||||
/// True when a per-sample read must fetch the tag's earliest sample: the start-of-data Partial rule applies only to a
|
||||
/// <b>forward</b> read (as in the SDK, whose reverse read keys <c>m_startOfData</c> on the first value it is fed — the
|
||||
/// latest) of an aggregate other than <c>Average</c>. Otherwise the backend skips that query.
|
||||
/// </summary>
|
||||
/// <param name="aggregate">Requested aggregate.</param>
|
||||
/// <param name="plan">The bucket plan.</param>
|
||||
/// <returns>Whether the earliest sample is needed.</returns>
|
||||
public static bool NeedsEarliestSample(HistoryAggregateType aggregate, BucketPlan plan)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
return !plan.Reversed && plan.BucketCount > 0 && SetsStartOfDataPartial(aggregate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates every non-empty bucket row and assembles one result per interval in the plan's order (descending for a
|
||||
/// reverse read): each value stamped with <see cref="BucketPlan.BucketTimestamp"/> (the earlier edge forward, the
|
||||
/// later edge reverse); empty buckets <c>BadNoData</c>; and — for every aggregate but <c>Average</c> — the Partial
|
||||
/// bit on a non-Bad result of the bucket the tag's data starts inside (#677, the SDK's <c>m_startOfData</c> rule).
|
||||
/// later edge reverse); empty buckets <c>BadNoData</c>; and — on a forward read, for every aggregate but
|
||||
/// <c>Average</c> — the Partial bit on a non-Bad result of the bucket the tag's data starts inside (#677, the SDK's
|
||||
/// <c>m_startOfData</c> rule; the SDK applies it to forward reads only, <see cref="NeedsEarliestSample"/>).
|
||||
/// </summary>
|
||||
/// <param name="aggregate">A per-sample (Simple / StartEnd / ActualTime) aggregate.</param>
|
||||
/// <param name="plan">The bucket plan.</param>
|
||||
@@ -98,7 +113,7 @@ public static class AggregateEvaluator
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
var evaluated = new Dictionary<long, DataValueSnapshot>();
|
||||
var partialRule = SetsStartOfDataPartial(aggregate);
|
||||
var partialRule = NeedsEarliestSample(aggregate, plan);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var v = Evaluate(aggregate, row, plan.BucketTimestamp(row.Bucket), now);
|
||||
|
||||
@@ -52,7 +52,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
/// counts as Bad (no data) time. A usable String/DateTime sample contributing to the interval ⇒
|
||||
/// <c>BadAggregateInvalidInputs</c>. Every status carries the Calculated bit, plus the Partial bit on a non-Bad
|
||||
/// result of the truncated interval or of the interval the data starts inside (the first sample fed lies strictly
|
||||
/// within it, #677); the timestamp is the interval's <see cref="BucketPlan.BucketTimestamp"/> (its start forward, its
|
||||
/// within it — forward reads only, as in the SDK, #677); the timestamp is the interval's <see cref="BucketPlan.BucketTimestamp"/> (its start forward, its
|
||||
/// later edge reverse); the value is a
|
||||
/// <see cref="double"/> (Boolean = 0/1, Int64 as double).
|
||||
/// </para>
|
||||
@@ -241,8 +241,8 @@ public sealed class TimeWeightedAggregator
|
||||
var stamp = _plan.BucketTimestamp(i);
|
||||
var duration = BucketEnd(i) - BucketStartTicks(i);
|
||||
// Partial: the truncated interval (the latest forward, the earliest reverse), or the one the data starts inside
|
||||
// (no bounding value before it — the SDK's m_startOfData rule, #677).
|
||||
var partial = _plan.IsTruncated(i) || _plan.DataStartsInside(i, _firstSampleUtc);
|
||||
// (no bounding value before it — the SDK's m_startOfData rule, #677), which the SDK applies to forward reads only.
|
||||
var partial = _plan.IsTruncated(i) || (!_plan.Reversed && _plan.DataStartsInside(i, _firstSampleUtc));
|
||||
DataValueSnapshot Calc(object? value, uint status)
|
||||
{
|
||||
var code = HistorianInfoBits.WithCalculated(status);
|
||||
|
||||
@@ -131,12 +131,13 @@ internal static class SqlAggregateQueries
|
||||
|
||||
/// <summary>
|
||||
/// The earliest stored sample of the tag (any quality), one row (<c>NULL</c> when none): the start-of-data Partial
|
||||
/// rule (#677). A <c>MIN</c> over the <c>(TagId, SourceTimeUtc)</c> key — a single seek.
|
||||
/// rule (#677; forward reads only). A <c>MIN</c> over the <c>(TagId, SourceTimeUtc)</c> key — a single seek — bounded
|
||||
/// by the window end (a sample at or past it cannot lie inside an interval).
|
||||
/// </summary>
|
||||
/// <param name="sample">Schema-qualified <c>Sample</c> table name.</param>
|
||||
/// <returns>The SQL text.</returns>
|
||||
public static string EarliestSample(string sample) =>
|
||||
$"SELECT MIN(SourceTimeUtc) FROM {sample} WHERE TagId = @id;";
|
||||
$"SELECT MIN(SourceTimeUtc) FROM {sample} WHERE TagId = @id AND SourceTimeUtc < @end;";
|
||||
|
||||
/// <summary>The <c>s</c> CTE: the window's samples with bucket index and the usable/good flags.</summary>
|
||||
private static string Classified(string sample, bool reverse) =>
|
||||
|
||||
+5
-3
@@ -449,8 +449,10 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
AggregateFamily.StartEnd => SqlAggregateQueries.StartEnd(sample, plan.Reversed),
|
||||
_ => SqlAggregateQueries.ActualTime(sample, aggregate == HistoryAggregateType.MaximumActualTime, plan.Reversed),
|
||||
};
|
||||
// Second result set: the tag's earliest sample (the start-of-data Partial rule, #677).
|
||||
sql += " " + SqlAggregateQueries.EarliestSample(sample);
|
||||
// Second result set: the tag's earliest sample (the start-of-data Partial rule, #677) — forward reads of an
|
||||
// aggregate other than Average only.
|
||||
var needsEarliest = AggregateEvaluator.NeedsEarliestSample(aggregate, plan);
|
||||
if (needsEarliest) sql += " " + SqlAggregateQueries.EarliestSample(sample);
|
||||
await using var conn = await _conn.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var cmd = _conn.CreateCommand(conn, sql);
|
||||
cmd.Parameters.Add(new SqlParameter("@id", SqlDbType.Int) { Value = id });
|
||||
@@ -461,7 +463,7 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
rows.Add(ReadBucket(reader, family));
|
||||
if (await reader.NextResultAsync(cancellationToken).ConfigureAwait(false)
|
||||
if (needsEarliest && await reader.NextResultAsync(cancellationToken).ConfigureAwait(false)
|
||||
&& await reader.ReadAsync(cancellationToken).ConfigureAwait(false) && !reader.IsDBNull(0))
|
||||
earliest = DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user