From f24f969a859ec405ff6b456a645960900acd986b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 07:11:02 -0400 Subject: [PATCH] =?UTF-8?q?Phase=202=20PR=2012=20=E2=80=94=20richer=20hist?= =?UTF-8?q?orian=20quality=20mapping.=20Replace=20MxAccessGalaxyBackend's?= =?UTF-8?q?=20inline=20MapHistorianQualityToOpcUa=20category-only=20helper?= =?UTF-8?q?=20(192+=E2=86=92Good,=2064-191=E2=86=92Uncertain,=200-63?= =?UTF-8?q?=E2=86=92Bad)=20with=20a=20new=20public=20HistorianQualityMappe?= =?UTF-8?q?r.Map=20utility=20that=20preserves=20specific=20OPC=20DA=20subc?= =?UTF-8?q?odes=20=E2=80=94=20BadNotConnected(8)=E2=86=920x808A0000u=20ins?= =?UTF-8?q?tead=20of=20generic=20Bad(0x80000000u),=20UncertainSubNormal(88?= =?UTF-8?q?)=E2=86=920x40950000u=20instead=20of=20generic=20Uncertain,=20G?= =?UTF-8?q?ood=5FLocalOverride(216)=E2=86=920x00D80000u=20instead=20of=20g?= =?UTF-8?q?eneric=20Good,=20etc.=20Mirrors=20v1=20QualityMapper.MapToOpcUa?= =?UTF-8?q?StatusCode=20byte-for-byte=20without=20pulling=20in=20OPC=20UA?= =?UTF-8?q?=20types=20=E2=80=94=20the=20function=20returns=20uint32=20lite?= =?UTF-8?q?rals=20that=20are=20the=20canonical=20OPC=20UA=20StatusCode=20w?= =?UTF-8?q?ire=20encoding,=20surfaced=20directly=20as=20DataValueSnapshot.?= =?UTF-8?q?StatusCode=20on=20the=20Proxy=20side=20with=20no=20additional?= =?UTF-8?q?=20translation.=20Unknown=20subcodes=20fall=20back=20to=20the?= =?UTF-8?q?=20family=20category=20(255=E2=86=92Good,=20150=E2=86=92Uncerta?= =?UTF-8?q?in,=2050=E2=86=92Bad)=20so=20a=20future=20SDK=20change=20that?= =?UTF-8?q?=20adds=20a=20quality=20code=20we=20don't=20map=20yet=20still?= =?UTF-8?q?=20gets=20a=20sensible=20bucket.=20GalaxyDataValue=20wire=20sha?= =?UTF-8?q?pe=20unchanged=20(StatusCode=20stays=20uint)=20=E2=80=94=20this?= =?UTF-8?q?=20is=20a=20pure=20fidelity=20upgrade=20on=20the=20Host=20side.?= =?UTF-8?q?=20Downstream=20callers=20(Admin=20UI=20status=20dashboard,=20O?= =?UTF-8?q?PC=20UA=20clients=20receiving=20historian=20samples)=20can=20no?= =?UTF-8?q?w=20distinguish=20e.g.=20a=20transport=20outage=20(BadNotConnec?= =?UTF-8?q?ted)=20from=20a=20sensor=20fault=20(BadSensorFailure)=20from=20?= =?UTF-8?q?a=20warm-up=20delay=20(BadWaitingForInitialData)=20without=20a?= =?UTF-8?q?=20second=20round-trip=20or=20dashboard=20heuristic.=2021=20new?= =?UTF-8?q?=20tests=20(HistorianQualityMapperTests):=20theory=20with=2015?= =?UTF-8?q?=20rows=20covering=20every=20specific=20mapping=20from=20the=20?= =?UTF-8?q?v1=20QualityMapper=20table,=20plus=206=20fallback=20tests=20ver?= =?UTF-8?q?ifying=20unknown-subcode=20codes=20in=20each=20family=20(Good/U?= =?UTF-8?q?ncertain/Bad)=20collapse=20to=20the=20family=20default.=20Galax?= =?UTF-8?q?y.Host.Tests=20Unit=20suite=2056/0=20(21=20new=20+=2035=20exist?= =?UTF-8?q?ing).=20Galaxy.Host=20builds=20clean=20(0/0).=20Branches=20off?= =?UTF-8?q?=20v2.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Historian/HistorianQualityMapper.cs | 46 ++++++++++++++ .../Backend/MxAccessGalaxyBackend.cs | 10 +-- .../HistorianQualityMapperTests.cs | 61 +++++++++++++++++++ 3 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Historian/HistorianQualityMapper.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistorianQualityMapperTests.cs diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Historian/HistorianQualityMapper.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Historian/HistorianQualityMapper.cs new file mode 100644 index 0000000..d84318a --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/Historian/HistorianQualityMapper.cs @@ -0,0 +1,46 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian; + +/// +/// Maps a raw OPC DA quality byte (as returned by Wonderware Historian's OpcQuality) +/// to an OPC UA StatusCode uint. Preserves specific codes (BadNotConnected, +/// UncertainSubNormal, etc.) instead of collapsing to Good/Uncertain/Bad categories. +/// Mirrors v1 QualityMapper.MapToOpcUaStatusCode without pulling in OPC UA types — +/// the returned value is the 32-bit OPC UA StatusCode wire encoding that the Proxy +/// surfaces directly as DataValueSnapshot.StatusCode. +/// +public static class HistorianQualityMapper +{ + /// + /// Map an 8-bit OPC DA quality byte to the corresponding OPC UA StatusCode. The byte + /// family bits decide the category (Good >= 192, Uncertain 64-191, Bad 0-63); the + /// low-nibble subcode selects the specific code. + /// + 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 code — fall back to the category so callers still get a sensible bucket. + _ when q >= 192 => 0x00000000u, + _ when q >= 64 => 0x40000000u, + _ => 0x80000000u, + }; +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs index 290fa29..10a0c1a 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/Backend/MxAccessGalaxyBackend.cs @@ -355,19 +355,11 @@ public sealed class MxAccessGalaxyBackend : IGalaxyBackend, IDisposable TagReference = reference, ValueBytes = sample.Value is null ? null : MessagePackSerializer.Serialize(sample.Value), ValueMessagePackType = 0, - StatusCode = MapHistorianQualityToOpcUa(sample.Quality), + StatusCode = HistorianQualityMapper.Map(sample.Quality), SourceTimestampUtcUnixMs = new DateTimeOffset(sample.TimestampUtc, TimeSpan.Zero).ToUnixTimeMilliseconds(), ServerTimestampUtcUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), }; - private static uint MapHistorianQualityToOpcUa(byte q) - { - // Category-only mapping — mirrors QualityMapper.MapToOpcUaStatusCode for the common ranges. - // The Proxy may refine this when it decodes the wire frame. - if (q >= 192) return 0x00000000u; // Good - if (q >= 64) return 0x40000000u; // Uncertain - return 0x80000000u; // Bad - } /// /// Maps a (one aggregate bucket) to the IPC wire diff --git a/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistorianQualityMapperTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistorianQualityMapperTests.cs new file mode 100644 index 0000000..8e75348 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/HistorianQualityMapperTests.cs @@ -0,0 +1,61 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; + +[Trait("Category", "Unit")] +public sealed class HistorianQualityMapperTests +{ + /// + /// Rich mapping preserves specific OPC DA subcodes through the historian ToWire path. + /// Before PR 12 the category-only fallback collapsed e.g. BadNotConnected(8) to + /// Bad(0x80000000) so downstream OPC UA clients could not distinguish transport issues + /// from sensor issues. After PR 12 every known subcode round-trips to its canonical + /// uint32 StatusCode and Proxy translation stays byte-for-byte with v1 QualityMapper. + /// + [Theory] + [InlineData((byte)192, 0x00000000u)] // Good + [InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride + [InlineData((byte)64, 0x40000000u)] // Uncertain + [InlineData((byte)68, 0x40900000u)] // Uncertain_LastUsableValue + [InlineData((byte)80, 0x40930000u)] // Uncertain_SensorNotAccurate + [InlineData((byte)84, 0x40940000u)] // Uncertain_EngineeringUnitsExceeded + [InlineData((byte)88, 0x40950000u)] // Uncertain_SubNormal + [InlineData((byte)0, 0x80000000u)] // Bad + [InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError + [InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected + [InlineData((byte)12, 0x808B0000u)] // Bad_DeviceFailure + [InlineData((byte)16, 0x808C0000u)] // Bad_SensorFailure + [InlineData((byte)20, 0x80050000u)] // Bad_CommunicationError + [InlineData((byte)24, 0x808D0000u)] // Bad_OutOfService + [InlineData((byte)32, 0x80320000u)] // Bad_WaitingForInitialData + public void Maps_specific_OPC_DA_codes_to_canonical_StatusCode(byte quality, uint expected) + { + HistorianQualityMapper.Map(quality).ShouldBe(expected); + } + + [Theory] + [InlineData((byte)200)] // Good — unknown subcode in Good family + [InlineData((byte)255)] // Good — unknown + public void Unknown_good_family_codes_fall_back_to_plain_Good(byte q) + { + HistorianQualityMapper.Map(q).ShouldBe(0x00000000u); + } + + [Theory] + [InlineData((byte)100)] // Uncertain — unknown subcode + [InlineData((byte)150)] // Uncertain — unknown + public void Unknown_uncertain_family_codes_fall_back_to_plain_Uncertain(byte q) + { + HistorianQualityMapper.Map(q).ShouldBe(0x40000000u); + } + + [Theory] + [InlineData((byte)1)] // Bad — unknown subcode + [InlineData((byte)50)] // Bad — unknown + public void Unknown_bad_family_codes_fall_back_to_plain_Bad(byte q) + { + HistorianQualityMapper.Map(q).ShouldBe(0x80000000u); + } +}