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); + } +}