fix(mtconnect): infer numeric EVENT types from the probe's UPPER_SNAKE spelling (Task 3 follow-up)

This commit is contained in:
Joseph Doherty
2026-07-24 13:57:13 -04:00
parent 3ca8ddf6f7
commit 79e30d1ead
2 changed files with 142 additions and 7 deletions
@@ -92,8 +92,17 @@ public readonly record struct MTConnectInferredType(DriverDataType DataType, boo
/// <c>DISCRETE</c> and <c>VALUE</c> are scalar and change nothing. /// <c>DISCRETE</c> and <c>VALUE</c> are scalar and change nothing.
/// </para> /// </para>
/// <para> /// <para>
/// Matching is case-insensitive and whitespace-trimmed on every input, because agents vary /// <b>Matching is case-insensitive AND separator-insensitive</b> on every input —
/// in how faithfully they echo the standard's spellings. /// <c>_</c>, <c>-</c> and whitespace are ignored wherever they appear, so
/// <c>PART_COUNT</c> ≡ <c>PartCount</c> ≡ <c>part-count</c> ≡ <c>partcount</c>. This is not
/// mere leniency: <b>MTConnect spells the same concept two different ways in two different
/// documents</b>. The Devices document (<c>/probe</c>) writes <c>DataItem@type</c> in
/// UPPER_SNAKE (<c>PART_COUNT</c>, <c>POSITION</c>, <c>PATH_FEEDRATE</c>), while the Streams
/// documents (<c>/current</c>, <c>/sample</c>) name the observation element in PascalCase
/// (<c>PartCount</c>, <c>Position</c>). Discovery reads the <i>probe</i> spelling, and plain
/// case-insensitivity does not bridge the underscore — so matching only the PascalCase
/// spelling types every real agent's <c>PART_COUNT</c> as <see cref="DriverDataType.String" />
/// while every unit test using the sketch's PascalCase spelling stays green.
/// </para> /// </para>
/// </remarks> /// </remarks>
public static class MTConnectDataTypeInference 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. /// every agent reports it as a parseable integer, and being wrong costs Bad quality.
/// <c>Line</c> is the deprecated spelling that <c>LineNumber</c> superseded in MTConnect 1.4; /// <c>Line</c> is the deprecated spelling that <c>LineNumber</c> superseded in MTConnect 1.4;
/// both are mapped so a current-version agent is not silently mistyped. /// both are mapped so a current-version agent is not silently mistyped.
/// <para>
/// The entries are written in the Streams (PascalCase) spelling, but the set's comparer
/// is <see cref="SeparatorInsensitiveComparer" />, so the probe document's
/// <c>PART_COUNT</c> / <c>LINE_NUMBER</c> hit the same entries. Adding a spelling variant
/// here would be redundant, not additional coverage.
/// </para>
/// </summary> /// </summary>
private static readonly FrozenSet<string> NumericEventTypes = private static readonly FrozenSet<string> NumericEventTypes =
new[] { "PartCount", "Line", "LineNumber" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); new[] { "PartCount", "Line", "LineNumber" }.ToFrozenSet(SeparatorInsensitiveComparer.Instance);
/// <summary> /// <summary>
/// Infers the OPC UA-facing type shape of an MTConnect <c>DataItem</c> from its device-model /// Infers the OPC UA-facing type shape of an MTConnect <c>DataItem</c> from its device-model
@@ -125,8 +140,10 @@ public static class MTConnectDataTypeInference
/// <see cref="DriverDataType.String" />. /// <see cref="DriverDataType.String" />.
/// </param> /// </param>
/// <param name="type"> /// <param name="type">
/// The <c>DataItem</c> type (e.g. <c>Position</c>, <c>PartCount</c>, <c>Execution</c>). /// The <c>DataItem</c> type, in <b>either</b> document's spelling — the probe's
/// Case-insensitive; an unrecognised or <c>null</c> value falls back to the category default. /// <c>PART_COUNT</c> / <c>POSITION</c> or the streams' <c>PartCount</c> / <c>Position</c>.
/// Matched case- and separator-insensitively (see the type-level remarks); an unrecognised
/// or <c>null</c> value falls back to the category default.
/// </param> /// </param>
/// <param name="units"> /// <param name="units">
/// The declared engineering units, if any. Accepted for signature stability and because every /// 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)) if (Matches(trimmedCategory, CategoryEvent))
{ {
var trimmedType = type?.Trim(); // No Trim() here: the set's comparer already ignores whitespace and separators
var dataType = trimmedType is { Length: > 0 } && NumericEventTypes.Contains(trimmedType) // 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.Int64
: DriverDataType.String; : DriverDataType.String;
@@ -189,4 +207,60 @@ public static class MTConnectDataTypeInference
private static bool Matches(ReadOnlySpan<char> value, string expected) private static bool Matches(ReadOnlySpan<char> value, string expected)
=> value.Equals(expected, StringComparison.OrdinalIgnoreCase); => value.Equals(expected, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Ordinal-ignore-case equality that additionally skips <c>_</c>, <c>-</c> and whitespace
/// wherever they occur, so the probe document's <c>PART_COUNT</c> and the streams
/// document's <c>PartCount</c> are one key. Backs <see cref="NumericEventTypes" />.
/// </summary>
/// <remarks>
/// Comparing through a comparer rather than normalising the input keeps the lookup
/// allocation-free on the discovery hot path: no <c>string.Replace</c>, no
/// <c>Trim()</c>, no temporary buffer — the raw attribute value from the parser is passed
/// straight to <see cref="FrozenSet{T}.Contains" />.
/// </remarks>
private sealed class SeparatorInsensitiveComparer : IEqualityComparer<string>
{
/// <summary>The single shared instance; the comparer is stateless.</summary>
internal static readonly SeparatorInsensitiveComparer Instance = new();
private SeparatorInsensitiveComparer() { }
/// <inheritdoc />
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++;
}
}
/// <inheritdoc />
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);
}
} }
@@ -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) 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); => 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] [Fact]
public void TimeSeries_sample_is_a_float64_array_with_declared_count() public void TimeSeries_sample_is_a_float64_array_with_declared_count()
{ {