feat(mtconnect): observation index + UNAVAILABLE->BadNoCommunication (Task 8)

This commit is contained in:
Joseph Doherty
2026-07-24 14:50:43 -04:00
parent cf43f8d0ab
commit ac0a284055
2 changed files with 1112 additions and 0 deletions
@@ -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;
/// <summary>
/// The driver's live <c>dataItemId → <see cref="DataValueSnapshot"/></c> map: the single place
/// an Agent observation's weakly-typed text becomes an OPC UA-shaped value + quality +
/// timestamp. Written by the <c>/current</c> baseline read and by the <c>/sample</c> long-poll
/// pump; read by <c>IReadable.ReadAsync</c> and the subscription fan-out.
/// </summary>
/// <remarks>
/// <para>
/// <b>Quality mapping (design §3.3).</b> MTConnect has exactly one way to say "I have no
/// value": the literal token <c>UNAVAILABLE</c>. It arrives as a Sample/Event's text, and —
/// after <see cref="MTConnectStreamsParser"/> normalizes the <c>&lt;Unavailable/&gt;</c>
/// element name — as a CONDITION's value on that same exact spelling. It maps to
/// <c>BadNoCommunication</c> with a <c>null</c> value: the Agent is reachable, the machine's
/// data item is not. The match is <b>ordinal and case-sensitive</b> against the one spelling
/// the parser guarantees; a case-insensitive match would swallow a legitimate free-text
/// EVENT whose value happens to read "Unavailable".
/// </para>
/// <para>
/// <b>Failure posture.</b> This type sits under <c>IReadable</c> 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 <i>when</i> the driver last heard about the tag even when it cannot show
/// <i>what</i>. The only exceptions raised are <see cref="ArgumentNullException"/> for a null
/// argument — a programming error, not data.
/// </para>
/// <para>
/// <b>Status codes</b> are the canonical <c>Opc.Ua.StatusCodes</c> numeric values, declared
/// once each below. Note that <see cref="BadTypeMismatch"/> is <c>0x80740000</c>: the
/// <c>0x80730000</c> that four sibling drivers declare under that name is really
/// <c>BadWriteNotSupported</c>, which would be actively misleading on a read path and
/// renders as bare "Bad" in the driver CLI's <c>SnapshotFormatter</c>.
/// </para>
/// <para>
/// <b>Thread safety.</b> Backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/> and
/// guaranteed <b>per-key atomic</b> — nothing more, deliberately. <see cref="Apply"/> 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 <b>not</b> a whole-batch atomic swap: OPC UA reads are per-tag and the driver
/// offers no cross-tag consistency contract, <c>/sample</c> 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.
/// </para>
/// <para>
/// <b>KNOWN LIMITATION — <c>DATA_SET</c> / <c>TABLE</c> observations.</b> Their real content
/// is <c>&lt;Entry key=…&gt;</c> child elements, but the parser reads an observation's value
/// with <c>XElement.Value</c>, 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 <c>DATA_SET</c>/<c>TABLE</c> to
/// <see cref="DriverDataType.String"/> — it coerces "successfully" into a <b>Good-coded
/// snapshot carrying noise</b>. This index <b>cannot</b> detect the shape: neither
/// <see cref="MTConnectObservation"/> nor <see cref="MTConnectTagDefinition"/> carries the
/// DataItem's <c>representation</c>, and no value-shape heuristic can separate a
/// concatenated entry list from a legitimate space-bearing EVENT (a <c>Message</c> reads
/// "Coolant level low"). The discriminating signal — the observation element having child
/// elements — exists only in <see cref="MTConnectStreamsParser"/>, so the fix belongs there
/// (flag structured observations at parse time; this index then codes them
/// <c>BadNotSupported</c> alongside the TIME_SERIES deferral below).
/// </para>
/// </remarks>
internal sealed class MTConnectObservationIndex
{
/// <summary>
/// The one token that means "the Agent has no value for this data item". Matched ordinally:
/// the parser already normalizes a CONDITION's <c>&lt;Unavailable/&gt;</c> element name onto
/// this exact spelling, so every category lands on one comparison.
/// </summary>
private const string UnavailableSentinel = "UNAVAILABLE";
private const uint Good = 0x00000000u;
/// <summary>The Agent reported <c>UNAVAILABLE</c>, or supplied no text at all.</summary>
private const uint BadNoCommunication = 0x80310000u;
/// <summary>The tag is authored but the Agent has not yet reported it.</summary>
private const uint BadWaitingForInitialData = 0x80320000u;
/// <summary>The dataItemId is neither authored nor ever observed.</summary>
private const uint BadNodeIdUnknown = 0x80340000u;
/// <summary>The Agent reported a number the authored type cannot represent.</summary>
private const uint BadOutOfRange = 0x803C0000u;
/// <summary>The observation's shape is real data this build cannot represent (TIME_SERIES vectors).</summary>
private const uint BadNotSupported = 0x803D0000u;
/// <summary>The Agent reported text that is not a value of the authored type at all.</summary>
private const uint BadTypeMismatch = 0x80740000u;
/// <summary>
/// MTConnect's CONDITION state vocabulary, ranked by severity. Used only to reconcile
/// several states reported for one dataItemId <i>within a single document</i> — see
/// <see cref="Reconcile"/>. An active <c>Fault</c> outranks <c>UNAVAILABLE</c> 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.
/// </summary>
private static readonly FrozenDictionary<string, int> ConditionSeverity =
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
[UnavailableSentinel] = 0,
["Normal"] = 1,
["Warning"] = 2,
["Fault"] = 3,
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, DataValueSnapshot> _snapshots = new(StringComparer.Ordinal);
private readonly FrozenDictionary<string, MTConnectTagDefinition> _tags;
private readonly TimeProvider _timeProvider;
/// <summary>
/// Builds an index over the driver instance's authored tags.
/// </summary>
/// <param name="tags">
/// The authored tags, keyed by <see cref="MTConnectTagDefinition.FullName"/> (the DataItem
/// <c>id</c>) — normally <see cref="MTConnectDriverOptions.Tags"/>. <c>null</c> or empty is
/// legal and means "no authored types": every observation is then indexed as its raw text
/// under <see cref="DriverDataType.String"/>.
/// <para>
/// Nothing enforces <c>FullName</c> uniqueness at the options layer, so a duplicate is
/// operator-authorable. <b>The last definition wins</b> 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.
/// </para>
/// </param>
/// <param name="timeProvider">Clock behind <c>ServerTimestampUtc</c>; defaults to <see cref="TimeProvider.System"/>.</param>
public MTConnectObservationIndex(
IEnumerable<MTConnectTagDefinition>? tags = null,
TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
var map = new Dictionary<string, MTConnectTagDefinition>(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);
}
/// <summary>The number of distinct dataItemIds currently indexed.</summary>
public int Count => _snapshots.Count;
/// <summary>
/// Folds a <c>/current</c> snapshot or one <c>/sample</c> 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.
/// </summary>
/// <param name="result">The parsed streams document. An observation-free result is a legal
/// keep-alive and leaves the index untouched.</param>
/// <exception cref="ArgumentNullException"><paramref name="result"/> is <c>null</c>.</exception>
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<string, MTConnectObservation>(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);
}
}
/// <summary>
/// Returns the current snapshot for a DataItem. Never <c>null</c> and never throws: an
/// unknown or not-yet-reported id yields a Bad-coded snapshot rather than an error.
/// </summary>
/// <param name="dataItemId">The DataItem <c>id</c> the tag binds.</param>
/// <returns>
/// The indexed snapshot; else <c>BadWaitingForInitialData</c> when the id is authored but
/// the Agent has not reported it yet (the standard OPC UA "subscribed, no value yet"
/// state); else <c>BadNodeIdUnknown</c>. The two are kept distinct because collapsing them
/// would tell an operator their correctly-authored tag does not exist.
/// </returns>
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);
}
/// <summary>
/// Drops every indexed observation. The Agent-restart re-baseline: when the streams header's
/// <c>instanceId</c> changes, every value the index holds describes a device model that no
/// longer exists, and serving it would be worse than serving nothing.
/// </summary>
public void Clear() => _snapshots.Clear();
private DateTime Now => _timeProvider.GetUtcNow().UtcDateTime;
/// <summary>
/// Picks the winner when one dataItemId appears more than once in a single document. A
/// <c>&lt;Condition&gt;</c> container legitimately carries several <b>simultaneously active</b>
/// states (a Fault and a Warning at once), so for a pair of recognized condition state words
/// the <b>most severe</b> 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.
/// <para>
/// Deliberately scoped to one document: a later <see cref="Apply"/> 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.
/// </para>
/// </summary>
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);
}
/// <summary>
/// Converts the Agent's text to the tag's authored type. Every parse is
/// <see cref="CultureInfo.InvariantCulture"/>: MTConnect is an invariant-format wire, and a
/// host running a comma-decimal culture would otherwise read <c>123.4567</c> as
/// <c>1234567</c> — a silently wrong value under Good quality, the worst possible outcome.
/// </summary>
/// <returns><see cref="Good"/> on success; otherwise a Bad code and a <c>null</c> value.</returns>
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
}
}
@@ -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;
/// <summary>
/// Task 8 — <see cref="MTConnectObservationIndex"/>: the thread-safe
/// <c>dataItemId → DataValueSnapshot</c> map that <c>/current</c> and the <c>/sample</c> pump
/// both write into, and the quality mapping that turns the Agent's weakly-typed text into an
/// OPC UA-shaped snapshot.
/// <para>
/// Status codes are asserted as <b>literals</b> here on purpose. The production constants
/// are <c>private</c>, 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 <c>Opc.Ua.StatusCodes</c>).
/// </para>
/// </summary>
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;
/// <summary>Every dataItemId both /current fixtures report.</summary>
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));
/// <summary>Wraps one synthetic observation in the minimal legal streams result.</summary>
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
/// <summary>The plan's headline case: the Agent's UNAVAILABLE sentinel is a no-comms quality, not a value.</summary>
[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);
}
/// <summary>
/// Every one of the seven observations in the all-UNAVAILABLE fixture maps to no-comms —
/// including <c>dev1_system_cond</c>, which reaches the index as a CONDITION whose
/// <c>&lt;Unavailable/&gt;</c> element name the parser normalized onto the exact literal
/// <c>UNAVAILABLE</c>. A case-sensitive miss on that one spelling is the defect this covers.
/// </summary>
[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");
}
}
/// <summary>An UNAVAILABLE observation still carries the Agent's timestamp — the quality changed, not the clock.</summary>
[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);
}
/// <summary>The sentinel is matched exactly; a lowercase spelling is a real value, not a no-comms marker.</summary>
[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
/// <summary>The plan's second headline case.</summary>
[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();
}
/// <summary>SourceTimestamp is the Agent's observation timestamp, never the indexing clock.</summary>
[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);
}
/// <summary>A Float64-authored SAMPLE coerces to a real double, not the raw text.</summary>
[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<double>().ShouldBe(123.4567);
}
/// <summary>An Int64-authored EVENT (PartCount) coerces to a long. sample.xml is the fixture with a present count.</summary>
[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<long>().ShouldBe(42L);
}
/// <summary>String-authored EVENT/CONDITION observations carry the Agent's text verbatim.</summary>
[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<string>().ShouldBe(expected);
}
/// <summary>The remaining scalar coercions, each landing on its exact CLR type.</summary>
[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);
}
/// <summary>A DateTime-authored tag parses ISO-8601 and lands on UTC kind.</summary>
[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<DateTime>();
value.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 250, DateTimeKind.Utc));
value.Kind.ShouldBe(DateTimeKind.Utc);
}
/// <summary>
/// Numeric coercion is culture-invariant. On a de-DE machine a naive
/// <c>double.TryParse("123.4567")</c> 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.
/// </summary>
[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<double>().ShouldBe(123.4567);
}
catch (Exception ex)
{
failure = ex;
}
});
thread.Start();
thread.Join();
failure.ShouldBeNull();
}
// ----------------------------------------------------------------------- coercion failures
/// <summary>
/// A vocabulary EVENT authored as a number cannot parse. It must degrade to a Bad-coded
/// snapshot — never an exception (the index sits under <c>IReadable</c>), and never a
/// silently-wrong zero.
/// </summary>
[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();
}
/// <summary>Non-numeric text is a type mismatch; a number the authored type cannot hold is out of range.</summary>
[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();
}
/// <summary>A Bad-coded coercion failure still reports when the Agent observed it.</summary>
[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));
}
/// <summary>An observation whose text is empty is "the Agent supplied no value" — never a Good empty string.</summary>
[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
/// <summary>A dataItemId that is neither authored nor ever observed is unknown to this driver.</summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[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();
}
/// <summary>An empty / whitespace ref is not a lookup; it degrades rather than throwing.</summary>
[Theory]
[InlineData("")]
[InlineData(" ")]
public void Blank_dataItemId_yields_BadNodeIdUnknown(string dataItemId)
{
var idx = new MTConnectObservationIndex();
idx.Get(dataItemId).StatusCode.ShouldBe(BadNodeIdUnknown);
}
// --------------------------------------------------------------------- unauthored observations
/// <summary>
/// The Agent streams the whole device, so most observations have no authored tag. They are
/// indexed as their raw text under <see cref="DriverDataType.String"/> — the type that
/// cannot fail to coerce — rather than dropped or coded Bad.
/// </summary>
[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<string>().ShouldBe("123.4567");
}
/// <summary>Every fixture observation is indexed, authored or not — nothing is silently dropped.</summary>
[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
/// <summary>
/// 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.
/// </summary>
[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();
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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<string>().ShouldBe("1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0");
}
// -------------------------------------------------------------------------------- ordering
/// <summary>Within one Apply, the later observation for a dataItemId wins (Agent order is ascending sequence).</summary>
[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));
}
/// <summary>
/// A <c>&lt;Condition&gt;</c> 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.
/// </summary>
[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);
}
/// <summary>An active fault carries more information than "no value"; it outranks UNAVAILABLE.</summary>
[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");
}
/// <summary>
/// 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.
/// </summary>
[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");
}
/// <summary>Severity reconciliation is for condition words only; repeated ordinary values stay last-wins.</summary>
[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");
}
/// <summary>A later Apply overwrites an earlier one — including Good going to no-comms.</summary>
[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);
}
/// <summary>An Apply carrying no observations leaves the index untouched (an idle /sample heartbeat).</summary>
[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);
}
/// <summary>Clear drops every indexed observation — the Agent-restart re-baseline Task 11 needs.</summary>
[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
/// <summary>
/// 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.
/// </summary>
[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<double>().ShouldBe(123.4567);
}
/// <summary>The index binds to the options' Tags collection, which defaults to empty and is never null.</summary>
[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
/// <summary>
/// /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.
/// </summary>
[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);
}
/// <summary>Applying a null result is a programming error, not degradable data.</summary>
[Fact]
public void Apply_rejects_a_null_result()
{
var idx = new MTConnectObservationIndex();
Should.Throw<ArgumentNullException>(() => idx.Apply(null!));
}
}