9631a40f1f
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
617 lines
29 KiB
C#
617 lines
29 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Collections.Frozen;
|
|
using System.Globalization;
|
|
using System.Text.Encodings.Web;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
|
|
|
/// <summary>
|
|
/// The driver's live <c>dataItemId → <see cref="DataValueSnapshot"/></c> map: the single place
|
|
/// an Agent observation's weakly-typed text becomes an OPC UA-shaped value + quality +
|
|
/// timestamp. Written by the <c>/current</c> baseline read and by the <c>/sample</c> long-poll
|
|
/// pump; read by <c>IReadable.ReadAsync</c> and the subscription fan-out.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Quality mapping (design §3.3).</b> MTConnect has exactly one way to say "I have no
|
|
/// value": the literal token <c>UNAVAILABLE</c>. It arrives as a Sample/Event's text, and —
|
|
/// after <see cref="MTConnectStreamsParser"/> normalizes the <c><Unavailable/></c>
|
|
/// element name — as a CONDITION's value on that same exact spelling. It maps to
|
|
/// <c>BadNoCommunication</c> with a <c>null</c> value: the Agent is reachable, the machine's
|
|
/// data item is not. The match is <b>ordinal and case-sensitive</b> against the one spelling
|
|
/// the parser guarantees; a case-insensitive match would swallow a legitimate free-text
|
|
/// EVENT whose value happens to read "Unavailable".
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Failure posture.</b> This type sits under <c>IReadable</c> and must never throw across
|
|
/// that boundary on account of observation content. Every unusable observation degrades to a
|
|
/// Bad-coded snapshot that still carries the Agent's source timestamp, so an operator can
|
|
/// always see <i>when</i> the driver last heard about the tag even when it cannot show
|
|
/// <i>what</i>. The only exceptions raised are <see cref="ArgumentNullException"/> for a null
|
|
/// argument — a programming error, not data.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Status codes</b> are the canonical <c>Opc.Ua.StatusCodes</c> numeric values, declared
|
|
/// once each below. Note that <see cref="BadTypeMismatch"/> is <c>0x80740000</c>: the
|
|
/// <c>0x80730000</c> that four sibling drivers declare under that name is really
|
|
/// <c>BadWriteNotSupported</c>, which would be actively misleading on a read path and
|
|
/// renders as bare "Bad" in the driver CLI's <c>SnapshotFormatter</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Thread safety.</b> Backed by a <see cref="ConcurrentDictionary{TKey,TValue}"/> and
|
|
/// guaranteed <b>per-key atomic</b> — nothing more, deliberately. <see cref="Apply"/> writes
|
|
/// each dataItemId exactly once per call with a single whole-snapshot assignment, so a
|
|
/// reader can never observe a torn snapshot (a Good code beside a stale value). It is
|
|
/// explicitly <b>not</b> a whole-batch atomic swap: OPC UA reads are per-tag and the driver
|
|
/// offers no cross-tag consistency contract, <c>/sample</c> chunks are deltas whose
|
|
/// interleaving is indistinguishable from two adjacent legal chunks, and a whole-index lock
|
|
/// would serialize the pump against every read for no semantic gain.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Shapes this build does not represent</b> are coded <c>BadNotSupported</c> with a null
|
|
/// value rather than approximated: a non-Float64 array tag and a <c>DATA_SET</c> / <c>TABLE</c>
|
|
/// observation whose children are not <c><Entry></c> elements (a Float64 <c>TIME_SERIES</c> vector is served as <c>double[]</c>, #627). The latter carries its
|
|
/// real content in <c><Entry key=…></c> child elements, which reach a text-valued
|
|
/// observation concatenated with the keys discarded — a two-entry set reads as the single
|
|
/// token <c>"12"</c> — and because the type inference correctly demotes those
|
|
/// representations to <see cref="DriverDataType.String"/>, coercion would otherwise
|
|
/// "succeed" into a Good-coded snapshot carrying noise. This index cannot detect that shape
|
|
/// itself (no value heuristic can separate concatenated entries from a legitimate
|
|
/// space-bearing EVENT such as a <c>Message</c> reading "Coolant level low"), so it relies on
|
|
/// <see cref="MTConnectObservation.IsStructured"/>, which
|
|
/// <see cref="MTConnectStreamsParser"/> sets while the XML is still XML.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class MTConnectObservationIndex
|
|
{
|
|
/// <summary>
|
|
/// The one token that means "the Agent has no value for this data item". Matched ordinally:
|
|
/// the parser already normalizes a CONDITION's <c><Unavailable/></c> element name onto
|
|
/// this exact spelling, so every category lands on one comparison.
|
|
/// </summary>
|
|
private const string UnavailableSentinel = "UNAVAILABLE";
|
|
|
|
private const uint Good = 0x00000000u;
|
|
|
|
/// <summary>The Agent reported <c>UNAVAILABLE</c>, or supplied no text at all.</summary>
|
|
private const uint BadNoCommunication = 0x80310000u;
|
|
|
|
/// <summary>The tag is authored but the Agent has not yet reported it.</summary>
|
|
private const uint BadWaitingForInitialData = 0x80320000u;
|
|
|
|
/// <summary>The dataItemId is neither authored nor ever observed.</summary>
|
|
private const uint BadNodeIdUnknown = 0x80340000u;
|
|
|
|
/// <summary>The Agent reported a number the authored type cannot represent.</summary>
|
|
private const uint BadOutOfRange = 0x803C0000u;
|
|
|
|
/// <summary>The observation's shape is real data this build cannot represent (a DATA_SET/TABLE, or a non-Float64 array tag).</summary>
|
|
private const uint BadNotSupported = 0x803D0000u;
|
|
|
|
/// <summary>A TIME_SERIES vector whose element count disagrees with its declared <c>sampleCount</c>.</summary>
|
|
private const uint BadDecodingError = 0x80070000u;
|
|
|
|
/// <summary>The Agent reported text that is not a value of the authored type at all.</summary>
|
|
private const uint BadTypeMismatch = 0x80740000u;
|
|
|
|
/// <summary>
|
|
/// MTConnect's CONDITION state vocabulary, ranked by severity. Used only to reconcile
|
|
/// several states reported for one dataItemId <i>within a single document</i> — see
|
|
/// <see cref="Reconcile"/>. An active <c>Fault</c> outranks <c>UNAVAILABLE</c> because a
|
|
/// fault is information and an absent value is not. Case-insensitive so a vendor's casing
|
|
/// of a state element still ranks; the parser itself emits the canonical spellings.
|
|
/// </summary>
|
|
private static readonly FrozenDictionary<string, int> ConditionSeverity =
|
|
new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
[UnavailableSentinel] = 0,
|
|
["Normal"] = 1,
|
|
["Warning"] = 2,
|
|
["Fault"] = 3,
|
|
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
|
|
|
private readonly ConcurrentDictionary<string, DataValueSnapshot> _snapshots = new(StringComparer.Ordinal);
|
|
|
|
// #650: the accumulated DATA_SET / TABLE content per dataItemId. A /sample chunk carries only the entries
|
|
// that changed, so the served JSON is the merge of the baseline and every delta since. Row value is the
|
|
// entry's text (DATA_SET) or its cell map (TABLE). Only the single pump (or a one-shot snapshot) calls
|
|
// Apply, but the lock keeps a concurrent one-shot read-through from corrupting a shared index.
|
|
private readonly Dictionary<string, SortedDictionary<string, SetEntry>> _sets = new(StringComparer.Ordinal);
|
|
private readonly object _setsLock = new();
|
|
private bool _anySets;
|
|
|
|
private sealed record SetEntry(string? Value, SortedDictionary<string, string>? Cells);
|
|
|
|
private static readonly Regex JsonNumber = new(
|
|
@"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
|
private readonly FrozenDictionary<string, MTConnectTagDefinition> _tags;
|
|
private readonly TimeProvider _timeProvider;
|
|
|
|
/// <summary>
|
|
/// Builds an index over the driver instance's authored tags.
|
|
/// </summary>
|
|
/// <param name="tags">
|
|
/// The authored tags, keyed by <see cref="MTConnectTagDefinition.FullName"/> (the DataItem
|
|
/// <c>id</c>) — normally <see cref="MTConnectDriverOptions.Tags"/>. <c>null</c> or empty is
|
|
/// legal and means "no authored types": every observation is then indexed as its raw text
|
|
/// under <see cref="DriverDataType.String"/>.
|
|
/// <para>
|
|
/// Nothing enforces <c>FullName</c> uniqueness at the options layer, so a duplicate is
|
|
/// operator-authorable. <b>The last definition wins</b> rather than throwing: refusing to
|
|
/// construct would turn an authoring slip into a driver that will not start, whereas
|
|
/// last-wins is deterministic and costs only that one tag's coercion type.
|
|
/// </para>
|
|
/// </param>
|
|
/// <param name="timeProvider">Clock behind <c>ServerTimestampUtc</c>; defaults to <see cref="TimeProvider.System"/>.</param>
|
|
public MTConnectObservationIndex(
|
|
IEnumerable<MTConnectTagDefinition>? tags = null,
|
|
TimeProvider? timeProvider = null)
|
|
{
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
|
|
var map = new Dictionary<string, MTConnectTagDefinition>(StringComparer.Ordinal);
|
|
foreach (var tag in tags ?? [])
|
|
{
|
|
if (tag is null || string.IsNullOrWhiteSpace(tag.FullName)) continue;
|
|
|
|
map[tag.FullName] = tag;
|
|
}
|
|
|
|
_tags = map.ToFrozenDictionary(StringComparer.Ordinal);
|
|
}
|
|
|
|
/// <summary>The number of distinct dataItemIds currently indexed.</summary>
|
|
public int Count => _snapshots.Count;
|
|
|
|
/// <summary>
|
|
/// Folds a <c>/current</c> snapshot or one <c>/sample</c> chunk into the index. Observations
|
|
/// with no authored tag are indexed too — the Agent streams the whole device, so they are
|
|
/// normal, and dropping them would make a streaming-but-not-yet-authored data item
|
|
/// indistinguishable from one that does not exist.
|
|
/// </summary>
|
|
/// <param name="result">The parsed streams document. An observation-free result is a legal
|
|
/// keep-alive and leaves the index untouched.</param>
|
|
/// <exception cref="ArgumentNullException"><paramref name="result"/> is <c>null</c>.</exception>
|
|
/// <param name="baseline">
|
|
/// <c>true</c> for a <c>/current</c> document, which states the <b>whole</b> DATA_SET / TABLE and so replaces
|
|
/// the accumulated set; <c>false</c> (default) for a <c>/sample</c> chunk, whose entries are deltas merged into it.
|
|
/// </param>
|
|
public void Apply(MTConnectStreamsResult result, bool baseline = false)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(result);
|
|
|
|
if (result.Observations is not { Count: > 0 }) return;
|
|
|
|
// Duplicates are reconciled on the RAW observations first, so each dataItemId is written
|
|
// exactly once below as a single atomic whole-snapshot assignment. Doing it the other way
|
|
// — writing every observation and merging snapshots in place — would need a read-modify-write
|
|
// per key and could expose a half-merged state to a concurrent reader.
|
|
var resolved = new Dictionary<string, MTConnectObservation>(StringComparer.Ordinal);
|
|
var seenSets = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (var raw in result.Observations)
|
|
{
|
|
if (raw is null || string.IsNullOrWhiteSpace(raw.DataItemId)) continue;
|
|
|
|
var observation = FoldDataSet(raw, baseline, seenSets);
|
|
|
|
resolved[observation.DataItemId] =
|
|
resolved.TryGetValue(observation.DataItemId, out var incumbent)
|
|
? Reconcile(incumbent, observation)
|
|
: observation;
|
|
}
|
|
|
|
foreach (var (dataItemId, observation) in resolved)
|
|
{
|
|
_snapshots[dataItemId] = BuildSnapshot(observation);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the current snapshot for a DataItem. Never <c>null</c> and never throws: an
|
|
/// unknown or not-yet-reported id yields a Bad-coded snapshot rather than an error.
|
|
/// </summary>
|
|
/// <param name="dataItemId">The DataItem <c>id</c> the tag binds.</param>
|
|
/// <returns>
|
|
/// The indexed snapshot; else <c>BadWaitingForInitialData</c> when the id is authored but
|
|
/// the Agent has not reported it yet (the standard OPC UA "subscribed, no value yet"
|
|
/// state); else <c>BadNodeIdUnknown</c>. The two are kept distinct because collapsing them
|
|
/// would tell an operator their correctly-authored tag does not exist.
|
|
/// </returns>
|
|
public DataValueSnapshot Get(string dataItemId)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(dataItemId) &&
|
|
_snapshots.TryGetValue(dataItemId, out var snapshot))
|
|
{
|
|
return snapshot;
|
|
}
|
|
|
|
var status = !string.IsNullOrWhiteSpace(dataItemId) && _tags.ContainsKey(dataItemId)
|
|
? BadWaitingForInitialData
|
|
: BadNodeIdUnknown;
|
|
|
|
return new DataValueSnapshot(null, status, null, Now);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drops every indexed observation. The Agent-restart re-baseline: when the streams header's
|
|
/// <c>instanceId</c> changes, every value the index holds describes a device model that no
|
|
/// longer exists, and serving it would be worse than serving nothing.
|
|
/// </summary>
|
|
public void Clear()
|
|
{
|
|
_snapshots.Clear();
|
|
lock (_setsLock) _sets.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// DATA_SET / TABLE (#650). Folds an observation's <c><Entry></c> children into the accumulated set for
|
|
/// its dataItemId and returns a plain String observation carrying the merged JSON; any other observation is
|
|
/// returned untouched, except that an <c>UNAVAILABLE</c> one drops the accumulated set (a later delta must not
|
|
/// resurrect pre-outage entries).
|
|
/// <para>
|
|
/// Semantics (MTConnect 2.x): a <c>/current</c> document (<paramref name="baseline"/>) or an observation
|
|
/// with <c>resetTriggered</c> <b>replaces</b> the set; otherwise entries merge by key. <c>removed="true"</c>
|
|
/// deletes the key (the key is absent from the JSON, never <c>null</c>). A TABLE row is replaced as a unit
|
|
/// by a later entry for the same row key; a removed row key disappears. Several observations of one item in
|
|
/// a single document fold in document order — only the first replaces on a baseline.
|
|
/// </para>
|
|
/// </summary>
|
|
private MTConnectObservation FoldDataSet(MTConnectObservation observation, bool baseline, HashSet<string> seenSets)
|
|
{
|
|
var id = observation.DataItemId;
|
|
|
|
if (observation.Entries is not { Count: > 0 } entries)
|
|
{
|
|
var unavailable = string.IsNullOrWhiteSpace(observation.Value) && !observation.IsDataSet ||
|
|
string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal);
|
|
|
|
if (unavailable)
|
|
{
|
|
// Hot path for every ordinary UNAVAILABLE observation: skip the lock until a set exists at all.
|
|
if (Volatile.Read(ref _anySets)) lock (_setsLock) _sets.Remove(id);
|
|
|
|
return observation;
|
|
}
|
|
|
|
if (observation.IsDataSet && !observation.IsStructured)
|
|
{
|
|
// An entry-less data-set-shaped observation (count="0", or resetTriggered) is the EMPTY set - a
|
|
// legitimate value, Good "{}", not an absence. A reset or a /current baseline discards what
|
|
// was accumulated; a bare empty delta leaves the set as it is.
|
|
string emptyJson;
|
|
lock (_setsLock)
|
|
{
|
|
var first = seenSets.Add(id);
|
|
if (!_sets.TryGetValue(id, out var set) || observation.ResetTriggered || (baseline && first))
|
|
{
|
|
set = new SortedDictionary<string, SetEntry>(StringComparer.Ordinal);
|
|
_sets[id] = set;
|
|
Volatile.Write(ref _anySets, true);
|
|
}
|
|
|
|
emptyJson = RenderSet(set);
|
|
}
|
|
|
|
return observation with { Value = emptyJson, ResetTriggered = false, IsDataSet = false };
|
|
}
|
|
|
|
return observation;
|
|
}
|
|
|
|
string json;
|
|
lock (_setsLock)
|
|
{
|
|
var first = seenSets.Add(id);
|
|
if (!_sets.TryGetValue(id, out var set) || observation.ResetTriggered || (baseline && first))
|
|
{
|
|
set = new SortedDictionary<string, SetEntry>(StringComparer.Ordinal);
|
|
_sets[id] = set;
|
|
Volatile.Write(ref _anySets, true);
|
|
}
|
|
|
|
foreach (var entry in entries)
|
|
{
|
|
if (entry.Removed)
|
|
{
|
|
set.Remove(entry.Key);
|
|
}
|
|
else if (entry.Cells is not null)
|
|
{
|
|
var cells = new SortedDictionary<string, string>(StringComparer.Ordinal);
|
|
foreach (var cell in entry.Cells) cells[cell.Key] = cell.Value;
|
|
set[entry.Key] = new SetEntry(null, cells);
|
|
}
|
|
else
|
|
{
|
|
set[entry.Key] = new SetEntry(entry.Value ?? string.Empty, null);
|
|
}
|
|
}
|
|
|
|
json = RenderSet(set);
|
|
}
|
|
|
|
return observation with { Value = json, IsStructured = false, Entries = null, ResetTriggered = false, IsDataSet = false };
|
|
}
|
|
|
|
/// <summary>
|
|
/// Compact, deterministic JSON: keys ordinal-sorted, no whitespace. Values that are strict JSON numbers are
|
|
/// emitted as numbers with their original token (lossless — <c>007</c> and <c>+5</c> stay strings); everything
|
|
/// else is a string.
|
|
/// </summary>
|
|
private static string RenderSet(SortedDictionary<string, SetEntry> set)
|
|
{
|
|
using var stream = new MemoryStream();
|
|
using (var w = new Utf8JsonWriter(stream, new JsonWriterOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }))
|
|
{
|
|
w.WriteStartObject();
|
|
foreach (var (key, entry) in set)
|
|
{
|
|
w.WritePropertyName(key);
|
|
if (entry.Cells is null)
|
|
{
|
|
WriteScalar(w, entry.Value ?? string.Empty);
|
|
continue;
|
|
}
|
|
|
|
w.WriteStartObject();
|
|
foreach (var (cellKey, cellValue) in entry.Cells)
|
|
{
|
|
w.WritePropertyName(cellKey);
|
|
WriteScalar(w, cellValue);
|
|
}
|
|
|
|
w.WriteEndObject();
|
|
}
|
|
|
|
w.WriteEndObject();
|
|
}
|
|
|
|
return System.Text.Encoding.UTF8.GetString(stream.ToArray());
|
|
}
|
|
|
|
private static void WriteScalar(Utf8JsonWriter w, string text)
|
|
{
|
|
if (JsonNumber.IsMatch(text) &&
|
|
double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) && double.IsFinite(d))
|
|
{
|
|
w.WriteRawValue(text);
|
|
}
|
|
else
|
|
{
|
|
w.WriteStringValue(text);
|
|
}
|
|
}
|
|
|
|
private DateTime Now => _timeProvider.GetUtcNow().UtcDateTime;
|
|
|
|
/// <summary>
|
|
/// Picks the winner when one dataItemId appears more than once in a single document. A
|
|
/// <c><Condition></c> container legitimately carries several <b>simultaneously active</b>
|
|
/// states (a Fault and a Warning at once), so for a pair of recognized condition state words
|
|
/// the <b>most severe</b> wins — plain last-wins would under-report an active Fault as a
|
|
/// Warning purely because of element order. Anything else is last-wins, matching the Agent's
|
|
/// ascending-sequence ordering.
|
|
/// <para>
|
|
/// Deliberately scoped to one document: a later <see cref="Apply"/> is the Agent's new
|
|
/// statement of the state, so a cleared fault must fall back to Normal. A worst-of that
|
|
/// spanned Applies would latch every fault forever.
|
|
/// </para>
|
|
/// </summary>
|
|
private static MTConnectObservation Reconcile(MTConnectObservation incumbent, MTConnectObservation challenger)
|
|
{
|
|
if (ConditionSeverity.TryGetValue(incumbent.Value ?? string.Empty, out var incumbentSeverity) &&
|
|
ConditionSeverity.TryGetValue(challenger.Value ?? string.Empty, out var challengerSeverity))
|
|
{
|
|
return challengerSeverity >= incumbentSeverity ? challenger : incumbent;
|
|
}
|
|
|
|
return challenger;
|
|
}
|
|
|
|
private DataValueSnapshot BuildSnapshot(MTConnectObservation observation)
|
|
{
|
|
var serverTimestamp = Now;
|
|
var sourceTimestamp = DateTime.SpecifyKind(observation.TimestampUtc, DateTimeKind.Utc);
|
|
|
|
// No text at all is the degenerate spelling of "no value": MTConnect defines UNAVAILABLE as
|
|
// the only way to report absence, so an empty element is not an empty string value.
|
|
if (string.IsNullOrWhiteSpace(observation.Value) ||
|
|
string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal))
|
|
{
|
|
return new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp);
|
|
}
|
|
|
|
// DATA_SET / TABLE: the Agent carried the content in <Entry key="…"> child elements, so
|
|
// Value is their concatenated text with the keys discarded (a two-entry set reads "12").
|
|
// Publishing that as a Good string would be noise an operator would trust. Checked before
|
|
// the tag lookup because it is a fact about the wire shape, true whether or not the data
|
|
// item is authored — and after the sentinel, so an unavailable data set still reports the
|
|
// truthful no-comms.
|
|
if (observation.IsStructured)
|
|
{
|
|
return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
|
|
}
|
|
|
|
_tags.TryGetValue(observation.DataItemId, out var tag);
|
|
|
|
// TIME_SERIES vectors arrive as one whitespace-separated string (#627). Checked AFTER the
|
|
// sentinel so an unavailable array tag still reports the truthful no-comms.
|
|
if (tag is { IsArray: true })
|
|
{
|
|
return BuildArraySnapshot(observation, tag, sourceTimestamp, serverTimestamp);
|
|
}
|
|
|
|
// An unauthored observation has no declared target type; String is the only coercion that
|
|
// cannot fail, and it carries the Agent's text with no interpretation applied.
|
|
var status = Coerce(observation.Value, tag?.DriverDataType ?? DriverDataType.String, out var value);
|
|
|
|
return status == Good
|
|
? new DataValueSnapshot(value, Good, sourceTimestamp, serverTimestamp)
|
|
: new DataValueSnapshot(null, status, sourceTimestamp, serverTimestamp);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Parses a <c>TIME_SERIES</c> vector into a <c>double[]</c>. Only <see cref="DriverDataType.Float64"/>
|
|
/// arrays are served (the MTConnect TIME_SERIES element type is a floating-point sample); any other
|
|
/// authored array type is <c>BadNotSupported</c> rather than an approximation. All-or-nothing: one
|
|
/// unparseable or non-finite token, or a count that disagrees with the observation's own
|
|
/// <c>sampleCount</c> (or the tag's fixed <c>ArrayDim</c>), makes the whole vector Bad — a partial
|
|
/// vector served as Good would be a silently wrong waveform.
|
|
/// </summary>
|
|
private static DataValueSnapshot BuildArraySnapshot(
|
|
MTConnectObservation observation, MTConnectTagDefinition tag, DateTime sourceTimestamp, DateTime serverTimestamp)
|
|
{
|
|
DataValueSnapshot Bad(uint status) => new(null, status, sourceTimestamp, serverTimestamp);
|
|
|
|
if (tag.DriverDataType != DriverDataType.Float64) return Bad(BadNotSupported);
|
|
|
|
var tokens = observation.Value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
|
var values = new double[tokens.Length];
|
|
for (var i = 0; i < tokens.Length; i++)
|
|
{
|
|
if (string.Equals(tokens[i], UnavailableSentinel, StringComparison.Ordinal)) return Bad(BadNoCommunication);
|
|
|
|
if (!double.TryParse(tokens[i], NumberStyles.Float, CultureInfo.InvariantCulture, out values[i]) ||
|
|
!double.IsFinite(values[i]))
|
|
{
|
|
return Bad(BadTypeMismatch);
|
|
}
|
|
}
|
|
|
|
if (observation.SampleCount is { } declared && declared != values.Length) return Bad(BadDecodingError);
|
|
if (tag.ArrayDim > 0 && tag.ArrayDim != values.Length) return Bad(BadDecodingError);
|
|
|
|
return new DataValueSnapshot(values, Good, sourceTimestamp, serverTimestamp);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts the Agent's text to the tag's authored type. Every parse is
|
|
/// <see cref="CultureInfo.InvariantCulture"/>: MTConnect is an invariant-format wire, and a
|
|
/// host running a comma-decimal culture would otherwise read <c>123.4567</c> as
|
|
/// <c>1234567</c> — a silently wrong value under Good quality, the worst possible outcome.
|
|
/// </summary>
|
|
/// <returns><see cref="Good"/> on success; otherwise a Bad code and a <c>null</c> value.</returns>
|
|
private static uint Coerce(string raw, DriverDataType type, out object? value)
|
|
{
|
|
value = null;
|
|
var text = raw.Trim();
|
|
|
|
switch (type)
|
|
{
|
|
// Reference is a Galaxy-style attribute reference encoded as an OPC UA String. It is
|
|
// never a sensible authoring for MTConnect, but degrading it to text is harmless where
|
|
// rejecting it would break a tag for no protection.
|
|
case DriverDataType.String:
|
|
case DriverDataType.Reference:
|
|
value = raw;
|
|
return Good;
|
|
|
|
case DriverDataType.Boolean:
|
|
if (bool.TryParse(text, out var flag)) { value = flag; return Good; }
|
|
if (text is "1") { value = true; return Good; }
|
|
if (text is "0") { value = false; return Good; }
|
|
return BadTypeMismatch;
|
|
|
|
case DriverDataType.DateTime:
|
|
if (DateTime.TryParse(
|
|
text,
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
|
|
out var moment))
|
|
{
|
|
value = DateTime.SpecifyKind(moment, DateTimeKind.Utc);
|
|
return Good;
|
|
}
|
|
|
|
return BadTypeMismatch;
|
|
|
|
case DriverDataType.Float32:
|
|
if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var single))
|
|
{
|
|
value = single;
|
|
return Good;
|
|
}
|
|
|
|
return BadTypeMismatch;
|
|
|
|
case DriverDataType.Float64:
|
|
if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var real))
|
|
{
|
|
value = real;
|
|
return Good;
|
|
}
|
|
|
|
return BadTypeMismatch;
|
|
|
|
case DriverDataType.Int16:
|
|
if (short.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i16))
|
|
{
|
|
value = i16;
|
|
return Good;
|
|
}
|
|
|
|
break;
|
|
|
|
case DriverDataType.Int32:
|
|
if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i32))
|
|
{
|
|
value = i32;
|
|
return Good;
|
|
}
|
|
|
|
break;
|
|
|
|
case DriverDataType.Int64:
|
|
if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i64))
|
|
{
|
|
value = i64;
|
|
return Good;
|
|
}
|
|
|
|
break;
|
|
|
|
case DriverDataType.UInt16:
|
|
if (ushort.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u16))
|
|
{
|
|
value = u16;
|
|
return Good;
|
|
}
|
|
|
|
break;
|
|
|
|
case DriverDataType.UInt32:
|
|
if (uint.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u32))
|
|
{
|
|
value = u32;
|
|
return Good;
|
|
}
|
|
|
|
break;
|
|
|
|
case DriverDataType.UInt64:
|
|
if (ulong.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u64))
|
|
{
|
|
value = u64;
|
|
return Good;
|
|
}
|
|
|
|
break;
|
|
|
|
default:
|
|
// A DriverDataType this driver has no rule for. Text always round-trips, so serving
|
|
// it beats failing a tag over an enum member added later.
|
|
value = raw;
|
|
return Good;
|
|
}
|
|
|
|
// Integer parse failed. Distinguishing the two causes is worth one extra parse: it tells an
|
|
// operator whether to retype the tag or widen it.
|
|
return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _)
|
|
? BadOutOfRange // a number the authored type cannot represent (overflow, or a fraction)
|
|
: BadTypeMismatch; // not a number at all
|
|
}
|
|
}
|