using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; /// /// Serves a processed read of the AVEVA-native TimeAverage / Total on the SQL backends' interval grid /// (), in either direction: forward — window [start, end), intervals stepping forward /// from start, the clipped interval the LATEST, each stamped with its earlier edge, oldest first; reverse (#677, /// #691.2) — window (end, start], intervals stepping back from start, the clipped interval the EARLIEST, /// each stamped with its later edge, newest first. The gateway refuses start > end and interval <= 0, /// so every request it sees is forward with a positive interval. /// /// /// /// Forward requests on the plan's grid. AVEVA anchors a native read's interval grid at the request start (live, /// 2026-10-12: rows at start + j·interval, plus one extra row for the interval before it, #685). So the plan's /// whole intervals are requested as ONE forward read spanning exactly them with the interval I, and the /// clipped interval (when the window is not a whole number of intervals) as a second forward read over exactly its /// span with its own width as the interval — AVEVA never averages time outside the window (before it on a reverse /// read, past EndTime on a forward one). An interval wider than the window is sent as the window, and /// interval <= 0 as one interval over the whole window (it used to reach the gateway as 0 and be refused). /// /// /// Soundness of reverse. A time-weighted average / integral over [a, b) and over (a, b] integrate /// the same function over sets differing by two instants, so a forward native row IS the value of the reverse /// interval with the same edges. The one place the half-open side could matter is AVEVA's status for an interval /// whose only non-good sample lies exactly on an edge — accepted, as on every native row. /// /// /// Mapping. Each row is mapped as before (: Uncertain keeps its /// value, no data ⇒ BadNoData, Calculated bits), placed by its interval start (else end), restamped with /// , and the clipped interval carries the Partial bit unless Bad (Part 13 /// §5.3.3.2 Case 3, as the SQL backends' time-weighted aggregates — which additionally mark the intervals the data /// starts/ends inside; AVEVA's rows carry no data range, so those are not reproduced). An interval AVEVA answers no /// row for is absent (not a fabricated BadNoData, unlike the SQL backends). /// /// /// ⚠️ Today historiangw#27 (the interval reaches AVEVA in ticks where it reads milliseconds) makes AVEVA answer ONE /// Good row per request — the whole-intervals read lands on its earliest interval with a whole-span value and the rest /// are absent — in both directions. The mapping needs no change once the gateway is fixed. /// /// internal static class NativeAggregatePlan { /// One forward native request and the ascending bucket index of its first interval. /// Forward request start (inclusive). /// Forward request end. /// The request's interval (> 0). /// Ascending plan bucket of the request's first interval. /// Number of plan buckets the request covers. internal readonly record struct Request(DateTime StartUtc, DateTime EndUtc, TimeSpan Interval, long FirstBucket, long BucketCount); /// The forward requests serving a plan: the whole intervals first, then the clipped one (if any). /// A plan with at least one bucket. /// One or two forward requests. public static IReadOnlyList Requests(BucketPlan plan) { var interval = TimeSpan.FromTicks(plan.IntervalTicks); var n = plan.BucketCount; var clipped = plan.IsTruncated(0) ? 0L : plan.IsTruncated(n - 1) ? n - 1 : -1L; if (clipped < 0) return [new Request(plan.StartUtc, plan.EndUtc, interval, 0, n)]; var clipLo = new DateTime(plan.LowerTicks(clipped), DateTimeKind.Utc); var clipHi = new DateTime(plan.UpperTicks(clipped), DateTimeKind.Utc); var requests = new List(2); if (n > 1) requests.Add(clipped == 0 ? new Request(clipHi, plan.EndUtc, interval, 1, n - 1) // reverse: the clipped one is the earliest : new Request(plan.StartUtc, clipLo, interval, 0, n - 1)); // forward: the clipped one is the latest requests.Add(new Request(clipLo, clipHi, clipHi - clipLo, clipped, 1)); return requests; } /// /// Maps a request's rows onto the plan's buckets: rows outside the request window (AVEVA's pre-window row, #685) /// are dropped; each in-window row is placed by its interval start (or, without one, its end). /// /// The plan. /// The forward request the rows answer. /// The drained rows. /// Results by ascending bucket index (first row per bucket wins). public static void Place(BucketPlan plan, Request request, IEnumerable rows, Dictionary byBucket) { var interval = request.Interval.Ticks; foreach (var row in rows) { if (!SampleMapper.IsInWindow(row, request.StartUtc, request.EndUtc)) continue; long offset = row.StartTime is { } s ? (s.ToDateTime().Ticks - request.StartUtc.Ticks) / interval : (row.EndTime.ToDateTime().Ticks - request.StartUtc.Ticks - 1) / interval; // IsInWindow ⇒ end in (lo, hi] if (offset < 0 || offset >= request.BucketCount) continue; var index = request.FirstBucket + offset; if (byBucket.ContainsKey(index)) continue; var snapshot = SampleMapper.ToAggregateSnapshot(row); var status = snapshot.StatusCode; if (plan.IsTruncated(index) && !StatusBits.IsBad(status)) status |= HistorianInfoBits.HistorianPartial; byBucket[index] = snapshot with { StatusCode = status, SourceTimestampUtc = plan.BucketTimestamp(index) }; } } /// The placed results in the read's order (ascending forward, descending reverse). /// The plan. /// Results by ascending bucket index. /// The results. public static IReadOnlyList Assemble(BucketPlan plan, IReadOnlyDictionary byBucket) => (plan.Reversed ? byBucket.OrderByDescending(kv => kv.Key) : byBucket.OrderBy(kv => kv.Key)).Select(kv => kv.Value).ToList(); }