From c7296d74585a3209de0b5ba0216685fc2a340697 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 26 Jun 2026 16:32:38 -0400 Subject: [PATCH] feat(historian-gateway): sample/aggregate->DataValueSnapshot + quality mapper (Wonderware parity) Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii --- .../Mapping/GatewayQualityMapper.cs | 47 ++++++++++ .../Mapping/SampleMapper.cs | 87 +++++++++++++++++++ .../Mapping/GatewayQualityMapperTests.cs | 19 ++++ .../Mapping/SampleMapperTests.cs | 56 ++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/GatewayQualityMapper.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/SampleMapper.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/GatewayQualityMapperTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/SampleMapperTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/GatewayQualityMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/GatewayQualityMapper.cs new file mode 100644 index 00000000..a4829da2 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/GatewayQualityMapper.cs @@ -0,0 +1,47 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping; + +/// +/// Maps a raw OPC DA quality byte (the gateway's opc_quality field) to an OPC UA StatusCode +/// uint. +/// +/// +/// Byte-identical port of +/// ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal.QualityMapper.Map (itself a +/// port of the sidecar's HistorianQualityMapper.Map). 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. +/// +internal static class GatewayQualityMapper +{ + /// Maps an OPC DA quality byte to an OPC UA StatusCode. + /// The OPC DA quality byte value. + /// An OPC UA StatusCode as a uint. + 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, + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/SampleMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/SampleMapper.cs new file mode 100644 index 00000000..fcc92adb --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/SampleMapper.cs @@ -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; + +/// +/// 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 = 0x00000000u; + private const uint StatusBadNoData = 0x800E0000u; + + /// OPC DA "Good" family floor — a quality byte at/above this carries usable data. + private const byte GoodQualityFloor = 192; + + /// Maps a single raw sample to a value snapshot. + /// The gateway raw sample. + /// The driver-agnostic snapshot. + 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); + } + + /// 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. + /// + /// Unlike the legacy Wonderware DTO (a nullable double?), the gateway proto carries a + /// non-optional double value, so an unavailable (no-data) bucket cannot be signalled by a + /// null value. Instead it is signalled by a non-Good opc_quality: a Good bucket + /// (opc_quality >= 192) yields its value with a Good status, anything else maps to + /// BadNoData with a null value — preserving the Wonderware aggregate contract (binary + /// Good-with-value / BadNoData-null). + /// + 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); + } + + /// Maps a batch of aggregate buckets to value snapshots, in order. + /// The gateway aggregate samples. + /// The driver-agnostic snapshots. + public static IReadOnlyList ToAggregateSnapshots(IEnumerable aggregates) + { + var result = new List(); + foreach (var aggregate in aggregates) + result.Add(ToAggregateSnapshot(aggregate)); + return result; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/GatewayQualityMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/GatewayQualityMapperTests.cs new file mode 100644 index 00000000..15c7fced --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/GatewayQualityMapperTests.cs @@ -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)); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/SampleMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/SampleMapperTests.cs new file mode 100644 index 00000000..969b9764 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Mapping/SampleMapperTests.cs @@ -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(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(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)); +}