Merge b647-mtc-dataset: MTConnect DATA_SET/TABLE as JSON strings; EVENT enumerations parked (#650)

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 19:21:34 -04:00
12 changed files with 647 additions and 39 deletions
@@ -27,7 +27,8 @@
EVENT, controlled vocabulary -> String fixture_execution, fixture_avail, fixture_mode
EVENT, free text -> String fixture_program, fixture_block (never fed),
fixture_writeback (never fed; write target)
EVENT, DATA_SET representation -> String fixture_varset
EVENT, DATA_SET representation -> String fixture_varset (JSON object, #650)
EVENT, TABLE representation -> String fixture_worktable (JSON object of objects, #650)
CONDITION (any type) -> String + IsAlarm fixture_x_travel, fixture_logic
fixture_block is deliberately NEVER fed by the adapter, so the Agent reports it
@@ -113,8 +114,10 @@
legal value, and never fed by the adapter, so nothing races the assertion.
-->
<DataItem category="EVENT" id="fixture_writeback" name="Pwriteback" type="PROGRAM_COMMENT"/>
<!-- DATA_SET representation demotes to String even though the Agent sends key=value entries. -->
<!-- DATA_SET representation is served as a String carrying JSON (#650); the Agent sends key=value entries. -->
<DataItem category="EVENT" id="fixture_varset" name="Pvars" type="VARIABLE" representation="DATA_SET"/>
<!-- TABLE representation (#650): rows of cells, served as nested JSON. -->
<DataItem category="EVENT" id="fixture_worktable" name="Ptab" type="WORK_OFFSET" representation="TABLE"/>
<DataItem category="CONDITION" id="fixture_logic" name="Plogic" type="LOGIC_PROGRAM"/>
</DataItems>
</Path>
@@ -83,7 +83,8 @@ observation correlation key is visible.
| `fixture_linenumber` | `Pline` | EVENT | LINE_NUMBER | | Int64 |
| `fixture_program` | `Pprogram` | EVENT | PROGRAM | | String |
| `fixture_block` | `Pblock` | EVENT | BLOCK | | String — **never fed ⇒ UNAVAILABLE** |
| `fixture_varset` | `Pvars` | EVENT | VARIABLE | DATA_SET | String (structured ⇒ BadNotSupported) |
| `fixture_varset` | `Pvars` | EVENT | VARIABLE | DATA_SET | String — JSON object (#650); `toolNumber`/`offsetX`/`offsetZ` stable, `cycle` moves every ~5 s |
| `fixture_worktable` | `Ptab` | EVENT | WORK_OFFSET | TABLE | String — JSON object of objects (#650); `G54`/`G55` stable, `G56` row moves every ~5 s |
| `fixture_logic` | `Plogic` | CONDITION | LOGIC_PROGRAM | | String + IsAlarm |
| `fixture_asset_changed` / `fixture_asset_removed` | — | EVENT | ASSET_CHANGED / ASSET_REMOVED | | String |
@@ -89,9 +89,12 @@ class Feed:
[
("Favail", "AVAILABLE"),
("Pprogram", "OTOPCUA-FIXTURE.NC"),
# DATA_SET representation: the Agent renders these as <Entry key=...> children,
# which is precisely the wire shape the driver demotes to String / BadNotSupported.
("Pvars", "toolNumber=7 offsetX=1.25 offsetZ=-0.5"),
# DATA_SET / TABLE representations: the Agent renders these as <Entry key=...>
# children, which the driver serves as a JSON String (#650). The three Pvars keys
# are STABLE for the life of the fixture; the periodic feed only touches `cycle`
# (Pvars) and the G56 row (Ptab).
("Pvars", "toolNumber=7 offsetX=1.25 offsetZ=-0.5 cycle=0"),
("Ptab", "G54={X=1.0 Y=2.0} G55={X=3.0} G56={X=0 Z=0.5}"),
]
)
self.send_conditions()
@@ -142,6 +145,17 @@ class Feed:
[("Cmode", CONTROLLER_MODES[(self._ticks // 44) % len(CONTROLLER_MODES)])]
)
# DATA_SET / TABLE churn (#650): every ~5 s change one key / one row so a live gate can
# observe a /sample delta merge. The other keys / rows never change.
if self._ticks % 20 == 0:
n = self._ticks // 20
self.send_observations(
[
("Pvars", f"cycle={n}"),
("Ptab", f"G56={{X={n} Z=0.5}}"),
]
)
# Re-assert conditions occasionally so a late-attaching driver still sees them.
if self._ticks % 60 == 0:
self.send_conditions()
@@ -448,6 +448,40 @@ public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
results.ShouldAllBe(r => r.Value == null);
}
/// <summary>
/// #650: the fixture's DATA_SET (<c>fixture_varset</c>) and TABLE (<c>fixture_worktable</c>) read as Good
/// Strings carrying JSON. Only the keys the adapter never mutates are asserted exactly; the periodic
/// <c>cycle</c> key / <c>G56</c> row are asserted present, because they move every ~5 s.
/// </summary>
[Fact(Timeout = TestTimeoutMs)]
public async Task Read_serves_data_set_and_table_as_json_strings()
{
SkipIfDown();
await using var live = await LiveDriverAsync(
[Tag("fixture_varset", DriverDataType.String), Tag("fixture_worktable", DriverDataType.String)]);
var results = await live.Driver.ReadAsync(["fixture_varset", "fixture_worktable"], Ct);
if (results.Any(r => r.StatusCode == BadNoCommunication))
{
Assert.Skip("The fixture's adapter is not feeding Pvars/Ptab (UNAVAILABLE). Is the adapter service running?");
}
results.ShouldAllBe(r => r.StatusCode == Good);
using var set = JsonDocument.Parse(results[0].Value.ShouldBeOfType<string>());
set.RootElement.GetProperty("toolNumber").GetInt32().ShouldBe(7);
set.RootElement.GetProperty("offsetX").GetDouble().ShouldBe(1.25);
set.RootElement.GetProperty("offsetZ").GetDouble().ShouldBe(-0.5);
set.RootElement.TryGetProperty("cycle", out _).ShouldBeTrue();
using var table = JsonDocument.Parse(results[1].Value.ShouldBeOfType<string>());
table.RootElement.GetProperty("G54").GetProperty("Y").GetDouble().ShouldBe(2.0);
table.RootElement.GetProperty("G55").GetProperty("X").GetDouble().ShouldBe(3.0);
table.RootElement.GetProperty("G56").GetProperty("Z").GetDouble().ShouldBe(0.5);
}
// ---- subscriptions: the /sample long poll ----
/// <summary>
@@ -0,0 +1,214 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// #650 — DATA_SET / TABLE observations are served as a String carrying compact, deterministic JSON.
/// Pins the parse (entries, cells, removed, resetTriggered) and the index's merge semantics
/// (/current replaces, /sample merges, UNAVAILABLE drops the accumulated set).
/// </summary>
public sealed class MTConnectDataSetTests
{
private const uint Good = 0x00000000u;
private const uint BadNoCommunication = 0x80310000u;
private static string Doc(string events, string attrs = "") =>
$"""
<MTConnectStreams>
<Header instanceId="1" nextSequence="2" firstSequence="1"/>
<Streams><DeviceStream><ComponentStream><Events>{events}</Events></ComponentStream></DeviceStream></Streams>
</MTConnectStreams>
""";
private static string DataSet(string body, string attrs = "") =>
$"""<VariableDataSet dataItemId="ds" timestamp="2026-07-24T12:00:00Z" count="0"{attrs}>{body}</VariableDataSet>""";
private static string Table(string body, string attrs = "") =>
$"""<WorkOffsetTable dataItemId="tb" timestamp="2026-07-24T12:00:00Z" count="0"{attrs}>{body}</WorkOffsetTable>""";
private static MTConnectObservationIndex NewIndex() =>
new([new MTConnectTagDefinition("ds", DriverDataType.String), new MTConnectTagDefinition("tb", DriverDataType.String)]);
private static void Apply(MTConnectObservationIndex idx, string events, bool baseline = false) =>
idx.Apply(MTConnectStreamsParser.Parse(Doc(events)), baseline);
[Fact]
public void Parser_captures_entries_cells_removed_and_reset()
{
var result = MTConnectStreamsParser.Parse(Doc(
DataSet("""<Entry key="a">1</Entry><Entry key="b" removed="true"/>""", " resetTriggered=\"MANUAL\"") +
Table("""<Entry key="G54"><Cell key="X">1.5</Cell><Cell key="Y">2</Cell></Entry>""")));
var ds = result.Observations[0];
ds.ResetTriggered.ShouldBeTrue();
ds.Entries!.Count.ShouldBe(2);
ds.Entries[0].ShouldBe(new MTConnectDataSetEntry("a", "1", false));
ds.Entries[1].Removed.ShouldBeTrue();
var tb = result.Observations[1];
tb.Entries!.ShouldHaveSingleItem().Cells!.Select(c => (c.Key, c.Value))
.ShouldBe([("X", "1.5"), ("Y", "2")]);
}
[Fact]
public void Data_set_is_served_as_sorted_compact_json_with_numbers_and_strings()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="toolNumber">7</Entry><Entry key="offsetX">1.25</Entry><Entry key="name">A &amp; "B"</Entry><Entry key="part">007</Entry>"""), baseline: true);
var snap = idx.Get("ds");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe("""{"name":"A & \"B\"","offsetX":1.25,"part":"007","toolNumber":7}""");
}
[Fact]
public void Table_is_served_as_nested_json()
{
var idx = NewIndex();
Apply(idx, Table("""<Entry key="G55"><Cell key="X">3</Cell></Entry><Entry key="G54"><Cell key="Y">2</Cell><Cell key="X">1.0</Cell></Entry>"""), baseline: true);
idx.Get("tb").Value.ShouldBe("""{"G54":{"X":1.0,"Y":2},"G55":{"X":3}}""");
}
[Fact]
public void Sample_delta_merges_and_removed_entry_disappears()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry><Entry key="b">2</Entry>"""), baseline: true);
Apply(idx, DataSet("""<Entry key="b">3</Entry><Entry key="c">x</Entry>"""));
idx.Get("ds").Value.ShouldBe("""{"a":1,"b":3,"c":"x"}""");
Apply(idx, DataSet("""<Entry key="a" removed="true"/>"""));
idx.Get("ds").Value.ShouldBe("""{"b":3,"c":"x"}""");
}
[Fact]
public void Table_row_delta_replaces_the_row_and_removed_row_disappears()
{
var idx = NewIndex();
Apply(idx, Table("""<Entry key="G54"><Cell key="X">1</Cell><Cell key="Y">2</Cell></Entry><Entry key="G55"><Cell key="X">3</Cell></Entry>"""), baseline: true);
Apply(idx, Table("""<Entry key="G54"><Cell key="X">9</Cell></Entry><Entry key="G55" removed="true"/>"""));
idx.Get("tb").Value.ShouldBe("""{"G54":{"X":9}}""");
}
[Fact]
public void Current_baseline_replaces_rather_than_merges()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry><Entry key="b">2</Entry>"""), baseline: true);
Apply(idx, DataSet("""<Entry key="c">3</Entry>"""), baseline: true);
idx.Get("ds").Value.ShouldBe("""{"c":3}""");
}
[Fact]
public void ResetTriggered_replaces_the_accumulated_set_on_a_sample()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry>"""), baseline: true);
Apply(idx, DataSet("""<Entry key="z">9</Entry>""", " resetTriggered=\"MANUAL\""));
idx.Get("ds").Value.ShouldBe("""{"z":9}""");
}
[Fact]
public void ResetTriggered_with_no_entries_is_an_empty_set_not_an_absence()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry>"""), baseline: true);
Apply(idx, DataSet("", " resetTriggered=\"MANUAL\""));
var snap = idx.Get("ds");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe("{}");
}
[Fact]
public void Unavailable_is_BadNoCommunication_and_drops_the_accumulated_set()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry>"""), baseline: true);
Apply(idx, DataSet("UNAVAILABLE"));
var snap = idx.Get("ds");
snap.StatusCode.ShouldBe(BadNoCommunication);
snap.Value.ShouldBeNull();
// A delta after the outage must not resurrect the pre-outage entry.
Apply(idx, DataSet("""<Entry key="b">2</Entry>"""));
idx.Get("ds").Value.ShouldBe("""{"b":2}""");
}
[Fact]
public void Several_observations_in_one_document_fold_in_order()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry>""") + DataSet("""<Entry key="b">2</Entry>"""), baseline: true);
idx.Get("ds").Value.ShouldBe("""{"a":1,"b":2}""");
}
[Fact]
public void Clear_forgets_the_accumulated_set()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry>"""), baseline: true);
idx.Clear();
Apply(idx, DataSet("""<Entry key="b">2</Entry>"""));
idx.Get("ds").Value.ShouldBe("""{"b":2}""");
}
[Fact]
public void Structured_content_that_is_not_entries_stays_BadNotSupported()
{
var idx = NewIndex();
Apply(idx, DataSet("<Other>1</Other>"), baseline: true);
idx.Get("ds").StatusCode.ShouldBe(0x803D0000u);
}
[Fact]
public void ResetTriggered_on_an_ordinary_sample_keeps_its_numeric_value()
{
// Regression: resetTriggered is also a standard attribute on accumulating SAMPLEs / counters. It must
// never turn one into an empty set.
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("pc", DriverDataType.Int64), new MTConnectTagDefinition("amt", DriverDataType.Float64)]);
idx.Apply(MTConnectStreamsParser.Parse(Doc(
"""<PartCount dataItemId="pc" timestamp="2026-07-24T12:00:00Z" resetTriggered="DAILY">5</PartCount>""" +
"""<Amount dataItemId="amt" timestamp="2026-07-24T12:00:00Z" resetTriggered="DAILY">2.5</Amount>""")));
idx.Get("pc").StatusCode.ShouldBe(Good);
idx.Get("pc").Value.ShouldBe(5L);
idx.Get("amt").Value.ShouldBe(2.5d);
}
[Fact]
public void An_empty_current_data_set_is_Good_empty_json_like_a_reset_one()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry>"""), baseline: true);
Apply(idx, DataSet(""), baseline: true);
var snap = idx.Get("ds");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe("{}");
}
[Fact]
public void An_all_removed_set_is_the_same_empty_json()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry>"""), baseline: true);
Apply(idx, DataSet("""<Entry key="a" removed="true"/>"""));
idx.Get("ds").Value.ShouldBe("{}");
}
}
@@ -589,11 +589,12 @@ public sealed class MTConnectObservationIndexTests
/// <summary>
/// End-to-end through the real parser: the entry list that concatenates to the single token
/// "12" is caught by the parser's structured flag and never surfaces as a Good value. This is
/// the leg that would regress silently if the parser stopped setting the flag.
/// "12" must never surface as that noise. Since #650 it is served as JSON carrying the keys
/// (full semantics in <c>MTConnectDataSetTests</c>); the structured-without-entries shape above
/// stays BadNotSupported.
/// </summary>
[Fact]
public void A_parsed_data_set_observation_never_surfaces_as_a_good_value()
public void A_parsed_data_set_observation_never_surfaces_as_concatenated_text()
{
const string xml = """
<MTConnectStreams>
@@ -612,8 +613,8 @@ public sealed class MTConnectObservationIndexTests
var snap = idx.Get("ds1");
snap.StatusCode.ShouldBe(BadNotSupported);
snap.Value.ShouldBeNull();
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe("""{"V1":1,"V2":2}""");
}
/// <summary>An ordinary text observation is not structured — the flag must not be over-applied.</summary>
@@ -377,6 +377,42 @@ public sealed class MTConnectSubscribeTests
For(seen, "dev1_pos").Last().Snapshot.Value.ShouldBe(77.5d);
}
/// <summary>
/// #650: a re-baseline <c>/current</c> states the WHOLE data set, so it must REPLACE what the deltas
/// accumulated - a key removed while the stream was down must not linger. Reverting the
/// <c>baseline: true</c> argument at the re-baseline call site leaves {a,b} merged and fails this.
/// (The init and one-shot-read call sites build a fresh index, so their flag is not observable.)
/// </summary>
[Fact]
public async Task Rebaseline_replaces_the_accumulated_data_set_rather_than_merging()
{
var (driver, client) = await InitializedDriverAsync();
static MTConnectStreamsResult SetChunk(long first, long next, params (string Key, string Value)[] entries) =>
new(FixtureInstanceId, next, first,
[
new MTConnectObservation(
"ds", "", ObservedAt, IsStructured: true, IsDataSet: true,
Entries: [.. entries.Select(e => new MTConnectDataSetEntry(e.Key, e.Value, false))]),
]);
const string Oor = "MTConnect /sample answered an MTConnectError document (OUT_OF_RANGE) under HTTP 200";
// First re-baseline seeds {a,b}; the stream then resumes from its nextSequence (500).
client.SampleFailures.Enqueue(new InvalidDataException(Oor));
client.CurrentAnswers.Enqueue(SetChunk(490, 500, ("a", "1"), ("b", "2")));
await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
await client.PumpAsync(Chunk(500, 505, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct);
driver.ObservationIndex.Get("ds").Value.ShouldBe("""{"a":1,"b":2}""");
client.SampleFailures.Enqueue(new InvalidDataException(Oor));
client.CurrentAnswers.Enqueue(SetChunk(600, 610, ("a", "1")));
await client.PumpAsync(Chunk(610, 615, ("dev1_pos", "77.5"))).WaitAsync(Watchdog, Ct);
driver.ObservationIndex.Get("ds").Value.ShouldBe("""{"a":1}""");
}
// ---- agent restart ----
/// <summary>