feat(historian-gateway): sample/aggregate->DataValueSnapshot + quality mapper (Wonderware parity)
Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
+47
@@ -0,0 +1,47 @@
|
|||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a raw OPC DA quality byte (the gateway's <c>opc_quality</c> field) to an OPC UA StatusCode
|
||||||
|
/// uint.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Byte-identical port of
|
||||||
|
/// <c>ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal.QualityMapper.Map</c> (itself a
|
||||||
|
/// port of the sidecar's <c>HistorianQualityMapper.Map</c>). The table is duplicated rather than
|
||||||
|
/// shared because the projects do not share an assembly; a change to the quality table must be
|
||||||
|
/// applied in every copy and is kept in parity by the per-byte tests.
|
||||||
|
/// </remarks>
|
||||||
|
internal static class GatewayQualityMapper
|
||||||
|
{
|
||||||
|
/// <summary>Maps an OPC DA quality byte to an OPC UA StatusCode.</summary>
|
||||||
|
/// <param name="q">The OPC DA quality byte value.</param>
|
||||||
|
/// <returns>An OPC UA StatusCode as a uint.</returns>
|
||||||
|
public static uint Map(byte q) => q switch
|
||||||
|
{
|
||||||
|
// Good family (192+)
|
||||||
|
192 => 0x00000000u, // Good
|
||||||
|
216 => 0x00D80000u, // Good_LocalOverride
|
||||||
|
|
||||||
|
// Uncertain family (64-191)
|
||||||
|
64 => 0x40000000u, // Uncertain
|
||||||
|
68 => 0x40900000u, // Uncertain_LastUsableValue
|
||||||
|
80 => 0x40930000u, // Uncertain_SensorNotAccurate
|
||||||
|
84 => 0x40940000u, // Uncertain_EngineeringUnitsExceeded
|
||||||
|
88 => 0x40950000u, // Uncertain_SubNormal
|
||||||
|
|
||||||
|
// Bad family (0-63)
|
||||||
|
0 => 0x80000000u, // Bad
|
||||||
|
4 => 0x80890000u, // Bad_ConfigurationError
|
||||||
|
8 => 0x808A0000u, // Bad_NotConnected
|
||||||
|
12 => 0x808B0000u, // Bad_DeviceFailure
|
||||||
|
16 => 0x808C0000u, // Bad_SensorFailure
|
||||||
|
20 => 0x80050000u, // Bad_CommunicationError
|
||||||
|
24 => 0x808D0000u, // Bad_OutOfService
|
||||||
|
32 => 0x80320000u, // Bad_WaitingForInitialData
|
||||||
|
|
||||||
|
// Unknown — fall back to category bucket so callers still get something usable.
|
||||||
|
_ when q >= 192 => 0x00000000u,
|
||||||
|
_ when q >= 64 => 0x40000000u,
|
||||||
|
_ => 0x80000000u,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
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 = 0x00000000u;
|
||||||
|
private const uint StatusBadNoData = 0x800E0000u;
|
||||||
|
|
||||||
|
/// <summary>OPC DA "Good" family floor — a quality byte at/above this carries usable data.</summary>
|
||||||
|
private const byte GoodQualityFloor = 192;
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
public static DataValueSnapshot ToSnapshot(HistorianSample sample)
|
||||||
|
{
|
||||||
|
// proto3 explicit presence: prefer the numeric value, else the string value, else null.
|
||||||
|
object? value;
|
||||||
|
if (sample.HasNumericValue)
|
||||||
|
value = sample.NumericValue; // boxes as System.Double
|
||||||
|
else if (sample.HasStringValue)
|
||||||
|
value = sample.StringValue;
|
||||||
|
else
|
||||||
|
value = null;
|
||||||
|
|
||||||
|
// 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 new DataValueSnapshot(
|
||||||
|
Value: value,
|
||||||
|
StatusCode: GatewayQualityMapper.Map(qualityByte),
|
||||||
|
SourceTimestampUtc: sample.Timestamp?.ToDateTime(), // Utc kind
|
||||||
|
ServerTimestampUtc: DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// Unlike the legacy Wonderware DTO (a nullable <c>double?</c>), the gateway proto carries a
|
||||||
|
/// non-optional <c>double value</c>, so an unavailable (no-data) bucket cannot be signalled by a
|
||||||
|
/// null value. Instead it is signalled by a non-Good <c>opc_quality</c>: a Good bucket
|
||||||
|
/// (<c>opc_quality >= 192</c>) yields its value with a Good status, anything else maps to
|
||||||
|
/// <c>BadNoData</c> with a null value — preserving the Wonderware aggregate contract (binary
|
||||||
|
/// Good-with-value / BadNoData-null).
|
||||||
|
/// </remarks>
|
||||||
|
public static DataValueSnapshot ToAggregateSnapshot(HistorianAggregateSample aggregate)
|
||||||
|
{
|
||||||
|
bool hasData = aggregate.OpcQuality >= GoodQualityFloor;
|
||||||
|
return new DataValueSnapshot(
|
||||||
|
Value: hasData ? aggregate.Value : null, // boxes as System.Double when present
|
||||||
|
StatusCode: hasData ? StatusGood : StatusBadNoData,
|
||||||
|
SourceTimestampUtc: (aggregate.EndTime ?? aggregate.StartTime)?.ToDateTime(), // bucket timestamp
|
||||||
|
ServerTimestampUtc: DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps a batch of aggregate buckets to value snapshots, in order.</summary>
|
||||||
|
/// <param name="aggregates">The gateway aggregate samples.</param>
|
||||||
|
/// <returns>The driver-agnostic snapshots.</returns>
|
||||||
|
public static IReadOnlyList<DataValueSnapshot> ToAggregateSnapshots(IEnumerable<HistorianAggregateSample> aggregates)
|
||||||
|
{
|
||||||
|
var result = new List<DataValueSnapshot>();
|
||||||
|
foreach (var aggregate in aggregates)
|
||||||
|
result.Add(ToAggregateSnapshot(aggregate));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping;
|
||||||
|
|
||||||
|
public sealed class GatewayQualityMapperTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(192, 0x00000000u)] // Good
|
||||||
|
[InlineData(216, 0x00D80000u)] // Good_LocalOverride
|
||||||
|
[InlineData(64, 0x40000000u)] // Uncertain
|
||||||
|
[InlineData(0, 0x80000000u)] // Bad
|
||||||
|
[InlineData(8, 0x808A0000u)] // Bad_NotConnected
|
||||||
|
[InlineData(255, 0x00000000u)] // >=192 bucket
|
||||||
|
[InlineData(100, 0x40000000u)] // >=64 bucket
|
||||||
|
[InlineData(1, 0x80000000u)] // bad bucket
|
||||||
|
public void Maps_opc_quality_byte(byte q, uint expected)
|
||||||
|
=> Assert.Equal(expected, GatewayQualityMapper.Map(q));
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Mapping;
|
||||||
|
|
||||||
|
public sealed class SampleMapperTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Numeric_sample_maps_value_quality_and_timestamps()
|
||||||
|
{
|
||||||
|
var s = new HistorianSample { Tag = "T", NumericValue = 12.5,
|
||||||
|
Quality = 192, OpcQuality = 192, Timestamp = Ts(2026, 1, 1, 0, 0, 0) };
|
||||||
|
var snap = SampleMapper.ToSnapshot(s);
|
||||||
|
Assert.Equal(12.5, Assert.IsType<double>(snap.Value));
|
||||||
|
Assert.Equal(0x00000000u, snap.StatusCode);
|
||||||
|
Assert.Equal(DateTimeKind.Utc, snap.SourceTimestampUtc!.Value.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void String_sample_carries_string_value()
|
||||||
|
{
|
||||||
|
var s = new HistorianSample { Tag = "T", StringValue = "abc", OpcQuality = 192, Timestamp = Ts(2026, 1, 1, 0, 0, 0) };
|
||||||
|
Assert.Equal("abc", SampleMapper.ToSnapshot(s).Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bad_quality_sample_maps_to_bad_status()
|
||||||
|
{
|
||||||
|
var s = new HistorianSample { Tag = "T", NumericValue = 1.0, OpcQuality = 0, Timestamp = Ts(2026, 1, 1, 0, 0, 0) };
|
||||||
|
Assert.Equal(0x80000000u, SampleMapper.ToSnapshot(s).StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Aggregate_null_value_is_BadNoData()
|
||||||
|
{
|
||||||
|
var a = new HistorianAggregateSample { Tag = "T", /* Value unset, no Good quality */ EndTime = Ts(2026, 1, 1, 0, 0, 0) };
|
||||||
|
var snap = SampleMapper.ToAggregateSnapshot(a);
|
||||||
|
Assert.Equal(0x800E0000u, snap.StatusCode); // BadNoData
|
||||||
|
Assert.Null(snap.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Aggregate_good_bucket_carries_value()
|
||||||
|
{
|
||||||
|
var a = new HistorianAggregateSample { Tag = "T", Value = 42.0, OpcQuality = 192, EndTime = Ts(2026, 1, 1, 0, 0, 0) };
|
||||||
|
var snap = SampleMapper.ToAggregateSnapshot(a);
|
||||||
|
Assert.Equal(0x00000000u, snap.StatusCode); // Good
|
||||||
|
Assert.Equal(42.0, Assert.IsType<double>(snap.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ts(...) builds a Google.Protobuf.WellKnownTypes.Timestamp from UTC parts.
|
||||||
|
private static Timestamp Ts(int y, int mo, int d, int h, int mi, int s)
|
||||||
|
=> Timestamp.FromDateTime(new DateTime(y, mo, d, h, mi, s, DateTimeKind.Utc));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user