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
new file mode 100644
index 00000000..cbf4312b
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/MTConnectDataTypeInference.cs
@@ -0,0 +1,192 @@
+using System.Collections.Frozen;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// The type shape inferred for a single MTConnect DataItem: the scalar
+/// plus the array shape that
+/// /
+/// 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.
+///
+/// The scalar element type of the observation.
+///
+/// true only for a SAMPLE declared representation="TIME_SERIES", whose
+/// observation carries a vector of samples rather than a single value.
+///
+///
+/// The probe-declared array length when is true and the device
+/// model declared a positive sampleCount; null for a variable-length series
+/// (many agents omit sampleCount) and always null for a scalar.
+///
+public readonly record struct MTConnectInferredType(DriverDataType DataType, bool IsArray, uint? ArrayDim);
+
+///
+/// Translates an MTConnect DataItem's device-model metadata (category / type / units /
+/// representation) into the server's . Pure and deterministic —
+/// no I/O, no logging, no mutable state.
+///
+///
+///
+/// This lives in .Contracts because three call sites must agree byte-for-byte, or a
+/// tag's OPC UA type stops matching its authored config: ITagDiscovery.DiscoverAsync
+/// (stamps on every browsed leaf), the
+/// universal browser's commit path (writes the browsed leaf into TagConfig), and the
+/// AdminUI typed tag editor (shows the inferred type and offers an override).
+///
+///
+/// MTConnect is weakly typed on the wire — every observation is text, and the standard
+/// defines hundreds of type values with more added each revision. This is therefore a
+/// rule, not an enumeration, and the result is stored per tag and author-overridable.
+/// The rule is asymmetric on purpose: a wrong numeric guess produces a value that
+/// fails to parse and surfaces as Bad quality at runtime, whereas
+/// always round-trips and the author can retype it. So every default leans to
+/// String except where the standard itself guarantees a number.
+///
+///
+/// Defaulting rule, per category (design §3.3):
+///
+///
+/// -
+///
+/// SAMPLE ⇒ , including for an
+/// unrecognised or missing type. The standard defines the whole SAMPLE
+/// category as a continuously-varying measured quantity, so the category alone is
+/// the guarantee — a missing units attribute is a gap in the device model,
+/// not evidence that the observation is text. Float64 rather than an integer
+/// type because a double parse accepts both 3 and 3.5.
+///
+///
+/// -
+///
+/// EVENT ⇒ by default, with a small
+/// exception list () of standard types that are
+/// defined as integers. The overwhelming majority of EVENT types are controlled
+/// vocabulary (Execution, ControllerMode, Availability) or free
+/// text (Program, Block, Message), and the exception list is
+/// deliberately short — each entry is a coercion risk, while every omission is
+/// merely a string the author can retype.
+///
+///
+/// -
+///
+/// CONDITION ⇒ always. The observation
+/// is a state word (Normal / Warning / Fault /
+/// Unavailable), never a number, regardless of the item's type.
+///
+///
+/// -
+///
+/// An unknown, blank, or missing category ⇒ .
+/// 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.
+///
+///
+///
+///
+/// Representation overrides the category where the two disagree about shape:
+/// DATA_SET / TABLE observations are key-value maps, so they demote to
+/// even on a SAMPLE; TIME_SERIES is
+/// defined only for SAMPLE and is ignored (not treated as an array) elsewhere;
+/// 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.
+///
+///
+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";
+
+ ///
+ /// The EVENT 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.
+ /// Line is the deprecated spelling that LineNumber superseded in MTConnect 1.4;
+ /// both are mapped so a current-version agent is not silently mistyped.
+ ///
+ private static readonly FrozenSet NumericEventTypes =
+ new[] { "PartCount", "Line", "LineNumber" }.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Infers the OPC UA-facing type shape of an MTConnect DataItem from its device-model
+ /// metadata. See the type-level remarks for the full rule and its rationale.
+ ///
+ ///
+ /// The DataItem category — SAMPLE, EVENT, or CONDITION.
+ /// Case-insensitive; an unknown, blank, or null value yields
+ /// .
+ ///
+ ///
+ /// The DataItem type (e.g. Position, PartCount, Execution).
+ /// Case-insensitive; an unrecognised or null value falls back to the category default.
+ ///
+ ///
+ /// The declared engineering units, if any. Accepted for signature stability and because every
+ /// call site already holds it, but not load-bearing in v1: SAMPLE is numeric by
+ /// definition of the category, so units add no discriminating power there, and treating a
+ /// units-bearing EVENT as numeric would be exactly the risky coercion this rule avoids.
+ ///
+ ///
+ /// The DataItem representation — VALUE (default), TIME_SERIES,
+ /// DATA_SET, TABLE, or DISCRETE. Case-insensitive.
+ ///
+ ///
+ /// The probe-declared sampleCount for a TIME_SERIES item. Flows into
+ /// when positive; null or a non-positive
+ /// value yields a variable-length array (ArrayDim = null). Ignored for scalars.
+ ///
+ /// The inferred scalar type plus array shape.
+ 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 value, string expected)
+ => value.Equals(expected, StringComparison.OrdinalIgnoreCase);
+}
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
new file mode 100644
index 00000000..1a82d954
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataTypeInferenceTests.cs
@@ -0,0 +1,250 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Golden table for (design §3.3).
+/// Three call sites must agree byte-for-byte on this mapping — ITagDiscovery.DiscoverAsync,
+/// 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.
+///
+[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);
+ }
+}