From ac0a28405558f17acd9d1ed1e6e568ab57974909 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 24 Jul 2026 14:50:43 -0400 Subject: [PATCH] feat(mtconnect): observation index + UNAVAILABLE->BadNoCommunication (Task 8) --- .../MTConnectObservationIndex.cs | 406 ++++++++++ .../MTConnectObservationIndexTests.cs | 706 ++++++++++++++++++ 2 files changed, 1112 insertions(+) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs 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!)); + } +}