156 lines
8.2 KiB
C#
156 lines
8.2 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// Maps gateway wire samples (<see cref="HistorianSample"/> / <see cref="HistorianAggregateSample"/>)
|
||
/// onto the driver-agnostic <see cref="DataValueSnapshot"/>, mirroring the legacy Wonderware client's
|
||
/// <c>ToSnapshots</c> / <c>ToAggregateSnapshots</c> conventions.
|
||
/// </summary>
|
||
internal static class SampleMapper
|
||
{
|
||
private const uint StatusGood = HistorianStatusCodes.Good;
|
||
private const uint StatusBadNoData = HistorianStatusCodes.BadNoData;
|
||
|
||
/// <summary>OPC DA "Good" family floor — a quality byte at/above this carries usable data.</summary>
|
||
private const byte GoodQualityFloor = 192;
|
||
|
||
/// <summary>OPC DA "Uncertain" family floor (64–191).</summary>
|
||
private const byte UncertainQualityFloor = 64;
|
||
|
||
/// <summary>Maps a single raw sample to a value snapshot.</summary>
|
||
/// <param name="sample">The gateway raw sample.</param>
|
||
/// <returns>The driver-agnostic snapshot.</returns>
|
||
/// <remarks>
|
||
/// #690: a Bad row carries no value. histsdk puts a value on EVERY row (<c>NumericValue = 0</c> on a numeric row,
|
||
/// <c>StringValue = ""</c> on a string one), so a stored NULL arrives as <c>0</c> / <c>""</c> with a Bad OPC quality
|
||
/// (live: <c>q=1 opc=0</c>) — 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.
|
||
/// </remarks>
|
||
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);
|
||
}
|
||
|
||
/// <summary>The OPC UA status of a raw sample (shared by raw reads and the #681 raw-evaluated aggregates).</summary>
|
||
/// <param name="sample">The gateway raw sample.</param>
|
||
/// <returns>The mapped status code.</returns>
|
||
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);
|
||
}
|
||
|
||
/// <summary>Maps a batch of raw samples to value snapshots, in order.</summary>
|
||
/// <param name="samples">The gateway raw samples.</param>
|
||
/// <returns>The driver-agnostic snapshots.</returns>
|
||
public static IReadOnlyList<DataValueSnapshot> ToSnapshots(IEnumerable<HistorianSample> samples)
|
||
{
|
||
var result = new List<DataValueSnapshot>();
|
||
foreach (var sample in samples)
|
||
result.Add(ToSnapshot(sample));
|
||
return result;
|
||
}
|
||
|
||
/// <summary>Maps a single aggregate bucket to a value snapshot.</summary>
|
||
/// <param name="aggregate">The gateway aggregate sample.</param>
|
||
/// <returns>The driver-agnostic snapshot.</returns>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The gateway proto carries a non-optional <c>double value</c>, so an unavailable (no-data) bucket cannot be signalled
|
||
/// by a null value; it is signalled by the OPC DA quality family of <c>opc_quality</c> (its low byte, as the raw mapping
|
||
/// reads it):
|
||
/// </para>
|
||
/// <list type="bullet">
|
||
/// <item>Good (≥ 192) ⇒ the value, <c>Good</c>.</item>
|
||
/// <item>Uncertain (64–191) ⇒ the value, <c>UncertainDataSubNormal</c> (#685): AVEVA marks an interval that holds a
|
||
/// NULL/Bad sample <c>opc_quality = 64</c> and still answers its value (computed over the interval's good time only —
|
||
/// see docs/Historian.md). It used to map to <c>BadNoData</c> with the value discarded. The sub-code is the one the SQL
|
||
/// backends' aggregates give an interval that includes non-good data.</item>
|
||
/// <item>Bad (< 64) ⇒ <c>BadNoData</c>, null value (the wire's 0 is not a value).</item>
|
||
/// </list>
|
||
/// <para>
|
||
/// #681: every status carries the Calculated info bits (<c>0x401</c>) 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: <c>start_time</c> is the interval start).
|
||
/// </para>
|
||
/// </remarks>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// True when the aggregate row's interval lies in the requested window — the span between <paramref name="startUtc"/>
|
||
/// and <paramref name="endUtc"/> in either order (a reverse request covers the same span). AVEVA answers one extra row
|
||
/// for the interval BEFORE the window (<c>[start − interval, start)</c>, #685), which Part 11/13 has no place for: an
|
||
/// interval belongs to the window when its start lies in <c>[lo, hi)</c>; a row without a start time (the mapper then
|
||
/// stamps it with its end) belongs when its end lies in <c>(lo, hi]</c>.
|
||
/// </summary>
|
||
/// <param name="aggregate">The gateway aggregate sample.</param>
|
||
/// <param name="startUtc">The request's start time.</param>
|
||
/// <param name="endUtc">The request's end time.</param>
|
||
/// <returns>Whether the row is one of the window's intervals.</returns>
|
||
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;
|
||
}
|
||
|
||
/// <summary>Maps the aggregate buckets of a window to value snapshots, in order, dropping rows outside it (#685).</summary>
|
||
/// <param name="aggregates">The gateway aggregate samples.</param>
|
||
/// <param name="startUtc">The request's start time.</param>
|
||
/// <param name="endUtc">The request's end time.</param>
|
||
/// <returns>The driver-agnostic snapshots, one per in-window interval row.</returns>
|
||
public static IReadOnlyList<DataValueSnapshot> ToAggregateSnapshots(IEnumerable<HistorianAggregateSample> aggregates,
|
||
DateTime startUtc, DateTime endUtc)
|
||
{
|
||
var result = new List<DataValueSnapshot>();
|
||
foreach (var aggregate in aggregates)
|
||
if (IsInWindow(aggregate, startUtc, endUtc))
|
||
result.Add(ToAggregateSnapshot(aggregate));
|
||
return result;
|
||
}
|
||
}
|