Files
lmxopcua/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/NativeAggregatePlan.cs
T

113 lines
7.5 KiB
C#

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;
/// <summary>
/// Serves a processed read of the AVEVA-native <c>TimeAverage</c> / <c>Total</c> on the SQL backends' interval grid
/// (<see cref="BucketPlan"/>), in either direction: forward — window <c>[start, end)</c>, intervals stepping forward
/// from <c>start</c>, the clipped interval the LATEST, each stamped with its earlier edge, oldest first; reverse (#677,
/// #691.2) — window <c>(end, start]</c>, intervals stepping back from <c>start</c>, the clipped interval the EARLIEST,
/// each stamped with its later edge, newest first. The gateway refuses <c>start &gt; end</c> and <c>interval &lt;= 0</c>,
/// so every request it sees is forward with a positive interval.
/// </summary>
/// <remarks>
/// <para>
/// <b>Forward requests on the plan's grid.</b> AVEVA anchors a native read's interval grid at the request start (live,
/// 2026-10-12: rows at <c>start + j·interval</c>, 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 <c>I</c>, 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 <c>EndTime</c> on a forward one). An interval wider than the window is sent as the window, and
/// <c>interval &lt;= 0</c> as one interval over the whole window (it used to reach the gateway as 0 and be refused).
/// </para>
/// <para>
/// <b>Soundness of reverse.</b> A time-weighted average / integral over <c>[a, b)</c> and over <c>(a, b]</c> 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.
/// </para>
/// <para>
/// <b>Mapping.</b> Each row is mapped as before (<see cref="SampleMapper.ToAggregateSnapshot"/>: Uncertain keeps its
/// value, no data ⇒ <c>BadNoData</c>, Calculated bits), placed by its interval start (else end), restamped with
/// <see cref="BucketPlan.BucketTimestamp"/>, 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 <c>BadNoData</c>, unlike the SQL backends).
/// </para>
/// <para>
/// ⚠️ 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.
/// </para>
/// </remarks>
internal static class NativeAggregatePlan
{
/// <summary>One forward native request and the ascending bucket index of its first interval.</summary>
/// <param name="StartUtc">Forward request start (inclusive).</param>
/// <param name="EndUtc">Forward request end.</param>
/// <param name="Interval">The request's interval (&gt; 0).</param>
/// <param name="FirstBucket">Ascending plan bucket of the request's first interval.</param>
/// <param name="BucketCount">Number of plan buckets the request covers.</param>
internal readonly record struct Request(DateTime StartUtc, DateTime EndUtc, TimeSpan Interval, long FirstBucket, long BucketCount);
/// <summary>The forward requests serving a plan: the whole intervals first, then the clipped one (if any).</summary>
/// <param name="plan">A plan with at least one bucket.</param>
/// <returns>One or two forward requests.</returns>
public static IReadOnlyList<Request> 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<Request>(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;
}
/// <summary>
/// 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).
/// </summary>
/// <param name="plan">The plan.</param>
/// <param name="request">The forward request the rows answer.</param>
/// <param name="rows">The drained rows.</param>
/// <param name="byBucket">Results by ascending bucket index (first row per bucket wins).</param>
public static void Place(BucketPlan plan, Request request, IEnumerable<HistorianAggregateSample> rows,
Dictionary<long, DataValueSnapshot> 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) };
}
}
/// <summary>The placed results in the read's order (ascending forward, descending reverse).</summary>
/// <param name="plan">The plan.</param>
/// <param name="byBucket">Results by ascending bucket index.</param>
/// <returns>The results.</returns>
public static IReadOnlyList<DataValueSnapshot> Assemble(BucketPlan plan, IReadOnlyDictionary<long, DataValueSnapshot> byBucket) =>
(plan.Reversed ? byBucket.OrderByDescending(kv => kv.Key) : byBucket.OrderBy(kv => kv.Key)).Select(kv => kv.Value).ToList();
}