b8344bb151
Per-sample aggregates (all but Average) now set Partial on a reverse read's interval that strictly contains the tag's latest sample — the SDK's reverse m_startOfData (verified vs the SDK harness), matching lane A's time-weighted rule. NeedsDataRange therefore applies in both directions for every per-sample aggregate but Average. PercentGood/Bad left the per-sample evaluator (lane A moved them to the time-weighted family); Evaluate refuses non-per-sample aggregates up front. Comments fixed.
201 lines
12 KiB
C#
201 lines
12 KiB
C#
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||
|
||
/// <summary>
|
||
/// The bucket layout of a processed (aggregate) read, per OPC UA Part 13 / the SDK's <c>AggregateCalculator</c>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Forward</b> (<c>start < end</c>): the window is <c>[start, end)</c>; bucket <c>i</c> =
|
||
/// <c>[start + i·interval, start + (i+1)·interval)</c>, the last bucket truncated at <c>end</c> (the partial one is
|
||
/// the <i>latest</i>); each bucket's timestamp is its start (earlier edge); results ascend.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Reverse</b> (<c>end < start</c>, #677): the read is anchored at the request's start — its <i>later</i>
|
||
/// time — and steps back. The window is <c>(end, start]</c> (Part 11: the request's start is inclusive, its end
|
||
/// exclusive, so a reverse read includes its later time and excludes its earlier one — the same window as a reverse
|
||
/// raw read); result <c>k</c> = <c>(start − (k+1)·interval, start − k·interval]</c>, clipped at <c>end</c> (the
|
||
/// partial bucket is the <i>earliest</i>); each bucket's timestamp is its later edge <c>start − k·interval</c>;
|
||
/// results descend. This is exactly the SDK's <c>CreateSlice</c> / <c>GetTimestamp</c> / backward
|
||
/// <c>GetValues</c>.
|
||
/// </para>
|
||
/// <para>
|
||
/// Internally buckets are indexed in ascending time (<c>0</c> = earliest) for both directions; <see cref="Assemble"/>
|
||
/// reverses the list for a reverse read. <see cref="StartUtc"/>/<see cref="EndUtc"/> are always the earlier / later
|
||
/// window edges. An interval <= 0 ⇒ one interval over the whole window. <c>start == end</c> ⇒ no intervals.
|
||
/// </para>
|
||
/// </remarks>
|
||
public sealed class BucketPlan
|
||
{
|
||
/// <summary>Spec §9.7: a processed read covering more intervals than this is refused.</summary>
|
||
public const int MaxBuckets = 100_000;
|
||
|
||
private BucketPlan(DateTime startUtc, DateTime endUtc, bool reversed, long intervalTicks, long bucketCount)
|
||
{
|
||
StartUtc = startUtc;
|
||
EndUtc = endUtc;
|
||
Reversed = reversed;
|
||
IntervalTicks = intervalTicks;
|
||
BucketCount = bucketCount;
|
||
// Forward buckets are anchored at the earlier edge; reverse ones at the later edge, so the grid origin sits a
|
||
// whole number of intervals before it (possibly before the window start — the earliest bucket is clipped).
|
||
OriginTicks = reversed ? endUtc.Ticks - bucketCount * intervalTicks : startUtc.Ticks;
|
||
}
|
||
|
||
/// <summary>The earlier edge of the window.</summary>
|
||
public DateTime StartUtc { get; }
|
||
|
||
/// <summary>The later edge of the window.</summary>
|
||
public DateTime EndUtc { get; }
|
||
|
||
/// <summary>True when the request came in reversed (<c>end < start</c>).</summary>
|
||
public bool Reversed { get; }
|
||
|
||
/// <summary>Bucket width in 100 ns ticks (clamped to the window; 0 for an empty window).</summary>
|
||
public long IntervalTicks { get; }
|
||
|
||
/// <summary>Number of buckets (0 for an empty window).</summary>
|
||
public long BucketCount { get; }
|
||
|
||
/// <summary>
|
||
/// The tick at which the (unclipped) bucket grid starts: <see cref="StartUtc"/> forward, <c>EndUtc − n·interval</c>
|
||
/// reverse. May precede <see cref="StartUtc"/> (and even <see cref="DateTime.MinValue"/>), so it is a raw tick count.
|
||
/// </summary>
|
||
public long OriginTicks { get; }
|
||
|
||
/// <summary><see cref="StartUtc"/> as 100 ns ticks since the Unix epoch.</summary>
|
||
public long StartUnixTicks => (StartUtc - DateTime.UnixEpoch).Ticks;
|
||
|
||
/// <summary>
|
||
/// The constant a backend adds to <c>elapsed = t − StartUtc</c> (ticks) before the integer division by
|
||
/// <see cref="IntervalTicks"/> to get a sample's ascending bucket index: <c>0</c> forward (<c>[lo, hi)</c> buckets);
|
||
/// <c>(StartUtc − OriginTicks) − 1</c> reverse, where buckets are <c>(lo, hi]</c> — index <c>⌈(t − origin)/I⌉ − 1
|
||
/// = (t − origin − 1) / I</c>. Never makes the dividend negative for an in-window sample (reverse excludes
|
||
/// <c>t == StartUtc</c>).
|
||
/// </summary>
|
||
public long BucketIndexAdjustTicks => Reversed ? StartUtc.Ticks - OriginTicks - 1 : 0;
|
||
|
||
/// <summary>
|
||
/// <see cref="StartUnixTicks"/> minus <see cref="BucketIndexAdjustTicks"/>: the base a backend subtracts from a
|
||
/// sample's Unix-epoch ticks to get the same dividend (<c>(t − base) / IntervalTicks</c>).
|
||
/// </summary>
|
||
public long BucketBaseUnixTicks => StartUnixTicks - BucketIndexAdjustTicks;
|
||
|
||
/// <summary>Plans the buckets of a processed read.</summary>
|
||
/// <param name="startUtc">Requested start.</param>
|
||
/// <param name="endUtc">Requested end (earlier than start for a reverse read).</param>
|
||
/// <param name="interval">Bucket width; <= 0 means one bucket over the whole window.</param>
|
||
/// <returns>The plan.</returns>
|
||
/// <exception cref="ArgumentOutOfRangeException">The window holds more than <see cref="MaxBuckets"/> intervals
|
||
/// (parameter name <c>interval</c>).</exception>
|
||
public static BucketPlan Create(DateTime startUtc, DateTime endUtc, TimeSpan interval)
|
||
{
|
||
var reverse = endUtc < startUtc;
|
||
var (start, end) = reverse ? (endUtc, startUtc) : (startUtc, endUtc);
|
||
var windowTicks = end.Ticks - start.Ticks;
|
||
// 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 > MaxBuckets)
|
||
throw new ArgumentOutOfRangeException(nameof(interval), bucketCount,
|
||
$"A processed read may cover at most {MaxBuckets} intervals; this window holds {bucketCount}.");
|
||
return new BucketPlan(start, end, reverse, intervalTicks, bucketCount);
|
||
}
|
||
|
||
/// <summary>The earlier edge of bucket <paramref name="index"/> (ascending index), in ticks, clipped to the window.</summary>
|
||
/// <param name="index">Zero-based ascending bucket index.</param>
|
||
/// <returns>The earlier edge.</returns>
|
||
public long LowerTicks(long index) => Math.Max(OriginTicks + index * IntervalTicks, StartUtc.Ticks);
|
||
|
||
/// <summary>The later edge of bucket <paramref name="index"/> (ascending index), in ticks, clipped to the window.</summary>
|
||
/// <param name="index">Zero-based ascending bucket index.</param>
|
||
/// <returns>The later edge.</returns>
|
||
public long UpperTicks(long index) => Math.Min(OriginTicks + (index + 1) * IntervalTicks, EndUtc.Ticks);
|
||
|
||
/// <summary>
|
||
/// The result timestamp of bucket <paramref name="index"/> (UTC kind): its earlier edge forward, its later edge
|
||
/// reverse (the SDK's <c>GetTimestamp</c>).
|
||
/// </summary>
|
||
/// <param name="index">Zero-based ascending bucket index.</param>
|
||
/// <returns>The bucket's timestamp.</returns>
|
||
public DateTime BucketTimestamp(long index) =>
|
||
new(Reversed ? UpperTicks(index) : LowerTicks(index), DateTimeKind.Utc);
|
||
|
||
/// <summary>True when bucket <paramref name="index"/> is shorter than the interval (the clipped one).</summary>
|
||
/// <param name="index">Zero-based ascending bucket index.</param>
|
||
/// <returns>Whether the bucket is truncated.</returns>
|
||
public bool IsTruncated(long index) => UpperTicks(index) - LowerTicks(index) < IntervalTicks;
|
||
|
||
/// <summary>
|
||
/// The ascending bucket index holding a sample at <paramref name="t"/>, or null when it is outside the window
|
||
/// (<c>[start, end)</c> forward, <c>(start, end]</c> reverse). The C# statement of the bucket math every backend's
|
||
/// SQL mirrors.
|
||
/// </summary>
|
||
/// <param name="t">Sample time.</param>
|
||
/// <returns>The bucket index, or null.</returns>
|
||
public long? IndexOf(DateTime t)
|
||
{
|
||
var x = t.Ticks;
|
||
if (BucketCount == 0) return null;
|
||
var inside = Reversed ? x > StartUtc.Ticks && x <= EndUtc.Ticks : x >= StartUtc.Ticks && x < EndUtc.Ticks;
|
||
if (!inside) return null;
|
||
return (x - StartUtc.Ticks + BucketIndexAdjustTicks) / IntervalTicks;
|
||
}
|
||
|
||
/// <summary>
|
||
/// True when the earliest stored sample of the tag lies strictly inside bucket <paramref name="index"/> — the data
|
||
/// starts part-way through the interval (no bounding value before it), so Part 13 / the SDK set the Partial bit.
|
||
/// </summary>
|
||
/// <param name="index">Zero-based ascending bucket index.</param>
|
||
/// <param name="earliestSampleUtc">The tag's earliest stored sample (any quality), or null when it has none.</param>
|
||
/// <returns>Whether the data starts inside the bucket.</returns>
|
||
public bool DataStartsInside(long index, DateTime? earliestSampleUtc) =>
|
||
earliestSampleUtc is { } e && e.Ticks > LowerTicks(index) && e.Ticks < UpperTicks(index);
|
||
|
||
/// <summary>
|
||
/// True when the latest stored sample of the tag lies in bucket <paramref name="index"/>: the data ends part-way
|
||
/// through the interval, so Part 13 §5.3.3.2 / the SDK set the Partial bit (#680). Forward the earlier edge is
|
||
/// included (the SDK's <c>m_endOfData >= startTime && m_endOfData < endTime</c>); reverse the sample
|
||
/// must lie strictly inside (the SDK's reverse read keys <c>m_startOfData</c> — a strict test — on the first value it
|
||
/// is fed, the latest).
|
||
/// </summary>
|
||
/// <param name="index">Zero-based ascending bucket index.</param>
|
||
/// <param name="latestSampleUtc">The tag's latest stored sample (any quality), or null when it has none.</param>
|
||
/// <returns>Whether the data ends inside the bucket.</returns>
|
||
public bool DataEndsInside(long index, DateTime? latestSampleUtc) =>
|
||
latestSampleUtc is { } l
|
||
&& (Reversed ? l.Ticks > LowerTicks(index) : l.Ticks >= LowerTicks(index)) && l.Ticks < UpperTicks(index);
|
||
|
||
/// <summary>
|
||
/// True when bucket <paramref name="index"/> overlaps the tag's data range <c>[earliest, latest]</c> — neither
|
||
/// wholly before the first sample nor wholly after the last (the SDK's <c>GetValues</c> null test:
|
||
/// <c>start > last || end < first</c> ⇒ no data, both edges inclusive in either direction). An empty bucket
|
||
/// inside the range is a Count of 0, not <c>BadNoData</c> (#680, Part 13 §5.4.3.21).
|
||
/// </summary>
|
||
/// <param name="index">Zero-based ascending bucket index.</param>
|
||
/// <param name="earliestSampleUtc">The tag's earliest stored sample (any quality), or null.</param>
|
||
/// <param name="latestSampleUtc">The tag's latest stored sample (any quality), or null.</param>
|
||
/// <returns>Whether the bucket is within the data range.</returns>
|
||
public bool WithinDataRange(long index, DateTime? earliestSampleUtc, DateTime? latestSampleUtc) =>
|
||
earliestSampleUtc is { } e && latestSampleUtc is { } l && LowerTicks(index) <= l.Ticks && UpperTicks(index) >= e.Ticks;
|
||
|
||
/// <summary>One snapshot per bucket in result order; missing buckets get <paramref name="empty"/>; descending if reversed.</summary>
|
||
/// <param name="byBucket">Evaluated snapshots of the non-empty buckets, by ascending bucket index.</param>
|
||
/// <param name="empty">Builds the snapshot of a bucket with no row, from its <see cref="BucketTimestamp"/>.</param>
|
||
/// <returns><see cref="BucketCount"/> snapshots.</returns>
|
||
public IReadOnlyList<DataValueSnapshot> Assemble(IReadOnlyDictionary<long, DataValueSnapshot> byBucket,
|
||
Func<DateTime, DataValueSnapshot> empty)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(byBucket);
|
||
ArgumentNullException.ThrowIfNull(empty);
|
||
var result = new List<DataValueSnapshot>((int)BucketCount);
|
||
for (long i = 0; i < BucketCount; i++)
|
||
result.Add(byBucket.TryGetValue(i, out var v) ? v : empty(BucketTimestamp(i)));
|
||
if (Reversed) result.Reverse();
|
||
return result;
|
||
}
|
||
}
|