fix(mtconnect): infer numeric EVENT types from the probe's UPPER_SNAKE spelling (Task 3 follow-up)
This commit is contained in:
+81
-7
@@ -92,8 +92,17 @@ public readonly record struct MTConnectInferredType(DriverDataType DataType, boo
|
||||
/// <c>DISCRETE</c> and <c>VALUE</c> are scalar and change nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Matching is case-insensitive and whitespace-trimmed on every input, because agents vary
|
||||
/// in how faithfully they echo the standard's spellings.
|
||||
/// <b>Matching is case-insensitive AND separator-insensitive</b> on every input —
|
||||
/// <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>
|
||||
/// </remarks>
|
||||
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.
|
||||
/// <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.
|
||||
/// <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>
|
||||
private static readonly FrozenSet<string> NumericEventTypes =
|
||||
new[] { "PartCount", "Line", "LineNumber" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
|
||||
new[] { "PartCount", "Line", "LineNumber" }.ToFrozenSet(SeparatorInsensitiveComparer.Instance);
|
||||
|
||||
/// <summary>
|
||||
/// 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" />.
|
||||
/// </param>
|
||||
/// <param name="type">
|
||||
/// The <c>DataItem</c> type (e.g. <c>Position</c>, <c>PartCount</c>, <c>Execution</c>).
|
||||
/// Case-insensitive; an unrecognised or <c>null</c> value falls back to the category default.
|
||||
/// The <c>DataItem</c> type, in <b>either</b> document's spelling — the probe's
|
||||
/// <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 name="units">
|
||||
/// 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<char> value, string expected)
|
||||
=> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+61
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user