feat(mtconnect): pure data-type inference table + golden test (Task 3)
This commit is contained in:
+192
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user