Auto: s7-b2 — block-read coalescing for contiguous DBs

Closes #293
This commit is contained in:
Joseph Doherty
2026-04-25 21:23:06 -04:00
parent 5432c49364
commit 17faf76ea7
7 changed files with 976 additions and 11 deletions

View File

@@ -0,0 +1,131 @@
using S7NetCpuType = global::S7.Net.CpuType;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
/// <summary>
/// End-to-end verification of the block-read coalescing planner (PR-S7-B2)
/// against the python-snap7 S7-1500 simulator. The headline assertion: 50
/// contiguous DBW reads (DB1.DBW0..DB1.DBW98) coalesce into exactly ONE
/// <c>Plc.ReadBytesAsync</c> call instead of 50 single-tag round-trips —
/// a 50:1 wire-level reduction.
/// </summary>
[Collection(Snap7ServerCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7_1500")]
public sealed class S7_1500BlockCoalescingTests(Snap7ServerFixture sim)
{
[Fact]
public async Task Driver_coalesces_contiguous_DBWs_into_single_byte_range_read()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
// Build a 50-tag config covering DB1.DBW0, DBW2, DBW4, ..., DBW98.
// Every offset is exactly 2 bytes apart, so the planner sees 50
// adjacent ranges with gap = 0 and folds them into one 100-byte
// ReadBytesAsync. With the multi-var packer (PR-S7-B1) alone the
// baseline would be ⌈50/19⌉ = 3 multi-var batches; the block coalescer
// beats that by an order of magnitude.
var tags = new List<S7TagDefinition>(50);
for (var i = 0; i < 50; i++)
tags.Add(new S7TagDefinition($"BulkDBW{i:D2}", $"DB1.DBW{i * 2}", S7DataType.UInt16));
var options = new S7DriverOptions
{
Host = sim.Host,
Port = sim.Port,
CpuType = S7NetCpuType.S71500,
Rack = 0,
Slot = 0,
Timeout = TimeSpan.FromSeconds(5),
Probe = new S7ProbeOptions { Enabled = false },
Tags = tags,
};
await using var drv = new S7Driver(options, driverInstanceId: "s7-block-coalesce");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var blockReadsBefore = drv.TotalBlockReads;
var multiVarBefore = drv.TotalMultiVarBatches;
var snapshots = await drv.ReadAsync(
tags.Select(t => t.Name).ToList(),
TestContext.Current.CancellationToken);
snapshots.Count.ShouldBe(50);
snapshots.ShouldAllBe(s => s.StatusCode == 0u, "every coalesced read must surface a Good status");
// Headline assertion: exactly one byte-range PDU was issued for the
// entire 50-tag fan-in. If the merge regressed we'd see 3 multi-var
// batches (and zero block reads) or 50 single reads in the worst case.
var blockReadsDelta = drv.TotalBlockReads - blockReadsBefore;
var multiVarDelta = drv.TotalMultiVarBatches - multiVarBefore;
blockReadsDelta.ShouldBe(1L,
$"50 contiguous DBWs must coalesce into exactly 1 ReadBytesAsync; saw {blockReadsDelta} block reads and {multiVarDelta} multi-var batches");
multiVarDelta.ShouldBe(0L,
"no singletons should fall through to the multi-var packer when every tag merged");
// Every tag in DB1 was zero-initialised by the snap7 simulator except
// the offsets the seed file declares; DBW0 reads back the probe value
// 4242 and DBW10 reads back -12345 (re-interpreted as ushort 53191).
// Spot-check the probe + a couple of post-seed offsets to confirm the
// slice math is correct.
Convert.ToInt32(snapshots[0].Value).ShouldBe(4242, "DB1.DBW0 carries the seeded 4242 probe value");
Convert.ToInt32(snapshots[5].Value).ShouldBe(unchecked((ushort)(short)-12345),
"DB1.DBW10 carries the seeded -12345 (read as UInt16 wire pattern)");
}
[Fact]
public async Task Driver_skips_coalescing_when_gap_threshold_is_zero_and_layout_is_sparse()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
// Sparse layout: 3 DBWs with a 100-byte gap between each. Default
// threshold (16) keeps them apart; explicit 0 also keeps them apart;
// either way we expect 3 standalone byte-range reads, not one giant
// over-fetched range. Verifies that the planner actually honours the
// gap-merge cutoff and doesn't blindly span the whole DB.
var tags = new[]
{
new S7TagDefinition("Sparse_0", "DB1.DBW0", S7DataType.UInt16),
new S7TagDefinition("Sparse_100", "DB1.DBW100", S7DataType.UInt16),
new S7TagDefinition("Sparse_200", "DB1.DBW200", S7DataType.UInt16),
};
var options = new S7DriverOptions
{
Host = sim.Host,
Port = sim.Port,
CpuType = S7NetCpuType.S71500,
Rack = 0,
Slot = 0,
Timeout = TimeSpan.FromSeconds(5),
Probe = new S7ProbeOptions { Enabled = false },
BlockCoalescingGapBytes = 0, // strict: only adjacent ranges merge
Tags = tags,
};
await using var drv = new S7Driver(options, driverInstanceId: "s7-block-coalesce-sparse");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var blockReadsBefore = drv.TotalBlockReads;
var multiVarBefore = drv.TotalMultiVarBatches;
var snapshots = await drv.ReadAsync(
tags.Select(t => t.Name).ToList(),
TestContext.Current.CancellationToken);
snapshots.ShouldAllBe(s => s.StatusCode == 0u);
// Each tag is a singleton range — the planner emits 3 single-tag
// ranges and the driver routes them through the multi-var packer
// rather than one ReadBytesAsync per tag. Result: 0 block reads, 1
// multi-var batch covering all 3 tags.
(drv.TotalBlockReads - blockReadsBefore).ShouldBe(0L,
"singletons must not pay for a one-tag ReadBytesAsync round-trip");
(drv.TotalMultiVarBatches - multiVarBefore).ShouldBe(1L,
"3 singleton tags should pack into a single multi-var batch");
}
}

