diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs
new file mode 100644
index 00000000..29703f0f
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs
@@ -0,0 +1,406 @@
+using System.Collections.Concurrent;
+using System.Collections.Frozen;
+using System.Globalization;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// The driver's live dataItemId → map: the single place
+/// an Agent observation's weakly-typed text becomes an OPC UA-shaped value + quality +
+/// timestamp. Written by the /current baseline read and by the /sample long-poll
+/// pump; read by IReadable.ReadAsync and the subscription fan-out.
+///
+///
+///
+/// Quality mapping (design §3.3). MTConnect has exactly one way to say "I have no
+/// value": the literal token UNAVAILABLE. It arrives as a Sample/Event's text, and —
+/// after normalizes the <Unavailable/>
+/// element name — as a CONDITION's value on that same exact spelling. It maps to
+/// BadNoCommunication with a null value: the Agent is reachable, the machine's
+/// data item is not. The match is ordinal and case-sensitive against the one spelling
+/// the parser guarantees; a case-insensitive match would swallow a legitimate free-text
+/// EVENT whose value happens to read "Unavailable".
+///
+///
+/// Failure posture. This type sits under IReadable and must never throw across
+/// that boundary on account of observation content. Every unusable observation degrades to a
+/// Bad-coded snapshot that still carries the Agent's source timestamp, so an operator can
+/// always see when the driver last heard about the tag even when it cannot show
+/// what. The only exceptions raised are for a null
+/// argument — a programming error, not data.
+///
+///
+/// Status codes are the canonical Opc.Ua.StatusCodes numeric values, declared
+/// once each below. Note that is 0x80740000: the
+/// 0x80730000 that four sibling drivers declare under that name is really
+/// BadWriteNotSupported, which would be actively misleading on a read path and
+/// renders as bare "Bad" in the driver CLI's SnapshotFormatter.
+///
+///
+/// Thread safety. Backed by a and
+/// guaranteed per-key atomic — nothing more, deliberately. writes
+/// each dataItemId exactly once per call with a single whole-snapshot assignment, so a
+/// reader can never observe a torn snapshot (a Good code beside a stale value). It is
+/// explicitly not a whole-batch atomic swap: OPC UA reads are per-tag and the driver
+/// offers no cross-tag consistency contract, /sample chunks are deltas whose
+/// interleaving is indistinguishable from two adjacent legal chunks, and a whole-index lock
+/// would serialize the pump against every read for no semantic gain.
+///
+///
+/// KNOWN LIMITATION — DATA_SET / TABLE observations. Their real content
+/// is <Entry key=…> child elements, but the parser reads an observation's value
+/// with XElement.Value, which concatenates all descendant text. Such an observation
+/// therefore reaches this index as a run-together string with the keys discarded, and —
+/// because the type inference correctly demotes DATA_SET/TABLE to
+/// — it coerces "successfully" into a Good-coded
+/// snapshot carrying noise. This index cannot detect the shape: neither
+/// nor carries the
+/// DataItem's representation, and no value-shape heuristic can separate a
+/// concatenated entry list from a legitimate space-bearing EVENT (a Message reads
+/// "Coolant level low"). The discriminating signal — the observation element having child
+/// elements — exists only in , so the fix belongs there
+/// (flag structured observations at parse time; this index then codes them
+/// BadNotSupported alongside the TIME_SERIES deferral below).
+///
+///
+internal sealed class MTConnectObservationIndex
+{
+ ///
+ /// The one token that means "the Agent has no value for this data item". Matched ordinally:
+ /// the parser already normalizes a CONDITION's <Unavailable/> element name onto
+ /// this exact spelling, so every category lands on one comparison.
+ ///
+ private const string UnavailableSentinel = "UNAVAILABLE";
+
+ private const uint Good = 0x00000000u;
+
+ /// The Agent reported UNAVAILABLE, or supplied no text at all.
+ private const uint BadNoCommunication = 0x80310000u;
+
+ /// The tag is authored but the Agent has not yet reported it.
+ private const uint BadWaitingForInitialData = 0x80320000u;
+
+ /// The dataItemId is neither authored nor ever observed.
+ private const uint BadNodeIdUnknown = 0x80340000u;
+
+ /// The Agent reported a number the authored type cannot represent.
+ private const uint BadOutOfRange = 0x803C0000u;
+
+ /// The observation's shape is real data this build cannot represent (TIME_SERIES vectors).
+ private const uint BadNotSupported = 0x803D0000u;
+
+ /// The Agent reported text that is not a value of the authored type at all.
+ private const uint BadTypeMismatch = 0x80740000u;
+
+ ///
+ /// MTConnect's CONDITION state vocabulary, ranked by severity. Used only to reconcile
+ /// several states reported for one dataItemId within a single document — see
+ /// . An active Fault outranks UNAVAILABLE because a
+ /// fault is information and an absent value is not. Case-insensitive so a vendor's casing
+ /// of a state element still ranks; the parser itself emits the canonical spellings.
+ ///
+ private static readonly FrozenDictionary ConditionSeverity =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [UnavailableSentinel] = 0,
+ ["Normal"] = 1,
+ ["Warning"] = 2,
+ ["Fault"] = 3,
+ }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
+
+ private readonly ConcurrentDictionary _snapshots = new(StringComparer.Ordinal);
+ private readonly FrozenDictionary _tags;
+ private readonly TimeProvider _timeProvider;
+
+ ///
+ /// Builds an index over the driver instance's authored tags.
+ ///
+ ///
+ /// The authored tags, keyed by (the DataItem
+ /// id) — normally . null or empty is
+ /// legal and means "no authored types": every observation is then indexed as its raw text
+ /// under .
+ ///
+ /// Nothing enforces FullName uniqueness at the options layer, so a duplicate is
+ /// operator-authorable. The last definition wins rather than throwing: refusing to
+ /// construct would turn an authoring slip into a driver that will not start, whereas
+ /// last-wins is deterministic and costs only that one tag's coercion type.
+ ///
+ ///
+ /// Clock behind ServerTimestampUtc; defaults to .
+ public MTConnectObservationIndex(
+ IEnumerable? tags = null,
+ TimeProvider? timeProvider = null)
+ {
+ _timeProvider = timeProvider ?? TimeProvider.System;
+
+ var map = new Dictionary(StringComparer.Ordinal);
+ foreach (var tag in tags ?? [])
+ {
+ if (tag is null || string.IsNullOrWhiteSpace(tag.FullName)) continue;
+
+ map[tag.FullName] = tag;
+ }
+
+ _tags = map.ToFrozenDictionary(StringComparer.Ordinal);
+ }
+
+ /// The number of distinct dataItemIds currently indexed.
+ public int Count => _snapshots.Count;
+
+ ///
+ /// Folds a /current snapshot or one /sample chunk into the index. Observations
+ /// with no authored tag are indexed too — the Agent streams the whole device, so they are
+ /// normal, and dropping them would make a streaming-but-not-yet-authored data item
+ /// indistinguishable from one that does not exist.
+ ///
+ /// The parsed streams document. An observation-free result is a legal
+ /// keep-alive and leaves the index untouched.
+ /// is null.
+ public void Apply(MTConnectStreamsResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+
+ if (result.Observations is not { Count: > 0 }) return;
+
+ // Duplicates are reconciled on the RAW observations first, so each dataItemId is written
+ // exactly once below as a single atomic whole-snapshot assignment. Doing it the other way
+ // — writing every observation and merging snapshots in place — would need a read-modify-write
+ // per key and could expose a half-merged state to a concurrent reader.
+ var resolved = new Dictionary(StringComparer.Ordinal);
+ foreach (var observation in result.Observations)
+ {
+ if (observation is null || string.IsNullOrWhiteSpace(observation.DataItemId)) continue;
+
+ resolved[observation.DataItemId] =
+ resolved.TryGetValue(observation.DataItemId, out var incumbent)
+ ? Reconcile(incumbent, observation)
+ : observation;
+ }
+
+ foreach (var (dataItemId, observation) in resolved)
+ {
+ _snapshots[dataItemId] = BuildSnapshot(observation);
+ }
+ }
+
+ ///
+ /// Returns the current snapshot for a DataItem. Never null and never throws: an
+ /// unknown or not-yet-reported id yields a Bad-coded snapshot rather than an error.
+ ///
+ /// The DataItem id the tag binds.
+ ///
+ /// The indexed snapshot; else BadWaitingForInitialData when the id is authored but
+ /// the Agent has not reported it yet (the standard OPC UA "subscribed, no value yet"
+ /// state); else BadNodeIdUnknown. The two are kept distinct because collapsing them
+ /// would tell an operator their correctly-authored tag does not exist.
+ ///
+ public DataValueSnapshot Get(string dataItemId)
+ {
+ if (!string.IsNullOrWhiteSpace(dataItemId) &&
+ _snapshots.TryGetValue(dataItemId, out var snapshot))
+ {
+ return snapshot;
+ }
+
+ var status = !string.IsNullOrWhiteSpace(dataItemId) && _tags.ContainsKey(dataItemId)
+ ? BadWaitingForInitialData
+ : BadNodeIdUnknown;
+
+ return new DataValueSnapshot(null, status, null, Now);
+ }
+
+ ///
+ /// Drops every indexed observation. The Agent-restart re-baseline: when the streams header's
+ /// instanceId changes, every value the index holds describes a device model that no
+ /// longer exists, and serving it would be worse than serving nothing.
+ ///
+ public void Clear() => _snapshots.Clear();
+
+ private DateTime Now => _timeProvider.GetUtcNow().UtcDateTime;
+
+ ///
+ /// Picks the winner when one dataItemId appears more than once in a single document. A
+ /// <Condition> container legitimately carries several simultaneously active
+ /// states (a Fault and a Warning at once), so for a pair of recognized condition state words
+ /// the most severe wins — plain last-wins would under-report an active Fault as a
+ /// Warning purely because of element order. Anything else is last-wins, matching the Agent's
+ /// ascending-sequence ordering.
+ ///
+ /// Deliberately scoped to one document: a later is the Agent's new
+ /// statement of the state, so a cleared fault must fall back to Normal. A worst-of that
+ /// spanned Applies would latch every fault forever.
+ ///
+ ///
+ private static MTConnectObservation Reconcile(MTConnectObservation incumbent, MTConnectObservation challenger)
+ {
+ if (ConditionSeverity.TryGetValue(incumbent.Value ?? string.Empty, out var incumbentSeverity) &&
+ ConditionSeverity.TryGetValue(challenger.Value ?? string.Empty, out var challengerSeverity))
+ {
+ return challengerSeverity >= incumbentSeverity ? challenger : incumbent;
+ }
+
+ return challenger;
+ }
+
+ private DataValueSnapshot BuildSnapshot(MTConnectObservation observation)
+ {
+ var serverTimestamp = Now;
+ var sourceTimestamp = DateTime.SpecifyKind(observation.TimestampUtc, DateTimeKind.Utc);
+
+ // No text at all is the degenerate spelling of "no value": MTConnect defines UNAVAILABLE as
+ // the only way to report absence, so an empty element is not an empty string value.
+ if (string.IsNullOrWhiteSpace(observation.Value) ||
+ string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal))
+ {
+ return new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp);
+ }
+
+ _tags.TryGetValue(observation.DataItemId, out var tag);
+
+ // TIME_SERIES vectors arrive as one space-separated string. P1 does not materialize OPC UA
+ // arrays (deferred to P1.5), and the honest answer is "real data I cannot represent" — not a
+ // type mismatch, and emphatically not the first element parsed out and served as Good.
+ // Checked AFTER the sentinel so an unavailable array tag still reports the truthful no-comms.
+ if (tag is { IsArray: true })
+ {
+ return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
+ }
+
+ // An unauthored observation has no declared target type; String is the only coercion that
+ // cannot fail, and it carries the Agent's text with no interpretation applied.
+ var status = Coerce(observation.Value, tag?.DriverDataType ?? DriverDataType.String, out var value);
+
+ return status == Good
+ ? new DataValueSnapshot(value, Good, sourceTimestamp, serverTimestamp)
+ : new DataValueSnapshot(null, status, sourceTimestamp, serverTimestamp);
+ }
+
+ ///
+ /// Converts the Agent's text to the tag's authored type. Every parse is
+ /// : MTConnect is an invariant-format wire, and a
+ /// host running a comma-decimal culture would otherwise read 123.4567 as
+ /// 1234567 — a silently wrong value under Good quality, the worst possible outcome.
+ ///
+ /// on success; otherwise a Bad code and a null value.
+ private static uint Coerce(string raw, DriverDataType type, out object? value)
+ {
+ value = null;
+ var text = raw.Trim();
+
+ switch (type)
+ {
+ // Reference is a Galaxy-style attribute reference encoded as an OPC UA String. It is
+ // never a sensible authoring for MTConnect, but degrading it to text is harmless where
+ // rejecting it would break a tag for no protection.
+ case DriverDataType.String:
+ case DriverDataType.Reference:
+ value = raw;
+ return Good;
+
+ case DriverDataType.Boolean:
+ if (bool.TryParse(text, out var flag)) { value = flag; return Good; }
+ if (text is "1") { value = true; return Good; }
+ if (text is "0") { value = false; return Good; }
+ return BadTypeMismatch;
+
+ case DriverDataType.DateTime:
+ if (DateTime.TryParse(
+ text,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
+ out var moment))
+ {
+ value = DateTime.SpecifyKind(moment, DateTimeKind.Utc);
+ return Good;
+ }
+
+ return BadTypeMismatch;
+
+ case DriverDataType.Float32:
+ if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var single))
+ {
+ value = single;
+ return Good;
+ }
+
+ return BadTypeMismatch;
+
+ case DriverDataType.Float64:
+ if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var real))
+ {
+ value = real;
+ return Good;
+ }
+
+ return BadTypeMismatch;
+
+ case DriverDataType.Int16:
+ if (short.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i16))
+ {
+ value = i16;
+ return Good;
+ }
+
+ break;
+
+ case DriverDataType.Int32:
+ if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i32))
+ {
+ value = i32;
+ return Good;
+ }
+
+ break;
+
+ case DriverDataType.Int64:
+ if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i64))
+ {
+ value = i64;
+ return Good;
+ }
+
+ break;
+
+ case DriverDataType.UInt16:
+ if (ushort.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u16))
+ {
+ value = u16;
+ return Good;
+ }
+
+ break;
+
+ case DriverDataType.UInt32:
+ if (uint.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u32))
+ {
+ value = u32;
+ return Good;
+ }
+
+ break;
+
+ case DriverDataType.UInt64:
+ if (ulong.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u64))
+ {
+ value = u64;
+ return Good;
+ }
+
+ break;
+
+ default:
+ // A DriverDataType this driver has no rule for. Text always round-trips, so serving
+ // it beats failing a tag over an enum member added later.
+ value = raw;
+ return Good;
+ }
+
+ // Integer parse failed. Distinguishing the two causes is worth one extra parse: it tells an
+ // operator whether to retype the tag or widen it.
+ return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _)
+ ? BadOutOfRange // a number the authored type cannot represent (overflow, or a fraction)
+ : BadTypeMismatch; // not a number at all
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs
new file mode 100644
index 00000000..205dec2a
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs
@@ -0,0 +1,706 @@
+using System.Globalization;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Task 8 — : the thread-safe
+/// dataItemId → DataValueSnapshot map that /current and the /sample pump
+/// both write into, and the quality mapping that turns the Agent's weakly-typed text into an
+/// OPC UA-shaped snapshot.
+///
+/// Status codes are asserted as literals here on purpose. The production constants
+/// are private, so a wrong numeric value in the driver cannot be masked by both
+/// sides sharing one symbol — the literal is an independent statement of the OPC UA
+/// numeric contract (verified against Opc.Ua.StatusCodes).
+///
+///
+public sealed class MTConnectObservationIndexTests
+{
+ private const string CurrentFixture = "Fixtures/current.xml";
+ private const string UnavailableFixture = "Fixtures/current-unavailable.xml";
+ private const string SampleFixture = "Fixtures/sample.xml";
+
+ private const uint Good = 0x00000000u;
+ private const uint BadNoCommunication = 0x80310000u;
+ private const uint BadWaitingForInitialData = 0x80320000u;
+ private const uint BadNodeIdUnknown = 0x80340000u;
+ private const uint BadOutOfRange = 0x803C0000u;
+ private const uint BadNotSupported = 0x803D0000u;
+ private const uint BadTypeMismatch = 0x80740000u;
+
+ /// Every dataItemId both /current fixtures report.
+ private static readonly string[] AllFixtureDataItemIds =
+ [
+ "dev1_avail",
+ "dev1_pos",
+ "dev1_vibration_ts",
+ "dev1_partcount",
+ "dev1_execution",
+ "dev1_program",
+ "dev1_system_cond"
+ ];
+
+ private static MTConnectStreamsResult ParseFixture(string path) =>
+ MTConnectStreamsParser.Parse(File.ReadAllText(path));
+
+ /// Wraps one synthetic observation in the minimal legal streams result.
+ private static MTConnectStreamsResult Synthetic(string dataItemId, string value, DateTime? timestampUtc = null) =>
+ new(
+ InstanceId: 1L,
+ NextSequence: 2L,
+ FirstSequence: 1L,
+ Observations: [new MTConnectObservation(
+ dataItemId,
+ value,
+ timestampUtc ?? new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc))]);
+
+ private static MTConnectTagDefinition Tag(string id, DriverDataType type) => new(id, type);
+
+ // --------------------------------------------------------------- UNAVAILABLE => BadNoCommunication
+
+ /// The plan's headline case: the Agent's UNAVAILABLE sentinel is a no-comms quality, not a value.
+ [Fact]
+ public void Unavailable_observation_maps_to_BadNoCommunication_with_null_value()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(UnavailableFixture));
+
+ var snap = idx.Get("dev1_partcount");
+
+ snap.Value.ShouldBeNull();
+ snap.StatusCode.ShouldBe(0x80310000u);
+ }
+
+ ///
+ /// Every one of the seven observations in the all-UNAVAILABLE fixture maps to no-comms —
+ /// including dev1_system_cond, which reaches the index as a CONDITION whose
+ /// <Unavailable/> element name the parser normalized onto the exact literal
+ /// UNAVAILABLE. A case-sensitive miss on that one spelling is the defect this covers.
+ ///
+ [Fact]
+ public void Every_observation_in_the_unavailable_fixture_maps_to_BadNoCommunication()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(UnavailableFixture));
+
+ foreach (var id in AllFixtureDataItemIds)
+ {
+ var snap = idx.Get(id);
+ snap.StatusCode.ShouldBe(BadNoCommunication, $"dataItemId '{id}' should be no-comms");
+ snap.Value.ShouldBeNull($"dataItemId '{id}' must not carry a value when unavailable");
+ }
+ }
+
+ /// An UNAVAILABLE observation still carries the Agent's timestamp — the quality changed, not the clock.
+ [Fact]
+ public void Unavailable_observation_keeps_the_agent_source_timestamp()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(UnavailableFixture));
+
+ var snap = idx.Get("dev1_partcount");
+
+ snap.SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 10, 0, 300, DateTimeKind.Utc));
+ snap.SourceTimestampUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
+ }
+
+ /// The sentinel is matched exactly; a lowercase spelling is a real value, not a no-comms marker.
+ [Theory]
+ [InlineData("UNAVAILABLE", BadNoCommunication)]
+ [InlineData("Unavailable", Good)]
+ [InlineData("unavailable", Good)]
+ public void Unavailable_sentinel_is_matched_on_the_exact_literal(string value, uint expectedStatus)
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
+ idx.Apply(Synthetic("x", value));
+
+ idx.Get("x").StatusCode.ShouldBe(expectedStatus);
+ }
+
+ // ------------------------------------------------------------------------- present values
+
+ /// The plan's second headline case.
+ [Fact]
+ public void Present_value_indexes_good_with_source_timestamp()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_pos");
+
+ snap.StatusCode.ShouldBe(0u);
+ snap.SourceTimestampUtc.ShouldNotBeNull();
+ }
+
+ /// SourceTimestamp is the Agent's observation timestamp, never the indexing clock.
+ [Fact]
+ public void Present_value_carries_the_observation_timestamp_not_the_index_clock()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_pos");
+
+ snap.SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc));
+ snap.ServerTimestampUtc.ShouldBeGreaterThan(snap.SourceTimestampUtc!.Value);
+ snap.ServerTimestampUtc.Kind.ShouldBe(DateTimeKind.Utc);
+ }
+
+ /// A Float64-authored SAMPLE coerces to a real double, not the raw text.
+ [Fact]
+ public void Float64_authored_tag_coerces_numeric_text_to_a_double()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_pos");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBeOfType().ShouldBe(123.4567);
+ }
+
+ /// An Int64-authored EVENT (PartCount) coerces to a long. sample.xml is the fixture with a present count.
+ [Fact]
+ public void Int64_authored_tag_coerces_integer_text_to_a_long()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_partcount", DriverDataType.Int64)]);
+ idx.Apply(ParseFixture(SampleFixture));
+
+ var snap = idx.Get("dev1_partcount");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBeOfType().ShouldBe(42L);
+ }
+
+ /// String-authored EVENT/CONDITION observations carry the Agent's text verbatim.
+ [Theory]
+ [InlineData("dev1_execution", "ACTIVE")]
+ [InlineData("dev1_program", "O1234")]
+ [InlineData("dev1_avail", "AVAILABLE")]
+ [InlineData("dev1_system_cond", "Normal")]
+ public void String_authored_tags_carry_the_raw_agent_text(string dataItemId, string expected)
+ {
+ var idx = new MTConnectObservationIndex([Tag(dataItemId, DriverDataType.String)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get(dataItemId);
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBeOfType().ShouldBe(expected);
+ }
+
+ /// The remaining scalar coercions, each landing on its exact CLR type.
+ [Theory]
+ [InlineData(DriverDataType.Boolean, "true", true)]
+ [InlineData(DriverDataType.Boolean, "FALSE", false)]
+ [InlineData(DriverDataType.Boolean, "1", true)]
+ [InlineData(DriverDataType.Boolean, "0", false)]
+ [InlineData(DriverDataType.Int16, "-12", (short)-12)]
+ [InlineData(DriverDataType.Int32, "70000", 70000)]
+ [InlineData(DriverDataType.UInt16, "65535", (ushort)65535)]
+ [InlineData(DriverDataType.UInt32, "4000000000", 4000000000u)]
+ [InlineData(DriverDataType.UInt64, "18446744073709551615", 18446744073709551615ul)]
+ [InlineData(DriverDataType.Float32, "1.5", 1.5f)]
+ public void Scalar_coercions_land_on_the_authored_clr_type(
+ DriverDataType type, string text, object expected)
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", type)]);
+ idx.Apply(Synthetic("x", text));
+
+ var snap = idx.Get("x");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBe(expected);
+ }
+
+ /// A DateTime-authored tag parses ISO-8601 and lands on UTC kind.
+ [Fact]
+ public void DateTime_authored_tag_coerces_to_a_utc_datetime()
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.DateTime)]);
+ idx.Apply(Synthetic("x", "2026-07-24T12:00:00.250Z"));
+
+ var snap = idx.Get("x");
+
+ snap.StatusCode.ShouldBe(Good);
+ var value = snap.Value.ShouldBeOfType();
+ value.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 250, DateTimeKind.Utc));
+ value.Kind.ShouldBe(DateTimeKind.Utc);
+ }
+
+ ///
+ /// Numeric coercion is culture-invariant. On a de-DE machine a naive
+ /// double.TryParse("123.4567") yields 1234567 — a silently wrong value with Good
+ /// quality, the worst possible outcome. Run on a dedicated thread so the culture change
+ /// cannot leak into any parallel test.
+ ///
+ [Fact]
+ public void Numeric_coercion_is_culture_invariant()
+ {
+ Exception? failure = null;
+
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE");
+
+ var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ idx.Get("dev1_pos").Value.ShouldBeOfType().ShouldBe(123.4567);
+ }
+ catch (Exception ex)
+ {
+ failure = ex;
+ }
+ });
+
+ thread.Start();
+ thread.Join();
+
+ failure.ShouldBeNull();
+ }
+
+ // ----------------------------------------------------------------------- coercion failures
+
+ ///
+ /// A vocabulary EVENT authored as a number cannot parse. It must degrade to a Bad-coded
+ /// snapshot — never an exception (the index sits under IReadable), and never a
+ /// silently-wrong zero.
+ ///
+ [Fact]
+ public void Coercion_failure_yields_BadTypeMismatch_and_never_throws()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_execution", DriverDataType.Float64)]);
+
+ Should.NotThrow(() => idx.Apply(ParseFixture(CurrentFixture)));
+
+ var snap = idx.Get("dev1_execution");
+
+ snap.StatusCode.ShouldBe(0x80740000u);
+ snap.Value.ShouldBeNull();
+ }
+
+ /// Non-numeric text is a type mismatch; a number the authored type cannot hold is out of range.
+ [Theory]
+ [InlineData(DriverDataType.Float64, "ACTIVE", BadTypeMismatch)]
+ [InlineData(DriverDataType.Int64, "ACTIVE", BadTypeMismatch)]
+ [InlineData(DriverDataType.Boolean, "ACTIVE", BadTypeMismatch)]
+ [InlineData(DriverDataType.Int16, "99999999999", BadOutOfRange)]
+ [InlineData(DriverDataType.Int32, "3.5", BadOutOfRange)]
+ [InlineData(DriverDataType.UInt32, "-1", BadOutOfRange)]
+ public void Failed_coercion_distinguishes_type_mismatch_from_out_of_range(
+ DriverDataType type, string text, uint expectedStatus)
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", type)]);
+ idx.Apply(Synthetic("x", text));
+
+ var snap = idx.Get("x");
+
+ snap.StatusCode.ShouldBe(expectedStatus);
+ snap.Value.ShouldBeNull();
+ }
+
+ /// A Bad-coded coercion failure still reports when the Agent observed it.
+ [Fact]
+ public void Failed_coercion_keeps_the_agent_source_timestamp()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_execution", DriverDataType.Float64)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ idx.Get("dev1_execution").SourceTimestampUtc
+ .ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 500, DateTimeKind.Utc));
+ }
+
+ /// An observation whose text is empty is "the Agent supplied no value" — never a Good empty string.
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Empty_observation_value_maps_to_BadNoCommunication(string value)
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
+ idx.Apply(Synthetic("x", value));
+
+ var snap = idx.Get("x");
+
+ snap.StatusCode.ShouldBe(BadNoCommunication);
+ snap.Value.ShouldBeNull();
+ }
+
+ // -------------------------------------------------------------------------- unknown lookups
+
+ /// A dataItemId that is neither authored nor ever observed is unknown to this driver.
+ [Fact]
+ public void Unknown_dataItemId_yields_BadNodeIdUnknown()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("no_such_data_item");
+
+ snap.StatusCode.ShouldBe(BadNodeIdUnknown);
+ snap.Value.ShouldBeNull();
+ snap.SourceTimestampUtc.ShouldBeNull();
+ }
+
+ ///
+ /// An authored tag the Agent has not reported yet is a different condition from an unknown
+ /// id — it is the standard OPC UA "subscribed, no value yet" state. Collapsing the two
+ /// would tell an operator their correctly-authored tag does not exist.
+ ///
+ [Fact]
+ public void Authored_but_never_observed_tag_yields_BadWaitingForInitialData()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_spindle_speed", DriverDataType.Float64)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_spindle_speed");
+
+ snap.StatusCode.ShouldBe(BadWaitingForInitialData);
+ snap.Value.ShouldBeNull();
+ }
+
+ /// An empty / whitespace ref is not a lookup; it degrades rather than throwing.
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Blank_dataItemId_yields_BadNodeIdUnknown(string dataItemId)
+ {
+ var idx = new MTConnectObservationIndex();
+
+ idx.Get(dataItemId).StatusCode.ShouldBe(BadNodeIdUnknown);
+ }
+
+ // --------------------------------------------------------------------- unauthored observations
+
+ ///
+ /// The Agent streams the whole device, so most observations have no authored tag. They are
+ /// indexed as their raw text under — the type that
+ /// cannot fail to coerce — rather than dropped or coded Bad.
+ ///
+ [Fact]
+ public void Unauthored_observation_is_indexed_as_a_good_string()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_pos");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBeOfType().ShouldBe("123.4567");
+ }
+
+ /// Every fixture observation is indexed, authored or not — nothing is silently dropped.
+ [Fact]
+ public void Every_fixture_observation_is_indexed_even_with_no_authored_tags()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
+ foreach (var id in AllFixtureDataItemIds)
+ {
+ idx.Get(id).StatusCode.ShouldNotBe(BadNodeIdUnknown, $"'{id}' should have been indexed");
+ }
+ }
+
+ // -------------------------------------------------------------------------------- TIME_SERIES
+
+ ///
+ /// P1 defers TIME_SERIES array materialization to P1.5. An array-authored tag therefore
+ /// reports BadNotSupported rather than pretending: the vector is real data this build
+ /// cannot represent, which is neither a type mismatch nor a comms failure.
+ ///
+ [Fact]
+ public void Time_series_authored_as_an_array_reports_BadNotSupported()
+ {
+ var idx = new MTConnectObservationIndex(
+ [new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_vibration_ts");
+
+ snap.StatusCode.ShouldBe(BadNotSupported);
+ snap.Value.ShouldBeNull();
+ }
+
+ ///
+ /// The falsifiable half of the deferral: the vector must never be silently reduced to its
+ /// first element. A Good 1.1 here would be a wrong value an operator would trust.
+ ///
+ [Fact]
+ public void Time_series_vector_is_never_silently_parsed_as_its_first_element()
+ {
+ var idx = new MTConnectObservationIndex(
+ [new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_vibration_ts");
+
+ snap.Value.ShouldNotBe(1.1);
+ snap.StatusCode.ShouldNotBe(Good);
+ }
+
+ ///
+ /// No-comms outranks the unsupported shape: an UNAVAILABLE array tag is a genuine, fully
+ /// representable state and must report it rather than the deferral code.
+ ///
+ [Fact]
+ public void Unavailable_wins_over_the_unsupported_array_shape()
+ {
+ var idx = new MTConnectObservationIndex(
+ [new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
+ idx.Apply(ParseFixture(UnavailableFixture));
+
+ idx.Get("dev1_vibration_ts").StatusCode.ShouldBe(BadNoCommunication);
+ }
+
+ ///
+ /// An unauthored TIME_SERIES observation carries the Agent's exact text as a string. That
+ /// is the raw truth with no interpretation applied — not a scalar guess.
+ ///
+ [Fact]
+ public void Unauthored_time_series_observation_carries_the_verbatim_vector_text()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_vibration_ts");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBeOfType().ShouldBe("1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0");
+ }
+
+ // -------------------------------------------------------------------------------- ordering
+
+ /// Within one Apply, the later observation for a dataItemId wins (Agent order is ascending sequence).
+ [Fact]
+ public void Last_write_wins_within_a_single_apply()
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.Int64)]);
+
+ idx.Apply(new MTConnectStreamsResult(1L, 4L, 1L, [
+ new MTConnectObservation("x", "1", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
+ new MTConnectObservation("x", "2", new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
+ new MTConnectObservation("x", "3", new DateTime(2026, 7, 24, 12, 0, 2, DateTimeKind.Utc)),
+ ]));
+
+ var snap = idx.Get("x");
+
+ snap.Value.ShouldBe(3L);
+ snap.SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 2, DateTimeKind.Utc));
+ }
+
+ ///
+ /// A <Condition> container may carry several simultaneously-active states
+ /// sharing one dataItemId (a Fault and a Warning at once), which the parser flattens into
+ /// repeats in one result. Within a single document those states are concurrent, so the most
+ /// severe wins — plain last-wins would under-report an active Fault as a Warning purely
+ /// because of element order.
+ ///
+ [Theory]
+ [InlineData("Fault", "Warning", "Fault")]
+ [InlineData("Warning", "Fault", "Fault")]
+ [InlineData("Normal", "Warning", "Warning")]
+ [InlineData("Warning", "Normal", "Warning")]
+ [InlineData("Fault", "Normal", "Fault")]
+ public void Simultaneous_condition_states_keep_the_most_severe_within_one_apply(
+ string first, string second, string expected)
+ {
+ var idx = new MTConnectObservationIndex(
+ [new MTConnectTagDefinition("c", DriverDataType.String, MtCategory: "CONDITION")]);
+
+ idx.Apply(new MTConnectStreamsResult(1L, 3L, 1L, [
+ new MTConnectObservation("c", first, new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
+ new MTConnectObservation("c", second, new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
+ ]));
+
+ idx.Get("c").Value.ShouldBe(expected);
+ }
+
+ /// An active fault carries more information than "no value"; it outranks UNAVAILABLE.
+ [Fact]
+ public void An_active_fault_outranks_unavailable_within_one_apply()
+ {
+ var idx = new MTConnectObservationIndex(
+ [new MTConnectTagDefinition("c", DriverDataType.String, MtCategory: "CONDITION")]);
+
+ idx.Apply(new MTConnectStreamsResult(1L, 3L, 1L, [
+ new MTConnectObservation("c", "UNAVAILABLE", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
+ new MTConnectObservation("c", "Fault", new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
+ ]));
+
+ var snap = idx.Get("c");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBe("Fault");
+ }
+
+ ///
+ /// Worst-of is scoped to ONE document. A later document is the Agent's new statement of the
+ /// condition's state, so a cleared fault must fall back to Normal — a worst-of that spanned
+ /// Applies would latch every fault forever.
+ ///
+ [Fact]
+ public void A_later_apply_clears_a_condition_to_its_new_state()
+ {
+ var idx = new MTConnectObservationIndex(
+ [new MTConnectTagDefinition("c", DriverDataType.String, MtCategory: "CONDITION")]);
+
+ idx.Apply(Synthetic("c", "Fault"));
+ idx.Get("c").Value.ShouldBe("Fault");
+
+ idx.Apply(Synthetic("c", "Normal"));
+
+ idx.Get("c").Value.ShouldBe("Normal");
+ }
+
+ /// Severity reconciliation is for condition words only; repeated ordinary values stay last-wins.
+ [Fact]
+ public void Repeated_non_condition_values_stay_last_wins()
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
+
+ idx.Apply(new MTConnectStreamsResult(1L, 3L, 1L, [
+ new MTConnectObservation("x", "Fault", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
+ new MTConnectObservation("x", "O1234", new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
+ ]));
+
+ idx.Get("x").Value.ShouldBe("O1234");
+ }
+
+ /// A later Apply overwrites an earlier one — including Good going to no-comms.
+ [Fact]
+ public void A_later_apply_overwrites_an_earlier_one()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
+
+ idx.Apply(ParseFixture(CurrentFixture));
+ idx.Get("dev1_pos").StatusCode.ShouldBe(Good);
+
+ idx.Apply(ParseFixture(UnavailableFixture));
+ idx.Get("dev1_pos").StatusCode.ShouldBe(BadNoCommunication);
+ }
+
+ /// An Apply carrying no observations leaves the index untouched (an idle /sample heartbeat).
+ [Fact]
+ public void An_observation_free_apply_leaves_the_index_untouched()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ idx.Apply(new MTConnectStreamsResult(1L, 200L, 1L, []));
+
+ idx.Get("dev1_pos").Value.ShouldBe(123.4567);
+ idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
+ }
+
+ /// Clear drops every indexed observation — the Agent-restart re-baseline Task 11 needs.
+ [Fact]
+ public void Clear_drops_every_indexed_observation()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(CurrentFixture));
+ idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
+
+ idx.Clear();
+
+ idx.Count.ShouldBe(0);
+ idx.Get("dev1_pos").StatusCode.ShouldBe(BadNodeIdUnknown);
+ }
+
+ // ------------------------------------------------------------------------ authored-tag map
+
+ ///
+ /// Nothing enforces FullName uniqueness at the options layer, so a duplicate is operator-
+ /// authorable. Last definition wins rather than throwing — refusing to construct would turn
+ /// an authoring slip into a driver that will not start.
+ ///
+ [Fact]
+ public void Duplicate_authored_fullname_takes_the_last_definition()
+ {
+ var idx = new MTConnectObservationIndex([
+ Tag("dev1_pos", DriverDataType.String),
+ Tag("dev1_pos", DriverDataType.Float64),
+ ]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ idx.Get("dev1_pos").Value.ShouldBeOfType().ShouldBe(123.4567);
+ }
+
+ /// The index binds to the options' Tags collection, which defaults to empty and is never null.
+ [Fact]
+ public void Index_can_be_built_from_driver_options()
+ {
+ var options = new MTConnectDriverOptions
+ {
+ AgentUri = "http://agent:5000",
+ Tags = [Tag("dev1_pos", DriverDataType.Float64)],
+ };
+
+ var idx = new MTConnectObservationIndex(options.Tags);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ idx.Get("dev1_pos").Value.ShouldBe(123.4567);
+ }
+
+ // -------------------------------------------------------------------------- thread safety
+
+ ///
+ /// /current (Task 10) and the /sample pump (Task 11) write concurrently while OPC UA reads
+ /// snapshot. Per-key atomicity is the contract: no throw, no torn snapshot (a Good value
+ /// paired with a Bad code), and every read observes one whole snapshot.
+ ///
+ [Fact]
+ public async Task Concurrent_applies_and_reads_stay_consistent_and_never_throw()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
+ var current = ParseFixture(CurrentFixture);
+ var unavailable = ParseFixture(UnavailableFixture);
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+ var writers = Enumerable.Range(0, 4).Select(i => Task.Run(() =>
+ {
+ while (!cts.IsCancellationRequested)
+ {
+ idx.Apply(i % 2 == 0 ? current : unavailable);
+ }
+ }));
+
+ var readers = Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
+ {
+ while (!cts.IsCancellationRequested)
+ {
+ var snap = idx.Get("dev1_pos");
+
+ // Exactly two snapshots are legal for this tag; anything else is a tear.
+ if (snap.StatusCode == 0u)
+ {
+ snap.Value.ShouldBe(123.4567);
+ }
+ else
+ {
+ snap.StatusCode.ShouldBe(BadNoCommunication);
+ snap.Value.ShouldBeNull();
+ }
+ }
+ }));
+
+ await Task.WhenAll(writers.Concat(readers));
+
+ idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
+ }
+
+ /// Applying a null result is a programming error, not degradable data.
+ [Fact]
+ public void Apply_rejects_a_null_result()
+ {
+ var idx = new MTConnectObservationIndex();
+
+ Should.Throw(() => idx.Apply(null!));
+ }
+}