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

156 lines
8.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (&lt; 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;
}
}