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 35cf733..a537263 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
@@ -431,19 +431,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);
+ }
+}