Merge b626-mtconnect (batch #626+)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:43:45 -04:00
12 changed files with 373 additions and 54 deletions
@@ -158,11 +158,22 @@ public sealed record MTConnectStreamsResult(
/// <c>false</c>, the shape of every ordinary scalar observation.
/// </para>
/// </param>
/// <param name="SampleCount">
/// The observation's own <c>sampleCount</c> attribute — the number of values a <c>TIME_SERIES</c>
/// vector claims to carry (#627). The index checks the parsed vector against it. <c>null</c> when absent.
/// </param>
/// <param name="SampleRate">
/// The observation's own <c>sampleRate</c> attribute (samples per second) on a <c>TIME_SERIES</c>
/// observation. Carried for the parser contract; not yet surfaced on the served node (see
/// docs/drivers/MTConnect.md). <c>null</c> when absent.
/// </param>
public sealed record MTConnectObservation(
string DataItemId,
string Value,
DateTime TimestampUtc,
bool IsStructured = false);
bool IsStructured = false,
int? SampleCount = null,
double? SampleRate = null);
/// <summary>
/// Recursive helpers over the <see cref="IMTConnectComponentContainer"/> device/component tree.
@@ -49,8 +49,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// </para>
/// <para>
/// <b>Shapes this build does not represent</b> are coded <c>BadNotSupported</c> with a null
/// value rather than approximated: a <c>TIME_SERIES</c> vector (array materialization is
/// deferred to P1.5) and a <c>DATA_SET</c> / <c>TABLE</c> observation. The latter carries its
/// value rather than approximated: a non-Float64 array tag and a <c>DATA_SET</c> / <c>TABLE</c>
/// observation (a Float64 <c>TIME_SERIES</c> vector is served as <c>double[]</c>, #627). The latter carries its
/// real content in <c>&lt;Entry key=…&gt;</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
@@ -85,9 +85,12 @@ internal sealed class MTConnectObservationIndex
/// <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 (TIME_SERIES vectors).</summary>
/// <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;
@@ -268,13 +271,11 @@ internal sealed class MTConnectObservationIndex
_tags.TryGetValue(observation.DataItemId, out var tag);
// TIME_SERIES vectors arrive as one space-separated string. P1 does not materialize OPC UA
// arrays (deferred to P1.5), and the honest answer is "real data I cannot represent" — not a
// type mismatch, and emphatically not the first element parsed out and served as Good.
// Checked AFTER the sentinel so an unavailable array tag still reports the truthful no-comms.
// 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 new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
return BuildArraySnapshot(observation, tag, sourceTimestamp, serverTimestamp);
}
// An unauthored observation has no declared target type; String is the only coercion that
@@ -286,6 +287,40 @@ internal sealed class MTConnectObservationIndex
: 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
@@ -377,6 +377,8 @@ internal static class MTConnectStreamsParser
string? dataItemId = null;
var timestampUtc = default(DateTime);
string? conditionState = null;
int? sampleCount = null;
double? sampleRate = null;
if (isObservation)
{
@@ -385,6 +387,24 @@ internal static class MTConnectStreamsParser
timestampUtc = ReadTimestampUtc(reader, dataItemId);
conditionState = isCondition ? ConditionState(name) : null;
// TIME_SERIES metadata rides the observation element itself. Read here, before the subtree
// walk moves the reader off this element's attributes. Lenient: a vendor's unparseable
// attribute is dropped rather than failing the whole document (the vector is checked
// against the declared sampleCount only when one parses).
if (!isCondition)
{
sampleCount = int.TryParse(
MTConnectXml.OptionalAttribute(reader, "sampleCount"),
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out var count) && count >= 0 ? count : null;
sampleRate = double.TryParse(
MTConnectXml.OptionalAttribute(reader, "sampleRate"),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var rate) && double.IsFinite(rate) ? rate : null;
}
// The slot is reserved BEFORE the subtree is read, and filled after: an observation is
// only complete once its text and its child-element flag are known, but appending it
// then would report a nested observation ahead of the one enclosing it. The tree walk
@@ -430,7 +450,9 @@ internal static class MTConnectStreamsParser
// MTConnectObservation.IsStructured. Never true for a CONDITION: its value comes from
// the element NAME, so child content cannot corrupt it, and flagging one would throw
// away a perfectly well-determined state.
IsStructured: !isCondition && hasElements);
IsStructured: !isCondition && hasElements,
SampleCount: sampleCount,
SampleRate: sampleRate);
}
/// <summary>