Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusCoalescingTests.cs
Joseph Doherty 366212417c Task #143 — Modbus block-read coalescing (with max-gap knob)
Adds a coalescing read planner that merges nearby tags into single FC03/FC04
PDUs, opt-in via ModbusDriverOptions.MaxReadGap. Default 0 = no coalescing
(every tag gets its own PDU — preserves pre-#143 wire output).

Worked example with MaxReadGap=10:
  T1 @ HR 100 (Int16, 1 reg)
  T2 @ HR 102 (Int16, 1 reg, gap 1 → joins block)
  T3 @ HR 110 (Float32, 2 regs, gap 7 → joins block)
  T4 @ HR 200 (Int16, 1 reg, gap 89 → splits, separate read)
  → 2 PDUs total: FC03 start=100 quantity=12 + FC03 start=200 quantity=1.

Planner:
- Eligible tags: known + register region (HR/IR) + scalar + not String /
  BitInRegister / array + not CoalesceProhibited.
- Groups by (UnitId, Region) — never coalesces across slaves or regions.
- Sorts by start address; merges when (next.start - last.end - 1) ≤ MaxReadGap
  AND the resulting span ≤ MaxRegistersPerRead. Otherwise opens a new block.
- Single-tag blocks are deferred to the per-tag path so WriteOnChange cache
  semantics stay correct without duplication.
- Per-block failure marks every member tag Bad and degrades health — same
  semantics the per-tag path has, but at the block granularity.

Per-tag escape hatch ModbusTagDefinition.CoalesceProhibited (bool, default
false) — when true, the tag is read in isolation regardless of MaxReadGap.
For PLCs with protected register holes between adjacent tags.

Tests (7 new ModbusCoalescingTests):
- MaxReadGap=0 keeps the per-tag behavior (2 reads for 2 tags).
- MaxReadGap=2 merges 3 tags within 5 registers into 1 read of qty=5.
- MaxReadGap=10 splits T1+T2 from T3 when the gap exceeds the threshold.
- CoalesceProhibited tag reads alone even when neighbours are eligible.
- Coalescing never crosses UnitId boundaries (multi-slave gateway safety).
- MaxRegistersPerRead caps a would-be block; planner falls back to separate
  reads when the merged span would exceed the cap.
- Per-tag values surface independently after coalescing (slice-math sanity).

Existing 220 unit tests still green; total 224 pass with the new file (tests
are additive, no regressions).

Follow-up: auto-split-on-protected-hole isn't shipped — a coalesced read
that hits an Illegal Data Address right now marks every member Bad until
the operator sets CoalesceProhibited on the offending tag. Tracked
implicitly by #138's e2e drill against a pymodbus profile with a protected
hole mid-block.
2026-04-25 00:21:18 -04:00

177 lines
8.7 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// #143 block-read coalescing: with MaxReadGap > 0 the driver merges nearby tags into a
/// single FC03/FC04 read. Coverage focuses on the planner output (PDU count + quantity)
/// rather than wire bytes — those are tested by ModbusDriverTests.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusCoalescingTests
{
private sealed class CountingTransport : IModbusTransport
{
public readonly List<(byte Unit, byte Fc, ushort Address, ushort Quantity)> Reads = new();
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
if (pdu[0] is 0x03 or 0x04) Reads.Add((unitId, pdu[0], addr, qty));
switch (pdu[0])
{
case 0x03: case 0x04:
{
var resp = new byte[2 + qty * 2];
resp[0] = pdu[0]; resp[1] = (byte)(qty * 2);
return Task.FromResult(resp);
}
default: return Task.FromResult(new byte[] { pdu[0], 0, 0 });
}
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task MaxReadGap_Zero_Defaults_To_Per_Tag_Reads()
{
var fake = new CountingTransport();
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2], MaxReadGap = 0,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T1", "T2"], CancellationToken.None);
// With coalescing off, expect 2 separate FC03 reads.
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
fc03Reads.Count.ShouldBe(2);
}
[Fact]
public async Task MaxReadGap_Bridges_Two_Adjacent_Tags_Into_One_Read()
{
var fake = new CountingTransport();
// Three tags within 5 registers: T1@100, T2@102, T3@104. Gaps: 1, 1. MaxReadGap=2 → 1 block.
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2, t3], MaxReadGap = 2,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T1", "T2", "T3"], CancellationToken.None);
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
fc03Reads.Count.ShouldBe(1);
fc03Reads[0].Address.ShouldBe((ushort)100);
fc03Reads[0].Quantity.ShouldBe((ushort)5); // 100..104
}
[Fact]
public async Task MaxReadGap_Splits_When_Gap_Exceeds_Threshold()
{
var fake = new CountingTransport();
// T1@100, T2@102 (gap 1, joins block), T3@200 (gap 97 → exceeds gap=10 → second block).
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16);
var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 200, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2, t3], MaxReadGap = 10,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T1", "T2", "T3"], CancellationToken.None);
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
fc03Reads.Count.ShouldBe(2); // T1+T2 coalesced; T3 alone
}
[Fact]
public async Task CoalesceProhibited_Tag_Reads_Alone()
{
var fake = new CountingTransport();
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16, CoalesceProhibited: true);
var t3 = new ModbusTagDefinition("T3", ModbusRegion.HoldingRegisters, 104, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2, t3], MaxReadGap = 10,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T1", "T2", "T3"], CancellationToken.None);
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
// T2 read alone (CoalesceProhibited). T1 and T3 coalesce (gap = 3 within MaxReadGap=10).
// Expect 2 reads total.
fc03Reads.Count.ShouldBe(2);
}
[Fact]
public async Task Coalescing_Does_Not_Cross_UnitId_Boundaries()
{
var fake = new CountingTransport();
// Same Region + adjacent addresses but different UnitIds → must NOT coalesce.
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16, UnitId: 1);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 102, ModbusDataType.Int16, UnitId: 2);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2], MaxReadGap = 100,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T1", "T2"], CancellationToken.None);
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
fc03Reads.Count.ShouldBe(2);
fc03Reads.Select(r => r.Unit).Distinct().Count().ShouldBe(2);
}
[Fact]
public async Task Coalescing_Splits_Block_That_Exceeds_MaxRegistersPerRead()
{
var fake = new CountingTransport();
// T1@0, T2@200 with MaxReadGap=300 would naturally form one block of 201 registers,
// but MaxRegistersPerRead=125 caps it. The planner should NOT coalesce because the
// resulting span exceeds the cap — it falls back to two separate reads.
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 200, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2], MaxReadGap = 300,
MaxRegistersPerRead = 125, Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["T1", "T2"], CancellationToken.None);
var fc03Reads = fake.Reads.Where(r => r.Fc == 0x03).ToList();
fc03Reads.Count.ShouldBe(2);
}
[Fact]
public async Task Coalesced_Read_Surfaces_Each_Tag_Value_Independently()
{
// Sanity check: after coalescing the per-tag values must still be correct (no
// index-shift bugs in the slice math).
var fake = new CountingTransport();
var t1 = new ModbusTagDefinition("T1", ModbusRegion.HoldingRegisters, 100, ModbusDataType.Int16);
var t2 = new ModbusTagDefinition("T2", ModbusRegion.HoldingRegisters, 101, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "f", Tags = [t1, t2], MaxReadGap = 5,
Probe = new ModbusProbeOptions { Enabled = false } };
var drv = new ModbusDriver(opts, "m1", _ => fake);
await drv.InitializeAsync("{}", CancellationToken.None);
var values = await drv.ReadAsync(["T1", "T2"], CancellationToken.None);
values.Count.ShouldBe(2);
values[0].StatusCode.ShouldBe(0u);
values[1].StatusCode.ShouldBe(0u);
// The fake returns zeros for our values; the assertion is on quality + that the slice
// didn't mis-index (a bug there would surface as IndexOutOfRange / wrong type).
}
}