feat(mtconnect): pure data-type inference table + golden test (Task 3)

This commit is contained in:
Joseph Doherty
2026-07-24 13:53:13 -04:00
parent 992ffe5620
commit c0729ae5c0
2 changed files with 442 additions and 0 deletions
@@ -0,0 +1,192 @@
using System.Collections.Frozen;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The type shape inferred for a single MTConnect <c>DataItem</c>: the scalar
/// <see cref="DriverDataType" /> plus the array shape that
/// <see cref="DriverAttributeInfo.IsArray" /> / <see cref="DriverAttributeInfo.ArrayDim" />
/// expect. Returned by value (a readonly record struct) so the discovery loop and the
/// AdminUI editor can call the inference on a hot path without allocating.
/// </summary>
/// <param name="DataType">The scalar element type of the observation.</param>
/// <param name="IsArray">
/// <c>true</c> only for a <c>SAMPLE</c> declared <c>representation="TIME_SERIES"</c>, whose
/// observation carries a vector of samples rather than a single value.
/// </param>
/// <param name="ArrayDim">
/// The probe-declared array length when <see cref="IsArray" /> is <c>true</c> and the device
/// model declared a positive <c>sampleCount</c>; <c>null</c> for a variable-length series
/// (many agents omit <c>sampleCount</c>) and always <c>null</c> for a scalar.
/// </param>
public readonly record struct MTConnectInferredType(DriverDataType DataType, bool IsArray, uint? ArrayDim);
/// <summary>
/// Translates an MTConnect <c>DataItem</c>'s device-model metadata (category / type / units /
/// representation) into the server's <see cref="DriverDataType" />. Pure and deterministic —
/// no I/O, no logging, no mutable state.
/// </summary>
/// <remarks>
/// <para>
/// This lives in <c>.Contracts</c> because three call sites must agree byte-for-byte, or a
/// tag's OPC UA type stops matching its authored config: <c>ITagDiscovery.DiscoverAsync</c>
/// (stamps <see cref="DriverAttributeInfo.DriverDataType" /> on every browsed leaf), the
/// universal browser's commit path (writes the browsed leaf into <c>TagConfig</c>), and the
/// AdminUI typed tag editor (shows the inferred type and offers an override).
/// </para>
/// <para>
/// <b>MTConnect is weakly typed on the wire</b> — every observation is text, and the standard
/// defines hundreds of <c>type</c> values with more added each revision. This is therefore a
/// <i>rule</i>, not an enumeration, and the result is stored per tag and author-overridable.
/// The rule is asymmetric on purpose: a wrong <i>numeric</i> guess produces a value that
/// fails to parse and surfaces as Bad quality at runtime, whereas <see cref="DriverDataType.String" />
/// always round-trips and the author can retype it. So every default leans to
/// <c>String</c> except where the standard itself guarantees a number.
/// </para>
/// <para>
/// <b>Defaulting rule, per category</b> (design §3.3):
/// </para>
/// <list type="bullet">
/// <item>
/// <description>
/// <c>SAMPLE</c> ⇒ <see cref="DriverDataType.Float64" />, <i>including for an
/// unrecognised or missing <c>type</c></i>. The standard defines the whole SAMPLE
/// category as a continuously-varying measured quantity, so the category alone is
/// the guarantee — a missing <c>units</c> attribute is a gap in the device model,
/// not evidence that the observation is text. <c>Float64</c> rather than an integer
/// type because a double parse accepts both <c>3</c> and <c>3.5</c>.
/// </description>
/// </item>
/// <item>
/// <description>
/// <c>EVENT</c> ⇒ <see cref="DriverDataType.String" /> by default, with a small
/// exception list (<see cref="NumericEventTypes" />) of standard types that are
/// defined as integers. The overwhelming majority of EVENT types are controlled
/// vocabulary (<c>Execution</c>, <c>ControllerMode</c>, <c>Availability</c>) or free
/// text (<c>Program</c>, <c>Block</c>, <c>Message</c>), and the exception list is
/// deliberately short — each entry is a coercion risk, while every omission is
/// merely a string the author can retype.
/// </description>
/// </item>
/// <item>
/// <description>
/// <c>CONDITION</c> ⇒ <see cref="DriverDataType.String" /> always. The observation
/// is a state word (<c>Normal</c> / <c>Warning</c> / <c>Fault</c> /
/// <c>Unavailable</c>), never a number, regardless of the item's <c>type</c>.
/// </description>
/// </item>
/// <item>
/// <description>
/// An <b>unknown, blank, or missing category</b> ⇒ <see cref="DriverDataType.String" />.
/// Probe XML in the wild is inconsistent; with no category we have no evidence the
/// observation is numeric, so we pick the type that cannot fail to parse.
/// </description>
/// </item>
/// </list>
/// <para>
/// Representation overrides the category where the two disagree about shape:
/// <c>DATA_SET</c> / <c>TABLE</c> observations are key-value maps, so they demote to
/// <see cref="DriverDataType.String" /> even on a <c>SAMPLE</c>; <c>TIME_SERIES</c> is
/// defined only for <c>SAMPLE</c> and is ignored (not treated as an array) elsewhere;
/// <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.
/// </para>
/// </remarks>
public static class MTConnectDataTypeInference
{
private const string CategorySample = "SAMPLE";
private const string CategoryEvent = "EVENT";
private const string RepresentationTimeSeries = "TIME_SERIES";
private const string RepresentationDataSet = "DATA_SET";
private const string RepresentationTable = "TABLE";
/// <summary>
/// The <c>EVENT</c> types the standard defines as integers — the exception list to the
/// "EVENT is text" default. Kept deliberately short: adding a type here is a claim that
/// 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.
/// </summary>
private static readonly FrozenSet<string> NumericEventTypes =
new[] { "PartCount", "Line", "LineNumber" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Infers the OPC UA-facing type shape of an MTConnect <c>DataItem</c> from its device-model
/// metadata. See the type-level remarks for the full rule and its rationale.
/// </summary>
/// <param name="category">
/// The <c>DataItem</c> category — <c>SAMPLE</c>, <c>EVENT</c>, or <c>CONDITION</c>.
/// Case-insensitive; an unknown, blank, or <c>null</c> value yields
/// <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.
/// </param>
/// <param name="units">
/// The declared engineering units, if any. Accepted for signature stability and because every
/// call site already holds it, but <b>not load-bearing in v1</b>: <c>SAMPLE</c> is numeric by
/// definition of the category, so units add no discriminating power there, and treating a
/// units-bearing <c>EVENT</c> as numeric would be exactly the risky coercion this rule avoids.
/// </param>
/// <param name="representation">
/// The <c>DataItem</c> representation — <c>VALUE</c> (default), <c>TIME_SERIES</c>,
/// <c>DATA_SET</c>, <c>TABLE</c>, or <c>DISCRETE</c>. Case-insensitive.
/// </param>
/// <param name="sampleCount">
/// The probe-declared <c>sampleCount</c> for a <c>TIME_SERIES</c> item. Flows into
/// <see cref="MTConnectInferredType.ArrayDim" /> when positive; <c>null</c> or a non-positive
/// value yields a variable-length array (<c>ArrayDim = null</c>). Ignored for scalars.
/// </param>
/// <returns>The inferred scalar type plus array shape.</returns>
public static MTConnectInferredType Infer(
string? category,
string? type = null,
string? units = null,
string? representation = null,
int? sampleCount = null)
{
_ = units; // See the parameter doc: intentionally not load-bearing in v1.
var trimmedCategory = category.AsSpan().Trim();
var trimmedRepresentation = representation.AsSpan().Trim();
// A key-value observation is never a number, whatever the category claims. Checked first so
// a DATA_SET SAMPLE cannot slip through as Float64 and go Bad on every parse.
if (Matches(trimmedRepresentation, RepresentationDataSet) || Matches(trimmedRepresentation, RepresentationTable))
return new MTConnectInferredType(DriverDataType.String, IsArray: false, ArrayDim: null);
if (Matches(trimmedCategory, CategorySample))
{
// TIME_SERIES is defined for SAMPLE only; the vector is a vector of the same scalar type.
if (Matches(trimmedRepresentation, RepresentationTimeSeries))
return new MTConnectInferredType(
DriverDataType.Float64,
IsArray: true,
ArrayDim: sampleCount is > 0 ? (uint)sampleCount.Value : null);
return new MTConnectInferredType(DriverDataType.Float64, IsArray: false, ArrayDim: null);
}
if (Matches(trimmedCategory, CategoryEvent))
{
var trimmedType = type?.Trim();
var dataType = trimmedType is { Length: > 0 } && NumericEventTypes.Contains(trimmedType)
? DriverDataType.Int64
: DriverDataType.String;
return new MTConnectInferredType(dataType, IsArray: false, ArrayDim: null);
}
// CONDITION (a state word) and every unknown / blank / missing category.
return new MTConnectInferredType(DriverDataType.String, IsArray: false, ArrayDim: null);
}
private static bool Matches(ReadOnlySpan<char> value, string expected)
=> value.Equals(expected, StringComparison.OrdinalIgnoreCase);
}
@@ -0,0 +1,250 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// Golden table for <see cref="MTConnectDataTypeInference.Infer" /> (design §3.3).
/// Three call sites must agree byte-for-byte on this mapping — <c>ITagDiscovery.DiscoverAsync</c>,
/// the universal browser's commit path, and the AdminUI typed tag editor — so the table is
/// pinned here rather than re-derived at each seam.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MTConnectDataTypeInferenceTests
{
// ---------------------------------------------------------------------
// The design §3.3 golden table.
// ---------------------------------------------------------------------
[Theory]
// SAMPLE numeric with units → Float64.
[InlineData("SAMPLE", "Position", "MILLIMETER", null, DriverDataType.Float64)]
[InlineData("SAMPLE", "SpindleSpeed", "REVOLUTION/MINUTE", "VALUE", DriverDataType.Float64)]
// EVENT numeric type → Int64.
[InlineData("EVENT", "PartCount", null, null, DriverDataType.Int64)]
[InlineData("EVENT", "Line", null, null, DriverDataType.Int64)]
// EVENT controlled vocabulary → String.
[InlineData("EVENT", "Execution", null, null, DriverDataType.String)]
[InlineData("EVENT", "ControllerMode", null, null, DriverDataType.String)]
[InlineData("EVENT", "Availability", null, null, DriverDataType.String)]
// EVENT free text → String.
[InlineData("EVENT", "Program", null, null, DriverDataType.String)]
[InlineData("EVENT", "Block", null, null, DriverDataType.String)]
[InlineData("EVENT", "Message", null, null, DriverDataType.String)]
// CONDITION → String (the state word).
[InlineData("CONDITION", "Temperature", null, null, DriverDataType.String)]
[InlineData("CONDITION", "SystemCondition", null, null, DriverDataType.String)]
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);
[Fact]
public void TimeSeries_sample_is_a_float64_array_with_declared_count()
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", sampleCount: 8);
r.DataType.ShouldBe(DriverDataType.Float64);
r.IsArray.ShouldBeTrue();
r.ArrayDim.ShouldBe(8u);
}
// ---------------------------------------------------------------------
// Array shape: only a SAMPLE/TIME_SERIES is an array, and ArrayDim is
// only populated when the probe declared a usable sampleCount.
// ---------------------------------------------------------------------
[Fact]
public void TimeSeries_without_a_declared_sample_count_is_a_variable_length_array()
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES");
r.IsArray.ShouldBeTrue();
r.ArrayDim.ShouldBeNull();
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void TimeSeries_with_a_non_positive_sample_count_reports_no_dimension(int declared)
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", declared);
r.IsArray.ShouldBeTrue();
r.ArrayDim.ShouldBeNull();
}
[Theory]
[InlineData("SAMPLE", "Position", "VALUE")]
[InlineData("SAMPLE", "Position", null)]
[InlineData("EVENT", "PartCount", "VALUE")]
[InlineData("CONDITION", "Temperature", null)]
public void A_scalar_item_is_never_an_array(string cat, string type, string? rep)
{
var r = MTConnectDataTypeInference.Infer(cat, type, null, rep);
r.IsArray.ShouldBeFalse();
r.ArrayDim.ShouldBeNull();
}
[Fact]
public void A_declared_sample_count_is_ignored_when_the_item_is_not_a_time_series()
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "VALUE", sampleCount: 8);
r.IsArray.ShouldBeFalse();
r.ArrayDim.ShouldBeNull();
}
[Theory]
[InlineData("EVENT")]
[InlineData("CONDITION")]
public void TIME_SERIES_outside_the_SAMPLE_category_is_not_treated_as_an_array(string cat)
{
// TIME_SERIES is defined only for SAMPLE. Honouring it elsewhere would hand the
// address space an array node whose observations are scalars.
var r = MTConnectDataTypeInference.Infer(cat, "Anything", null, "TIME_SERIES", sampleCount: 8);
r.IsArray.ShouldBeFalse();
r.ArrayDim.ShouldBeNull();
r.DataType.ShouldBe(DriverDataType.String);
}
// ---------------------------------------------------------------------
// The defaulting rule for unrecognised `type` values — the part of the
// table that is a *rule*, not an enumeration. MTConnect defines hundreds
// of types; the fallback per category is what actually ships.
// ---------------------------------------------------------------------
[Theory]
[InlineData("Xacceleration")]
[InlineData("SomeVendorExtension")]
[InlineData(null)]
[InlineData("")]
public void An_unrecognised_SAMPLE_type_still_defaults_to_Float64(string? type)
=> MTConnectDataTypeInference.Infer("SAMPLE", type, null, null).DataType.ShouldBe(DriverDataType.Float64);
[Fact]
public void A_SAMPLE_without_units_is_still_numeric()
// SAMPLE is numeric by definition in the standard; a missing `units` attribute is a
// gap in the device model, not evidence that the observation is text.
=> MTConnectDataTypeInference.Infer("SAMPLE", "Position", null, null).DataType
.ShouldBe(DriverDataType.Float64);
[Theory]
[InlineData("ToolNumber")]
[InlineData("PalletId")]
[InlineData("LineLabel")]
[InlineData("SomeVendorExtension")]
[InlineData(null)]
[InlineData("")]
public void An_unrecognised_EVENT_type_defaults_to_String(string? type)
// Fail-safe direction: a wrong numeric coercion produces a Bad-quality value at runtime,
// whereas String always round-trips and the author can override.
=> MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.String);
[Theory]
[InlineData("LineNumber")]
public void The_modern_spelling_of_a_known_numeric_EVENT_is_also_numeric(string type)
// `Line` was superseded by `LineNumber`; mapping only the deprecated spelling would
// silently mistype the same data item on any current-version agent.
=> MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.Int64);
[Theory]
[InlineData("Anything")]
[InlineData(null)]
public void Every_CONDITION_is_a_String_regardless_of_type(string? type)
=> MTConnectDataTypeInference.Infer("CONDITION", type, "CELSIUS", null).DataType
.ShouldBe(DriverDataType.String);
// ---------------------------------------------------------------------
// Unknown / missing category — probe XML in the wild is inconsistent.
// ---------------------------------------------------------------------
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("ASSET")]
[InlineData("nonsense")]
public void An_unknown_or_missing_category_falls_back_to_String(string? cat)
// We cannot know the observation is numeric, so we choose the type that always
// round-trips rather than one that can fail to parse.
=> MTConnectDataTypeInference.Infer(cat, "Position", "MILLIMETER", null).DataType
.ShouldBe(DriverDataType.String);
// ---------------------------------------------------------------------
// Case / whitespace tolerance.
// ---------------------------------------------------------------------
[Theory]
[InlineData("sample", DriverDataType.Float64)]
[InlineData("Sample", DriverDataType.Float64)]
[InlineData(" SAMPLE ", DriverDataType.Float64)]
[InlineData("condition", DriverDataType.String)]
public void Category_matching_is_case_and_whitespace_insensitive(string cat, DriverDataType expected)
=> MTConnectDataTypeInference.Infer(cat, "Position", "MM", null).DataType.ShouldBe(expected);
[Theory]
[InlineData("partcount")]
[InlineData("PARTCOUNT")]
[InlineData(" PartCount ")]
public void Numeric_EVENT_type_matching_is_case_and_whitespace_insensitive(string type)
=> MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.Int64);
[Theory]
[InlineData("time_series")]
[InlineData("Time_Series")]
[InlineData(" TIME_SERIES ")]
public void Representation_matching_is_case_and_whitespace_insensitive(string rep)
=> MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", rep, 4).IsArray.ShouldBeTrue();
// ---------------------------------------------------------------------
// Non-scalar representations. A DATA_SET / TABLE observation is a
// key-value map, not a number — typing it Float64 guarantees Bad quality.
// ---------------------------------------------------------------------
[Theory]
[InlineData("DATA_SET")]
[InlineData("TABLE")]
[InlineData("data_set")]
public void A_key_value_representation_is_a_String_even_on_a_SAMPLE(string rep)
{
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MILLIMETER", rep);
r.DataType.ShouldBe(DriverDataType.String);
r.IsArray.ShouldBeFalse();
}
[Fact]
public void A_key_value_representation_also_demotes_a_numeric_EVENT()
{
var r = MTConnectDataTypeInference.Infer("EVENT", "PartCount", null, "DATA_SET");
r.DataType.ShouldBe(DriverDataType.String);
r.IsArray.ShouldBeFalse();
}
[Fact]
public void DISCRETE_is_a_scalar_representation_and_does_not_change_the_type()
{
// DISCRETE changes *when* the agent reports, not what the value is.
var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MILLIMETER", "DISCRETE");
r.DataType.ShouldBe(DriverDataType.Float64);
r.IsArray.ShouldBeFalse();
}
// ---------------------------------------------------------------------
// Purity — the three call sites rely on identical results for identical
// inputs with no shared state between calls.
// ---------------------------------------------------------------------
[Fact]
public void Infer_is_deterministic()
{
var first = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", 8);
var second = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", 8);
second.ShouldBe(first);
}
}