View File

@@ -0,0 +1,305 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Unit tests for the block-read coalescing planner (PR-S7-B2). Pins the
/// merge math so a regression in the gap-merge logic surfaces here instead
/// of as a flaky 50:1 wire-reduction integration test against the simulator.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7BlockCoalescingPlannerTests
{
private static S7BlockCoalescingPlanner.TagSpec Db(int caller, int dbNumber, int byteOffset, int byteCount, bool opaque = false)
=> new(caller, S7Area.DataBlock, dbNumber, byteOffset, byteCount, opaque);
private static S7BlockCoalescingPlanner.TagSpec M(int caller, int byteOffset, int byteCount)
=> new(caller, S7Area.Memory, DbNumber: 0, byteOffset, byteCount, OpaqueSize: false);
[Fact]
public void Three_contiguous_DBWs_coalesce_into_one_six_byte_range()
{
// DB1.DBW0 (2 B) + DB1.DBW2 (2 B) + DB1.DBW4 (2 B) → one 6-byte range
// covering offsets 0..6 within DB1. The headline coalescing claim.
var specs = new[]
{
Db(caller: 0, dbNumber: 1, byteOffset: 0, byteCount: 2),
Db(caller: 1, dbNumber: 1, byteOffset: 2, byteCount: 2),
Db(caller: 2, dbNumber: 1, byteOffset: 4, byteCount: 2),
};
var ranges = S7BlockCoalescingPlanner.Plan(specs);
ranges.Count.ShouldBe(1);
ranges[0].Area.ShouldBe(S7Area.DataBlock);
ranges[0].DbNumber.ShouldBe(1);
ranges[0].StartByte.ShouldBe(0);
ranges[0].ByteCount.ShouldBe(6);
ranges[0].Tags.Count.ShouldBe(3);
ranges[0].Tags.Select(t => t.CallerIndex).ShouldBe(new[] { 0, 1, 2 });
ranges[0].Tags.Select(t => t.OffsetInBlock).ShouldBe(new[] { 0, 2, 4 });
}
[Fact]
public void Far_apart_tags_do_not_merge_when_gap_exceeds_threshold()
{
// DB1.DBW0 + DB1.DBW100 → gap of 98 bytes, way above the default 16-byte
// threshold. Two standalone ranges so neither over-fetches into dead space.
var specs = new[]
{
Db(0, 1, 0, 2),
Db(1, 1, 100, 2),
};
var ranges = S7BlockCoalescingPlanner.Plan(specs);
ranges.Count.ShouldBe(2);
ranges.OrderBy(r => r.StartByte).Select(r => r.StartByte).ShouldBe(new[] { 0, 100 });
ranges.ShouldAllBe(r => r.ByteCount == 2);
}
[Fact]
public void Tags_within_default_gap_threshold_merge_into_one_range()
{
// DBW0 + DBW10 → gap = 8 bytes (DBW0 ends at 2, DBW10 starts at 10).
// 8 ≤ 16 default threshold → merge into one 12-byte range starting at 0.
var specs = new[]
{
Db(0, 1, 0, 2),
Db(1, 1, 10, 2),
};
var ranges = S7BlockCoalescingPlanner.Plan(specs);
ranges.Count.ShouldBe(1);
ranges[0].StartByte.ShouldBe(0);
ranges[0].ByteCount.ShouldBe(12);
ranges[0].Tags.Select(t => t.OffsetInBlock).ShouldBe(new[] { 0, 10 });
}
[Fact]
public void Different_areas_never_merge_even_when_offsets_align()
{
// DB1.DBW0 and MW0 share a byte offset of 0 but live in different
// address spaces — coalescing across areas is a wire-protocol error.
var specs = new S7BlockCoalescingPlanner.TagSpec[]
{
Db(0, 1, 0, 2),
M(1, 0, 2),
};
var ranges = S7BlockCoalescingPlanner.Plan(specs);
ranges.Count.ShouldBe(2);
ranges.Any(r => r.Area == S7Area.DataBlock && r.DbNumber == 1).ShouldBeTrue();
ranges.Any(r => r.Area == S7Area.Memory).ShouldBeTrue();
}
[Fact]
public void Different_DB_numbers_never_merge()
{
// DB1.DBW0 and DB2.DBW0 share area type but live in different DBs —
// S7 read requests carry the DB number, can't cover two DBs in one read.
var specs = new[]
{
Db(0, 1, 0, 2),
Db(1, 2, 0, 2),
};
var ranges = S7BlockCoalescingPlanner.Plan(specs);
ranges.Count.ShouldBe(2);
ranges.Select(r => r.DbNumber).OrderBy(n => n).ShouldBe(new[] { 1, 2 });
}
[Fact]
public void Opaque_tag_in_middle_of_run_splits_into_three_ranges()
{
// Sequence: DBW0, STRING@DB1.4 (opaque), DBW10. The opaque row emits
// its own standalone range; the planner sees the remaining mergeable
// candidates as DBW0 + DBW10 with gap 8 ≤ 16, so they merge into one
// 12-byte range. Total 2 ranges (DBW0/DBW10 merged + opaque STRING).
// Setting the test to 3 ranges deliberately — verify that the opaque
// entry never participates in or crosses the neighbour-merge path.
var specs = new[]
{
Db(0, 1, 0, 2),
Db(1, 1, 4, 256, opaque: true), // STRING-shaped, variable header
Db(2, 1, 270, 2), // far enough to not merge with DBW0
};
var ranges = S7BlockCoalescingPlanner.Plan(specs);
ranges.Count.ShouldBe(3);
ranges.Count(r => r.Tags.Count == 1).ShouldBe(3);
ranges.Single(r => r.StartByte == 4 && r.Tags[0].CallerIndex == 1).ByteCount.ShouldBe(256);
ranges.Single(r => r.StartByte == 0).Tags[0].CallerIndex.ShouldBe(0);
ranges.Single(r => r.StartByte == 270).Tags[0].CallerIndex.ShouldBe(2);
}
[Fact]
public void Opaque_tag_does_not_extend_a_neighbour_block()
{
// DBW0, DBW2, then opaque STRING at byte 4 — without the opaque opt-out
// the planner would happily fold them all into one read. The opaque
// marker must keep the STRING out of the merged range.
var specs = new[]
{
Db(0, 1, 0, 2),
Db(1, 1, 2, 2),
Db(2, 1, 4, 256, opaque: true),
};
var ranges = S7BlockCoalescingPlanner.Plan(specs);
ranges.Count.ShouldBe(2);
var merged = ranges.Single(r => r.Tags.Count == 2);
merged.ByteCount.ShouldBe(4); // DBW0 + DBW2 only — STRING is its own range
var opaque = ranges.Single(r => r.Tags.Count == 1);
opaque.StartByte.ShouldBe(4);
opaque.ByteCount.ShouldBe(256);
}
[Fact]
public void Configurable_gap_threshold_can_merge_a_wider_gap()
{
// gap = 20 bytes between DBW0 (ends @2) and DBD22 (starts @22).
// Default threshold (16) keeps them apart; threshold = 32 merges them
// into one 26-byte range. Operator-tunable knob.
var specs = new[]
{
Db(0, 1, 0, 2),
Db(1, 1, 22, 4),
};
var defaultPlan = S7BlockCoalescingPlanner.Plan(specs, gapMergeBytes: 16);
defaultPlan.Count.ShouldBe(2);
var widenedPlan = S7BlockCoalescingPlanner.Plan(specs, gapMergeBytes: 32);
widenedPlan.Count.ShouldBe(1);
widenedPlan[0].StartByte.ShouldBe(0);
widenedPlan[0].ByteCount.ShouldBe(26);
}
[Fact]
public void Zero_gap_threshold_only_merges_strictly_adjacent_ranges()
{
// DBW0 (0..2) + DBW2 (2..4) are adjacent (gap = 0); DBW6 has gap = 2.
// Threshold 0 → DBW0+DBW2 merge but DBW6 stays standalone.
var specs = new[]
{
Db(0, 1, 0, 2),
Db(1, 1, 2, 2),
Db(2, 1, 6, 2),
};
var ranges = S7BlockCoalescingPlanner.Plan(specs, gapMergeBytes: 0);
ranges.Count.ShouldBe(2);
ranges.Single(r => r.Tags.Count == 2).ByteCount.ShouldBe(4);
ranges.Single(r => r.Tags.Count == 1).StartByte.ShouldBe(6);
}
[Fact]
public void Empty_input_returns_empty_plan()
{
var ranges = S7BlockCoalescingPlanner.Plan(System.Array.Empty<S7BlockCoalescingPlanner.TagSpec>());
ranges.ShouldBeEmpty();
}
[Fact]
public void Negative_gap_threshold_is_rejected()
{
Should.Throw<ArgumentOutOfRangeException>(() =>
S7BlockCoalescingPlanner.Plan([Db(0, 1, 0, 2)], gapMergeBytes: -1));
}
[Fact]
public void Tags_with_overlapping_ranges_still_coalesce_correctly()
{
// DBD0 (0..4) + DBW2 (2..4): the second tag is entirely inside the
// first's footprint. Treat as zero-gap merge (overlap == negative gap)
// — block end stays at 4, byte count stays at 4, both tags slice from
// the same buffer.
var specs = new[]
{
Db(0, 1, 0, 4),
Db(1, 1, 2, 2),
};
var ranges = S7BlockCoalescingPlanner.Plan(specs);
ranges.Count.ShouldBe(1);
ranges[0].StartByte.ShouldBe(0);
ranges[0].ByteCount.ShouldBe(4);
ranges[0].Tags.Count.ShouldBe(2);
}
[Fact]
public void Fifty_contiguous_DBWs_coalesce_into_one_hundred_byte_range()
{
// The integration-test workload at the unit level: 50 DBW reads at
// offsets 0,2,4,...,98 must coalesce into one read covering 100 bytes.
var specs = Enumerable.Range(0, 50)
.Select(i => Db(caller: i, dbNumber: 1, byteOffset: i * 2, byteCount: 2))
.ToArray();
var ranges = S7BlockCoalescingPlanner.Plan(specs);
ranges.Count.ShouldBe(1);
ranges[0].StartByte.ShouldBe(0);
ranges[0].ByteCount.ShouldBe(100);
ranges[0].Tags.Count.ShouldBe(50);
}
// ---- IsOpaqueSize classifier ----
[Theory]
[InlineData(S7DataType.String, true)]
[InlineData(S7DataType.WString, true)]
[InlineData(S7DataType.Char, true)]
[InlineData(S7DataType.WChar, true)]
[InlineData(S7DataType.Dtl, true)]
[InlineData(S7DataType.DateAndTime, true)]
[InlineData(S7DataType.S5Time, true)]
[InlineData(S7DataType.Time, true)]
[InlineData(S7DataType.TimeOfDay, true)]
[InlineData(S7DataType.Date, true)]
[InlineData(S7DataType.Bool, false)]
[InlineData(S7DataType.Byte, false)]
[InlineData(S7DataType.Int16, false)]
[InlineData(S7DataType.UInt16, false)]
[InlineData(S7DataType.Int32, false)]
[InlineData(S7DataType.UInt32, false)]
[InlineData(S7DataType.Float32, false)]
[InlineData(S7DataType.Float64, false)]
public void IsOpaqueSize_flags_string_and_structured_timestamp_types(S7DataType type, bool expected)
{
var tag = new S7TagDefinition("t", "DB1.DBW0", type);
S7BlockCoalescingPlanner.IsOpaqueSize(tag).ShouldBe(expected);
}
[Fact]
public void IsOpaqueSize_flags_arrays_regardless_of_element_type()
{
// Even Int16 — which is otherwise mergeable as a scalar — turns opaque
// when ElementCount > 1 because the per-tag width is N × 2 bytes.
var arrayTag = new S7TagDefinition("a", "DB1.DBW0", S7DataType.Int16, ElementCount: 4);
S7BlockCoalescingPlanner.IsOpaqueSize(arrayTag).ShouldBeTrue();
var scalarTag = new S7TagDefinition("s", "DB1.DBW0", S7DataType.Int16);
S7BlockCoalescingPlanner.IsOpaqueSize(scalarTag).ShouldBeFalse();
}
[Theory]
[InlineData(S7Size.Bit, 1)]
[InlineData(S7Size.Byte, 1)]
[InlineData(S7Size.Word, 2)]
[InlineData(S7Size.DWord, 4)]
[InlineData(S7Size.LWord, 8)]
public void ScalarByteCount_returns_wire_width_per_size_suffix(S7Size size, int expected)
{
S7BlockCoalescingPlanner.ScalarByteCount(size).ShouldBe(expected);
}
}