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_COUNTPartCountpart-countpartcount. 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() {