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;
}
}