using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; /// /// The bucket layout of a processed (aggregate) read, per OPC UA Part 13 / the SDK's AggregateCalculator. /// /// /// /// Forward (start < end): the window is [start, end); bucket i = /// [start + i·interval, start + (i+1)·interval), the last bucket truncated at end (the partial one is /// the latest); each bucket's timestamp is its start (earlier edge); results ascend. /// /// /// Reverse (end < start, #677): the read is anchored at the request's start — its later /// time — and steps back. The window is (end, start] (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 k = (start − (k+1)·interval, start − k·interval], clipped at end (the /// partial bucket is the earliest); each bucket's timestamp is its later edge start − k·interval; /// results descend. This is exactly the SDK's CreateSlice / GetTimestamp / backward /// GetValues. /// /// /// Internally buckets are indexed in ascending time (0 = earliest) for both directions; /// reverses the list for a reverse read. / are always the earlier / later /// window edges. An interval <= 0 ⇒ one interval over the whole window. start == end ⇒ no intervals. /// /// public sealed class BucketPlan { /// Spec §9.7: a processed read covering more intervals than this is refused. 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; } /// The earlier edge of the window. public DateTime StartUtc { get; } /// The later edge of the window. public DateTime EndUtc { get; } /// True when the request came in reversed (end < start). public bool Reversed { get; } /// Bucket width in 100 ns ticks (clamped to the window; 0 for an empty window). public long IntervalTicks { get; } /// Number of buckets (0 for an empty window). public long BucketCount { get; } /// /// The tick at which the (unclipped) bucket grid starts: forward, EndUtc − n·interval /// reverse. May precede (and even ), so it is a raw tick count. /// public long OriginTicks { get; } /// as 100 ns ticks since the Unix epoch. public long StartUnixTicks => (StartUtc - DateTime.UnixEpoch).Ticks; /// /// The constant a backend adds to elapsed = t − StartUtc (ticks) before the integer division by /// to get a sample's ascending bucket index: 0 forward ([lo, hi) buckets); /// (StartUtc − OriginTicks) − 1 reverse, where buckets are (lo, hi] — index ⌈(t − origin)/I⌉ − 1 /// = (t − origin − 1) / I. Never makes the dividend negative for an in-window sample (reverse excludes /// t == StartUtc). /// public long BucketIndexAdjustTicks => Reversed ? StartUtc.Ticks - OriginTicks - 1 : 0; /// /// minus : the base a backend subtracts from a /// sample's Unix-epoch ticks to get the same dividend ((t − base) / IntervalTicks). /// public long BucketBaseUnixTicks => StartUnixTicks - BucketIndexAdjustTicks; /// Plans the buckets of a processed read. /// Requested start. /// Requested end (earlier than start for a reverse read). /// Bucket width; <= 0 means one bucket over the whole window. /// The plan. /// The window holds more than intervals /// (parameter name interval). 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); } /// The earlier edge of bucket (ascending index), in ticks, clipped to the window. /// Zero-based ascending bucket index. /// The earlier edge. public long LowerTicks(long index) => Math.Max(OriginTicks + index * IntervalTicks, StartUtc.Ticks); /// The later edge of bucket (ascending index), in ticks, clipped to the window. /// Zero-based ascending bucket index. /// The later edge. public long UpperTicks(long index) => Math.Min(OriginTicks + (index + 1) * IntervalTicks, EndUtc.Ticks); /// /// The result timestamp of bucket (UTC kind): its earlier edge forward, its later edge /// reverse (the SDK's GetTimestamp). /// /// Zero-based ascending bucket index. /// The bucket's timestamp. public DateTime BucketTimestamp(long index) => new(Reversed ? UpperTicks(index) : LowerTicks(index), DateTimeKind.Utc); /// True when bucket is shorter than the interval (the clipped one). /// Zero-based ascending bucket index. /// Whether the bucket is truncated. public bool IsTruncated(long index) => UpperTicks(index) - LowerTicks(index) < IntervalTicks; /// /// The ascending bucket index holding a sample at , or null when it is outside the window /// ([start, end) forward, (start, end] reverse). The C# statement of the bucket math every backend's /// SQL mirrors. /// /// Sample time. /// The bucket index, or null. 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; } /// /// True when the earliest stored sample of the tag lies strictly inside bucket — the data /// starts part-way through the interval (no bounding value before it), so Part 13 / the SDK set the Partial bit. /// /// Zero-based ascending bucket index. /// The tag's earliest stored sample (any quality), or null when it has none. /// Whether the data starts inside the bucket. public bool DataStartsInside(long index, DateTime? earliestSampleUtc) => earliestSampleUtc is { } e && e.Ticks > LowerTicks(index) && e.Ticks < UpperTicks(index); /// /// True when the latest stored sample of the tag lies in bucket : 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 m_endOfData >= startTime && m_endOfData < endTime); reverse the sample /// must lie strictly inside (the SDK's reverse read keys m_startOfData — a strict test — on the first value it /// is fed, the latest). /// /// Zero-based ascending bucket index. /// The tag's latest stored sample (any quality), or null when it has none. /// Whether the data ends inside the bucket. public bool DataEndsInside(long index, DateTime? latestSampleUtc) => latestSampleUtc is { } l && (Reversed ? l.Ticks > LowerTicks(index) : l.Ticks >= LowerTicks(index)) && l.Ticks < UpperTicks(index); /// /// True when bucket overlaps the tag's data range [earliest, latest] — neither /// wholly before the first sample nor wholly after the last (the SDK's GetValues null test: /// start > last || end < first ⇒ no data, both edges inclusive in either direction). An empty bucket /// inside the range is a Count of 0, not BadNoData (#680, Part 13 §5.4.3.21). /// /// Zero-based ascending bucket index. /// The tag's earliest stored sample (any quality), or null. /// The tag's latest stored sample (any quality), or null. /// Whether the bucket is within the data range. public bool WithinDataRange(long index, DateTime? earliestSampleUtc, DateTime? latestSampleUtc) => earliestSampleUtc is { } e && latestSampleUtc is { } l && LowerTicks(index) <= l.Ticks && UpperTicks(index) >= e.Ticks; /// One snapshot per bucket in result order; missing buckets get ; descending if reversed. /// Evaluated snapshots of the non-empty buckets, by ascending bucket index. /// Builds the snapshot of a bucket with no row, from its . /// snapshots. public IReadOnlyList Assemble(IReadOnlyDictionary byBucket, Func empty) { ArgumentNullException.ThrowIfNull(byBucket); ArgumentNullException.ThrowIfNull(empty); var result = new List((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; } }