using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping; /// /// Maps gateway wire samples ( / ) /// onto the driver-agnostic , mirroring the legacy Wonderware client's /// ToSnapshots / ToAggregateSnapshots conventions. /// internal static class SampleMapper { private const uint StatusGood = HistorianStatusCodes.Good; private const uint StatusBadNoData = HistorianStatusCodes.BadNoData; /// OPC DA "Good" family floor — a quality byte at/above this carries usable data. private const byte GoodQualityFloor = 192; /// OPC DA "Uncertain" family floor (64–191). private const byte UncertainQualityFloor = 64; /// Maps a single raw sample to a value snapshot. /// The gateway raw sample. /// The driver-agnostic snapshot. /// /// #690: a Bad row carries no value. histsdk puts a value on EVERY row (NumericValue = 0 on a numeric row, /// StringValue = "" on a string one), so a stored NULL arrives as 0 / "" with a Bad OPC quality /// (live: q=1 opc=0) — that 0 is not a value, and AVEVA stores a Bad-quality sample as NULL, so a Bad row never /// carries a real one. The value is therefore dropped whenever the mapped status is Bad, matching the SqlServer/QuestDB /// backends (an Empty sample is null + Bad) and the aggregate mapper's no-data rows. Uncertain rows keep their value. /// public static DataValueSnapshot ToSnapshot(HistorianSample sample) { var status = ToStatus(sample); // proto3 explicit presence: prefer the numeric value, else the string value, else null. object? value; if (StatusBits.IsBad(status)) value = null; else if (sample.HasNumericValue) value = sample.NumericValue; // boxes as System.Double else if (sample.HasStringValue) value = sample.StringValue; else value = null; return new DataValueSnapshot( Value: value, StatusCode: status, SourceTimestampUtc: sample.Timestamp?.ToDateTime(), // Utc kind ServerTimestampUtc: DateTime.UtcNow); } /// The OPC UA status of a raw sample (shared by raw reads and the #681 raw-evaluated aggregates). /// The gateway raw sample. /// The mapped status code. public static uint ToStatus(HistorianSample sample) { // Prefer the OPC DA quality byte (opc_quality); the gateway populates it directly from the // SDK's OpcQuality, so it is the authoritative byte for GatewayQualityMapper. Fall back to // the historian quality field only when opc_quality is unset (0). byte qualityByte = sample.OpcQuality != 0 ? (byte)sample.OpcQuality : (byte)sample.Quality; return GatewayQualityMapper.Map(qualityByte); } /// Maps a batch of raw samples to value snapshots, in order. /// The gateway raw samples. /// The driver-agnostic snapshots. public static IReadOnlyList ToSnapshots(IEnumerable samples) { var result = new List(); foreach (var sample in samples) result.Add(ToSnapshot(sample)); return result; } /// Maps a single aggregate bucket to a value snapshot. /// The gateway aggregate sample. /// The driver-agnostic snapshot. /// /// /// The gateway proto carries a non-optional double value, so an unavailable (no-data) bucket cannot be signalled /// by a null value; it is signalled by the OPC DA quality family of opc_quality (its low byte, as the raw mapping /// reads it): /// /// /// Good (≥ 192) ⇒ the value, Good. /// Uncertain (64–191) ⇒ the value, UncertainDataSubNormal (#685): AVEVA marks an interval that holds a /// NULL/Bad sample opc_quality = 64 and still answers its value (computed over the interval's good time only — /// see docs/Historian.md). It used to map to BadNoData with the value discarded. The sub-code is the one the SQL /// backends' aggregates give an interval that includes non-good data. /// Bad (< 64) ⇒ BadNoData, null value (the wire's 0 is not a value). /// /// /// #681: every status carries the Calculated info bits (0x401) like every other backend's processed value, and /// the bucket is stamped with its interval START (Part 13; it was the interval end), falling back to the end only when /// the start is absent (verified on live AVEVA 2026-10-12: start_time is the interval start). /// /// public static DataValueSnapshot ToAggregateSnapshot(HistorianAggregateSample aggregate) { var q = (byte)aggregate.OpcQuality; var status = q >= GoodQualityFloor ? StatusGood : q >= UncertainQualityFloor ? Sql.Common.HistorianStatusCodes.UncertainDataSubNormal : StatusBadNoData; return new DataValueSnapshot( Value: status == StatusBadNoData ? null : aggregate.Value, // boxes as System.Double when present StatusCode: HistorianInfoBits.WithCalculated(status), SourceTimestampUtc: (aggregate.StartTime ?? aggregate.EndTime)?.ToDateTime(), // bucket timestamp = interval start ServerTimestampUtc: DateTime.UtcNow); } /// /// True when the aggregate row's interval lies in the requested window — the span between /// and in either order (a reverse request covers the same span). AVEVA answers one extra row /// for the interval BEFORE the window ([start − interval, start), #685), which Part 11/13 has no place for: an /// interval belongs to the window when its start lies in [lo, hi); a row without a start time (the mapper then /// stamps it with its end) belongs when its end lies in (lo, hi]. /// /// The gateway aggregate sample. /// The request's start time. /// The request's end time. /// Whether the row is one of the window's intervals. public static bool IsInWindow(HistorianAggregateSample aggregate, DateTime startUtc, DateTime endUtc) { var lo = startUtc <= endUtc ? startUtc : endUtc; var hi = startUtc <= endUtc ? endUtc : startUtc; if (aggregate.StartTime is { } s) { var t = s.ToDateTime(); return t >= lo && t < hi; } if (aggregate.EndTime is { } e) { var t = e.ToDateTime(); return t > lo && t <= hi; } return false; } /// Maps the aggregate buckets of a window to value snapshots, in order, dropping rows outside it (#685). /// The gateway aggregate samples. /// The request's start time. /// The request's end time. /// The driver-agnostic snapshots, one per in-window interval row. public static IReadOnlyList ToAggregateSnapshots(IEnumerable aggregates, DateTime startUtc, DateTime endUtc) { var result = new List(); foreach (var aggregate in aggregates) if (IsInWindow(aggregate, startUtc, endUtc)) result.Add(ToAggregateSnapshot(aggregate)); return result; } }