diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDataTypeInference.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDataTypeInference.cs
index cbf4312b..4bddb20c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDataTypeInference.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDataTypeInference.cs
@@ -92,8 +92,17 @@ public readonly record struct MTConnectInferredType(DriverDataType DataType, boo
/// DISCRETE and VALUE are scalar and change nothing.
///
///
-/// Matching is case-insensitive and whitespace-trimmed on every input, because agents vary
-/// in how faithfully they echo the standard's spellings.
+/// Matching is case-insensitive AND separator-insensitive on every input —
+/// _, - and whitespace are ignored wherever they appear, so
+/// PART_COUNT ≡ PartCount ≡ part-count ≡ partcount. This is not
+/// mere leniency: MTConnect spells the same concept two different ways in two different
+/// documents. The Devices document (/probe) writes DataItem@type in
+/// UPPER_SNAKE (PART_COUNT, POSITION, PATH_FEEDRATE), while the Streams
+/// documents (/current, /sample) name the observation element in PascalCase
+/// (PartCount, Position). Discovery reads the probe spelling, and plain
+/// case-insensitivity does not bridge the underscore — so matching only the PascalCase
+/// spelling types every real agent's PART_COUNT as
+/// while every unit test using the sketch's PascalCase spelling stays green.
///
///
public static class MTConnectDataTypeInference
@@ -111,9 +120,15 @@ public static class MTConnectDataTypeInference
/// every agent reports it as a parseable integer, and being wrong costs Bad quality.
/// Line is the deprecated spelling that LineNumber superseded in MTConnect 1.4;
/// both are mapped so a current-version agent is not silently mistyped.
+ ///
+ /// The entries are written in the Streams (PascalCase) spelling, but the set's comparer
+ /// is , so the probe document's
+ /// PART_COUNT / LINE_NUMBER hit the same entries. Adding a spelling variant
+ /// here would be redundant, not additional coverage.
+ ///
///
private static readonly FrozenSet NumericEventTypes =
- new[] { "PartCount", "Line", "LineNumber" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
+ new[] { "PartCount", "Line", "LineNumber" }.ToFrozenSet(SeparatorInsensitiveComparer.Instance);
///
/// Infers the OPC UA-facing type shape of an MTConnect DataItem from its device-model
@@ -125,8 +140,10 @@ public static class MTConnectDataTypeInference
/// .
///
///
- /// The DataItem type (e.g. Position, PartCount, Execution).
- /// Case-insensitive; an unrecognised or null value falls back to the category default.
+ /// The DataItem type, in either document's spelling — the probe's
+ /// PART_COUNT / POSITION or the streams' PartCount / Position.
+ /// Matched case- and separator-insensitively (see the type-level remarks); an unrecognised
+ /// or null value falls back to the category default.
///
///
/// The declared engineering units, if any. Accepted for signature stability and because every
@@ -175,8 +192,9 @@ public static class MTConnectDataTypeInference
if (Matches(trimmedCategory, CategoryEvent))
{
- var trimmedType = type?.Trim();
- var dataType = trimmedType is { Length: > 0 } && NumericEventTypes.Contains(trimmedType)
+ // No Trim() here: the set's comparer already ignores whitespace and separators
+ // wherever they appear, so the raw attribute value is looked up allocation-free.
+ var dataType = type is { Length: > 0 } && NumericEventTypes.Contains(type)
? DriverDataType.Int64
: DriverDataType.String;
@@ -189,4 +207,60 @@ public static class MTConnectDataTypeInference
private static bool Matches(ReadOnlySpan value, string expected)
=> value.Equals(expected, StringComparison.OrdinalIgnoreCase);
+
+ ///
+ /// Ordinal-ignore-case equality that additionally skips _, - and whitespace
+ /// wherever they occur, so the probe document's PART_COUNT and the streams
+ /// document's PartCount are one key. Backs .
+ ///
+ ///
+ /// Comparing through a comparer rather than normalising the input keeps the lookup
+ /// allocation-free on the discovery hot path: no string.Replace, no
+ /// Trim(), no temporary buffer — the raw attribute value from the parser is passed
+ /// straight to .
+ ///
+ private sealed class SeparatorInsensitiveComparer : IEqualityComparer
+ {
+ /// The single shared instance; the comparer is stateless.
+ internal static readonly SeparatorInsensitiveComparer Instance = new();
+
+ private SeparatorInsensitiveComparer() { }
+
+ ///
+ public bool Equals(string? x, string? y)
+ {
+ if (ReferenceEquals(x, y)) return true;
+ if (x is null || y is null) return false;
+
+ int i = 0, j = 0;
+ while (true)
+ {
+ while (i < x.Length && IsSeparator(x[i])) i++;
+ while (j < y.Length && IsSeparator(y[j])) j++;
+
+ if (i == x.Length || j == y.Length) return i == x.Length && j == y.Length;
+ if (char.ToUpperInvariant(x[i]) != char.ToUpperInvariant(y[j])) return false;
+
+ i++;
+ j++;
+ }
+ }
+
+ ///
+ public int GetHashCode(string obj)
+ {
+ // Must hash exactly the characters Equals compares, or two equal spellings land in
+ // different buckets and the set silently misses.
+ var hash = new HashCode();
+ foreach (var c in obj)
+ {
+ if (IsSeparator(c)) continue;
+ hash.Add(char.ToUpperInvariant(c));
+ }
+
+ return hash.ToHashCode();
+ }
+
+ private static bool IsSeparator(char c) => c is '_' or '-' || char.IsWhiteSpace(c);
+ }
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataTypeInferenceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataTypeInferenceTests.cs
index 1a82d954..487778a2 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataTypeInferenceTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataTypeInferenceTests.cs
@@ -38,6 +38,67 @@ public sealed class MTConnectDataTypeInferenceTests
public void Infer_maps_the_v1_table(string cat, string type, string? units, string? rep, DriverDataType expected)
=> MTConnectDataTypeInference.Infer(cat, type, units, rep).DataType.ShouldBe(expected);
+ // ---------------------------------------------------------------------
+ // The SAME golden table, spelled the way a real agent's /probe document
+ // spells it. MTConnect writes the same concept two ways: the Devices
+ // (/probe) document's `DataItem@type` is UPPER_SNAKE (PART_COUNT), while
+ // the Streams (/current, /sample) document's observation element name is
+ // PascalCase (PartCount). `DiscoverAsync` feeds the *probe* spelling, so
+ // an inference that only knows the PascalCase spelling is green in tests
+ // and wrong live. Every row below is a DataItem from Fixtures/probe.xml.
+ // ---------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("EVENT", "PART_COUNT", null, DriverDataType.Int64)] // dev1_partcount
+ [InlineData("SAMPLE", "POSITION", "MILLIMETER", DriverDataType.Float64)] // dev1_pos
+ [InlineData("EVENT", "EXECUTION", null, DriverDataType.String)] // dev1_execution
+ [InlineData("EVENT", "PROGRAM", null, DriverDataType.String)] // dev1_program
+ [InlineData("EVENT", "AVAILABILITY", null, DriverDataType.String)] // dev1_avail
+ [InlineData("CONDITION", "SYSTEM", null, DriverDataType.String)] // dev1_system_cond
+ public void Infer_maps_the_probe_documents_UPPER_SNAKE_spelling(
+ string cat, string type, string? units, DriverDataType expected)
+ => MTConnectDataTypeInference.Infer(cat, type, units, null).DataType.ShouldBe(expected);
+
+ [Fact]
+ public void The_probes_TIME_SERIES_item_is_a_float64_array_of_its_declared_sample_count()
+ {
+ // dev1_vibration_ts: category=SAMPLE type=PATH_FEEDRATE representation=TIME_SERIES sampleCount=10.
+ var r = MTConnectDataTypeInference.Infer(
+ "SAMPLE", "PATH_FEEDRATE", "MILLIMETER/SECOND", "TIME_SERIES", sampleCount: 10);
+
+ r.DataType.ShouldBe(DriverDataType.Float64);
+ r.IsArray.ShouldBeTrue();
+ r.ArrayDim.ShouldBe(10u);
+ }
+
+ [Theory]
+ [InlineData("PART_COUNT", "PartCount")]
+ [InlineData("LINE_NUMBER", "LineNumber")]
+ [InlineData("part_count", "partcount")]
+ [InlineData("PART-COUNT", "PartCount")]
+ [InlineData("PART_COUNT", " Part Count ")]
+ public void The_two_document_spellings_of_one_type_infer_identically(string probeSpelling, string streamsSpelling)
+ {
+ // The equivalence is the whole point: a tag discovered from /probe and the same tag
+ // named from a /current observation must land on one type, or the authored config
+ // stops matching the value that arrives.
+ var fromProbe = MTConnectDataTypeInference.Infer("EVENT", probeSpelling, null, null);
+ var fromStreams = MTConnectDataTypeInference.Infer("EVENT", streamsSpelling, null, null);
+
+ fromProbe.ShouldBe(fromStreams);
+ fromProbe.DataType.ShouldBe(DriverDataType.Int64);
+ }
+
+ [Theory]
+ [InlineData("PART_COUNTER")]
+ [InlineData("PARTS_COUNT")]
+ [InlineData("LINE_NUMBERS")]
+ [InlineData("_")]
+ public void Separator_insensitivity_does_not_widen_the_numeric_EVENT_exception_list(string type)
+ // Falsifiability control: ignoring separators must not turn a *different* type into a
+ // match. The exception list stays exactly PartCount / Line / LineNumber.
+ => MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.String);
+
[Fact]
public void TimeSeries_sample_is_a_float64_array_with_declared_count()
{